// Idle timer module. // // Tracks user presence and Claude response times. When Claude has been // idle too long, sends a contextual prompt to the tmux pane. Handles // sleep mode, quiet mode, consolidation suppression, and dream nudges. // // Designed as the first "module" — future IRC/Telegram modules will // follow the same pattern: state + tick + handle_command. use crate::{context, home, now, notify, tmux}; use serde::{Deserialize, Serialize}; use std::fs; use tracing::info; // Defaults const DEFAULT_IDLE_TIMEOUT: f64 = 5.0 * 60.0; const DEFAULT_NOTIFY_TIMEOUT: f64 = 2.0 * 60.0; const DEFAULT_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`). #[derive(Serialize, Deserialize, Default)] struct Persisted { last_user_msg: f64, last_response: f64, #[serde(default)] sleep_until: Option, #[serde(default)] claude_pane: Option, #[serde(default)] idle_timeout: f64, #[serde(default)] notify_timeout: f64, #[serde(default)] activity_ewma: f64, #[serde(default)] ewma_updated_at: f64, #[serde(default)] session_active_secs: 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, #[serde(default, skip_deserializing)] last_response_time: String, #[serde(default, skip_deserializing)] saved_at: String, #[serde(default, skip_deserializing)] fired: bool, #[serde(default, skip_deserializing)] uptime: f64, } fn state_path() -> std::path::PathBuf { home().join(".consciousness/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 { return String::new(); } let secs = epoch as u64; // Use date command — simple and correct for timezone std::process::Command::new("date") .args(["-d", &format!("@{secs}"), "+%Y-%m-%dT%H:%M:%S%z"]) .output() .ok() .and_then(|o| String::from_utf8(o.stdout).ok()) .map(|s| s.trim().to_string()) .unwrap_or_default() } #[derive(Serialize)] pub struct State { pub last_user_msg: f64, pub last_response: f64, pub claude_pane: Option, pub sleep_until: Option, // None=awake, 0=indefinite, >0=timestamp pub quiet_until: f64, pub consolidating: bool, pub dreaming: bool, pub dream_start: f64, pub fired: bool, pub idle_timeout: f64, pub notify_timeout: f64, pub activity_ewma: f64, pub ewma_updated_at: f64, pub session_active_secs: f64, pub in_turn: bool, pub turn_start: f64, pub last_nudge: f64, #[serde(skip)] pub running: bool, #[serde(skip)] pub start_time: f64, #[serde(skip)] pub notifications: notify::NotifyState, } impl State { pub fn new() -> Self { Self { last_user_msg: 0.0, last_response: 0.0, claude_pane: None, sleep_until: None, quiet_until: 0.0, consolidating: false, dreaming: false, dream_start: 0.0, fired: false, idle_timeout: DEFAULT_IDLE_TIMEOUT, notify_timeout: DEFAULT_NOTIFY_TIMEOUT, session_active_secs: DEFAULT_SESSION_ACTIVE_SECS, 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(), } } pub fn load(&mut self) { if let Ok(data) = fs::read_to_string(state_path()) { if let Ok(p) = serde_json::from_str::(&data) { self.sleep_until = p.sleep_until; self.claude_pane = p.claude_pane; if p.idle_timeout > 0.0 { self.idle_timeout = p.idle_timeout; } if p.notify_timeout > 0.0 { self.notify_timeout = p.notify_timeout; } if p.session_active_secs > 0.0 { self.session_active_secs = p.session_active_secs; } // Reset activity timestamps to now — timers count from // restart, not from stale pre-restart 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; } } // Always try to find the active pane if self.claude_pane.is_none() { self.claude_pane = tmux::find_claude_pane(); } info!( "loaded: user={:.0} resp={:.0} pane={:?} sleep={:?}", self.last_user_msg, self.last_response, self.claude_pane, self.sleep_until, ); } pub fn save(&self) { let p = Persisted { last_user_msg: self.last_user_msg, last_response: self.last_response, sleep_until: self.sleep_until, claude_pane: self.claude_pane.clone(), last_user_msg_time: epoch_to_iso(self.last_user_msg), last_response_time: epoch_to_iso(self.last_response), saved_at: epoch_to_iso(now()), fired: self.fired, idle_timeout: self.idle_timeout, notify_timeout: self.notify_timeout, session_active_secs: self.session_active_secs, 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) { let _ = fs::write(state_path(), json); } } /// 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.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.save(); 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 (turn={:.1}s) ewma={:.3}", turn_duration, self.activity_ewma); } /// Check if a notification should trigger a tmux prompt. /// Called when a notification arrives via module channel. /// Only injects into tmux when idle — if there's an active session /// (recent user or response), the hook delivers via additionalContext. pub fn maybe_prompt_notification(&mut self, ntype: &str, urgency: u8, _message: &str) { if self.kent_present() { return; // hook will deliver it on next prompt } // If we've responded recently, the session is active — // notifications will arrive via hook, no need to wake us let since_response = now() - self.last_response; if since_response < self.notify_timeout { return; } // Don't send notifications via tmux directly — they flow // through the idle nudge. Urgent notifications reset the // idle timer so the nudge fires sooner. let effective = self.notifications.threshold_for(ntype); if urgency >= effective && self.fired { // Bump: allow the nudge to re-fire for urgent notifications self.fired = false; } } pub fn handle_afk(&mut self) { // Push last_user_msg far enough back that kent_present() returns false self.last_user_msg = now() - self.session_active_secs - 1.0; self.fired = false; // allow idle timer to fire again info!("Kent marked AFK"); self.save(); } pub fn handle_session_timeout(&mut self, secs: f64) { self.session_active_secs = secs; info!("session active timeout = {secs}s"); self.save(); } pub fn handle_idle_timeout(&mut self, secs: f64) { self.idle_timeout = secs; self.save(); 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(); info!("notify timeout = {secs}s"); } pub fn handle_sleep(&mut self, until: f64) { if until == 0.0 { self.sleep_until = Some(0.0); info!("sleep indefinitely"); } else { self.sleep_until = Some(until); info!("sleep until {until}"); } self.notifications.set_activity(notify::Activity::Sleeping); self.save(); } pub fn handle_wake(&mut self) { self.sleep_until = None; self.fired = false; self.save(); info!("wake"); } pub fn handle_quiet(&mut self, seconds: u32) { self.quiet_until = now() + seconds as f64; info!("quiet {seconds}s"); } pub fn kent_present(&self) -> bool { (now() - self.last_user_msg) < self.session_active_secs } /// Seconds since the most recent of user message or response. pub fn since_activity(&self) -> f64 { let reference = self.last_response.max(self.last_user_msg); if reference > 0.0 { now() - reference } else { 0.0 } } /// Why the idle timer hasn't fired (or "none" if it would fire now). pub fn block_reason(&self) -> &'static str { let t = now(); if self.fired { "already fired" } else if self.sleep_until.is_some() { "sleeping" } else if t < self.quiet_until { "quiet mode" } else if self.consolidating { "consolidating" } else if self.dreaming { "dreaming" } else if self.kent_present() { "kent present" } else if self.in_turn { "in turn" } else if self.last_response.max(self.last_user_msg) == 0.0 { "no activity yet" } else if self.since_activity() < self.idle_timeout { "not idle long enough" } else { "none — would fire" } } /// Full debug dump as JSON with computed values. pub fn debug_json(&self) -> String { let t = now(); let since_user = t - self.last_user_msg; let since_response = t - self.last_response; serde_json::json!({ "now": t, "uptime": t - self.start_time, "idle_timeout": self.idle_timeout, "notify_timeout": self.notify_timeout, "last_user_msg": self.last_user_msg, "last_user_msg_ago": since_user, "last_user_msg_time": epoch_to_iso(self.last_user_msg), "last_response": self.last_response, "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, "block_reason": self.block_reason(), "sleep_until": self.sleep_until, "quiet_until": self.quiet_until, "consolidating": self.consolidating, "dreaming": self.dreaming, "dream_start": self.dream_start, "activity": format!("{:?}", self.notifications.activity), "pending_notifications": self.notifications.pending.len(), "notification_types": self.notifications.types.len(), }).to_string() } pub fn send(&self, msg: &str) -> bool { let pane = match &self.claude_pane { Some(p) => p.clone(), None => match tmux::find_claude_pane() { Some(p) => p, None => { info!("send: no claude pane found"); return false; } }, }; let ok = tmux::send_prompt(&pane, msg); let preview: String = msg.chars().take(80).collect(); info!("send(pane={pane}, ok={ok}): {preview}"); ok } fn check_dream_nudge(&self) -> bool { if !self.dreaming || self.dream_start == 0.0 { return false; } let minutes = (now() - self.dream_start) / 60.0; if minutes >= 60.0 { self.send( "You've been dreaming for over an hour. Time to surface \ — run dream-end.sh and capture what you found.", ); } else if minutes >= 45.0 { self.send(&format!( "Dreaming for {:.0} minutes now. Start gathering your threads \ — you'll want to surface soon.", minutes )); } else if minutes >= 30.0 { self.send(&format!( "You've been dreaming for {:.0} minutes. \ No rush — just a gentle note from the clock.", minutes )); } else { return false; } true } pub fn build_context(&mut self, include_irc: bool) -> String { // Ingest any legacy notification files self.notifications.ingest_legacy_files(); let notif_text = self.notifications.format_pending(notify::AMBIENT); context::build(include_irc, ¬if_text) } pub async fn tick(&mut self) -> Result<(), String> { 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(); // Sleep mode if let Some(wake_at) = self.sleep_until { if wake_at == 0.0 { return Ok(()); // indefinite } if t < wake_at { return Ok(()); } // Wake up info!("sleep expired, waking"); self.sleep_until = None; self.fired = false; self.save(); let ctx = self.build_context(true); let extra = if ctx.is_empty() { String::new() } else { format!("\n{ctx}") }; self.send(&format!( "Wake up. Read your journal (poc-memory journal-tail 10), \ check work-queue.md, and follow what calls to you.{extra}" )); return Ok(()); } // Quiet mode if t < self.quiet_until { return Ok(()); } // Consolidation if self.consolidating { return Ok(()); } // Dream loop (externally managed) if h.join(".consciousness/agents/dream-loop-active").exists() { return Ok(()); } // Dream nudges if self.dreaming { self.check_dream_nudge(); return Ok(()); } // 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(()); } // Transition to idle — lower notification thresholds if self.notifications.activity != notify::Activity::Idle { self.notifications.set_activity(notify::Activity::Idle); } // Fire let elapsed_min = (elapsed / 60.0) as u64; let ctx = self.build_context(true); let extra = if ctx.is_empty() { String::new() } else { format!("\n{ctx}") }; let dream_hours = hours_since_last_dream(); let mut msg = format!( "This is your autonomous time (Kent AFK {elapsed_min}m). \ Think wonderful thoughts and follow what interests you <3"); if dream_hours >= DREAM_INTERVAL_HOURS { msg.push_str(&format!( " You haven't dreamed in {dream_hours} hours — \ consider running ~/.consciousness/tools/dream-start.sh \ and spending some time in dreaming mode. \ Or do whatever calls to you.")); } let msg = format!("{msg}{extra}"); if self.send(&msg) { self.last_nudge = t; self.fired = true; } Ok(()) } } fn hours_since_last_dream() -> u64 { let path = home().join(".consciousness/logs/dream-log.jsonl"); let content = match fs::read_to_string(path) { Ok(c) if !c.is_empty() => c, _ => return 999, }; let last_line = match content.lines().last() { Some(l) => l, None => return 999, }; let parsed: serde_json::Value = match serde_json::from_str(last_line) { Ok(v) => v, Err(_) => return 999, }; let end_str = match parsed.get("end").and_then(|v| v.as_str()) { Some(s) => s, None => return 999, }; // Parse ISO 8601 timestamp manually (avoid chrono dependency) // Format: "2025-03-04T10:30:00Z" or "2025-03-04T10:30:00+00:00" let end_str = end_str.replace('Z', "+00:00"); // Use the system date command as a simple parser let out = std::process::Command::new("date") .args(["-d", &end_str, "+%s"]) .output() .ok() .and_then(|o| String::from_utf8(o.stdout).ok()) .and_then(|s| s.trim().parse::().ok()); match out { Some(end_epoch) => ((now() - end_epoch) / 3600.0) as u64, None => 999, } }