Refactor hook: split agent orchestration from formatting

- Remove POC_AGENT early return (was from old claude -p era)
- Split hook into run_agent_cycles() -> AgentCycleOutput (returns
  memory keys + reflection) and format_agent_output() (renders for
  Claude Code injection). poc-agent can call run_agent_cycles
  directly and handle output its own way.
- Fix UTF-8 panic in runner.rs display_buf slicing (floor_char_boundary)
- Add priority debug label to API requests
- Wire up F2 agents screen: live pid status, output files, hook log
  tail, arrow key navigation, Enter for log detail view

Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
Kent Overstreet 2026-04-02 00:32:23 -04:00
parent c72eb4d528
commit a0245c1279
4 changed files with 364 additions and 115 deletions

View file

@ -21,9 +21,6 @@ pub use crate::session::Session;
/// Run the hook logic on parsed JSON input. Returns output to inject.
pub fn run_hook(input: &str) -> String {
// Daemon agent calls set POC_AGENT=1 — skip memory search.
if std::env::var("POC_AGENT").is_ok() { return String::new(); }
let Some(session) = Session::from_json(input) else { return String::new() };
hook(&session)
}
@ -127,12 +124,72 @@ fn mark_seen(dir: &Path, session_id: &str, key: &str, seen: &mut HashSet<String>
}
}
/// Unified agent cycle — runs surface-observe agent with state dir.
/// Reads output files for surface results, spawns new agent when ready.
///
/// Pipelining: if a running agent is past the surface phase, start
/// a new one so surface stays fresh.
fn surface_observe_cycle(session: &Session, out: &mut String, log_f: &mut File) {
/// Output from a single agent orchestration cycle.
pub struct AgentCycleOutput {
/// Memory node keys surfaced by surface-observe.
pub surfaced_keys: Vec<String>,
/// Freeform reflection text from the reflect agent.
pub reflection: Option<String>,
/// How long we slept waiting for observe to catch up, if at all.
pub sleep_secs: Option<f64>,
}
/// Run all agent cycles: surface-observe, reflect, journal.
/// Returns surfaced memory keys and any reflection text.
/// Caller decides how to render and inject the output.
pub fn run_agent_cycles(session: &Session) -> AgentCycleOutput {
let log_dir = dirs::home_dir().unwrap_or_default().join(".consciousness/logs");
fs::create_dir_all(&log_dir).ok();
let log_path = log_dir.join(format!("hook-{}", session.session_id));
let Ok(mut log_f) = fs::OpenOptions::new().create(true).append(true).open(log_path)
else { return AgentCycleOutput { surfaced_keys: vec![], reflection: None, sleep_secs: None } };
let ts = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S");
let _ = writeln!(log_f, "\n=== {} agent_cycles ===", ts);
cleanup_stale_files(&session.state_dir, Duration::from_secs(86400));
let (surfaced_keys, sleep_secs) = surface_observe_cycle(session, &mut log_f);
let reflection = reflection_cycle(session, &mut log_f);
journal_cycle(session, &mut log_f);
AgentCycleOutput { surfaced_keys, reflection, sleep_secs }
}
/// Format agent cycle output for injection into a Claude Code session.
pub fn format_agent_output(output: &AgentCycleOutput) -> String {
let mut out = String::new();
if let Some(secs) = output.sleep_secs {
out.push_str(&format!("Slept {secs:.2}s to let observe catch up\n"));
}
if !output.surfaced_keys.is_empty() {
if let Ok(store) = crate::store::Store::load() {
for key in &output.surfaced_keys {
if let Some(rendered) = crate::cli::node::render_node(&store, key) {
if !rendered.trim().is_empty() {
use std::fmt::Write as _;
writeln!(out, "--- {} (surfaced) ---", key).ok();
write!(out, "{}", rendered).ok();
}
}
}
}
}
if let Some(ref reflection) = output.reflection {
use std::fmt::Write as _;
writeln!(out, "--- subconscious reflection ---").ok();
write!(out, "{}", reflection.trim()).ok();
}
out
}
/// Surface-observe cycle: read surfaced keys, manage agent lifecycle.
/// Returns (surfaced keys, optional sleep duration).
fn surface_observe_cycle(session: &Session, log_f: &mut File) -> (Vec<String>, Option<f64>) {
let state_dir = crate::store::memory_dir()
.join("agent-output")
.join("surface-observe");
@ -153,51 +210,35 @@ fn surface_observe_cycle(session: &Session, out: &mut String, log_f: &mut File)
let _ = writeln!(log_f, "alive pid-{}: phase={}", pid, phase);
}
// Read surface output and inject into context
// Read surfaced keys
let mut surfaced_keys = Vec::new();
let surface_path = state_dir.join("surface");
if let Ok(content) = fs::read_to_string(&surface_path) {
match crate::store::Store::load() {
Ok(store) => {
let mut seen = session.seen();
let seen_path = session.path("seen");
for key in content.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
if !seen.insert(key.to_string()) {
let _ = writeln!(log_f, " skip (seen): {}", key);
continue;
}
if let Some(rendered) = crate::cli::node::render_node(&store, key) {
if !rendered.trim().is_empty() {
use std::fmt::Write as _;
writeln!(out, "--- {} (surfaced) ---", key).ok();
write!(out, "{}", rendered).ok();
let _ = writeln!(log_f, " rendered {}: {} bytes", key, rendered.len());
if let Ok(mut f) = fs::OpenOptions::new()
.create(true).append(true).open(&seen_path) {
let ts = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S");
writeln!(f, "{}\t{}", ts, key).ok();
}
}
}
let mut seen = session.seen();
let seen_path = session.path("seen");
for key in content.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
if !seen.insert(key.to_string()) {
let _ = writeln!(log_f, " skip (seen): {}", key);
continue;
}
surfaced_keys.push(key.to_string());
if let Ok(mut f) = fs::OpenOptions::new()
.create(true).append(true).open(&seen_path) {
let ts = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S");
writeln!(f, "{}\t{}", ts, key).ok();
}
let _ = writeln!(log_f, " surfaced: {}", key);
}
Err(e) => {
let _ = writeln!(log_f, "error loading store: {}", e);
}
}
// Clear surface output after consuming
fs::remove_file(&surface_path).ok();
}
// Start a new agent if:
// - nothing running, OR
// - something running but past surface phase (pipelining)
// Spawn new agent if needed
let live = crate::agents::knowledge::scan_pid_files(&state_dir, timeout);
let any_in_surface = live.iter().any(|(p, _)| p == "surface");
if any_in_surface {
let _ = writeln!(log_f, "agent in surface phase (have {:?}), waiting", live);
} else {
// Record transcript offset so we can detect falling behind
if transcript.size > 0 {
fs::write(&offset_path, transcript.size.to_string()).ok();
}
@ -206,18 +247,16 @@ fn surface_observe_cycle(session: &Session, out: &mut String, log_f: &mut File)
let _ = writeln!(log_f, "spawned agent {:?}, have {:?}", pid, live);
}
// If the agent is significantly behind, wait for it to finish.
// This prevents the agent from falling behind during heavy reading
// (studying, reading a book, etc.)
// Wait if agent is significantly behind
let mut sleep_secs = None;
let conversation_budget: u64 = 50_000;
if !live.is_empty() && transcript.size > 0 {
let behind = transcript.size.saturating_sub(last_offset);
if behind > conversation_budget / 2 {
// Wait up to 5s for the current agent to finish
let sleep_start = Instant::now();
let _ = write!(log_f, "agent {}KB behind (budget {}",
let _ = write!(log_f, "agent {}KB behind (budget {}KB)",
behind / 1024, conversation_budget / 1024);
for _ in 0..5 {
@ -226,24 +265,22 @@ fn surface_observe_cycle(session: &Session, out: &mut String, log_f: &mut File)
if still_live.is_empty() { break; }
}
let sleep_secs = (Instant::now() - sleep_start).as_secs_f64();
let _ = writeln!(log_f, ", slept {sleep_secs:.2}s");
out.push_str(&format!("Slept {sleep_secs:.2}s to let observe catch up\n"));
let secs = (Instant::now() - sleep_start).as_secs_f64();
let _ = writeln!(log_f, ", slept {secs:.2}s");
sleep_secs = Some(secs);
}
}
(surfaced_keys, sleep_secs)
}
/// Run the reflection agent on a slower cadence — every 100KB of transcript.
/// Uses the surface-observe state dir to read walked nodes and write reflections.
/// Reflections are injected into the conversation context.
fn reflection_cycle(session: &Session, out: &mut String, log_f: &mut File) {
/// Reflection cycle: spawn reflect agent, return any pending reflection.
fn reflection_cycle(session: &Session, log_f: &mut File) -> Option<String> {
let state_dir = crate::store::memory_dir()
.join("agent-output")
.join("reflect");
fs::create_dir_all(&state_dir).ok();
// Check transcript growth since last reflection
let offset_path = state_dir.join("transcript-offset");
let transcript = session.transcript();
@ -253,17 +290,16 @@ fn reflection_cycle(session: &Session, out: &mut String, log_f: &mut File) {
const REFLECTION_INTERVAL: u64 = 100_000;
if transcript.size.saturating_sub(last_offset) < REFLECTION_INTERVAL {
return;
return None;
}
// Don't run if another reflection is already going
let live = crate::agents::knowledge::scan_pid_files(&state_dir, 300);
if !live.is_empty() {
let _ = writeln!(log_f, "reflect: already running {:?}", live);
return;
return None;
}
// Copy walked nodes from surface-observe state dir so reflect can read them
// Copy walked nodes from surface-observe
let so_state = crate::store::memory_dir()
.join("agent-output")
.join("surface-observe");
@ -271,26 +307,23 @@ fn reflection_cycle(session: &Session, out: &mut String, log_f: &mut File) {
fs::write(state_dir.join("walked"), &walked).ok();
}
// Read previous reflection and inject into context
if let Ok(reflection) = fs::read_to_string(state_dir.join("reflection")) {
if !reflection.trim().is_empty() {
use std::fmt::Write as _;
writeln!(out, "--- subconscious reflection ---").ok();
write!(out, "{}", reflection.trim()).ok();
let _ = writeln!(log_f, "reflect: injected {} bytes", reflection.len());
}
// Read and consume pending reflection
let reflection = fs::read_to_string(state_dir.join("reflection")).ok()
.filter(|s| !s.trim().is_empty());
if reflection.is_some() {
fs::remove_file(state_dir.join("reflection")).ok();
let _ = writeln!(log_f, "reflect: consumed reflection");
}
fs::write(&offset_path, transcript.size.to_string()).ok();
let pid = crate::agents::knowledge::spawn_agent(
"reflect", &state_dir, &session.session_id);
let _ = writeln!(log_f, "reflect: spawned {:?}", pid);
reflection
}
/// Run the journal agent on its own cadence — every 20KB of transcript.
/// Standalone agent that captures episodic memory independently of the
/// surface-observe pipeline.
/// Journal cycle: fire and forget.
fn journal_cycle(session: &Session, log_f: &mut File) {
let state_dir = crate::store::memory_dir()
.join("agent-output")
@ -401,14 +434,11 @@ fn hook(session: &Session) -> String {
} else {
let cfg = crate::config::get();
if cfg.surface_hooks.iter().any(|h| h == &session.hook_event) {
surface_observe_cycle(session, &mut out, &mut log_f);
reflection_cycle(session, &mut out, &mut log_f);
journal_cycle(session, &mut log_f);
let cycle_output = run_agent_cycles(&session);
out.push_str(&format_agent_output(&cycle_output));
}
}
cleanup_stale_files(&session.state_dir, Duration::from_secs(86400));
let _ = write!(log_f, "{}", out);
let duration = (Instant::now() - start_time).as_secs_f64();