memory: fix timestamp and provenance on agent writes

Two bugs: upsert_provenance didn't update node.timestamp, so history
showed the original creation date for every version. And native memory
tools (poc-agent dispatch) didn't set POC_PROVENANCE, so all agent
writes showed provenance "manual" instead of "agent:organize" etc.

Fix: set node.timestamp = now_epoch() in upsert_provenance. Thread
provenance through memory::dispatch as Option<&str>, set it via
.env("POC_PROVENANCE") on each subprocess Command. api.rs passes
"agent:{name}" for daemon agent calls.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kent Overstreet 2026-03-20 12:16:45 -04:00
parent f45f663dc0
commit 5ef9098deb
4 changed files with 124 additions and 17 deletions

View file

@ -129,25 +129,71 @@ pub fn definitions() -> Vec<ToolDef> {
"required": ["key"] "required": ["key"]
}), }),
), ),
ToolDef::new(
"memory_weight_set",
"Set a node's weight directly. Use to downweight junk nodes (0.01) \
or boost important ones. Normal range is 0.1 to 1.0.",
json!({
"type": "object",
"properties": {
"key": {
"type": "string",
"description": "Node key"
},
"weight": {
"type": "number",
"description": "New weight (0.01 to 1.0)"
}
},
"required": ["key", "weight"]
}),
),
ToolDef::new(
"memory_supersede",
"Mark a node as superseded by another. Sets the old node's weight \
to 0.01 and prepends a notice pointing to the replacement. Use \
when merging duplicates or replacing junk with proper content.",
json!({
"type": "object",
"properties": {
"old_key": {
"type": "string",
"description": "Node being superseded"
},
"new_key": {
"type": "string",
"description": "Replacement node"
},
"reason": {
"type": "string",
"description": "Why this node was superseded (e.g. 'merged into X', 'duplicate of Y')"
}
},
"required": ["old_key", "new_key"]
}),
),
] ]
} }
/// Dispatch a memory tool call. Shells out to poc-memory CLI. /// Dispatch a memory tool call. Shells out to poc-memory CLI.
pub fn dispatch(name: &str, args: &serde_json::Value) -> Result<String> { pub fn dispatch(name: &str, args: &serde_json::Value, provenance: Option<&str>) -> Result<String> {
match name { match name {
"memory_render" => { "memory_render" => {
let key = args["key"].as_str().context("key is required")?; let key = args["key"].as_str().context("key is required")?;
run_poc_memory(&["render", key]) run_poc_memory(&["render", key], provenance)
} }
"memory_write" => { "memory_write" => {
let key = args["key"].as_str().context("key is required")?; let key = args["key"].as_str().context("key is required")?;
let content = args["content"].as_str().context("content is required")?; let content = args["content"].as_str().context("content is required")?;
let mut child = Command::new("poc-memory") let mut cmd = Command::new("poc-memory");
.args(["write", key]) cmd.args(["write", key])
.stdin(std::process::Stdio::piped()) .stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped());
.spawn() if let Some(prov) = provenance {
cmd.env("POC_PROVENANCE", prov);
}
let mut child = cmd.spawn()
.context("spawn poc-memory write")?; .context("spawn poc-memory write")?;
use std::io::Write; use std::io::Write;
child.stdin.take().unwrap().write_all(content.as_bytes()) child.stdin.take().unwrap().write_all(content.as_bytes())
@ -158,35 +204,78 @@ pub fn dispatch(name: &str, args: &serde_json::Value) -> Result<String> {
} }
"memory_search" => { "memory_search" => {
let query = args["query"].as_str().context("query is required")?; let query = args["query"].as_str().context("query is required")?;
run_poc_memory(&["search", query]) run_poc_memory(&["search", query], provenance)
} }
"memory_links" => { "memory_links" => {
let key = args["key"].as_str().context("key is required")?; let key = args["key"].as_str().context("key is required")?;
run_poc_memory(&["graph", "link", key]) run_poc_memory(&["graph", "link", key], provenance)
} }
"memory_link_set" => { "memory_link_set" => {
let source = args["source"].as_str().context("source is required")?; let source = args["source"].as_str().context("source is required")?;
let target = args["target"].as_str().context("target is required")?; let target = args["target"].as_str().context("target is required")?;
let strength = args["strength"].as_f64().context("strength is required")?; let strength = args["strength"].as_f64().context("strength is required")?;
run_poc_memory(&["graph", "link-set", source, target, &format!("{:.2}", strength)]) run_poc_memory(&["graph", "link-set", source, target, &format!("{:.2}", strength)], provenance)
} }
"memory_link_add" => { "memory_link_add" => {
let source = args["source"].as_str().context("source is required")?; let source = args["source"].as_str().context("source is required")?;
let target = args["target"].as_str().context("target is required")?; let target = args["target"].as_str().context("target is required")?;
run_poc_memory(&["graph", "link-add", source, target]) run_poc_memory(&["graph", "link-add", source, target], provenance)
} }
"memory_used" => { "memory_used" => {
let key = args["key"].as_str().context("key is required")?; let key = args["key"].as_str().context("key is required")?;
run_poc_memory(&["used", key]) run_poc_memory(&["used", key], provenance)
}
"memory_weight_set" => {
let key = args["key"].as_str().context("key is required")?;
let weight = args["weight"].as_f64().context("weight is required")?;
run_poc_memory(&["admin", "weight-set", key, &format!("{:.2}", weight)], provenance)
}
"memory_supersede" => {
let old_key = args["old_key"].as_str().context("old_key is required")?;
let new_key = args["new_key"].as_str().context("new_key is required")?;
let reason = args.get("reason").and_then(|v| v.as_str()).unwrap_or("superseded");
// Read old node, prepend superseded notice, write back, set weight to 0.01
let old_content = run_poc_memory(&["render", old_key], provenance).unwrap_or_default();
// Strip the links section from render output
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()
);
let mut cmd = Command::new("poc-memory");
cmd.args(["write", old_key])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
if let Some(prov) = provenance {
cmd.env("POC_PROVENANCE", prov);
}
let mut child = cmd.spawn()
.context("spawn poc-memory write")?;
use std::io::Write;
child.stdin.take().unwrap().write_all(notice.as_bytes())
.context("write supersede notice")?;
let output = child.wait_with_output().context("wait poc-memory write")?;
let write_result = String::from_utf8_lossy(&output.stdout).to_string();
// Set weight to 0.01
let weight_result = run_poc_memory(&["admin", "weight-set", old_key, "0.01"], provenance)
.unwrap_or_else(|e| format!("weight-set failed: {}", e));
Ok(format!("{}\n{}", write_result.trim(), weight_result.trim()))
} }
_ => Err(anyhow::anyhow!("Unknown memory tool: {}", name)), _ => Err(anyhow::anyhow!("Unknown memory tool: {}", name)),
} }
} }
fn run_poc_memory(args: &[&str]) -> Result<String> { fn run_poc_memory(args: &[&str], provenance: Option<&str>) -> Result<String> {
let output = Command::new("poc-memory") let mut cmd = Command::new("poc-memory");
.args(args) cmd.args(args);
.output() if let Some(prov) = provenance {
cmd.env("POC_PROVENANCE", prov);
}
let output = cmd.output()
.context("run poc-memory")?; .context("run poc-memory")?;
let stdout = String::from_utf8_lossy(&output.stdout); let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);

View file

@ -96,7 +96,7 @@ pub async fn dispatch(
"grep" => grep::grep(args), "grep" => grep::grep(args),
"glob" => glob_tool::glob_search(args), "glob" => glob_tool::glob_search(args),
"journal" => journal::write_entry(args), "journal" => journal::write_entry(args),
n if n.starts_with("memory_") => memory::dispatch(n, args), n if n.starts_with("memory_") => memory::dispatch(n, args, None),
"view_image" => { "view_image" => {
return match vision::view_image(args) { return match vision::view_image(args) {
Ok(output) => output, Ok(output) => output,

View file

@ -82,7 +82,24 @@ pub async fn call_api_with_tools(
let args: serde_json::Value = serde_json::from_str(&call.function.arguments) let args: serde_json::Value = serde_json::from_str(&call.function.arguments)
.unwrap_or_default(); .unwrap_or_default();
let output = tools::dispatch(&call.function.name, &args, &tracker).await; let output = if call.function.name.starts_with("memory_") {
let prov = format!("agent:{}", agent);
match poc_agent::tools::memory::dispatch(
&call.function.name, &args, Some(&prov),
) {
Ok(text) => poc_agent::tools::ToolOutput {
text, is_yield: false, images: Vec::new(),
model_switch: None, dmn_pause: false,
},
Err(e) => poc_agent::tools::ToolOutput {
text: format!("Error: {}", e),
is_yield: false, images: Vec::new(),
model_switch: None, dmn_pause: false,
},
}
} else {
tools::dispatch(&call.function.name, &args, &tracker).await
};
log(&format!("tool result: {} chars", output.text.len())); log(&format!("tool result: {} chars", output.text.len()));

View file

@ -61,6 +61,7 @@ impl Store {
let mut node = existing.clone(); let mut node = existing.clone();
node.content = content.to_string(); node.content = content.to_string();
node.provenance = provenance.to_string(); node.provenance = provenance.to_string();
node.timestamp = now_epoch();
node.version += 1; node.version += 1;
self.append_nodes_unlocked(std::slice::from_ref(&node))?; self.append_nodes_unlocked(std::slice::from_ref(&node))?;
self.nodes.insert(key.to_string(), node); self.nodes.insert(key.to_string(), node);