From 22a9fdabdb581bbf40b5b4b63f8570ea654c4159 Mon Sep 17 00:00:00 2001 From: ProofOfConcept Date: Sat, 7 Mar 2026 02:05:27 -0500 Subject: [PATCH] idle: EWMA activity tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- schema/daemon.capnp | 3 + src/bin/poc-daemon/idle.rs | 151 +++++++++++++++++++++++++++++++------ src/bin/poc-daemon/main.rs | 15 +++- src/bin/poc-daemon/rpc.rs | 12 +++ 4 files changed, 157 insertions(+), 24 deletions(-) diff --git a/schema/daemon.capnp b/schema/daemon.capnp index 87fef2e..23ec212 100644 --- a/schema/daemon.capnp +++ b/schema/daemon.capnp @@ -44,6 +44,7 @@ struct Status { sinceActivity @14 :Float64; # secs since max(lastUserMsg, lastResponse) sinceUser @15 :Float64; # secs since lastUserMsg blockReason @16 :Text; # why idle timer hasn't fired + activityEwma @17 :Float64; # 0-1, EWMA of recent activity (running fraction) } interface Daemon { @@ -71,6 +72,8 @@ interface Daemon { save @17 () -> (); debug @18 () -> (json :Text); + ewma @20 (value :Float64) -> (current :Float64); + # Modules moduleCommand @15 (module :Text, command :Text, args :List(Text)) -> (result :Text); diff --git a/src/bin/poc-daemon/idle.rs b/src/bin/poc-daemon/idle.rs index 166c558..bd78f73 100644 --- a/src/bin/poc-daemon/idle.rs +++ b/src/bin/poc-daemon/idle.rs @@ -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; } diff --git a/src/bin/poc-daemon/main.rs b/src/bin/poc-daemon/main.rs index 5716c11..4e91b34 100644 --- a/src/bin/poc-daemon/main.rs +++ b/src/bin/poc-daemon/main.rs @@ -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, + }, /// Dump full internal state as JSON Debug, /// Shut down daemon @@ -202,9 +207,10 @@ async fn client_main(cmd: Command) -> Result<(), Box> { 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> { 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()?; diff --git a/src/bin/poc-daemon/rpc.rs b/src/bin/poc-daemon/rpc.rs index 08f73f8..37c70d4 100644 --- a/src/bin/poc-daemon/rpc.rs +++ b/src/bin/poc-daemon/rpc.rs @@ -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(()) }