consciousness-claude/src/poc-hook.rs
ProofOfConcept 899cdd0165 poc-daemon: reset nudge timer on tool use, move nudge interval to claude
PostToolUse now signals response activity so the nudge timer resets
while I'm actively working. Nudge interval constant moved from
thalamus (where it doesn't belong) to consciousness-claude.

Co-Authored-By: Proof of Concept <poc@bcachefs.org>
2026-04-11 16:49:27 -04:00

278 lines
8.6 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};
use consciousness::store;
use consciousness_claude::hook as memory_search;
const CONTEXT_THRESHOLD: u64 = 900_000;
const RATE_LIMIT_SECS: u64 = 60;
const SOCK_PATH: &str = ".consciousness/daemon.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() {
let pane = std::env::var("TMUX_PANE").unwrap_or_default();
if pane.is_empty() {
daemon_cmd(&["response"]);
} else {
daemon_cmd(&["response", &pane]);
}
}
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 for stale agent processes in a state dir.
/// Cleans up pid files for dead processes and kills timed-out ones.
/// Also detects PID reuse by checking if the process is actually a
/// claude/poc-memory process (reads /proc/pid/cmdline).
fn reap_agent_pids(state_dir: &std::path::Path, timeout_secs: u64) {
let Ok(entries) = fs::read_dir(state_dir) else { return };
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
let Some(pid_str) = name_str.strip_prefix("pid-") else { continue };
let Ok(pid) = pid_str.parse::<i32>() else { continue };
// Check if the process is actually alive
if unsafe { libc::kill(pid, 0) } != 0 {
fs::remove_file(entry.path()).ok();
continue;
}
// Check if the PID still belongs to a claude/poc-memory process.
// PID reuse by an unrelated process would otherwise block the
// agent from being re-launched.
let is_ours = fs::read_to_string(format!("/proc/{}/cmdline", pid))
.map(|cmd| cmd.contains("claude") || cmd.contains("poc-memory"))
.unwrap_or(false);
if !is_ours {
fs::remove_file(entry.path()).ok();
continue;
}
if timeout_secs > 0 {
if let Ok(meta) = entry.metadata() {
if let Ok(modified) = meta.modified() {
if modified.elapsed().unwrap_or_default().as_secs() > timeout_secs {
unsafe { libc::kill(pid, libc::SIGTERM); }
fs::remove_file(entry.path()).ok();
}
}
}
}
}
}
/// Reap all agent output directories.
fn reap_all_agents() {
let agent_output = store::memory_dir().join("agent-output");
if let Ok(entries) = fs::read_dir(&agent_output) {
for entry in entries.flatten() {
if entry.file_type().map_or(false, |t| t.is_dir()) {
reap_agent_pids(&entry.path(), 600); // 10 min timeout
}
}
}
}
/// Check if enough new conversation has accumulated to trigger an observation run.
fn maybe_trigger_observation(transcript: &PathBuf) {
let cursor_file = 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 = dirs::home_dir().unwrap_or_default().join(".consciousness/cache/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."
);
}
}
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();
reap_all_agents();
print!("{}", memory_search::run_hook(&input));
if let Some(ref t) = transcript {
check_context(t, false);
maybe_trigger_observation(t);
}
}
"PostToolUse" => {
signal_response();
print!("{}", 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);
if !stop_hook_active {
signal_response();
}
}
_ => {}
}
}