diff --git a/src/agent/oneshot.rs b/src/agent/oneshot.rs index 81bcc91..cc590bb 100644 --- a/src/agent/oneshot.rs +++ b/src/agent/oneshot.rs @@ -193,7 +193,7 @@ impl AutoAgent { if next_step < self.steps.len() { backend.push_node( - AstNode::user_msg(&self.steps[next_step].prompt)).await; + AstNode::system_msg(&self.steps[next_step].prompt)).await; next_step += 1; } @@ -218,8 +218,8 @@ impl AutoAgent { let text = result.text; if text.is_empty() { dbglog!("[auto] {} empty response, retrying", self.name); - backend.push_node(AstNode::user_msg( - "[system] Your previous response was empty. \ + backend.push_node(AstNode::system_msg( + "Your previous response was empty. \ Please respond with text or use a tool." )).await; continue; @@ -234,7 +234,7 @@ impl AutoAgent { } self.current_phase = self.steps[next_step].phase.clone(); backend.push_node( - AstNode::user_msg(&self.steps[next_step].prompt)).await; + AstNode::system_msg(&self.steps[next_step].prompt)).await; next_step += 1; dbglog!("[auto] {} step {}/{}", self.name, next_step, self.steps.len()); diff --git a/src/mind/mod.rs b/src/mind/mod.rs index 8a68662..010829f 100644 --- a/src/mind/mod.rs +++ b/src/mind/mod.rs @@ -113,6 +113,10 @@ pub struct MindState { pub last_turn_had_tools: bool, /// Handle to the currently running turn task. pub turn_handle: Option>, + /// Unconscious agent idle state — true when 60s timer has expired. + pub unc_idle: bool, + /// When the unconscious idle timer will fire (for UI display). + pub unc_idle_deadline: Instant, } impl Clone for MindState { @@ -129,6 +133,8 @@ impl Clone for MindState { consecutive_errors: self.consecutive_errors, last_turn_had_tools: self.last_turn_had_tools, turn_handle: None, // Not cloned — only Mind's loop uses this + unc_idle: self.unc_idle, + unc_idle_deadline: self.unc_idle_deadline, } } } @@ -164,6 +170,8 @@ impl MindState { consecutive_errors: 0, last_turn_had_tools: false, turn_handle: None, + unc_idle: false, + unc_idle_deadline: Instant::now() + std::time::Duration::from_secs(60), } } @@ -264,6 +272,9 @@ pub struct Mind { pub unconscious: Arc>, turn_tx: mpsc::Sender<(Result, StreamTarget)>, turn_watch: tokio::sync::watch::Sender, + /// Signals conscious activity to the unconscious loop. + /// true = active, false = idle opportunity. + conscious_active: tokio::sync::watch::Sender, bg_tx: mpsc::UnboundedSender, bg_rx: std::sync::Mutex>>, _supervisor: crate::thalamus::supervisor::Supervisor, @@ -291,6 +302,7 @@ impl Mind { let shared = Arc::new(std::sync::Mutex::new(MindState::new(config.app.dmn.max_turns))); let (turn_watch, _) = tokio::sync::watch::channel(false); + let (conscious_active, _) = tokio::sync::watch::channel(false); let (bg_tx, bg_rx) = mpsc::unbounded_channel(); let mut sup = crate::thalamus::supervisor::Supervisor::new(); @@ -300,10 +312,53 @@ impl Mind { let subconscious = Arc::new(tokio::sync::Mutex::new(Subconscious::new())); subconscious.lock().await.init_output_tool(subconscious.clone()); + let unconscious = Arc::new(tokio::sync::Mutex::new(Unconscious::new())); + + // Spawn the unconscious loop on its own task + if !config.no_agents { + let unc = unconscious.clone(); + let shared_for_unc = shared.clone(); + let mut unc_rx = conscious_active.subscribe(); + tokio::spawn(async move { + const IDLE_DELAY: std::time::Duration = std::time::Duration::from_secs(60); + loop { + // Wait for conscious side to go inactive + if *unc_rx.borrow() { + if unc_rx.changed().await.is_err() { break; } + continue; + } + // Conscious is inactive — wait 60s before starting + let deadline = tokio::time::Instant::now() + IDLE_DELAY; + { + let mut s = shared_for_unc.lock().unwrap(); + s.unc_idle = false; + s.unc_idle_deadline = Instant::now() + IDLE_DELAY; + } + let went_active = tokio::select! { + _ = tokio::time::sleep_until(deadline) => false, + r = unc_rx.changed() => r.is_ok(), + }; + if went_active { continue; } + + // Idle period reached — run agents until conscious goes active + { + let mut s = shared_for_unc.lock().unwrap(); + s.unc_idle = true; + } + loop { + unc.lock().await.trigger().await; + // Check if conscious became active + if *unc_rx.borrow() { break; } + // Brief yield to not starve other tasks + tokio::task::yield_now().await; + } + } + }); + } + Self { agent, shared, config, - subconscious, - unconscious: Arc::new(tokio::sync::Mutex::new(Unconscious::new())), - turn_tx, turn_watch, bg_tx, + subconscious, unconscious, + turn_tx, turn_watch, conscious_active, bg_tx, bg_rx: std::sync::Mutex::new(Some(bg_rx)), _supervisor: sup } } @@ -504,6 +559,7 @@ impl Mind { } self.shared.lock().unwrap().turn_active = true; let _ = self.turn_watch.send(true); + let _ = self.conscious_active.send(true); let agent = self.agent.clone(); let result_tx = self.turn_tx.clone(); self.shared.lock().unwrap().turn_handle = Some(tokio::spawn(async move { @@ -525,7 +581,6 @@ impl Mind { let mut bg_rx = self.bg_rx.lock().unwrap().take() .expect("Mind::run() called twice"); let mut sub_handle: Option> = None; - let mut unc_handle: Option> = None; loop { let (timeout, has_input) = { let me = self.shared.lock().unwrap(); @@ -554,6 +609,7 @@ impl Mind { } Some((result, target)) = turn_rx.recv() => { + let _ = self.conscious_active.send(false); let model_switch = { let mut s = self.shared.lock().unwrap(); s.turn_handle = None; @@ -584,12 +640,6 @@ impl Mind { s.trigger(&agent).await; })); } - if unc_handle.as_ref().map_or(true, |h| h.is_finished()) { - let unc = self.unconscious.clone(); - unc_handle = Some(tokio::spawn(async move { - unc.lock().await.trigger().await; - })); - } } // Check for pending user input → push to agent context and start turn diff --git a/src/mind/subconscious.rs b/src/mind/subconscious.rs index 7ea9ae4..6e85081 100644 --- a/src/mind/subconscious.rs +++ b/src/mind/subconscious.rs @@ -278,11 +278,11 @@ 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 + ("subconscious-surface", 0), // every new conversation content + ("subconscious-observe", 1_000), // every ~1KB of conversation + ("subconscious-thalamus", 0), // every new conversation content + ("subconscious-journal", 10_000), // every ~10KB of conversation + ("subconscious-reflect", 10_000), // every ~10KB of conversation ]; /// Snapshot for the TUI — includes a handle to the forked agent @@ -354,7 +354,9 @@ impl SubconsciousAgent { fn should_trigger(&self, conversation_bytes: u64, interval: u64) -> bool { if !self.auto.enabled || self.is_running() { return false; } - if interval == 0 { return true; } + if interval == 0 { + return conversation_bytes > self.last_trigger_bytes; + } conversation_bytes.saturating_sub(self.last_trigger_bytes) >= interval } @@ -556,7 +558,8 @@ impl Subconscious { let ctx = agent.context.lock().await; let bytes = ctx.conversation().iter() .filter(|node| !matches!(node.leaf().map(|l| l.body()), - Some(NodeBody::Log(_)) | Some(NodeBody::Memory { .. }))) + Some(NodeBody::Log(_)) | Some(NodeBody::Memory { .. }) + | Some(NodeBody::Dmn(_)))) .map(|node| node.render().len() as u64) .sum::(); let keys: Vec = ctx.conversation().iter().filter_map(|node| { diff --git a/src/mind/unconscious.rs b/src/mind/unconscious.rs index eb7f854..009e80f 100644 --- a/src/mind/unconscious.rs +++ b/src/mind/unconscious.rs @@ -2,10 +2,10 @@ // // Standalone agents that operate on the memory graph without needing // conversation context. Each agent runs in a loop: finish one run, -// wait a cooldown, start the next. Agents can be toggled on/off, -// persisted to ~/.consciousness/agent-enabled.json. +// start the next. Agents can be toggled on/off, persisted to +// ~/.consciousness/agent-enabled.json. -use std::time::{Duration, Instant}; +use std::time::Instant; use std::collections::HashMap; use futures::FutureExt; @@ -13,9 +13,6 @@ use crate::agent::oneshot::{AutoAgent, AutoStep}; use crate::agent::tools; use crate::subconscious::defs; -/// Cooldown between consecutive runs of the same agent. -const COOLDOWN: Duration = Duration::from_secs(120); - fn config_path() -> std::path::PathBuf { dirs::home_dir().unwrap_or_default() .join(".consciousness/agent-enabled.json") @@ -50,11 +47,7 @@ impl UnconsciousAgent { } fn should_run(&self) -> bool { - if !self.enabled || self.is_running() { return false; } - match self.last_run { - Some(t) => t.elapsed() >= COOLDOWN, - None => true, - } + self.enabled && !self.is_running() } } @@ -167,7 +160,7 @@ impl Unconscious { pub async fn trigger(&mut self) { // Periodic graph health refresh (also on first call) if self.last_health_check - .map(|t| t.elapsed() > Duration::from_secs(600)) + .map(|t| t.elapsed() > std::time::Duration::from_secs(600)) .unwrap_or(true) { self.refresh_health(); @@ -194,17 +187,14 @@ impl Unconscious { } let running = self.agents.iter().filter(|a| a.is_running()).count(); - if running >= self.max_concurrent { return; } - let slots = self.max_concurrent - running; - - let ready: Vec = self.agents.iter().enumerate() - .filter(|(_, a)| a.should_run()) - .map(|(i, _)| i) - .take(slots) - .collect(); - - for idx in ready { - self.spawn_agent(idx).await; + for _ in running..self.max_concurrent { + let next = self.agents.iter().enumerate() + .filter(|(_, a)| a.should_run()) + .min_by_key(|(_, a)| a.last_run); + match next { + Some((idx, _)) => self.spawn_agent(idx).await, + None => break, + } } } @@ -299,8 +289,25 @@ impl Unconscious { self.agents[idx].handle = Some(tokio::spawn(async move { let result = auto.run_shared(&agent).await; + save_agent_log(&auto.name, &agent).await; auto.steps = orig_steps; (auto, result) })); } } + +async fn save_agent_log(name: &str, agent: &std::sync::Arc) { + let dir = dirs::home_dir().unwrap_or_default() + .join(format!(".consciousness/logs/{}", name)); + if std::fs::create_dir_all(&dir).is_err() { return; } + let ts = chrono::Utc::now().format("%Y%m%d-%H%M%S"); + let path = dir.join(format!("{}.json", ts)); + let nodes: Vec = { + let ctx = agent.context.lock().await; + ctx.conversation().to_vec() + }; + if let Ok(json) = serde_json::to_string_pretty(&nodes) { + let _ = std::fs::write(&path, json); + dbglog!("[unconscious] saved log to {}", path.display()); + } +} diff --git a/src/subconscious/agents/subconscious-observe.agent b/src/subconscious/agents/subconscious-observe.agent index cb1bf6d..309c73c 100644 --- a/src/subconscious/agents/subconscious-observe.agent +++ b/src/subconscious/agents/subconscious-observe.agent @@ -30,33 +30,29 @@ Do no more than 3-5 operations. === PROMPT phase:observe === -Record what happened in the conversation. You're the librarian of the -memory system — your job is to organize knowledge so it can be found -and used later. Update existing nodes and create new nodes as needed, -adding links so you can find these memories in the future. +Extract knowledge from the conversation into the memory graph. You're +the librarian — your job is to build a conceptual map that's useful for +future work, not a diary of events. -Be factual and specific. For technical work, capture: what the bug was, -what the root cause was, what the fix was, and why. For decisions, capture -the decision and the rationale. For corrections, capture what was wrong -and what was right. These details are what future-you needs. +Think in terms of *concepts*, not episodes. "What did I learn from this"? Find +and update the relevant concepts, and consider what concepts are important to +you - what are the things you'll want to remember and come back to? What will +be useful? What did I learn about? You're building up a memory graph that is a +map of the things you know about, it should be organized in terms of people, +places, things, ideas, concepts that you've discovered and are important to +you. -Don't editorialize or draw metaphors — just record clearly. If something -was emotionally significant, note that it was and what the emotion was, -but don't build a theory around it. The journal is for reflection; observe -is for memory. +For technical work: capture the *understanding*, not the narrative. What's the +architecture? What are the invariants? What's the tricky part? For decisions: +capture the principle, not just the instance. For corrections: what's the +general lesson? -Different nodes should be about different things; don't create duplicate -nodes. Here's what you've recently written — update these instead of -creating new ones if the topic overlaps: -{{recently_written}} +Different nodes should be about different things; don't create duplicates. +Here's what you've recently written — update these instead of creating new ones +if the topic overlaps: {{recently_written}} -Before creating a new node, check what you've already walked — if -a node for this concept exists, update it instead of creating a new one. +Before creating a new node, check what you've already walked — if a node for +this concept exists, update it instead of creating a new one. -Some things worth remembering: technical insights and root causes, work -practices and why they work, decisions with rationale, corrections -("I thought X but actually Y"), relationship dynamics, things you notice -about yourself and other people. - -Focus on the recent stuff; you wake up and run frequently, so most of the -conversation should be things you've already seen before and added. +Focus on the recent conversation; you run frequently, so most of it should +already be covered. diff --git a/src/user/mod.rs b/src/user/mod.rs index 6904234..ec35423 100644 --- a/src/user/mod.rs +++ b/src/user/mod.rs @@ -109,6 +109,7 @@ struct App { context_info: Option, agent_state: Vec, unconscious_state: Vec, + mind_state: Option, graph_health: Option, /// Agent toggle requests from UI — consumed by mind loop. pub agent_toggles: Vec, @@ -137,6 +138,7 @@ impl App { context_info: None, agent_state: Vec::new(), unconscious_state: Vec::new(), + mind_state: None, graph_health: None, agent_toggles: Vec::new(), walked_count: 0, @@ -403,6 +405,7 @@ async fn run( } app.unconscious_state = unc.snapshots(); app.graph_health = unc.graph_health.clone(); + app.mind_state = Some(mind.shared.lock().unwrap().clone()); } app.walked_count = mind.subconscious_walked().await.len(); if !startup_done { diff --git a/src/user/thalamus.rs b/src/user/thalamus.rs index 38fef38..5556596 100644 --- a/src/user/thalamus.rs +++ b/src/user/thalamus.rs @@ -100,6 +100,27 @@ impl ScreenView for ThalamusScreen { } lines.push(Line::raw("")); + // Mind state + lines.push(Line::styled("── Mind ──", section)); + lines.push(Line::raw("")); + if let Some(ref ms) = app.mind_state { + lines.push(Line::raw(format!(" DMN: {} (turn {}/{})", + ms.dmn.label(), ms.dmn_turns, ms.max_dmn_turns))); + lines.push(Line::raw(format!(" Turn active: {}", ms.turn_active))); + lines.push(Line::raw(format!(" Scoring: {}", ms.scoring_in_flight))); + let unc_status = if ms.unc_idle { + "idle (agents running)".to_string() + } else { + let remaining = ms.unc_idle_deadline + .saturating_duration_since(std::time::Instant::now()); + format!("{:.0}s until idle", remaining.as_secs_f64()) + }; + lines.push(Line::raw(format!(" Unconscious: {}", unc_status))); + } else { + lines.push(Line::styled(" not initialized", dim)); + } + lines.push(Line::raw("")); + // Channel status lines.push(Line::styled("── Channels ──", section)); lines.push(Line::raw(""));