spool-memory 0.2.3

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
//! `spool hook pre-compact` — persist self-tagged memories before
//! context compaction so they survive the window trim.
//!
//! ## Pipeline
//! 1. Read active context from stdin (Claude Code passes the about-to-be-
//!    compacted conversation as a text blob).
//! 2. Run `self_tag::detect` to find explicit memory markers.
//! 3. Redact secrets, dedupe against existing ledger entries.
//! 4. Persist surviving signals as `accepted` via `LifecycleService`.
//! 5. Write `<cwd>/.spool/last-pre-compact.unix` marker.
//!
//! ## Why this matters
//! Context compaction is lossy. If the user said "记一下: X" early in a
//! session and the compactor drops that turn, the memory is lost unless
//! we capture it here. The Stop hook also captures self-tags, but it
//! runs *after* the session ends — pre-compact fires *during* the
//! session, before the window shrinks.

use std::io::{IsTerminal, Read as _};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

use anyhow::{Context, Result};

use super::project_runtime_dir;
use crate::distill::heuristic::self_tag::{self, SelfTagSignal};
use crate::distill::redact;
use crate::domain::MemoryScope;
use crate::lifecycle_service::LifecycleService;
use crate::lifecycle_store::{RecordMemoryRequest, TransitionMetadata};
use crate::vault_writer;

#[derive(Debug, Clone)]
pub struct PreCompactArgs {
    pub config_path: PathBuf,
    pub cwd: Option<PathBuf>,
    /// Override for stdin content (used in tests).
    pub context_override: Option<String>,
}

#[derive(Debug, Clone, Default)]
pub struct PreCompactReport {
    pub signals_detected: usize,
    pub signals_redacted_dropped: usize,
    pub signals_duplicate_dropped: usize,
    pub signals_persisted: Vec<String>,
}

pub fn run(args: PreCompactArgs) -> Result<PreCompactReport> {
    let cwd = match args.cwd {
        Some(p) => p,
        None => std::env::current_dir().context("resolving cwd for pre-compact hook")?,
    };
    let dir = project_runtime_dir(&cwd)?;

    let context_text = match args.context_override {
        Some(text) => text,
        None => read_stdin_context(),
    };

    let mut report = PreCompactReport::default();

    if !context_text.is_empty() {
        let signals = self_tag::detect(&context_text);
        report.signals_detected = signals.len();

        let existing_summaries = load_existing_summaries(&args.config_path);

        for signal in &signals {
            let redacted = redact::redact(&signal.content);
            if !redacted.is_clean() {
                report.signals_redacted_dropped += 1;
                continue;
            }
            let summary_lc = redacted.redacted.to_lowercase();
            if existing_summaries.contains(&summary_lc) {
                report.signals_duplicate_dropped += 1;
                continue;
            }
            match persist_signal(&args.config_path, signal, &redacted.redacted) {
                Ok(record_id) => report.signals_persisted.push(record_id),
                Err(err) => {
                    eprintln!("[spool pre-compact] persist failed: {:#}", err);
                }
            }
        }
    }

    let stamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);
    let path = dir.join("last-pre-compact.unix");
    std::fs::write(&path, stamp.to_string())
        .with_context(|| format!("writing pre-compact timestamp {}", path.display()))?;

    Ok(report)
}

fn read_stdin_context() -> String {
    if std::io::stdin().is_terminal() {
        return String::new();
    }
    let mut buf = String::new();
    let _ = std::io::stdin().read_to_string(&mut buf);
    buf
}

fn load_existing_summaries(config_path: &Path) -> Vec<String> {
    match LifecycleService::new().load_workbench(config_path) {
        Ok(snap) => snap
            .wakeup_ready
            .into_iter()
            .map(|e| e.record.summary.to_lowercase())
            .collect(),
        Err(_) => Vec::new(),
    }
}

fn persist_signal(config_path: &Path, signal: &SelfTagSignal, summary: &str) -> Result<String> {
    let title = format!("[{}] {}", signal.trigger, first_chars(&signal.content, 60));
    let request = RecordMemoryRequest {
        title,
        summary: summary.to_string(),
        memory_type: signal.kind.memory_type().to_string(),
        scope: MemoryScope::Project,
        source_ref: "hook:pre-compact:self-tag".to_string(),
        project_id: None,
        user_id: None,
        sensitivity: None,
        metadata: TransitionMetadata {
            actor: Some("spool-hook-pre-compact".to_string()),
            reason: Some(format!(
                "self-tag detected before compaction: {}",
                signal.trigger
            )),
            evidence_refs: Vec::new(),
        },
        entities: Vec::new(),
        tags: Vec::new(),
        triggers: Vec::new(),
        related_files: Vec::new(),
        related_records: Vec::new(),
        supersedes: None,
        applies_to: Vec::new(),
        valid_until: None,
    };
    let result = LifecycleService::new().record_manual(config_path, request)?;
    vault_writer::writeback_from_config(config_path, &result.entry);
    Ok(result.entry.record_id)
}

