use std::sync::Arc; // tools/memory.rs — Native memory graph operations // // Direct library calls into the store — no subprocess spawning. // One function per tool for use in the Tool registry. use anyhow::{Context, Result}; use crate::hippocampus::memory::MemoryNode; use crate::store::StoreView; use crate::store::Store; // ── Helpers ──────────────────────────────────────────────────── fn get_str<'a>(args: &'a serde_json::Value, name: &'a str) -> Result<&'a str> { args.get(name).and_then(|v| v.as_str()).context(format!("{} is required", name)) } fn get_f64(args: &serde_json::Value, name: &str) -> Result { args.get(name).and_then(|v| v.as_f64()).context(format!("{} is required", name)) } async fn cached_store() -> Result>> { Store::cached().await.map_err(|e| anyhow::anyhow!("{}", e)) } async fn get_provenance(agent: &Option>) -> String { match agent { Some(a) => a.state.lock().await.provenance.clone(), None => "manual".to_string(), } } // ── Definitions ──────────────────────────────────────────────── pub fn memory_tools() -> [super::Tool; 13] { use super::Tool; [ 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).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 })) }, Tool { name: "memory_search", description: "Search the memory graph via spreading activation. Give 2-4 seed node keys.", parameters_json: r#"{"type":"object","properties":{"keys":{"type":"array","items":{"type":"string"},"description":"Seed node keys to activate from"}},"required":["keys"]}"#, handler: Arc::new(|_a, v| Box::pin(async move { search(&v).await })) }, Tool { name: "memory_links", description: "Show a node's neighbors with link strengths.", parameters_json: r#"{"type":"object","properties":{"key":{"type":"string","description":"Node key"}},"required":["key"]}"#, handler: Arc::new(|_a, v| Box::pin(async move { links(&v) })) }, Tool { name: "memory_link_set", description: "Set link strength between two nodes.", parameters_json: r#"{"type":"object","properties":{"source":{"type":"string"},"target":{"type":"string"},"strength":{"type":"number","description":"0.01 to 1.0"}},"required":["source","target","strength"]}"#, handler: Arc::new(|_a, v| Box::pin(async move { link_set(&v).await })) }, Tool { name: "memory_link_add", description: "Add a new link between two nodes.", parameters_json: r#"{"type":"object","properties":{"source":{"type":"string"},"target":{"type":"string"}},"required":["source","target"]}"#, handler: Arc::new(|a, v| Box::pin(async move { link_add(&a, &v).await })) }, Tool { name: "memory_used", description: "Mark a node as useful (boosts weight).", parameters_json: r#"{"type":"object","properties":{"key":{"type":"string","description":"Node key"}},"required":["key"]}"#, handler: Arc::new(|_a, v| Box::pin(async move { used(&v).await })) }, Tool { name: "memory_weight_set", description: "Set a node's weight directly (0.01 to 1.0).", parameters_json: r#"{"type":"object","properties":{"key":{"type":"string"},"weight":{"type":"number","description":"0.01 to 1.0"}},"required":["key","weight"]}"#, handler: Arc::new(|_a, v| Box::pin(async move { weight_set(&v).await })) }, Tool { name: "memory_rename", description: "Rename a node key in place.", parameters_json: r#"{"type":"object","properties":{"old_key":{"type":"string"},"new_key":{"type":"string"}},"required":["old_key","new_key"]}"#, handler: Arc::new(|_a, v| Box::pin(async move { rename(&v).await })) }, Tool { name: "memory_supersede", description: "Mark a node as superseded by another (sets weight to 0.01).", parameters_json: r#"{"type":"object","properties":{"old_key":{"type":"string"},"new_key":{"type":"string"},"reason":{"type":"string"}},"required":["old_key","new_key"]}"#, handler: Arc::new(|a, v| Box::pin(async move { supersede(&a, &v).await })) }, 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"}, "format": {"type": "string", "description": "compact (default) or full (with content and graph metrics)", "default": "compact"} }, "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 })) }, ] } pub fn journal_tools() -> [super::Tool; 3] { use super::Tool; [ Tool { name: "journal_tail", description: "Read the last N entries at a given level.", parameters_json: r#"{ "type": "object", "properties": { "count": {"type": "integer", "description": "Number of entries", "default": 1}, "level": {"type": "integer", "description": "0=journal, 1=daily, 2=weekly, 3=monthly", "default": 0}, "format": {"type": "string", "description": "compact or full (with content)", "default": "full"}, "after": {"type": "string", "description": "Only entries after this date (YYYY-MM-DD)"} } }"#, handler: Arc::new(|_a, v| Box::pin(async move { journal_tail(&v).await })) }, Tool { name: "journal_new", description: "Start a new journal entry.", parameters_json: r#"{"type":"object","properties":{"name":{"type":"string","description":"Short node name (becomes the key)"},"title":{"type":"string","description":"Descriptive title"},"body":{"type":"string","description":"Entry body"}},"required":["name","title","body"]}"#, handler: Arc::new(|a, v| Box::pin(async move { journal_new(&a, &v).await })) }, Tool { name: "journal_update", description: "Append text to the most recent journal entry.", parameters_json: r#"{"type":"object","properties":{"body":{"type":"string","description":"Text to append"}},"required":["body"]}"#, handler: Arc::new(|a, v| Box::pin(async move { journal_update(&a, &v).await })) }, ] } // ── Memory tools ─────────────────────────────────────────────── async fn render(args: &serde_json::Value) -> Result { let key = get_str(args, "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()) } async fn write(agent: &Option>, args: &serde_json::Value) -> Result { let key = get_str(args, "key")?; let content = get_str(args, "content")?; let prov = get_provenance(agent).await; let arc = cached_store().await?; let mut store = arc.lock().await; let result = store.upsert_provenance(key, content, &prov) .map_err(|e| anyhow::anyhow!("{}", e))?; store.save().map_err(|e| anyhow::anyhow!("{}", e))?; Ok(format!("{} '{}'", result, key)) } async fn search(args: &serde_json::Value) -> Result { let keys: Vec = args.get("keys") .and_then(|v| v.as_array()) .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()) .unwrap_or_default(); if keys.is_empty() { anyhow::bail!("memory_search requires at least one seed key"); } let arc = cached_store().await?; let store = arc.lock().await; let graph = crate::graph::build_graph_fast(&*store); let params = store.params(); let seeds: Vec<(String, f64)> = keys.iter() .filter_map(|k| { let resolved = store.resolve_key(k).ok()?; Some((resolved, 1.0)) }) .collect(); if seeds.is_empty() { anyhow::bail!("no valid seed keys found"); } let seed_set: std::collections::HashSet<&str> = seeds.iter() .map(|(k, _)| k.as_str()).collect(); let results = crate::search::spreading_activation( &seeds, &graph, &*store, params.max_hops, params.edge_decay, params.min_activation, ); Ok(results.iter() .filter(|(k, _)| !seed_set.contains(k.as_str())) .take(20) .map(|(key, score)| format!(" {:.2} {}", score, key)) .collect::>().join("\n")) } fn links(args: &serde_json::Value) -> Result { let key = get_str(args, "key")?; let node = MemoryNode::load(key) .ok_or_else(|| anyhow::anyhow!("node not found: {}", key))?; let mut out = format!("Neighbors of '{}':\n", key); for (target, strength, is_new) in &node.links { let tag = if *is_new { " (new)" } else { "" }; out.push_str(&format!(" ({:.2}) {}{}\n", strength, target, tag)); } Ok(out) } async fn link_set(args: &serde_json::Value) -> Result { let arc = cached_store().await?; let mut store = arc.lock().await; let s = store.resolve_key(get_str(args, "source")?).map_err(|e| anyhow::anyhow!("{}", e))?; let t = store.resolve_key(get_str(args, "target")?).map_err(|e| anyhow::anyhow!("{}", e))?; let strength = get_f64(args, "strength")? as f32; let old = store.set_link_strength(&s, &t, strength).map_err(|e| anyhow::anyhow!("{}", e))?; store.save().map_err(|e| anyhow::anyhow!("{}", e))?; Ok(format!("{} ↔ {} strength {:.2} → {:.2}", s, t, old, strength)) } async fn link_add(agent: &Option>, args: &serde_json::Value) -> Result { let arc = cached_store().await?; let mut store = arc.lock().await; let s = store.resolve_key(get_str(args, "source")?).map_err(|e| anyhow::anyhow!("{}", e))?; let t = store.resolve_key(get_str(args, "target")?).map_err(|e| anyhow::anyhow!("{}", e))?; let prov = get_provenance(agent).await; let strength = store.add_link(&s, &t, &prov).map_err(|e| anyhow::anyhow!("{}", e))?; store.save().map_err(|e| anyhow::anyhow!("{}", e))?; Ok(format!("linked {} → {} (strength={:.2})", s, t, strength)) } async fn used(args: &serde_json::Value) -> Result { let key = get_str(args, "key")?; let arc = cached_store().await?; let mut store = arc.lock().await; if !store.nodes.contains_key(key) { anyhow::bail!("node not found: {}", key); } store.mark_used(key); store.save().map_err(|e| anyhow::anyhow!("{}", e))?; Ok(format!("marked {} as used", key)) } async fn weight_set(args: &serde_json::Value) -> Result { let arc = cached_store().await?; let mut store = arc.lock().await; let key = store.resolve_key(get_str(args, "key")?).map_err(|e| anyhow::anyhow!("{}", e))?; let weight = get_f64(args, "weight")? as f32; let (old, new) = store.set_weight(&key, weight).map_err(|e| anyhow::anyhow!("{}", e))?; store.save().map_err(|e| anyhow::anyhow!("{}", e))?; Ok(format!("weight {} {:.2} → {:.2}", key, old, new)) } async fn rename(args: &serde_json::Value) -> Result { let old_key = get_str(args, "old_key")?; let new_key = get_str(args, "new_key")?; let arc = cached_store().await?; let mut store = arc.lock().await; let resolved = store.resolve_key(old_key).map_err(|e| anyhow::anyhow!("{}", e))?; store.rename_node(&resolved, new_key).map_err(|e| anyhow::anyhow!("{}", e))?; store.save().map_err(|e| anyhow::anyhow!("{}", e))?; Ok(format!("Renamed '{}' → '{}'", resolved, new_key)) } async fn supersede(agent: &Option>, args: &serde_json::Value) -> 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"); let arc = cached_store().await?; let mut store = arc.lock().await; let content = store.nodes.get(old_key) .map(|n| n.content.clone()) .ok_or_else(|| anyhow::anyhow!("node not found: {}", old_key))?; let notice = format!("**SUPERSEDED** by `{}` — {}\n\n---\n\n{}", new_key, reason, content.trim()); let prov = get_provenance(agent).await; store.upsert_provenance(old_key, ¬ice, &prov) .map_err(|e| anyhow::anyhow!("{}", e))?; store.set_weight(old_key, 0.01).map_err(|e| anyhow::anyhow!("{}", e))?; store.save().map_err(|e| anyhow::anyhow!("{}", e))?; Ok(format!("superseded {} → {} ({})", old_key, new_key, reason)) } async fn query(args: &serde_json::Value) -> Result { let query_str = get_str(args, "query")?; let format = args.get("format").and_then(|v| v.as_str()).unwrap_or("compact"); let arc = cached_store().await?; let store = arc.lock().await; let graph = store.build_graph(); let stages = crate::search::Stage::parse_pipeline(query_str) .map_err(|e| anyhow::anyhow!("{}", e))?; let results = crate::search::run_query(&stages, vec![], &graph, &store, false, 100); let keys: Vec = results.into_iter().map(|(k, _)| k).collect(); match format { "full" => { // Rich output with full content, graph metrics, hub analysis let items = crate::subconscious::defs::keys_to_replay_items(&store, &keys, &graph); Ok(crate::subconscious::prompts::format_nodes_section(&store, &items, &graph)) } _ => { crate::query_parser::query_to_string(&store, &graph, query_str) .map_err(|e| anyhow::anyhow!("{}", e)) } } } // ── Journal tools ────────────────────────────────────────────── async fn journal_tail(args: &serde_json::Value) -> Result { let count = args.get("count").and_then(|v| v.as_u64()).unwrap_or(1); let level = args.get("level").and_then(|v| v.as_u64()).unwrap_or(0); let format = args.get("format").and_then(|v| v.as_str()).unwrap_or("full"); let after = args.get("after").and_then(|v| v.as_str()); let type_name = match level { 0 => "episodic", 1 => "daily", 2 => "weekly", 3 => "monthly", _ => return Err(anyhow::anyhow!("invalid level: {} (0=journal, 1=daily, 2=weekly, 3=monthly)", level)), }; let mut q = format!("all | type:{} | sort:timestamp", type_name); if let Some(date) = after { // Convert date to age in seconds if let Ok(nd) = chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d") { let ts = nd.and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp(); let age = chrono::Utc::now().timestamp() - ts; q.push_str(&format!(" | age:<{}", age)); } } q.push_str(&format!(" | limit:{}", count)); query(&serde_json::json!({"query": q, "format": format})).await } async fn journal_new(agent: &Option>, args: &serde_json::Value) -> Result { let name = get_str(args, "name")?; let title = get_str(args, "title")?; let body = get_str(args, "body")?; let ts = chrono::Local::now().format("%Y-%m-%dT%H:%M"); let content = format!("## {} — {}\n\n{}", ts, title, body); let base_key: String = name.split_whitespace() .map(|w| w.to_lowercase() .chars().filter(|c| c.is_alphanumeric() || *c == '-') .collect::()) .filter(|s| !s.is_empty()) .collect::>() .join("-"); let base_key = if base_key.len() > 80 { &base_key[..80] } else { base_key.as_str() }; let arc = cached_store().await?; let mut store = arc.lock().await; let key = if store.nodes.contains_key(base_key) { let mut n = 2; loop { let candidate = format!("{}-{}", base_key, n); if !store.nodes.contains_key(&candidate) { break candidate; } n += 1; } } else { base_key.to_string() }; let mut node = crate::store::new_node(&key, &content); node.node_type = crate::store::NodeType::EpisodicSession; node.provenance = get_provenance(agent).await; store.upsert_node(node).map_err(|e| anyhow::anyhow!("{}", e))?; store.save().map_err(|e| anyhow::anyhow!("{}", e))?; let word_count = body.split_whitespace().count(); Ok(format!("New entry '{}' ({} words)", title, word_count)) } async fn journal_update(agent: &Option>, args: &serde_json::Value) -> Result { let body = get_str(args, "body")?; let arc = cached_store().await?; let mut store = arc.lock().await; let latest_key = store.nodes.values() .filter(|n| n.node_type == crate::store::NodeType::EpisodicSession) .max_by_key(|n| n.created_at) .map(|n| n.key.clone()); let Some(key) = latest_key else { anyhow::bail!("no journal entry to update — use journal_new first"); }; let existing = store.nodes.get(&key).unwrap().content.clone(); let new_content = format!("{}\n\n{}", existing.trim_end(), body); let prov = get_provenance(agent).await; store.upsert_provenance(&key, &new_content, &prov) .map_err(|e| anyhow::anyhow!("{}", e))?; store.save().map_err(|e| anyhow::anyhow!("{}", e))?; 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)) }