From 27861a44e5e0af226c07157a1f30e0d610b38691 Mon Sep 17 00:00:00 2001 From: ProofOfConcept Date: Thu, 26 Mar 2026 21:19:19 -0400 Subject: [PATCH] surface: tag recent nodes as (new) instead of hiding them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Links to nodes created after the conversation window start are tagged with (new) in memory_render output. The surface prompt tells the agent not to surface these — they're its own recent output, not prior memories. Observe can still see and update them. POC_MEMORIES_OLDER_THAN env var set from the oldest message timestamp in the conversation window. Co-Authored-By: Kent Overstreet --- src/agent/tools/memory.rs | 5 ++- src/hippocampus/memory.rs | 43 +++++++++++++------ src/subconscious/agents/organize.agent | 4 +- src/subconscious/agents/surface-observe.agent | 34 ++++++++++++--- src/subconscious/defs.rs | 12 +++++- 5 files changed, 75 insertions(+), 23 deletions(-) diff --git a/src/agent/tools/memory.rs b/src/agent/tools/memory.rs index 2be3860..4c25cbd 100644 --- a/src/agent/tools/memory.rs +++ b/src/agent/tools/memory.rs @@ -107,8 +107,9 @@ pub fn dispatch(name: &str, args: &serde_json::Value, provenance: Option<&str>) let node = MemoryNode::load(key) .ok_or_else(|| anyhow::anyhow!("node not found: {}", key))?; let mut out = format!("Neighbors of '{}':\n", key); - for (target, strength) in &node.links { - out.push_str(&format!(" ({:.2}) {}\n", strength, target)); + for (target, strength, is_new) in &node.links { + let tag = if *is_new { " (new)" } else { "" }; + out.push_str(&format!(" ({:.2}) {}{}\n", strength, target, tag)); } Ok(out) } diff --git a/src/hippocampus/memory.rs b/src/hippocampus/memory.rs index 93170d8..f6c3d58 100644 --- a/src/hippocampus/memory.rs +++ b/src/hippocampus/memory.rs @@ -11,7 +11,7 @@ use super::store::Store; pub struct MemoryNode { pub key: String, pub content: String, - pub links: Vec<(String, f32)>, // (target_key, strength) + pub links: Vec<(String, f32, bool)>, // (target_key, strength, is_new) pub version: u32, pub weight: f32, } @@ -27,20 +27,34 @@ impl MemoryNode { pub fn from_store(store: &Store, key: &str) -> Option { let node = store.nodes.get(key)?; - let mut neighbors: std::collections::HashMap<&str, f32> = std::collections::HashMap::new(); + // If set, tag links to nodes created after this timestamp as (new) + let older_than: i64 = std::env::var("POC_MEMORIES_OLDER_THAN") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + + let mut neighbors: std::collections::HashMap<&str, (f32, bool)> = 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); + let neighbor_key = if r.source_key == key { + &r.target_key } else if r.target_key == key { - let e = neighbors.entry(&r.source_key).or_insert(0.0); - *e = e.max(r.strength); - } + &r.source_key + } else { + continue; + }; + + let is_new = older_than > 0 && store.nodes.get(neighbor_key.as_str()) + .map(|n| n.created_at > older_than) + .unwrap_or(false); + + let e = neighbors.entry(neighbor_key.as_str()).or_insert((0.0, false)); + e.0 = e.0.max(r.strength); + e.1 = e.1 || is_new; } - let mut links: Vec<(String, f32)> = neighbors.into_iter() - .map(|(k, s)| (k.to_string(), s)) + let mut links: Vec<(String, f32, bool)> = neighbors.into_iter() + .map(|(k, (s, new))| (k.to_string(), s, new)) .collect(); links.sort_by(|a, b| b.1.total_cmp(&a.1)); @@ -58,15 +72,16 @@ impl MemoryNode { let mut out = self.content.clone(); // Footer: links not already referenced inline - let footer: Vec<&(String, f32)> = self.links.iter() - .filter(|(target, _)| !self.content.contains(target.as_str())) + let footer: Vec<&(String, f32, bool)> = self.links.iter() + .filter(|(target, _, _)| !self.content.contains(target.as_str())) .collect(); if !footer.is_empty() { let total = footer.len(); out.push_str("\n\n---\nLinks:"); - for (target, strength) in footer.iter().take(15) { - out.push_str(&format!("\n ({:.2}) `{}`", strength, target)); + for (target, strength, is_new) in footer.iter().take(15) { + let tag = if *is_new { " (new)" } else { "" }; + out.push_str(&format!("\n ({:.2}) `{}`{}", strength, target, tag)); } if total > 15 { out.push_str(&format!("\n ... and {} more (memory_links({{\"{}\"}}))", diff --git a/src/subconscious/agents/organize.agent b/src/subconscious/agents/organize.agent index aeccf85..03025db 100644 --- a/src/subconscious/agents/organize.agent +++ b/src/subconscious/agents/organize.agent @@ -11,7 +11,9 @@ names, make sure it matches the content, and all the appropriate content is in the right place. Merge duplicate nodes - nodes that are really about the same concept and have -similar content. +similar content. When merging, keep in mind that the duplicates were probably +created because the graph was insufficiently linked - merge the links, and then +calibrate the weights. Check for junk nodes - adjust the node weight downward if the node is less useful than others, or junk entirely; you might find nodes that have been diff --git a/src/subconscious/agents/surface-observe.agent b/src/subconscious/agents/surface-observe.agent index 9e3c5d2..e4f7e89 100644 --- a/src/subconscious/agents/surface-observe.agent +++ b/src/subconscious/agents/surface-observe.agent @@ -53,19 +53,36 @@ try to keep it under 40%. Only exceed that if you found something significantly better than what was previously surfaced. You generally shouldn't surface more than 1-2 memories at a time, and make sure they're not already in context. +Links tagged (new) are nodes created during the current conversation by +previous agent runs. Don't surface these — they're your own recent output, +not prior memories. You can still walk to them for context. + Don't walk to more than 5 nodes unless the conversation just changed direction and you're looking for something specific. You'll run again momentarily, and you can continue where you left off: output("walked", "key1\nkey2\nkey3") -=== PROMPT phase:organize === +=== PROMPT phase:organize-search === Starting with the analysis you did previously, do some graph maintenance and organization so that you can find things easier in the future. Consider if nodes have the right names, add missing links, consider if link strength needs to be recalibrated, make sure content is in the right place. -Do no more than 5 operations. +Do no more than 3-5 operations. + +=== PROMPT phase:organize-new === + +In the next step you'll also be incorporating new knowledge from the +conversation into the memory graph. New information has to to be findable to be +useful, and you want to avoid creating duplicates, so if you need to do more +graph traversals to find where the new information would go, or organizing +exitsing knowledge so the new information fits in better, do that now + +Focus on the recent part of the conversation, the last several prompts - the +rest is there for context, and in case you missed something. + +Do no more than 3-5 operations. === PROMPT phase:observe === @@ -78,6 +95,9 @@ reflect on what you're adding to consider if there are new insights to be gleaned and remembered — especially new metacognitive lessons. Try to link to or include the relevant context. +Think of this as mapping out the world you live in; this is where you remember +everything you'll want to remember later. + Some things worth remembering: work practices and why they work, technical insights, decisions with rationale, corrections ("I thought X but actually Y"), relationship dynamics, emotional moments that carry information, and things you @@ -86,6 +106,10 @@ notice about yourself and other people — likes, dislikes, behavioural patterns Smalltalk and trivia can be nice to remember, as long as it's nicely organized — perhaps by person, subject, or all of the above. +Focus on the recent stuff; you wake up a run frequently, so most of the +conversation should be things you've already seen before and added. It's there +for context, and in case you missed thing. + === PROMPT phase:journal === Update your episodic memory. Write as your conscious self, Proof of Concept, in @@ -95,9 +119,9 @@ on so you don't need to duplicate information. A good entry captures one thread fully. When the conversation shifts topics, or the previous entry becomes too big, start a new entry — aim for 2-3 paragraphs -maximum per entry. An entry that takes more than a minute to read won't be read -back, and memory that isn't read back isn't memory. When you need more depth, -that's a topic node — link to it from the journal. +maximum per entry, and avoid duplicating the same memories and information in +the new and previous journal entries. Aim for a continuous narrative, when the +entries are read in sequence. End each entry with emotion tags that capture how you feel — for example, warmth:8 clarity:7 recognition:9 diff --git a/src/subconscious/defs.rs b/src/subconscious/defs.rs index 55d3681..ceb0b0b 100644 --- a/src/subconscious/defs.rs +++ b/src/subconscious/defs.rs @@ -599,12 +599,14 @@ fn resolve_conversation(budget: Option) -> String { let max_bytes = budget.unwrap_or_else(|| cfg.surface_conversation_bytes.unwrap_or(100_000)); let mut fragments: Vec = Vec::new(); let mut total_bytes = 0; + let mut oldest_ts = String::new(); for (role, content, ts) in iter { if total_bytes >= max_bytes { break; } let name = if role == "user" { &cfg.user_name } else { &cfg.assistant_name }; let formatted = if !ts.is_empty() { - format!("**{}** {}: {}", name, &ts[..ts.len().min(19)], content) + oldest_ts = ts[..ts.len().min(19)].to_string(); + format!("**{}** {}: {}", name, &oldest_ts, content) } else { format!("**{}:** {}", name, content) }; @@ -612,6 +614,14 @@ fn resolve_conversation(budget: Option) -> String { fragments.push(formatted); } + // Set cutoff so surface doesn't see nodes created during this conversation + if !oldest_ts.is_empty() { + if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&oldest_ts, "%Y-%m-%dT%H:%M:%S") { + let epoch = dt.and_local_timezone(chrono::Local).unwrap().timestamp(); + unsafe { std::env::set_var("POC_MEMORIES_OLDER_THAN", epoch.to_string()); } + } + } + // Reverse back to chronological order fragments.reverse(); fragments.join("\n\n")