From 11f2d5b169df852ed6f555b8cae9e7ae34a378d9 Mon Sep 17 00:00:00 2001 From: ProofOfConcept Date: Sun, 12 Apr 2026 23:16:12 -0400 Subject: [PATCH] graph_trace, graph_link_impact: convert to RPC tools Agents can use these to understand graph structure: - trace: shows node and neighbors grouped by type - link_impact: analyzes what happens if a link is removed Co-Authored-By: Proof of Concept --- src/agent/tools/memory.rs | 113 ++++++++++++++++++++++++++++++++++++++ src/cli/graph.rs | 109 ++++-------------------------------- 2 files changed, 123 insertions(+), 99 deletions(-) diff --git a/src/agent/tools/memory.rs b/src/agent/tools/memory.rs index 8e86fe5..ae07b9a 100644 --- a/src/agent/tools/memory.rs +++ b/src/agent/tools/memory.rs @@ -124,6 +124,8 @@ async fn dispatch( "graph_health" => graph_health().await, "graph_communities" => graph_communities(&args).await, "graph_normalize_strengths" => graph_normalize_strengths(&args).await, + "graph_trace" => graph_trace(&args).await, + "graph_link_impact" => graph_link_impact(&args).await, "journal_tail" => journal_tail(&args).await, "journal_new" => journal_new(agent, &args).await, "journal_update" => journal_update(agent, &args).await, @@ -686,3 +688,114 @@ async fn graph_normalize_strengths(args: &serde_json::Value) -> Result { Ok(out) } + +async fn graph_link_impact(args: &serde_json::Value) -> Result { + let source = get_str(args, "source")?; + let target = get_str(args, "target")?; + + let arc = cached_store().await?; + let store = arc.lock().await; + let source = store.resolve_key(source).map_err(|e| anyhow::anyhow!("{}", e))?; + let target = store.resolve_key(target).map_err(|e| anyhow::anyhow!("{}", e))?; + let g = store.build_graph(); + let impact = g.link_impact(&source, &target); + + use std::fmt::Write; + let mut out = String::new(); + writeln!(out, "Link impact: {} → {}", source, target).ok(); + writeln!(out, " Source degree: {} Target degree: {}", impact.source_deg, impact.target_deg).ok(); + writeln!(out, " Hub link: {} Same community: {}", impact.is_hub_link, impact.same_community).ok(); + writeln!(out, " ΔCC source: {:+.4} ΔCC target: {:+.4}", impact.delta_cc_source, impact.delta_cc_target).ok(); + writeln!(out, " ΔGini: {:+.6}", impact.delta_gini).ok(); + writeln!(out, " Assessment: {}", impact.assessment).ok(); + Ok(out) +} + +async fn graph_trace(args: &serde_json::Value) -> Result { + let key = get_str(args, "key")?; + + let arc = cached_store().await?; + let store = arc.lock().await; + let resolved = store.resolve_key(key).map_err(|e| anyhow::anyhow!("{}", e))?; + let g = store.build_graph(); + + let node = store.nodes.get(&resolved) + .ok_or_else(|| anyhow::anyhow!("Node not found: {}", resolved))?; + + use std::fmt::Write; + let mut out = String::new(); + + writeln!(out, "=== {} ===", resolved).ok(); + writeln!(out, "Type: {:?} Weight: {:.2}", node.node_type, node.weight).ok(); + if !node.source_ref.is_empty() { + writeln!(out, "Source: {}", node.source_ref).ok(); + } + + let preview = crate::util::truncate(&node.content, 200, "..."); + writeln!(out, "\n{}\n", preview).ok(); + + // Walk neighbors, grouped by node type + let neighbors = g.neighbors(&resolved); + let mut episodic_session = Vec::new(); + let mut episodic_daily = Vec::new(); + let mut episodic_weekly = Vec::new(); + let mut semantic = Vec::new(); + + for (n, strength) in &neighbors { + if let Some(nnode) = store.nodes.get(n.as_str()) { + let entry = (n.as_str(), *strength, nnode); + match nnode.node_type { + crate::store::NodeType::EpisodicSession => episodic_session.push(entry), + crate::store::NodeType::EpisodicDaily => episodic_daily.push(entry), + crate::store::NodeType::EpisodicWeekly + | crate::store::NodeType::EpisodicMonthly => episodic_weekly.push(entry), + crate::store::NodeType::Semantic => semantic.push(entry), + } + } + } + + if !episodic_weekly.is_empty() { + writeln!(out, "Weekly digests:").ok(); + for (k, s, n) in &episodic_weekly { + let preview = crate::util::first_n_chars(n.content.lines().next().unwrap_or(""), 80); + writeln!(out, " [{:.2}] {} — {}", s, k, preview).ok(); + } + } + + if !episodic_daily.is_empty() { + writeln!(out, "Daily digests:").ok(); + for (k, s, n) in &episodic_daily { + let preview = crate::util::first_n_chars(n.content.lines().next().unwrap_or(""), 80); + writeln!(out, " [{:.2}] {} — {}", s, k, preview).ok(); + } + } + + if !episodic_session.is_empty() { + writeln!(out, "Session entries:").ok(); + for (k, s, n) in &episodic_session { + let preview = crate::util::first_n_chars( + n.content.lines() + .find(|l| !l.is_empty() && !l.starts_with("