forked from kent/consciousness
centralize memory store interface in hippocampus/mod.rs
This commit is contained in:
parent
063cf031d3
commit
5db00e083f
8 changed files with 899 additions and 723 deletions
|
|
@ -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<Option<Arc<crate::Mutex<Store>>>> = OnceLock::new();
|
||||
|
||||
// Client's socket connection (thread-local for lock-free access).
|
||||
thread_local! {
|
||||
static SOCKET_CONN: RefCell<Option<SocketConn>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// How we access the memory store.
|
||||
enum StoreAccess {
|
||||
Daemon(Arc<crate::Mutex<Store>>), // 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<crate::Mutex<Store>>) {
|
||||
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<std::os::unix::net::UnixStream>,
|
||||
writer: std::io::BufWriter<std::os::unix::net::UnixStream>,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
impl SocketConn {
|
||||
fn connect() -> Result<Self> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<bool>])
|
||||
// - 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<std::sync::Arc<crate::agent::Agent>>) ->
|
|||
}
|
||||
}
|
||||
|
||||
// ── Macro for generating tool wrappers ─────────────────────────
|
||||
//
|
||||
// memory_tool!(name, mut, arg1: [str], arg2: [Option<bool>])
|
||||
// - 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<LinkInfo>, key: [str]);
|
||||
|
||||
// ── Journal tools ──────────────────────────────────────────────
|
||||
|
||||
pub use crate::hippocampus::JournalEntry;
|
||||
pub use crate::hippocampus::local::JournalEntry;
|
||||
|
||||
memory_tool!(journal_tail, ref -> Vec<JournalEntry>, count: [Option<u64>], level: [Option<u64>], after: [Option<&str>]);
|
||||
memory_tool!(journal_new, mut, name: [str], title: [str], body: [str], level: [Option<i64>]);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
590
src/hippocampus/local.rs
Normal file
590
src/hippocampus/local.rs
Normal file
|
|
@ -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<bool>) -> Result<String> {
|
||||
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<String> {
|
||||
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<String>,
|
||||
max_hops: Option<u32>,
|
||||
edge_decay: Option<f64>,
|
||||
min_activation: Option<f64>,
|
||||
limit: Option<usize>,
|
||||
) -> Result<String> {
|
||||
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::<Vec<_>>().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<Vec<LinkInfo>> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<bool>) -> Result<String> {
|
||||
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<crate::store::Node> = Vec::new();
|
||||
while let Ok(msg) = capnp::serialize::read_message(&mut reader, capnp::message::ReaderOptions::new()) {
|
||||
let log = msg.get_root::<crate::memory_capnp::node_log::Reader>()
|
||||
.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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<ReplayItem> {
|
||||
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<String> {
|
||||
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<String> = 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<u64>, level: Option<u64>, after: Option<&str>) -> Result<Vec<JournalEntry>> {
|
||||
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<i64>) -> Result<String> {
|
||||
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::<String>())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.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<i64>) -> Result<String> {
|
||||
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<String> {
|
||||
let graph = store.build_graph();
|
||||
Ok(crate::subconscious::prompts::format_topology_header(store, &graph))
|
||||
}
|
||||
|
||||
pub fn graph_health(store: &Store, _provenance: &str) -> Result<String> {
|
||||
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<usize>, min_size: Option<usize>) -> Result<String> {
|
||||
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<bool>) -> Result<String> {
|
||||
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<String> {
|
||||
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<usize>) -> Result<String> {
|
||||
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<String> = 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<String> {
|
||||
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("<!--"))
|
||||
.unwrap_or(""),
|
||||
80);
|
||||
writeln!(out, " [{:.2}] {}", s, k).ok();
|
||||
if !n.source_ref.is_empty() {
|
||||
writeln!(out, " ↳ source: {}", n.source_ref).ok();
|
||||
}
|
||||
writeln!(out, " {}", preview).ok();
|
||||
}
|
||||
}
|
||||
|
||||
if !semantic.is_empty() {
|
||||
writeln!(out, "Semantic links:").ok();
|
||||
for (k, s, _) in &semantic {
|
||||
writeln!(out, " [{:.2}] {}", s, k).ok();
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(out, "\nLinks: {} session, {} daily, {} weekly, {} semantic",
|
||||
episodic_session.len(), episodic_daily.len(),
|
||||
episodic_weekly.len(), semantic.len()).ok();
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
pub mod memory;
|
||||
pub mod store;
|
||||
pub mod graph;
|
||||
pub mod local;
|
||||
pub mod lookups;
|
||||
pub mod query;
|
||||
pub mod spectral;
|
||||
|
|
@ -18,593 +19,320 @@ pub mod neuro;
|
|||
pub mod counters;
|
||||
pub mod transcript;
|
||||
|
||||
use anyhow::Result;
|
||||
use crate::hippocampus::memory::MemoryNode;
|
||||
use std::cell::RefCell;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use anyhow::{Context, Result};
|
||||
use crate::hippocampus::store::Store;
|
||||
use crate::graph::Graph;
|
||||
use crate::neuro::{consolidation_priority, ReplayItem};
|
||||
|
||||
// ── Memory operations ──────────────────────────────────────────
|
||||
pub use local::{LinkInfo, JournalEntry};
|
||||
|
||||
pub fn memory_render(store: &Store, _provenance: &str, key: &str, raw: Option<bool>) -> Result<String> {
|
||||
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())
|
||||
// ── Store access ───────────────────────────────────────────────
|
||||
|
||||
/// Daemon's store (eager init) or client's fallback local store.
|
||||
static STORE_ACCESS: OnceLock<Option<Arc<crate::Mutex<Store>>>> = OnceLock::new();
|
||||
|
||||
// Client's socket connection (thread-local for lock-free access).
|
||||
thread_local! {
|
||||
static SOCKET_CONN: RefCell<Option<SocketConn>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// How we access the memory store.
|
||||
pub enum StoreAccess {
|
||||
Daemon(Arc<crate::Mutex<Store>>), // 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<crate::Mutex<Store>>) {
|
||||
STORE_ACCESS.set(Some(store)).ok();
|
||||
}
|
||||
|
||||
/// Get store access: daemon's store, socket, or local fallback.
|
||||
pub 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 memory_write(store: &mut Store, provenance: &str, key: &str, content: &str) -> Result<String> {
|
||||
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 socket_path() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_default()
|
||||
.join(".consciousness/mcp.sock")
|
||||
}
|
||||
|
||||
pub fn memory_search(
|
||||
store: &Store,
|
||||
_provenance: &str,
|
||||
keys: Vec<String>,
|
||||
max_hops: Option<u32>,
|
||||
edge_decay: Option<f64>,
|
||||
min_activation: Option<f64>,
|
||||
limit: Option<usize>,
|
||||
) -> Result<String> {
|
||||
if keys.is_empty() {
|
||||
anyhow::bail!("memory_search requires at least one seed key");
|
||||
struct SocketConn {
|
||||
reader: std::io::BufReader<std::os::unix::net::UnixStream>,
|
||||
writer: std::io::BufWriter<std::os::unix::net::UnixStream>,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
impl SocketConn {
|
||||
fn connect() -> Result<Self> {
|
||||
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 })
|
||||
}
|
||||
|
||||
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);
|
||||
fn call(&mut self, tool_name: &str, args: &serde_json::Value) -> Result<String> {
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
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");
|
||||
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())
|
||||
}
|
||||
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::<Vec<_>>().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,
|
||||
/// Forward a tool call to the daemon via socket.
|
||||
/// Only valid when access() returns Client.
|
||||
pub fn memory_rpc(tool_name: &str, args: serde_json::Value) -> Result<String> {
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
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 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,
|
||||
});
|
||||
// ── 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<f64> {
|
||||
args.get(name).and_then(|v| v.as_f64()).context(format!("{} is required", name))
|
||||
}
|
||||
|
||||
/// Get provenance from agent state, or "manual".
|
||||
async fn get_provenance(agent: &Option<std::sync::Arc<crate::agent::Agent>>) -> String {
|
||||
match agent {
|
||||
Some(a) => a.state.lock().await.provenance.clone(),
|
||||
None => "manual".to_string(),
|
||||
}
|
||||
Ok(links)
|
||||
}
|
||||
|
||||
pub fn memory_link_set(store: &mut Store, _provenance: &str, source: &str, target: &str, strength: f32) -> Result<String> {
|
||||
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))
|
||||
}
|
||||
// ── Macro for generating tool wrappers ─────────────────────────
|
||||
//
|
||||
// memory_tool!(name, mut, arg1: [str], arg2: [Option<bool>])
|
||||
// - mut/ref for store mutability
|
||||
// - generates jsonargs_* (internal, JSON args) and public typed API
|
||||
|
||||
pub fn memory_link_add(store: &mut Store, provenance: &str, source: &str, target: &str) -> Result<String> {
|
||||
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))
|
||||
}
|
||||
macro_rules! memory_tool {
|
||||
// ── Helper rules (must come first) ─────────────────────────────
|
||||
|
||||
pub fn memory_delete(store: &mut Store, _provenance: &str, key: &str) -> Result<String> {
|
||||
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))
|
||||
}
|
||||
// Extract from JSON
|
||||
(@extract $args:ident, $name:ident, str) => {
|
||||
get_str($args, stringify!($name))?
|
||||
};
|
||||
(@extract $args:ident, $name:ident, f32) => {
|
||||
get_f64($args, stringify!($name))? as f32
|
||||
};
|
||||
(@extract $args:ident, $name:ident, Vec<String>) => {
|
||||
$args.get(stringify!($name))
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect::<Vec<_>>())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
(@extract $args:ident, $name:ident, Option<&str>) => {
|
||||
$args.get(stringify!($name)).and_then(|v| v.as_str())
|
||||
};
|
||||
(@extract $args:ident, $name:ident, Option<bool>) => {
|
||||
$args.get(stringify!($name)).and_then(|v| v.as_bool())
|
||||
};
|
||||
(@extract $args:ident, $name:ident, Option<u64>) => {
|
||||
$args.get(stringify!($name)).and_then(|v| v.as_u64())
|
||||
};
|
||||
(@extract $args:ident, $name:ident, Option<i64>) => {
|
||||
$args.get(stringify!($name)).and_then(|v| v.as_i64())
|
||||
};
|
||||
(@extract $args:ident, $name:ident, Option<usize>) => {
|
||||
$args.get(stringify!($name)).and_then(|v| v.as_u64()).map(|v| v as usize)
|
||||
};
|
||||
(@extract $args:ident, $name:ident, Option<u32>) => {
|
||||
$args.get(stringify!($name)).and_then(|v| v.as_u64()).map(|v| v as u32)
|
||||
};
|
||||
(@extract $args:ident, $name:ident, Option<f64>) => {
|
||||
$args.get(stringify!($name)).and_then(|v| v.as_f64())
|
||||
};
|
||||
|
||||
pub fn memory_history(store: &Store, _provenance: &str, key: &str, full: Option<bool>) -> Result<String> {
|
||||
let key = store.resolve_key(key).unwrap_or_else(|_| key.to_string());
|
||||
let full = full.unwrap_or(false);
|
||||
// Parameter types for function signatures
|
||||
(@param_type str) => { &str };
|
||||
(@param_type f32) => { f32 };
|
||||
(@param_type Vec<String>) => { Vec<String> };
|
||||
(@param_type Option<&str>) => { Option<&str> };
|
||||
(@param_type Option<bool>) => { Option<bool> };
|
||||
(@param_type Option<u64>) => { Option<u64> };
|
||||
(@param_type Option<i64>) => { Option<i64> };
|
||||
(@param_type Option<usize>) => { Option<usize> };
|
||||
(@param_type Option<u32>) => { Option<u32> };
|
||||
(@param_type Option<f64>) => { Option<f64> };
|
||||
|
||||
let path = crate::store::nodes_path();
|
||||
if !path.exists() {
|
||||
anyhow::bail!("No node log found");
|
||||
}
|
||||
// Serialize result for jsonargs
|
||||
(@serialize $t:ty, $result:expr) => { serde_json::to_string(&$result)? };
|
||||
|
||||
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);
|
||||
// Deserialize RPC response
|
||||
(@deserialize $t:ty, $json:expr) => { serde_json::from_str(&$json).map_err(|e| anyhow::anyhow!("{}", e)) };
|
||||
|
||||
let mut versions: Vec<crate::store::Node> = Vec::new();
|
||||
while let Ok(msg) = capnp::serialize::read_message(&mut reader, capnp::message::ReaderOptions::new()) {
|
||||
let log = msg.get_root::<crate::memory_capnp::node_log::Reader>()
|
||||
.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);
|
||||
// Serialize to JSON for RPC
|
||||
(@insert_json $map:ident, $name:ident, str) => {
|
||||
$map.insert(stringify!($name).into(), serde_json::json!($name));
|
||||
};
|
||||
(@insert_json $map:ident, $name:ident, f32) => {
|
||||
$map.insert(stringify!($name).into(), serde_json::json!($name));
|
||||
};
|
||||
(@insert_json $map:ident, $name:ident, Vec<String>) => {
|
||||
$map.insert(stringify!($name).into(), serde_json::json!($name));
|
||||
};
|
||||
(@insert_json $map:ident, $name:ident, Option<&str>) => {
|
||||
if let Some(v) = $name { $map.insert(stringify!($name).into(), serde_json::json!(v)); }
|
||||
};
|
||||
(@insert_json $map:ident, $name:ident, Option<bool>) => {
|
||||
if let Some(v) = $name { $map.insert(stringify!($name).into(), serde_json::json!(v)); }
|
||||
};
|
||||
(@insert_json $map:ident, $name:ident, Option<u64>) => {
|
||||
if let Some(v) = $name { $map.insert(stringify!($name).into(), serde_json::json!(v)); }
|
||||
};
|
||||
(@insert_json $map:ident, $name:ident, Option<i64>) => {
|
||||
if let Some(v) = $name { $map.insert(stringify!($name).into(), serde_json::json!(v)); }
|
||||
};
|
||||
(@insert_json $map:ident, $name:ident, Option<usize>) => {
|
||||
if let Some(v) = $name { $map.insert(stringify!($name).into(), serde_json::json!(v)); }
|
||||
};
|
||||
(@insert_json $map:ident, $name:ident, Option<u32>) => {
|
||||
if let Some(v) = $name { $map.insert(stringify!($name).into(), serde_json::json!(v)); }
|
||||
};
|
||||
(@insert_json $map:ident, $name:ident, Option<f64>) => {
|
||||
if let Some(v) = $name { $map.insert(stringify!($name).into(), serde_json::json!(v)); }
|
||||
};
|
||||
|
||||
// Call hippocampus with appropriate mutability
|
||||
(@call mut, $name:ident, $store:ident, $prov:expr $(, $arg:expr)*) => {
|
||||
local::$name(&mut $store, $prov $(, $arg)*)
|
||||
};
|
||||
(@call ref, $name:ident, $store:ident, $prov:expr $(, $arg:expr)*) => {
|
||||
local::$name(&$store, $prov $(, $arg)*)
|
||||
};
|
||||
|
||||
// ── 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! {
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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))
|
||||
}
|
||||
// ── Memory tools ───────────────────────────────────────────────
|
||||
|
||||
pub fn memory_rename(store: &mut Store, _provenance: &str, old_key: &str, new_key: &str) -> Result<String> {
|
||||
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<String> {
|
||||
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<ReplayItem> {
|
||||
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<String> {
|
||||
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<String> = 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
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_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]);
|
||||
memory_tool!(memory_history, ref, key: [str], full: [Option<bool>]);
|
||||
memory_tool!(memory_weight_set, mut, key: [str], weight: [f32]);
|
||||
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>]);
|
||||
memory_tool!(memory_links, ref -> Vec<LinkInfo>, key: [str]);
|
||||
|
||||
// ── 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<u64>, level: Option<u64>, after: Option<&str>) -> Result<Vec<JournalEntry>> {
|
||||
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<i64>) -> Result<String> {
|
||||
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::<String>())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.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<i64>) -> Result<String> {
|
||||
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))
|
||||
}
|
||||
memory_tool!(journal_tail, ref -> Vec<JournalEntry>, count: [Option<u64>], level: [Option<u64>], after: [Option<&str>]);
|
||||
memory_tool!(journal_new, mut, name: [str], title: [str], body: [str], level: [Option<i64>]);
|
||||
memory_tool!(journal_update, mut, body: [str], level: [Option<i64>]);
|
||||
|
||||
// ── Graph tools ───────────────────────────────────────────────
|
||||
|
||||
pub fn graph_topology(store: &Store, _provenance: &str) -> Result<String> {
|
||||
let graph = store.build_graph();
|
||||
Ok(crate::subconscious::prompts::format_topology_header(store, &graph))
|
||||
}
|
||||
|
||||
pub fn graph_health(store: &Store, _provenance: &str) -> Result<String> {
|
||||
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<usize>, min_size: Option<usize>) -> Result<String> {
|
||||
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<bool>) -> Result<String> {
|
||||
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<String> {
|
||||
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<usize>) -> Result<String> {
|
||||
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<String> = 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<String> {
|
||||
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("<!--"))
|
||||
.unwrap_or(""),
|
||||
80);
|
||||
writeln!(out, " [{:.2}] {}", s, k).ok();
|
||||
if !n.source_ref.is_empty() {
|
||||
writeln!(out, " ↳ source: {}", n.source_ref).ok();
|
||||
}
|
||||
writeln!(out, " {}", preview).ok();
|
||||
}
|
||||
}
|
||||
|
||||
if !semantic.is_empty() {
|
||||
writeln!(out, "Semantic links:").ok();
|
||||
for (k, s, _) in &semantic {
|
||||
writeln!(out, " [{:.2}] {}", s, k).ok();
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(out, "\nLinks: {} session, {} daily, {} weekly, {} semantic",
|
||||
episodic_session.len(), episodic_daily.len(),
|
||||
episodic_weekly.len(), semantic.len()).ok();
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
memory_tool!(graph_topology, ref);
|
||||
memory_tool!(graph_health, ref);
|
||||
memory_tool!(graph_communities, ref, top_n: [Option<usize>], min_size: [Option<usize>]);
|
||||
memory_tool!(graph_normalize_strengths, mut, apply: [Option<bool>]);
|
||||
memory_tool!(graph_link_impact, ref, source: [str], target: [str]);
|
||||
memory_tool!(graph_hubs, ref, count: [Option<usize>]);
|
||||
memory_tool!(graph_trace, ref, key: [str]);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue