// cli/graph.rs — graph subcommand handlers // // Extracted from main.rs. All graph-related CLI commands: // link, link-add, link-impact, link-audit, cap-degree, // normalize-strengths, trace, spectral-*, organize, communities. use crate::{store, graph}; use crate::store::StoreView; pub fn cmd_graph() -> Result<(), String> { let store = store::Store::load()?; let g = store.build_graph(); println!("Graph: {} nodes, {} edges, {} communities", g.nodes().len(), g.edge_count(), g.community_count()); println!("σ={:.2} α={:.2} gini={:.3} cc={:.4}", g.small_world_sigma(), g.degree_power_law_exponent(), g.degree_gini(), g.avg_clustering_coefficient()); Ok(()) } pub fn cmd_cap_degree(max_deg: usize) -> Result<(), String> { let mut store = store::Store::load()?; let (hubs, pruned) = store.cap_degree(max_deg)?; store.save()?; println!("Capped {} hubs, pruned {} weak Auto edges (max_degree={})", hubs, pruned, max_deg); Ok(()) } pub fn cmd_normalize_strengths(apply: bool) -> Result<(), String> { if apply { super::check_dry_run(); } let result = crate::mcp_server::memory_rpc( "graph_normalize_strengths", serde_json::json!({"apply": apply}), ).map_err(|e| e.to_string())?; print!("{}", result); Ok(()) } pub fn cmd_link(key: &[String]) -> Result<(), String> { if key.is_empty() { return Err("link requires a key".into()); } let key = key.join(" "); let result = crate::mcp_server::memory_rpc( "memory_links", serde_json::json!({"key": key}), ).map_err(|e| e.to_string())?; print!("{}", result); Ok(()) } pub fn cmd_link_add(source: &str, target: &str, _reason: &[String]) -> Result<(), String> { super::check_dry_run(); let result = crate::mcp_server::memory_rpc( "memory_link_add", serde_json::json!({"source": source, "target": target}), ).map_err(|e| e.to_string())?; println!("{}", result); Ok(()) } pub fn cmd_link_set(source: &str, target: &str, strength: f32) -> Result<(), String> { super::check_dry_run(); let result = crate::mcp_server::memory_rpc( "memory_link_set", serde_json::json!({"source": source, "target": target, "strength": strength}), ).map_err(|e| e.to_string())?; println!("{}", result); Ok(()) } pub fn cmd_link_impact(source: &str, target: &str) -> Result<(), String> { let result = crate::mcp_server::memory_rpc( "graph_link_impact", serde_json::json!({"source": source, "target": target}), ).map_err(|e| e.to_string())?; print!("{}", result); Ok(()) } pub fn cmd_trace(key: &[String]) -> Result<(), String> { if key.is_empty() { return Err("trace requires a key".into()); } let key = key.join(" "); let result = crate::mcp_server::memory_rpc( "graph_trace", serde_json::json!({"key": key}), ).map_err(|e| e.to_string())?; print!("{}", result); Ok(()) } pub fn cmd_organize(term: &str, key_only: bool, create_anchor: bool) -> Result<(), String> { let mut store = store::Store::load()?; // Step 1: find all non-deleted nodes matching the term let term_lower = term.to_lowercase(); let mut topic_nodes: Vec<(String, String)> = Vec::new(); // (key, content) let skip_prefixes = ["_", "deep-index#", "facts-", "irc-history#"]; for (key, node) in &store.nodes { if node.deleted { continue; } // Skip episodic/digest nodes — use NodeType, not key prefix if node.node_type != crate::store::NodeType::Semantic { continue; } let key_matches = key.to_lowercase().contains(&term_lower); let content_matches = !key_only && node.content.to_lowercase().contains(&term_lower); if !key_matches && !content_matches { continue; } if skip_prefixes.iter().any(|p| key.starts_with(p)) { continue; } topic_nodes.push((key.clone(), node.content.clone())); } if topic_nodes.is_empty() { println!("No topic nodes found matching '{}'", term); return Ok(()); } topic_nodes.sort_by(|a, b| a.0.cmp(&b.0)); println!("=== Organize: '{}' ===", term); println!("Found {} topic nodes:\n", topic_nodes.len()); for (key, content) in &topic_nodes { let lines = content.lines().count(); let words = content.split_whitespace().count(); println!(" {:60} {:>4} lines {:>5} words", key, lines, words); } // Step 2: check connectivity within cluster let g = store.build_graph(); println!("=== Connectivity ===\n"); // Pick hub by intra-cluster connectivity, not overall degree let cluster_keys: std::collections::HashSet<&str> = topic_nodes.iter() .filter(|(k,_)| store.nodes.contains_key(k.as_str())) .map(|(k,_)| k.as_str()) .collect(); let mut best_hub: Option<(&str, usize)> = None; for key in &cluster_keys { let intra_degree = g.neighbor_keys(key).iter() .filter(|n| cluster_keys.contains(*n)) .count(); if best_hub.is_none() || intra_degree > best_hub.unwrap().1 { best_hub = Some((key, intra_degree)); } } if let Some((hub, deg)) = best_hub { println!(" Hub: {} (degree {})", hub, deg); let hub_nbrs = g.neighbor_keys(hub); let mut unlinked = Vec::new(); for (key, _) in &topic_nodes { if key == hub { continue; } if store.nodes.get(key.as_str()).is_none() { continue; } if !hub_nbrs.contains(key.as_str()) { unlinked.push(key.clone()); } } if unlinked.is_empty() { println!(" All cluster nodes connected to hub ✓"); } else { println!(" NOT linked to hub:"); for key in &unlinked { println!(" {} → needs link to {}", key, hub); } } } // Step 4: anchor node if create_anchor { println!("\n=== Anchor node ===\n"); if store.nodes.contains_key(term) && !store.nodes[term].deleted { println!(" Anchor '{}' already exists ✓", term); } else { let desc = format!("Anchor node for '{}' search term", term); store.upsert(term, &desc)?; let anchor_uuid = store.nodes.get(term).unwrap().uuid; for (key, _) in &topic_nodes { if store.nodes.get(key.as_str()).is_none() { continue; } let target_uuid = store.nodes[key.as_str()].uuid; let rel = store::new_relation( anchor_uuid, target_uuid, store::RelationType::Link, 0.8, term, key, ); store.add_relation(rel)?; } println!(" Created anchor '{}' with {} links", term, topic_nodes.len()); } } store.save()?; Ok(()) } /// Show communities sorted by isolation (most isolated first). /// Useful for finding poorly-integrated knowledge clusters that need /// organize agents aimed at them. pub fn cmd_communities(top_n: usize, min_size: usize) -> Result<(), String> { let result = crate::mcp_server::memory_rpc( "graph_communities", serde_json::json!({"top_n": top_n, "min_size": min_size}), ).map_err(|e| e.to_string())?; print!("{}", result); Ok(()) }