hippocampus: move MemoryNode + store ops to where they belong

MemoryNode moved from agent/memory.rs to hippocampus/memory.rs — it's
a view over hippocampus data, not agent-specific.

Store operations (set_weight, set_link_strength, add_link) moved into
store/ops.rs. CLI code (cli/graph.rs, cli/node.rs) and agent tools
both call the same store methods now. render_node() delegates to
MemoryNode::from_store().render() — 3 lines instead of 40.

Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
ProofOfConcept 2026-03-25 01:55:21 -04:00
parent 4b97bb2f2e
commit 10932cb67e
10 changed files with 108 additions and 191 deletions

View file

@ -1,146 +0,0 @@
// agent/memory.rs — Agent's live view of memory nodes
//
// MemoryNode is the agent's in-memory representation of a loaded
// graph node. Unlike the store's Node (which has all the metadata),
// this holds what the agent needs: the key, rendered content, and
// links for navigation. The agent's context window tracks which
// MemoryNodes are currently loaded.
use crate::store::Store;
/// A memory node loaded into the agent's working memory.
#[derive(Debug, Clone)]
pub struct MemoryNode {
pub key: String,
pub content: String,
pub links: Vec<Link>,
/// Version from the store — used for change detection.
pub version: u32,
/// Weight in the graph.
pub weight: f32,
}
/// A link to a neighbor node.
#[derive(Debug, Clone)]
pub struct Link {
pub target: String,
pub strength: f32,
/// Whether this link target is already referenced inline in the content.
pub inline: bool,
}
impl MemoryNode {
/// Load a node from the store by key. Returns None if not found.
pub fn load(key: &str) -> Option<Self> {
let store = Store::load().ok()?;
Self::from_store(&store, key)
}
/// Load from an already-open store.
pub fn from_store(store: &Store, key: &str) -> Option<Self> {
let node = store.nodes.get(key)?;
// Collect neighbor strengths
let mut neighbors: std::collections::HashMap<&str, f32> = std::collections::HashMap::new();
for r in &store.relations {
if r.deleted { continue; }
if r.source_key == key {
let e = neighbors.entry(&r.target_key).or_insert(0.0);
*e = e.max(r.strength);
} else if r.target_key == key {
let e = neighbors.entry(&r.source_key).or_insert(0.0);
*e = e.max(r.strength);
}
}
let mut links: Vec<Link> = neighbors.into_iter()
.map(|(target, strength)| Link {
inline: node.content.contains(target),
target: target.to_string(),
strength,
})
.collect();
links.sort_by(|a, b| b.strength.total_cmp(&a.strength));
Some(MemoryNode {
key: key.to_string(),
content: node.content.clone(),
links,
version: node.version,
weight: node.weight,
})
}
/// Render for inclusion in the context window.
pub fn render(&self) -> String {
let mut out = self.content.clone();
// Footer: links not already referenced inline
let footer_links: Vec<&Link> = self.links.iter()
.filter(|l| !l.inline)
.collect();
if !footer_links.is_empty() {
let total = footer_links.len();
out.push_str("\n\n---\nLinks:");
for link in footer_links.iter().take(15) {
out.push_str(&format!("\n ({:.2}) `poc-memory render {}`",
link.strength, link.target));
}
if total > 15 {
out.push_str(&format!("\n ... and {} more (`poc-memory graph link {}`)",
total - 15, self.key));
}
}
out
}
/// Write content to the store and return an updated MemoryNode.
pub fn write(key: &str, content: &str, provenance: Option<&str>) -> Result<Self, String> {
let prov = provenance.unwrap_or("manual");
let mut store = Store::load()?;
store.upsert_provenance(key, content, prov)?;
store.save()?;
Self::from_store(&store, key)
.ok_or_else(|| format!("wrote {} but failed to load back", key))
}
/// Search for nodes matching a query. Returns lightweight results.
pub fn search(query: &str) -> Result<Vec<SearchResult>, String> {
let store = Store::load()?;
let results = crate::search::search(query, &store);
Ok(results.into_iter().take(20).map(|hit| SearchResult {
key: hit.key.clone(),
score: hit.activation as f32,
snippet: hit.snippet.unwrap_or_default(),
}).collect())
}
/// Mark a node as used (boosts weight).
pub fn mark_used(key: &str) -> Result<String, String> {
let mut store = Store::load()?;
if !store.nodes.contains_key(key) {
return Err(format!("node not found: {}", key));
}
store.mark_used(key);
store.save()?;
Ok(format!("marked {} as used", key))
}
}
/// A search result — lightweight, not a full node load.
#[derive(Debug, Clone)]
pub struct SearchResult {
pub key: String,
pub score: f32,
pub snippet: String,
}
impl SearchResult {
/// Format for display.
pub fn render(&self) -> String {
format!("({:.2}) {}{}", self.score, self.key, self.snippet)
}
}

View file

@ -28,7 +28,6 @@ pub mod tools;
pub mod ui_channel;
pub mod journal;
pub mod memory;
pub mod runner;
pub mod cli;
pub mod context;

View file

