Skip to main content

innate_core/
hook.rs

1#[derive(Subcommand)]
2pub enum HookCommands {
3    /// Process a Claude Code Stop hook payload from stdin and record session events
4    Stop,
5}
6fn extract_content_text(content: Option<&serde_json::Value>) -> String {
7    match content {
8        None => String::new(),
9        Some(serde_json::Value::String(s)) => s.clone(),
10        Some(serde_json::Value::Array(arr)) => arr
11            .iter()
12            .filter_map(|b| b.get("text").and_then(|t| t.as_str()))
13            .collect::<Vec<_>>()
14            .join(" "),
15        _ => String::new(),
16    }
17}
18
19fn run_hook_stop() -> anyhow::Result<()> {
20    use std::io::{Read, Write};
21
22    let mut input = String::new();
23    std::io::stdin().read_to_string(&mut input)?;
24
25    let data: serde_json::Value = serde_json::from_str(&input).unwrap_or(serde_json::Value::Null);
26
27    let empty = vec![];
28    let transcript = data
29        .get("transcript")
30        .or_else(|| data.get("messages"))
31        .and_then(|v| v.as_array())
32        .unwrap_or(&empty);
33
34    let mut query = String::new();
35    let mut summary = String::new();
36
37    for m in transcript.iter().rev() {
38        let role = m.get("role").and_then(|r| r.as_str()).unwrap_or("");
39        if query.is_empty() && role == "user" {
40            query = extract_content_text(m.get("content"))
41                .chars()
42                .take(200)
43                .collect();
44        }
45        if summary.is_empty() && role == "assistant" {
46            summary = extract_content_text(m.get("content"))
47                .chars()
48                .take(400)
49                .collect();
50        }
51        if !query.is_empty() && !summary.is_empty() {
52            break;
53        }
54    }
55
56    let mut events: Vec<serde_json::Value> = Vec::new();
57    if !query.is_empty() {
58        events.push(json!({"event_type": "session_start", "query": query.trim()}));
59    }
60    if !summary.is_empty() {
61        events.push(json!({"event_type": "tool_success", "output_summary": summary.trim(), "outcome": "ok"}));
62    }
63    events.push(json!({"event_type": "session_end"}));
64
65    let log_path = crate::paths::session_log_path();
66
67    if let Some(parent) = log_path.parent() {
68        std::fs::create_dir_all(parent)?;
69    }
70
71    let mut file = std::fs::OpenOptions::new()
72        .create(true)
73        .append(true)
74        .open(&log_path)?;
75
76    for event in &events {
77        writeln!(file, "{}", serde_json::to_string(event)?)?;
78    }
79
80    Ok(())
81}
82
83pub(crate) fn run_command(action: &HookCommands) -> anyhow::Result<()> {
84    match action {
85        HookCommands::Stop => run_hook_stop(),
86    }
87}
88use clap::Subcommand;
89use serde_json::json;