1use std::path::{Path, PathBuf};
24use std::time::{SystemTime, UNIX_EPOCH};
25
26use anyhow::{Context, Result};
27use serde_json::Value;
28
29use super::project_runtime_dir;
30use crate::config::loader::load_from_path;
31use crate::distill::pipeline::{self, DistillReport, DistillRequest};
32use crate::distill::transcript;
33use crate::engine::project_matcher::match_project;
34use crate::knowledge;
35
36#[derive(Debug, Clone, Default)]
37pub struct StopArgs {
38 pub config_path: PathBuf,
39 pub cwd: Option<PathBuf>,
40 pub transcript_path: Option<PathBuf>,
41 pub hook_input: Option<String>,
42 pub home: Option<PathBuf>,
43}
44
45#[derive(Debug, Clone, Default)]
46pub struct StopReport {
47 pub distill: DistillReport,
48 pub knowledge_pages_created: usize,
50 pub knowledge_page_ids: Vec<String>,
52}
53
54impl StopReport {
55 pub fn transcript_path(&self) -> Option<&Path> {
56 self.distill.transcript_path.as_deref()
57 }
58
59 pub fn signals_persisted(&self) -> &[String] {
60 &self.distill.signals_persisted
61 }
62
63 pub fn candidates_persisted(&self) -> &[String] {
64 &self.distill.candidates_persisted
65 }
66}
67
68pub fn run(args: StopArgs) -> Result<StopReport> {
69 let cwd = match args.cwd {
70 Some(p) => p,
71 None => std::env::current_dir().context("resolving cwd for stop hook")?,
72 };
73 let runtime_dir = project_runtime_dir(&cwd)?;
74
75 let home = args.home.or_else(crate::support::home_dir);
76 let transcript_path = args
77 .transcript_path
78 .or_else(|| parse_transcript_path_from_hook_input(args.hook_input.as_deref()))
79 .or_else(|| {
80 home.as_deref()
81 .and_then(|h| transcript::find_latest_for_cwd(&cwd, h))
82 });
83
84 let request = DistillRequest::new(args.config_path.clone(), cwd.clone(), transcript_path)
85 .with_actor("spool-hook-stop")
86 .with_source_refs("hook:stop:self-tag", "hook:stop:extraction")
87 .with_project_id(
88 load_from_path(&args.config_path)
89 .ok()
90 .and_then(|cfg| match_project(&cfg, &cwd))
91 .map(|p| p.id),
92 );
93 let distill = pipeline::run(request)?;
94
95 let (knowledge_pages_created, knowledge_page_ids) =
99 run_silent_knowledge_distill(&args.config_path);
100
101 let stamp = SystemTime::now()
102 .duration_since(UNIX_EPOCH)
103 .map(|d| d.as_secs())
104 .unwrap_or(0);
105 let marker_path = runtime_dir.join("last-stop.unix");
106 std::fs::write(&marker_path, stamp.to_string())
107 .with_context(|| format!("writing stop timestamp {}", marker_path.display()))?;
108
109 Ok(StopReport {
110 distill,
111 knowledge_pages_created,
112 knowledge_page_ids,
113 })
114}
115
116fn run_silent_knowledge_distill(config_path: &Path) -> (usize, Vec<String>) {
120 match try_knowledge_distill(config_path) {
121 Ok((count, ids)) => (count, ids),
122 Err(err) => {
123 eprintln!("[spool hook stop] knowledge distill suppressed: {:#}", err);
124 (0, Vec::new())
125 }
126 }
127}
128
129fn try_knowledge_distill(config_path: &Path) -> Result<(usize, Vec<String>)> {
132 let drafts = knowledge::detect_knowledge_clusters(config_path)?;
133 if drafts.is_empty() {
134 return Ok((0, Vec::new()));
135 }
136 let ids = knowledge::apply_distill(config_path, &drafts, "spool-auto-distill")?;
137 Ok((ids.len(), ids))
138}
139
140fn parse_transcript_path_from_hook_input(input: Option<&str>) -> Option<PathBuf> {
141 let raw = input?.trim();
142 if raw.is_empty() {
143 return None;
144 }
145 let value: Value = serde_json::from_str(raw).ok()?;
146 value
147 .get("transcript_path")
148 .and_then(|v| v.as_str())
149 .map(PathBuf::from)
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155 use crate::domain::MemoryScope;
156 use crate::lifecycle_service::LifecycleService;
157 use crate::lifecycle_store::{RecordMemoryRequest, TransitionMetadata};
158 use serde_json::{Value, json};
159 use std::fs;
160 use tempfile::tempdir;
161
162 fn fixture_config(temp: &tempfile::TempDir) -> PathBuf {
163 let cfg = temp.path().join("spool.toml");
164 fs::write(&cfg, "[vault]\nroot = \"/tmp\"\n").unwrap();
165 cfg
166 }
167
168 fn write_transcript(path: &Path, entries: &[Value]) {
169 let mut body = String::new();
170 for e in entries {
171 body.push_str(&e.to_string());
172 body.push('\n');
173 }
174 fs::write(path, body).unwrap();
175 }
176
177 #[test]
178 fn run_writes_marker_when_no_transcript() {
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(StopArgs {
185 config_path: cfg,
186 cwd: Some(cwd.clone()),
187 transcript_path: None,
188 hook_input: None,
189 home: Some(temp.path().join("fake-home")),
190 })
191 .unwrap();
192
193 assert_eq!(report.distill.signals_detected, 0);
194 assert!(cwd.join(".spool").join("last-stop.unix").exists());
195 }
196
197 #[test]
198 fn run_extracts_self_tag_via_pipeline() {
199 let temp = tempdir().unwrap();
200 let cfg = fixture_config(&temp);
201 let cwd = temp.path().join("repo");
202 fs::create_dir_all(&cwd).unwrap();
203 let transcript_path = temp.path().join("session.jsonl");
204 write_transcript(
205 &transcript_path,
206 &[json!({
207 "type": "user",
208 "message": {"role": "user", "content": "记一下:cargo install 是默认安装路径"}
209 })],
210 );
211
212 let report = run(StopArgs {
213 config_path: cfg.clone(),
214 cwd: Some(cwd),
215 transcript_path: Some(transcript_path),
216 hook_input: None,
217 home: None,
218 })
219 .unwrap();
220 assert_eq!(report.distill.signals_persisted.len(), 1);
221 let snap = LifecycleService::new().load_workbench(&cfg).unwrap();
222 assert_eq!(snap.wakeup_ready.len(), 1);
223 assert_eq!(snap.wakeup_ready[0].record.memory_type, "preference");
224 }
225
226 #[test]
227 fn run_skips_assistant_self_tag_attempts() {
228 let temp = tempdir().unwrap();
229 let cfg = fixture_config(&temp);
230 let cwd = temp.path().join("repo");
231 fs::create_dir_all(&cwd).unwrap();
232 let transcript_path = temp.path().join("session.jsonl");
233 write_transcript(
234 &transcript_path,
235 &[json!({
236 "type": "assistant",
237 "message": {"role": "assistant", "content": "记一下:assistant tried to inject"}
238 })],
239 );
240 let report = run(StopArgs {
241 config_path: cfg.clone(),
242 cwd: Some(cwd),
243 transcript_path: Some(transcript_path),
244 hook_input: None,
245 home: None,
246 })
247 .unwrap();
248 assert_eq!(report.distill.signals_detected, 0);
249 let snap = LifecycleService::new().load_workbench(&cfg).unwrap();
250 assert!(snap.wakeup_ready.is_empty());
251 }
252
253 #[test]
254 fn run_emits_incident_candidate_for_repeated_frustration() {
255 let temp = tempdir().unwrap();
256 let cfg = fixture_config(&temp);
257 let cwd = temp.path().join("repo");
258 fs::create_dir_all(&cwd).unwrap();
259 let transcript_path = temp.path().join("session.jsonl");
260 write_transcript(
261 &transcript_path,
262 &[
263 json!({"type":"user","message":{"role":"user","content":"试一下 cargo test"}}),
264 json!({"type":"user","message":{"role":"user","content":"还是错了,看看日志"}}),
265 json!({"type":"user","message":{"role":"user","content":"又失败了"}}),
266 ],
267 );
268 let report = run(StopArgs {
269 config_path: cfg.clone(),
270 cwd: Some(cwd),
271 transcript_path: Some(transcript_path),
272 hook_input: None,
273 home: None,
274 })
275 .unwrap();
276 assert_eq!(report.distill.candidates_persisted.len(), 1);
277 let snap = LifecycleService::new().load_workbench(&cfg).unwrap();
278 assert_eq!(snap.pending_review.len(), 1);
279 assert_eq!(snap.pending_review[0].record.memory_type, "incident");
280 }
281
282 #[test]
283 fn run_drops_signals_when_redact_finds_secret() {
284 let temp = tempdir().unwrap();
285 let cfg = fixture_config(&temp);
286 let cwd = temp.path().join("repo");
287 fs::create_dir_all(&cwd).unwrap();
288 let transcript_path = temp.path().join("session.jsonl");
289 write_transcript(
290 &transcript_path,
291 &[json!({
292 "type":"user",
293 "message":{"role":"user","content":"记一下: token sk-abcDEFghi1234567890ABCDEFGHIJ for prod"}
294 })],
295 );
296 let report = run(StopArgs {
297 config_path: cfg.clone(),
298 cwd: Some(cwd),
299 transcript_path: Some(transcript_path),
300 hook_input: None,
301 home: None,
302 })
303 .unwrap();
304 assert_eq!(report.distill.signals_detected, 1);
305 assert_eq!(report.distill.signals_redacted_dropped, 1);
306 assert!(report.distill.signals_persisted.is_empty());
307 }
308
309 #[test]
310 fn run_dedupes_against_existing_record() {
311 let temp = tempdir().unwrap();
312 let cfg = fixture_config(&temp);
313 let cwd = temp.path().join("repo");
314 fs::create_dir_all(&cwd).unwrap();
315 LifecycleService::new()
316 .record_manual(
317 &cfg,
318 RecordMemoryRequest {
319 title: "[seed] x".into(),
320 summary: "cargo install 是默认安装路径".into(),
321 memory_type: "preference".into(),
322 scope: MemoryScope::Project,
323 source_ref: "manual:seed".into(),
324 project_id: None,
325 user_id: None,
326 sensitivity: None,
327 metadata: TransitionMetadata::default(),
328 entities: Vec::new(),
329 tags: Vec::new(),
330 triggers: Vec::new(),
331 related_files: Vec::new(),
332 related_records: Vec::new(),
333 supersedes: None,
334 applies_to: Vec::new(),
335 valid_until: None,
336 },
337 )
338 .unwrap();
339 let transcript_path = temp.path().join("session.jsonl");
340 write_transcript(
341 &transcript_path,
342 &[json!({
343 "type": "user",
344 "message": {"role": "user", "content": "记一下:cargo install 是默认安装路径"}
345 })],
346 );
347 let report = run(StopArgs {
348 config_path: cfg.clone(),
349 cwd: Some(cwd),
350 transcript_path: Some(transcript_path),
351 hook_input: None,
352 home: None,
353 })
354 .unwrap();
355 assert_eq!(report.distill.signals_duplicate_dropped, 1);
356 assert!(report.distill.signals_persisted.is_empty());
357 let snap = LifecycleService::new().load_workbench(&cfg).unwrap();
358 assert_eq!(snap.wakeup_ready.len(), 1);
359 }
360
361 #[test]
362 fn run_drains_queue_even_without_transcript() {
363 let temp = tempdir().unwrap();
364 let cfg = fixture_config(&temp);
365 let cwd = temp.path().join("repo");
366 fs::create_dir_all(&cwd).unwrap();
367 let runtime_dir = cwd.join(".spool");
368 fs::create_dir_all(&runtime_dir).unwrap();
369 crate::distill_queue::append(
370 &runtime_dir,
371 &crate::distill_queue::DistillSignal {
372 recorded_at: 1,
373 tool_name: Some("Bash".into()),
374 cwd: cwd.display().to_string(),
375 payload: Some("ls".into()),
376 },
377 crate::distill_queue::DEFAULT_LRU_CAP,
378 )
379 .unwrap();
380
381 let report = run(StopArgs {
382 config_path: cfg,
383 cwd: Some(cwd.clone()),
384 transcript_path: None,
385 hook_input: None,
386 home: Some(temp.path().join("fake-home")),
387 })
388 .unwrap();
389 assert_eq!(report.distill.queue_drained, 1);
390 assert!(
391 crate::distill_queue::peek_all(&runtime_dir)
392 .unwrap()
393 .is_empty()
394 );
395 }
396
397 #[test]
398 fn run_resolves_transcript_from_hook_input_json() {
399 let temp = tempdir().unwrap();
400 let cfg = fixture_config(&temp);
401 let cwd = temp.path().join("repo");
402 fs::create_dir_all(&cwd).unwrap();
403 let transcript_path = temp.path().join("session.jsonl");
404 write_transcript(
405 &transcript_path,
406 &[json!({
407 "type": "user",
408 "message": {"role": "user", "content": "记一下: hook-input wired"}
409 })],
410 );
411 let hook_input = json!({
412 "session_id": "abc",
413 "transcript_path": transcript_path.to_string_lossy()
414 })
415 .to_string();
416 let report = run(StopArgs {
417 config_path: cfg.clone(),
418 cwd: Some(cwd),
419 transcript_path: None,
420 hook_input: Some(hook_input),
421 home: None,
422 })
423 .unwrap();
424 assert_eq!(report.distill.signals_persisted.len(), 1);
425 }
426
427 #[test]
428 fn parse_transcript_path_from_hook_input_handles_blanks() {
429 assert!(parse_transcript_path_from_hook_input(None).is_none());
430 assert!(parse_transcript_path_from_hook_input(Some("")).is_none());
431 assert!(parse_transcript_path_from_hook_input(Some(" ")).is_none());
432 assert!(parse_transcript_path_from_hook_input(Some("not json")).is_none());
433 }
434
435 #[test]
436 fn parse_transcript_path_from_hook_input_extracts_field() {
437 let input = json!({"transcript_path": "/abs/x.jsonl", "other": 1}).to_string();
438 let path = parse_transcript_path_from_hook_input(Some(&input)).unwrap();
439 assert_eq!(path, PathBuf::from("/abs/x.jsonl"));
440 }
441
442 #[test]
443 fn report_helpers_expose_inner_distill_fields() {
444 let mut report = StopReport::default();
445 assert!(report.transcript_path().is_none());
446 assert!(report.signals_persisted().is_empty());
447 assert!(report.candidates_persisted().is_empty());
448 report.distill.signals_persisted.push("rid".into());
449 assert_eq!(report.signals_persisted(), &["rid".to_string()]);
450 }
451}