@ -16,8 +16,6 @@
use anyhow::Result;
use tiktoken_rs::CoreBPE;
use std::io::Write;
use crate::agent::api::ApiClient;
use crate::agent::journal;
use crate::agent::log::ConversationLog;
@ -445,7 +443,7 @@ impl Agent {
match call.function.name.as_str() {
"memory_render" | "memory_links" => {
if let Some(key) = args.get("key").and_then(|v| v.as_str()) {
if let Some(node) = crate::agent::memory::MemoryNode::load(key) {
if let Some(node) = crate::hippocampus::memory::MemoryNode::load(key) {
// Replace if already tracked, otherwise add
if let Some(existing) = self.context.loaded_nodes.iter_mut()
.find(|n| n.key == node.key) {
@ -458,7 +456,7 @@ impl Agent {
}
"memory_write" => {
if let Some(key) = args.get("key").and_then(|v| v.as_str()) {
if let Some(node) = crate::agent::memory::MemoryNode::load(key) {
if let Some(node) = crate::hippocampus::memory::MemoryNode::load(key) {
// Refresh if already tracked
if let Some(existing) = self.context.loaded_nodes.iter_mut()
.find(|n| n.key == node.key) {

View file

@ -7,9 +7,9 @@
use anyhow::{Context, Result};
use serde_json::json;
use crate::agent::memory::MemoryNode;
use crate::hippocampus::memory::MemoryNode;
use crate::agent::types::ToolDef;
use crate::store::{self, Store};
use crate::store::Store;
pub fn definitions() -> Vec<ToolDef> {
vec![
@ -246,70 +246,18 @@ fn link_set(source: &str, target: &str, strength: f32) -> Result<String> {
let mut store = Store::load().map_err(|e| anyhow::anyhow!("{}", e))?;
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 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)
{
if first {
let old = rel.strength;
rel.strength = strength;
first = false;
found = true;
if (old - strength).abs() < 0.001 {
return Ok(format!("{}{} already at {:.2}", source, target, strength));
}
} else {
rel.deleted = true; // deduplicate
}
}
}
if !found {
anyhow::bail!("no link found between {} and {}", source, target);
}
let old = store.set_link_strength(&source, &target, strength)
.map_err(|e| anyhow::anyhow!("{}", e))?;
store.save().map_err(|e| anyhow::anyhow!("{}", e))?;
Ok(format!("set {}{} strength to {:.2}", source, target, strength))
Ok(format!("set {}{} strength {:.2}{:.2}", source, target, old, strength))
}
fn link_add(source: &str, target: &str, prov: &str) -> Result<String> {
let mut store = Store::load().map_err(|e| anyhow::anyhow!("{}", e))?;
let source = store.resolve_key(source).map_err(|e| anyhow::anyhow!("{}", e))?;
let target = store.resolve_key(target).map_err(|e| anyhow::anyhow!("{}", e))?;
// Check for existing link
let exists = store.relations.iter().any(|r|
!r.deleted &&
((r.source_key == source && r.target_key == target) ||
(r.source_key == target && r.target_key == source)));
if exists {
return Ok(format!("link already exists: {}{}", source, target));
}
let source_uuid = store.nodes.get(&source)
.map(|n| n.uuid)
.ok_or_else(|| anyhow::anyhow!("source not found: {}", source))?;
let target_uuid = store.nodes.get(&target)
.map(|n| n.uuid)
.ok_or_else(|| anyhow::anyhow!("target not found: {}", target))?;
// Compute initial strength from Jaccard similarity
let graph = store.build_graph();
let jaccard = graph.jaccard(&source, &target);
let strength = (jaccard * 3.0).clamp(0.1, 1.0) as f32;
let mut rel = store::new_relation(
source_uuid, target_uuid,
store::RelationType::Link, strength,
&source, &target,
);
rel.provenance = prov.to_string();
store.add_relation(rel).map_err(|e| anyhow::anyhow!("{}", e))?;
let strength = store.add_link(&source, &target, prov)
.map_err(|e| anyhow::anyhow!("{}", e))?;
store.save().map_err(|e| anyhow::anyhow!("{}", e))?;
Ok(format!("linked {}{} (strength={:.2})", source, target, strength))
}
@ -317,14 +265,10 @@ fn link_add(source: &str, target: &str, prov: &str) -> Result<String> {
fn weight_set(key: &str, weight: f32) -> Result<String> {
let mut store = Store::load().map_err(|e| anyhow::anyhow!("{}", e))?;
let resolved = store.resolve_key(key).map_err(|e| anyhow::anyhow!("{}", e))?;
let weight = weight.clamp(0.01, 1.0);
let node = store.nodes.get_mut(&resolved)
.ok_or_else(|| anyhow::anyhow!("node not found: {}", resolved))?;
let old = node.weight;
node.weight = weight;
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, weight))
Ok(format!("weight {} {:.2}{:.2}", resolved, old, new))
}
fn supersede(args: &serde_json::Value, prov: &str) -> Result<String> {

View file

@ -327,7 +327,7 @@ pub struct ContextState {
/// Memory nodes currently loaded in the context window.
/// Tracked so the agent knows what it's "seeing" and can
/// refresh nodes after writes.
pub loaded_nodes: Vec<crate::agent::memory::MemoryNode>,
pub loaded_nodes: Vec<crate::hippocampus::memory::MemoryNode>,
}
pub const WORKING_STACK_INSTRUCTIONS: &str = "/home/kent/.config/poc-agent/working-stack.md";