// 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::{Ast, AstNode, NodeBody}; 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, /// Shared persistent state — accumulated across all agent runs. pub state: std::collections::BTreeMap, /// Recent store activity for this agent: (key, timestamp), newest first. pub history: Vec<(String, i64)>, } 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, state: &std::collections::BTreeMap, history: Vec<(String, i64)>) -> 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, state: state.clone(), history, } } } /// Background agent orchestration — owns the subconscious agents /// and their shared persistent state. pub struct Subconscious { agents: Vec, /// Shared state across all agents — persisted to disk. pub state: std::collections::BTreeMap, state_path: Option, } impl Subconscious { pub fn new() -> Self { let agents = AGENTS.iter() .filter_map(|(name, _)| SubconsciousAgent::new(name)) .collect(); Self { agents, state: std::collections::BTreeMap::new(), state_path: None } } /// Set the state file path and load any existing state from disk. pub fn set_state_path(&mut self, path: std::path::PathBuf) { if let Ok(data) = std::fs::read_to_string(&path) { if let Ok(saved) = serde_json::from_str::< std::collections::BTreeMap >(&data) { self.state = saved; dbglog!("[subconscious] loaded {} state keys from {}", self.state.len(), path.display()); } } self.state_path = Some(path); } fn save_state(&self) { let Some(path) = &self.state_path else { return }; if let Ok(json) = serde_json::to_string_pretty(&self.state) { let _ = std::fs::write(path, json); } } pub fn walked(&self) -> Vec { self.state.get("walked") .map(|s| s.lines().map(|l| l.trim().to_string()).filter(|l| !l.is_empty()).collect()) .unwrap_or_default() } pub fn snapshots(&self, store: Option<&crate::store::Store>) -> Vec { self.agents.iter().map(|s| { let history = store.map(|st| { let prov = format!("agent:{}", s.name); st.recent_by_provenance(&prov, 30) }).unwrap_or_default(); s.snapshot(&self.state, history) }).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(); let had_finished = !finished.is_empty(); 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); // Merge into shared persistent state for (k, v) in &outputs { self.state.insert(k.clone(), v.clone()); } // Inject all outputs into the conscious agent under one lock let has_outputs = outputs.contains_key("surface") || outputs.contains_key("reflection") || outputs.contains_key("thalamus"); if has_outputs { let mut ag = agent.lock().await; if let Some(surface_str) = outputs.get("surface") { let store = crate::store::Store::cached().await.ok(); let store_guard = match &store { Some(s) => Some(s.lock().await), None => None, }; for key in surface_str.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) { let rendered = store_guard.as_ref() .and_then(|s| crate::cli::node::render_node(s, key)); if let Some(rendered) = rendered { ag.push_node(AstNode::memory( key, format!("--- {} (surfaced) ---\n{}", key, rendered), )); } } } if let Some(reflection) = outputs.get("reflection") { if !reflection.trim().is_empty() { ag.push_node(AstNode::dmn(format!( "--- subconscious reflection ---\n{}", reflection.trim(), ))); } } if let Some(nudge) = outputs.get("thalamus") { let nudge = nudge.trim(); if !nudge.is_empty() && nudge != "ok" { ag.push_node(AstNode::dmn(format!( "--- thalamus ---\n{}", nudge, ))); } } } dbglog!("[subconscious] {} completed", name); } Err(e) => dbglog!("[subconscious] agent failed: {}", e), } } if had_finished { self.save_state(); } } /// 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.conversation().iter() .filter(|node| !matches!(node.leaf().map(|l| l.body()), Some(NodeBody::Log(_)) | Some(NodeBody::Memory { .. }))) .map(|node| node.render().len() as u64) .sum::(); let keys: Vec = ag.context.conversation().iter().filter_map(|node| { if let Some(NodeBody::Memory { key, .. }) = node.leaf().map(|l| l.body()) { 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; for (idx, mut auto) in to_run { dbglog!("[subconscious] triggering {}", auto.name); let mut forked = conscious.fork(auto.tools.clone()); forked.provenance = format!("agent:{}", auto.name); let fork_point = forked.context.conversation().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 st = self.state.clone(); self.agents[idx].handle = Some(tokio::spawn(async move { let result = auto.run_forked_shared(&shared_forked, &keys, &st).await; (auto, result) })); } } }