Skip to main content

spool/hook_runtime/
post_tool_use.rs

1//! `spool hook post-tool-use` — append a signal envelope to the distill
2//! queue.
3//!
4//! The queue file is a JSONL-formatted append-only log under
5//! `<cwd>/.spool/distill-pending.queue`. Each line is one signal
6//! envelope; later phases (R3 starts consuming) read the queue at Stop
7//! to produce candidate memories.
8//!
9//! ## What gets recorded
10//! - `recorded_at` (unix seconds)
11//! - `tool_name` — the AI client passes this in via env or stdin
12//! - `cwd` — for cross-project sanity checks
13//! - `payload` — opaque string blob, currently the tool's input/output
14//!   summary truncated to 4 KiB to keep the queue bounded.
15//!
16//! ## R2 boundaries
17//! - We don't yet redact secrets here; redact lives in R3
18//!   `src/distill/redact.rs` and runs at consume time. Storing raw
19//!   payload is fine because `.spool/` is project-local and we never
20//!   send it anywhere in R2.
21//! - Queue trimming (LRU 100) is also R3 — R2 just appends.
22
23use std::fs::OpenOptions;
24use std::io::Write;
25use std::path::PathBuf;
26use std::time::{SystemTime, UNIX_EPOCH};
27
28use anyhow::Context;
29use serde::{Deserialize, Serialize};
30
31use super::project_runtime_dir;
32
33#[derive(Debug, Clone)]
34pub struct PostToolUseArgs {
35    pub config_path: PathBuf,
36    pub cwd: Option<PathBuf>,
37    /// Name of the tool that just ran (e.g. "Bash", "Edit"). Optional.
38    pub tool_name: Option<String>,
39    /// Raw payload describing what the tool produced. Truncated when
40    /// stored. Optional.
41    pub payload: Option<String>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
45pub struct DistillSignalEnvelope {
46    pub recorded_at: u64,
47    pub tool_name: Option<String>,
48    pub cwd: String,
49    pub payload: Option<String>,
50}
51
52const MAX_PAYLOAD_BYTES: usize = 4096;
53pub(crate) const QUEUE_FILE_NAME: &str = "distill-pending.queue";
54
55pub fn run(args: PostToolUseArgs) -> anyhow::Result<()> {
56    let cwd = match args.cwd {
57        Some(p) => p,
58        None => std::env::current_dir().context("resolving cwd for post-tool-use hook")?,
59    };
60    let runtime_dir = project_runtime_dir(&cwd)?;
61    let queue_path = runtime_dir.join(QUEUE_FILE_NAME);
62
63    let envelope = DistillSignalEnvelope {
64        recorded_at: SystemTime::now()
65            .duration_since(UNIX_EPOCH)
66            .map(|d| d.as_secs())
67            .unwrap_or(0),
68        tool_name: args.tool_name,
69        cwd: cwd.display().to_string(),
70        payload: args.payload.map(truncate_payload),
71    };
72    let line = serde_json::to_string(&envelope).context("serializing distill signal envelope")?;
73
74    let mut file = OpenOptions::new()
75        .create(true)
76        .append(true)
77        .open(&queue_path)
78        .with_context(|| format!("opening distill queue {}", queue_path.display()))?;
79    writeln!(file, "{}", line).with_context(|| format!("appending to {}", queue_path.display()))?;
80
81    // Light sanity: surface a missing config to stderr (run_silent
82    // wraps this; user sees it in hook log).
83    if !args.config_path.exists() {
84        anyhow::bail!("config path does not exist: {}", args.config_path.display());
85    }
86    Ok(())
87}
88
89fn truncate_payload(p: String) -> String {
90    if p.len() <= MAX_PAYLOAD_BYTES {
91        return p;
92    }
93    let mut end = MAX_PAYLOAD_BYTES;
94    // Avoid splitting on a UTF-8 boundary.
95    while end > 0 && !p.is_char_boundary(end) {
96        end -= 1;
97    }
98    let mut out = String::with_capacity(end + 1);
99    out.push_str(&p[..end]);
100    out.push('…');
101    out
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use tempfile::tempdir;
108
109    fn cfg(temp: &tempfile::TempDir) -> PathBuf {
110        let cfg = temp.path().join("spool.toml");
111        std::fs::write(&cfg, "x=1").unwrap();
112        cfg
113    }
114
115    #[test]
116    fn run_appends_envelope_to_queue() {
117        let temp = tempdir().unwrap();
118        let config_path = cfg(&temp);
119        run(PostToolUseArgs {
120            config_path,
121            cwd: Some(temp.path().to_path_buf()),
122            tool_name: Some("Bash".into()),
123            payload: Some("ls -la".into()),
124        })
125        .unwrap();
126
127        let queue = temp.path().join(".spool").join("distill-pending.queue");
128        let raw = std::fs::read_to_string(&queue).unwrap();
129        assert_eq!(raw.lines().count(), 1, "exactly one line per call");
130        let env: DistillSignalEnvelope = serde_json::from_str(raw.lines().next().unwrap()).unwrap();
131        assert_eq!(env.tool_name.as_deref(), Some("Bash"));
132        assert_eq!(env.payload.as_deref(), Some("ls -la"));
133    }
134
135    #[test]
136    fn run_appends_multiple_lines_idempotently() {
137        let temp = tempdir().unwrap();
138        let config_path = cfg(&temp);
139        for i in 0..3 {
140            run(PostToolUseArgs {
141                config_path: config_path.clone(),
142                cwd: Some(temp.path().to_path_buf()),
143                tool_name: Some(format!("tool-{}", i)),
144                payload: None,
145            })
146            .unwrap();
147        }
148        let queue = temp.path().join(".spool").join("distill-pending.queue");
149        let raw = std::fs::read_to_string(&queue).unwrap();
150        assert_eq!(raw.lines().count(), 3);
151    }
152
153    #[test]
154    fn truncate_payload_caps_long_input() {
155        let big = "x".repeat(MAX_PAYLOAD_BYTES * 2);
156        let short = truncate_payload(big);
157        assert!(short.len() <= MAX_PAYLOAD_BYTES + 4);
158        assert!(short.ends_with('…'));
159    }
160
161    #[test]
162    fn truncate_payload_passes_short_input_through() {
163        let s = "hello".to_string();
164        assert_eq!(truncate_payload(s.clone()), s);
165    }
166
167    #[test]
168    fn run_errors_on_missing_config() {
169        let temp = tempdir().unwrap();
170        let err = run(PostToolUseArgs {
171            config_path: temp.path().join("nope.toml"),
172            cwd: Some(temp.path().to_path_buf()),
173            tool_name: None,
174            payload: None,
175        })
176        .unwrap_err();
177        assert!(err.to_string().contains("config path does not exist"));
178    }
179}