diff --git a/poc-memory/src/bin/poc-hook.rs b/poc-memory/src/bin/poc-hook.rs index ad05d33..a28e89a 100644 --- a/poc-memory/src/bin/poc-hook.rs +++ b/poc-memory/src/bin/poc-hook.rs @@ -163,6 +163,134 @@ Keep it narrative, not a task log." } } +/// Surface agent cycle: consume previous result, spawn next run. +/// Called from both UserPromptSubmit and PostToolUse. +fn surface_agent_cycle(hook: &Value) { + let session_id = hook["session_id"].as_str().unwrap_or(""); + if session_id.is_empty() { return; } + + 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)); + + 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) => { + 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 + } else { + let elapsed = now_secs().saturating_sub(start_ts); + if elapsed > surface_timeout { + unsafe { libc::kill(pid as i32, libc::SIGTERM); } + true + } else { + false + } + } + } + } + Err(_) => true, + }; + + if agent_done { + if let Ok(result) = fs::read_to_string(&result_path) { + if !result.trim().is_empty() { + let tail_lines: Vec<&str> = result.lines().rev() + .filter(|l| !l.trim().is_empty()) + .take(8) + .collect(); + let has_new = tail_lines.iter() + .any(|l| l.starts_with("NEW RELEVANT MEMORIES:")); + let has_none = tail_lines.iter() + .any(|l| l.starts_with("NO NEW RELEVANT MEMORIES")); + + if has_new { + 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() { + 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); + 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 !has_none { + 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 last = tail_lines.first().unwrap_or(&""); + let _ = writeln!(f, "[{}] unexpected surface output: {}", + ts, last); + } + } + } + } + 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); + } + } + } + } +} + fn main() { let mut input = String::new(); io::stdin().read_to_string(&mut input).ok(); @@ -216,141 +344,7 @@ fn main() { 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() { - // Search the last 8 non-empty lines for the marker - let tail_lines: Vec<&str> = result.lines().rev() - .filter(|l| !l.trim().is_empty()) - .take(8) - .collect(); - let has_new = tail_lines.iter() - .any(|l| l.starts_with("NEW RELEVANT MEMORIES:")); - let has_none = tail_lines.iter() - .any(|l| l.starts_with("NO NEW RELEVANT MEMORIES")); - - if has_new { - // Parse key list from lines after the last 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 !has_none { - // 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 last = tail_lines.first().unwrap_or(&""); - let _ = writeln!(f, "[{}] unexpected surface output: {}", - ts, last); - } - } - } - } - 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 - } + surface_agent_cycle(&hook); } "PostToolUse" => { // Drip-feed pending context chunks from initial load @@ -377,6 +371,8 @@ fn main() { if let Some(ref t) = transcript { check_context(t, true); } + + surface_agent_cycle(&hook); } "Stop" => { let stop_hook_active = hook["stop_hook_active"].as_bool().unwrap_or(false);