// cli/agent.rs — agent subcommand handlers use crate::store; use crate::store::StoreView; use crate::agents::llm; use std::sync::atomic::{AtomicUsize, Ordering}; pub fn cmd_consolidate_batch(count: usize, auto: bool, agent: Option) -> Result<(), String> { let store = store::Store::load()?; if let Some(agent_name) = agent { let batch = crate::agents::prompts::agent_prompt(&store, &agent_name, count)?; println!("{}", batch.prompt); Ok(()) } else { crate::agents::prompts::consolidation_batch(&store, count, auto) } } pub fn cmd_replay_queue(count: usize) -> Result<(), String> { let store = store::Store::load()?; let queue = crate::neuro::replay_queue(&store, count); println!("Replay queue ({} items):", queue.len()); for (i, item) in queue.iter().enumerate() { println!(" {:2}. [{:.3}] {:>10} {} (interval={}d, emotion={:.1}, spectral={:.1})", i + 1, item.priority, item.classification, item.key, item.interval_days, item.emotion, item.outlier_score); } Ok(()) } pub fn cmd_consolidate_session() -> Result<(), String> { let store = store::Store::load()?; let plan = crate::neuro::consolidation_plan(&store); println!("{}", crate::neuro::format_plan(&plan)); Ok(()) } pub fn cmd_consolidate_full() -> Result<(), String> { let mut store = store::Store::load()?; crate::consolidate::consolidate_full(&mut store) } pub fn cmd_digest_links(do_apply: bool) -> Result<(), String> { let store = store::Store::load()?; let links = crate::digest::parse_all_digest_links(&store); drop(store); println!("Found {} unique links from digest nodes", links.len()); if !do_apply { for (i, link) in links.iter().enumerate() { println!(" {:3}. {} → {}", i + 1, link.source, link.target); if !link.reason.is_empty() { println!(" ({})", &link.reason[..link.reason.len().min(80)]); } } println!("\nTo apply: poc-memory digest-links --apply"); return Ok(()); } let mut store = store::Store::load()?; let (applied, skipped, fallbacks) = crate::digest::apply_digest_links(&mut store, &links); println!("\nApplied: {} ({} file-level fallbacks) Skipped: {}", applied, fallbacks, skipped); Ok(()) } pub fn cmd_journal_enrich(jsonl_path: &str, entry_text: &str, grep_line: usize) -> Result<(), String> { if !std::path::Path::new(jsonl_path).is_file() { return Err(format!("JSONL not found: {}", jsonl_path)); } let mut store = store::Store::load()?; crate::enrich::journal_enrich(&mut store, jsonl_path, entry_text, grep_line) } pub fn cmd_apply_consolidation(do_apply: bool, report_file: Option<&str>) -> Result<(), String> { let mut store = store::Store::load()?; crate::consolidate::apply_consolidation(&mut store, do_apply, report_file) } pub fn cmd_knowledge_loop(max_cycles: usize, batch_size: usize, window: usize, max_depth: i32) -> Result<(), String> { let config = crate::knowledge::KnowledgeLoopConfig { max_cycles, batch_size, window, max_depth, ..Default::default() }; let results = crate::knowledge::run_knowledge_loop(&config)?; eprintln!("\nCompleted {} cycles, {} total actions applied", results.len(), results.iter().map(|r| r.total_applied).sum::()); Ok(()) } pub fn cmd_fact_mine(path: &str, batch: bool, dry_run: bool, output_file: Option<&str>, min_messages: usize) -> Result<(), String> { let p = std::path::Path::new(path); let paths: Vec = if batch { if !p.is_dir() { return Err(format!("Not a directory: {}", path)); } let mut files: Vec<_> = std::fs::read_dir(p) .map_err(|e| format!("read dir: {}", e))? .filter_map(|e| e.ok()) .map(|e| e.path()) .filter(|p| p.extension().map(|x| x == "jsonl").unwrap_or(false)) .collect(); files.sort(); eprintln!("Found {} transcripts", files.len()); files } else { vec![p.to_path_buf()] }; let path_refs: Vec<&std::path::Path> = paths.iter().map(|p| p.as_path()).collect(); let facts = crate::fact_mine::mine_batch(&path_refs, min_messages, dry_run)?; if !dry_run { let json = serde_json::to_string_pretty(&facts) .map_err(|e| format!("serialize: {}", e))?; if let Some(out) = output_file { std::fs::write(out, &json).map_err(|e| format!("write: {}", e))?; eprintln!("\nWrote {} facts to {}", facts.len(), out); } else { println!("{}", json); } } eprintln!("\nTotal: {} facts from {} transcripts", facts.len(), paths.len()); Ok(()) } pub fn cmd_fact_mine_store(path: &str) -> Result<(), String> { let path = std::path::Path::new(path); if !path.exists() { return Err(format!("File not found: {}", path.display())); } let count = crate::fact_mine::mine_and_store(path, None)?; eprintln!("Stored {} facts", count); Ok(()) } /// Sample recent actions from each agent type, sort by quality using /// LLM pairwise comparison, report per-type rankings. pub fn cmd_evaluate_agents(samples_per_type: usize, model: &str, dry_run: bool) -> Result<(), String> { let store = store::Store::load()?; // Collect consolidation reports grouped by agent type let agent_types = ["linker", "organize", "replay", "connector", "separator", "transfer", "distill", "rename"]; // Load agent prompt files for context let prompts_dir = { let repo = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("agents"); if repo.is_dir() { repo } else { crate::store::memory_dir().join("agents") } }; let mut all_samples: Vec<(String, String, String)> = Vec::new(); // (agent_type, key, context) for agent_type in &agent_types { // Load the agent's prompt file (skip JSON header line) let prompt_file = prompts_dir.join(format!("{}.agent", agent_type)); let agent_prompt = std::fs::read_to_string(&prompt_file) .unwrap_or_default() .lines().skip(1).collect::>().join("\n"); let agent_prompt = crate::util::truncate(&agent_prompt, 500, "..."); let prefix = format!("_consolidate-{}", agent_type); let mut keys: Vec<(String, i64)> = store.nodes.iter() .filter(|(k, _)| k.starts_with(&prefix)) .map(|(k, n)| (k.clone(), n.timestamp)) .collect(); keys.sort_by(|a, b| b.1.cmp(&a.1)); keys.truncate(samples_per_type); for (key, _) in &keys { let report = store.nodes.get(key) .map(|n| n.content.clone()) .unwrap_or_default(); // Extract target node keys mentioned in the report and include their content let mut target_content = String::new(); let mut seen_keys = std::collections::HashSet::new(); for word in report.split_whitespace() { let clean = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '-' && c != '_'); if clean.len() > 10 && seen_keys.insert(clean.to_string()) && store.nodes.contains_key(clean) { if let Some(node) = store.nodes.get(clean) { let preview = crate::util::truncate(&node.content, 200, "..."); target_content.push_str(&format!("\n### {}\n{}\n", clean, preview)); if target_content.len() > 1500 { break; } } } } let context = format!( "## Agent instructions\n{}\n\n## Report output\n{}\n\n## Affected nodes\n{}", agent_prompt, crate::util::truncate(&report, 1000, "..."), if target_content.is_empty() { "(none found)".into() } else { target_content } ); all_samples.push((agent_type.to_string(), key.clone(), context)); } } if all_samples.len() < 2 { return Err("Not enough samples to compare".into()); } eprintln!("Collected {} samples from {} agent types", all_samples.len(), agent_types.len()); eprintln!("Sorting with {} pairwise comparisons (model={})...", all_samples.len() * (all_samples.len() as f64).log2() as usize, model); if dry_run { // Show what a comparison looks like without calling the LLM if all_samples.len() >= 2 { let a = &all_samples[0]; let b = &all_samples[all_samples.len() - 1]; let prompt = build_compare_prompt(a, b); println!("=== DRY RUN: Example comparison prompt ===\n"); println!("{}", prompt); println!("\n=== {} samples collected, would do ~{} comparisons ===", all_samples.len(), all_samples.len() * (all_samples.len() as f64).log2() as usize); } return Ok(()); } // Sort with LLM comparator — yes, really. Rayon's parallel merge sort // with an LLM as the comparison function. Multiple API calls in parallel. let comparisons = AtomicUsize::new(0); use rayon::slice::ParallelSliceMut; all_samples.par_sort_by(|a, b| { let n = comparisons.fetch_add(1, Ordering::Relaxed); if n % 10 == 0 { eprint!(" {} comparisons...\r", n); } llm_compare(a, b, model).unwrap_or(std::cmp::Ordering::Equal) }); eprintln!(" {} total comparisons", comparisons.load(Ordering::Relaxed)); let sorted = all_samples; // Print ranked results println!("\nAgent Action Ranking (best → worst):\n"); for (rank, (agent_type, key, summary)) in sorted.iter().enumerate() { let preview = if summary.len() > 80 { &summary[..80] } else { summary }; println!(" {:3}. [{:10}] {} — {}", rank + 1, agent_type, key, preview); } // Compute per-type average rank println!("\nPer-type average rank (lower = better):\n"); let n = sorted.len() as f64; let mut type_ranks: std::collections::HashMap<&str, Vec> = std::collections::HashMap::new(); for (rank, (agent_type, _, _)) in sorted.iter().enumerate() { type_ranks.entry(agent_type).or_default().push(rank + 1); } let mut avgs: Vec<(&str, f64, usize)> = type_ranks.iter() .map(|(t, ranks)| { let avg = ranks.iter().sum::() as f64 / ranks.len() as f64; (*t, avg, ranks.len()) }) .collect(); avgs.sort_by(|a, b| a.1.total_cmp(&b.1)); for (agent_type, avg_rank, count) in &avgs { let quality = 1.0 - (avg_rank / n); println!(" {:12} avg_rank={:5.1} quality={:.2} (n={})", agent_type, avg_rank, quality, count); } Ok(()) } fn build_compare_prompt( a: &(String, String, String), b: &(String, String, String), ) -> String { if a.0 == b.0 { // Same agent type — show instructions once // Split context at "## Report output" to extract shared prompt let split_a: Vec<&str> = a.2.splitn(2, "## Report output").collect(); let split_b: Vec<&str> = b.2.splitn(2, "## Report output").collect(); let shared_prompt = split_a.first().unwrap_or(&""); let report_a = split_a.get(1).unwrap_or(&""); let report_b = split_b.get(1).unwrap_or(&""); format!( "Compare two actions from the same {} agent. Which was better?\n\n\ {}\n\n\ ## Action A\n## Report output{}\n\n\ ## Action B\n## Report output{}\n\n\ Say which is better and why in 1-2 sentences, then end with:\n\ BETTER: A or BETTER: B or BETTER: TIE", a.0, shared_prompt, report_a, report_b ) } else { format!( "Compare these two memory graph agent actions. Which one was better \ for building a useful, well-organized knowledge graph?\n\n\ ## Action A ({} agent)\n{}\n\n\ ## Action B ({} agent)\n{}\n\n\ Say which is better and why in 1-2 sentences, then end with:\n\ BETTER: A or BETTER: B or BETTER: TIE", a.0, a.2, b.0, b.2 ) } } fn llm_compare( a: &(String, String, String), b: &(String, String, String), model: &str, ) -> Result { let prompt = build_compare_prompt(a, b); let response = if model == "haiku" { llm::call_haiku("compare", &prompt)? } else { llm::call_sonnet("compare", &prompt)? }; let response = response.trim().to_uppercase(); if response.contains("BETTER: A") { Ok(std::cmp::Ordering::Less) // A is better = A comes first } else if response.contains("BETTER: B") { Ok(std::cmp::Ordering::Greater) } else { Ok(std::cmp::Ordering::Equal) } }