// idle.rs — Universal idle state machine // // Tracks user presence and response times. Manages sleep mode, // quiet mode, activity EWMA, and notification thresholds. // No Claude Code or tmux dependencies — pure state machine. // // The consciousness binary feeds events directly (key presses, // turn completions). Claude Code feeds events via the daemon RPC. use crate::thalamus::{home, now, notify}; use serde::{Deserialize, Serialize}; use std::fs; use log::info; // Defaults pub const DEFAULT_IDLE_TIMEOUT: f64 = 5.0 * 60.0; pub const DEFAULT_NOTIFY_TIMEOUT: f64 = 2.0 * 60.0; pub const DEFAULT_SESSION_ACTIVE_SECS: f64 = 15.0 * 60.0; pub 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. pub const MIN_NUDGE_INTERVAL: f64 = 15.0; /// Boost half-life in seconds (60s). const EWMA_BOOST_HALF_LIFE: f64 = 60.0; /// Steady-state target for active work. const EWMA_TARGET: f64 = 0.75; /// Persisted subset — survives restarts. #[derive(Serialize, Deserialize, Default)] struct Persisted { last_user_msg: f64, last_response: f64, #[serde(default)] sleep_until: 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 #[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). pub fn ewma_factor(elapsed: f64, half_life: f64) -> f64 { (0.5_f64).powf(elapsed / half_life) } /// Format epoch seconds as ISO timestamp. pub fn epoch_to_iso(epoch: f64) -> String { if epoch == 0.0 { return String::new(); } let secs = epoch as u64; 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 sleep_until: Option, 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, 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; 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.activity_ewma > 0.0 { self.activity_ewma = p.activity_ewma; self.ewma_updated_at = p.ewma_updated_at; } if p.session_active_secs > 0.0 { self.session_active_secs = p.session_active_secs; } self.last_user_msg = p.last_user_msg; self.last_response = p.last_response; self.in_turn = p.in_turn; self.turn_start = p.turn_start; self.last_nudge = p.last_nudge; info!("loaded idle state"); } } } pub fn save(&self) { let p = Persisted { last_user_msg: self.last_user_msg, last_response: self.last_response, sleep_until: self.sleep_until, idle_timeout: self.idle_timeout, notify_timeout: self.notify_timeout, activity_ewma: self.activity_ewma, ewma_updated_at: self.ewma_updated_at, session_active_secs: self.session_active_secs, in_turn: self.in_turn, turn_start: self.turn_start, last_nudge: self.last_nudge, 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, uptime: now() - self.start_time, ..Default::default() }; if let Ok(json) = serde_json::to_string_pretty(&p) { let _ = fs::write(state_path(), json); } } pub fn decay_ewma(&mut self) { let t = now(); let elapsed = t - self.ewma_updated_at; if elapsed > 0.0 { let factor = ewma_factor(elapsed, EWMA_DECAY_HALF_LIFE); self.activity_ewma *= factor; self.ewma_updated_at = t; } } pub fn boost_ewma(&mut self, turn_duration: f64) { self.decay_ewma(); let gap = EWMA_TARGET - self.activity_ewma; let boost = gap * (1.0 - ewma_factor(turn_duration, EWMA_BOOST_HALF_LIFE)); self.activity_ewma += boost.max(0.0); } /// Record user activity. pub fn user_activity(&mut self) { let t = now(); self.last_user_msg = t; self.fired = false; if self.notifications.activity != notify::Activity::Focused { self.notifications.set_activity(notify::Activity::Focused); } self.save(); } /// Record response activity. pub fn response_activity(&mut self) { let t = now(); if self.in_turn { self.boost_ewma(t - self.turn_start); } self.last_response = t; self.in_turn = false; self.fired = false; self.save(); } pub fn handle_afk(&mut self) { self.last_user_msg = 0.0; self.fired = false; self.save(); } pub fn handle_session_timeout(&mut self, secs: f64) { self.session_active_secs = secs; self.save(); } pub fn handle_idle_timeout(&mut self, secs: f64) { self.idle_timeout = secs; self.save(); } pub fn handle_ewma(&mut self, value: f64) -> f64 { if value >= 0.0 { self.decay_ewma(); self.activity_ewma = value; self.save(); } self.activity_ewma } pub fn handle_notify_timeout(&mut self, secs: f64) { self.notify_timeout = secs; self.save(); } pub fn handle_sleep(&mut self, until: f64) { if until == 0.0 { self.sleep_until = Some(0.0); // indefinite } else { self.sleep_until = Some(now() + until); } self.fired = false; self.notifications.set_activity(notify::Activity::Sleeping); self.save(); } pub fn handle_wake(&mut self) { self.sleep_until = None; self.fired = false; self.notifications.set_activity(notify::Activity::Idle); self.save(); } pub fn handle_quiet(&mut self, seconds: u32) { self.quiet_until = now() + seconds as f64; self.save(); } pub fn user_present(&self) -> bool { (now() - self.last_user_msg) < self.session_active_secs } 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 } } 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.user_present() { "user 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" } } /// Should we transition to idle? Call on every tick. pub fn should_go_idle(&self) -> bool { if self.sleep_until.is_some() { return false; } if now() < self.quiet_until { return false; } if self.consolidating { return false; } if self.dreaming { return false; } if self.user_present() { return false; } if self.in_turn { return false; } let reference = self.last_response.max(self.last_user_msg); if reference == 0.0 { return false; } self.since_activity() >= self.idle_timeout } pub fn debug_json(&self) -> String { let t = now(); 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": t - self.last_user_msg, "last_response": self.last_response, "last_response_ago": t - self.last_response, "since_activity": self.since_activity(), "activity_ewma": self.activity_ewma, "in_turn": self.in_turn, "user_present": self.user_present(), "fired": self.fired, "block_reason": self.block_reason(), "sleep_until": self.sleep_until, "quiet_until": self.quiet_until, "consolidating": self.consolidating, "dreaming": self.dreaming, "activity": format!("{:?}", self.notifications.activity), "pending_notifications": self.notifications.pending.len(), }).to_string() } } pub 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, }; let end_str = end_str.replace('Z', "+00:00"); 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, } }