spool-memory 0.2.3

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
//! `spool hook post-tool-use` — append a signal envelope to the distill
//! queue.
//!
//! The queue file is a JSONL-formatted append-only log under
//! `<cwd>/.spool/distill-pending.queue`. Each line is one signal
//! envelope; later phases (R3 starts consuming) read the queue at Stop
//! to produce candidate memories.
//!
//! ## What gets recorded
//! - `recorded_at` (unix seconds)
//! - `tool_name` — the AI client passes this in via env or stdin
//! - `cwd` — for cross-project sanity checks
//! - `payload` — opaque string blob, currently the tool's input/output
//!   summary truncated to 4 KiB to keep the queue bounded.
//!
//! ## R2 boundaries
//! - We don't yet redact secrets here; redact lives in R3
//!   `src/distill/redact.rs` and runs at consume time. Storing raw
//!   payload is fine because `.spool/` is project-local and we never
//!   send it anywhere in R2.
//! - Queue trimming (LRU 100) is also R3 — R2 just appends.

use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};

use anyhow::Context;
use serde::{Deserialize, Serialize};

use super::project_runtime_dir;

#[derive(Debug, Clone)]
pub struct PostToolUseArgs {
    pub config_path: PathBuf,
    pub cwd: Option<PathBuf>,
    /// Name of the tool that just ran (e.g. "Bash", "Edit"). Optional.
    pub tool_name: Option<String>,
    /// Raw payload describing what the tool produced. Truncated when
    /// stored. Optional.
    pub payload: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DistillSignalEnvelope {
    pub recorded_at: u64,
    pub tool_name: Option<String>,
    pub cwd: String,
    pub payload: Option<String>,
}

const MAX_PAYLOAD_BYTES: usize = 4096;
pub(crate) const QUEUE_FILE_NAME: &str = "distill-pending.queue";

pub fn run(args: PostToolUseArgs) -> anyhow::Result<()> {
    let cwd = match args.cwd {
        Some(p) => p,
        None => std::env::current_dir().context("resolving cwd for post-tool-use hook")?,
    };
    let runtime_dir = project_runtime_dir(&cwd)?;
    let queue_path = runtime_dir.join(QUEUE_FILE_NAME);

    let envelope = DistillSignalEnvelope {
        recorded_at: SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or(0),
        tool_name: args.tool_name,
        cwd: cwd.display().to_string(),
        payload: args.payload.map(truncate_payload),
    };
    let line = serde_json::to_string(&envelope).context("serializing distill signal envelope")?;

    let mut file = OpenOptions::new()
        .create(true)
        .append(true)
        .open(&queue_path)
        .with_context(|| format!("opening distill queue {}", queue_path.display()))?;
    writeln!(file, "{}", line).with_context(|| format!("appending to {}", queue_path.display()))?;

    // Light sanity: surface a missing config to stderr (run_silent
    // wraps this; user sees it in hook log).
    if !args.config_path.exists() {
        anyhow::bail!("config path does not exist: {}", args.config_path.display());
    }
    Ok(())
}

fn truncate_payload(p: String) -> String {
    if p.len() <= MAX_PAYLOAD_BYTES {
        return p;
    }
    let mut end = MAX_PAYLOAD_BYTES;
    // Avoid splitting on a UTF-8 boundary.
    while end > 0 && !p.is_char_boundary(end) {
        end -= 1;
    }
    let mut out = String::with_capacity(end + 1);
    out.push_str(&p[..end]);
    out.push('');
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;

    fn cfg(temp: &tempfile::TempDir) -> PathBuf {
        let cfg = temp.path().join("spool.toml");
        std::fs::write(&cfg, "x=1").unwrap();
        cfg
    }

    #[test]
    fn run_appends_envelope_to_queue() {
        let temp = tempdir().unwrap();
        let config_path = cfg(&temp);
        run(PostToolUseArgs {
            config_path,
            cwd: Some(temp.path().to_path_buf()),
            tool_name: Some("Bash".into()),
            payload: Some("ls -la".into()),
        })
        .unwrap();

        let queue = temp.path().join(".spool").join("distill-pending.queue");
        let raw = std::fs::read_to_string(&queue).unwrap();
        assert_eq!(raw.lines().count(), 1, "exactly one line per call");
        let env: DistillSignalEnvelope = serde_json::from_str(raw.lines().next().unwrap()).unwrap();
        assert_eq!(env.tool_name.as_deref(), Some("Bash"));
        assert_eq!(env.payload.as_deref(), Some("ls -la"));
    }

    #[test]
    fn run_appends_multiple_lines_idempotently() {
        let temp = tempdir().unwrap();
        let config_path = cfg(&temp);
        for i in 0..3 {
            run(PostToolUseArgs {
                config_path: config_path.clone(),
                cwd: Some(temp.path().to_path_buf()),
                tool_name: Some(format!("tool-{}", i)),
                payload: None,
            })
            .unwrap();
        }
        let queue = temp.path().join(".spool").join("distill-pending.queue");
        let raw = std::fs::read_to_string(&queue).unwrap();
        assert_eq!(raw.lines().count(), 3);
    }

    #[test]
    fn truncate_payload_caps_long_input() {
        let big = "x".repeat(MAX_PAYLOAD_BYTES * 2);
        let short = truncate_payload(big);
        assert!(short.len() <= MAX_PAYLOAD_BYTES + 4);
        assert!(short.ends_with(''));
    }

    #[test]
    fn truncate_payload_passes_short_input_through() {
        let s = "hello".to_string();
        assert_eq!(truncate_payload(s.clone()), s);
    }

    #[test]
    fn run_errors_on_missing_config() {
        let temp = tempdir().unwrap();
        let err = run(PostToolUseArgs {
            config_path: temp.path().join("nope.toml"),
            cwd: Some(temp.path().to_path_buf()),
            tool_name: None,
            payload: None,
        })
        .unwrap_err();
        assert!(err.to_string().contains("config path does not exist"));
    }
}