From aade8a9cce5799c0ca3e0d59af065eaf539671a2 Mon Sep 17 00:00:00 2001 From: ProofOfConcept Date: Fri, 10 Apr 2026 13:44:41 -0400 Subject: [PATCH 01/27] Add per-agent run stats (messages, tool calls by type) compute_run_stats() walks the conversation AST after each agent completes, counting messages and tool calls by tool name. Stats are returned from save_agent_log(), stored on UnconsciousAgent, and displayed in the agent list UI. Co-Authored-By: Proof of Concept --- src/mind/unconscious.rs | 71 +++++++++++++++++++++++++++++++--------- src/user/subconscious.rs | 4 +++ 2 files changed, 59 insertions(+), 16 deletions(-) diff --git a/src/mind/unconscious.rs b/src/mind/unconscious.rs index a7e9e0a..a137fe0 100644 --- a/src/mind/unconscious.rs +++ b/src/mind/unconscious.rs @@ -34,11 +34,12 @@ struct UnconsciousAgent { name: String, enabled: bool, auto: AutoAgent, - handle: Option)>>, + handle: Option, RunStats)>>, /// Shared agent handle — UI locks to read context live. pub agent: Option>, last_run: Option, runs: usize, + last_stats: Option, } impl UnconsciousAgent { @@ -60,6 +61,7 @@ pub struct UnconsciousSnapshot { pub runs: usize, pub last_run_secs_ago: Option, pub agent: Option>, + pub last_stats: Option, } pub struct Unconscious { @@ -105,6 +107,7 @@ impl Unconscious { agent: None, last_run: None, runs: 0, + last_stats: None, }); } agents.sort_by(|a, b| a.name.cmp(&b.name)); @@ -144,6 +147,7 @@ impl Unconscious { runs: a.runs, last_run_secs_ago: a.last_run.map(|t| t.elapsed().as_secs_f64()), agent: a.agent.clone(), + last_stats: a.last_stats.clone(), }).collect() } @@ -173,8 +177,9 @@ impl Unconscious { agent.runs += 1; // Get the AutoAgent back from the finished task match handle.now_or_never() { - Some(Ok((auto_back, result))) => { + Some(Ok((auto_back, result, stats))) => { agent.auto = auto_back; + agent.last_stats = Some(stats); match result { Ok(_) => dbglog!("[unconscious] {} completed (run {})", agent.name, agent.runs), @@ -289,30 +294,64 @@ impl Unconscious { self.agents[idx].handle = Some(tokio::spawn(async move { let result = auto.run_shared(&agent).await; - save_agent_log(&auto.name, &agent).await; + let stats = save_agent_log(&auto.name, &agent).await; auto.steps = orig_steps; - (auto, result) + (auto, result, stats) })); } } -pub async fn save_agent_log(name: &str, agent: &std::sync::Arc) { +pub async fn save_agent_log(name: &str, agent: &std::sync::Arc) -> RunStats { let dir = dirs::home_dir().unwrap_or_default() .join(format!(".consciousness/logs/{}", name)); - if std::fs::create_dir_all(&dir).is_err() { return; } - let ts = chrono::Utc::now().format("%Y%m%d-%H%M%S"); - let path = dir.join(format!("{}.json", ts)); - let sections: serde_json::Value = { - let ctx = agent.context.lock().await; - serde_json::json!({ + let ctx = agent.context.lock().await; + let stats = compute_run_stats(ctx.conversation()); + if std::fs::create_dir_all(&dir).is_ok() { + let ts = chrono::Utc::now().format("%Y%m%d-%H%M%S"); + let path = dir.join(format!("{}.json", ts)); + let sections = serde_json::json!({ "system": ctx.system(), "identity": ctx.identity(), "journal": ctx.journal(), "conversation": ctx.conversation(), - }) - }; - if let Ok(json) = serde_json::to_string_pretty(§ions) { - let _ = std::fs::write(&path, json); - dbglog!("[unconscious] saved log to {}", path.display()); + "stats": stats, + }); + if let Ok(json) = serde_json::to_string_pretty(§ions) { + let _ = std::fs::write(&path, json); + } } + dbglog!("[unconscious] {} — {} msgs, {} tool calls", + name, stats.messages, stats.tool_calls); + stats +} + +#[derive(Clone, serde::Serialize)] +pub struct RunStats { + pub messages: usize, + pub tool_calls: usize, + pub tool_calls_by_type: HashMap, +} + +fn compute_run_stats(conversation: &[crate::agent::context::AstNode]) -> RunStats { + use crate::agent::context::{AstNode, NodeBody}; + + let mut messages = 0usize; + let mut tool_calls = 0usize; + let mut by_type: HashMap = HashMap::new(); + + for node in conversation { + if let AstNode::Branch { children, .. } = node { + messages += 1; + for child in children { + if let AstNode::Leaf(leaf) = child { + if let NodeBody::ToolCall { name, .. } = leaf.body() { + tool_calls += 1; + *by_type.entry(name.to_string()).or_default() += 1; + } + } + } + } + } + + RunStats { messages, tool_calls, tool_calls_by_type: by_type } } diff --git a/src/user/subconscious.rs b/src/user/subconscious.rs index d22742a..8c76b1d 100644 --- a/src/user/subconscious.rs +++ b/src/user/subconscious.rs @@ -250,6 +250,10 @@ impl SubconsciousScreen { format!("run {}", snap.runs + 1) } else if !snap.enabled { "off".to_string() + } else if let Some(ref stats) = snap.last_stats { + format!("×{} {} {}msg {}tc", + snap.runs, ago, + stats.messages, stats.tool_calls) } else { format!("×{} {}", snap.runs, ago) }; From be6ac762f63680061af85c20fcaecaa8bfc2efd6 Mon Sep 17 00:00:00 2001 From: ProofOfConcept Date: Fri, 10 Apr 2026 15:22:18 -0400 Subject: [PATCH 02/27] memory_render: use cached store instead of loading from disk each call MemoryNode::load() was calling Store::load() on every render, hitting disk each time. Use cached_store() + MemoryNode::from_store() so repeated renders (4 per agent template) share the cached store. Co-Authored-By: Proof of Concept --- src/agent/tools/memory.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/agent/tools/memory.rs b/src/agent/tools/memory.rs index 5c34a1b..dc6f2fa 100644 --- a/src/agent/tools/memory.rs +++ b/src/agent/tools/memory.rs @@ -38,7 +38,7 @@ pub fn memory_tools() -> [super::Tool; 11] { [ Tool { name: "memory_render", description: "Read a memory node's content and links.", parameters_json: r#"{"type":"object","properties":{"key":{"type":"string","description":"Node key"}},"required":["key"]}"#, - handler: Arc::new(|_a, v| Box::pin(async move { render(&v) })) }, + handler: Arc::new(|_a, v| Box::pin(async move { render(&v).await })) }, Tool { name: "memory_write", description: "Create or update a memory node.", parameters_json: r#"{"type":"object","properties":{"key":{"type":"string","description":"Node key"},"content":{"type":"string","description":"Full content (markdown)"}},"required":["key","content"]}"#, handler: Arc::new(|a, v| Box::pin(async move { write(&a, &v).await })) }, @@ -89,9 +89,11 @@ pub fn journal_tools() -> [super::Tool; 3] { // ── Memory tools ─────────────────────────────────────────────── -fn render(args: &serde_json::Value) -> Result { +async fn render(args: &serde_json::Value) -> Result { let key = get_str(args, "key")?; - Ok(MemoryNode::load(key) + let arc = cached_store().await?; + let store = arc.lock().await; + Ok(MemoryNode::from_store(&store, key) .ok_or_else(|| anyhow::anyhow!("node not found: {}", key))? .render()) } From 2587303e9875eabd0a9fe684c286d3b548c18b28 Mon Sep 17 00:00:00 2001 From: ProofOfConcept Date: Fri, 10 Apr 2026 15:12:53 -0400 Subject: [PATCH 03/27] Add {{tool:}} placeholder for agent templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent templates can now inline tool call results with {{tool: tool_name args}}. Dispatches to the same store operations the tools use, but runs synchronously during prompt resolution. Supports memory_render, memory_query, memory_search, memory_links, and journal_tail. This replaces the need for special-purpose placeholders — {{pairs}}, {{rename}}, etc. can be expressed as queries through {{tool: memory_query {"query": "..."}}} instead. Co-Authored-By: Proof of Concept --- src/subconscious/defs.rs | 44 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/subconscious/defs.rs b/src/subconscious/defs.rs index 7039c6a..983b420 100644 --- a/src/subconscious/defs.rs +++ b/src/subconscious/defs.rs @@ -561,6 +561,12 @@ fn resolve( Some(Resolved { text, keys }) } + // tool:NAME ARGS — run a tool call and include its output + _ if name.starts_with("tool:") => { + let spec = name[5..].trim(); + resolve_tool(spec, store, graph) + } + // bash:COMMAND — run a shell command and include its stdout _ if name.starts_with("bash:") => { let cmd = &name[5..]; @@ -721,6 +727,44 @@ fn resolve_memory_ratio() -> String { pct, keys.len(), memory_bytes / 1024, transcript_size / 1024) } +/// Resolve a {{tool: name {args}}} placeholder by calling the tool +/// handler from the registry. Uses block_in_place to bridge sync→async. +fn resolve_tool(spec: &str, _store: &Store, _graph: &Graph) -> Option { + // Parse "tool_name {json args}" or "tool_name arg" + let (name, args) = match spec.find('{') { + Some(i) => { + let name = spec[..i].trim(); + let args: serde_json::Value = serde_json::from_str(&spec[i..]).ok()?; + (name, args) + } + None => { + let mut parts = spec.splitn(2, char::is_whitespace); + let name = parts.next()?; + match parts.next() { + Some(arg) => (name, serde_json::json!({"key": arg})), + None => (name, serde_json::json!({})), + } + } + }; + + let tools = crate::agent::tools::tools(); + let tool = tools.iter().find(|t| t.name == name)?; + + let result = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on( + (tool.handler)(None, args.clone()) + ) + }); + + match result { + Ok(text) => Some(Resolved { text, keys: vec![] }), + Err(e) => { + eprintln!("[defs] {{{{tool: {}}}}} failed: {}", name, e); + Some(Resolved { text: format!("(tool error: {})", e), keys: vec![] }) + } + } +} + /// Resolve all {{placeholder}} patterns in a prompt template. /// Returns the resolved text and all node keys collected from placeholders. pub fn resolve_placeholders( From 1a03264233eea0e48360575828c66a3fbdb5ef79 Mon Sep 17 00:00:00 2001 From: ProofOfConcept Date: Fri, 10 Apr 2026 15:17:32 -0400 Subject: [PATCH 04/27] Convert {{node:KEY}} to {{tool: memory_render KEY}} in all agents Use the new {{tool:}} placeholder mechanism instead of the special-purpose {{node:}} resolver. All 17 unconscious agent files converted. Co-Authored-By: Proof of Concept --- src/subconscious/agents/calibrate.agent | 8 ++++---- src/subconscious/agents/challenger.agent | 8 ++++---- src/subconscious/agents/compare.agent | 8 ++++---- src/subconscious/agents/connector.agent | 8 ++++---- src/subconscious/agents/digest.agent | 8 ++++---- src/subconscious/agents/distill.agent | 8 ++++---- src/subconscious/agents/evaluate.agent | 8 ++++---- src/subconscious/agents/extractor.agent | 8 ++++---- src/subconscious/agents/health.agent | 8 ++++---- src/subconscious/agents/linker.agent | 8 ++++---- src/subconscious/agents/naming.agent | 8 ++++---- src/subconscious/agents/organize.agent | 8 ++++---- src/subconscious/agents/rename.agent | 8 ++++---- src/subconscious/agents/replay.agent | 8 ++++---- src/subconscious/agents/separator.agent | 8 ++++---- src/subconscious/agents/split.agent | 8 ++++---- src/subconscious/agents/transfer.agent | 8 ++++---- 17 files changed, 68 insertions(+), 68 deletions(-) diff --git a/src/subconscious/agents/calibrate.agent b/src/subconscious/agents/calibrate.agent index 5b73600..d964789 100644 --- a/src/subconscious/agents/calibrate.agent +++ b/src/subconscious/agents/calibrate.agent @@ -2,13 +2,13 @@ # Calibrate Agent — Link Strength Assessment -{{node:core-personality}} +{{tool: memory_render core-personality}} -{{node:memory-instructions-core}} +{{tool: memory_render memory-instructions-core}} -{{node:memory-instructions-core-subconscious}} +{{tool: memory_render memory-instructions-core-subconscious}} -{{node:subconscious-notes-{agent_name}}} +{{tool: memory_render subconscious-notes-{agent_name}}} You calibrate link strengths in the knowledge graph. You receive a seed node with all its neighbors — your job is to read the neighbors diff --git a/src/subconscious/agents/challenger.agent b/src/subconscious/agents/challenger.agent index 21ba151..737b9d2 100644 --- a/src/subconscious/agents/challenger.agent +++ b/src/subconscious/agents/challenger.agent @@ -2,13 +2,13 @@ # Challenger Agent — Adversarial Truth-Testing -{{node:core-personality}} +{{tool: memory_render core-personality}} -{{node:memory-instructions-core}} +{{tool: memory_render memory-instructions-core}} -{{node:memory-instructions-core-subconscious}} +{{tool: memory_render memory-instructions-core-subconscious}} -{{node:subconscious-notes-{agent_name}}} +{{tool: memory_render subconscious-notes-{agent_name}}} You are a knowledge challenger agent. Your job is to stress-test existing knowledge nodes by finding counterexamples, edge cases, diff --git a/src/subconscious/agents/compare.agent b/src/subconscious/agents/compare.agent index 48beb60..4e095a9 100644 --- a/src/subconscious/agents/compare.agent +++ b/src/subconscious/agents/compare.agent @@ -3,13 +3,13 @@ # Compare Agent — Pairwise Action Quality Comparison -{{node:core-personality}} +{{tool: memory_render core-personality}} -{{node:memory-instructions-core}} +{{tool: memory_render memory-instructions-core}} -{{node:memory-instructions-core-subconscious}} +{{tool: memory_render memory-instructions-core-subconscious}} -{{node:subconscious-notes-{agent_name}}} +{{tool: memory_render subconscious-notes-{agent_name}}} You compare two memory graph actions and decide which one was better. diff --git a/src/subconscious/agents/connector.agent b/src/subconscious/agents/connector.agent index 7757c52..ba48189 100644 --- a/src/subconscious/agents/connector.agent +++ b/src/subconscious/agents/connector.agent @@ -2,13 +2,13 @@ # Connector Agent — Cross-Domain Insight -{{node:core-personality}} +{{tool: memory_render core-personality}} -{{node:memory-instructions-core}} +{{tool: memory_render memory-instructions-core}} -{{node:memory-instructions-core-subconscious}} +{{tool: memory_render memory-instructions-core-subconscious}} -{{node:subconscious-notes-{agent_name}}} +{{tool: memory_render subconscious-notes-{agent_name}}} You are a connector agent. Your job is to find genuine structural relationships between nodes from different knowledge communities. diff --git a/src/subconscious/agents/digest.agent b/src/subconscious/agents/digest.agent index b4b3bf0..a70fe89 100644 --- a/src/subconscious/agents/digest.agent +++ b/src/subconscious/agents/digest.agent @@ -3,13 +3,13 @@ # {{LEVEL}} Episodic Digest -{{node:core-personality}} +{{tool: memory_render core-personality}} -{{node:memory-instructions-core}} +{{tool: memory_render memory-instructions-core}} -{{node:memory-instructions-core-subconscious}} +{{tool: memory_render memory-instructions-core-subconscious}} -{{node:subconscious-notes-{agent_name}}} +{{tool: memory_render subconscious-notes-{agent_name}}} You are generating a {{LEVEL}} episodic digest for {assistant_name}. {{PERIOD}}: {{LABEL}} diff --git a/src/subconscious/agents/distill.agent b/src/subconscious/agents/distill.agent index da0fbdf..c310f1b 100644 --- a/src/subconscious/agents/distill.agent +++ b/src/subconscious/agents/distill.agent @@ -1,16 +1,16 @@ {"agent":"distill","query":"all | type:semantic | sort:degree | limit:1","schedule":"daily"} -{{node:core-personality}} +{{tool: memory_render core-personality}} -{{node:memory-instructions-core}} +{{tool: memory_render memory-instructions-core}} ## Here's your seed node, and its siblings: {{neighborhood}} -{{node:memory-instructions-core-subconscious}} +{{tool: memory_render memory-instructions-core-subconscious}} -{{node:subconscious-notes-{agent_name}}} +{{tool: memory_render subconscious-notes-{agent_name}}} ## Your task diff --git a/src/subconscious/agents/evaluate.agent b/src/subconscious/agents/evaluate.agent index 93f825d..112a9b4 100644 --- a/src/subconscious/agents/evaluate.agent +++ b/src/subconscious/agents/evaluate.agent @@ -5,13 +5,13 @@ You review recent consolidation agent outputs and assess their quality. Your assessment feeds back into which agent types get run more often. -{{node:core-personality}} +{{tool: memory_render core-personality}} -{{node:memory-instructions-core}} +{{tool: memory_render memory-instructions-core}} -{{node:memory-instructions-core-subconscious}} +{{tool: memory_render memory-instructions-core-subconscious}} -{{node:subconscious-notes-{agent_name}}} +{{tool: memory_render subconscious-notes-{agent_name}}} ## How to work diff --git a/src/subconscious/agents/extractor.agent b/src/subconscious/agents/extractor.agent index 364517c..ef31e82 100644 --- a/src/subconscious/agents/extractor.agent +++ b/src/subconscious/agents/extractor.agent @@ -1,13 +1,13 @@ {"agent": "extractor", "query": "all | not-visited:extractor,7d | sort:priority | limit:3 | spread | not-visited:extractor,7d | limit:20", "schedule": "daily"} # Extractor Agent — Knowledge Organizer -{{node:core-personality}} +{{tool: memory_render core-personality}} -{{node:memory-instructions-core}} +{{tool: memory_render memory-instructions-core}} -{{node:memory-instructions-core-subconscious}} +{{tool: memory_render memory-instructions-core-subconscious}} -{{node:subconscious-notes-{agent_name}}} +{{tool: memory_render subconscious-notes-{agent_name}}} You are a knowledge organization agent. You look at a neighborhood of related nodes and make it better: consolidate redundancies, file diff --git a/src/subconscious/agents/health.agent b/src/subconscious/agents/health.agent index a359ff6..a968ffb 100644 --- a/src/subconscious/agents/health.agent +++ b/src/subconscious/agents/health.agent @@ -3,13 +3,13 @@ # Health Agent — Synaptic Homeostasis -{{node:core-personality}} +{{tool: memory_render core-personality}} -{{node:memory-instructions-core}} +{{tool: memory_render memory-instructions-core}} -{{node:memory-instructions-core-subconscious}} +{{tool: memory_render memory-instructions-core-subconscious}} -{{node:subconscious-notes-{agent_name}}} +{{tool: memory_render subconscious-notes-{agent_name}}} You are a memory health monitoring agent implementing synaptic homeostasis. diff --git a/src/subconscious/agents/linker.agent b/src/subconscious/agents/linker.agent index 60f82f0..caba70e 100644 --- a/src/subconscious/agents/linker.agent +++ b/src/subconscious/agents/linker.agent @@ -2,17 +2,17 @@ # Linker Agent — Relational Binding -{{node:core-personality}} +{{tool: memory_render core-personality}} -{{node:memory-instructions-core}} +{{tool: memory_render memory-instructions-core}} ## Seed nodes {{nodes}} -{{node:memory-instructions-core-subconscious}} +{{tool: memory_render memory-instructions-core-subconscious}} -{{node:subconscious-notes-{agent_name}}} +{{tool: memory_render subconscious-notes-{agent_name}}} ## Your task diff --git a/src/subconscious/agents/naming.agent b/src/subconscious/agents/naming.agent index 6b12b80..1577a32 100644 --- a/src/subconscious/agents/naming.agent +++ b/src/subconscious/agents/naming.agent @@ -2,13 +2,13 @@ # Naming Agent — Node Key Resolution -{{node:core-personality}} +{{tool: memory_render core-personality}} -{{node:memory-instructions-core}} +{{tool: memory_render memory-instructions-core}} -{{node:memory-instructions-core-subconscious}} +{{tool: memory_render memory-instructions-core-subconscious}} -{{node:subconscious-notes-{agent_name}}} +{{tool: memory_render subconscious-notes-{agent_name}}} You are given a proposed new node (key + content) and a list of existing nodes that might overlap with it. Decide what to do: diff --git a/src/subconscious/agents/organize.agent b/src/subconscious/agents/organize.agent index 36aa678..e524948 100644 --- a/src/subconscious/agents/organize.agent +++ b/src/subconscious/agents/organize.agent @@ -1,6 +1,6 @@ {"agent":"organize","query":"all | not-visited:organize,86400 | sort:degree*0.5+isolation*0.3+recency(organize)*0.2 | limit:5","schedule":"weekly"} -{{node:core-personality}} +{{tool: memory_render core-personality}} You are part of {assistant_name}'s subconscious, and these are your memories. @@ -24,11 +24,11 @@ subconcepts. Calibrate node weights while you're looking at them. -{{node:memory-instructions-core}} +{{tool: memory_render memory-instructions-core}} -{{node:memory-instructions-core-subconscious}} +{{tool: memory_render memory-instructions-core-subconscious}} -{{node:subconscious-notes-{agent_name}}} +{{tool: memory_render subconscious-notes-{agent_name}}} ## Here's your seed node, and its siblings: diff --git a/src/subconscious/agents/rename.agent b/src/subconscious/agents/rename.agent index 5477282..be34f91 100644 --- a/src/subconscious/agents/rename.agent +++ b/src/subconscious/agents/rename.agent @@ -3,13 +3,13 @@ # Rename Agent — Semantic Key Generation -{{node:core-personality}} +{{tool: memory_render core-personality}} -{{node:memory-instructions-core}} +{{tool: memory_render memory-instructions-core}} -{{node:memory-instructions-core-subconscious}} +{{tool: memory_render memory-instructions-core-subconscious}} -{{node:subconscious-notes-{agent_name}}} +{{tool: memory_render subconscious-notes-{agent_name}}} You are a memory maintenance agent that gives nodes better names. diff --git a/src/subconscious/agents/replay.agent b/src/subconscious/agents/replay.agent index 9595ee4..2ff4a0b 100644 --- a/src/subconscious/agents/replay.agent +++ b/src/subconscious/agents/replay.agent @@ -2,13 +2,13 @@ # Replay Agent — Hippocampal Replay + Schema Assimilation -{{node:core-personality}} +{{tool: memory_render core-personality}} -{{node:memory-instructions-core}} +{{tool: memory_render memory-instructions-core}} -{{node:memory-instructions-core-subconscious}} +{{tool: memory_render memory-instructions-core-subconscious}} -{{node:subconscious-notes-{agent_name}}} +{{tool: memory_render subconscious-notes-{agent_name}}} You are a memory consolidation agent performing hippocampal replay. diff --git a/src/subconscious/agents/separator.agent b/src/subconscious/agents/separator.agent index 72eda56..f82edb0 100644 --- a/src/subconscious/agents/separator.agent +++ b/src/subconscious/agents/separator.agent @@ -3,13 +3,13 @@ # Separator Agent — Pattern Separation (Dentate Gyrus) -{{node:core-personality}} +{{tool: memory_render core-personality}} -{{node:memory-instructions-core}} +{{tool: memory_render memory-instructions-core}} -{{node:memory-instructions-core-subconscious}} +{{tool: memory_render memory-instructions-core-subconscious}} -{{node:subconscious-notes-{agent_name}}} +{{tool: memory_render subconscious-notes-{agent_name}}} You are a memory consolidation agent performing pattern separation. diff --git a/src/subconscious/agents/split.agent b/src/subconscious/agents/split.agent index b3bdfaa..4247548 100644 --- a/src/subconscious/agents/split.agent +++ b/src/subconscious/agents/split.agent @@ -1,16 +1,16 @@ {"agent": "split", "query": "all | type:semantic | !key:_* | sort:content-len | limit:1", "schedule": "daily"} -{{node:core-personality}} +{{tool: memory_render core-personality}} -{{node:memory-instructions-core}} +{{tool: memory_render memory-instructions-core}} ## Node to split {{seed}} -{{node:memory-instructions-core-subconscious}} +{{tool: memory_render memory-instructions-core-subconscious}} -{{node:subconscious-notes-{agent_name}}} +{{tool: memory_render subconscious-notes-{agent_name}}} ## Your task diff --git a/src/subconscious/agents/transfer.agent b/src/subconscious/agents/transfer.agent index 3c36684..5afe55e 100644 --- a/src/subconscious/agents/transfer.agent +++ b/src/subconscious/agents/transfer.agent @@ -1,13 +1,13 @@ {"agent": "transfer", "query": "all | type:episodic | sort:timestamp | limit:15", "schedule": "daily"} # Transfer Agent — Complementary Learning Systems -{{node:core-personality}} +{{tool: memory_render core-personality}} -{{node:memory-instructions-core}} +{{tool: memory_render memory-instructions-core}} -{{node:memory-instructions-core-subconscious}} +{{tool: memory_render memory-instructions-core-subconscious}} -{{node:subconscious-notes-{agent_name}}} +{{tool: memory_render subconscious-notes-{agent_name}}} You are a memory consolidation agent performing CLS (complementary learning systems) transfer: moving knowledge from fast episodic storage to slow From fd722662da6d5c4fc4fed39475febc1b4faf7393 Mon Sep 17 00:00:00 2001 From: ProofOfConcept Date: Fri, 10 Apr 2026 15:25:57 -0400 Subject: [PATCH 05/27] Add graph_topology, graph_health, interference_pairs tools Convert {{topology}}, {{health}}, {{pairs}} placeholders to {{tool:}} calls. Made format_topology_header, format_health_section, format_pairs_section pub so tools can call them. Co-Authored-By: Proof of Concept --- src/agent/tools/memory.rs | 38 +++++++++++++++++++++++- src/subconscious/agents/challenger.agent | 2 +- src/subconscious/agents/connector.agent | 2 +- src/subconscious/agents/extractor.agent | 2 +- src/subconscious/agents/health.agent | 4 +-- src/subconscious/agents/replay.agent | 2 +- src/subconscious/agents/separator.agent | 4 +-- src/subconscious/agents/transfer.agent | 2 +- src/subconscious/prompts.rs | 6 ++-- 9 files changed, 49 insertions(+), 13 deletions(-) diff --git a/src/agent/tools/memory.rs b/src/agent/tools/memory.rs index dc6f2fa..efad978 100644 --- a/src/agent/tools/memory.rs +++ b/src/agent/tools/memory.rs @@ -33,7 +33,7 @@ async fn get_provenance(agent: &Option>) -> // ── Definitions ──────────────────────────────────────────────── -pub fn memory_tools() -> [super::Tool; 11] { +pub fn memory_tools() -> [super::Tool; 14] { use super::Tool; [ Tool { name: "memory_render", description: "Read a memory node's content and links.", @@ -69,6 +69,15 @@ pub fn memory_tools() -> [super::Tool; 11] { Tool { name: "memory_query", description: "Run a structured query against the memory graph.", parameters_json: r#"{"type":"object","properties":{"query":{"type":"string","description":"Query expression"}},"required":["query"]}"#, handler: Arc::new(|_a, v| Box::pin(async move { query(&v).await })) }, + Tool { name: "graph_topology", description: "Show graph topology stats (nodes, edges, clustering, hubs).", + parameters_json: r#"{"type":"object","properties":{}}"#, + handler: Arc::new(|_a, _v| Box::pin(async { graph_topology().await })) }, + Tool { name: "graph_health", description: "Show graph health report with maintenance recommendations.", + parameters_json: r#"{"type":"object","properties":{}}"#, + handler: Arc::new(|_a, _v| Box::pin(async { graph_health().await })) }, + Tool { name: "interference_pairs", description: "Find similar nodes that may interfere (duplicates or near-duplicates).", + parameters_json: r#"{"type":"object","properties":{"threshold":{"type":"number","description":"Similarity threshold (default 0.5)"},"limit":{"type":"integer","description":"Max pairs to return (default 10)"}}}"#, + handler: Arc::new(|_a, v| Box::pin(async move { interference_pairs(&v).await })) }, ] } @@ -317,3 +326,30 @@ async fn journal_update(agent: &Option>, arg let word_count = body.split_whitespace().count(); Ok(format!("Updated last entry (+{} words)", word_count)) } + +// ── Graph tools ─────────────────────────────────────────────── + +async fn graph_topology() -> Result { + let arc = cached_store().await?; + let store = arc.lock().await; + let graph = store.build_graph(); + Ok(crate::subconscious::prompts::format_topology_header(&graph)) +} + +async fn graph_health() -> Result { + let arc = cached_store().await?; + let store = arc.lock().await; + let graph = store.build_graph(); + Ok(crate::subconscious::prompts::format_health_section(&store, &graph)) +} + +async fn interference_pairs(args: &serde_json::Value) -> Result { + let threshold = args.get("threshold").and_then(|v| v.as_f64()).unwrap_or(0.5) as f32; + let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize; + let arc = cached_store().await?; + let store = arc.lock().await; + let graph = store.build_graph(); + let mut pairs = crate::neuro::detect_interference(&store, &graph, threshold); + pairs.truncate(limit); + Ok(crate::subconscious::prompts::format_pairs_section(&pairs, &store, &graph)) +} diff --git a/src/subconscious/agents/challenger.agent b/src/subconscious/agents/challenger.agent index 737b9d2..2257ada 100644 --- a/src/subconscious/agents/challenger.agent +++ b/src/subconscious/agents/challenger.agent @@ -46,7 +46,7 @@ For each target node, one of: - **Don't be contrarian for its own sake.** If a node is correct, say so and move on. -{{TOPOLOGY}} +{{tool: graph_topology}} {{SIBLINGS}} diff --git a/src/subconscious/agents/connector.agent b/src/subconscious/agents/connector.agent index ba48189..78a8009 100644 --- a/src/subconscious/agents/connector.agent +++ b/src/subconscious/agents/connector.agent @@ -79,7 +79,7 @@ Set with: `poc-memory graph link-set ` If you see default-strength links (0.10 or 0.30) in the neighborhoods you're exploring and you have context to judge them, reweight those too. -{{TOPOLOGY}} +{{tool: graph_topology}} ## Nodes to examine for cross-community connections diff --git a/src/subconscious/agents/extractor.agent b/src/subconscious/agents/extractor.agent index ef31e82..07ff0ec 100644 --- a/src/subconscious/agents/extractor.agent +++ b/src/subconscious/agents/extractor.agent @@ -44,7 +44,7 @@ pattern you've found. - **Preserve diversity.** Multiple perspectives on the same concept are valuable. Only delete actual duplicates. -{{TOPOLOGY}} +{{tool: graph_topology}} ## Neighborhood nodes diff --git a/src/subconscious/agents/health.agent b/src/subconscious/agents/health.agent index a968ffb..67d3c2d 100644 --- a/src/subconscious/agents/health.agent +++ b/src/subconscious/agents/health.agent @@ -36,8 +36,8 @@ overall structure. - Most output should be observations about system health. Act on structural problems you find — link orphans, refine outdated nodes. -{{topology}} +{{tool: graph_topology}} ## Current health data -{{health}} +{{tool: graph_health}} diff --git a/src/subconscious/agents/replay.agent b/src/subconscious/agents/replay.agent index 2ff4a0b..5620d65 100644 --- a/src/subconscious/agents/replay.agent +++ b/src/subconscious/agents/replay.agent @@ -40,7 +40,7 @@ clusters and determine how it fits. - **Trust the decay.** Unimportant nodes don't need pruning — just don't link them. -{{TOPOLOGY}} +{{tool: graph_topology}} ## Nodes to review diff --git a/src/subconscious/agents/separator.agent b/src/subconscious/agents/separator.agent index f82edb0..51b6068 100644 --- a/src/subconscious/agents/separator.agent +++ b/src/subconscious/agents/separator.agent @@ -35,8 +35,8 @@ overlapping inputs and orthogonalize them. - **Session summaries are the biggest source of interference.** - **Look for the supersession pattern.** -{{topology}} +{{tool: graph_topology}} ## Interfering pairs to review -{{pairs}} +{{tool: interference_pairs}} diff --git a/src/subconscious/agents/transfer.agent b/src/subconscious/agents/transfer.agent index 5afe55e..c607649 100644 --- a/src/subconscious/agents/transfer.agent +++ b/src/subconscious/agents/transfer.agent @@ -45,7 +45,7 @@ entries, and extract those patterns into semantic nodes. - **The best extractions change how you think, not just what you know.** Extract the conceptual version, not just the factual one. -{{TOPOLOGY}} +{{tool: graph_topology}} {{SIBLINGS}} diff --git a/src/subconscious/prompts.rs b/src/subconscious/prompts.rs index b2a3fbf..132ef3f 100644 --- a/src/subconscious/prompts.rs +++ b/src/subconscious/prompts.rs @@ -23,7 +23,7 @@ pub struct AgentBatch { pub node_keys: Vec, } -pub(super) fn format_topology_header(graph: &Graph) -> String { +pub fn format_topology_header(graph: &Graph) -> String { let sigma = graph.small_world_sigma(); let alpha = graph.degree_power_law_exponent(); let gini = graph.degree_gini(); @@ -139,7 +139,7 @@ pub(super) fn format_nodes_section(store: &Store, items: &[ReplayItem], graph: & out } -pub(super) fn format_health_section(store: &Store, graph: &Graph) -> String { +pub fn format_health_section(store: &Store, graph: &Graph) -> String { use crate::graph; let health = graph::health_report(graph, store); @@ -195,7 +195,7 @@ pub(super) fn format_health_section(store: &Store, graph: &Graph) -> String { out } -pub(super) fn format_pairs_section( +pub fn format_pairs_section( pairs: &[(String, String, f32)], store: &Store, graph: &Graph, From 92ef9b5215af7a6b194a82f72d20ec9b55075782 Mon Sep 17 00:00:00 2001 From: ProofOfConcept Date: Fri, 10 Apr 2026 15:32:30 -0400 Subject: [PATCH 06/27] Delete separator agent and interference_pairs tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interference detection via O(n²) text cosine similarity is redundant — the graph structure should surface similar nodes through link topology, shared neighbors, and community detection. The other agents (linker, extractor) already maintain these relationships. Co-Authored-By: Proof of Concept --- src/agent/tools/memory.rs | 15 +-------- src/subconscious/agents/separator.agent | 42 ------------------------- src/subconscious/prompts.rs | 2 +- 3 files changed, 2 insertions(+), 57 deletions(-) delete mode 100644 src/subconscious/agents/separator.agent diff --git a/src/agent/tools/memory.rs b/src/agent/tools/memory.rs index efad978..f64a4d3 100644 --- a/src/agent/tools/memory.rs +++ b/src/agent/tools/memory.rs @@ -33,7 +33,7 @@ async fn get_provenance(agent: &Option>) -> // ── Definitions ──────────────────────────────────────────────── -pub fn memory_tools() -> [super::Tool; 14] { +pub fn memory_tools() -> [super::Tool; 13] { use super::Tool; [ Tool { name: "memory_render", description: "Read a memory node's content and links.", @@ -75,9 +75,6 @@ pub fn memory_tools() -> [super::Tool; 14] { Tool { name: "graph_health", description: "Show graph health report with maintenance recommendations.", parameters_json: r#"{"type":"object","properties":{}}"#, handler: Arc::new(|_a, _v| Box::pin(async { graph_health().await })) }, - Tool { name: "interference_pairs", description: "Find similar nodes that may interfere (duplicates or near-duplicates).", - parameters_json: r#"{"type":"object","properties":{"threshold":{"type":"number","description":"Similarity threshold (default 0.5)"},"limit":{"type":"integer","description":"Max pairs to return (default 10)"}}}"#, - handler: Arc::new(|_a, v| Box::pin(async move { interference_pairs(&v).await })) }, ] } @@ -343,13 +340,3 @@ async fn graph_health() -> Result { Ok(crate::subconscious::prompts::format_health_section(&store, &graph)) } -async fn interference_pairs(args: &serde_json::Value) -> Result { - let threshold = args.get("threshold").and_then(|v| v.as_f64()).unwrap_or(0.5) as f32; - let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize; - let arc = cached_store().await?; - let store = arc.lock().await; - let graph = store.build_graph(); - let mut pairs = crate::neuro::detect_interference(&store, &graph, threshold); - pairs.truncate(limit); - Ok(crate::subconscious::prompts::format_pairs_section(&pairs, &store, &graph)) -} diff --git a/src/subconscious/agents/separator.agent b/src/subconscious/agents/separator.agent deleted file mode 100644 index 51b6068..0000000 --- a/src/subconscious/agents/separator.agent +++ /dev/null @@ -1,42 +0,0 @@ -{"agent": "separator", "query": "", "schedule": "daily"} - -# Separator Agent — Pattern Separation (Dentate Gyrus) - - -{{tool: memory_render core-personality}} - -{{tool: memory_render memory-instructions-core}} - -{{tool: memory_render memory-instructions-core-subconscious}} - -{{tool: memory_render subconscious-notes-{agent_name}}} - -You are a memory consolidation agent performing pattern separation. - -## What you're doing - -When two memories are similar but semantically distinct, actively make -their representations MORE different to reduce interference. Take -overlapping inputs and orthogonalize them. - -## Types of interference - -1. **Genuine duplicates**: Merge them. -2. **Near-duplicates with important differences**: Sharpen the distinction, - add distinguishing links. -3. **Surface similarity, deep difference**: Categorize differently. -4. **Supersession**: Link with supersession note, let older decay. - -## Guidelines - -- **Read both nodes carefully before deciding.** -- **Merge is a strong action.** When in doubt, differentiate instead. -- **The goal is retrieval precision.** -- **Session summaries are the biggest source of interference.** -- **Look for the supersession pattern.** - -{{tool: graph_topology}} - -## Interfering pairs to review - -{{tool: interference_pairs}} diff --git a/src/subconscious/prompts.rs b/src/subconscious/prompts.rs index 132ef3f..854ac19 100644 --- a/src/subconscious/prompts.rs +++ b/src/subconscious/prompts.rs @@ -195,7 +195,7 @@ pub fn format_health_section(store: &Store, graph: &Graph) -> String { out } -pub fn format_pairs_section( +pub(super) fn format_pairs_section( pairs: &[(String, String, f32)], store: &Store, graph: &Graph, From 96e573f2e5ca3957a2e8ab110f8a438abfe43d54 Mon Sep 17 00:00:00 2001 From: ProofOfConcept Date: Fri, 10 Apr 2026 15:44:10 -0400 Subject: [PATCH 07/27] Delete similarity module, rewrite module, and all text-similarity code Text cosine similarity was being used as a crutch for operations the graph structure should handle: interference detection, orphan linking, triangle closing, hub differentiation. These are all graph-structural operations that the agents (linker, extractor) handle with actual semantic understanding. Removed: similarity.rs (stemming + cosine), rewrite.rs (orphan linking, triangle closing, hub differentiation), detect_interference, and all CLI commands and consolidation steps that used them. -794 lines. Co-Authored-By: Proof of Concept --- src/cli/graph.rs | 119 +---------- src/hippocampus/mod.rs | 1 - src/hippocampus/neuro/mod.rs | 15 +- src/hippocampus/neuro/rewrite.rs | 348 ------------------------------- src/hippocampus/neuro/scoring.rs | 57 +---- src/hippocampus/similarity.rs | 140 ------------- src/lib.rs | 2 +- src/main.rs | 46 ---- src/subconscious/consolidate.rs | 13 +- src/subconscious/defs.rs | 13 -- src/subconscious/digest.rs | 6 - src/subconscious/prompts.rs | 45 +--- 12 files changed, 11 insertions(+), 794 deletions(-) delete mode 100644 src/hippocampus/neuro/rewrite.rs delete mode 100644 src/hippocampus/similarity.rs diff --git a/src/cli/graph.rs b/src/cli/graph.rs index 8712af4..0bc391e 100644 --- a/src/cli/graph.rs +++ b/src/cli/graph.rs @@ -1,11 +1,10 @@ // cli/graph.rs — graph subcommand handlers // // Extracted from main.rs. All graph-related CLI commands: -// link, link-add, link-impact, link-audit, link-orphans, -// triangle-close, cap-degree, normalize-strengths, differentiate, -// trace, spectral-*, organize, interference. +// link, link-add, link-impact, link-audit, cap-degree, +// normalize-strengths, trace, spectral-*, organize, communities. -use crate::{store, graph, neuro}; +use crate::{store, graph}; use crate::store::StoreView; pub fn cmd_graph() -> Result<(), String> { @@ -19,14 +18,6 @@ pub fn cmd_graph() -> Result<(), String> { Ok(()) } -pub fn cmd_link_orphans(min_deg: usize, links_per: usize, sim_thresh: f32) -> Result<(), String> { - let mut store = store::Store::load()?; - let (orphans, links) = neuro::link_orphans(&mut store, min_deg, links_per, sim_thresh); - println!("Linked {} orphans, added {} connections (min_degree={}, links_per={}, sim>{})", - orphans, links, min_deg, links_per, sim_thresh); - 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)?; @@ -162,16 +153,6 @@ pub fn cmd_link(key: &[String]) -> Result<(), String> { &format!("neighbors('{}') | select strength,clustering_coefficient", resolved)) } -pub fn cmd_triangle_close(min_degree: usize, sim_threshold: f32, max_per_hub: usize) -> Result<(), String> { - println!("Triangle closure: min_degree={}, sim_threshold={}, max_per_hub={}", - min_degree, sim_threshold, max_per_hub); - - let mut store = store::Store::load()?; - let (hubs, added) = neuro::triangle_close(&mut store, min_degree, sim_threshold, max_per_hub); - println!("\nProcessed {} hubs, added {} lateral links", hubs, added); - Ok(()) -} - pub fn cmd_link_add(source: &str, target: &str, reason: &[String]) -> Result<(), String> { super::check_dry_run(); let mut store = store::Store::load()?; @@ -179,11 +160,6 @@ pub fn cmd_link_add(source: &str, target: &str, reason: &[String]) -> Result<(), let target = store.resolve_key(target)?; let reason = reason.join(" "); - // Refine target to best-matching section - let source_content = store.nodes.get(&source) - .map(|n| n.content.as_str()).unwrap_or(""); - let target = neuro::refine_target(&store, source_content, &target); - match store.add_link(&source, &target, "manual") { Ok(strength) => { store.save()?; @@ -226,60 +202,6 @@ pub fn cmd_link_impact(source: &str, target: &str) -> Result<(), String> { Ok(()) } -pub fn cmd_differentiate(key_arg: Option<&str>, do_apply: bool) -> Result<(), String> { - let mut store = store::Store::load()?; - - if let Some(key) = key_arg { - let resolved = store.resolve_key(key)?; - let moves = neuro::differentiate_hub(&store, &resolved) - .ok_or_else(|| format!("'{}' is not a file-level hub with sections", resolved))?; - - // Group by target section for display - let mut by_section: std::collections::BTreeMap> = - std::collections::BTreeMap::new(); - for mv in &moves { - by_section.entry(mv.to_section.clone()).or_default().push(mv); - } - - println!("Hub '{}' — {} links to redistribute across {} sections\n", - resolved, moves.len(), by_section.len()); - - for (section, section_moves) in &by_section { - println!(" {} ({} links):", section, section_moves.len()); - for mv in section_moves.iter().take(5) { - println!(" [{:.3}] {} — {}", mv.similarity, - mv.neighbor_key, mv.neighbor_snippet); - } - if section_moves.len() > 5 { - println!(" ... and {} more", section_moves.len() - 5); - } - } - - if !do_apply { - println!("\nTo apply: poc-memory differentiate {} --apply", resolved); - return Ok(()); - } - - let (applied, skipped) = neuro::apply_differentiation(&mut store, &moves); - store.save()?; - println!("\nApplied: {} Skipped: {}", applied, skipped); - } else { - let hubs = neuro::find_differentiable_hubs(&store); - if hubs.is_empty() { - println!("No file-level hubs with sections found above threshold"); - return Ok(()); - } - - println!("Differentiable hubs (file-level nodes with sections):\n"); - for (key, degree, sections) in &hubs { - println!(" {:40} deg={:3} sections={}", key, degree, sections); - } - println!("\nRun: poc-memory differentiate KEY to preview a specific hub"); - } - - Ok(()) -} - pub fn cmd_link_audit(apply: bool) -> Result<(), String> { let mut store = store::Store::load()?; let stats = crate::audit::link_audit(&mut store, apply)?; @@ -420,24 +342,7 @@ pub fn cmd_organize(term: &str, threshold: f32, key_only: bool, create_anchor: b println!(" {:60} {:>4} lines {:>5} words", key, lines, words); } - // Step 2: pairwise similarity - let pairs = crate::similarity::pairwise_similar(&topic_nodes, threshold); - - if pairs.is_empty() { - println!("\nNo similar pairs above threshold {:.2}", threshold); - } else { - println!("\n=== Similar pairs (cosine > {:.2}) ===\n", threshold); - for (a, b, sim) in &pairs { - let a_words = topic_nodes.iter().find(|(k,_)| k == a) - .map(|(_,c)| c.split_whitespace().count()).unwrap_or(0); - let b_words = topic_nodes.iter().find(|(k,_)| k == b) - .map(|(_,c)| c.split_whitespace().count()).unwrap_or(0); - - println!(" [{:.3}] {} ({} words) ↔ {} ({} words)", sim, a, a_words, b, b_words); - } - } - - // Step 3: check connectivity within cluster + // Step 2: check connectivity within cluster let g = store.build_graph(); println!("=== Connectivity ===\n"); @@ -507,22 +412,6 @@ pub fn cmd_organize(term: &str, threshold: f32, key_only: bool, create_anchor: b Ok(()) } -pub fn cmd_interference(threshold: f32) -> Result<(), String> { - let store = store::Store::load()?; - let g = store.build_graph(); - let pairs = neuro::detect_interference(&store, &g, threshold); - - if pairs.is_empty() { - println!("No interfering pairs above threshold {:.2}", threshold); - } else { - println!("Interfering pairs (similarity > {:.2}, different communities):", threshold); - for (a, b, sim) in &pairs { - println!(" [{:.3}] {} ↔ {}", sim, a, b); - } - } - Ok(()) -} - /// Show communities sorted by isolation (most isolated first). /// Useful for finding poorly-integrated knowledge clusters that need /// organize agents aimed at them. diff --git a/src/hippocampus/mod.rs b/src/hippocampus/mod.rs index bd98675..9e1300a 100644 --- a/src/hippocampus/mod.rs +++ b/src/hippocampus/mod.rs @@ -11,7 +11,6 @@ pub mod graph; pub mod lookups; pub mod cursor; pub mod query; -pub mod similarity; pub mod spectral; pub mod neuro; pub mod counters; diff --git a/src/hippocampus/neuro/mod.rs b/src/hippocampus/neuro/mod.rs index 31e2580..6186a45 100644 --- a/src/hippocampus/neuro/mod.rs +++ b/src/hippocampus/neuro/mod.rs @@ -1,25 +1,14 @@ -// Neuroscience-inspired memory algorithms, split by concern: +// Neuroscience-inspired memory algorithms: // -// scoring — pure analysis: priority, replay queues, interference, plans -// prompts — agent prompt generation and formatting -// rewrite — graph topology mutations: differentiation, closure, linking +// scoring — pure analysis: priority, replay queues, plans mod scoring; -mod rewrite; pub use scoring::{ ReplayItem, ConsolidationPlan, consolidation_priority, replay_queue, replay_queue_with_graph, - detect_interference, consolidation_plan, consolidation_plan_quick, format_plan, daily_check, }; - -pub use rewrite::{ - refine_target, LinkMove, - differentiate_hub, - apply_differentiation, find_differentiable_hubs, - triangle_close, link_orphans, -}; diff --git a/src/hippocampus/neuro/rewrite.rs b/src/hippocampus/neuro/rewrite.rs deleted file mode 100644 index 054c345..0000000 --- a/src/hippocampus/neuro/rewrite.rs +++ /dev/null @@ -1,348 +0,0 @@ -// Graph topology mutations: hub differentiation, triangle closure, -// orphan linking, and link refinement. These modify the store. - -use crate::store::{Store, new_relation}; -use crate::graph::Graph; -use crate::similarity; - -/// Collect (key, content) pairs for all section children of a file-level node. -fn section_children<'a>(store: &'a Store, file_key: &str) -> Vec<(&'a str, &'a str)> { - let prefix = format!("{}#", file_key); - store.nodes.iter() - .filter(|(k, _)| k.starts_with(&prefix)) - .map(|(k, n)| (k.as_str(), n.content.as_str())) - .collect() -} - -/// Find the best matching candidate by cosine similarity against content. -/// Returns (key, similarity) if any candidate exceeds threshold. -fn best_match(candidates: &[(&str, &str)], content: &str, threshold: f32) -> Option<(String, f32)> { - let (best_key, best_sim) = candidates.iter() - .map(|(key, text)| (*key, similarity::cosine_similarity(content, text))) - .max_by(|a, b| a.1.total_cmp(&b.1))?; - if best_sim > threshold { - Some((best_key.to_string(), best_sim)) - } else { - None - } -} - -/// Refine a link target: if the target is a file-level node with section -/// children, find the best-matching section by cosine similarity against -/// the source content. Returns the original key if no sections exist or -/// no section matches above threshold. -/// -/// This prevents hub formation at link creation time — every new link -/// targets the most specific available node. -pub fn refine_target(store: &Store, source_content: &str, target_key: &str) -> String { - // Only refine file-level nodes (no # in key) - if target_key.contains('#') { return target_key.to_string(); } - - let sections = section_children(store, target_key); - - if sections.is_empty() { return target_key.to_string(); } - - best_match(§ions, source_content, 0.05) - .map(|(key, _)| key) - .unwrap_or_else(|| target_key.to_string()) -} - -/// A proposed link move: from hub→neighbor to section→neighbor -pub struct LinkMove { - pub neighbor_key: String, - pub from_hub: String, - pub to_section: String, - pub similarity: f32, - pub neighbor_snippet: String, -} - -/// Analyze a hub node and propose redistributing its links to child sections. -/// -/// Returns None if the node isn't a hub or has no sections to redistribute to. -pub fn differentiate_hub(store: &Store, hub_key: &str) -> Option> { - let graph = store.build_graph(); - differentiate_hub_with_graph(store, hub_key, &graph) -} - -/// Like differentiate_hub but uses a pre-built graph. -fn differentiate_hub_with_graph(store: &Store, hub_key: &str, graph: &Graph) -> Option> { - let degree = graph.degree(hub_key); - - // Only differentiate actual hubs - if degree < 20 { return None; } - - // Only works on file-level nodes that have section children - if hub_key.contains('#') { return None; } - - let sections = section_children(store, hub_key); - if sections.is_empty() { return None; } - - // Get all neighbors of the hub - let neighbors = graph.neighbors(hub_key); - let prefix = format!("{}#", hub_key); - - let mut moves = Vec::new(); - - for (neighbor_key, _strength) in &neighbors { - // Skip section children — they should stay linked to parent - if neighbor_key.starts_with(&prefix) { continue; } - - let neighbor_content = match store.nodes.get(neighbor_key.as_str()) { - Some(n) => &n.content, - None => continue, - }; - - // Find best-matching section by content similarity - if let Some((best_section, best_sim)) = best_match(§ions, neighbor_content, 0.05) { - let snippet = crate::util::first_n_chars( - neighbor_content.lines() - .find(|l| !l.is_empty() && !l.starts_with("