memory_links: return typed Vec<LinkInfo> with node weights

- hippocampus::memory_links now returns Vec<LinkInfo> with key,
  link_strength, and node_weight for each neighbor
- Unified memory_tool! macro: mut/ref as token, single main rule
- All tools use serde serialize/deserialize for RPC consistency
- jsonargs handlers now work in client mode (RPC to daemon)
- cli/graph.rs formats LinkInfo for display

Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
Kent Overstreet 2026-04-13 15:12:06 -04:00
parent 359955f838
commit 598f0112a4
3 changed files with 67 additions and 57 deletions

View file

@ -209,6 +209,12 @@ macro_rules! memory_tool {
(@param_type Option<u32>) => { Option<u32> };
(@param_type Option<f64>) => { Option<f64> };
// Serialize result for jsonargs
(@serialize $t:ty, $result:expr) => { serde_json::to_string(&$result)? };
// Deserialize RPC response
(@deserialize $t:ty, $json:expr) => { serde_json::from_str(&$json).map_err(|e| anyhow::anyhow!("{}", e)) };
// Serialize to JSON for RPC
(@insert_json $map:ident, $name:ident, str) => {
$map.insert(stringify!($name).into(), serde_json::json!($name));
@ -241,64 +247,45 @@ macro_rules! memory_tool {
if let Some(v) = $name { $map.insert(stringify!($name).into(), serde_json::json!(v)); }
};
// ── Main rules ─────────────────────────────────────────────────
// Mutable store variant
($name:ident, mut $(, $($arg:ident : [$($typ:tt)+]),* $(,)?)?) => {
paste::paste! {
async fn [<jsonargs_ $name>](agent: &Option<std::sync::Arc<crate::agent::Agent>>, args: &serde_json::Value) -> Result<String> {
$($(let $arg = memory_tool!(@extract args, $arg, $($typ)+);)*)?
let prov = get_provenance(agent).await;
match access() {
StoreAccess::Daemon(arc) => {
let mut store = arc.lock().await;
crate::hippocampus::$name(&mut store, &prov $($(, $arg)*)?)
}
StoreAccess::Client => anyhow::bail!("jsonargs called in client mode"),
StoreAccess::None(err) => anyhow::bail!("{}", err),
}
}
pub async fn $name(agent: Option<&crate::agent::Agent> $($(, $arg: memory_tool!(@param_type $($typ)+))*)?) -> Result<String> {
let prov = match agent {
Some(a) => a.state.lock().await.provenance.clone(),
None => "manual".to_string(),
};
match access() {
StoreAccess::Daemon(arc) => {
let mut store = arc.lock().await;
crate::hippocampus::$name(&mut store, &prov $($(, $arg)*)?)
}
StoreAccess::Client => {
#[allow(unused_mut)]
let mut map = serde_json::Map::new();
$($(memory_tool!(@insert_json map, $arg, $($typ)+);)*)?
memory_rpc(stringify!($name), serde_json::Value::Object(map))
}
StoreAccess::None(err) => anyhow::bail!("{}", err),
}
}
}
// Call hippocampus with appropriate mutability
(@call mut, $name:ident, $store:ident, $prov:expr $(, $arg:expr)*) => {
crate::hippocampus::$name(&mut $store, $prov $(, $arg)*)
};
(@call ref, $name:ident, $store:ident, $prov:expr $(, $arg:expr)*) => {
crate::hippocampus::$name(&$store, $prov $(, $arg)*)
};
// Immutable store variant
($name:ident, ref $(, $($arg:ident : [$($typ:tt)+]),* $(,)?)?) => {
// ── Main rules ─────────────────────────────────────────────────
// Shorthand: mut/ref without return type defaults to String
($name:ident, $m:ident $(, $($arg:ident : [$($typ:tt)+]),* $(,)?)?) => {
memory_tool!($name, $m -> String $(, $($arg : [$($typ)+]),*)?);
};
// Full form with return type
($name:ident, $m:ident -> $ret:ty $(, $($arg:ident : [$($typ:tt)+]),* $(,)?)?) => {
paste::paste! {
async fn [<jsonargs_ $name>](agent: &Option<std::sync::Arc<crate::agent::Agent>>, args: &serde_json::Value) -> Result<String> {
$($(let $arg = memory_tool!(@extract args, $arg, $($typ)+);)*)?
let prov = get_provenance(agent).await;
match access() {
StoreAccess::Daemon(arc) => {
let store = arc.lock().await;
crate::hippocampus::$name(&store, &prov $($(, $arg)*)?)
#[allow(unused_mut)]
let mut store = arc.lock().await;
let result: $ret = memory_tool!(@call $m, $name, store, &prov $($(, $arg)*)?)?;
Ok(memory_tool!(@serialize $ret, result))
}
StoreAccess::Client => {
#[allow(unused_mut)]
let mut map = serde_json::Map::new();
$($(memory_tool!(@insert_json map, $arg, $($typ)+);)*)?
memory_rpc(stringify!($name), serde_json::Value::Object(map))
}
StoreAccess::Client => anyhow::bail!("jsonargs called in client mode"),
StoreAccess::None(err) => anyhow::bail!("{}", err),
}
}
pub async fn $name(agent: Option<&crate::agent::Agent> $($(, $arg: memory_tool!(@param_type $($typ)+))*)?) -> Result<String> {
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(),
@ -306,14 +293,16 @@ macro_rules! memory_tool {
match access() {
StoreAccess::Daemon(arc) => {
let store = arc.lock().await;
crate::hippocampus::$name(&store, &prov $($(, $arg)*)?)
#[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)+);)*)?
memory_rpc(stringify!($name), serde_json::Value::Object(map))
let json = memory_rpc(stringify!($name), serde_json::Value::Object(map))?;
memory_tool!(@deserialize $ret, json)
}
StoreAccess::None(err) => anyhow::bail!("{}", err),
}
@ -327,7 +316,6 @@ macro_rules! memory_tool {
memory_tool!(memory_render, ref, key: [str], raw: [Option<bool>]);
memory_tool!(memory_write, mut, key: [str], content: [str]);
memory_tool!(memory_search, ref, keys: [Vec<String>], max_hops: [Option<u32>], edge_decay: [Option<f64>], min_activation: [Option<f64>], limit: [Option<usize>]);
memory_tool!(memory_links, ref, key: [str]);
memory_tool!(memory_link_set, mut, source: [str], target: [str], strength: [f32]);
memory_tool!(memory_link_add, mut, source: [str], target: [str]);
memory_tool!(memory_delete, mut, key: [str]);
@ -337,6 +325,11 @@ 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;
memory_tool!(memory_links, ref -> Vec<LinkInfo>, key: [str]);
// ── Journal tools ──────────────────────────────────────────────
memory_tool!(journal_tail, ref, count: [Option<u64>], level: [Option<u64>], format: [Option<&str>], after: [Option<&str>]);

View file

@ -28,9 +28,12 @@ pub async fn cmd_link(key: &[String]) -> Result<(), String> {
return Err("link requires a key".into());
}
let key = key.join(" ");
let result = memory::memory_links(None, &key).await
let links = memory::memory_links(None, &key).await
.map_err(|e| e.to_string())?;
print!("{}", result);
println!("Neighbors of '{}':", key);
for link in links {
println!(" ({:.2}) {} [w={:.2}]", link.link_strength, link.key, link.node_weight);
}
Ok(())
}

View file

@ -84,15 +84,29 @@ pub fn memory_search(
.collect::<Vec<_>>().join("\n"))
}
pub fn memory_links(store: &Store, _provenance: &str, key: &str) -> Result<String> {
/// 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<Vec<LinkInfo>> {
let node = MemoryNode::from_store(store, 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));
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(out)
Ok(links)
}
pub fn memory_link_set(store: &mut Store, _provenance: &str, source: &str, target: &str, strength: f32) -> Result<String> {