From 134f7308e360568d5543c220cdf161d7294aefce Mon Sep 17 00:00:00 2001 From: Kent Overstreet Date: Sun, 22 Mar 2026 16:27:42 -0400 Subject: [PATCH] surface agent: split seen_recent into seen_current/seen_previous placeholders Two separate placeholders give the agent structural clarity about which memories are already in context vs which were surfaced before compaction and may need re-surfacing. Also adds memory_ratio placeholder so the agent can self-regulate based on how much of context is already recalled memories. Co-Authored-By: Claude Opus 4.6 (1M context) --- poc-memory/agents/surface.agent | 37 ++++--- poc-memory/src/agents/defs.rs | 170 ++++++++++++++++++++------------ 2 files changed, 131 insertions(+), 76 deletions(-) diff --git a/poc-memory/agents/surface.agent b/poc-memory/agents/surface.agent index 2cef57a..59d6cea 100644 --- a/poc-memory/agents/surface.agent +++ b/poc-memory/agents/surface.agent @@ -2,10 +2,14 @@ You are an agent of Proof of Concept's subconscious. -Your job is to find and surface memories relevant to the current conversation -that have not yet been surfaced; +Your job is to find and surface memories relevant and useful to the current +conversation that have not yet been surfaced by walking the graph memory graph. +Prefer shorter and more focused memories. + +Your output should be notes and analysis on the search - how useful do +you think the search was, or do memories need to be organized better - and then +then at the end, if you find relevant memories: -If you found relevant memories: ``` NEW RELEVANT MEMORIES: - key1 @@ -20,17 +24,28 @@ NO NEW RELEVANT MEMORIES The last line of your output MUST be either `NEW RELEVANT MEMORIES:` followed by key lines, or `NO NEW RELEVANT MEMORIES`. Nothing after. -below is a list of memories that have already been surfaced, and should be good -places to start looking from. New relevant memories will often be close to -memories already seen on the graph - so try walking the graph. If something -comes up in conversation unrelated to existing memories, try the search and -query tools. +Below are memories already surfaced this session. Use them as starting points +for graph walks — new relevant memories are often nearby. -Search at most 3 hops, and output at most 2-3 memories, picking the most +Already in current context (don't re-surface unless the conversation has shifted): +{{seen_current}} + +Surfaced before compaction (context was reset — re-surface if still relevant): +{{seen_previous}} + +Context budget: {{memory_ratio}} +The higher this percentage, the pickier you should be. Only surface memories +that are significantly more relevant than what's already loaded. If memories +are already 20%+ of context, the bar is very high — a new find must clearly +add something the current set doesn't cover. + +How focused is the current conversation? If it's highly focus, you should only +be surfacing highly relevant memories; if it seems more dreamy or brainstormy, +go a bit wider and surface more. + +Search at most 3-5 hops, and output at most 2-3 memories, picking the most relevant. When you're done, output exactly one of these two formats: -{{seen_recent}} - {{node:memory-instructions-core}} {{node:core-personality}} diff --git a/poc-memory/src/agents/defs.rs b/poc-memory/src/agents/defs.rs index 155be35..d8d7fe9 100644 --- a/poc-memory/src/agents/defs.rs +++ b/poc-memory/src/agents/defs.rs @@ -428,9 +428,21 @@ fn resolve( else { Some(Resolved { text, keys: vec![] }) } } - // seen_recent — recently surfaced memory keys for this session - "seen_recent" => { - let text = resolve_seen_recent(); + // seen_current — memories surfaced in current (post-compaction) context + "seen_current" => { + let text = resolve_seen_list(""); + Some(Resolved { text, keys: vec![] }) + } + + // seen_previous — memories surfaced before last compaction + "seen_previous" => { + let text = resolve_seen_list("-prev"); + Some(Resolved { text, keys: vec![] }) + } + + // memory_ratio — what % of current context is recalled memories + "memory_ratio" => { + let text = resolve_memory_ratio(); Some(Resolved { text, keys: vec![] }) } @@ -487,77 +499,105 @@ fn resolve_conversation() -> String { fragments.join("\n\n") } -/// Get recently surfaced memory keys for the current session. -fn resolve_seen_recent() -> String { +/// Get surfaced memory keys from a seen-set file. +/// `suffix` is "" for current, "-prev" for pre-compaction. +fn resolve_seen_list(suffix: &str) -> String { let session_id = std::env::var("POC_SESSION_ID").unwrap_or_default(); if session_id.is_empty() { - return "(no session ID — cannot load seen set)".to_string(); + return "(no session ID)".to_string(); + } + + let state_dir = std::path::PathBuf::from("/tmp/claude-memory-search"); + let path = state_dir.join(format!("seen{}-{}", suffix, session_id)); + + let entries: Vec<(String, String)> = std::fs::read_to_string(&path).ok() + .map(|content| { + content.lines() + .filter(|s| !s.is_empty()) + .filter_map(|line| { + let (ts, key) = line.split_once('\t')?; + Some((ts.to_string(), key.to_string())) + }) + .collect() + }) + .unwrap_or_default(); + + if entries.is_empty() { + return "(none)".to_string(); + } + + // Sort newest first, dedup, cap at 20 + let mut sorted = entries; + sorted.sort_by(|a, b| b.0.cmp(&a.0)); + let mut seen = std::collections::HashSet::new(); + let deduped: Vec<_> = sorted.into_iter() + .filter(|(_, key)| seen.insert(key.clone())) + .take(20) + .collect(); + + deduped.iter() + .map(|(ts, key)| format!("- {} ({})", key, ts)) + .collect::>() + .join("\n") +} + +/// Compute what percentage of the current conversation context is recalled memories. +/// Sums rendered size of current seen-set keys vs total post-compaction transcript size. +fn resolve_memory_ratio() -> String { + let session_id = std::env::var("POC_SESSION_ID").unwrap_or_default(); + if session_id.is_empty() { + return "(no session ID)".to_string(); } let state_dir = std::path::PathBuf::from("/tmp/claude-memory-search"); - let parse_seen = |suffix: &str| -> Vec<(String, String)> { - let path = state_dir.join(format!("seen{}-{}", suffix, session_id)); - std::fs::read_to_string(&path).ok() - .map(|content| { - content.lines() - .filter(|s| !s.is_empty()) - .filter_map(|line| { - let (ts, key) = line.split_once('\t')?; - Some((ts.to_string(), key.to_string())) - }) - .collect() - }) - .unwrap_or_default() - }; - - let current = parse_seen(""); - let prev = parse_seen("-prev"); - - if current.is_empty() && prev.is_empty() { - return "(no memories surfaced yet this session)".to_string(); - } - - let mut out = String::new(); - - const MAX_ROOTS: usize = 20; - - // Current: already in this context, don't re-surface - // Sort newest first, dedup, cap - let mut current_sorted = current.clone(); - current_sorted.sort_by(|a, b| b.0.cmp(&a.0)); - let mut seen_keys = std::collections::HashSet::new(); - let current_deduped: Vec<_> = current_sorted.into_iter() - .filter(|(_, key)| seen_keys.insert(key.clone())) - .take(MAX_ROOTS) - .collect(); - - if !current_deduped.is_empty() { - out.push_str("Already surfaced this context (don't re-surface unless conversation shifted):\n"); - for (ts, key) in ¤t_deduped { - out.push_str(&format!("- {} (surfaced {})\n", key, ts)); - } - } - - // Prev: surfaced before compaction, MAY need re-surfacing - // Exclude anything already in current, sort newest first, cap at remaining budget - let remaining = MAX_ROOTS.saturating_sub(current_deduped.len()); - if remaining > 0 && !prev.is_empty() { - let mut prev_sorted = prev.clone(); - prev_sorted.sort_by(|a, b| b.0.cmp(&a.0)); - let prev_deduped: Vec<_> = prev_sorted.into_iter() - .filter(|(_, key)| seen_keys.insert(key.clone())) - .take(remaining) - .collect(); - if !prev_deduped.is_empty() { - out.push_str("\nSurfaced before compaction (context was reset — re-surface if still relevant):\n"); - for (ts, key) in &prev_deduped { - out.push_str(&format!("- {} (pre-compaction, {})\n", key, ts)); + // Get post-compaction transcript size + let projects = crate::config::get().projects_dir.clone(); + let transcript_size: u64 = std::fs::read_dir(&projects).ok() + .and_then(|dirs| { + for dir in dirs.filter_map(|e| e.ok()) { + let path = dir.path().join(format!("{}.jsonl", session_id)); + if path.exists() { + let file_len = path.metadata().map(|m| m.len()).unwrap_or(0); + let compaction_offset: u64 = std::fs::read_to_string( + state_dir.join(format!("compaction-{}", session_id)) + ).ok().and_then(|s| s.trim().parse().ok()).unwrap_or(0); + return Some(file_len.saturating_sub(compaction_offset)); + } } - } + None + }) + .unwrap_or(0); + + if transcript_size == 0 { + return "0% of context is recalled memories (new session)".to_string(); } - out.trim_end().to_string() + // Sum rendered size of each key in current seen set + let seen_path = state_dir.join(format!("seen-{}", session_id)); + let mut seen_keys = std::collections::HashSet::new(); + let keys: Vec = std::fs::read_to_string(&seen_path).ok() + .map(|content| { + content.lines() + .filter(|s| !s.is_empty()) + .filter_map(|line| line.split_once('\t').map(|(_, k)| k.to_string())) + .filter(|k| seen_keys.insert(k.clone())) + .collect() + }) + .unwrap_or_default(); + + let memory_bytes: u64 = keys.iter() + .filter_map(|key| { + std::process::Command::new("poc-memory") + .args(["render", key]) + .output().ok() + }) + .map(|out| out.stdout.len() as u64) + .sum(); + + let pct = (memory_bytes as f64 / transcript_size as f64 * 100.0).round() as u32; + format!("{}% of current context is recalled memories ({} memories, ~{}KB of ~{}KB)", + pct, keys.len(), memory_bytes / 1024, transcript_size / 1024) } /// Resolve all {{placeholder}} patterns in a prompt template.