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

@ -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