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) <noreply@anthropic.com>
This commit is contained in:
Kent Overstreet 2026-03-22 16:27:42 -04:00
parent 53b63ab45b
commit 134f7308e3
2 changed files with 131 additions and 76 deletions

View file

@ -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::<Vec<_>>()
.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 &current_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<String> = 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.