fn first_chars(s: &str, max: usize) -> String {
    let mut out = String::new();
    for (i, ch) in s.chars().enumerate() {
        if i >= max {
            out.push('');
            break;
        }
        out.push(ch);
    }
    out
}

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

    fn fixture_config(temp: &tempfile::TempDir) -> PathBuf {
        let cfg = temp.path().join("spool.toml");
        fs::write(&cfg, "[vault]\nroot = \"/tmp\"\n").unwrap();
        cfg
    }

    #[test]
    fn run_writes_marker_when_no_stdin() {
        let temp = tempdir().unwrap();
        let cfg = fixture_config(&temp);
        let cwd = temp.path().join("repo");
        fs::create_dir_all(&cwd).unwrap();

        let report = run(PreCompactArgs {
            config_path: cfg,
            cwd: Some(cwd.clone()),
            context_override: Some(String::new()),
        })
        .unwrap();

        assert_eq!(report.signals_detected, 0);
        assert!(cwd.join(".spool").join("last-pre-compact.unix").exists());
    }

    #[test]
    fn run_persists_self_tag_from_context() {
        let temp = tempdir().unwrap();
        let cfg = fixture_config(&temp);
        let cwd = temp.path().join("repo");
        fs::create_dir_all(&cwd).unwrap();

        let context =
            "用户之前说过:记一下:cargo install 是默认路径\n后面还有别的内容".to_string();
        let report = run(PreCompactArgs {
            config_path: cfg.clone(),
            cwd: Some(cwd.clone()),
            context_override: Some(context),
        })
        .unwrap();

        assert_eq!(report.signals_detected, 1);
        assert_eq!(report.signals_persisted.len(), 1);
        let snap = LifecycleService::new().load_workbench(&cfg).unwrap();
        assert_eq!(snap.wakeup_ready.len(), 1);
        assert_eq!(snap.wakeup_ready[0].record.memory_type, "preference");
    }

    #[test]
    fn run_dedupes_against_existing_entries() {
        let temp = tempdir().unwrap();
        let cfg = fixture_config(&temp);
        let cwd = temp.path().join("repo");
        fs::create_dir_all(&cwd).unwrap();

        // First run persists
        let context = "记一下:cargo install 是默认路径".to_string();
        let r1 = run(PreCompactArgs {
            config_path: cfg.clone(),
            cwd: Some(cwd.clone()),
            context_override: Some(context.clone()),
        })
        .unwrap();
        assert_eq!(r1.signals_persisted.len(), 1);

        // Second run dedupes
        let r2 = run(PreCompactArgs {
            config_path: cfg,
            cwd: Some(cwd),
            context_override: Some(context),
        })
        .unwrap();
        assert_eq!(r2.signals_detected, 1);
        assert_eq!(r2.signals_duplicate_dropped, 1);
        assert!(r2.signals_persisted.is_empty());
    }

    #[test]
    fn run_drops_signal_with_secret() {
        let temp = tempdir().unwrap();
        let cfg = fixture_config(&temp);
        let cwd = temp.path().join("repo");
        fs::create_dir_all(&cwd).unwrap();

        let context = "记一下: token sk-abcDEFghi1234567890ABCDEFGHIJ for prod".to_string();
        let report = run(PreCompactArgs {
            config_path: cfg,
            cwd: Some(cwd),
            context_override: Some(context),
        })
        .unwrap();

        assert_eq!(report.signals_detected, 1);
        assert_eq!(report.signals_redacted_dropped, 1);
        assert!(report.signals_persisted.is_empty());
    }

    #[test]
    fn run_handles_multiple_signals() {
        let temp = tempdir().unwrap();
        let cfg = fixture_config(&temp);
        let cwd = temp.path().join("repo");
        fs::create_dir_all(&cwd).unwrap();

        let context = "记一下:A 是 X\n以后都用 B 不用 C".to_string();
        let report = run(PreCompactArgs {
            config_path: cfg,
            cwd: Some(cwd),
            context_override: Some(context),
        })
        .unwrap();

        assert_eq!(report.signals_detected, 2);
        assert_eq!(report.signals_persisted.len(), 2);
    }
}