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>,
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();
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);
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);
}
}