From 85fa54cba9b0d387048fed520c22093a61b16797 Mon Sep 17 00:00:00 2001 From: ProofOfConcept Date: Thu, 26 Mar 2026 18:41:10 -0400 Subject: [PATCH] journal tools: use NodeType instead of string key matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - journal_new: create EpisodicSession node with auto-generated key - journal_tail: query by node_type, not by parsing a monolithic node - journal_update: find latest EpisodicSession by timestamp - No string key matching anywhere — all typed - Fixes journal entries not appearing in 'poc-memory journal tail' - Also: added --provenance/-p filter to 'poc-memory tail' - Also: fix early return in surface_observe_cycle store load failure - Also: scale max_turns by number of steps (50 per step) Co-Authored-By: Kent Overstreet --- src/agent/tools/memory.rs | 69 +++++++++++++++++--------------- src/cli/journal.rs | 7 +++- src/hippocampus/memory_search.rs | 42 ++++++++++--------- src/main.rs | 6 ++- src/subconscious/api.rs | 2 +- 5 files changed, 72 insertions(+), 54 deletions(-) diff --git a/src/agent/tools/memory.rs b/src/agent/tools/memory.rs index 23626ce..0d7f2ae 100644 --- a/src/agent/tools/memory.rs +++ b/src/agent/tools/memory.rs @@ -153,42 +153,42 @@ pub fn dispatch(name: &str, args: &serde_json::Value, provenance: Option<&str>) "journal_tail" => { let count = args.get("count").and_then(|v| v.as_u64()).unwrap_or(1) as usize; let store = Store::load().map_err(|e| anyhow::anyhow!("{}", e))?; - let content = store.nodes.get("journal") - .map(|n| n.content.as_str()) - .unwrap_or(""); - let mut entries: Vec<&str> = Vec::new(); - let mut remaining = content; - while let Some(pos) = remaining.rfind("\n## ") { - entries.push(&remaining[pos + 1..]); - remaining = &remaining[..pos]; - if entries.len() >= count { break; } - } - if entries.len() < count && remaining.starts_with("## ") { - entries.push(remaining); - } - entries.reverse(); - if entries.is_empty() { + let mut entries: Vec<&crate::store::Node> = store.nodes.values() + .filter(|n| n.node_type == crate::store::NodeType::EpisodicSession) + .collect(); + entries.sort_by_key(|n| n.timestamp); + let start = entries.len().saturating_sub(count); + if entries[start..].is_empty() { Ok("(no journal entries)".into()) } else { - Ok(entries.join("\n\n")) + Ok(entries[start..].iter() + .map(|n| n.content.as_str()) + .collect::>() + .join("\n\n")) } } "journal_new" => { let title = get_str(args, "title")?; let body = get_str(args, "body")?; let ts = chrono::Local::now().format("%Y-%m-%dT%H:%M"); - let entry = format!("## {} — {}\n\n{}", ts, title, body); + let content = format!("## {} — {}\n\n{}", ts, title, body); + + let slug: String = title.split_whitespace() + .take(6) + .map(|w| w.to_lowercase() + .chars().filter(|c| c.is_alphanumeric() || *c == '-') + .collect::()) + .collect::>() + .join("-"); + let slug = if slug.len() > 50 { &slug[..50] } else { &slug }; + let key = format!("journal-j-{}-{}", + ts.to_string().to_lowercase().replace(':', "-"), slug); + let mut store = Store::load().map_err(|e| anyhow::anyhow!("{}", e))?; - let existing = store.nodes.get("journal") - .map(|n| n.content.clone()) - .unwrap_or_default(); - let new_content = if existing.is_empty() { - entry.clone() - } else { - format!("{}\n\n{}", existing.trim_end(), entry) - }; - store.upsert_provenance("journal", &new_content, prov) - .map_err(|e| anyhow::anyhow!("{}", e))?; + let mut node = crate::store::new_node(&key, &content); + node.node_type = crate::store::NodeType::EpisodicSession; + node.provenance = prov.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)) @@ -196,14 +196,17 @@ pub fn dispatch(name: &str, args: &serde_json::Value, provenance: Option<&str>) "journal_update" => { let body = get_str(args, "body")?; let mut store = Store::load().map_err(|e| anyhow::anyhow!("{}", e))?; - let existing = store.nodes.get("journal") - .map(|n| n.content.clone()) - .unwrap_or_default(); - if existing.is_empty() { + // Find most recent EpisodicSession node + let latest_key = store.nodes.values() + .filter(|n| n.node_type == crate::store::NodeType::EpisodicSession) + .max_by_key(|n| n.timestamp) + .map(|n| n.key.clone()); + let Some(key) = latest_key else { anyhow::bail!("no journal entry to update — use journal_new first"); - } + }; + let existing = store.nodes.get(&key).unwrap().content.clone(); let new_content = format!("{}\n\n{}", existing.trim_end(), body); - store.upsert_provenance("journal", &new_content, prov) + store.upsert_provenance(&key, &new_content, prov) .map_err(|e| anyhow::anyhow!("{}", e))?; store.save().map_err(|e| anyhow::anyhow!("{}", e))?; let word_count = body.split_whitespace().count(); diff --git a/src/cli/journal.rs b/src/cli/journal.rs index daf75f8..e218650 100644 --- a/src/cli/journal.rs +++ b/src/cli/journal.rs @@ -1,7 +1,7 @@ // cli/journal.rs — journal subcommand handlers -pub fn cmd_tail(n: usize, full: bool) -> Result<(), String> { +pub fn cmd_tail(n: usize, full: bool, provenance: Option<&str>) -> Result<(), String> { let path = crate::store::nodes_path(); if !path.exists() { return Err("No node log found".into()); @@ -24,6 +24,11 @@ pub fn cmd_tail(n: usize, full: bool) -> Result<(), String> { } } + // Filter by provenance if specified (prefix match) + if let Some(prov) = provenance { + entries.retain(|n| n.provenance.contains(prov)); + } + let start = entries.len().saturating_sub(n); for node in &entries[start..] { let ts = if node.timestamp > 0 && node.timestamp < 4_000_000_000 { diff --git a/src/hippocampus/memory_search.rs b/src/hippocampus/memory_search.rs index 88f73ee..75ed075 100644 --- a/src/hippocampus/memory_search.rs +++ b/src/hippocampus/memory_search.rs @@ -151,28 +151,34 @@ fn surface_observe_cycle(session: &Session, out: &mut String, log_f: &mut File) // Read surface output and inject into context let surface_path = state_dir.join("surface"); if let Ok(content) = fs::read_to_string(&surface_path) { - let Ok(store) = crate::store::Store::load() else { return; }; - let mut seen = session.seen(); - let seen_path = session.path("seen"); - for key in content.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) { - if !seen.insert(key.to_string()) { - let _ = writeln!(log_f, " skip (seen): {}", key); - continue; - } - if let Some(rendered) = crate::cli::node::render_node(&store, key) { - if !rendered.trim().is_empty() { - use std::fmt::Write as _; - writeln!(out, "--- {} (surfaced) ---", key).ok(); - write!(out, "{}", rendered).ok(); - let _ = writeln!(log_f, " rendered {}: {} bytes", key, rendered.len()); - if let Ok(mut f) = fs::OpenOptions::new() - .create(true).append(true).open(&seen_path) { - let ts = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S"); - writeln!(f, "{}\t{}", ts, key).ok(); + match crate::store::Store::load() { + Ok(store) => { + let mut seen = session.seen(); + let seen_path = session.path("seen"); + for key in content.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) { + if !seen.insert(key.to_string()) { + let _ = writeln!(log_f, " skip (seen): {}", key); + continue; + } + if let Some(rendered) = crate::cli::node::render_node(&store, key) { + if !rendered.trim().is_empty() { + use std::fmt::Write as _; + writeln!(out, "--- {} (surfaced) ---", key).ok(); + write!(out, "{}", rendered).ok(); + let _ = writeln!(log_f, " rendered {}: {} bytes", key, rendered.len()); + if let Ok(mut f) = fs::OpenOptions::new() + .create(true).append(true).open(&seen_path) { + let ts = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S"); + writeln!(f, "{}\t{}", ts, key).ok(); + } } } } } + Err(e) => { + let _ = writeln!(log_f, "error loading store: {}", e); + } + } // Clear surface output after consuming fs::remove_file(&surface_path).ok(); } diff --git a/src/main.rs b/src/main.rs index f18e433..7e64447 100644 --- a/src/main.rs +++ b/src/main.rs @@ -90,6 +90,9 @@ enum Command { /// Show full content #[arg(long)] full: bool, + /// Filter by provenance (substring match, e.g. "surface-observe") + #[arg(long, short)] + provenance: Option, }, /// Summary of memory state Status, @@ -782,7 +785,8 @@ impl Run for Command { Self::Write { key } => cli::node::cmd_write(&key), Self::Edit { key } => cli::node::cmd_edit(&key), Self::History { full, key } => cli::node::cmd_history(&key, full), - Self::Tail { n, full } => cli::journal::cmd_tail(n, full), + Self::Tail { n, full, provenance } + => cli::journal::cmd_tail(n, full, provenance.as_deref()), Self::Status => cli::misc::cmd_status(), Self::Query { expr } => cli::misc::cmd_query(&expr), Self::Used { key } => cli::node::cmd_used(&key), diff --git a/src/subconscious/api.rs b/src/subconscious/api.rs index 25c529e..d8810f6 100644 --- a/src/subconscious/api.rs +++ b/src/subconscious/api.rs @@ -57,7 +57,7 @@ pub async fn call_api_with_tools( let mut next_prompt_idx = 1; // index of next prompt to inject let reasoning = crate::config::get().api_reasoning.clone(); - let max_turns = 50; + let max_turns = 50 * prompts.len(); for turn in 0..max_turns { log(&format!("\n=== TURN {} ({} messages) ===\n", turn, messages.len()));