// hippocampus — memory storage, retrieval, and consolidation // // The graph-structured memory system: nodes, relations, queries, // similarity scoring, spectral analysis, and neuroscience-inspired // consolidation (spaced repetition, interference detection, schema // assimilation). // // Tool implementations are typed functions that take &Store or &mut Store. // The tools/memory.rs layer handles JSON parsing and RPC routing. pub mod memory; pub mod store; pub mod graph; pub mod lookups; pub mod query; pub mod spectral; pub mod neuro; pub mod counters; pub mod transcript; use anyhow::Result; use crate::hippocampus::memory::MemoryNode; use crate::hippocampus::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 ────────────────────────────────────────────── pub fn journal_tail(store: &Store, _provenance: &str, count: Option, level: Option, format: Option<&str>, after: Option<&str>) -> Result { let count = count.unwrap_or(1); let level = level.unwrap_or(0); let format = format.unwrap_or("full"); 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 = std::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(&std::format!(" | age:<{}", age)); } } q.push_str(&std::format!(" | limit:{}", count)); memory_query(store, _provenance, &q, Some(format)) } 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("