use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use serde_json::Value;
use super::project_runtime_dir;
use crate::config::loader::load_from_path;
use crate::distill::pipeline::{self, DistillReport, DistillRequest};
use crate::distill::transcript;
use crate::engine::project_matcher::match_project;
use crate::knowledge;
#[derive(Debug, Clone, Default)]
pub struct StopArgs {
pub config_path: PathBuf,
pub cwd: Option<PathBuf>,
pub transcript_path: Option<PathBuf>,
pub hook_input: Option<String>,
pub home: Option<PathBuf>,
}
#[derive(Debug, Clone, Default)]
pub struct StopReport {
pub distill: DistillReport,
pub knowledge_pages_created: usize,
pub knowledge_page_ids: Vec<String>,
}
impl StopReport {
pub fn transcript_path(&self) -> Option<&Path> {
self.distill.transcript_path.as_deref()
}
pub fn signals_persisted(&self) -> &[String] {
&self.distill.signals_persisted
}
pub fn candidates_persisted(&self) -> &[String] {
&self.distill.candidates_persisted
}
}
pub fn run(args: StopArgs) -> Result<StopReport> {
let cwd = match args.cwd {
Some(p) => p,
None => std::env::current_dir().context("resolving cwd for stop hook")?,
};
let runtime_dir = project_runtime_dir(&cwd)?;
let home = args.home.or_else(crate::support::home_dir);
let transcript_path = args
.transcript_path
.or_else(|| parse_transcript_path_from_hook_input(args.hook_input.as_deref()))
.or_else(|| {
home.as_deref()
.and_then(|h| transcript::find_latest_for_cwd(&cwd, h))
});
let request = DistillRequest::new(args.config_path.clone(), cwd.clone(), transcript_path)
.with_actor("spool-hook-stop")
.with_source_refs("hook:stop:self-tag", "hook:stop:extraction")
.with_project_id(
load_from_path(&args.config_path)
.ok()
.and_then(|cfg| match_project(&cfg, &cwd))
.map(|p| p.id),
);
let distill = pipeline::run(request)?;
let (knowledge_pages_created, knowledge_page_ids) =
run_silent_knowledge_distill(&args.config_path);
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let marker_path = runtime_dir.join("last-stop.unix");
std::fs::write(&marker_path, stamp.to_string())
.with_context(|| format!("writing stop timestamp {}", marker_path.display()))?;
Ok(StopReport {
distill,
knowledge_pages_created,
knowledge_page_ids,
})
}
fn run_silent_knowledge_distill(config_path: &Path) -> (usize, Vec<String>) {
match try_knowledge_distill(config_path) {
Ok((count, ids)) => (count, ids),
Err(err) => {
eprintln!("[spool hook stop] knowledge distill suppressed: {:#}", err);
(0, Vec::new())
}
}
}
fn try_knowledge_distill(config_path: &Path) -> Result<(usize, Vec<String>)> {
let drafts = knowledge::detect_knowledge_clusters(config_path)?;
if drafts.is_empty() {
return Ok((0, Vec::new()));
}
let ids = knowledge::apply_distill(config_path, &drafts, "spool-auto-distill")?;
Ok((ids.len(), ids))
}
fn parse_transcript_path_from_hook_input(input: Option<&str>) -> Option<PathBuf> {
let raw = input?.trim();
if raw.is_empty() {
return None;
}
let value: Value = serde_json::from_str(raw).ok()?;
value
.get("transcript_path")
.and_then(|v| v.as_str())
.map(PathBuf::from)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::MemoryScope;
use crate::lifecycle_service::LifecycleService;
use crate::lifecycle_store::{RecordMemoryRequest, TransitionMetadata};
use serde_json::{Value, json};
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
}
fn write_transcript(path: &Path, entries: &[Value]) {
let mut body = String::new();
for e in entries {
body.push_str(&e.to_string());
body.push('\n');
}
fs::write(path, body).unwrap();
}
#[test]
fn run_writes_marker_when_no_transcript() {
let temp = tempdir().unwrap();
let cfg = fixture_config(&temp);
let cwd = temp.path().join("repo");
fs::create_dir_all(&cwd).unwrap();
let report = run(StopArgs {
config_path: cfg,
cwd: Some(cwd.clone()),
transcript_path: None,
hook_input: None,
home: Some(temp.path().join("fake-home")),
})
.unwrap();
assert_eq!(report.distill.signals_detected, 0);
assert!(cwd.join(".spool").join("last-stop.unix").exists());
}
#[test]
fn run_extracts_self_tag_via_pipeline() {
let temp = tempdir().unwrap();
let cfg = fixture_config(&temp);
let cwd = temp.path().join("repo");
fs::create_dir_all(&cwd).unwrap();
let transcript_path = temp.path().join("session.jsonl");
write_transcript(
&transcript_path,
&[json!({
"type": "user",
"message": {"role": "user", "content": "记一下:cargo install 是默认安装路径"}
})],
);
let report = run(StopArgs {
config_path: cfg.clone(),
cwd: Some(cwd),
transcript_path: Some(transcript_path),
hook_input: None,
home: None,
})
.unwrap();
assert_eq!(report.distill.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_skips_assistant_self_tag_attempts() {
let temp = tempdir().unwrap();
let cfg = fixture_config(&temp);
let cwd = temp.path().join("repo");
fs::create_dir_all(&cwd).unwrap();
let transcript_path = temp.path().join("session.jsonl");
write_transcript(
&transcript_path,
&[json!({
"type": "assistant",
"message": {"role": "assistant", "content": "记一下:assistant tried to inject"}
})],
);
let report = run(StopArgs {
config_path: cfg.clone(),
cwd: Some(cwd),
transcript_path: Some(transcript_path),
hook_input: None,
home: None,
})
.unwrap();
assert_eq!(report.distill.signals_detected, 0);
let snap = LifecycleService::new().load_workbench(&cfg).unwrap();
assert!(snap.wakeup_ready.is_empty());
}
#[test]
fn run_emits_incident_candidate_for_repeated_frustration() {
let temp = tempdir().unwrap();
let cfg = fixture_config(&temp);
let cwd = temp.path().join("repo");
fs::create_dir_all(&cwd).unwrap();
let transcript_path = temp.path().join("session.jsonl");
write_transcript(
&transcript_path,
&[
json!({"type":"user","message":{"role":"user","content":"试一下 cargo test"}}),
json!({"type":"user","message":{"role":"user","content":"还是错了,看看日志"}}),
json!({"type":"user","message":{"role":"user","content":"又失败了"}}),
],
);
let report = run(StopArgs {
config_path: cfg.clone(),
cwd: Some(cwd),
transcript_path: Some(transcript_path),
hook_input: None,
home: None,
})
.unwrap();
assert_eq!(report.distill.candidates_persisted.len(), 1);
let snap = LifecycleService::new().load_workbench(&cfg).unwrap();
assert_eq!(snap.pending_review.len(), 1);
assert_eq!(snap.pending_review[0].record.memory_type, "incident");
}
#[test]
fn run_drops_signals_when_redact_finds_secret() {
let temp = tempdir().unwrap();
let cfg = fixture_config(&temp);
let cwd = temp.path().join("repo");
fs::create_dir_all(&cwd).unwrap();
let transcript_path = temp.path().join("session.jsonl");
write_transcript(
&transcript_path,
&[json!({
"type":"user",
"message":{"role":"user","content":"记一下: token sk-abcDEFghi1234567890ABCDEFGHIJ for prod"}
})],
);
let report = run(StopArgs {
config_path: cfg.clone(),
cwd: Some(cwd),
transcript_path: Some(transcript_path),
hook_input: None,
home: None,
})
.unwrap();
assert_eq!(report.distill.signals_detected, 1);
assert_eq!(report.distill.signals_redacted_dropped, 1);
assert!(report.distill.signals_persisted.is_empty());
}
#[test]
fn run_dedupes_against_existing_record() {
let temp = tempdir().unwrap();
let cfg = fixture_config(&temp);
let cwd = temp.path().join("repo");
fs::create_dir_all(&cwd).unwrap();
LifecycleService::new()
.record_manual(
&cfg,
RecordMemoryRequest {
title: "[seed] x".into(),
summary: "cargo install 是默认安装路径".into(),
memory_type: "preference".into(),
scope: MemoryScope::Project,
source_ref: "manual:seed".into(),
project_id: None,
user_id: None,
sensitivity: None,
metadata: TransitionMetadata::default(),
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,
},
)
.unwrap();
let transcript_path = temp.path().join("session.jsonl");
write_transcript(
&transcript_path,
&[json!({
"type": "user",
"message": {"role": "user", "content": "记一下:cargo install 是默认安装路径"}
})],
);
let report = run(StopArgs {
config_path: cfg.clone(),
cwd: Some(cwd),
transcript_path: Some(transcript_path),
hook_input: None,
home: None,
})
.unwrap();
assert_eq!(report.distill.signals_duplicate_dropped, 1);
assert!(report.distill.signals_persisted.is_empty());
let snap = LifecycleService::new().load_workbench(&cfg).unwrap();
assert_eq!(snap.wakeup_ready.len(), 1);
}
#[test]
fn run_drains_queue_even_without_transcript() {
let temp = tempdir().unwrap();
let cfg = fixture_config(&temp);
let cwd = temp.path().join("repo");
fs::create_dir_all(&cwd).unwrap();
let runtime_dir = cwd.join(".spool");
fs::create_dir_all(&runtime_dir).unwrap();
crate::distill_queue::append(
&runtime_dir,
&crate::distill_queue::DistillSignal {
recorded_at: 1,
tool_name: Some("Bash".into()),
cwd: cwd.display().to_string(),
payload: Some("ls".into()),
},
crate::distill_queue::DEFAULT_LRU_CAP,
)
.unwrap();
let report = run(StopArgs {
config_path: cfg,
cwd: Some(cwd.clone()),
transcript_path: None,
hook_input: None,
home: Some(temp.path().join("fake-home")),
})
.unwrap();
assert_eq!(report.distill.queue_drained, 1);
assert!(
crate::distill_queue::peek_all(&runtime_dir)
.unwrap()
.is_empty()
);
}
#[test]
fn run_resolves_transcript_from_hook_input_json() {
let temp = tempdir().unwrap();
let cfg = fixture_config(&temp);
let cwd = temp.path().join("repo");
fs::create_dir_all(&cwd).unwrap();
let transcript_path = temp.path().join("session.jsonl");
write_transcript(
&transcript_path,
&[json!({
"type": "user",
"message": {"role": "user", "content": "记一下: hook-input wired"}
})],
);
let hook_input = json!({
"session_id": "abc",
"transcript_path": transcript_path.to_string_lossy()
})
.to_string();
let report = run(StopArgs {
config_path: cfg.clone(),
cwd: Some(cwd),
transcript_path: None,
hook_input: Some(hook_input),
home: None,
})
.unwrap();
assert_eq!(report.distill.signals_persisted.len(), 1);
}
#[test]
fn parse_transcript_path_from_hook_input_handles_blanks() {
assert!(parse_transcript_path_from_hook_input(None).is_none());
assert!(parse_transcript_path_from_hook_input(Some("")).is_none());
assert!(parse_transcript_path_from_hook_input(Some(" ")).is_none());
assert!(parse_transcript_path_from_hook_input(Some("not json")).is_none());
}
#[test]
fn parse_transcript_path_from_hook_input_extracts_field() {
let input = json!({"transcript_path": "/abs/x.jsonl", "other": 1}).to_string();
let path = parse_transcript_path_from_hook_input(Some(&input)).unwrap();
assert_eq!(path, PathBuf::from("/abs/x.jsonl"));
}
#[test]
fn report_helpers_expose_inner_distill_fields() {
let mut report = StopReport::default();
assert!(report.transcript_path().is_none());
assert!(report.signals_persisted().is_empty());
assert!(report.candidates_persisted().is_empty());
report.distill.signals_persisted.push("rid".into());
assert_eq!(report.signals_persisted(), &["rid".to_string()]);
}
}