diff --git a/src/claude/idle.rs b/src/claude/idle.rs index 7149f9f..e30bdc3 100644 --- a/src/claude/idle.rs +++ b/src/claude/idle.rs @@ -1,434 +1,80 @@ -// Idle timer module. +// idle.rs — Claude Code idle timer // -// 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. +// Wraps the universal thalamus idle state machine with Claude-specific +// functionality: tmux pane tracking, prompt injection, dream nudges, +// and context building for autonomous nudges. use super::{context, tmux}; -use crate::thalamus::{home, now, notify}; -use serde::{Deserialize, Serialize}; -use std::fs; +use crate::thalamus::{home, now, notify, idle as thalamus_idle}; 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)] +/// Claude Code idle state — wraps the universal state machine. pub struct State { - pub last_user_msg: f64, - pub last_response: f64, + pub inner: thalamus_idle::State, 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 std::ops::Deref for State { + type Target = thalamus_idle::State; + fn deref(&self) -> &Self::Target { &self.inner } +} + +impl std::ops::DerefMut for State { + fn deref_mut(&mut self) -> &mut Self::Target { &mut self.inner } } impl State { pub fn new() -> Self { Self { - last_user_msg: 0.0, - last_response: 0.0, + inner: thalamus_idle::State::new(), 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; + self.inner.load(); + // Also load claude_pane from persisted state + let path = home().join(".consciousness/daemon-state.json"); + if let Ok(data) = std::fs::read_to_string(&path) { + if let Ok(v) = serde_json::from_str::(&data) { + if let Some(p) = v.get("claude_pane").and_then(|v| v.as_str()) { + self.claude_pane = Some(p.to_string()); } - 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); - } + self.inner.save(); } - /// 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 + /// Record user activity with pane tracking. pub fn handle_user(&mut self, pane: &str) { - self.decay_ewma(); - self.in_turn = true; - self.turn_start = now(); - let from_user = !self.fired; - if from_user { - 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={}, from_user={from_user}) ewma={:.3}", - if pane.is_empty() { "unchanged" } else { pane }, - self.activity_ewma); + self.claude_pane = Some(pane.to_string()); + self.inner.user_activity(); } + /// Record response activity with pane tracking. 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); + self.claude_pane = Some(pane.to_string()); + self.inner.response_activity(); } - /// 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. + /// Maybe send a notification as a tmux prompt. pub fn maybe_prompt_notification(&mut self, ntype: &str, urgency: u8, _message: &str) { - if self.user_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; + let threshold = self.inner.notifications.threshold_for(ntype); + if urgency >= threshold { + let deliverable = self.inner.notifications.drain_deliverable(); + if !deliverable.is_empty() { + let msgs: Vec = deliverable.iter() + .map(|n| format!("[{}] {}", n.ntype, n.message)) + .collect(); + self.send(&msgs.join("\n")); + } } } - pub fn handle_afk(&mut self) { - // Push last_user_msg far enough back that user_present() returns false - self.last_user_msg = now() - self.session_active_secs - 1.0; - self.fired = false; // allow idle timer to fire again - info!("User 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 user_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.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" - } - } - - /// 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, - "user_present": self.user_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() - } - + /// Send text to the Claude tmux pane. pub fn send(&self, msg: &str) -> bool { let pane = match &self.claude_pane { Some(p) => p.clone(), @@ -440,7 +86,6 @@ impl State { } }, }; - let ok = tmux::send_prompt(&pane, msg); let preview: String = msg.chars().take(80).collect(); info!("send(pane={pane}, ok={ok}): {preview}"); @@ -448,10 +93,10 @@ impl State { } fn check_dream_nudge(&self) -> bool { - if !self.dreaming || self.dream_start == 0.0 { + if !self.inner.dreaming || self.inner.dream_start == 0.0 { return false; } - let minutes = (now() - self.dream_start) / 60.0; + let minutes = (now() - self.inner.dream_start) / 60.0; if minutes >= 60.0 { self.send( "You've been dreaming for over an hour. Time to surface \ @@ -460,14 +105,12 @@ impl State { } else if minutes >= 45.0 { self.send(&format!( "Dreaming for {:.0} minutes now. Start gathering your threads \ - — you'll want to surface soon.", - minutes + — 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 + No rush — just a gentle note from the clock.", minutes )); } else { return false; @@ -476,9 +119,8 @@ impl State { } 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); + self.inner.notifications.ingest_legacy_files(); + let notif_text = self.inner.notifications.format_pending(notify::AMBIENT); context::build(include_irc, ¬if_text) } @@ -486,31 +128,28 @@ impl State { let t = now(); let h = home(); - // Decay EWMA on every tick - self.decay_ewma(); + self.inner.decay_ewma(); + self.inner.notifications.ingest_legacy_files(); - // Ingest legacy notification files every tick - self.notifications.ingest_legacy_files(); + // Find pane if we don't have one + if self.claude_pane.is_none() { + self.claude_pane = tmux::find_claude_pane(); + } // Sleep mode - if let Some(wake_at) = self.sleep_until { + if let Some(wake_at) = self.inner.sleep_until { if wake_at == 0.0 { - return Ok(()); // indefinite + return Ok(()); } if t < wake_at { return Ok(()); } - // Wake up info!("sleep expired, waking"); - self.sleep_until = None; - self.fired = false; - self.save(); + self.inner.sleep_until = None; + self.inner.fired = false; + self.inner.save(); let ctx = self.build_context(true); - let extra = if ctx.is_empty() { - String::new() - } else { - format!("\n{ctx}") - }; + 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}" @@ -518,72 +157,40 @@ impl State { 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 { + // Quiet / consolidation / dream loop guards + if t < self.inner.quiet_until { return Ok(()); } + if self.inner.consolidating { return Ok(()); } + if h.join(".consciousness/agents/dream-loop-active").exists() { return Ok(()); } + if self.inner.dreaming { self.check_dream_nudge(); return Ok(()); } + if self.inner.user_present() { return Ok(()); } + if self.inner.in_turn { return Ok(()); } - // Don't nudge while User is here — conversation drives activity - if self.user_present() { - return Ok(()); + // Min nudge interval + let since_nudge = t - self.inner.last_nudge; + if since_nudge < thalamus_idle::MIN_NUDGE_INTERVAL { return Ok(()); } + + // Idle timeout check + if !self.inner.should_go_idle() { return Ok(()); } + + // Transition to idle + if self.inner.notifications.activity != notify::Activity::Idle { + self.inner.notifications.set_activity(notify::Activity::Idle); } - // 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 + // Fire nudge + let elapsed = self.inner.since_activity(); 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 extra = if ctx.is_empty() { String::new() } else { format!("\n{ctx}") }; - let dream_hours = hours_since_last_dream(); + let dream_hours = thalamus_idle::hours_since_last_dream(); let mut msg = format!( "This is your autonomous time (User AFK {elapsed_min}m). \ Keep doing what you're doing, or find something new to do"); - if dream_hours >= DREAM_INTERVAL_HOURS { + if dream_hours >= thalamus_idle::DREAM_INTERVAL_HOURS { msg.push_str(&format!( " You haven't dreamed in {dream_hours} hours — \ consider running ~/.consciousness/tools/dream-start.sh \ @@ -593,50 +200,33 @@ impl State { let msg = format!("{msg}{extra}"); if self.send(&msg) { - self.last_nudge = t; - self.fired = true; + self.inner.last_nudge = t; + self.inner.fired = true; } Ok(()) } -} + // Delegate common methods to inner + pub fn handle_afk(&mut self) { self.inner.handle_afk(); } + pub fn handle_session_timeout(&mut self, s: f64) { self.inner.handle_session_timeout(s); } + pub fn handle_idle_timeout(&mut self, s: f64) { self.inner.handle_idle_timeout(s); } + pub fn handle_ewma(&mut self, v: f64) -> f64 { self.inner.handle_ewma(v) } + pub fn handle_notify_timeout(&mut self, s: f64) { self.inner.handle_notify_timeout(s); } + pub fn handle_sleep(&mut self, until: f64) { self.inner.handle_sleep(until); } + pub fn handle_wake(&mut self) { self.inner.handle_wake(); } + pub fn handle_quiet(&mut self, seconds: u32) { self.inner.handle_quiet(seconds); } + pub fn user_present(&self) -> bool { self.inner.user_present() } + pub fn since_activity(&self) -> f64 { self.inner.since_activity() } + pub fn block_reason(&self) -> &'static str { self.inner.block_reason() } -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, + pub fn debug_json(&self) -> String { + // Add claude_pane to inner's json + let mut v: serde_json::Value = serde_json::from_str(&self.inner.debug_json()) + .unwrap_or_default(); + if let Some(obj) = v.as_object_mut() { + obj.insert("claude_pane".into(), serde_json::json!(self.claude_pane)); + } + v.to_string() } } diff --git a/src/thalamus/idle.rs b/src/thalamus/idle.rs new file mode 100644 index 0000000..32fba8d --- /dev/null +++ b/src/thalamus/idle.rs @@ -0,0 +1,392 @@ +// 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 tracing::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, + } +} diff --git a/src/thalamus/mod.rs b/src/thalamus/mod.rs index e2ece15..13c7e2c 100644 --- a/src/thalamus/mod.rs +++ b/src/thalamus/mod.rs @@ -6,6 +6,7 @@ // binary. pub mod channels; +pub mod idle; pub mod supervisor; pub mod notify;