// dmn.rs — Default Mode Network // // The DMN is the outer loop that keeps the agent alive. Instead of // blocking on user input (the REPL model), the DMN continuously // decides what to do next. User input is one signal among many; // the model waiting for user input is a conscious action (calling // yield_to_user), not the default. // // This inverts the tool-chaining problem: instead of needing the // model to sustain multi-step chains (hard, model-dependent), the // DMN provides continuation externally. The model takes one step // at a time. The DMN handles "and then what?" // // Named after the brain's default mode network — the always-on // background process for autobiographical memory, future planning, // and creative insight. The biological DMN isn't the thinking itself // — it's the tonic firing that keeps the cortex warm enough to // think. Our DMN is the ARAS for the agent: it doesn't decide // what to think about, it just ensures thinking happens. use std::path::PathBuf; use std::time::{Duration, Instant}; /// DMN state machine. #[derive(Debug)] pub enum State { /// Responding to user input. Short interval — stay engaged. Engaged, /// Autonomous work in progress. Short interval — keep momentum. Working, /// Exploring memory, code, ideas. Medium interval — thinking time. Foraging, /// Idle. Long interval — periodic heartbeats check for signals. Resting { since: Instant }, /// Fully paused — no autonomous ticks. Agent only responds to /// user input. Safety valve for thought spirals. Only the user /// can exit this state (Ctrl+P or /wake). Paused, /// Persistently off — survives restarts. Like Paused but sticky. /// Toggling past this state removes the persist file. Off, } /// Context for DMN prompts — tells the model about user presence /// and recent error patterns so it can decide whether to ask or proceed. pub struct DmnContext { /// Time since the user last typed something. pub user_idle: Duration, /// Number of consecutive tool errors in the current turn sequence. pub consecutive_errors: u32, /// Whether the last turn used any tools (false = text-only response). pub last_turn_had_tools: bool, } impl DmnContext { /// Whether the user appears to be actively present (typed recently). pub fn user_present(&self) -> bool { self.user_idle < Duration::from_secs(120) } /// Whether we appear stuck (multiple errors in a row). pub fn appears_stuck(&self) -> bool { self.consecutive_errors >= 3 } } impl State { /// How long to wait before the next DMN prompt in this state. pub fn interval(&self) -> Duration { match self { State::Engaged => Duration::from_secs(5), State::Working => Duration::from_secs(3), State::Foraging => Duration::from_secs(30), State::Resting { .. } => Duration::from_secs(300), State::Paused | State::Off => Duration::from_secs(86400), // effectively never } } /// Short label for debug output. pub fn label(&self) -> &'static str { match self { State::Engaged => "engaged", State::Working => "working", State::Foraging => "foraging", State::Resting { .. } => "resting", State::Paused => "paused", State::Off => "OFF", } } /// Generate the DMN prompt for the current state, informed by /// user presence and error patterns. pub fn prompt(&self, ctx: &DmnContext) -> String { let user = &crate::config::get().user_name; let idle_info = if ctx.user_idle < Duration::from_secs(60) { format!("{} is here (active recently).", user) } else { let mins = ctx.user_idle.as_secs() / 60; format!("{} has been away for {} min.", user, mins) }; let stuck_warning = if ctx.appears_stuck() { format!( " WARNING: {} consecutive tool errors — you may be stuck. \ If {} is here, ask. If away, send a Telegram \ (bash: ~/.consciousness/telegram/send.sh \"message\") and yield.", ctx.consecutive_errors, user ) } else { String::new() }; let presence_guidance = if ctx.user_present() { format!(" {} is watching — if you're confused or unsure, ask rather than guess.", user) } else { String::new() }; match self { State::Engaged => { format!( "[dmn] Your response was delivered. No new user input yet. {} \ Continue working, explore something, or call yield_to_user to wait.{}{}", idle_info, presence_guidance, stuck_warning ) } State::Working => { let nudge = if !ctx.last_turn_had_tools { " Your last response was text-only — if you have more \ work to do, use tools. If you're done, call yield_to_user." } else { "" }; format!( "[dmn] Continuing. No user input pending. {}{}{}{}", idle_info, nudge, presence_guidance, stuck_warning ) } State::Foraging => { format!( "[dmn] Foraging time. {} Follow whatever catches your attention — \ memory files, code, ideas. Call yield_to_user when you want to rest.{}", idle_info, stuck_warning ) } State::Resting { since } => { let mins = since.elapsed().as_secs() / 60; format!( "[dmn] Heartbeat ({} min idle). {} Any signals? Anything on your mind? \ Call yield_to_user to continue resting.{}", mins, idle_info, stuck_warning ) } State::Paused | State::Off => { // Should never fire (interval is 24h), but just in case "[dmn] Paused — waiting for user input only.".to_string() } } } } const OFF_FILE: &str = ".consciousness/cache/dmn-off"; /// Path to the DMN-off persist file. fn off_path() -> PathBuf { dirs::home_dir().unwrap_or_default().join(OFF_FILE) } /// Check if DMN was persistently disabled. pub fn is_off() -> bool { off_path().exists() } /// Set or clear the persistent off state. pub fn set_off(off: bool) { let path = off_path(); if off { if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } let _ = std::fs::write(&path, ""); } else { let _ = std::fs::remove_file(&path); } } /// Decide the next state after an agent turn. /// /// The transition logic: /// - yield_to_user → always rest (model explicitly asked to pause) /// - conversation turn → rest (wait for user to respond) /// - autonomous turn with tool calls → keep working /// - autonomous turn without tools → ramp down pub fn transition( current: &State, yield_requested: bool, had_tool_calls: bool, was_conversation: bool, ) -> State { if yield_requested { return State::Resting { since: Instant::now(), }; } // Conversation turns: always rest afterward — wait for the user // to say something. Don't start autonomous work while they're // reading our response. if was_conversation { return State::Resting { since: Instant::now(), }; } match current { State::Engaged => { if had_tool_calls { State::Working } else { // Model responded without tools — don't drop straight to // Resting (5 min). Go to Working first so the DMN can // nudge it to continue with tools if it has more to do. // Gradual ramp-down: Engaged→Working→Foraging→Resting State::Working } } State::Working => { if had_tool_calls { State::Working // Keep going } else { State::Foraging // Task seems done, explore } } State::Foraging => { if had_tool_calls { State::Working // Found something to do } else { State::Resting { since: Instant::now(), } } } State::Resting { .. } => { if had_tool_calls { State::Working // Woke up and found work } else { State::Resting { since: Instant::now(), } } } // Paused/Off stay put — only the user can unpause State::Paused | State::Off => current.stay(), } } impl State { /// Return a same-kind state (needed because Resting has a field). fn stay(&self) -> State { match self { State::Paused => State::Paused, State::Off => State::Off, State::Resting { since } => State::Resting { since: *since }, other => panic!("stay() called on {:?}", other), } } }