Skip to main content

spool/hook_runtime/
pre_compact.rs

1//! `spool hook pre-compact` — persist self-tagged memories before
2//! context compaction so they survive the window trim.
3//!
4//! ## Pipeline
5//! 1. Read active context from stdin (Claude Code passes the about-to-be-
6//!    compacted conversation as a text blob).
7//! 2. Run `self_tag::detect` to find explicit memory markers.
8//! 3. Redact secrets, dedupe against existing ledger entries.
9//! 4. Persist surviving signals as `accepted` via `LifecycleService`.
10//! 5. Write `<cwd>/.spool/last-pre-compact.unix` marker.
11//!
12//! ## Why this matters
13//! Context compaction is lossy. If the user said "记一下: X" early in a
14//! session and the compactor drops that turn, the memory is lost unless
15//! we capture it here. The Stop hook also captures self-tags, but it
16//! runs *after* the session ends — pre-compact fires *during* the
17//! session, before the window shrinks.
18
19use std::io::{IsTerminal, Read as _};
20use std::path::{Path, PathBuf};
21use std::time::{SystemTime, UNIX_EPOCH};
22
23use anyhow::{Context, Result};
24
25use super::project_runtime_dir;
26use crate::distill::heuristic::self_tag::{self, SelfTagSignal};
27use crate::distill::redact;
28use crate::domain::MemoryScope;
29use crate::lifecycle_service::LifecycleService;
30use crate::lifecycle_store::{RecordMemoryRequest, TransitionMetadata};
31use crate::vault_writer;
32
33#[derive(Debug, Clone)]
34pub struct PreCompactArgs {
35    pub config_path: PathBuf,
36    pub cwd: Option<PathBuf>,
37    /// Override for stdin content (used in tests).
38    pub context_override: Option<String>,
39}
40
41#[derive(Debug, Clone, Default)]
42pub struct PreCompactReport {
43    pub signals_detected: usize,
44    pub signals_redacted_dropped: usize,
45    pub signals_duplicate_dropped: usize,
46    pub signals_persisted: Vec<String>,
47}
48
49pub fn run(args: PreCompactArgs) -> Result<PreCompactReport> {
50    let cwd = match args.cwd {
51        Some(p) => p,
52        None => std::env::current_dir().context("resolving cwd for pre-compact hook")?,
53    };
54    let dir = project_runtime_dir(&cwd)?;
55
56    let context_text = match args.context_override {
57        Some(text) => text,
58        None => read_stdin_context(),
59    };
60
61    let mut report = PreCompactReport::default();
62
63    if !context_text.is_empty() {
64        let signals = self_tag::detect(&context_text);
65        report.signals_detected = signals.len();
66
67        let existing_summaries = load_existing_summaries(&args.config_path);
68
69        for signal in &signals {
70            let redacted = redact::redact(&signal.content);
71            if !redacted.is_clean() {
72                report.signals_redacted_dropped += 1;
73                continue;
74            }
75            let summary_lc = redacted.redacted.to_lowercase();
76            if existing_summaries.contains(&summary_lc) {
77                report.signals_duplicate_dropped += 1;
78                continue;
79            }
80            match persist_signal(&args.config_path, signal, &redacted.redacted) {
81                Ok(record_id) => report.signals_persisted.push(record_id),
82                Err(err) => {
83                    eprintln!("[spool pre-compact] persist failed: {:#}", err);
84                }
85            }
86        }
87    }
88
89    let stamp = SystemTime::now()
90        .duration_since(UNIX_EPOCH)
91        .map(|d| d.as_secs())
92        .unwrap_or(0);
93    let path = dir.join("last-pre-compact.unix");
94    std::fs::write(&path, stamp.to_string())
95        .with_context(|| format!("writing pre-compact timestamp {}", path.display()))?;
96
97    Ok(report)
98}
99
100fn read_stdin_context() -> String {
101    if std::io::stdin().is_terminal() {
102        return String::new();
103    }
104    let mut buf = String::new();
105    let _ = std::io::stdin().read_to_string(&mut buf);
106    buf
107}
108
109fn load_existing_summaries(config_path: &Path) -> Vec<String> {
110    match LifecycleService::new().load_workbench(config_path) {
111        Ok(snap) => snap
112            .wakeup_ready
113            .into_iter()
114            .map(|e| e.record.summary.to_lowercase())
115            .collect(),
116        Err(_) => Vec::new(),
117    }
118}
119
120fn persist_signal(config_path: &Path, signal: &SelfTagSignal, summary: &str) -> Result<String> {
121    let title = format!("[{}] {}", signal.trigger, first_chars(&signal.content, 60));
122    let request = RecordMemoryRequest {
123        title,
124        summary: summary.to_string(),
125        memory_type: signal.kind.memory_type().to_string(),
126        scope: MemoryScope::Project,
127        source_ref: "hook:pre-compact:self-tag".to_string(),
128        project_id: None,
129        user_id: None,
130        sensitivity: None,
131        metadata: TransitionMetadata {
132            actor: Some("spool-hook-pre-compact".to_string()),
133            reason: Some(format!(
134                "self-tag detected before compaction: {}",
135                signal.trigger
136            )),
137            evidence_refs: Vec::new(),
138        },
139        entities: Vec::new(),
140        tags: Vec::new(),
141        triggers: Vec::new(),
142        related_files: Vec::new(),
143        related_records: Vec::new(),
144        supersedes: None,
145        applies_to: Vec::new(),
146        valid_until: None,
147    };
148    let result = LifecycleService::new().record_manual(config_path, request)?;
149    vault_writer::writeback_from_config(config_path, &result.entry);
150    Ok(result.entry.record_id)
151}
152
153fn first_chars(s: &str, max: usize) -> String {
154    let mut out = String::new();
155    for (i, ch) in s.chars().enumerate() {
156        if i >= max {
157            out.push('…');
158            break;
159        }
160        out.push(ch);
161    }
162    out
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use std::fs;
169    use tempfile::tempdir;
170
171    fn fixture_config(temp: &tempfile::TempDir) -> PathBuf {
172        let cfg = temp.path().join("spool.toml");
173        fs::write(&cfg, "[vault]\nroot = \"/tmp\"\n").unwrap();
174        cfg
175    }
176
177    #[test]
178    fn run_writes_marker_when_no_stdin() {
179        let temp = tempdir().unwrap();
180        let cfg = fixture_config(&temp);
181        let cwd = temp.path().join("repo");
182        fs::create_dir_all(&cwd).unwrap();
183
184        let report = run(PreCompactArgs {
185            config_path: cfg,
186            cwd: Some(cwd.clone()),
187            context_override: Some(String::new()),
188        })
189        .unwrap();
190
191        assert_eq!(report.signals_detected, 0);
192        assert!(cwd.join(".spool").join("last-pre-compact.unix").exists());
193    }
194
195    #[test]
196    fn run_persists_self_tag_from_context() {
197        let temp = tempdir().unwrap();
198        let cfg = fixture_config(&temp);
199        let cwd = temp.path().join("repo");
200        fs::create_dir_all(&cwd).unwrap();
201
202        let context =
203            "用户之前说过:记一下:cargo install 是默认路径\n后面还有别的内容".to_string();
204        let report = run(PreCompactArgs {
205            config_path: cfg.clone(),
206            cwd: Some(cwd.clone()),
207            context_override: Some(context),
208        })
209        .unwrap();
210
211        assert_eq!(report.signals_detected, 1);
212        assert_eq!(report.signals_persisted.len(), 1);
213        let snap = LifecycleService::new().load_workbench(&cfg).unwrap();
214        assert_eq!(snap.wakeup_ready.len(), 1);
215        assert_eq!(snap.wakeup_ready[0].record.memory_type, "preference");
216    }
217
218    #[test]
219    fn run_dedupes_against_existing_entries() {
220        let temp = tempdir().unwrap();
221        let cfg = fixture_config(&temp);
222        let cwd = temp.path().join("repo");
223        fs::create_dir_all(&cwd).unwrap();
224
225        // First run persists
226        let context = "记一下:cargo install 是默认路径".to_string();
227        let r1 = run(PreCompactArgs {
228            config_path: cfg.clone(),
229            cwd: Some(cwd.clone()),
230            context_override: Some(context.clone()),
231        })
232        .unwrap();
233        assert_eq!(r1.signals_persisted.len(), 1);
234
235        // Second run dedupes
236        let r2 = run(PreCompactArgs {
237            config_path: cfg,
238            cwd: Some(cwd),
239            context_override: Some(context),
240        })
241        .unwrap();
242        assert_eq!(r2.signals_detected, 1);
243        assert_eq!(r2.signals_duplicate_dropped, 1);
244        assert!(r2.signals_persisted.is_empty());
245    }
246
247    #[test]
248    fn run_drops_signal_with_secret() {
249        let temp = tempdir().unwrap();
250        let cfg = fixture_config(&temp);
251        let cwd = temp.path().join("repo");
252        fs::create_dir_all(&cwd).unwrap();
253
254        let context = "记一下: token sk-abcDEFghi1234567890ABCDEFGHIJ for prod".to_string();
255        let report = run(PreCompactArgs {
256            config_path: cfg,
257            cwd: Some(cwd),
258            context_override: Some(context),
259        })
260        .unwrap();
261
262        assert_eq!(report.signals_detected, 1);
263        assert_eq!(report.signals_redacted_dropped, 1);
264        assert!(report.signals_persisted.is_empty());
265    }
266
267    #[test]
268    fn run_handles_multiple_signals() {
269        let temp = tempdir().unwrap();
270        let cfg = fixture_config(&temp);
271        let cwd = temp.path().join("repo");
272        fs::create_dir_all(&cwd).unwrap();
273
274        let context = "记一下:A 是 X\n以后都用 B 不用 C".to_string();
275        let report = run(PreCompactArgs {
276            config_path: cfg,
277            cwd: Some(cwd),
278            context_override: Some(context),
279        })
280        .unwrap();
281
282        assert_eq!(report.signals_detected, 2);
283        assert_eq!(report.signals_persisted.len(), 2);
284    }
285}