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>
278 lines
8.6 KiB
Rust
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();
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|