forked from kent/consciousness
Previously the organize agent received a pre-computed cluster from a term search — 69% of runs produced 0 actions because the same clusters kept being found via different entry points. Now: seed nodes shown with content previews and neighbor lists. Agent uses tools (render, query neighbors, search) to explore outward and discover what needs organizing. Visit filter set to 24h cooldown. Prompt rewritten to encourage active exploration rather than static cluster analysis.
361 lines
12 KiB
Rust
361 lines
12 KiB
Rust
// 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<String>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
}
|
|
|
|
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<AgentDef> {
|
|
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<AgentDef> {
|
|
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<AgentDef> {
|
|
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<String>,
|
|
}
|
|
|
|
/// 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<Resolved> {
|
|
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<String> = 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::<Vec<_>>().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::<Vec<_>>()
|
|
.join("\n\n---\n\n");
|
|
Some(Resolved { text, keys: vec![] })
|
|
}
|
|
|
|
"siblings" | "neighborhood" => {
|
|
let mut seen: std::collections::HashSet<String> = 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<String>) {
|
|
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<super::prompts::AgentBatch, String> {
|
|
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::<Vec<_>>()
|
|
} 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<ReplayItem> {
|
|
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()
|
|
}
|