surface agent infrastructure: hook spawn, seen set rotation, config

Surface agent fires asynchronously on UserPromptSubmit, deposits
results for the next prompt to consume.  This commit adds:

- poc-hook: spawn surface agent with PID tracking and configurable
  timeout, consume results (NEW RELEVANT MEMORIES / NO NEW), render
  and inject surfaced memories, observation trigger on conversation
  volume
- memory-search: rotate seen set on compaction (current → prev)
  instead of deleting, merge both for navigation roots
- config: surface_timeout_secs option

The .agent file and agent output routing are still pending.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kent Overstreet 2026-03-22 01:50:46 -04:00
parent 53c5424c98
commit 85307fd6cb
3 changed files with 170 additions and 2 deletions

View file

@ -110,10 +110,16 @@ fn main() {
let is_first = !cookie_path.exists();
if is_first || is_compaction {
// Reset seen set and returned list
// Rotate seen set: current → prev (for surface agent navigation roots)
let seen_path = state_dir.join(format!("seen-{}", session_id));
let seen_prev_path = state_dir.join(format!("seen-prev-{}", session_id));
let returned_path = state_dir.join(format!("returned-{}", session_id));
fs::remove_file(&seen_path).ok();
if is_compaction {
fs::rename(&seen_path, &seen_prev_path).ok();
} else {
fs::remove_file(&seen_path).ok();
fs::remove_file(&seen_prev_path).ok();
}
fs::remove_file(&returned_path).ok();
}
@ -592,6 +598,35 @@ fn parse_seen_line(line: &str) -> &str {
line.split_once('\t').map(|(_, key)| key).unwrap_or(line)
}
/// Load the most recently surfaced memory keys, sorted newest-first, capped at `limit`.
/// Used to give the surface agent navigation roots.
fn load_recent_seen(dir: &Path, session_id: &str, limit: usize) -> Vec<String> {
// Merge current and previous seen sets
let mut entries: Vec<(String, String)> = Vec::new();
for suffix in ["", "-prev"] {
let path = dir.join(format!("seen{}-{}", suffix, session_id));
if let Ok(content) = fs::read_to_string(&path) {
entries.extend(
content.lines()
.filter(|s| !s.is_empty())
.filter_map(|line| {
let (ts, key) = line.split_once('\t')?;
Some((ts.to_string(), key.to_string()))
})
);
}
}
// Sort by timestamp descending (newest first), dedup by key
entries.sort_by(|a, b| b.0.cmp(&a.0));
let mut seen = HashSet::new();
entries.into_iter()
.filter(|(_, key)| seen.insert(key.clone()))
.take(limit)
.map(|(_, key)| key)
.collect()
}
fn load_seen(dir: &Path, session_id: &str) -> HashSet<String> {
let path = dir.join(format!("seen-{}", session_id));
if path.exists() {