From 5db00e083f8a9658899b7bafade9a4ae48af0ba9 Mon Sep 17 00:00:00 2001 From: Kent Overstreet Date: Mon, 13 Apr 2026 17:44:33 -0400 Subject: [PATCH] centralize memory store interface in hippocampus/mod.rs --- src/agent/tools/memory.rs | 184 +-------- src/cli/admin.rs | 4 +- src/cli/agent.rs | 2 +- src/cli/graph.rs | 2 +- src/cli/journal.rs | 2 +- src/cli/node.rs | 2 +- src/hippocampus/local.rs | 590 +++++++++++++++++++++++++++ src/hippocampus/mod.rs | 836 +++++++++++++------------------------- 8 files changed, 899 insertions(+), 723 deletions(-) create mode 100644 src/hippocampus/local.rs diff --git a/src/agent/tools/memory.rs b/src/agent/tools/memory.rs index 81f24bc..93d36ff 100644 --- a/src/agent/tools/memory.rs +++ b/src/agent/tools/memory.rs @@ -6,136 +6,25 @@ #![allow(unused_variables)] // macro-generated args for no-param tools use anyhow::{Context, Result}; -use std::cell::RefCell; -use std::path::PathBuf; -use std::sync::{Arc, OnceLock}; +use std::sync::Arc; +use crate::hippocampus::{access, memory_rpc, StoreAccess}; -use crate::store::Store; +// Re-export typed API from hippocampus for backward compatibility +pub use crate::hippocampus::{ + memory_render, memory_write, memory_search, memory_link_set, memory_link_add, + memory_delete, memory_history, memory_weight_set, memory_rename, memory_supersede, + memory_query, memory_links, + journal_tail, journal_new, journal_update, + graph_topology, graph_health, graph_communities, graph_normalize_strengths, + graph_link_impact, graph_hubs, graph_trace, + set_store, socket_path, +}; -// ── Store access ─────────────────────────────────────────────── - -/// Daemon's store (eager init) or client's fallback local store. -static STORE_ACCESS: OnceLock>>> = OnceLock::new(); - -// Client's socket connection (thread-local for lock-free access). -thread_local! { - static SOCKET_CONN: RefCell> = const { RefCell::new(None) }; -} - -/// How we access the memory store. -enum StoreAccess { - Daemon(Arc>), // Direct store access - Client, // Socket to daemon (in thread-local) - None(String), // Error: couldn't get access -} - -/// Set the global store handle. Call once at daemon startup (eager init). -pub fn set_store(store: Arc>) { - STORE_ACCESS.set(Some(store)).ok(); -} - -/// Get store access: daemon's store, socket, or local fallback. -fn access() -> StoreAccess { - // Daemon: already set via set_store() - if let Some(Some(store)) = STORE_ACCESS.get() { - return StoreAccess::Daemon(store.clone()); - } - - // Client: check if socket already cached in thread-local - let have_socket = SOCKET_CONN.with(|cell| cell.borrow().is_some()); - if have_socket { - return StoreAccess::Client; - } - - // No socket cached, try connecting - if let Ok(conn) = SocketConn::connect() { - SOCKET_CONN.with(|cell| *cell.borrow_mut() = Some(conn)); - return StoreAccess::Client; - } - - // Socket failed - try local store as fallback (cached in STORE_ACCESS) - let store_opt = STORE_ACCESS.get_or_init(|| { - Store::load().ok().map(|s| Arc::new(crate::Mutex::new(s))) - }); - - match store_opt { - Some(store) => StoreAccess::Daemon(store.clone()), - None => StoreAccess::None("could not connect to daemon or open store locally".into()), - } -} - -pub fn socket_path() -> PathBuf { - dirs::home_dir() - .unwrap_or_default() - .join(".consciousness/mcp.sock") -} - -struct SocketConn { - reader: std::io::BufReader, - writer: std::io::BufWriter, - next_id: u64, -} - -impl SocketConn { - fn connect() -> Result { - use std::os::unix::net::UnixStream; - use std::io::{BufRead, BufReader, BufWriter, Write}; - - let path = socket_path(); - let stream = UnixStream::connect(&path)?; - let mut reader = BufReader::new(stream.try_clone()?); - let mut writer = BufWriter::new(stream); - - // Initialize MCP connection - let init = serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": {"protocolVersion": "2024-11-05", "capabilities": {}, - "clientInfo": {"name": "forward", "version": "0.1"}}}); - writeln!(writer, "{}", init)?; - writer.flush()?; - let mut buf = String::new(); - reader.read_line(&mut buf)?; - - Ok(Self { reader, writer, next_id: 1 }) - } - - fn call(&mut self, tool_name: &str, args: &serde_json::Value) -> Result { - use std::io::{BufRead, Write}; - - self.next_id += 1; - let call = serde_json::json!({"jsonrpc": "2.0", "id": self.next_id, "method": "tools/call", - "params": {"name": tool_name, "arguments": args}}); - writeln!(self.writer, "{}", call)?; - self.writer.flush()?; - - let mut buf = String::new(); - self.reader.read_line(&mut buf)?; - - let resp: serde_json::Value = serde_json::from_str(&buf)?; - if let Some(err) = resp.get("error") { - anyhow::bail!("daemon error: {}", err); - } - let result = resp.get("result").cloned().unwrap_or(serde_json::json!({})); - let text = result.get("content") - .and_then(|c| c.as_array()) - .and_then(|arr| arr.first()) - .and_then(|c| c.get("text")) - .and_then(|t| t.as_str()) - .unwrap_or(""); - Ok(text.to_string()) - } -} - -/// Forward a tool call to the daemon via socket. -/// Only valid when access() returns Client. -fn memory_rpc(tool_name: &str, args: serde_json::Value) -> Result { - SOCKET_CONN.with(|cell| { - let mut conn = cell.borrow_mut(); - let conn = conn.as_mut().expect("access() returned Client but SOCKET_CONN is None"); - conn.call(tool_name, &args) - }) -} - -// ── Helpers ──────────────────────────────────────────────────── +// ── Macro for generating tool wrappers ───────────────────────── +// +// memory_tool!(name, mut, arg1: [str], arg2: [Option]) +// - mut/ref for store mutability +// - generates jsonargs_* (internal, JSON args) and public typed API 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)) @@ -153,12 +42,6 @@ async fn get_provenance(agent: &Option>) -> } } -// ── Macro for generating tool wrappers ───────────────────────── -// -// memory_tool!(name, mut, arg1: [str], arg2: [Option]) -// - mut/ref for store mutability -// - generates jsonargs_* (internal, JSON args) and public typed API - macro_rules! memory_tool { // ── Helper rules (must come first) ───────────────────────────── @@ -249,10 +132,10 @@ macro_rules! memory_tool { // Call hippocampus with appropriate mutability (@call mut, $name:ident, $store:ident, $prov:expr $(, $arg:expr)*) => { - crate::hippocampus::$name(&mut $store, $prov $(, $arg)*) + crate::hippocampus::local::$name(&mut $store, $prov $(, $arg)*) }; (@call ref, $name:ident, $store:ident, $prov:expr $(, $arg:expr)*) => { - crate::hippocampus::$name(&$store, $prov $(, $arg)*) + crate::hippocampus::local::$name(&$store, $prov $(, $arg)*) }; // ── Main rules ───────────────────────────────────────────────── @@ -284,29 +167,6 @@ macro_rules! memory_tool { StoreAccess::None(err) => anyhow::bail!("{}", err), } } - - pub async fn $name(agent: Option<&crate::agent::Agent> $($(, $arg: memory_tool!(@param_type $($typ)+))*)?) -> Result<$ret> { - let prov = match agent { - Some(a) => a.state.lock().await.provenance.clone(), - None => "manual".to_string(), - }; - - match access() { - StoreAccess::Daemon(arc) => { - #[allow(unused_mut)] - let mut store = arc.lock().await; - memory_tool!(@call $m, $name, store, &prov $($(, $arg)*)?) - } - StoreAccess::Client => { - #[allow(unused_mut)] - let mut map = serde_json::Map::new(); - $($(memory_tool!(@insert_json map, $arg, $($typ)+);)*)? - let json = memory_rpc(stringify!($name), serde_json::Value::Object(map))?; - memory_tool!(@deserialize $ret, json) - } - StoreAccess::None(err) => anyhow::bail!("{}", err), - } - } } }; } @@ -325,14 +185,14 @@ memory_tool!(memory_rename, mut, old_key: [str], new_key: [str]); memory_tool!(memory_supersede, mut, old_key: [str], new_key: [str], reason: [Option<&str>]); memory_tool!(memory_query, ref, query: [str], format: [Option<&str>]); -// Re-export LinkInfo for callers -pub use crate::hippocampus::LinkInfo; +// Re-export types and typed API from hippocampus +pub use crate::hippocampus::local::LinkInfo; memory_tool!(memory_links, ref -> Vec, key: [str]); // ── Journal tools ────────────────────────────────────────────── -pub use crate::hippocampus::JournalEntry; +pub use crate::hippocampus::local::JournalEntry; memory_tool!(journal_tail, ref -> Vec, count: [Option], level: [Option], after: [Option<&str>]); memory_tool!(journal_new, mut, name: [str], title: [str], body: [str], level: [Option]); diff --git a/src/cli/admin.rs b/src/cli/admin.rs index 03bd3b6..b06099b 100644 --- a/src/cli/admin.rs +++ b/src/cli/admin.rs @@ -1,5 +1,6 @@ // cli/admin.rs — admin subcommand handlers +use crate::hippocampus as memory; use crate::store; fn install_default_file(data_dir: &std::path::Path, name: &str, content: &str) -> Result<(), String> { @@ -329,7 +330,6 @@ pub fn cmd_dedup(apply: bool) -> Result<(), String> { } pub async fn cmd_health() -> Result<(), String> { - use crate::agent::tools::memory; let result = memory::graph_health(None).await .map_err(|e| e.to_string())?; print!("{}", result); @@ -337,7 +337,6 @@ pub async fn cmd_health() -> Result<(), String> { } pub async fn cmd_topology() -> Result<(), String> { - use crate::agent::tools::memory; let result = memory::graph_topology(None).await .map_err(|e| e.to_string())?; print!("{}", result); @@ -421,7 +420,6 @@ pub fn cmd_export(files: &[String], export_all: bool) -> Result<(), String> { } pub async fn cmd_status() -> Result<(), String> { - use crate::agent::tools::memory; let result = memory::graph_topology(None).await .map_err(|e| e.to_string())?; print!("{}", result); diff --git a/src/cli/agent.rs b/src/cli/agent.rs index 8cd7e92..6ddf8bb 100644 --- a/src/cli/agent.rs +++ b/src/cli/agent.rs @@ -1,6 +1,6 @@ // cli/agent.rs — agent subcommand handlers -use crate::agent::tools::memory; +use crate::hippocampus as memory; use crate::store; pub async fn cmd_run_agent(agent: &str, count: usize, target: &[String], query: Option<&str>, dry_run: bool, _local: bool, state_dir: Option<&str>) -> Result<(), String> { diff --git a/src/cli/graph.rs b/src/cli/graph.rs index fad9a45..06807ab 100644 --- a/src/cli/graph.rs +++ b/src/cli/graph.rs @@ -4,7 +4,7 @@ // link, link-add, link-impact, link-audit, cap-degree, // normalize-strengths, trace, spectral-*, organize, communities. -use crate::agent::tools::memory; +use crate::hippocampus as memory; use crate::store; pub fn cmd_cap_degree(max_deg: usize) -> Result<(), String> { diff --git a/src/cli/journal.rs b/src/cli/journal.rs index 2152f9d..e3cb324 100644 --- a/src/cli/journal.rs +++ b/src/cli/journal.rs @@ -1,6 +1,6 @@ // cli/journal.rs — journal subcommand handlers -use crate::agent::tools::memory; +use crate::hippocampus as memory; pub fn cmd_tail(n: usize, full: bool, provenance: Option<&str>, dedup: bool) -> Result<(), String> { let path = crate::store::nodes_path(); diff --git a/src/cli/node.rs b/src/cli/node.rs index 2745041..17914f2 100644 --- a/src/cli/node.rs +++ b/src/cli/node.rs @@ -3,7 +3,7 @@ // render, write, node-delete, node-rename, history, list-keys, // list-edges, dump-json, lookup-bump, lookups. -use crate::agent::tools::memory; +use crate::hippocampus as memory; use crate::store; pub async fn cmd_weight_set(key: &str, weight: f32) -> Result<(), String> { diff --git a/src/hippocampus/local.rs b/src/hippocampus/local.rs new file mode 100644 index 0000000..a357cea --- /dev/null +++ b/src/hippocampus/local.rs @@ -0,0 +1,590 @@ +use anyhow::Result; +use super::memory::MemoryNode; +use super::store::Store; +use crate::graph::Graph; +use crate::neuro::{consolidation_priority, ReplayItem}; + +// ── Memory operations ────────────────────────────────────────── + +pub fn memory_render(store: &Store, _provenance: &str, key: &str, raw: Option) -> Result { + let node = MemoryNode::from_store(store, key) + .ok_or_else(|| anyhow::anyhow!("node not found: {}", key))?; + if raw.unwrap_or(false) { + Ok(node.content) + } else { + Ok(node.render()) + } +} + +pub fn memory_write(store: &mut Store, provenance: &str, key: &str, content: &str) -> Result { + let result = store.upsert_provenance(key, content, provenance) + .map_err(|e| anyhow::anyhow!("{}", e))?; + store.save().map_err(|e| anyhow::anyhow!("{}", e))?; + Ok(format!("{} '{}'", result, key)) +} + +pub fn memory_search( + store: &Store, + _provenance: &str, + keys: Vec, + max_hops: Option, + edge_decay: Option, + min_activation: Option, + limit: Option, +) -> Result { + if keys.is_empty() { + anyhow::bail!("memory_search requires at least one seed key"); + } + + let max_hops = max_hops.unwrap_or(3); + let edge_decay = edge_decay.unwrap_or(0.3); + let min_activation = min_activation.unwrap_or(0.01); + let limit = limit.unwrap_or(20); + + let graph = crate::graph::build_graph_fast(store); + 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, + max_hops, edge_decay, min_activation, + ); + Ok(results.iter() + .filter(|(k, _)| !seed_set.contains(k.as_str())) + .take(limit) + .map(|(key, score)| format!(" {:.2} {}", score, key)) + .collect::>().join("\n")) +} + +/// Info about a linked neighbor node. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct LinkInfo { + pub key: String, + pub link_strength: f32, + pub node_weight: f32, +} + +pub fn memory_links(store: &Store, _provenance: &str, key: &str) -> Result> { + let node = MemoryNode::from_store(store, key) + .ok_or_else(|| anyhow::anyhow!("node not found: {}", key))?; + let mut links = Vec::new(); + for (target, strength, _is_new) in &node.links { + let node_weight = store.nodes.get(target.as_str()) + .map(|n| n.weight) + .unwrap_or(0.5); + links.push(LinkInfo { + key: target.clone(), + link_strength: *strength, + node_weight, + }); + } + Ok(links) +} + +pub fn memory_link_set(store: &mut Store, _provenance: &str, source: &str, target: &str, strength: f32) -> Result { + let s = store.resolve_key(source).map_err(|e| anyhow::anyhow!("{}", e))?; + let t = store.resolve_key(target).map_err(|e| anyhow::anyhow!("{}", e))?; + 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)) +} + +pub fn memory_link_add(store: &mut Store, provenance: &str, source: &str, target: &str) -> Result { + let s = store.resolve_key(source).map_err(|e| anyhow::anyhow!("{}", e))?; + let t = store.resolve_key(target).map_err(|e| anyhow::anyhow!("{}", e))?; + let strength = store.add_link(&s, &t, provenance).map_err(|e| anyhow::anyhow!("{}", e))?; + store.save().map_err(|e| anyhow::anyhow!("{}", e))?; + Ok(format!("linked {} → {} (strength={:.2})", s, t, strength)) +} + +pub fn memory_delete(store: &mut Store, _provenance: &str, key: &str) -> Result { + let resolved = store.resolve_key(key).map_err(|e| anyhow::anyhow!("{}", e))?; + store.delete_node(&resolved).map_err(|e| anyhow::anyhow!("{}", e))?; + store.save().map_err(|e| anyhow::anyhow!("{}", e))?; + Ok(format!("deleted {}", resolved)) +} + +pub fn memory_history(store: &Store, _provenance: &str, key: &str, full: Option) -> Result { + let key = store.resolve_key(key).unwrap_or_else(|_| key.to_string()); + let full = full.unwrap_or(false); + + let path = crate::store::nodes_path(); + if !path.exists() { + anyhow::bail!("No node log found"); + } + + use std::io::BufReader; + let file = std::fs::File::open(&path) + .map_err(|e| anyhow::anyhow!("open {}: {}", path.display(), e))?; + let mut reader = BufReader::new(file); + + let mut versions: Vec = Vec::new(); + while let Ok(msg) = capnp::serialize::read_message(&mut reader, capnp::message::ReaderOptions::new()) { + let log = msg.get_root::() + .map_err(|e| anyhow::anyhow!("read log: {}", e))?; + for node_reader in log.get_nodes() + .map_err(|e| anyhow::anyhow!("get nodes: {}", e))? { + let node = crate::store::Node::from_capnp_migrate(node_reader) + .map_err(|e| anyhow::anyhow!("{}", e))?; + if node.key == key { + versions.push(node); + } + } + } + + if versions.is_empty() { + anyhow::bail!("No history found for '{}'", key); + } + + let mut out = format!("{} versions of '{}':\n\n", versions.len(), key); + for node in &versions { + let ts = crate::store::format_datetime(node.timestamp); + let deleted = if node.deleted { " DELETED" } else { "" }; + if full { + out.push_str(&format!("=== v{} {} {}{} w={:.3} {}b ===\n", + node.version, ts, node.provenance, deleted, node.weight, node.content.len())); + out.push_str(&node.content); + out.push('\n'); + } else { + let preview = crate::util::first_n_chars(&node.content, 120).replace('\n', "\\n"); + out.push_str(&format!("v{:<3} {} {:24} w={:.3} {}b{}\n {}\n", + node.version, ts, node.provenance, node.weight, node.content.len(), deleted, preview)); + } + } + Ok(out) +} + +pub fn memory_weight_set(store: &mut Store, _provenance: &str, key: &str, weight: f32) -> Result { + let resolved = store.resolve_key(key).map_err(|e| anyhow::anyhow!("{}", e))?; + let (old, new) = store.set_weight(&resolved, weight).map_err(|e| anyhow::anyhow!("{}", e))?; + store.save().map_err(|e| anyhow::anyhow!("{}", e))?; + Ok(format!("weight {} {:.2} → {:.2}", resolved, old, new)) +} + +pub fn memory_rename(store: &mut Store, _provenance: &str, old_key: &str, new_key: &str) -> Result { + 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)) +} + +pub fn memory_supersede(store: &mut Store, provenance: &str, old_key: &str, new_key: &str, reason: Option<&str>) -> Result { + let reason = reason.unwrap_or("superseded"); + 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()); + store.upsert_provenance(old_key, ¬ice, provenance) + .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)) +} + +/// Convert a list of keys to ReplayItems with priority and graph metrics. +pub fn keys_to_replay_items( + store: &Store, + keys: &[String], + graph: &Graph, +) -> Vec { + keys.iter() + .filter_map(|key| { + let node = store.nodes.get(key)?; + let priority = consolidation_priority(store, key, graph, None); + let cc = graph.clustering_coefficient(key); + + Some(ReplayItem { + key: key.clone(), + priority, + interval_days: node.spaced_repetition_interval, + emotion: node.emotion, + cc, + classification: "unknown", + outlier_score: 0.0, + }) + }) + .collect() +} + +pub fn memory_query(store: &Store, _provenance: &str, query_str: &str, format: Option<&str>) -> Result { + let graph = store.build_graph(); + + match format.unwrap_or("compact") { + "full" => { + // Rich output with full content, graph metrics, hub analysis + let results = crate::query_parser::execute_query(store, &graph, query_str) + .map_err(|e| anyhow::anyhow!("{}", e))?; + let keys: Vec = results.into_iter().map(|r| r.key).collect(); + let items = keys_to_replay_items(store, &keys, &graph); + Ok(crate::subconscious::prompts::format_nodes_section(store, &items, &graph)) + } + _ => { + // Compact output: handles count, select, and all expression types + crate::query_parser::query_to_string(store, &graph, query_str) + .map_err(|e| anyhow::anyhow!("{}", e)) + } + } +} + +// ── Journal tools ────────────────────────────────────────────── + +/// A journal entry with key, content, and timestamp. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct JournalEntry { + pub key: String, + pub content: String, + pub created_at: i64, +} + +/// Get journal entries, sorted by timestamp (newest first). +/// level: 0=session, 1=daily, 2=weekly, 3=monthly +/// after: only entries after this date (YYYY-MM-DD) +pub fn journal_tail(store: &Store, _provenance: &str, count: Option, level: Option, after: Option<&str>) -> Result> { + let count = count.unwrap_or(10) as usize; + let level = level.unwrap_or(0); + let node_type = match level { + 0 => crate::store::NodeType::EpisodicSession, + 1 => crate::store::NodeType::EpisodicDaily, + 2 => crate::store::NodeType::EpisodicWeekly, + 3 => crate::store::NodeType::EpisodicMonthly, + _ => return Err(anyhow::anyhow!("invalid level: {}", level)), + }; + + let after_ts = after.and_then(|date| { + chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d").ok() + .and_then(|nd| nd.and_hms_opt(0, 0, 0)) + .map(|dt| dt.and_utc().timestamp()) + }); + + let mut entries: Vec<_> = store.nodes.values() + .filter(|n| n.node_type == node_type) + .filter(|n| after_ts.map(|ts| n.created_at >= ts).unwrap_or(true)) + .map(|n| JournalEntry { + key: n.key.clone(), + content: n.content.clone(), + created_at: n.created_at, + }) + .collect(); + entries.sort_by_key(|e| std::cmp::Reverse(e.created_at)); + entries.truncate(count); + Ok(entries) +} + +fn level_to_node_type(level: i64) -> crate::store::NodeType { + match level { + 1 => crate::store::NodeType::EpisodicDaily, + 2 => crate::store::NodeType::EpisodicWeekly, + 3 => crate::store::NodeType::EpisodicMonthly, + _ => crate::store::NodeType::EpisodicSession, + } +} + +pub fn journal_new(store: &mut Store, provenance: &str, name: &str, title: &str, body: &str, level: Option) -> Result { + let level = level.unwrap_or(0); + 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 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 = level_to_node_type(level); + node.provenance = provenance.to_string(); + 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)) +} + +pub fn journal_update(store: &mut Store, provenance: &str, body: &str, level: Option) -> Result { + let level = level.unwrap_or(0); + let node_type = level_to_node_type(level); + let latest_key = store.nodes.values() + .filter(|n| n.node_type == node_type) + .max_by_key(|n| n.created_at) + .map(|n| n.key.clone()); + let Some(key) = latest_key else { + anyhow::bail!("no entry at level {} to update — use journal_new first", level); + }; + let existing = store.nodes.get(&key).unwrap().content.clone(); + let new_content = format!("{}\n\n{}", existing.trim_end(), body); + store.upsert_provenance(&key, &new_content, provenance) + .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 ─────────────────────────────────────────────── + +pub fn graph_topology(store: &Store, _provenance: &str) -> Result { + let graph = store.build_graph(); + Ok(crate::subconscious::prompts::format_topology_header(store, &graph)) +} + +pub fn graph_health(store: &Store, _provenance: &str) -> Result { + let graph = store.build_graph(); + Ok(crate::subconscious::prompts::format_health_section(store, &graph)) +} + +pub fn graph_communities(store: &Store, _provenance: &str, top_n: Option, min_size: Option) -> Result { + let top_n = top_n.unwrap_or(10); + let min_size = min_size.unwrap_or(3); + let g = store.build_graph(); + let infos = g.community_info(); + + let total = infos.len(); + let shown: Vec<_> = infos.into_iter() + .filter(|c| c.size >= min_size) + .take(top_n) + .collect(); + + use std::fmt::Write; + let mut out = String::new(); + writeln!(out, "{} communities total ({} with size >= {})\n", + total, shown.len(), min_size).ok(); + writeln!(out, "{:<6} {:>5} {:>7} {:>7} members", "id", "size", "iso", "cross").ok(); + writeln!(out, "{}", "-".repeat(70)).ok(); + + for c in &shown { + let preview: Vec<&str> = c.members.iter() + .take(5) + .map(|s| s.as_str()) + .collect(); + let more = if c.size > 5 { + format!(" +{}", c.size - 5) + } else { + String::new() + }; + writeln!(out, "{:<6} {:>5} {:>6.0}% {:>7} {}{}", + c.id, c.size, c.isolation * 100.0, c.cross_edges, + preview.join(", "), more).ok(); + } + + Ok(out) +} + +pub fn graph_normalize_strengths(store: &mut Store, _provenance: &str, apply: Option) -> Result { + let apply = apply.unwrap_or(false); + let graph = store.build_graph(); + let strengths = graph.jaccard_strengths(); + + // Build lookup from (source_key, target_key) → new_strength + let mut updates: std::collections::HashMap<(String, String), f32> = std::collections::HashMap::new(); + for (a, b, s) in &strengths { + updates.insert((a.clone(), b.clone()), *s); + updates.insert((b.clone(), a.clone()), *s); + } + + let mut changed = 0usize; + let mut unchanged = 0usize; + let mut temporal_skipped = 0usize; + let mut delta_sum: f64 = 0.0; + let mut buckets = [0usize; 10]; + + for rel in &mut store.relations { + if rel.deleted { continue; } + if rel.strength == 1.0 && rel.rel_type == crate::store::RelationType::Auto { + temporal_skipped += 1; + continue; + } + if let Some(&new_s) = updates.get(&(rel.source_key.clone(), rel.target_key.clone())) { + let old_s = rel.strength; + let delta = (new_s - old_s).abs(); + if delta > 0.001 { + delta_sum += delta as f64; + if apply { rel.strength = new_s; } + changed += 1; + } else { + unchanged += 1; + } + let bucket = ((new_s * 10.0) as usize).min(9); + buckets[bucket] += 1; + } + } + + use std::fmt::Write; + let mut out = String::new(); + writeln!(out, "Normalize link strengths (Jaccard similarity)").ok(); + writeln!(out, " Total edges in graph: {}", strengths.len()).ok(); + writeln!(out, " Would change: {}", changed).ok(); + writeln!(out, " Unchanged: {}", unchanged).ok(); + writeln!(out, " Temporal (skipped): {}", temporal_skipped).ok(); + if changed > 0 { + writeln!(out, " Avg delta: {:.3}", delta_sum / changed as f64).ok(); + } + writeln!(out).ok(); + writeln!(out, " Strength distribution:").ok(); + for (i, &count) in buckets.iter().enumerate() { + let lo = i as f32 / 10.0; + let hi = lo + 0.1; + let bar = "#".repeat(count / 50 + if count > 0 { 1 } else { 0 }); + writeln!(out, " {:.1}-{:.1}: {:5} {}", lo, hi, count, bar).ok(); + } + + if apply { + store.save().map_err(|e| anyhow::anyhow!("{}", e))?; + writeln!(out, "\nApplied {} strength updates.", changed).ok(); + } else { + writeln!(out, "\nDry run. Pass apply:true to write changes.").ok(); + } + + Ok(out) +} + +pub fn graph_link_impact(store: &Store, _provenance: &str, source: &str, target: &str) -> Result { + 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 g = store.build_graph(); + let impact = g.link_impact(&source, &target); + + use std::fmt::Write; + let mut out = String::new(); + writeln!(out, "Link impact: {} → {}", source, target).ok(); + writeln!(out, " Source degree: {} Target degree: {}", impact.source_deg, impact.target_deg).ok(); + writeln!(out, " Hub link: {} Same community: {}", impact.is_hub_link, impact.same_community).ok(); + writeln!(out, " ΔCC source: {:+.4} ΔCC target: {:+.4}", impact.delta_cc_source, impact.delta_cc_target).ok(); + writeln!(out, " ΔGini: {:+.6}", impact.delta_gini).ok(); + writeln!(out, " Assessment: {}", impact.assessment).ok(); + Ok(out) +} + +pub fn graph_hubs(store: &Store, _provenance: &str, count: Option) -> Result { + let count = count.unwrap_or(20); + let graph = store.build_graph(); + + // Top hub nodes by degree, spread apart (skip neighbors of already-selected hubs) + let mut hubs: Vec<(String, usize)> = store.nodes.iter() + .filter(|(k, n)| !n.deleted && !k.starts_with('_')) + .map(|(k, _)| { + let degree = graph.neighbors(k).len(); + (k.clone(), degree) + }) + .collect(); + hubs.sort_by(|a, b| b.1.cmp(&a.1)); + + let mut selected = Vec::new(); + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + for (key, degree) in &hubs { + if seen.contains(key) { continue; } + selected.push(format!(" - {} (degree {})", key, degree)); + // Mark neighbors as seen so we pick far-apart hubs + for (nbr, _) in graph.neighbors(key) { + seen.insert(nbr.clone()); + } + seen.insert(key.clone()); + if selected.len() >= count { break; } + } + + Ok(format!("## Hub nodes (link targets)\n\n{}", selected.join("\n"))) +} + +pub fn graph_trace(store: &Store, _provenance: &str, key: &str) -> Result { + let resolved = store.resolve_key(key).map_err(|e| anyhow::anyhow!("{}", e))?; + let g = store.build_graph(); + + let node = store.nodes.get(&resolved) + .ok_or_else(|| anyhow::anyhow!("Node not found: {}", resolved))?; + + use std::fmt::Write; + let mut out = String::new(); + + writeln!(out, "=== {} ===", resolved).ok(); + writeln!(out, "Type: {:?} Weight: {:.2}", node.node_type, node.weight).ok(); + if !node.source_ref.is_empty() { + writeln!(out, "Source: {}", node.source_ref).ok(); + } + + let preview = crate::util::truncate(&node.content, 200, "..."); + writeln!(out, "\n{}\n", preview).ok(); + + // Walk neighbors, grouped by node type + let neighbors = g.neighbors(&resolved); + let mut episodic_session = Vec::new(); + let mut episodic_daily = Vec::new(); + let mut episodic_weekly = Vec::new(); + let mut semantic = Vec::new(); + + for (n, strength) in &neighbors { + if let Some(nnode) = store.nodes.get(n.as_str()) { + let entry = (n.as_str(), *strength, nnode); + match nnode.node_type { + crate::store::NodeType::EpisodicSession => episodic_session.push(entry), + crate::store::NodeType::EpisodicDaily => episodic_daily.push(entry), + crate::store::NodeType::EpisodicWeekly + | crate::store::NodeType::EpisodicMonthly => episodic_weekly.push(entry), + crate::store::NodeType::Semantic => semantic.push(entry), + } + } + } + + if !episodic_weekly.is_empty() { + writeln!(out, "Weekly digests:").ok(); + for (k, s, n) in &episodic_weekly { + let preview = crate::util::first_n_chars(n.content.lines().next().unwrap_or(""), 80); + writeln!(out, " [{:.2}] {} — {}", s, k, preview).ok(); + } + } + + if !episodic_daily.is_empty() { + writeln!(out, "Daily digests:").ok(); + for (k, s, n) in &episodic_daily { + let preview = crate::util::first_n_chars(n.content.lines().next().unwrap_or(""), 80); + writeln!(out, " [{:.2}] {} — {}", s, k, preview).ok(); + } + } + + if !episodic_session.is_empty() { + writeln!(out, "Session entries:").ok(); + for (k, s, n) in &episodic_session { + let preview = crate::util::first_n_chars( + n.content.lines() + .find(|l| !l.is_empty() && !l.starts_with("