// 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, Clone)] 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), } } } // --------------------------------------------------------------------------- // Subconscious — background agents forked from the conscious agent // --------------------------------------------------------------------------- use std::sync::Arc; use crate::agent::{Agent, oneshot::{AutoAgent, AutoStep}}; use crate::agent::context::ConversationEntry; use crate::subconscious::defs; /// Names and byte-interval triggers for the built-in subconscious agents. const AGENTS: &[(&str, u64)] = &[ ("subconscious-surface", 0), // every trigger ("subconscious-observe", 0), // every trigger ("subconscious-thalamus", 0), // every trigger ("subconscious-journal", 20_000), // every ~20KB of conversation ("subconscious-reflect", 100_000), // every ~100KB of conversation ]; /// Snapshot for the TUI — includes a handle to the forked agent /// so the detail view can read entries live. #[derive(Clone)] pub struct SubconsciousSnapshot { pub name: String, pub running: bool, pub current_phase: String, pub turn: usize, pub last_run_secs_ago: Option, /// Shared handle to the forked agent — UI locks to read entries. pub forked_agent: Option>>, /// Entry index where the fork diverged. pub fork_point: usize, } struct SubconsciousAgent { name: String, auto: AutoAgent, last_trigger_bytes: u64, last_run: Option, /// The forked agent for the current/last run. Shared with the /// spawned task so the UI can read entries live. forked_agent: Option>>, /// Entry index where the fork diverged from the conscious agent. fork_point: usize, handle: Option)>>, } impl SubconsciousAgent { fn new(name: &str) -> Option { let def = defs::get_def(name)?; let all_tools = crate::agent::tools::memory_and_journal_tools(); let tools: Vec = if def.tools.is_empty() { all_tools.to_vec() } else { all_tools.into_iter() .filter(|t| def.tools.iter().any(|w| w == t.name)) .collect() }; let steps: Vec = def.steps.iter().map(|s| AutoStep { prompt: s.prompt.clone(), phase: s.phase.clone(), }).collect(); let auto = AutoAgent::new( name.to_string(), tools, steps, def.temperature.unwrap_or(0.6), def.priority, ); Some(Self { name: name.to_string(), auto, last_trigger_bytes: 0, last_run: None, forked_agent: None, fork_point: 0, handle: None, }) } fn is_running(&self) -> bool { self.handle.as_ref().is_some_and(|h| !h.is_finished()) } fn should_trigger(&self, conversation_bytes: u64, interval: u64) -> bool { if self.is_running() { return false; } if interval == 0 { return true; } conversation_bytes.saturating_sub(self.last_trigger_bytes) >= interval } fn snapshot(&self) -> SubconsciousSnapshot { SubconsciousSnapshot { name: self.name.clone(), running: self.is_running(), current_phase: self.auto.current_phase.clone(), turn: self.auto.turn, last_run_secs_ago: self.last_run.map(|t| t.elapsed().as_secs_f64()), forked_agent: self.forked_agent.clone(), fork_point: self.fork_point, } } } /// Background agent orchestration — owns the subconscious agents /// and their shared state (walked keys, etc.). pub struct Subconscious { agents: Vec, pub walked: Vec, } impl Subconscious { pub fn new() -> Self { let agents = AGENTS.iter() .filter_map(|(name, _)| SubconsciousAgent::new(name)) .collect(); Self { agents, walked: Vec::new() } } pub fn snapshots(&self) -> Vec { self.agents.iter().map(|s| s.snapshot()).collect() } /// Collect results from finished agents, inject outputs into the /// conscious agent's context. pub async fn collect_results(&mut self, agent: &Arc>) { let finished: Vec<(usize, tokio::task::JoinHandle<(AutoAgent, Result)>)> = self.agents.iter_mut().enumerate().filter_map(|(i, sub)| { if sub.handle.as_ref().is_some_and(|h| h.is_finished()) { sub.last_run = Some(Instant::now()); Some((i, sub.handle.take().unwrap())) } else { None } }).collect(); for (idx, handle) in finished { let (auto_back, result) = handle.await.unwrap_or_else( |e| (AutoAgent::new(String::new(), vec![], vec![], 0.0, 0), Err(format!("task panicked: {}", e)))); self.agents[idx].auto = auto_back; match result { Ok(_) => { let name = self.agents[idx].name.clone(); let outputs = std::mem::take(&mut self.agents[idx].auto.outputs); if let Some(walked_str) = outputs.get("walked") { self.walked = walked_str.lines() .map(|l| l.trim().to_string()) .filter(|l| !l.is_empty()) .collect(); } if let Some(surface_str) = outputs.get("surface") { let mut ag = agent.lock().await; for key in surface_str.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) { if let Some(rendered) = crate::cli::node::render_node( &crate::store::Store::load().unwrap_or_default(), key, ) { let mut msg = crate::agent::api::types::Message::user(format!( "\n--- {} (surfaced) ---\n{}\n", key, rendered, )); msg.stamp(); ag.push_entry(ConversationEntry::Memory { key: key.to_string(), message: msg, score: None, }); } } } if let Some(reflection) = outputs.get("reflection") { if !reflection.trim().is_empty() { let mut ag = agent.lock().await; ag.push_message(crate::agent::api::types::Message::user(format!( "\n--- subconscious reflection ---\n{}\n", reflection.trim(), ))); } } if let Some(nudge) = outputs.get("thalamus") { let nudge = nudge.trim(); if !nudge.is_empty() && nudge != "ok" { let mut ag = agent.lock().await; ag.push_message(crate::agent::api::types::Message::user(format!( "\n--- thalamus ---\n{}\n", nudge, ))); } } dbglog!("[subconscious] {} completed", name); } Err(e) => dbglog!("[subconscious] agent failed: {}", e), } } } /// Trigger subconscious agents that are due to run. pub async fn trigger(&mut self, agent: &Arc>) { let (conversation_bytes, memory_keys) = { let ag = agent.lock().await; let bytes = ag.context.entries.iter() .filter(|e| !e.is_log() && !e.is_memory()) .map(|e| e.message().content_text().len() as u64) .sum::(); let keys: Vec = ag.context.entries.iter().filter_map(|e| { if let ConversationEntry::Memory { key, .. } = e { Some(key.clone()) } else { None } }).collect(); (bytes, keys) }; // Find which agents to trigger, take their AutoAgents out let mut to_run: Vec<(usize, AutoAgent)> = Vec::new(); for (i, &(_name, interval)) in AGENTS.iter().enumerate() { if i >= self.agents.len() { continue; } if !self.agents[i].should_trigger(conversation_bytes, interval) { continue; } self.agents[i].last_trigger_bytes = conversation_bytes; let auto = std::mem::replace(&mut self.agents[i].auto, AutoAgent::new(String::new(), vec![], vec![], 0.0, 0)); to_run.push((i, auto)); } if to_run.is_empty() { return; } let conscious = agent.lock().await; let walked = self.walked.clone(); for (idx, mut auto) in to_run { dbglog!("[subconscious] triggering {}", auto.name); let forked = conscious.fork(auto.tools.clone()); let fork_point = forked.context.entries.len(); let shared_forked = Arc::new(tokio::sync::Mutex::new(forked)); self.agents[idx].forked_agent = Some(shared_forked.clone()); self.agents[idx].fork_point = fork_point; let keys = memory_keys.clone(); let w = walked.clone(); self.agents[idx].handle = Some(tokio::spawn(async move { let result = auto.run_forked_shared(&shared_forked, &keys, &w).await; (auto, result) })); } } }