1use 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 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 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 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}