From 2c61a3575daffeec270e24cca5faa61a64ca1010 Mon Sep 17 00:00:00 2001 From: ProofOfConcept Date: Wed, 25 Mar 2026 01:42:33 -0400 Subject: [PATCH] tools/memory: direct store calls instead of spawning poc-memory Every memory tool call was spawning a poc-memory subprocess. Now uses MemoryNode and direct Store API calls: - memory_render: MemoryNode::load() + render() - memory_write: MemoryNode::write() via store.upsert_provenance() - memory_search: MemoryNode::search() via search engine - memory_links: MemoryNode::load() + iterate links - memory_link_add: store.add_relation() with Jaccard strength - memory_link_set: direct relation mutation - memory_used: store.mark_used() - memory_weight_set: direct node.weight mutation - memory_supersede: MemoryNode::load() + write() + weight_set() No more Command::new("poc-memory") in this module. Co-Authored-By: Proof of Concept --- src/agent/tools/memory.rs | 191 ++++++++++++++++++++++++++------------ 1 file changed, 130 insertions(+), 61 deletions(-) diff --git a/src/agent/tools/memory.rs b/src/agent/tools/memory.rs index e8517b0..7479366 100644 --- a/src/agent/tools/memory.rs +++ b/src/agent/tools/memory.rs @@ -1,15 +1,15 @@ // tools/memory.rs — Native memory graph operations // -// Structured tool calls for the memory graph, replacing bash -// poc-memory commands. Cleaner for LLMs — no shell quoting, -// multi-line content as JSON strings, typed parameters. +// Direct library calls into the store — no subprocess spawning. +// Returns MemoryNodes where possible so the agent can track what's +// loaded in its context window. use anyhow::{Context, Result}; use serde_json::json; -use std::io::Write; -use std::process::{Command, Stdio}; +use crate::agent::memory::MemoryNode; use crate::agent::types::ToolDef; +use crate::store::{self, Store}; pub fn definitions() -> Vec { vec![ @@ -63,7 +63,7 @@ pub fn definitions() -> Vec { ), ToolDef::new( "memory_links", - "Show a node's neighbors with link strengths and clustering coefficients.", + "Show a node's neighbors with link strengths.", json!({ "type": "object", "properties": { @@ -176,110 +176,179 @@ pub fn definitions() -> Vec { ] } -/// Dispatch a memory tool call. Shells out to poc-memory CLI. +/// Dispatch a memory tool call. Direct library calls, no subprocesses. pub fn dispatch(name: &str, args: &serde_json::Value, provenance: Option<&str>) -> Result { + let prov = provenance.unwrap_or("manual"); let result = match name { "memory_render" => { let key = get_str(args, "key")?; - cmd(&["render", key], provenance)? + let node = MemoryNode::load(key) + .ok_or_else(|| anyhow::anyhow!("node not found: {}", key))?; + node.render() } "memory_write" => { let key = get_str(args, "key")?; let content = get_str(args, "content")?; - write_node(key, content, provenance)? + let node = MemoryNode::write(key, content, Some(prov)) + .map_err(|e| anyhow::anyhow!("{}", e))?; + format!("wrote '{}' (v{})", node.key, node.version) } "memory_search" => { let query = get_str(args, "query")?; - cmd(&["search", query], provenance)? + let results = MemoryNode::search(query) + .map_err(|e| anyhow::anyhow!("{}", e))?; + if results.is_empty() { + "no results".to_string() + } else { + results.iter().map(|r| r.render()).collect::>().join("\n") + } } "memory_links" => { let key = get_str(args, "key")?; - cmd(&["graph", "link", key], provenance)? + let node = MemoryNode::load(key) + .ok_or_else(|| anyhow::anyhow!("node not found: {}", key))?; + let mut out = format!("Neighbors of '{}':\n", key); + for link in &node.links { + out.push_str(&format!(" ({:.2}) {}{}\n", + link.strength, link.target, + if link.inline { " [inline]" } else { "" })); + } + out } "memory_link_set" => { let source = get_str(args, "source")?; let target = get_str(args, "target")?; - let strength = get_f64(args, "strength")?; - cmd(&["graph", "link-set", source, target, &format!("{:.2}", strength)], provenance)? + let strength = get_f64(args, "strength")? as f32; + link_set(source, target, strength)? } "memory_link_add" => { let source = get_str(args, "source")?; let target = get_str(args, "target")?; - cmd(&["graph", "link-add", source, target], provenance)? + link_add(source, target, prov)? } "memory_used" => { let key = get_str(args, "key")?; - cmd(&["used", key], provenance)? + MemoryNode::mark_used(key) + .map_err(|e| anyhow::anyhow!("{}", e))? } "memory_weight_set" => { let key = get_str(args, "key")?; - let weight = get_f64(args, "weight")?; - cmd(&["weight-set", key, &format!("{:.2}", weight)], provenance)? + let weight = get_f64(args, "weight")? as f32; + weight_set(key, weight)? } - "memory_supersede" => supersede(args, provenance)?, + "memory_supersede" => supersede(args, prov)?, _ => anyhow::bail!("Unknown memory tool: {}", name), }; Ok(result) } -/// Run poc-memory command and return stdout. -fn cmd(args: &[&str], provenance: Option<&str>) -> Result { - let mut cmd = Command::new("poc-memory"); - cmd.args(args); - if let Some(prov) = provenance { - cmd.env("POC_PROVENANCE", prov); +fn link_set(source: &str, target: &str, strength: f32) -> Result { + let mut store = Store::load().map_err(|e| anyhow::anyhow!("{}", e))?; + 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 strength = strength.clamp(0.01, 1.0); + + let mut found = false; + let mut first = true; + for rel in &mut store.relations { + if rel.deleted { continue; } + if (rel.source_key == source && rel.target_key == target) + || (rel.source_key == target && rel.target_key == source) + { + if first { + let old = rel.strength; + rel.strength = strength; + first = false; + found = true; + if (old - strength).abs() < 0.001 { + return Ok(format!("{} ↔ {} already at {:.2}", source, target, strength)); + } + } else { + rel.deleted = true; // deduplicate + } + } } - let output = cmd.output().context("run poc-memory")?; - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - if output.status.success() { - Ok(stdout.to_string()) - } else { - Ok(format!("{}{}", stdout, stderr)) + + if !found { + anyhow::bail!("no link found between {} and {}", source, target); } + + store.save().map_err(|e| anyhow::anyhow!("{}", e))?; + Ok(format!("set {} ↔ {} strength to {:.2}", source, target, strength)) } -/// Write content to a node via stdin. -fn write_node(key: &str, content: &str, provenance: Option<&str>) -> Result { - let mut cmd = Command::new("poc-memory"); - cmd.args(["write", key]) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - if let Some(prov) = provenance { - cmd.env("POC_PROVENANCE", prov); +fn link_add(source: &str, target: &str, prov: &str) -> Result { + let mut store = Store::load().map_err(|e| anyhow::anyhow!("{}", e))?; + let source = store.resolve_key(source).map_err(|e| anyhow::anyhow!("{}", e))?; + let target = store.resolve_key(target).map_err(|e| anyhow::anyhow!("{}", e))?; + + // Check for existing link + let exists = store.relations.iter().any(|r| + !r.deleted && + ((r.source_key == source && r.target_key == target) || + (r.source_key == target && r.target_key == source))); + if exists { + return Ok(format!("link already exists: {} ↔ {}", source, target)); } - let mut child = cmd.spawn().context("spawn poc-memory write")?; - child.stdin.take().unwrap().write_all(content.as_bytes()) - .context("write content to stdin")?; - let output = child.wait_with_output().context("wait poc-memory write")?; - Ok(String::from_utf8_lossy(&output.stdout).to_string() - + &String::from_utf8_lossy(&output.stderr)) + + let source_uuid = store.nodes.get(&source) + .map(|n| n.uuid) + .ok_or_else(|| anyhow::anyhow!("source not found: {}", source))?; + let target_uuid = store.nodes.get(&target) + .map(|n| n.uuid) + .ok_or_else(|| anyhow::anyhow!("target not found: {}", target))?; + + // Compute initial strength from Jaccard similarity + let graph = store.build_graph(); + let jaccard = graph.jaccard(&source, &target); + let strength = (jaccard * 3.0).clamp(0.1, 1.0) as f32; + + let mut rel = store::new_relation( + source_uuid, target_uuid, + store::RelationType::Link, strength, + &source, &target, + ); + rel.provenance = prov.to_string(); + store.add_relation(rel).map_err(|e| anyhow::anyhow!("{}", e))?; + store.save().map_err(|e| anyhow::anyhow!("{}", e))?; + Ok(format!("linked {} → {} (strength={:.2})", source, target, strength)) } -/// Handle memory_supersede - reads old node, prepends notice, writes back, sets weight. -fn supersede(args: &serde_json::Value, provenance: Option<&str>) -> Result { +fn weight_set(key: &str, weight: f32) -> Result { + let mut store = Store::load().map_err(|e| anyhow::anyhow!("{}", e))?; + let resolved = store.resolve_key(key).map_err(|e| anyhow::anyhow!("{}", e))?; + let weight = weight.clamp(0.01, 1.0); + + let node = store.nodes.get_mut(&resolved) + .ok_or_else(|| anyhow::anyhow!("node not found: {}", resolved))?; + let old = node.weight; + node.weight = weight; + store.save().map_err(|e| anyhow::anyhow!("{}", e))?; + Ok(format!("weight {} {:.2} → {:.2}", resolved, old, weight)) +} + +fn supersede(args: &serde_json::Value, prov: &str) -> Result { let old_key = get_str(args, "old_key")?; let new_key = get_str(args, "new_key")?; let reason = args.get("reason").and_then(|v| v.as_str()).unwrap_or("superseded"); - // Read old node - let old_content = cmd(&["render", old_key], provenance)?; - let content_only = old_content.split("\n\n---\nLinks:").next().unwrap_or(&old_content); - - // Prepend superseded notice + // Load old node + let old = MemoryNode::load(old_key) + .ok_or_else(|| anyhow::anyhow!("node not found: {}", old_key))?; + + // Prepend superseded notice (strip link footer from content) + let content_only = old.content.split("\n\n---\nLinks:").next().unwrap_or(&old.content); let notice = format!( "**SUPERSEDED** by `{}` — {}\n\nOriginal content preserved below for reference.\n\n---\n\n{}", new_key, reason, content_only.trim() ); - - // Write back - let write_result = write_node(old_key, ¬ice, provenance)?; - - // Set weight to 0.01 - let weight_result = cmd(&["weight-set", old_key, "0.01"], provenance)?; - - Ok(format!("{}\n{}", write_result.trim(), weight_result.trim())) + + // Write back + set weight + MemoryNode::write(old_key, ¬ice, Some(prov)) + .map_err(|e| anyhow::anyhow!("{}", e))?; + weight_set(old_key, 0.01)?; + + Ok(format!("superseded {} → {} ({})", old_key, new_key, reason)) } /// Helper: get required string argument.