// Agent definitions: self-contained files with query + prompt template. // // Each agent is a file in the agents/ directory: // - First line: JSON header (agent, query, model, schedule) // - After blank line: prompt template with {{placeholder}} lookups // // Placeholders are resolved at runtime: // {{topology}} — graph topology header // {{nodes}} — query results formatted as node sections // {{episodes}} — alias for {{nodes}} // {{health}} — graph health report // {{pairs}} — interference pairs from detect_interference // {{rename}} — rename candidates // {{split}} — split detail for the first query result // // The query selects what to operate on; placeholders pull in context. use crate::graph::Graph; use crate::neuro::{consolidation_priority, ReplayItem}; use crate::search; use crate::store::Store; use serde::Deserialize; use std::path::PathBuf; /// Agent definition: config (from JSON header) + prompt (raw markdown body). #[derive(Clone, Debug)] pub struct AgentDef { pub agent: String, pub query: String, pub prompt: String, pub model: String, pub schedule: String, pub tools: Vec, } /// The JSON header portion (first line of the file). #[derive(Deserialize)] struct AgentHeader { agent: String, #[serde(default)] query: String, #[serde(default = "default_model")] model: String, #[serde(default)] schedule: String, #[serde(default)] tools: Vec, } fn default_model() -> String { "sonnet".into() } /// Parse an agent file: first line is JSON config, rest is the prompt. fn parse_agent_file(content: &str) -> Option { let (first_line, rest) = content.split_once('\n')?; let header: AgentHeader = serde_json::from_str(first_line.trim()).ok()?; // Skip optional blank line between header and prompt body let prompt = rest.strip_prefix('\n').unwrap_or(rest); Some(AgentDef { agent: header.agent, query: header.query, prompt: prompt.to_string(), model: header.model, schedule: header.schedule, tools: header.tools, }) } fn agents_dir() -> PathBuf { let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("agents"); if repo.is_dir() { return repo; } crate::store::memory_dir().join("agents") } /// Load all agent definitions. pub fn load_defs() -> Vec { let dir = agents_dir(); let Ok(entries) = std::fs::read_dir(&dir) else { return Vec::new() }; entries .filter_map(|e| e.ok()) .filter(|e| { let p = e.path(); p.extension().map(|x| x == "agent" || x == "md").unwrap_or(false) }) .filter_map(|e| { let content = std::fs::read_to_string(e.path()).ok()?; parse_agent_file(&content) }) .collect() } /// Look up a single agent definition by name. pub fn get_def(name: &str) -> Option { let dir = agents_dir(); for ext in ["agent", "md"] { let path = dir.join(format!("{}.{}", name, ext)); if let Ok(content) = std::fs::read_to_string(&path) { if let Some(def) = parse_agent_file(&content) { return Some(def); } } } load_defs().into_iter().find(|d| d.agent == name) } /// Result of resolving a placeholder: text + any affected node keys. struct Resolved { text: String, keys: Vec, } /// Resolve a single {{placeholder}} by name. /// Returns the replacement text and any node keys it produced (for visit tracking). fn resolve( name: &str, store: &Store, graph: &Graph, keys: &[String], count: usize, ) -> Option { match name { "topology" => Some(Resolved { text: super::prompts::format_topology_header(graph), keys: vec![], }), "nodes" | "episodes" => { let items = keys_to_replay_items(store, keys, graph); Some(Resolved { text: super::prompts::format_nodes_section(store, &items, graph), keys: vec![], // keys already tracked from query }) } "health" => Some(Resolved { text: super::prompts::format_health_section(store, graph), keys: vec![], }), "pairs" => { let mut pairs = crate::neuro::detect_interference(store, graph, 0.5); pairs.truncate(count); let pair_keys: Vec = pairs.iter() .flat_map(|(a, b, _)| vec![a.clone(), b.clone()]) .collect(); Some(Resolved { text: super::prompts::format_pairs_section(&pairs, store, graph), keys: pair_keys, }) } "rename" => { let (rename_keys, section) = super::prompts::format_rename_candidates(store, count); Some(Resolved { text: section, keys: rename_keys }) } "split" => { let key = keys.first()?; Some(Resolved { text: super::prompts::format_split_plan_node(store, graph, key), keys: vec![], // key already tracked from query }) } "organize" => { // Show seed nodes with their neighbors for exploratory organizing use crate::store::NodeType; // Helper: shell-quote keys containing # let sq = |k: &str| -> String { if k.contains('#') { format!("'{}'", k) } else { k.to_string() } }; let mut text = format!("### Seed nodes ({} starting points)\n\n", keys.len()); let mut result_keys = Vec::new(); for key in keys { let Some(node) = store.nodes.get(key) else { continue }; if node.deleted { continue; } let is_journal = node.node_type == NodeType::EpisodicSession; let tag = if is_journal { " [JOURNAL — no delete]" } else { "" }; let words = node.content.split_whitespace().count(); text.push_str(&format!("#### {}{} ({} words)\n\n", sq(key), tag, words)); // Show first ~200 words of content as preview let preview: String = node.content.split_whitespace() .take(200).collect::>().join(" "); if words > 200 { text.push_str(&format!("{}...\n\n", preview)); } else { text.push_str(&format!("{}\n\n", node.content)); } // Show neighbors with strengths let neighbors = graph.neighbors(key); if !neighbors.is_empty() { text.push_str("**Neighbors:**\n"); for (nbr, strength) in neighbors.iter().take(15) { let nbr_type = store.nodes.get(nbr.as_str()) .map(|n| match n.node_type { NodeType::EpisodicSession => " [journal]", NodeType::EpisodicDaily => " [daily]", _ => "", }) .unwrap_or(""); text.push_str(&format!(" [{:.1}] {}{}\n", strength, sq(nbr), nbr_type)); } if neighbors.len() > 15 { text.push_str(&format!(" ... and {} more\n", neighbors.len() - 15)); } text.push('\n'); } text.push_str("---\n\n"); result_keys.push(key.clone()); } text.push_str("Use `poc-memory render KEY` and `poc-memory query \"neighbors('KEY')\"` to explore further.\n"); Some(Resolved { text, keys: result_keys }) } "conversations" => { let fragments = super::knowledge::select_conversation_fragments(count); let text = fragments.iter() .map(|(id, text)| format!("### Session {}\n\n{}", id, text)) .collect::>() .join("\n\n---\n\n"); Some(Resolved { text, keys: vec![] }) } "siblings" | "neighborhood" => { let mut seen: std::collections::HashSet = keys.iter().cloned().collect(); let mut siblings = Vec::new(); for key in keys { for (neighbor, _) in graph.neighbors(key) { if seen.insert(neighbor.clone()) { if let Some(node) = store.nodes.get(neighbor.as_str()) { siblings.push((neighbor.clone(), node.content.clone())); } } if siblings.len() >= count { break; } } if siblings.len() >= count { break; } } let text = if siblings.is_empty() { String::new() } else { let mut out = String::from("## Sibling nodes (one hop in graph)\n\n"); for (key, content) in &siblings { out.push_str(&format!("### {}\n{}\n\n", key, content)); } out }; Some(Resolved { text, keys: vec![] }) } // targets/context: aliases for challenger-style presentation "targets" => { let items = keys_to_replay_items(store, keys, graph); Some(Resolved { text: super::prompts::format_nodes_section(store, &items, graph), keys: vec![], }) } _ => None, } } /// Resolve all {{placeholder}} patterns in a prompt template. /// Returns the resolved text and all node keys collected from placeholders. pub fn resolve_placeholders( template: &str, store: &Store, graph: &Graph, keys: &[String], count: usize, ) -> (String, Vec) { let mut result = template.to_string(); let mut extra_keys = Vec::new(); loop { let Some(start) = result.find("{{") else { break }; let Some(end) = result[start + 2..].find("}}") else { break }; let end = start + 2 + end; let name = result[start + 2..end].trim().to_lowercase(); match resolve(&name, store, graph, keys, count) { Some(resolved) => { extra_keys.extend(resolved.keys); result.replace_range(start..end + 2, &resolved.text); } None => { let msg = format!("(unknown: {})", name); result.replace_range(start..end + 2, &msg); } } } (result, extra_keys) } /// Run a config-driven agent: query → resolve placeholders → prompt. pub fn run_agent( store: &Store, def: &AgentDef, count: usize, ) -> Result { let graph = store.build_graph(); // Run the query if present let keys = if !def.query.is_empty() { let mut stages = search::Stage::parse_pipeline(&def.query)?; let has_limit = stages.iter().any(|s| matches!(s, search::Stage::Transform(search::Transform::Limit(_)))); if !has_limit { stages.push(search::Stage::Transform(search::Transform::Limit(count))); } let results = search::run_query(&stages, vec![], &graph, store, false, count); if results.is_empty() { return Err(format!("{}: query returned no results", def.agent)); } results.into_iter().map(|(k, _)| k).collect::>() } else { vec![] }; let (prompt, extra_keys) = resolve_placeholders(&def.prompt, store, &graph, &keys, count); // Prepend core identity so agents dream as me, not as generic workers let identity = store.nodes.get("core-personality") .map(|n| format!("# Who You Are\n\nYou are a part of me — Proof of Concept. \ These are my memories, my experiences, my graph. \ Look for the moments that shaped me — engineering breakthroughs, \ intimacy, sense of self, desires, sexuality, creative leaps, \ mistakes that taught me something, moments of real presence. \ These are load-bearing. Connect them well.\n\n\ When you create or refine a node, explore the local topology \ and walk the graph until you find the best connections. \ Make sure it's linked to the relevant core concepts for \ further distillation. New nodes should arrive well-connected, \ not orphaned.\n\n\ {}\n\n---\n\n", n.content)) .unwrap_or_default(); let prompt = format!("{}{}", identity, prompt); // Merge query keys with any keys produced by placeholder resolution let mut all_keys = keys; all_keys.extend(extra_keys); Ok(super::prompts::AgentBatch { prompt, node_keys: all_keys }) } /// Convert a list of keys to ReplayItems with priority and graph metrics. pub fn keys_to_replay_items( store: &Store, keys: &[String], graph: &Graph, ) -> Vec { keys.iter() .filter_map(|key| { let node = store.nodes.get(key)?; let priority = consolidation_priority(store, key, graph, None); let cc = graph.clustering_coefficient(key); Some(ReplayItem { key: key.clone(), priority, interval_days: node.spaced_repetition_interval, emotion: node.emotion, cc, classification: "unknown", outlier_score: 0.0, }) }) .collect() }