initial commit
This commit is contained in:
commit
d2f5881838
13 changed files with 6848 additions and 0 deletions
272
src/poc-hook.rs
Normal file
272
src/poc-hook.rs
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
// 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() {
|
||||
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 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" => {
|
||||
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();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue