run surface agent on both UserPromptSubmit and PostToolUse
Extract surface_agent_cycle() and call from both hooks. Enables memory surfacing during autonomous work (tool calls without human prompts). Rate limiting via PID file prevents overlap. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b402746070
commit
870b87df1b
1 changed files with 131 additions and 135 deletions
|
|
@ -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() {
|
fn main() {
|
||||||
let mut input = String::new();
|
let mut input = String::new();
|
||||||
io::stdin().read_to_string(&mut input).ok();
|
io::stdin().read_to_string(&mut input).ok();
|
||||||
|
|
@ -216,141 +344,7 @@ fn main() {
|
||||||
maybe_trigger_observation(t);
|
maybe_trigger_observation(t);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Surface agent: read previous result, then fire next run async
|
surface_agent_cycle(&hook);
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
"PostToolUse" => {
|
"PostToolUse" => {
|
||||||
// Drip-feed pending context chunks from initial load
|
// Drip-feed pending context chunks from initial load
|
||||||
|
|
@ -377,6 +371,8 @@ fn main() {
|
||||||
if let Some(ref t) = transcript {
|
if let Some(ref t) = transcript {
|
||||||
check_context(t, true);
|
check_context(t, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
surface_agent_cycle(&hook);
|
||||||
}
|
}
|
||||||
"Stop" => {
|
"Stop" => {
|
||||||
let stop_hook_active = hook["stop_hook_active"].as_bool().unwrap_or(false);
|
let stop_hook_active = hook["stop_hook_active"].as_bool().unwrap_or(false);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue