diff --git a/poc-memory/src/bin/memory-search.rs b/poc-memory/src/bin/memory-search.rs index 5c48a34..823d725 100644 --- a/poc-memory/src/bin/memory-search.rs +++ b/poc-memory/src/bin/memory-search.rs @@ -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 { + // 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 { let path = dir.join(format!("seen-{}", session_id)); if path.exists() { diff --git a/poc-memory/src/bin/poc-hook.rs b/poc-memory/src/bin/poc-hook.rs index ded3777..389db98 100644 --- a/poc-memory/src/bin/poc-hook.rs +++ b/poc-memory/src/bin/poc-hook.rs @@ -215,6 +215,135 @@ fn main() { check_context(t, false); maybe_trigger_observation(t); } + + // Surface agent: read previous result, then fire next run async + let session_id = hook["session_id"].as_str().unwrap_or(""); + if !session_id.is_empty() { + let state_dir = PathBuf::from("/tmp/claude-memory-search"); + let result_path = state_dir.join(format!("surface-result-{}", session_id)); + let pid_path = state_dir.join(format!("surface-pid-{}", session_id)); + + // Check if previous surface agent has finished. + // If still running past the timeout, kill it. + let surface_timeout = poc_memory::config::get() + .surface_timeout_secs + .unwrap_or(120) as u64; + + let agent_done = match fs::read_to_string(&pid_path) { + Ok(content) => { + // Format: "PID\tTIMESTAMP" + let parts: Vec<&str> = content.split('\t').collect(); + let pid: u32 = parts.first() + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(0); + let start_ts: u64 = parts.get(1) + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(0); + + if pid == 0 { + true + } else { + let alive = unsafe { libc::kill(pid as i32, 0) == 0 }; + if !alive { + true // process exited + } else { + let elapsed = now_secs().saturating_sub(start_ts); + if elapsed > surface_timeout { + // Kill stale agent + unsafe { libc::kill(pid as i32, libc::SIGTERM); } + true + } else { + false // still running, under timeout + } + } + } + } + Err(_) => true, // no pid file = no previous run + }; + + // Inject previous result if agent is done + if agent_done { + if let Ok(result) = fs::read_to_string(&result_path) { + if !result.trim().is_empty() { + let last_line = result.lines().rev() + .find(|l| !l.trim().is_empty()) + .unwrap_or(""); + + if last_line.starts_with("NEW RELEVANT MEMORIES:") { + // Parse key list from lines after the marker + let after_marker = result.rsplit_once("NEW RELEVANT MEMORIES:") + .map(|(_, rest)| rest) + .unwrap_or(""); + let keys: Vec<&str> = after_marker.lines() + .map(|l| l.trim().trim_start_matches("- ").trim()) + .filter(|l| !l.is_empty()) + .collect(); + + if !keys.is_empty() { + // Render and inject memories + for key in &keys { + if let Ok(output) = Command::new("poc-memory") + .args(["render", key]) + .output() + { + if output.status.success() { + let content = String::from_utf8_lossy(&output.stdout); + if !content.trim().is_empty() { + println!("--- {} (surfaced) ---", key); + print!("{}", content); + // Mark as seen + let seen_path = state_dir.join(format!("seen-{}", session_id)); + if let Ok(mut f) = fs::OpenOptions::new() + .create(true).append(true).open(&seen_path) + { + use std::io::Write; + let ts = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S"); + let _ = writeln!(f, "{}\t{}", ts, key); + } + } + } + } + } + } + } else if !last_line.starts_with("NO NEW RELEVANT MEMORIES") { + // Unexpected output — log error + let log_dir = poc_memory::store::memory_dir().join("logs"); + fs::create_dir_all(&log_dir).ok(); + let log_path = log_dir.join("surface-errors.log"); + if let Ok(mut f) = fs::OpenOptions::new() + .create(true).append(true).open(&log_path) + { + use std::io::Write; + let ts = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S"); + let _ = writeln!(f, "[{}] unexpected surface output: {}", + ts, last_line); + } + } + } + } + fs::remove_file(&result_path).ok(); + fs::remove_file(&pid_path).ok(); + + // Spawn next surface agent + if let Ok(output_file) = fs::File::create(&result_path) { + if let Ok(child) = Command::new("poc-memory") + .args(["agent", "run", "surface", "--count", "1", "--local"]) + .env("POC_SESSION_ID", session_id) + .stdout(output_file) + .stderr(std::process::Stdio::null()) + .spawn() + { + use std::io::Write; + let pid = child.id(); + let ts = now_secs(); + if let Ok(mut f) = fs::File::create(&pid_path) { + let _ = write!(f, "{}\t{}", pid, ts); + } + } + } + } + // else: previous agent still running, skip this cycle + } } "PostToolUse" => { // Drip-feed pending context chunks from initial load diff --git a/poc-memory/src/config.rs b/poc-memory/src/config.rs index 43482c9..287197a 100644 --- a/poc-memory/src/config.rs +++ b/poc-memory/src/config.rs @@ -67,6 +67,9 @@ pub struct Config { agent_model: Option, pub api_reasoning: String, pub agent_types: Vec, + /// Surface agent timeout in seconds. Kill if running longer than this. + #[serde(default)] + pub surface_timeout_secs: Option, } impl Default for Config { @@ -105,6 +108,7 @@ impl Default for Config { "linker".into(), "organize".into(), "distill".into(), "separator".into(), "split".into(), ], + surface_timeout_secs: None, } } }