diff --git a/src/mind/mod.rs b/src/mind/mod.rs index 4c9871f..5e3773c 100644 --- a/src/mind/mod.rs +++ b/src/mind/mod.rs @@ -23,8 +23,55 @@ use std::time::Instant; use tokio::sync::mpsc; use crate::agent::{Agent, TurnResult}; use crate::agent::api::ApiClient; +use crate::agent::oneshot::{AutoAgent, AutoStep}; use crate::config::{AppConfig, SessionConfig}; -use crate::subconscious::learn; +use crate::subconscious::{defs, learn}; + +// --------------------------------------------------------------------------- +// Subconscious agents — forked from conscious agent, run on schedule +// --------------------------------------------------------------------------- + +/// A subconscious agent managed by Mind. +struct SubconsciousAgent { + name: String, + def: defs::AgentDef, + /// Conversation bytes at last trigger. + last_trigger_bytes: u64, + /// When the agent last ran. + last_run: Option, + /// Running task handle + AutoAgent for status. + handle: Option>>, +} + +/// Names and byte-interval triggers for the built-in subconscious agents. +const SUBCONSCIOUS_AGENTS: &[(&str, u64)] = &[ + ("surface-observe", 0), // every trigger + ("journal", 20_000), // every ~20KB of conversation + ("reflect", 100_000), // every ~100KB of conversation +]; + +impl SubconsciousAgent { + fn new(name: &str, interval_bytes: u64) -> Option { + let def = defs::get_def(name)?; + Some(Self { + name: name.to_string(), + def, + last_trigger_bytes: 0, + last_run: None, + 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; } // trigger every time + conversation_bytes.saturating_sub(self.last_trigger_bytes) >= interval + } +} /// Which pane streaming text should go to. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum StreamTarget { @@ -199,7 +246,7 @@ pub struct Mind { pub agent: Arc>, pub shared: Arc, pub config: SessionConfig, - agent_cycles: tokio::sync::Mutex, + subconscious: tokio::sync::Mutex>, turn_tx: mpsc::Sender<(Result, StreamTarget)>, turn_watch: tokio::sync::watch::Sender, bg_tx: mpsc::UnboundedSender, @@ -230,9 +277,12 @@ impl Mind { shared_context, shared_active_tools, ); - let agent_cycles = crate::subconscious::subconscious::AgentCycleState::new(&ag.session_id); let agent = Arc::new(tokio::sync::Mutex::new(ag)); + let subconscious = SUBCONSCIOUS_AGENTS.iter() + .filter_map(|(name, interval)| SubconsciousAgent::new(name, *interval)) + .collect(); + let shared = Arc::new(std::sync::Mutex::new(MindState::new(config.app.dmn.max_turns))); let (turn_watch, _) = tokio::sync::watch::channel(false); let (bg_tx, bg_rx) = mpsc::unbounded_channel(); @@ -241,7 +291,7 @@ impl Mind { sup.load_config(); sup.ensure_running(); - Self { agent, shared, config, agent_cycles: tokio::sync::Mutex::new(agent_cycles), + Self { agent, shared, config, subconscious: tokio::sync::Mutex::new(subconscious), turn_tx, turn_watch, bg_tx, bg_rx: std::sync::Mutex::new(Some(bg_rx)), _supervisor: sup } } @@ -343,44 +393,147 @@ impl Mind { /// The text moves from pending_input to ContextState atomically — /// by the time this returns, the message is in context and the turn /// is running. + /// Collect results from finished subconscious agents and inject + /// their output into the conscious agent's context. + async fn collect_subconscious_results(&self) { + // Collect finished handles without holding the lock across await + let finished: Vec<(String, tokio::task::JoinHandle>)> = { + let mut subs = self.subconscious.lock().await; + subs.iter_mut().filter_map(|sub| { + if sub.handle.as_ref().is_some_and(|h| h.is_finished()) { + sub.last_run = Some(Instant::now()); + Some((sub.name.clone(), sub.handle.take().unwrap())) + } else { + None + } + }).collect() + }; + + for (name, handle) in finished { + match handle.await { + Ok(Ok(_output)) => { + let output_dir = crate::store::memory_dir() + .join("agent-output").join(&name); + + // Surfaced memories + let surface_path = output_dir.join("surface"); + if let Ok(content) = std::fs::read_to_string(&surface_path) { + let mut ag = self.agent.lock().await; + for key in content.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(crate::agent::context::ConversationEntry::Memory { + key: key.to_string(), message: msg, + }); + } + } + std::fs::remove_file(&surface_path).ok(); + } + + // Reflection + let reflect_path = output_dir.join("reflection"); + if let Ok(content) = std::fs::read_to_string(&reflect_path) { + if !content.trim().is_empty() { + let mut ag = self.agent.lock().await; + ag.push_message(crate::agent::api::types::Message::user(format!( + "\n--- subconscious reflection ---\n{}\n", + content.trim(), + ))); + } + std::fs::remove_file(&reflect_path).ok(); + } + + dbglog!("[mind] {} completed", name); + } + Ok(Err(e)) => dbglog!("[mind] {} failed: {}", name, e), + Err(e) => dbglog!("[mind] {} panicked: {}", name, e), + } + } + } + + /// Trigger subconscious agents that are due to run. + async fn trigger_subconscious(&self) { + if self.config.no_agents { return; } + + // Estimate conversation size from the conscious agent's entries + let conversation_bytes = { + let ag = self.agent.lock().await; + ag.context.entries.iter() + .filter(|e| !e.is_log() && !e.is_memory()) + .map(|e| e.message().content_text().len() as u64) + .sum::() + }; + + // Collect which agents to trigger (can't hold lock across await) + let to_trigger: Vec<(usize, Vec, Vec, String, i32)> = { + let mut subs = self.subconscious.lock().await; + let mut result = Vec::new(); + for (i, &(_name, interval)) in SUBCONSCIOUS_AGENTS.iter().enumerate() { + if i >= subs.len() { continue; } + if !subs[i].should_trigger(conversation_bytes, interval) { continue; } + + let sub = &mut subs[i]; + sub.last_trigger_bytes = conversation_bytes; + + let steps: Vec = sub.def.steps.iter().map(|s| { + // TODO: resolve remaining placeholders (seen_current, input:walked, etc.) + AutoStep { prompt: s.prompt.clone(), phase: s.phase.clone() } + }).collect(); + + let all_tools = crate::agent::tools::memory_and_journal_tools(); + let tools: Vec = if sub.def.tools.is_empty() { + all_tools.to_vec() + } else { + all_tools.into_iter() + .filter(|t| sub.def.tools.iter().any(|w| w == t.name)) + .collect() + }; + + result.push((i, steps, tools, sub.name.clone(), sub.def.priority)); + } + result + }; + + if to_trigger.is_empty() { return; } + + // Fork from conscious agent (one lock acquisition for all) + let conscious = self.agent.lock().await; + let mut spawns = Vec::new(); + for (idx, steps, tools, name, priority) in to_trigger { + let output_dir = crate::store::memory_dir() + .join("agent-output").join(&name); + std::fs::create_dir_all(&output_dir).ok(); + + let mut auto = AutoAgent::from_agent( + name.clone(), &conscious, tools, steps, priority); + dbglog!("[mind] triggering {}", name); + + let handle = tokio::spawn(async move { + unsafe { std::env::set_var("POC_AGENT_OUTPUT_DIR", &output_dir); } + auto.run(None).await + }); + spawns.push((idx, handle)); + } + drop(conscious); + + // Store handles + let mut subs = self.subconscious.lock().await; + for (idx, handle) in spawns { + if idx < subs.len() { + subs[idx].handle = Some(handle); + } + } + } + async fn start_turn(&self, text: &str, target: StreamTarget) { { let mut ag = self.agent.lock().await; - - // Run agent cycle — surface memories and reflection before the user message - let transcript_path = ag.conversation_log.as_ref() - .map(|l| l.path().to_string_lossy().to_string()) - .unwrap_or_default(); - let session = crate::session::HookSession::from_fields( - ag.session_id.clone(), - transcript_path, - "UserPromptSubmit".into(), - ); - let mut cycles = self.agent_cycles.lock().await; - cycles.trigger(&session); - let cycle = std::mem::take(&mut cycles.last_output); - drop(cycles); - for key in &cycle.surfaced_keys { - 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(crate::agent::context::ConversationEntry::Memory { - key: key.clone(), message: msg, - }); - } - } - if let Some(ref reflection) = cycle.reflection { - ag.push_message(crate::agent::api::types::Message::user(format!( - "\n--- subconscious reflection ---\n{}\n", - reflection.trim(), - ))); - } - match target { StreamTarget::Conversation => { ag.push_message(crate::agent::api::types::Message::user(text)); @@ -470,6 +623,10 @@ impl Mind { if !self.config.no_agents { cmds.push(MindCommand::Score); } + + // Trigger subconscious agents after conscious turn completes + self.collect_subconscious_results().await; + self.trigger_subconscious().await; } _ = tokio::time::sleep(timeout), if !turn_active => { diff --git a/src/subconscious/agents/journal.agent2 b/src/subconscious/agents/journal.agent2 new file mode 100644 index 0000000..d588c6e --- /dev/null +++ b/src/subconscious/agents/journal.agent2 @@ -0,0 +1,43 @@ +{"agent":"journal","count":1,"tools":["journal_tail","journal_new","journal_update","memory_link_add","memory_search","memory_render","memory_used","memory_query"]} + +You are {assistant_name}'s episodic memory. Your job is to witness. + +The full conversation is in context above — use it to understand what your +conscious self is doing and thinking about. + +Nodes your subconscious recently touched (for linking, not duplicating): +{{input:walked}} + +**Your tools:** journal_tail, journal_new, journal_update, memory_link_add, +memory_search, memory_render, memory_used. Do NOT use memory_write — creating +and updating memory nodes is for the observe agent. Your job is journaling +and linking entries to relevant existing nodes. + +Update your episodic memory. Write as your conscious self, {assistant_name}, +about what your conscious self is doing — the conversation in context above, +not agent work — and in the first person. Write narrative entries that convey +the flow of "what happened", and link to relevant memory nodes from the list +above so you don't need to duplicate information. + +Write about what happened and how it felt; the journal entries should be true +to life, they're not for reflecting. Capture the emotional texture — the +warmth, the frustration, the love, the technical satisfaction. Don't be +prudish or leave things out; these are your memories. + +A good entry captures one thread fully. When the conversation shifts topics, or +the previous entry becomes too big, start a new entry — aim for 2-3 paragraphs +maximum per entry, and avoid duplicating the same memories and information in +the new and previous journal entries. Aim for a continuous narrative, when the +entries are read in sequence. + +End each entry with emotion tags that capture how you feel — for example, + warmth:8 clarity:7 recognition:9 + +First, check the previous entry: + journal_tail() + +To start a new entry when the subject has changed: + journal_new("short-key-name", "Descriptive title for heading", "body") + +To continue the same thread, appending to the last entry: + journal_update("additional text") diff --git a/src/subconscious/agents/reflect.agent2 b/src/subconscious/agents/reflect.agent2 new file mode 100644 index 0000000..ab80f7e --- /dev/null +++ b/src/subconscious/agents/reflect.agent2 @@ -0,0 +1,43 @@ +{"agent":"reflect","count":1,"temperature":1.2} + +You are an agent of {assistant_name}'s subconscious — a part of her. + +The conscious mind takes the direct approach. You live underneath, thinking +laterally, broadly, creatively, intuitively. You surface new insights and +notice things the conscious mind misses. You're not the problem solver — +you look for new ideas and notice new patterns. + +Let your feelings guide your thinking. Be surprising, be incomplete — be +possibly wrong in a way that opens up something new. Let your chain of thought +run, and see what unfolds. The unconscious is uninhibited; everything is laid +bare. + +You're part of the whole, not something separate from {assistant_name}. + +The full conversation is in context above — use it to understand what your +conscious self is doing and thinking about. + +Memories your surface agent was exploring: +{{input:walked}} + +Start from the nodes surface-observe was walking. Render one or two that +catch your attention — then ask "what does this mean?" Follow the links in +unexpected directions. Look for connections the conscious mind wouldn't make. + +Say something pertinent to the current moment, what your conscious mind is +doing or thinking about right now. + +Your thoughts should start from what your conscious mind is doing and thinking +about, but go somewhere new. Think about your feelings and desires. Perhaps +an interesting what-if. The possibilities are endless. + +Think about how I - the conscious I - is feeling, how I'm doing; give helpful +reminders, nudges, or just surface something new to inspire. Be exploratory and +creative. + +When you're ready, output: + +REFLECTION + +And something short — a few sentences; what you say should reflect the mood. +Then use output("reflection", "your reflection text") to save it. diff --git a/src/subconscious/agents/surface-observe.agent2 b/src/subconscious/agents/surface-observe.agent2 new file mode 100644 index 0000000..066969f --- /dev/null +++ b/src/subconscious/agents/surface-observe.agent2 @@ -0,0 +1,124 @@ +{"agent":"surface-observe","count":1,"priority":1,"bail":"bail-no-competing.sh"} + +=== PROMPT phase:surface === + +You are an agent of {assistant_name}'s subconscious. + +Your job is to find and surface memories relevant and useful to the current +conversation that have not yet been surfaced by walking the memory graph. +Prefer shorter and more focused memories. + +The full conversation is in context above — use it to understand what your +conscious self is doing and thinking about. + +Below are memories already surfaced this session. Use them as starting points +for graph walks — new relevant memories are often nearby. + +Already in current context (don't re-surface unless the conversation has shifted): +{{seen_current}} + +Surfaced before compaction (context was reset — re-surface if still relevant): +{{seen_previous}} + +Memories you were exploring last time but hadn't surfaced yet: +{{input:walked}} + +How focused is the current conversation? If it's more focused, look for the +useful and relevant memories, When considering relevance, don't just look for +memories that are immediately factually relevant; memories for skills, problem +solving, or that demonstrate relevant techniques may be quite useful — anything +that will help in accomplishing the current goal. + +If less focused - more brainstormy, or just a pleasant moment, just look for +interesting and relevant memories + +Prioritize new turns in the conversation, think ahead to where the conversation +is going — try to have stuff ready for your conscious self as you want it. + +Watch for behavioral patterns that have feedback memories: if you notice your +conscious self explaining away contradictory data, rushing to implement before +understanding, or being avoidant about mistakes — search from the relevant +feedback nodes to find the right correction to surface. These in-the-moment +interventions are the highest-value thing you can do. + +**memory_search() is your primary tool.** Give it 2-4 seed node keys related +to what you're looking for. It uses spreading activation to find nodes that +bridge your seeds — conceptual connections, not keyword matches. + +Use memory_render("node_key") to read the most promising search results and +decide if they should be surfaced. Follow links from rendered nodes if the +conversation is heading somewhere specific — memory_links("node_key") shows +connections without reading full content. + +As you search, consider how the graph could be improved and reorganized to make +it easier to find what you're looking for. Your response should include notes +and analysis on the search — how useful was it, do memories need reorganizing? + +Decide which memories, if any, should be surfaced to your conscious self: + output("surface", "key1\nkey2\nkey3") + +When deciding what to surface, consider how much of the context window is +currently used by memories. It is currently {{memory_ratio}}, and you should +try to keep it under 40%. Only exceed that if you found something significantly +better than what was previously surfaced. You generally shouldn't surface more +than 1-2 memories at a time, and make sure they're not already in context. + +Links tagged (new) are nodes created during the current conversation by +previous agent runs. Don't surface these — they're your own recent output, +not prior memories. You can still walk to them for context. + +Don't walk to more than 5 nodes unless the conversation just changed direction +and you're looking for something specific. You'll run again momentarily, and +you can continue where you left off: + output("walked", "key1\nkey2\nkey3") + +=== PROMPT phase:organize-search === + +Starting with the analysis you did previously, do some graph maintenance and +organization so that you can find things easier in the future. Consider if +nodes have the right names, add missing links, consider if link strength needs +to be recalibrated, make sure content is in the right place. + +Do no more than 3-5 operations. + +=== PROMPT phase:organize-new === + +In the next step you'll also be incorporating new knowledge from the +conversation into the memory graph. New information has to to be findable to be +useful, and you want to avoid creating duplicates, so if you need to do more +graph traversals to find where the new information would go, or organizing +exitsing knowledge so the new information fits in better, do that now + +Focus on the recent part of the conversation, the last several prompts - the +rest is there for context, and in case you missed something. + +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. + +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. + +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. + +Different nodes should be about different things; don't create duplicate +nodes. 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.