diff --git a/poc-memory/src/bin/memory-search.rs b/poc-memory/src/bin/memory-search.rs index d1bc01b..d0c2cd2 100644 --- a/poc-memory/src/bin/memory-search.rs +++ b/poc-memory/src/bin/memory-search.rs @@ -1,450 +1,28 @@ -// memory-search: combined hook for session context loading + ambient memory retrieval +// memory-search CLI — thin wrapper around poc_memory::memory_search // -// Modes: -// --hook Run as Claude Code UserPromptSubmit hook (reads stdin, injects into conversation) -// --debug Replay last stashed input, dump every stage to stdout -// --seen Show the seen set for current session -// (default) No-op (future: manual search modes) +// --hook: run hook logic (for debugging; poc-hook calls the library directly) +// no args: show seen set for current session use clap::Parser; -use poc_memory::search::{self, AlgoStage}; -use std::collections::{BTreeMap, HashSet}; use std::fs; use std::io::{self, Read, Write}; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const STASH_PATH: &str = "/tmp/claude-memory-search/last-input.json"; #[derive(Parser)] #[command(name = "memory-search")] struct Args { - /// Run as Claude Code hook (reads stdin, outputs for injection) + /// Run hook logic (reads JSON from stdin or stash file) #[arg(long)] hook: bool, - - /// Debug mode: replay last stashed input, dump every stage - #[arg(short, long)] - debug: bool, - - /// Show session state: seen set info - #[arg(long)] - seen: bool, - - /// Search query (bypasses stashed input, uses this as the prompt) - #[arg(long, short)] - query: Option, - - /// Algorithm pipeline stages - pipeline: Vec, -} - -const STASH_PATH: &str = "/tmp/claude-memory-search/last-input.json"; - -fn now_secs() -> u64 { - SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() -} -/// Max bytes per context chunk (hook output limit is ~10K chars) -const CHUNK_SIZE: usize = 9000; - -struct Session { - session_id: String, - transcript_path: String, - state_dir: PathBuf, -} - -impl Session { - fn load(hook: bool) -> Option { - let state_dir = PathBuf::from("/tmp/claude-memory-search"); - fs::create_dir_all(&state_dir).ok(); - - let input = if hook { - let mut buf = String::new(); - io::stdin().read_to_string(&mut buf).unwrap_or_default(); - fs::write(STASH_PATH, &buf).ok(); - buf - } else { - fs::read_to_string(STASH_PATH).ok()? - }; - - let json: serde_json::Value = serde_json::from_str(&input).ok()?; - let session_id = json["session_id"].as_str().unwrap_or("").to_string(); - if session_id.is_empty() { return None; } - let transcript_path = json["transcript_path"].as_str().unwrap_or("").to_string(); - - Some(Session { session_id, transcript_path, state_dir }) - } - - fn path(&self, prefix: &str) -> PathBuf { - self.state_dir.join(format!("{}-{}", prefix, self.session_id)) - } -} - -fn main() { - // Daemon agent calls set POC_AGENT=1 — skip memory search. - if std::env::var("POC_AGENT").is_ok() { - return; - } - - let args = Args::parse(); - - if args.seen { - show_seen(); - return; - } - - // --query mode: skip all hook/context machinery, just search - if let Some(ref query_str) = args.query { - run_query_mode(query_str, &args); - return; - } - - let Some(session) = Session::load(args.hook) else { return }; - let debug = args.debug || !args.hook; - - let is_compaction = poc_memory::transcript::detect_new_compaction( - &session.state_dir, &session.session_id, &session.transcript_path, - ); - let cookie_path = session.path("cookie"); - let is_first = !cookie_path.exists(); - - if is_first || is_compaction { - // Rotate seen set on compaction, clear on first - if is_compaction { - fs::rename(&session.path("seen"), &session.path("seen-prev")).ok(); - } else { - fs::remove_file(&session.path("seen")).ok(); - fs::remove_file(&session.path("seen-prev")).ok(); - } - fs::remove_file(&session.path("returned")).ok(); - } - - if debug { - println!("[memory-search] session={} is_first={} is_compaction={}", - session.session_id, is_first, is_compaction); - } - - if is_first || is_compaction { - if is_first { - fs::write(&cookie_path, generate_cookie()).ok(); - } - - if debug { println!("[memory-search] loading full context"); } - - if let Ok(output) = Command::new("poc-memory").args(["admin", "load-context"]).output() { - if output.status.success() { - let ctx = String::from_utf8_lossy(&output.stdout).to_string(); - if !ctx.trim().is_empty() { - let mut ctx_seen = load_seen(&session.state_dir, &session.session_id); - for line in ctx.lines() { - if line.starts_with("--- ") && line.ends_with(" ---") { - let inner = &line[4..line.len() - 4]; - if let Some(paren) = inner.rfind(" (") { - let key = inner[..paren].trim(); - mark_seen(&session.state_dir, &session.session_id, key, &mut ctx_seen); - } - } - } - - let chunks = chunk_context(&ctx, CHUNK_SIZE); - if debug { - println!("[memory-search] context: {} bytes, {} chunks", - ctx.len(), chunks.len()); - } - - if let Some(first) = chunks.first() { - if args.hook { print!("{}", first); } - } - save_pending_chunks(&session.state_dir, &session.session_id, &chunks[1..]); - } - } - } - } else if let Some(chunk) = pop_pending_chunk(&session.state_dir, &session.session_id) { - if debug { - println!("[memory-search] drip-feeding pending chunk: {} bytes", chunk.len()); - } - if args.hook { print!("{}", chunk); } - } - - // Surface agent: consume previous result, inject memories, spawn next run - if args.hook { - surface_agent_cycle(&session); - } - - cleanup_stale_files(&session.state_dir, Duration::from_secs(86400)); -} - -/// Direct query mode: search for a term without hook/stash machinery. -fn run_query_mode(query: &str, args: &Args) { - let store = match poc_memory::store::Store::load() { - Ok(s) => s, - Err(e) => { eprintln!("failed to load store: {}", e); return; } - }; - - // Build terms from the query string - let mut terms: BTreeMap = BTreeMap::new(); - let prompt_terms = search::extract_query_terms(query, 8); - for word in prompt_terms.split_whitespace() { - terms.entry(word.to_lowercase()).or_insert(1.0); - } - - // Also check for exact node key match (the query itself, lowercased) - let query_lower = query.to_lowercase(); - for (key, node) in &store.nodes { - if node.deleted { continue; } - if key.to_lowercase() == query_lower { - terms.insert(query_lower.clone(), 10.0); - break; - } - } - - println!("[query] terms: {:?}", terms); - - if terms.is_empty() { - println!("[query] no terms extracted"); - return; - } - - let graph = poc_memory::graph::build_graph_fast(&store); - let (seeds, direct_hits) = search::match_seeds(&terms, &store); - - println!("[query] {} seeds", seeds.len()); - let mut sorted = seeds.clone(); - sorted.sort_by(|a, b| b.1.total_cmp(&a.1)); - for (key, score) in sorted.iter().take(20) { - let marker = if direct_hits.contains(key) { "→" } else { " " }; - println!(" {} {:.4} {}", marker, score, key); - } - - let pipeline: Vec = if args.pipeline.is_empty() { - vec![AlgoStage::parse("spread").unwrap()] - } else { - args.pipeline.iter() - .filter_map(|a| AlgoStage::parse(a).ok()) - .collect() - }; - - let max_results = 50; - let results = search::run_pipeline(&pipeline, seeds, &graph, &store, true, max_results); - - println!("\n[query] top {} results:", results.len().min(25)); - for (i, (key, score)) in results.iter().take(25).enumerate() { - let marker = if direct_hits.contains(key) { "→" } else { " " }; - println!(" {:2}. {} [{:.4}] {}", i + 1, marker, score, key); - } -} - -/// Split context output into chunks of approximately `max_bytes`, breaking -/// at section boundaries ("--- KEY (group) ---" lines). -fn chunk_context(ctx: &str, max_bytes: usize) -> Vec { - // Split into sections at group boundaries, then merge small adjacent - // sections into chunks up to max_bytes. - let mut sections: Vec = Vec::new(); - let mut current = String::new(); - - for line in ctx.lines() { - // Group headers start new sections - if line.starts_with("--- ") && line.ends_with(" ---") && !current.is_empty() { - sections.push(std::mem::take(&mut current)); - } - if !current.is_empty() { - current.push('\n'); - } - current.push_str(line); - } - if !current.is_empty() { - sections.push(current); - } - - // Merge small sections into chunks, respecting max_bytes - let mut chunks: Vec = Vec::new(); - let mut chunk = String::new(); - for section in sections { - if !chunk.is_empty() && chunk.len() + section.len() + 1 > max_bytes { - chunks.push(std::mem::take(&mut chunk)); - } - if !chunk.is_empty() { - chunk.push('\n'); - } - chunk.push_str(§ion); - } - if !chunk.is_empty() { - chunks.push(chunk); - } - chunks -} - -/// Save remaining chunks to disk for drip-feeding on subsequent hook calls. -fn save_pending_chunks(dir: &Path, session_id: &str, chunks: &[String]) { - let chunks_dir = dir.join(format!("chunks-{}", session_id)); - // Clear any old chunks - let _ = fs::remove_dir_all(&chunks_dir); - if chunks.is_empty() { return; } - fs::create_dir_all(&chunks_dir).ok(); - for (i, chunk) in chunks.iter().enumerate() { - let path = chunks_dir.join(format!("{:04}", i)); - fs::write(path, chunk).ok(); - } -} - -/// Pop the next pending chunk (lowest numbered file). Returns None if no chunks remain. -fn pop_pending_chunk(dir: &Path, session_id: &str) -> Option { - let chunks_dir = dir.join(format!("chunks-{}", session_id)); - if !chunks_dir.exists() { return None; } - - let mut entries: Vec<_> = fs::read_dir(&chunks_dir).ok()? - .flatten() - .filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false)) - .collect(); - entries.sort_by_key(|e| e.file_name()); - - let first = entries.first()?; - let content = fs::read_to_string(first.path()).ok()?; - fs::remove_file(first.path()).ok(); - - // Clean up directory if empty - if fs::read_dir(&chunks_dir).ok().map(|mut d| d.next().is_none()).unwrap_or(true) { - fs::remove_dir(&chunks_dir).ok(); - } - - Some(content) -} - -fn generate_cookie() -> String { - uuid::Uuid::new_v4().as_simple().to_string()[..12].to_string() -} - - -/// Parse a seen-file line: "TIMESTAMP\tKEY" or legacy "KEY" -fn parse_seen_line(line: &str) -> &str { - line.split_once('\t').map(|(_, key)| key).unwrap_or(line) -} - -fn load_seen(dir: &Path, session_id: &str) -> HashSet { - let path = dir.join(format!("seen-{}", session_id)); - if path.exists() { - let set: HashSet = fs::read_to_string(&path) - .unwrap_or_default() - .lines() - .filter(|s| !s.is_empty()) - .map(|s| parse_seen_line(s).to_string()) - .collect(); - set - } else { - HashSet::new() - } -} - -fn mark_seen(dir: &Path, session_id: &str, key: &str, seen: &mut HashSet) { - if !seen.insert(key.to_string()) { return; } - let path = dir.join(format!("seen-{}", session_id)); - if let Ok(mut f) = fs::OpenOptions::new().create(true).append(true).open(path) { - let ts = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S"); - writeln!(f, "{}\t{}", ts, key).ok(); - } -} - - -/// Surface agent lifecycle: check if previous agent finished, consume results, -/// render and inject unseen memories, spawn next agent run. -fn surface_agent_cycle(session: &Session) { - let result_path = session.state_dir.join(format!("surface-result-{}", session.session_id)); - let pid_path = session.state_dir.join(format!("surface-pid-{}", session.session_id)); - - let surface_timeout = poc_memory::config::get() - .surface_timeout_secs - .unwrap_or(120) as u64; - - // Check if previous agent is done - 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 if now_secs().saturating_sub(start_ts) > surface_timeout { - unsafe { libc::kill(pid as i32, libc::SIGTERM); } - true - } else { false } - } - } - Err(_) => true, - }; - - if !agent_done { return; } - - // Consume result - 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 = after_marker.lines() - .map(|l| l.trim().trim_start_matches("- ").trim().to_string()) - .filter(|l| !l.is_empty()).collect(); - - // Render and inject unseen keys - let mut seen = load_seen(&session.state_dir, &session.session_id); - let seen_path = session.path("seen"); - for key in &keys { - if !seen.insert(key.clone()) { continue; } - 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); - if let Ok(mut f) = fs::OpenOptions::new() - .create(true).append(true).open(&seen_path) { - let ts = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S"); - writeln!(f, "{}\t{}", ts, key).ok(); - } - } - } - } - } - } 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) { - 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.session_id) - .stdout(output_file) - .stderr(std::process::Stdio::null()) - .spawn() - { - let pid = child.id(); - let ts = now_secs(); - if let Ok(mut f) = fs::File::create(&pid_path) { - write!(f, "{}\t{}", pid, ts).ok(); - } - } - } } fn show_seen() { - let Some(session) = Session::load(false) else { + let input = match fs::read_to_string(STASH_PATH) { + Ok(s) => s, + Err(_) => { eprintln!("No session state available"); return; } + }; + let Some(session) = poc_memory::memory_search::Session::from_json(&input) else { eprintln!("No session state available"); return; }; @@ -484,19 +62,26 @@ fn show_seen() { } } -fn cleanup_stale_files(dir: &Path, max_age: Duration) { - let entries = match fs::read_dir(dir) { - Ok(e) => e, - Err(_) => return, - }; - let cutoff = SystemTime::now() - max_age; - for entry in entries.flatten() { - if let Ok(meta) = entry.metadata() { - if let Ok(modified) = meta.modified() { - if modified < cutoff { - fs::remove_file(entry.path()).ok(); - } +fn main() { + let args = Args::parse(); + + if args.hook { + // Read from stdin if piped, otherwise from stash + let input = { + let mut buf = String::new(); + io::stdin().read_to_string(&mut buf).ok(); + if buf.trim().is_empty() { + fs::read_to_string(STASH_PATH).unwrap_or_default() + } else { + let _ = fs::create_dir_all("/tmp/claude-memory-search"); + let _ = fs::write(STASH_PATH, &buf); + buf } - } + }; + + let output = poc_memory::memory_search::run_hook(&input); + print!("{}", output); + } else { + show_seen() } } diff --git a/poc-memory/src/bin/poc-hook.rs b/poc-memory/src/bin/poc-hook.rs index c37d094..22a2eed 100644 --- a/poc-memory/src/bin/poc-hook.rs +++ b/poc-memory/src/bin/poc-hook.rs @@ -163,7 +163,6 @@ Keep it narrative, not a task log." } } - fn main() { let mut input = String::new(); io::stdin().read_to_string(&mut input).ok(); @@ -190,60 +189,19 @@ fn main() { "UserPromptSubmit" => { signal_user(); check_notifications(); - - // Run memory-search, passing through the hook input it needs - if let Ok(output) = Command::new("memory-search") - .arg("--hook") - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::null()) - .spawn() - .and_then(|mut child| { - if let Some(ref mut stdin) = child.stdin { - use std::io::Write; - let _ = stdin.write_all(input.as_bytes()); - } - child.wait_with_output() - }) - { - let text = String::from_utf8_lossy(&output.stdout); - if !text.is_empty() { - print!("{text}"); - } - } + print!("{}", poc_memory::memory_search::run_hook(&input)); if let Some(ref t) = transcript { check_context(t, false); maybe_trigger_observation(t); } - } "PostToolUse" => { - // Drip-feed pending context chunks from initial load - if let Ok(output) = Command::new("memory-search") - .arg("--hook") - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::null()) - .spawn() - .and_then(|mut child| { - if let Some(ref mut stdin) = child.stdin { - use std::io::Write; - let _ = stdin.write_all(input.as_bytes()); - } - child.wait_with_output() - }) - { - let text = String::from_utf8_lossy(&output.stdout); - if !text.is_empty() { - print!("{text}"); - } - } + print!("{}", poc_memory::memory_search::run_hook(&input)); if let Some(ref t) = transcript { check_context(t, true); } - } "Stop" => { let stop_hook_active = hook["stop_hook_active"].as_bool().unwrap_or(false); diff --git a/poc-memory/src/lib.rs b/poc-memory/src/lib.rs index 977aee1..9653b04 100644 --- a/poc-memory/src/lib.rs +++ b/poc-memory/src/lib.rs @@ -34,6 +34,8 @@ pub use agents::{ enrich, digest, daemon, }; +pub mod memory_search; + pub mod memory_capnp { include!(concat!(env!("OUT_DIR"), "/schema/memory_capnp.rs")); } diff --git a/poc-memory/src/memory_search.rs b/poc-memory/src/memory_search.rs new file mode 100644 index 0000000..22453ed --- /dev/null +++ b/poc-memory/src/memory_search.rs @@ -0,0 +1,342 @@ +// memory-search: context loading + ambient memory retrieval +// +// Core hook logic lives here as a library module so poc-hook can call +// it directly (no subprocess). The memory-search binary is a thin CLI +// wrapper with --hook for debugging and show_seen for inspection. + +use std::collections::HashSet; +use std::fs; +use std::fs::File; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +fn now_secs() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() +} + +/// Max bytes per context chunk (hook output limit is ~10K chars) +const CHUNK_SIZE: usize = 9000; + +pub struct Session { + pub session_id: String, + pub transcript_path: String, + pub hook_event: String, + pub state_dir: PathBuf, +} + +impl Session { + pub fn from_json(input: &str) -> Option { + let state_dir = PathBuf::from("/tmp/claude-memory-search"); + fs::create_dir_all(&state_dir).ok(); + + let json: serde_json::Value = serde_json::from_str(input).ok()?; + let session_id = json["session_id"].as_str().unwrap_or("").to_string(); + if session_id.is_empty() { return None; } + let transcript_path = json["transcript_path"].as_str().unwrap_or("").to_string(); + let hook_event = json["hook_event_name"].as_str().unwrap_or("").to_string(); + + Some(Session { session_id, transcript_path, hook_event, state_dir }) + } + + pub fn path(&self, prefix: &str) -> PathBuf { + self.state_dir.join(format!("{}-{}", prefix, self.session_id)) + } +} + +/// Run the hook logic on parsed JSON input. Returns output to inject. +pub fn run_hook(input: &str) -> String { + // Daemon agent calls set POC_AGENT=1 — skip memory search. + if std::env::var("POC_AGENT").is_ok() { return String::new(); } + + let Some(session) = Session::from_json(input) else { return String::new() }; + hook(&session) +} + +/// Split context output into chunks of approximately `max_bytes`, breaking +/// at section boundaries ("--- KEY (group) ---" lines). +fn chunk_context(ctx: &str, max_bytes: usize) -> Vec { + let mut sections: Vec = Vec::new(); + let mut current = String::new(); + + for line in ctx.lines() { + if line.starts_with("--- ") && line.ends_with(" ---") && !current.is_empty() { + sections.push(std::mem::take(&mut current)); + } + if !current.is_empty() { + current.push('\n'); + } + current.push_str(line); + } + if !current.is_empty() { + sections.push(current); + } + + let mut chunks: Vec = Vec::new(); + let mut chunk = String::new(); + for section in sections { + if !chunk.is_empty() && chunk.len() + section.len() + 1 > max_bytes { + chunks.push(std::mem::take(&mut chunk)); + } + if !chunk.is_empty() { + chunk.push('\n'); + } + chunk.push_str(§ion); + } + if !chunk.is_empty() { + chunks.push(chunk); + } + chunks +} + +fn save_pending_chunks(dir: &Path, session_id: &str, chunks: &[String]) { + let chunks_dir = dir.join(format!("chunks-{}", session_id)); + let _ = fs::remove_dir_all(&chunks_dir); + if chunks.is_empty() { return; } + fs::create_dir_all(&chunks_dir).ok(); + for (i, chunk) in chunks.iter().enumerate() { + let path = chunks_dir.join(format!("{:04}", i)); + fs::write(path, chunk).ok(); + } +} + +fn pop_pending_chunk(dir: &Path, session_id: &str) -> Option { + let chunks_dir = dir.join(format!("chunks-{}", session_id)); + if !chunks_dir.exists() { return None; } + + let mut entries: Vec<_> = fs::read_dir(&chunks_dir).ok()? + .flatten() + .filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false)) + .collect(); + entries.sort_by_key(|e| e.file_name()); + + let first = entries.first()?; + let content = fs::read_to_string(first.path()).ok()?; + fs::remove_file(first.path()).ok(); + + if fs::read_dir(&chunks_dir).ok().map(|mut d| d.next().is_none()).unwrap_or(true) { + fs::remove_dir(&chunks_dir).ok(); + } + + Some(content) +} + +fn generate_cookie() -> String { + uuid::Uuid::new_v4().as_simple().to_string()[..12].to_string() +} + +fn parse_seen_line(line: &str) -> &str { + line.split_once('\t').map(|(_, key)| key).unwrap_or(line) +} + +fn load_seen(dir: &Path, session_id: &str) -> HashSet { + let path = dir.join(format!("seen-{}", session_id)); + if path.exists() { + fs::read_to_string(&path) + .unwrap_or_default() + .lines() + .filter(|s| !s.is_empty()) + .map(|s| parse_seen_line(s).to_string()) + .collect() + } else { + HashSet::new() + } +} + +fn mark_seen(dir: &Path, session_id: &str, key: &str, seen: &mut HashSet) { + if !seen.insert(key.to_string()) { return; } + let path = dir.join(format!("seen-{}", session_id)); + if let Ok(mut f) = fs::OpenOptions::new().create(true).append(true).open(path) { + let ts = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S"); + writeln!(f, "{}\t{}", ts, key).ok(); + } +} + +fn surface_agent_cycle(session: &Session, out: &mut String, log_f: &mut File) { + let result_path = session.state_dir.join(format!("surface-result-{}", session.session_id)); + let pid_path = session.state_dir.join(format!("surface-pid-{}", session.session_id)); + + let surface_timeout = crate::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 if now_secs().saturating_sub(start_ts) > surface_timeout { + unsafe { libc::kill(pid as i32, libc::SIGTERM); } + true + } else { false } + } + } + Err(_) => true, + }; + + let _ = writeln!(log_f, "agent_done {agent_done}"); + + if !agent_done { return; } + + 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")); + + let _ = writeln!(log_f, "has_new {has_new} has_none {has_none}"); + + if has_new { + let after_marker = result.rsplit_once("NEW RELEVANT MEMORIES:") + .map(|(_, rest)| rest).unwrap_or(""); + let keys: Vec = after_marker.lines() + .map(|l| l.trim().trim_start_matches("- ").trim().to_string()) + .filter(|l| !l.is_empty() && !l.starts_with("```")).collect(); + + let _ = writeln!(log_f, "keys {:?}", keys); + + let Ok(store) = crate::store::Store::load() else { return; }; + let mut seen = load_seen(&session.state_dir, &session.session_id); + let seen_path = session.path("seen"); + for key in &keys { + if !seen.insert(key.clone()) { + let _ = writeln!(log_f, " skip (seen): {}", key); + continue; + } + if let Some(content) = crate::cli::node::render_node(&store, key) { + if !content.trim().is_empty() { + use std::fmt::Write as _; + writeln!(out, "--- {} (surfaced) ---", key).ok(); + write!(out, "{}", content).ok(); + let _ = writeln!(log_f, " rendered {}: {} bytes, out now {} bytes", key, content.len(), out.len()); + if let Ok(mut f) = fs::OpenOptions::new() + .create(true).append(true).open(&seen_path) { + let ts = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S"); + writeln!(f, "{}\t{}", ts, key).ok(); + } + } + } + } + } else if !has_none { + let log_dir = crate::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) { + 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(); + + 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.session_id) + .stdout(output_file) + .stderr(std::process::Stdio::null()) + .spawn() + { + let pid = child.id(); + let ts = now_secs(); + if let Ok(mut f) = fs::File::create(&pid_path) { + write!(f, "{}\t{}", pid, ts).ok(); + } + } + } +} + +fn cleanup_stale_files(dir: &Path, max_age: Duration) { + let entries = match fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + let cutoff = SystemTime::now() - max_age; + for entry in entries.flatten() { + if let Ok(meta) = entry.metadata() { + if let Ok(modified) = meta.modified() { + if modified < cutoff { + fs::remove_file(entry.path()).ok(); + } + } + } + } +} + +fn hook(session: &Session) -> String { + let mut out = String::new(); + let is_compaction = crate::transcript::detect_new_compaction( + &session.state_dir, &session.session_id, &session.transcript_path, + ); + let cookie_path = session.path("cookie"); + let is_first = !cookie_path.exists(); + + let log_path = session.state_dir.join(format!("hook-log-{}", session.session_id)); + let Ok(mut log_f) = fs::OpenOptions::new().create(true).append(true).open(log_path) else { return Default::default(); }; + let ts = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S"); + let _ = writeln!(log_f, "\n=== {} ({}) {} bytes ===", ts, session.hook_event, out.len()); + + let _ = writeln!(log_f, "is_first {is_first} is_compaction {is_compaction}"); + + if is_first || is_compaction { + if is_compaction { + fs::rename(&session.path("seen"), &session.path("seen-prev")).ok(); + } else { + fs::remove_file(&session.path("seen")).ok(); + fs::remove_file(&session.path("seen-prev")).ok(); + } + fs::remove_file(&session.path("returned")).ok(); + + if is_first { + fs::write(&cookie_path, generate_cookie()).ok(); + } + + if let Ok(output) = Command::new("poc-memory").args(["admin", "load-context"]).output() { + if output.status.success() { + let ctx = String::from_utf8_lossy(&output.stdout).to_string(); + if !ctx.trim().is_empty() { + let mut ctx_seen = load_seen(&session.state_dir, &session.session_id); + for line in ctx.lines() { + if line.starts_with("--- ") && line.ends_with(" ---") { + let inner = &line[4..line.len() - 4]; + if let Some(paren) = inner.rfind(" (") { + let key = inner[..paren].trim(); + mark_seen(&session.state_dir, &session.session_id, key, &mut ctx_seen); + } + } + } + + let chunks = chunk_context(&ctx, CHUNK_SIZE); + + if let Some(first) = chunks.first() { + out.push_str(first); + } + save_pending_chunks(&session.state_dir, &session.session_id, &chunks[1..]); + } + } + } + } + + if let Some(chunk) = pop_pending_chunk(&session.state_dir, &session.session_id) { + out.push_str(&chunk); + } else { + let cfg = crate::config::get(); + if cfg.surface_hooks.iter().any(|h| h == &session.hook_event) { + surface_agent_cycle(session, &mut out, &mut log_f); + } + } + + cleanup_stale_files(&session.state_dir, Duration::from_secs(86400)); + + let _ = write!(log_f, "{}", out); + out +}