forked from kent/consciousness
The surface agent result consumer in poc-hook was writing to the seen file but not the returned file, so surfaced keys showed up as "context-loaded" in memory-search --seen. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
392 lines
14 KiB
Rust
392 lines
14 KiB
Rust
// Unified Claude Code hook.
|
|
//
|
|
// Single binary handling all hook events:
|
|
// UserPromptSubmit — signal daemon, check notifications, check context
|
|
// PostToolUse — check context (rate-limited)
|
|
// Stop — signal daemon response
|
|
//
|
|
// Replaces: record-user-message-time.sh, check-notifications.sh,
|
|
// check-context-usage.sh, notify-done.sh, context-check
|
|
|
|
use serde_json::Value;
|
|
use std::fs;
|
|
use std::io::{self, Read};
|
|
use std::path::PathBuf;
|
|
use std::process::Command;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
const CONTEXT_THRESHOLD: u64 = 900_000;
|
|
const RATE_LIMIT_SECS: u64 = 60;
|
|
const SOCK_PATH: &str = ".claude/hooks/idle-timer.sock";
|
|
/// How many bytes of new transcript before triggering an observation run.
|
|
/// Override with POC_OBSERVATION_THRESHOLD env var.
|
|
/// Default: 20KB ≈ 5K tokens. The observation agent's chunk_size (in .agent
|
|
/// file) controls how much context it actually reads.
|
|
fn observation_threshold() -> u64 {
|
|
std::env::var("POC_OBSERVATION_THRESHOLD")
|
|
.ok()
|
|
.and_then(|s| s.parse().ok())
|
|
.unwrap_or(20_000)
|
|
}
|
|
|
|
fn now_secs() -> u64 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_secs()
|
|
}
|
|
|
|
fn home() -> PathBuf {
|
|
PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/root".into()))
|
|
}
|
|
|
|
fn daemon_cmd(args: &[&str]) {
|
|
Command::new("poc-daemon")
|
|
.args(args)
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.status()
|
|
.ok();
|
|
}
|
|
|
|
fn daemon_available() -> bool {
|
|
home().join(SOCK_PATH).exists()
|
|
}
|
|
|
|
fn signal_user() {
|
|
let pane = std::env::var("TMUX_PANE").unwrap_or_default();
|
|
if pane.is_empty() {
|
|
daemon_cmd(&["user"]);
|
|
} else {
|
|
daemon_cmd(&["user", &pane]);
|
|
}
|
|
}
|
|
|
|
fn signal_response() {
|
|
daemon_cmd(&["response"]);
|
|
}
|
|
|
|
fn check_notifications() {
|
|
if !daemon_available() {
|
|
return;
|
|
}
|
|
let output = Command::new("poc-daemon")
|
|
.arg("notifications")
|
|
.output()
|
|
.ok();
|
|
if let Some(out) = output {
|
|
let text = String::from_utf8_lossy(&out.stdout);
|
|
if !text.trim().is_empty() {
|
|
println!("You have pending notifications:");
|
|
print!("{text}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Check if enough new conversation has accumulated to trigger an observation run.
|
|
fn maybe_trigger_observation(transcript: &PathBuf) {
|
|
let cursor_file = poc_memory::store::memory_dir().join("observation-cursor");
|
|
|
|
let last_pos: u64 = fs::read_to_string(&cursor_file)
|
|
.ok()
|
|
.and_then(|s| s.trim().parse().ok())
|
|
.unwrap_or(0);
|
|
|
|
let current_size = transcript.metadata()
|
|
.map(|m| m.len())
|
|
.unwrap_or(0);
|
|
|
|
if current_size > last_pos + observation_threshold() {
|
|
// Queue observation via daemon RPC
|
|
let _ = Command::new("poc-memory")
|
|
.args(["agent", "daemon", "run", "observation", "1"])
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.spawn();
|
|
|
|
eprintln!("[poc-hook] observation triggered ({} new bytes)", current_size - last_pos);
|
|
|
|
// Update cursor to current position
|
|
let _ = fs::write(&cursor_file, current_size.to_string());
|
|
}
|
|
}
|
|
|
|
fn check_context(transcript: &PathBuf, rate_limit: bool) {
|
|
if rate_limit {
|
|
let rate_file = PathBuf::from("/tmp/claude-context-check-last");
|
|
if let Ok(s) = fs::read_to_string(&rate_file) {
|
|
if let Ok(last) = s.trim().parse::<u64>() {
|
|
if now_secs() - last < RATE_LIMIT_SECS {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
let _ = fs::write(&rate_file, now_secs().to_string());
|
|
}
|
|
|
|
if !transcript.exists() {
|
|
return;
|
|
}
|
|
|
|
let content = match fs::read_to_string(transcript) {
|
|
Ok(c) => c,
|
|
Err(_) => return,
|
|
};
|
|
|
|
let mut usage: u64 = 0;
|
|
for line in content.lines().rev().take(500) {
|
|
if !line.contains("cache_read_input_tokens") {
|
|
continue;
|
|
}
|
|
if let Ok(v) = serde_json::from_str::<Value>(line) {
|
|
let u = &v["message"]["usage"];
|
|
let input_tokens = u["input_tokens"].as_u64().unwrap_or(0);
|
|
let cache_creation = u["cache_creation_input_tokens"].as_u64().unwrap_or(0);
|
|
let cache_read = u["cache_read_input_tokens"].as_u64().unwrap_or(0);
|
|
usage = input_tokens + cache_creation + cache_read;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if usage > CONTEXT_THRESHOLD {
|
|
print!(
|
|
"\
|
|
CONTEXT WARNING: Compaction approaching ({usage} tokens). Write a journal entry NOW.
|
|
|
|
Use `poc-memory journal write \"entry text\"` to save a dated entry covering:
|
|
- What you're working on and current state (done / in progress / blocked)
|
|
- Key things learned this session (patterns, debugging insights)
|
|
- Anything half-finished that needs pickup
|
|
|
|
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);
|
|
}
|
|
let returned_path = state_dir.join(format!("returned-{}", session_id));
|
|
if let Ok(mut f) = fs::OpenOptions::new()
|
|
.create(true).append(true).open(&returned_path)
|
|
{
|
|
use std::io::Write;
|
|
let _ = writeln!(f, "{}", 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();
|
|
|
|
let hook: Value = match serde_json::from_str(&input) {
|
|
Ok(v) => v,
|
|
Err(_) => return,
|
|
};
|
|
|
|
let hook_type = hook["hook_event_name"].as_str().unwrap_or("unknown");
|
|
let transcript = hook["transcript_path"]
|
|
.as_str()
|
|
.filter(|p| !p.is_empty())
|
|
.map(PathBuf::from);
|
|
|
|
// Daemon agent calls set POC_AGENT=1 — skip all signaling.
|
|
// Without this, the daemon's claude -p calls trigger hooks that
|
|
// signal "user active", keeping the idle timer permanently reset.
|
|
if std::env::var("POC_AGENT").is_ok() {
|
|
return;
|
|
}
|
|
|
|
match hook_type {
|
|
"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}");
|
|
}
|
|
}
|
|
|
|
if let Some(ref t) = transcript {
|
|
check_context(t, false);
|
|
maybe_trigger_observation(t);
|
|
}
|
|
|
|
surface_agent_cycle(&hook);
|
|
}
|
|
"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}");
|
|
}
|
|
}
|
|
|
|
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);
|
|
if !stop_hook_active {
|
|
signal_response();
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|