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:
parent
53c5424c98
commit
85307fd6cb
3 changed files with 170 additions and 2 deletions
|
|
@ -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));
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -67,6 +67,9 @@ pub struct Config {
|
|||
agent_model: Option<String>,
|
||||
pub api_reasoning: String,
|
||||
pub agent_types: Vec<String>,
|
||||
/// Surface agent timeout in seconds. Kill if running longer than this.
|
||||
#[serde(default)]
|
||||
pub surface_timeout_secs: Option<u32>,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue