// 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" => { // Run cluster diagnostic for the query term // The query field of the agent def holds the search term let term = if keys.is_empty() { "" } else { &keys[0] }; if term.is_empty() { return Some(Resolved { text: "(no term provided)".into(), keys: vec![] }); } let term_lower = term.to_lowercase(); let skip_prefixes = ["journal#", "daily-", "weekly-", "monthly-", "_", "deep-index#", "facts-", "irc-history#"]; let mut cluster: Vec<(String, String)> = Vec::new(); for (key, node) in &store.nodes { if node.deleted { continue; } if !key.to_lowercase().contains(&term_lower) { continue; } if skip_prefixes.iter().any(|p| key.starts_with(p)) { continue; } cluster.push((key.clone(), node.content.clone())); } cluster.sort_by(|a, b| a.0.cmp(&b.0)); // Cap cluster size — agent has tools to explore more if needed if cluster.len() > 20 { cluster.truncate(20); } // Similarity pairs let pairs = crate::similarity::pairwise_similar(&cluster, 0.4); let mut text = format!("### Cluster: '{}' ({} nodes)\n\n", term, cluster.len()); // Similarity report if !pairs.is_empty() { text.push_str("#### Similarity scores\n\n"); for (a, b, sim) in &pairs { text.push_str(&format!(" [{:.3}] {} ↔ {}\n", sim, a, b)); } text.push('\n'); } // Connectivity let cluster_keys: std::collections::HashSet<&str> = cluster.iter() .map(|(k,_)| k.as_str()).collect(); let mut best_hub: Option<(&str, usize)> = None; for key in &cluster_keys { let intra = graph.neighbor_keys(key).iter() .filter(|n| cluster_keys.contains(*n)) .count(); if best_hub.is_none() || intra > best_hub.unwrap().1 { best_hub = Some((key, intra)); } } if let Some((hub, deg)) = best_hub { text.push_str(&format!("#### Hub: {} (intra-cluster degree {})\n\n", hub, deg)); let hub_nbrs = graph.neighbor_keys(hub); for key in &cluster_keys { if *key == hub { continue; } if !hub_nbrs.contains(*key) { text.push_str(&format!(" NOT linked to hub: {}\n", key)); } } text.push('\n'); } // Full node contents text.push_str("#### Node contents\n\n"); let mut result_keys = Vec::new(); for (key, content) in &cluster { let words = content.split_whitespace().count(); text.push_str(&format!("##### {} ({} words)\n\n{}\n\n---\n\n", key, words, content)); result_keys.push(key.clone()); } 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); // 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() }