spool/hook_runtime/
user_prompt.rs1use 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 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 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}