Skip to main content

spool/hook_runtime/
user_prompt.rs

1//! `spool hook user-prompt` — invoked when the user submits a prompt.
2//!
3//! ## Behavior
4//! 1. Read the user's prompt from stdin (Claude Code passes it as the
5//!    hook payload).
6//! 2. Check for recall trigger keywords ("remind me", "之前说过",
7//!    "上次决定", etc.).
8//! 3. If triggered, run a lightweight context retrieval and emit a
9//!    compact memory block to stdout so the AI sees relevant memories.
10//! 4. Update `last-prompt.unix` timestamp marker.
11//!
12//! ## Performance
13//! The retrieval path is the same as `spool get` — local vault scan,
14//! no network. Must stay under 500ms p95.
15
16use std::io::{IsTerminal, Read as _, Write};
17use std::path::{Path, PathBuf};
18use std::time::{SystemTime, UNIX_EPOCH};
19
20use anyhow::Context;
21
22use crate::domain::{OutputFormat, RouteInput, TargetTool};
23use crate::memory_gateway;
24use crate::output;
25
26use super::project_runtime_dir;
27
28#[derive(Debug, Clone)]
29pub struct UserPromptArgs {
30    pub config_path: PathBuf,
31    pub cwd: Option<PathBuf>,
32    /// Override for stdin content (used in tests).
33    pub prompt_override: Option<String>,
34}
35
36pub fn run(args: UserPromptArgs) -> anyhow::Result<()> {
37    let cwd = match args.cwd {
38        Some(p) => p,
39        None => std::env::current_dir().context("resolving cwd for user-prompt hook")?,
40    };
41    let runtime_dir = project_runtime_dir(&cwd)?;
42
43    let prompt_text = match args.prompt_override {
44        Some(text) => text,
45        None => read_stdin_prompt(),
46    };
47
48    if !prompt_text.is_empty()
49        && has_recall_trigger(&prompt_text)
50        && let Ok(block) = build_recall_block(&args.config_path, &cwd, &prompt_text)
51        && !block.is_empty()
52    {
53        let mut stdout = std::io::stdout().lock();
54        let _ = writeln!(stdout, "<!-- spool:recall -->");
55        let _ = writeln!(stdout, "{}", block.trim());
56        let _ = writeln!(stdout, "<!-- /spool:recall -->");
57    }
58
59    let stamp = SystemTime::now()
60        .duration_since(UNIX_EPOCH)
61        .map(|d| d.as_secs())
62        .unwrap_or(0);
63    let path = runtime_dir.join("last-prompt.unix");
64    std::fs::write(&path, stamp.to_string())
65        .with_context(|| format!("writing user-prompt timestamp to {}", path.display()))?;
66
67    if !args.config_path.exists() {
68        anyhow::bail!("config path does not exist: {}", args.config_path.display());
69    }
70    Ok(())
71}
72
73fn read_stdin_prompt() -> String {
74    if std::io::stdin().is_terminal() {
75        return String::new();
76    }
77    let mut buf = String::new();
78    let _ = std::io::stdin().read_to_string(&mut buf);
79    buf
80}
81
82static RECALL_TRIGGERS: &[&str] = &[
83    "remind me",
84    "as i said before",
85    "as we discussed",
86    "remember when",
87    "what did i say about",
88    "what did we decide",
89    "之前说过",
90    "上次决定",
91    "之前决定",
92    "提醒我",
93    "我说过",
94    "我们决定",
95    "之前提到",
96    "上次提到",
97];
98
99fn has_recall_trigger(prompt: &str) -> bool {
100    let lower = prompt.to_lowercase();
101    RECALL_TRIGGERS
102        .iter()
103        .any(|trigger| lower.contains(trigger))
104}
105
106fn build_recall_block(config_path: &Path, cwd: &Path, prompt: &str) -> anyhow::Result<String> {
107    let input = RouteInput {
108        task: prompt.to_string(),
109        cwd: cwd.to_path_buf(),
110        files: Vec::new(),
111        target: TargetTool::Claude,
112        format: OutputFormat::Prompt,
113    };
114    let request = memory_gateway::context_request(input);
115    let response = memory_gateway::execute(config_path, request, None)?;
116    let rendered = output::prompt::render(&response.bundle, 4000);
117    Ok(rendered)
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use std::fs;
124    use tempfile::tempdir;
125
126    #[test]
127    fn has_recall_trigger_detects_english() {
128        assert!(has_recall_trigger(
129            "Can you remind me what we said about auth?"
130        ));
131        assert!(has_recall_trigger("What did we decide about the API?"));
132        assert!(has_recall_trigger("As I said before, use cargo install"));
133    }
134
135    #[test]
136    fn has_recall_trigger_detects_chinese() {
137        assert!(has_recall_trigger("之前说过用 cargo install"));
138        assert!(has_recall_trigger("上次决定的方案是什么"));
139        assert!(has_recall_trigger("提醒我一下那个约束"));
140    }
141
142    #[test]
143    fn has_recall_trigger_returns_false_for_normal_prompts() {
144        assert!(!has_recall_trigger("fix the bug in auth.rs"));
145        assert!(!has_recall_trigger("继续推进"));
146        assert!(!has_recall_trigger("add a test for the new feature"));
147    }
148
149    #[test]
150    fn run_writes_timestamp_without_trigger() {
151        let temp = tempdir().unwrap();
152        let cfg = temp.path().join("spool.toml");
153        fs::write(&cfg, "[vault]\nroot = \"/tmp\"\n").unwrap();
154
155        run(UserPromptArgs {
156            config_path: cfg,
157            cwd: Some(temp.path().to_path_buf()),
158            prompt_override: Some("fix the bug".to_string()),
159        })
160        .unwrap();
161
162        let stamp =
163            fs::read_to_string(temp.path().join(".spool").join("last-prompt.unix")).unwrap();
164        assert!(stamp.trim().parse::<u64>().is_ok());
165    }
166
167    #[test]
168    fn run_errors_when_config_missing() {
169        let temp = tempdir().unwrap();
170        let err = run(UserPromptArgs {
171            config_path: temp.path().join("nope.toml"),
172            cwd: Some(temp.path().to_path_buf()),
173            prompt_override: Some(String::new()),
174        })
175        .unwrap_err();
176        assert!(err.to_string().contains("config path does not exist"));
177    }
178
179    #[test]
180    fn run_attempts_recall_on_trigger() {
181        let temp = tempdir().unwrap();
182        let vault = temp.path().join("vault");
183        fs::create_dir_all(vault.join("10-Projects")).unwrap();
184        fs::write(
185            vault.join("10-Projects/auth.md"),
186            "---\nmemory_type: decision\n---\n# Auth Decision\n\nUse JWT tokens.\n",
187        )
188        .unwrap();
189        let repo = temp.path().join("repo");
190        fs::create_dir_all(&repo).unwrap();
191        let cfg = temp.path().join("spool.toml");
192        fs::write(
193            &cfg,
194            format!(
195                "[vault]\nroot = \"{}\"\n\n[output]\ndefault_format = \"prompt\"\nmax_chars = 4000\nmax_notes = 3\n\n[[projects]]\nid = \"test\"\nname = \"test\"\nrepo_paths = [\"{}\"]\nnote_roots = [\"10-Projects\"]\n",
196                vault.display(),
197                repo.display()
198            ),
199        )
200        .unwrap();
201
202        // Should not panic even with a recall trigger
203        let result = run(UserPromptArgs {
204            config_path: cfg,
205            cwd: Some(repo),
206            prompt_override: Some("之前说过 auth 怎么做的".to_string()),
207        });
208        assert!(result.is_ok());
209    }
210}