idle: EWMA activity tracking
Track activity level as an EWMA (exponentially weighted moving average) driven by turn duration. Long turns (engaged work) produce large boosts; short turns (bored responses) barely register. Asymmetric time constants: 60s boost half-life for fast wake-up, 5-minute decay half-life for gradual wind-down. Self-limiting boost formula converges toward 0.75 target — can't overshoot. - Add activity_ewma, turn_start, last_nudge to persisted state - Boost on handle_response proportional to turn duration - Decay on every tick and state transition - Fix kent_present: self-nudge responses (fired=true) don't update last_user_msg, so kent_present stays false during autonomous mode - Nudge only when Kent is away, minimum 15s between nudges - CLI: `poc-daemon ewma [VALUE]` to query or set - Status output shows activity percentage
This commit is contained in:
parent
7ea7c78a35
commit
22a9fdabdb
4 changed files with 157 additions and 24 deletions
|
|
@ -18,6 +18,20 @@ const DEFAULT_NOTIFY_TIMEOUT: f64 = 2.0 * 60.0;
|
|||
const SESSION_ACTIVE_SECS: f64 = 15.0 * 60.0;
|
||||
const DREAM_INTERVAL_HOURS: u64 = 18;
|
||||
|
||||
/// EWMA decay half-life in seconds (5 minutes).
|
||||
const EWMA_DECAY_HALF_LIFE: f64 = 5.0 * 60.0;
|
||||
|
||||
/// Minimum seconds between autonomous nudges.
|
||||
const MIN_NUDGE_INTERVAL: f64 = 15.0;
|
||||
|
||||
/// Boost half-life in seconds (60s). A 60s turn covers half the gap to
|
||||
/// target; a 15s turn covers ~16%; a 2s turn covers ~2%.
|
||||
const EWMA_BOOST_HALF_LIFE: f64 = 60.0;
|
||||
|
||||
/// Steady-state target for active work. The EWMA converges toward this
|
||||
/// during sustained activity rather than toward 1.0.
|
||||
const EWMA_TARGET: f64 = 0.75;
|
||||
|
||||
/// Persisted subset of daemon state — survives daemon restarts.
|
||||
/// Includes both epoch floats (for computation) and ISO timestamps
|
||||
/// (for human debugging via `cat daemon-state.json | jq`).
|
||||
|
|
@ -33,6 +47,16 @@ struct Persisted {
|
|||
idle_timeout: f64,
|
||||
#[serde(default)]
|
||||
notify_timeout: f64,
|
||||
#[serde(default)]
|
||||
activity_ewma: f64,
|
||||
#[serde(default)]
|
||||
ewma_updated_at: f64,
|
||||
#[serde(default)]
|
||||
in_turn: bool,
|
||||
#[serde(default)]
|
||||
turn_start: f64,
|
||||
#[serde(default)]
|
||||
last_nudge: f64,
|
||||
// Human-readable mirrors — written but not consumed on load
|
||||
#[serde(default, skip_deserializing)]
|
||||
last_user_msg_time: String,
|
||||
|
|
@ -50,6 +74,11 @@ fn state_path() -> std::path::PathBuf {
|
|||
home().join(".claude/hooks/daemon-state.json")
|
||||
}
|
||||
|
||||
/// Compute EWMA decay factor: 0.5^(elapsed / half_life).
|
||||
fn ewma_factor(elapsed: f64, half_life: f64) -> f64 {
|
||||
(0.5_f64).powf(elapsed / half_life)
|
||||
}
|
||||
|
||||
/// Format epoch seconds as a human-readable ISO-ish timestamp.
|
||||
fn epoch_to_iso(epoch: f64) -> String {
|
||||
if epoch == 0.0 {
|
||||
|
|
@ -79,6 +108,11 @@ pub struct State {
|
|||
pub fired: bool,
|
||||
pub idle_timeout: f64,
|
||||
pub notify_timeout: f64,
|
||||
pub activity_ewma: f64,
|
||||
pub ewma_updated_at: f64,
|
||||
pub in_turn: bool,
|
||||
pub turn_start: f64,
|
||||
pub last_nudge: f64,
|
||||
#[serde(skip)]
|
||||
pub running: bool,
|
||||
#[serde(skip)]
|
||||
|
|
@ -101,6 +135,11 @@ impl State {
|
|||
fired: false,
|
||||
idle_timeout: DEFAULT_IDLE_TIMEOUT,
|
||||
notify_timeout: DEFAULT_NOTIFY_TIMEOUT,
|
||||
activity_ewma: 0.0,
|
||||
ewma_updated_at: now(),
|
||||
in_turn: false,
|
||||
turn_start: 0.0,
|
||||
last_nudge: 0.0,
|
||||
running: true,
|
||||
start_time: now(),
|
||||
notifications: notify::NotifyState::new(),
|
||||
|
|
@ -123,6 +162,15 @@ impl State {
|
|||
let t = now();
|
||||
self.last_user_msg = t;
|
||||
self.last_response = t;
|
||||
// Restore EWMA state, applying decay for time spent shut down
|
||||
if p.ewma_updated_at > 0.0 {
|
||||
let elapsed = t - p.ewma_updated_at;
|
||||
self.activity_ewma = p.activity_ewma * ewma_factor(elapsed, EWMA_DECAY_HALF_LIFE);
|
||||
self.in_turn = p.in_turn;
|
||||
self.turn_start = p.turn_start;
|
||||
self.last_nudge = p.last_nudge;
|
||||
}
|
||||
self.ewma_updated_at = t;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -149,6 +197,11 @@ impl State {
|
|||
fired: self.fired,
|
||||
idle_timeout: self.idle_timeout,
|
||||
notify_timeout: self.notify_timeout,
|
||||
activity_ewma: self.activity_ewma,
|
||||
ewma_updated_at: self.ewma_updated_at,
|
||||
in_turn: self.in_turn,
|
||||
turn_start: self.turn_start,
|
||||
last_nudge: self.last_nudge,
|
||||
uptime: now() - self.start_time,
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string_pretty(&p) {
|
||||
|
|
@ -156,26 +209,59 @@ impl State {
|
|||
}
|
||||
}
|
||||
|
||||
/// Decay the activity EWMA toward zero based on elapsed time.
|
||||
fn decay_ewma(&mut self) {
|
||||
let t = now();
|
||||
let elapsed = t - self.ewma_updated_at;
|
||||
if elapsed <= 0.0 {
|
||||
return;
|
||||
}
|
||||
self.activity_ewma *= ewma_factor(elapsed, EWMA_DECAY_HALF_LIFE);
|
||||
self.ewma_updated_at = t;
|
||||
}
|
||||
|
||||
/// Boost the EWMA based on turn duration. The boost is proportional to
|
||||
/// distance from EWMA_TARGET, scaled by a saturation curve on duration.
|
||||
/// A 15s turn covers half the gap to target; a 2s turn barely registers.
|
||||
/// Self-limiting: converges toward target, can't overshoot.
|
||||
fn boost_ewma(&mut self, turn_duration: f64) {
|
||||
let gap = (EWMA_TARGET - self.activity_ewma).max(0.0);
|
||||
let saturation = 1.0 - ewma_factor(turn_duration, EWMA_BOOST_HALF_LIFE);
|
||||
self.activity_ewma += gap * saturation;
|
||||
}
|
||||
|
||||
// Typed handlers for RPC
|
||||
pub fn handle_user(&mut self, pane: &str) {
|
||||
self.last_user_msg = now();
|
||||
self.decay_ewma();
|
||||
self.in_turn = true;
|
||||
self.turn_start = now();
|
||||
let from_kent = !self.fired;
|
||||
if from_kent {
|
||||
self.last_user_msg = now();
|
||||
self.notifications.set_activity(notify::Activity::Focused);
|
||||
}
|
||||
self.fired = false;
|
||||
if !pane.is_empty() {
|
||||
self.claude_pane = Some(pane.to_string());
|
||||
}
|
||||
self.notifications.set_activity(notify::Activity::Focused);
|
||||
self.save();
|
||||
info!("user (pane={})", if pane.is_empty() { "unchanged" } else { pane });
|
||||
info!("user (pane={}, kent={from_kent}) ewma={:.3}",
|
||||
if pane.is_empty() { "unchanged" } else { pane },
|
||||
self.activity_ewma);
|
||||
}
|
||||
|
||||
pub fn handle_response(&mut self, pane: &str) {
|
||||
let turn_duration = now() - self.turn_start;
|
||||
self.decay_ewma();
|
||||
self.boost_ewma(turn_duration);
|
||||
self.in_turn = false;
|
||||
self.last_response = now();
|
||||
self.fired = false;
|
||||
if !pane.is_empty() {
|
||||
self.claude_pane = Some(pane.to_string());
|
||||
}
|
||||
self.save();
|
||||
info!("response");
|
||||
info!("response (turn={:.1}s) ewma={:.3}", turn_duration, self.activity_ewma);
|
||||
}
|
||||
|
||||
/// Check if a notification should trigger a tmux prompt.
|
||||
|
|
@ -204,6 +290,16 @@ impl State {
|
|||
info!("idle timeout = {secs}s");
|
||||
}
|
||||
|
||||
pub fn handle_ewma(&mut self, value: f64) -> f64 {
|
||||
if value >= 0.0 {
|
||||
self.activity_ewma = value.min(1.0);
|
||||
self.ewma_updated_at = now();
|
||||
self.save();
|
||||
info!("ewma set to {:.3}", self.activity_ewma);
|
||||
}
|
||||
self.activity_ewma
|
||||
}
|
||||
|
||||
pub fn handle_notify_timeout(&mut self, secs: f64) {
|
||||
self.notify_timeout = secs;
|
||||
self.save();
|
||||
|
|
@ -284,6 +380,9 @@ impl State {
|
|||
"last_response_ago": since_response,
|
||||
"last_response_time": epoch_to_iso(self.last_response),
|
||||
"since_activity": self.since_activity(),
|
||||
"activity_ewma": self.activity_ewma,
|
||||
"in_turn": self.in_turn,
|
||||
"turn_start": self.turn_start,
|
||||
"kent_present": self.kent_present(),
|
||||
"claude_pane": self.claude_pane,
|
||||
"fired": self.fired,
|
||||
|
|
@ -356,6 +455,9 @@ impl State {
|
|||
let t = now();
|
||||
let h = home();
|
||||
|
||||
// Decay EWMA on every tick
|
||||
self.decay_ewma();
|
||||
|
||||
// Ingest legacy notification files every tick
|
||||
self.notifications.ingest_legacy_files();
|
||||
|
||||
|
|
@ -406,21 +508,32 @@ impl State {
|
|||
return Ok(());
|
||||
}
|
||||
|
||||
// Main idle check
|
||||
// Don't nudge while Kent is here — conversation drives activity
|
||||
if self.kent_present() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Don't nudge while in a turn
|
||||
if self.in_turn {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Minimum interval between nudges
|
||||
let since_nudge = t - self.last_nudge;
|
||||
if since_nudge < MIN_NUDGE_INTERVAL {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Initial idle timeout — don't start nudging until first idle period
|
||||
let reference = self.last_response.max(self.last_user_msg);
|
||||
if reference == 0.0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let elapsed = t - reference;
|
||||
if elapsed < self.idle_timeout {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if self.fired {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Transition to idle — lower notification thresholds
|
||||
if self.notifications.activity != notify::Activity::Idle {
|
||||
self.notifications.set_activity(notify::Activity::Idle);
|
||||
|
|
@ -428,20 +541,14 @@ impl State {
|
|||
|
||||
// Fire
|
||||
let elapsed_min = (elapsed / 60.0) as u64;
|
||||
let present = self.kent_present();
|
||||
let ctx = self.build_context(!present);
|
||||
let ctx = self.build_context(true);
|
||||
let extra = if ctx.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("\n{ctx}")
|
||||
};
|
||||
|
||||
let msg = if present {
|
||||
format!(
|
||||
"Let your feelings guide your thinking. \
|
||||
What's interesting right now?{extra}"
|
||||
)
|
||||
} else {
|
||||
let msg = {
|
||||
let dream_hours = hours_since_last_dream();
|
||||
if dream_hours >= DREAM_INTERVAL_HOURS {
|
||||
format!(
|
||||
|
|
@ -453,16 +560,14 @@ impl State {
|
|||
)
|
||||
} else {
|
||||
format!(
|
||||
"You're on your own (Kent AFK {elapsed_min}m). \
|
||||
Do whatever calls to you — work, read, think, \
|
||||
chat on IRC, or rest. Check ~/.claude/memory/work-state \
|
||||
for where you left off. Check work-queue.md \
|
||||
if you want structure.{extra}"
|
||||
"This is your time (Kent AFK {elapsed_min}m). \
|
||||
What are you drawn to?{extra}"
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
if self.send(&msg) {
|
||||
self.last_nudge = t;
|
||||
self.fired = true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -104,6 +104,11 @@ enum Command {
|
|||
DreamEnd,
|
||||
/// Force state persistence to disk
|
||||
Save,
|
||||
/// Get or set the activity EWMA (0.0-1.0). No value = query.
|
||||
Ewma {
|
||||
/// Value to set (omit to query)
|
||||
value: Option<f64>,
|
||||
},
|
||||
/// Dump full internal state as JSON
|
||||
Debug,
|
||||
/// Shut down daemon
|
||||
|
|
@ -202,9 +207,10 @@ async fn client_main(cmd: Command) -> Result<(), Box<dyn std::error::Error>> {
|
|||
fmt_secs(s.get_since_activity()),
|
||||
fmt_secs(s.get_notify_timeout()),
|
||||
);
|
||||
println!("kent: {} (last {})",
|
||||
println!("kent: {} (last {}) activity: {:.1}%",
|
||||
if s.get_kent_present() { "present" } else { "away" },
|
||||
fmt_secs(s.get_since_user()),
|
||||
s.get_activity_ewma() * 100.0,
|
||||
);
|
||||
|
||||
let sleep = s.get_sleep_until();
|
||||
|
|
@ -271,6 +277,13 @@ async fn client_main(cmd: Command) -> Result<(), Box<dyn std::error::Error>> {
|
|||
daemon.save_request().send().promise.await?;
|
||||
println!("state saved");
|
||||
}
|
||||
Command::Ewma { value } => {
|
||||
let mut req = daemon.ewma_request();
|
||||
req.get().set_value(value.unwrap_or(-1.0));
|
||||
let reply = req.send().promise.await?;
|
||||
let current = reply.get()?.get_current();
|
||||
println!("{:.1}%", current * 100.0);
|
||||
}
|
||||
Command::Debug => {
|
||||
let reply = daemon.debug_request().send().promise.await?;
|
||||
let json = reply.get()?.get_json()?.to_str()?;
|
||||
|
|
|
|||
|
|
@ -166,6 +166,17 @@ impl daemon::Server for DaemonImpl {
|
|||
Promise::ok(())
|
||||
}
|
||||
|
||||
fn ewma(
|
||||
&mut self,
|
||||
params: daemon::EwmaParams,
|
||||
mut results: daemon::EwmaResults,
|
||||
) -> Promise<(), capnp::Error> {
|
||||
let value = pry!(params.get()).get_value();
|
||||
let current = self.state.borrow_mut().handle_ewma(value);
|
||||
results.get().set_current(current);
|
||||
Promise::ok(())
|
||||
}
|
||||
|
||||
fn stop(
|
||||
&mut self,
|
||||
_params: daemon::StopParams,
|
||||
|
|
@ -211,6 +222,7 @@ impl daemon::Server for DaemonImpl {
|
|||
status.set_since_activity(s.since_activity());
|
||||
status.set_since_user(crate::now() - s.last_user_msg);
|
||||
status.set_block_reason(s.block_reason());
|
||||
status.set_activity_ewma(s.activity_ewma);
|
||||
|
||||
Promise::ok(())
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue