forked from kent/consciousness
- Remove MEMORY_FILES constant from identity.rs - Add ContextGroup struct for deserializing from config - Load context_groups from ~/.config/poc-agent/config.json5 - Check ~/.config/poc-agent/ first for identity files, then project/global - Debug screen now shows what's actually configured This eliminates the hardcoded duplication and makes the debug output match what's in the config file.
256 lines
7.8 KiB
Rust
256 lines
7.8 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."
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
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);
|
|
}
|
|
|
|
}
|
|
"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);
|
|
}
|
|
|
|
}
|
|
"Stop" => {
|
|
let stop_hook_active = hook["stop_hook_active"].as_bool().unwrap_or(false);
|
|
if !stop_hook_active {
|
|
signal_response();
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|