#[derive(Subcommand)]
pub enum HookCommands {
Stop,
Prompt,
SessionStart,
}
fn extract_content_text(content: Option<&serde_json::Value>) -> String {
match content {
None => String::new(),
Some(serde_json::Value::String(s)) => s.clone(),
Some(serde_json::Value::Array(arr)) => arr
.iter()
.filter_map(|b| b.get("text").and_then(|t| t.as_str()))
.collect::<Vec<_>>()
.join(" "),
_ => String::new(),
}
}
fn run_hook_stop() -> anyhow::Result<()> {
use std::io::{Read, Write};
let mut input = String::new();
std::io::stdin().read_to_string(&mut input)?;
let data: serde_json::Value = serde_json::from_str(&input).unwrap_or(serde_json::Value::Null);
let transcript_text = data
.get("transcript_path")
.and_then(|v| v.as_str())
.and_then(|p| std::fs::read_to_string(p).ok())
.unwrap_or_default();
let recall_used = transcript_text
.lines()
.any(|l| l.contains("tool_use") && l.contains("innate_recall"))
|| (input.contains("tool_use") && input.contains("innate_recall"));
let mut summary: String = data
.get("last_assistant_message")
.and_then(|v| v.as_str())
.unwrap_or("")
.chars()
.take(400)
.collect();
let mut query = String::new();
for line in transcript_text.lines().rev() {
if !query.is_empty() && !summary.is_empty() {
break;
}
let Ok(m) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
let role = m
.pointer("/message/role")
.and_then(|r| r.as_str())
.unwrap_or("");
let content = m.pointer("/message/content");
if query.is_empty() && role == "user" {
let q = extract_content_text(content);
if !q.trim().is_empty() {
query = q.chars().take(200).collect();
}
}
if summary.is_empty() && role == "assistant" {
summary = extract_content_text(content).chars().take(400).collect();
}
}
if query.is_empty() || summary.is_empty() {
let empty = vec![];
let transcript = data
.get("transcript")
.or_else(|| data.get("messages"))
.and_then(|v| v.as_array())
.unwrap_or(&empty);
for m in transcript.iter().rev() {
let role = m.get("role").and_then(|r| r.as_str()).unwrap_or("");
if query.is_empty() && role == "user" {
query = extract_content_text(m.get("content"))
.chars()
.take(200)
.collect();
}
if summary.is_empty() && role == "assistant" {
summary = extract_content_text(m.get("content"))
.chars()
.take(400)
.collect();
}
if !query.is_empty() && !summary.is_empty() {
break;
}
}
}
let mut events: Vec<serde_json::Value> = Vec::new();
if !query.is_empty() {
events.push(json!({"event_type": "session_start", "query": query.trim()}));
}
if !summary.is_empty() && recall_used {
events.push(json!({"event_type": "tool_success", "output_summary": summary.trim(), "outcome": "unknown"}));
}
events.push(json!({"event_type": "session_end"}));
let log_path = crate::paths::session_log_path();
if let Some(parent) = log_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)?;
for event in &events {
writeln!(file, "{}", serde_json::to_string(event)?)?;
}
Ok(())
}
pub(crate) fn run_command(action: &HookCommands, db_path: &Path) -> anyhow::Result<()> {
match action {
HookCommands::Stop => run_hook_stop(),
HookCommands::Prompt => {
let _ = run_hook_recall(db_path, HookKind::Prompt);
Ok(())
}
HookCommands::SessionStart => {
let _ = run_hook_recall(db_path, HookKind::SessionStart);
Ok(())
}
}
}
#[derive(Clone, Copy)]
enum HookKind {
Prompt,
SessionStart,
}
const DEFAULT_HOOK_MIN_SCORE: f64 = 0.40;
fn run_hook_recall(db_path: &Path, kind: HookKind) -> anyhow::Result<()> {
use std::io::Read;
let mut input = String::new();
std::io::stdin().read_to_string(&mut input)?;
let data: serde_json::Value = serde_json::from_str(&input).unwrap_or(serde_json::Value::Null);
let query: String = match kind {
HookKind::Prompt => data
.get("prompt")
.and_then(|v| v.as_str())
.unwrap_or("")
.chars()
.take(500)
.collect(),
HookKind::SessionStart => {
let cwd = data
.get("cwd")
.and_then(|v| v.as_str())
.or_else(|| data.get("workspace").and_then(|v| v.as_str()))
.unwrap_or("");
std::path::Path::new(cwd)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string()
}
};
if query.trim().is_empty() {
return Ok(());
}
let min_score = std::env::var("INNATE_HOOK_MIN_SCORE")
.ok()
.and_then(|v| v.parse::<f64>().ok())
.unwrap_or(DEFAULT_HOOK_MIN_SCORE);
let kb = crate::open_kb(db_path)?;
let result = kb.recall(RecallParams {
query: &query,
budget: 4000,
trace: true,
include_sparks: false,
top: Some(5),
source: "hook",
expand_deps: "false",
allow_trim: false,
refine_mode: "off",
min_score: Some(min_score),
})?;
if result.knowledge.is_empty() {
return Ok(());
}
let mut out = String::new();
out.push_str("<innate-recall>\n");
out.push_str(&format!(
"Innate recalled {} relevant knowledge chunk(s). Apply what helps; \
when you finish, call innate_record(trace_id, outcome, used=[ids you actually applied], \
feedback_up/down=[ids that helped/misled]).\n\n",
result.knowledge.len()
));
for c in &result.knowledge {
let id = c.get("id").and_then(|v| v.as_str()).unwrap_or("?");
let content = c.get("content").and_then(|v| v.as_str()).unwrap_or("");
let conf = c.get("confidence").and_then(|v| v.as_f64()).unwrap_or(0.0);
out.push_str(&format!("- [{id}] (confidence {conf:.2}) {content}\n"));
}
out.push_str(&format!("\ntrace_id: {}\n", result.trace_id));
out.push_str("</innate-recall>");
println!("{out}");
Ok(())
}
use crate::kb::RecallParams;
use clap::Subcommand;
use serde_json::json;
use std::path::Path;