// Agent prompt generation and formatting. Presentation logic — // builds text prompts from store data for consolidation agents. use crate::store::Store; use crate::graph::Graph; use crate::neuro::ReplayItem; /// Result of building an agent prompt — includes both the prompt text /// and the keys of nodes selected for processing, so the caller can /// record visits after successful completion. /// A resolved step ready for execution. pub struct ResolvedStep { pub prompt: String, pub phase: String, } pub struct AgentBatch { pub steps: Vec, pub node_keys: Vec, } pub fn format_topology_header(store: &Store, graph: &Graph) -> String { let sigma = graph.small_world_sigma(); let alpha = graph.degree_power_law_exponent(); let gini = graph.degree_gini(); let avg_cc = graph.avg_clustering_coefficient(); let n = graph.nodes().len(); let e = graph.edge_count(); // Type counts let mut type_counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new(); let all_keys = store.all_keys().unwrap_or_default(); for key in &all_keys { if let Ok(Some(node)) = store.get_node(key) { let label = match node.node_type { crate::store::NodeType::Semantic => "semantic", crate::store::NodeType::EpisodicSession | crate::store::NodeType::EpisodicDaily | crate::store::NodeType::EpisodicWeekly | crate::store::NodeType::EpisodicMonthly => "episodic", }; *type_counts.entry(label).or_default() += 1; } } let mut types: Vec<_> = type_counts.iter().collect(); types.sort_by_key(|(_, c)| std::cmp::Reverse(**c)); let type_str: String = types.iter() .map(|(t, c)| format!("{}={}", t, c)) .collect::>() .join(" "); // Identify saturated hubs — nodes with degree well above threshold let threshold = graph.hub_threshold(); let mut hubs: Vec<_> = graph.nodes().iter() .map(|k| (k.clone(), graph.degree(k))) .filter(|(_, d)| *d >= threshold) .collect(); hubs.sort_by(|a, b| b.1.cmp(&a.1)); hubs.truncate(15); let hub_list = if hubs.is_empty() { String::new() } else { let lines: Vec = hubs.iter() .map(|(k, d)| format!(" - {} (degree {})", k, d)) .collect(); format!( "### SATURATED HUBS — DO NOT LINK TO THESE\n\ The following nodes are already over-connected. Adding more links\n\ to them makes the graph worse (star topology). Find lateral\n\ connections between peripheral nodes instead.\n\n{}\n\n\ Only link to a hub if it is genuinely the ONLY reasonable target.\n\n", lines.join("\n")) }; format!( "## Current graph topology\n\ Nodes: {} Edges: {} Communities: {} Types: {}\n\ Small-world σ: {:.1} Power-law α: {:.2} Degree Gini: {:.3}\n\ Avg clustering coefficient: {:.4}\n\n\ {}\ Each node below shows its hub-link ratio (fraction of edges to top-5% degree nodes).\n\ Use `poc-memory link-impact SOURCE TARGET` to evaluate proposed links.\n\n", n, e, graph.community_count(), type_str, sigma, alpha, gini, avg_cc, hub_list) } pub fn format_nodes_section(store: &Store, items: &[ReplayItem], graph: &Graph) -> String { let hub_thresh = graph.hub_threshold(); let mut out = String::new(); for item in items { let node = match store.get_node(&item.key).ok().flatten() { Some(n) => n, None => continue, }; out.push_str(&format!("## {} \n", item.key)); out.push_str(&format!("Priority: {:.3} CC: {:.3} Emotion: {:.1} ", item.priority, item.cc, item.emotion)); out.push_str(&format!("Interval: {}d\n", node.spaced_repetition_interval)); if item.outlier_score > 0.0 { out.push_str(&format!("Spectral: {} (outlier={:.1})\n", item.classification, item.outlier_score)); } if let Some(community) = node.community_id { out.push_str(&format!("Community: {} ", community)); } let deg = graph.degree(&item.key); let cc = graph.clustering_coefficient(&item.key); // Hub-link ratio: what fraction of this node's edges go to hubs? let neighbors = graph.neighbors(&item.key); let hub_links = neighbors.iter() .filter(|(n, _)| graph.degree(n) >= hub_thresh) .count(); let hub_ratio = if deg > 0 { hub_links as f32 / deg as f32 } else { 0.0 }; let is_hub = deg >= hub_thresh; out.push_str(&format!("Degree: {} CC: {:.3} Hub-link ratio: {:.0}% ({}/{})", deg, cc, hub_ratio * 100.0, hub_links, deg)); if is_hub { out.push_str(" ← THIS IS A HUB"); } else if hub_ratio > 0.6 { out.push_str(" ← mostly hub-connected, needs lateral links"); } out.push('\n'); let hits = crate::counters::search_hit_count(&item.key); if hits > 0 { out.push_str(&format!("Search hits: {} ← actively found by search, prefer to keep\n", hits)); } // Full content — the agent needs to see everything to do quality work let content = &node.content; out.push_str(&format!("\nContent:\n{}\n\n", content)); // Neighbors let neighbors = graph.neighbors(&item.key); if !neighbors.is_empty() { out.push_str("Neighbors:\n"); for (n, strength) in neighbors.iter().take(15) { let n_cc = graph.clustering_coefficient(n); let n_community = store.get_node(n) .ok() .flatten() .and_then(|n| n.community_id); out.push_str(&format!(" - {} (str={:.2}, cc={:.3}", n, strength, n_cc)); if let Some(c) = n_community { out.push_str(&format!(", c{}", c)); } out.push_str(")\n"); } } out.push_str("\n---\n\n"); } out } pub fn format_health_section(store: &Store, graph: &Graph) -> String { use crate::graph; let health = graph::health_report(graph, store); let mut out = health; out.push_str("\n\n## Weight distribution\n"); // Weight histogram let mut buckets = [0u32; 10]; // 0.0-0.1, 0.1-0.2, ..., 0.9-1.0 let all_keys = store.all_keys().unwrap_or_default(); for key in &all_keys { if let Ok(Some(node)) = store.get_node(key) { let bucket = ((node.weight * 10.0) as usize).min(9); buckets[bucket] += 1; } } for (i, &count) in buckets.iter().enumerate() { let lo = i as f32 / 10.0; let hi = (i + 1) as f32 / 10.0; let bar = "█".repeat((count as usize) / 10); out.push_str(&format!(" {:.1}-{:.1}: {:4} {}\n", lo, hi, count, bar)); } // Near-prune nodes let near_prune: Vec<_> = all_keys.iter() .filter_map(|k| store.get_node(k).ok()?.map(|n| (k.clone(), n.weight))) .filter(|(_, w)| *w < 0.15) .collect(); if !near_prune.is_empty() { out.push_str(&format!("\n## Near-prune nodes ({} total)\n", near_prune.len())); for (k, w) in near_prune.iter().take(20) { out.push_str(&format!(" [{:.3}] {}\n", w, k)); } } // Community sizes let communities = graph.communities(); let mut comm_sizes: std::collections::HashMap> = std::collections::HashMap::new(); for (key, &label) in communities { comm_sizes.entry(label).or_default().push(key.clone()); } let mut sizes: Vec<_> = comm_sizes.iter() .map(|(id, members)| (*id, members.len(), members.clone())) .collect(); sizes.sort_by(|a, b| b.1.cmp(&a.1)); out.push_str("\n## Largest communities\n"); for (id, size, members) in sizes.iter().take(10) { out.push_str(&format!(" Community {} ({} nodes): ", id, size)); let sample: Vec<_> = members.iter().take(5).map(|s| s.as_str()).collect(); out.push_str(&sample.join(", ")); if *size > 5 { out.push_str(", ..."); } out.push('\n'); } out } /// Generate a specific agent prompt with filled-in data. pub async fn agent_prompt(agent: &str, count: usize) -> Result { let def = super::defs::get_def(agent) .ok_or_else(|| format!("Unknown agent: {}", agent))?; super::defs::run_agent(&def, count, &Default::default()).await }