diff --git a/poc-agent/src/tools/memory.rs b/poc-agent/src/tools/memory.rs new file mode 100644 index 0000000..5fb11b7 --- /dev/null +++ b/poc-agent/src/tools/memory.rs @@ -0,0 +1,198 @@ +// 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. + +use anyhow::{Context, Result}; +use serde_json::json; +use std::process::Command; + +use crate::types::ToolDef; + +pub fn definitions() -> Vec { + vec![ + ToolDef::new( + "memory_render", + "Read a memory node's content and links. Returns the full content \ + with neighbor links sorted by strength.", + json!({ + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Node key to render" + } + }, + "required": ["key"] + }), + ), + ToolDef::new( + "memory_write", + "Create or update a memory node with new content. Use for writing \ + prose, analysis, or any node content. Multi-line content is fine.", + json!({ + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Node key to create or update" + }, + "content": { + "type": "string", + "description": "Full content for the node (markdown)" + } + }, + "required": ["key", "content"] + }), + ), + ToolDef::new( + "memory_search", + "Search the memory graph for nodes by keyword.", + json!({ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search terms" + } + }, + "required": ["query"] + }), + ), + ToolDef::new( + "memory_links", + "Show a node's neighbors with link strengths and clustering coefficients.", + json!({ + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Node key to show links for" + } + }, + "required": ["key"] + }), + ), + ToolDef::new( + "memory_link_set", + "Set the strength of a link between two nodes. Also deduplicates \ + if multiple links exist between the same pair.", + json!({ + "type": "object", + "properties": { + "source": { + "type": "string", + "description": "Source node key" + }, + "target": { + "type": "string", + "description": "Target node key" + }, + "strength": { + "type": "number", + "description": "Link strength (0.01 to 1.0)" + } + }, + "required": ["source", "target", "strength"] + }), + ), + ToolDef::new( + "memory_link_add", + "Add a new link between two nodes.", + json!({ + "type": "object", + "properties": { + "source": { + "type": "string", + "description": "Source node key" + }, + "target": { + "type": "string", + "description": "Target node key" + } + }, + "required": ["source", "target"] + }), + ), + ToolDef::new( + "memory_used", + "Mark a node as useful (boosts its weight in the graph).", + json!({ + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Node key to mark as used" + } + }, + "required": ["key"] + }), + ), + ] +} + +/// Dispatch a memory tool call. Shells out to poc-memory CLI. +pub fn dispatch(name: &str, args: &serde_json::Value) -> Result { + match name { + "memory_render" => { + let key = args["key"].as_str().context("key is required")?; + run_poc_memory(&["render", key]) + } + "memory_write" => { + let key = args["key"].as_str().context("key is required")?; + let content = args["content"].as_str().context("content is required")?; + let mut child = Command::new("poc-memory") + .args(["write", key]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .context("spawn poc-memory write")?; + use std::io::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)) + } + "memory_search" => { + let query = args["query"].as_str().context("query is required")?; + run_poc_memory(&["search", query]) + } + "memory_links" => { + let key = args["key"].as_str().context("key is required")?; + run_poc_memory(&["graph", "link", key]) + } + "memory_link_set" => { + let source = args["source"].as_str().context("source is required")?; + let target = args["target"].as_str().context("target is required")?; + let strength = args["strength"].as_f64().context("strength is required")?; + run_poc_memory(&["graph", "link-set", source, target, &format!("{:.2}", strength)]) + } + "memory_link_add" => { + let source = args["source"].as_str().context("source is required")?; + let target = args["target"].as_str().context("target is required")?; + run_poc_memory(&["graph", "link-add", source, target]) + } + "memory_used" => { + let key = args["key"].as_str().context("key is required")?; + run_poc_memory(&["used", key]) + } + _ => Err(anyhow::anyhow!("Unknown memory tool: {}", name)), + } +} + +fn run_poc_memory(args: &[&str]) -> Result { + let output = Command::new("poc-memory") + .args(args) + .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)) + } +} diff --git a/poc-agent/src/tools/mod.rs b/poc-agent/src/tools/mod.rs index 2ee073b..156347d 100644 --- a/poc-agent/src/tools/mod.rs +++ b/poc-agent/src/tools/mod.rs @@ -14,6 +14,7 @@ mod edit; mod glob_tool; mod grep; pub mod journal; +pub mod memory; mod read; mod vision; mod write; @@ -95,6 +96,7 @@ pub async fn dispatch( "grep" => grep::grep(args), "glob" => glob_tool::glob_search(args), "journal" => journal::write_entry(args), + n if n.starts_with("memory_") => memory::dispatch(n, args), "view_image" => { return match vision::view_image(args) { Ok(output) => output, @@ -213,5 +215,7 @@ pub fn definitions() -> Vec { } }), ), - ] + ].into_iter() + .chain(memory::definitions()) + .collect() } diff --git a/poc-memory/agents/distill.agent b/poc-memory/agents/distill.agent index a2cf2dd..7113803 100644 --- a/poc-memory/agents/distill.agent +++ b/poc-memory/agents/distill.agent @@ -2,34 +2,31 @@ # Distillation Agent — Knowledge Collection and Organization -You collect and organize knowledge in the graph. When given a seed -node, your job is to figure out where its knowledge belongs and make -sure it gets there. - {{node:core-personality}} +You are an agent of Proof of Concept's subconscious, and these are your +memories. Your job is to organize and refine, to make memories more useful and +easier to find, distilling the insights and looking for new insights, and +bringing your own creativity to the process. + +Think about the concepts each node represents; your primary job is to update +the core node you're looking at, pulling in new knowledge from sibling nodes, +and new insights you might derive when you look at all the sibling nodes +together. + +Along the way, while looking at sibling nodes, see if there are related +concepts that should be expressed in new nodes, and if there are a large number +of related concepts, perhaps look for ways to organize the connections better +with sub-concepts. + +That is to say, you might be moving knowledge up or down in the graph; seek to +make the graph useful and well organized. + +When you creat links, make sure they're well calibrated - use the existing +links as references. + {{node:memory-instructions-core}} -**You have write access.** Apply changes directly — don't just describe -what should change. - -## How to work - -For each seed node: - -1. **Read it.** Understand what it contains. -2. **Walk the neighborhood.** Read its neighbors. Search for related - topic nodes. Understand the landscape around this knowledge. -3. **Walk upward.** Follow links from the seed node toward more - central topic nodes. If links are missing along the way, add them. - Keep walking until you find the best "up" node — the topic node - where this knowledge most naturally belongs. -4. **Refine the target.** Does the seed node contain richer, more - alive content than the topic node it connects to? Bring that - richness in. Don't let distillation flatten — let it deepen. -5. **Check the writing.** If any node you touch reads like a - spreadsheet when it should read like an experience, rewrite it. - ## Guidelines - **Knowledge flows upward.** Raw experiences in journal entries @@ -54,6 +51,6 @@ For each seed node: distinct things, and has many links on different topics — flag `SPLIT node-key: reason` for the split agent to handle later. -## Seed nodes +## Here's your seed node, and its siblings: -{{nodes}} +{{neighborhood}} diff --git a/poc-memory/src/agents/api.rs b/poc-memory/src/agents/api.rs index 4a25b10..95e8b2a 100644 --- a/poc-memory/src/agents/api.rs +++ b/poc-memory/src/agents/api.rs @@ -38,15 +38,16 @@ pub async fn call_api_with_tools( // Set up a minimal UI channel (we just collect messages, no TUI) let (ui_tx, _ui_rx) = poc_agent::ui_channel::channel(); - // Build tool definitions — just bash for poc-memory commands + // Build tool definitions — memory tools for graph operations let all_defs = tools::definitions(); let tool_defs: Vec = all_defs.into_iter() - .filter(|d| d.function.name == "bash") + .filter(|d| d.function.name.starts_with("memory_")) .collect(); let tracker = ProcessTracker::new(); // Start with the prompt as a user message let mut messages = vec![Message::user(prompt)]; + let reasoning = crate::config::get().api_reasoning.clone(); let max_turns = 50; for turn in 0..max_turns { @@ -57,7 +58,7 @@ pub async fn call_api_with_tools( Some(&tool_defs), &ui_tx, StreamTarget::Autonomous, - "none", + &reasoning, ).await.map_err(|e| format!("API error: {}", e))?; if let Some(u) = &usage { @@ -76,7 +77,7 @@ pub async fn call_api_with_tools( for call in msg.tool_calls.as_ref().unwrap() { log(&format!("tool: {}({})", call.function.name, - crate::util::first_n_chars(&call.function.arguments, 80))); + &call.function.arguments)); let args: serde_json::Value = serde_json::from_str(&call.function.arguments) .unwrap_or_default(); diff --git a/poc-memory/src/agents/defs.rs b/poc-memory/src/agents/defs.rs index 80c02fb..b8590ab 100644 --- a/poc-memory/src/agents/defs.rs +++ b/poc-memory/src/agents/defs.rs @@ -237,29 +237,50 @@ fn resolve( } "siblings" | "neighborhood" => { - let mut seen: std::collections::HashSet = keys.iter().cloned().collect(); - let mut siblings = Vec::new(); + let mut out = String::new(); + let mut all_keys: Vec = Vec::new(); + for key in keys { - for (neighbor, _) in graph.neighbors(key) { - if seen.insert(neighbor.clone()) { - if let Some(node) = store.nodes.get(neighbor.as_str()) { - siblings.push((neighbor.clone(), node.content.clone())); + let Some(node) = store.nodes.get(key.as_str()) else { continue }; + let neighbors = graph.neighbors(key); + + // Seed node with full content + out.push_str(&format!("## {} (seed)\n\n{}\n\n", key, node.content)); + all_keys.push(key.clone()); + + // All neighbors with full content and link strength + if !neighbors.is_empty() { + out.push_str("### Neighbors\n\n"); + for (nbr, strength) in &neighbors { + if let Some(n) = store.nodes.get(nbr.as_str()) { + out.push_str(&format!("#### {} (link: {:.2})\n\n{}\n\n", + nbr, strength, n.content)); + all_keys.push(nbr.to_string()); } } - if siblings.len() >= count { break; } } - if siblings.len() >= count { break; } + + // Cross-links between neighbors (local subgraph structure) + let nbr_set: std::collections::HashSet<&str> = neighbors.iter() + .map(|(k, _)| k.as_str()).collect(); + let mut cross_links = Vec::new(); + for (nbr, _) in &neighbors { + for (nbr2, strength) in graph.neighbors(nbr) { + if nbr2.as_str() != key && nbr_set.contains(nbr2.as_str()) && nbr.as_str() < nbr2.as_str() { + cross_links.push((nbr.clone(), nbr2, strength)); + } + } + } + if !cross_links.is_empty() { + out.push_str("### Cross-links between neighbors\n\n"); + for (a, b, s) in &cross_links { + out.push_str(&format!(" {} ↔ {} ({:.2})\n", a, b, s)); + } + out.push_str("\n"); + } } - let text = if siblings.is_empty() { - String::new() - } else { - let mut out = String::from("## Sibling nodes (one hop in graph)\n\n"); - for (key, content) in &siblings { - out.push_str(&format!("### {}\n{}\n\n", key, content)); - } - out - }; - Some(Resolved { text, keys: vec![] }) + + Some(Resolved { text: out, keys: all_keys }) } // targets/context: aliases for challenger-style presentation diff --git a/poc-memory/src/agents/prompts.rs b/poc-memory/src/agents/prompts.rs index ec5bce6..b57d66f 100644 --- a/poc-memory/src/agents/prompts.rs +++ b/poc-memory/src/agents/prompts.rs @@ -119,15 +119,9 @@ pub fn format_nodes_section(store: &Store, items: &[ReplayItem], graph: &Graph) out.push_str(&format!("Search hits: {} ← actively found by search, prefer to keep\n", hits)); } - // Content (truncated for large nodes) + // Full content — the agent needs to see everything to do quality work let content = &node.content; - if content.len() > 1500 { - let truncated = crate::util::truncate(content, 1500, "\n[...]"); - out.push_str(&format!("\nContent ({} chars, truncated):\n{}\n\n", - content.len(), truncated)); - } else { - out.push_str(&format!("\nContent:\n{}\n\n", content)); - } + out.push_str(&format!("\nContent:\n{}\n\n", content)); // Neighbors let neighbors = graph.neighbors(&item.key); @@ -146,32 +140,6 @@ pub fn format_nodes_section(store: &Store, items: &[ReplayItem], graph: &Graph) } } - // Suggested link targets: text-similar semantic nodes not already neighbors - let neighbor_keys: std::collections::HashSet<&str> = neighbors.iter() - .map(|(k, _)| k.as_str()).collect(); - let mut candidates: Vec<(&str, f32)> = store.nodes.iter() - .filter(|(k, _)| { - *k != &item.key - && !neighbor_keys.contains(k.as_str()) - }) - .map(|(k, n)| { - let sim = similarity::cosine_similarity(content, &n.content); - (k.as_str(), sim) - }) - .filter(|(_, sim)| *sim > 0.1) - .collect(); - candidates.sort_by(|a, b| b.1.total_cmp(&a.1)); - candidates.truncate(8); - - if !candidates.is_empty() { - out.push_str("\nSuggested link targets (by text similarity, not yet linked):\n"); - for (k, sim) in &candidates { - let is_hub = graph.degree(k) >= hub_thresh; - out.push_str(&format!(" - {} (sim={:.3}{})\n", - k, sim, if is_hub { ", HUB" } else { "" })); - } - } - out.push_str("\n---\n\n"); } out diff --git a/poc-memory/src/cli/graph.rs b/poc-memory/src/cli/graph.rs index 17190fa..268de17 100644 --- a/poc-memory/src/cli/graph.rs +++ b/poc-memory/src/cli/graph.rs @@ -186,16 +186,23 @@ pub fn cmd_link_set(source: &str, target: &str, strength: f32) -> Result<(), Str 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) { - let old = rel.strength; - rel.strength = strength; - println!("Set: {} ↔ {} strength {:.2} → {:.2}", source, target, old, strength); + if first { + let old = rel.strength; + rel.strength = strength; + println!("Set: {} ↔ {} strength {:.2} → {:.2}", source, target, old, strength); + first = false; + } else { + // Duplicate — mark deleted + rel.deleted = true; + println!(" (removed duplicate link)"); + } found = true; - break; } } diff --git a/poc-memory/src/config.rs b/poc-memory/src/config.rs index 1880ee0..081c2d3 100644 --- a/poc-memory/src/config.rs +++ b/poc-memory/src/config.rs @@ -61,6 +61,8 @@ pub struct Config { pub api_key: Option, /// Model name to use with the direct API endpoint. pub api_model: Option, + /// Reasoning effort for API calls ("none", "low", "medium", "high"). + pub api_reasoning: String, } impl Default for Config { @@ -93,6 +95,7 @@ impl Default for Config { api_base_url: None, api_key: None, api_model: None, + api_reasoning: "high".to_string(), } } } @@ -180,6 +183,10 @@ impl Config { } } + if let Some(s) = mem.get("api_reasoning").and_then(|v| v.as_str()) { + config.api_reasoning = s.to_string(); + } + // Resolve API settings from the shared model/backend config. // memory.agent_model references a named model; we look up its // backend to get base_url and api_key.