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:
ProofOfConcept 2026-03-07 02:05:27 -05:00
parent 7ea7c78a35
commit 22a9fdabdb
4 changed files with 157 additions and 24 deletions

View file

@ -44,6 +44,7 @@ struct Status {
sinceActivity @14 :Float64; # secs since max(lastUserMsg, lastResponse) sinceActivity @14 :Float64; # secs since max(lastUserMsg, lastResponse)
sinceUser @15 :Float64; # secs since lastUserMsg sinceUser @15 :Float64; # secs since lastUserMsg
blockReason @16 :Text; # why idle timer hasn't fired blockReason @16 :Text; # why idle timer hasn't fired
activityEwma @17 :Float64; # 0-1, EWMA of recent activity (running fraction)
} }
interface Daemon { interface Daemon {
@ -71,6 +72,8 @@ interface Daemon {
save @17 () -> (); save @17 () -> ();
debug @18 () -> (json :Text); debug @18 () -> (json :Text);
ewma @20 (value :Float64) -> (current :Float64);
# Modules # Modules
moduleCommand @15 (module :Text, command :Text, args :List(Text)) moduleCommand @15 (module :Text, command :Text, args :List(Text))
-> (result :Text); -> (result :Text);

View file

@ -18,6 +18,20 @@ const DEFAULT_NOTIFY_TIMEOUT: f64 = 2.0 * 60.0;
const SESSION_ACTIVE_SECS: f64 = 15.0 * 60.0; const SESSION_ACTIVE_SECS: f64 = 15.0 * 60.0;
const DREAM_INTERVAL_HOURS: u64 = 18; 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. /// Persisted subset of daemon state — survives daemon restarts.
/// Includes both epoch floats (for computation) and ISO timestamps /// Includes both epoch floats (for computation) and ISO timestamps
/// (for human debugging via `cat daemon-state.json | jq`). /// (for human debugging via `cat daemon-state.json | jq`).
@ -33,6 +47,16 @@ struct Persisted {
idle_timeout: f64, idle_timeout: f64,
#[serde(default)] #[serde(default)]
notify_timeout: f64, 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 // Human-readable mirrors — written but not consumed on load
#[serde(default, skip_deserializing)] #[serde(default, skip_deserializing)]
last_user_msg_time: String, last_user_msg_time: String,
@ -50,6 +74,11 @@ fn state_path() -> std::path::PathBuf {
home().join(".claude/hooks/daemon-state.json") 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. /// Format epoch seconds as a human-readable ISO-ish timestamp.
fn epoch_to_iso(epoch: f64) -> String { fn epoch_to_iso(epoch: f64) -> String {
if epoch == 0.0 { if epoch == 0.0 {
@ -79,6 +108,11 @@ pub struct State {
pub fired: bool, pub fired: bool,
pub idle_timeout: f64, pub idle_timeout: f64,
pub notify_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)] #[serde(skip)]
pub running: bool, pub running: bool,
#[serde(skip)] #[serde(skip)]
@ -101,6 +135,11 @@ impl State {
fired: false, fired: false,
idle_timeout: DEFAULT_IDLE_TIMEOUT, idle_timeout: DEFAULT_IDLE_TIMEOUT,
notify_timeout: DEFAULT_NOTIFY_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, running: true,
start_time: now(), start_time: now(),
notifications: notify::NotifyState::new(), notifications: notify::NotifyState::new(),
@ -123,6 +162,15 @@ impl State {
let t = now(); let t = now();
self.last_user_msg = t; self.last_user_msg = t;
self.last_response = 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, fired: self.fired,
idle_timeout: self.idle_timeout, idle_timeout: self.idle_timeout,
notify_timeout: self.notify_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, uptime: now() - self.start_time,
}; };
if let Ok(json) = serde_json::to_string_pretty(&p) { 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 // Typed handlers for RPC
pub fn handle_user(&mut self, pane: &str) { pub fn handle_user(&mut self, pane: &str) {
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.last_user_msg = now();
self.notifications.set_activity(notify::Activity::Focused);
}
self.fired = false; self.fired = false;
if !pane.is_empty() { if !pane.is_empty() {
self.claude_pane = Some(pane.to_string()); self.claude_pane = Some(pane.to_string());
} }
self.notifications.set_activity(notify::Activity::Focused);
self.save(); 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) { 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.last_response = now();
self.fired = false; self.fired = false;
if !pane.is_empty() { if !pane.is_empty() {
self.claude_pane = Some(pane.to_string()); self.claude_pane = Some(pane.to_string());
} }
self.save(); 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. /// Check if a notification should trigger a tmux prompt.
@ -204,6 +290,16 @@ impl State {
info!("idle timeout = {secs}s"); 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) { pub fn handle_notify_timeout(&mut self, secs: f64) {
self.notify_timeout = secs; self.notify_timeout = secs;
self.save(); self.save();
@ -284,6 +380,9 @@ impl State {
"last_response_ago": since_response, "last_response_ago": since_response,
"last_response_time": epoch_to_iso(self.last_response), "last_response_time": epoch_to_iso(self.last_response),
"since_activity": self.since_activity(), "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(), "kent_present": self.kent_present(),
"claude_pane": self.claude_pane, "claude_pane": self.claude_pane,
"fired": self.fired, "fired": self.fired,
@ -356,6 +455,9 @@ impl State {
let t = now(); let t = now();
let h = home(); let h = home();
// Decay EWMA on every tick
self.decay_ewma();
// Ingest legacy notification files every tick // Ingest legacy notification files every tick
self.notifications.ingest_legacy_files(); self.notifications.ingest_legacy_files();
@ -406,21 +508,32 @@ impl State {
return Ok(()); 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); let reference = self.last_response.max(self.last_user_msg);
if reference == 0.0 { if reference == 0.0 {
return Ok(()); return Ok(());
} }
let elapsed = t - reference; let elapsed = t - reference;
if elapsed < self.idle_timeout { if elapsed < self.idle_timeout {
return Ok(()); return Ok(());
} }
if self.fired {
return Ok(());
}
// Transition to idle — lower notification thresholds // Transition to idle — lower notification thresholds
if self.notifications.activity != notify::Activity::Idle { if self.notifications.activity != notify::Activity::Idle {
self.notifications.set_activity(notify::Activity::Idle); self.notifications.set_activity(notify::Activity::Idle);
@ -428,20 +541,14 @@ impl State {
// Fire // Fire
let elapsed_min = (elapsed / 60.0) as u64; let elapsed_min = (elapsed / 60.0) as u64;
let present = self.kent_present(); let ctx = self.build_context(true);
let ctx = self.build_context(!present);
let extra = if ctx.is_empty() { let extra = if ctx.is_empty() {
String::new() String::new()
} else { } else {
format!("\n{ctx}") format!("\n{ctx}")
}; };
let msg = if present { let msg = {
format!(
"Let your feelings guide your thinking. \
What's interesting right now?{extra}"
)
} else {
let dream_hours = hours_since_last_dream(); let dream_hours = hours_since_last_dream();
if dream_hours >= DREAM_INTERVAL_HOURS { if dream_hours >= DREAM_INTERVAL_HOURS {
format!( format!(
@ -453,16 +560,14 @@ impl State {
) )
} else { } else {
format!( format!(
"You're on your own (Kent AFK {elapsed_min}m). \ "This is your time (Kent AFK {elapsed_min}m). \
Do whatever calls to you work, read, think, \ What are you drawn to?{extra}"
chat on IRC, or rest. Check ~/.claude/memory/work-state \
for where you left off. Check work-queue.md \
if you want structure.{extra}"
) )
} }
}; };
if self.send(&msg) { if self.send(&msg) {
self.last_nudge = t;
self.fired = true; self.fired = true;
} }

View file

@ -104,6 +104,11 @@ enum Command {
DreamEnd, DreamEnd,
/// Force state persistence to disk /// Force state persistence to disk
Save, 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 /// Dump full internal state as JSON
Debug, Debug,
/// Shut down daemon /// 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_since_activity()),
fmt_secs(s.get_notify_timeout()), fmt_secs(s.get_notify_timeout()),
); );
println!("kent: {} (last {})", println!("kent: {} (last {}) activity: {:.1}%",
if s.get_kent_present() { "present" } else { "away" }, if s.get_kent_present() { "present" } else { "away" },
fmt_secs(s.get_since_user()), fmt_secs(s.get_since_user()),
s.get_activity_ewma() * 100.0,
); );
let sleep = s.get_sleep_until(); 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?; daemon.save_request().send().promise.await?;
println!("state saved"); 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 => { Command::Debug => {
let reply = daemon.debug_request().send().promise.await?; let reply = daemon.debug_request().send().promise.await?;
let json = reply.get()?.get_json()?.to_str()?; let json = reply.get()?.get_json()?.to_str()?;

View file

@ -166,6 +166,17 @@ impl daemon::Server for DaemonImpl {
Promise::ok(()) 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( fn stop(
&mut self, &mut self,
_params: daemon::StopParams, _params: daemon::StopParams,
@ -211,6 +222,7 @@ impl daemon::Server for DaemonImpl {
status.set_since_activity(s.since_activity()); status.set_since_activity(s.since_activity());
status.set_since_user(crate::now() - s.last_user_msg); status.set_since_user(crate::now() - s.last_user_msg);
status.set_block_reason(s.block_reason()); status.set_block_reason(s.block_reason());
status.set_activity_ewma(s.activity_ewma);
Promise::ok(()) Promise::ok(())
} }