cli/recall.rs
1//! `mushroomdb recall <db>`: the body of the UserPromptSubmit hook.
2//!
3//! The hook has two things to say, and says whichever one the moment calls
4//! for.
5//!
6//! When the prompt arrives from a checkout with a **dirty working tree**, the
7//! change already in progress is the more useful subject: the nudge names what
8//! those files reach that is *not* already open — the files that usually change
9//! with them, the files that import them, who owns them, and whether a learned
10//! concept has just gone out of date. That is what an assistant would otherwise
11//! only find out by reading half the repository.
12//!
13//! Otherwise the prompt's own words are all there is to go on, and the topic
14//! digest answers: the nodes closest to it and their strongest edges. The
15//! digest itself is [`core_api::repograph::recall_digest`].
16//!
17//! Everything specific to being a hook stays here: reading the payload, opening
18//! the store read-only, keeping inside one byte budget, and staying silent on
19//! any error. A recall hook must never block or slow the user's prompt.
20use core_api::repograph::{
21 impact, path_excluded, recall_digest, sanitize, stale_concepts, FileImpact, ImpactOptions,
22 ImpactReport, DEFAULT_EXCLUDES, HINT, MAX_OUTPUT_BYTES, UNTRUSTED_FRAMING,
23};
24use core_api::{GraphDb, OpenOptions, Value};
25use std::collections::{BTreeMap, BTreeSet};
26use std::ffi::OsStr;
27use std::fmt::Write as _;
28use std::path::{Path, PathBuf};
29use std::process::Command;
30
31/// Lines the nudge prints under the framing line, its closing hint included.
32/// Past this it stops being a nudge and becomes something to read.
33const MAX_NUDGE_LINES: usize = 8;
34/// Files named on the `usually changes with:` line.
35const MAX_NUDGE_PARTNERS: usize = 3;
36/// Files named on the `imported by:` line.
37const MAX_NUDGE_IMPORTERS: usize = 3;
38/// Changed files the nudge asks the graph about. A rebase or a generated
39/// commit can dirty thousands of paths, and this hook has five seconds; the
40/// count in the first line still reports the whole diff.
41const MAX_NUDGE_FILES: usize = 50;
42
43/// Extract the prompt text from a hook payload. Accepts `prompt`,
44/// `user_prompt`, and `user_input` (the docs disagree on the field name).
45fn prompt_from_payload(raw: &str) -> Option<String> {
46 let v: serde_json::Value = serde_json::from_str(raw).ok()?;
47 for k in ["prompt", "user_prompt", "user_input"] {
48 if let Some(s) = v.get(k).and_then(|x| x.as_str()) {
49 let s = s.trim();
50 if !s.is_empty() {
51 return Some(s.to_string());
52 }
53 }
54 }
55 None
56}
57
58/// The directory the payload says the prompt was sent from.
59fn cwd_from_payload(raw: &str) -> Option<PathBuf> {
60 let v: serde_json::Value = serde_json::from_str(raw).ok()?;
61 let s = v.get("cwd").and_then(|x| x.as_str())?.trim();
62 (!s.is_empty()).then(|| PathBuf::from(s))
63}
64
65/// Rewrite free-form prompt text as a full-text OR query.
66///
67/// The rewrite itself lives in `core_api::repograph::or_query`, because the
68/// `recall` MCP tool applies it to its `topic` argument and the two must not
69/// disagree about what a prompt means. The tests below stay here: this is the
70/// caller whose behaviour they describe.
71fn fulltext_or_query(prompt: &str) -> Option<String> {
72 core_api::repograph::or_query(prompt)
73}
74
75pub fn run_recall(db_dir: &Path, hook_stdin: &str) -> String {
76 let Some(prompt) = prompt_from_payload(hook_stdin)
77 .as_deref()
78 .and_then(fulltext_or_query)
79 else {
80 return String::new();
81 };
82 // Guard the open: `RealFs::new` runs `create_dir_all`, so without this a
83 // hook pointed at a typo'd path would keep creating empty directories.
84 if !db_dir.exists() {
85 return String::new();
86 }
87 // Read-only, with both write flags off as well. `auto_migrate` rewrites an
88 // old-format snapshot and deletes a stale `.bak`; `repair_wal` writes the
89 // valid prefix back over a torn tail. A digest that fires on every prompt,
90 // under a 5 s kill, must never write to the user's store: a `serve`
91 // mid-append would lose a frame it believes durable. `read_only` also keeps
92 // the hook off the cross-process write lock entirely, so it can never make
93 // a writer wait and never fails because one is running. The valid prefix is
94 // still replayed in memory.
95 let Ok(db) = GraphDb::open_with_options(
96 db_dir,
97 OpenOptions {
98 auto_migrate: false,
99 repair_wal: false,
100 read_only: true,
101 },
102 ) else {
103 return String::new();
104 };
105 // The change in progress outranks the prompt's own words: it is both more
106 // specific and about to be wrong if nobody says otherwise. With no change
107 // to report — a clean tree, a prompt sent from outside a checkout, a diff
108 // the graph knows nothing about — the topic digest answers as it always
109 // did.
110 if let Some(nudge) = diff_nudge(
111 &db,
112 hook_stdin,
113 std::env::var_os("CLAUDE_PROJECT_DIR").as_deref(),
114 ) {
115 return nudge;
116 }
117 recall_digest(
118 &db,
119 &prompt,
120 &db_dir.display().to_string(),
121 MAX_OUTPUT_BYTES,
122 )
123}
124
125// ── the diff-aware nudge ────────────────────────────────────────────────────
126
127/// The nudge for whatever is dirty in the checkout this prompt came from, or
128/// `None` when there is nothing to nudge about.
129fn diff_nudge(
130 db: &crate::structure::Db,
131 hook_stdin: &str,
132 project_dir: Option<&OsStr>,
133) -> Option<String> {
134 let root = nudge_root(db, hook_stdin, project_dir)?;
135 let changed = changed_paths(&root);
136 if changed.is_empty() {
137 return None;
138 }
139 // The whole change decides the `modified` flag: a partner that is itself
140 // being edited is a different fact from one that is not, and only this set
141 // tells them apart. The graph is asked about a bounded prefix of it.
142 let modified: BTreeSet<String> = changed.iter().cloned().collect();
143 let asked: Vec<String> = changed.iter().take(MAX_NUDGE_FILES).cloned().collect();
144 let report = impact(db, &asked, &modified, &ImpactOptions::default());
145 render_nudge(db, &report, &modified, &changed)
146}
147
148/// The checkout the nudge reports on.
149///
150/// The payload's `cwd` is where the host says the prompt was sent from, and it
151/// decides outright: a prompt sent from outside a checkout is not about a diff,
152/// even when the store knows a repository that has one. `$CLAUDE_PROJECT_DIR`
153/// stands in for a host that sends no `cwd`, and the store's own `GitSync`
154/// marker for one that sets neither.
155fn nudge_root(
156 db: &crate::structure::Db,
157 hook_stdin: &str,
158 project_dir: Option<&OsStr>,
159) -> Option<PathBuf> {
160 if let Some(cwd) = cwd_from_payload(hook_stdin) {
161 return repo_root(&cwd);
162 }
163 if let Some(dir) = project_dir.filter(|d| !d.is_empty()) {
164 if let Some(root) = repo_root(Path::new(dir)) {
165 return Some(root);
166 }
167 }
168 let repo = match db
169 .node_ref(crate::ingest_git::SYNC_KEY)
170 .and_then(|n| n.prop("repo"))
171 {
172 Some(Value::Str(s)) => s,
173 _ => return None,
174 };
175 repo_root(Path::new(&repo))
176}
177
178/// The root of the checkout `dir` is in, or `None` when it is not in one.
179fn repo_root(dir: &Path) -> Option<PathBuf> {
180 if !dir.is_dir() {
181 return None;
182 }
183 let output = Command::new("git")
184 .arg("-C")
185 .arg(dir)
186 .args(["rev-parse", "--show-toplevel"])
187 .output()
188 .ok()?;
189 if !output.status.success() {
190 return None;
191 }
192 let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
193 (!root.is_empty()).then(|| PathBuf::from(root))
194}
195
196/// Paths under `root` that differ from `HEAD` or are not tracked at all:
197/// root-relative, sorted, deduplicated, and filtered by the same
198/// [`DEFAULT_EXCLUDES`] the ingest applied — a path the ingest skipped has no
199/// `File` node to say anything about.
200///
201/// The same listing the `impact` MCP tool builds its default file set from,
202/// implemented again here because this crate cannot depend on the server crate.
203/// Empty on any failure: a hook has nothing to say about a repository git
204/// cannot read.
205///
206/// `-z` rather than the default listing: git escapes and quotes a path holding
207/// a tab, a newline or a non-ASCII byte, and a quoted path matches no key.
208/// `root` rather than the directory the prompt came from: `ls-files` lists
209/// relative to the working directory while `diff` lists relative to the root,
210/// so running both anywhere else would mix two conventions in one list.
211fn changed_paths(root: &Path) -> Vec<String> {
212 const LISTS: [&[&str]; 2] = [
213 &["diff", "--name-only", "-z", "HEAD"],
214 &["ls-files", "--others", "--exclude-standard", "-z"],
215 ];
216 let excludes: Vec<String> = DEFAULT_EXCLUDES.iter().map(|p| (*p).to_string()).collect();
217 let mut out: BTreeSet<String> = BTreeSet::new();
218 for args in LISTS {
219 let Ok(output) = Command::new("git").arg("-C").arg(root).args(args).output() else {
220 return Vec::new();
221 };
222 // `diff HEAD` fails in a repository with no commits yet. Nothing is
223 // dirty relative to a head that does not exist, so that is not an
224 // error — the other listing still answers.
225 if !output.status.success() {
226 continue;
227 }
228 for path in String::from_utf8_lossy(&output.stdout).split('\0') {
229 if !path.is_empty() && !path_excluded(path, &excludes) {
230 out.insert(path.to_string());
231 }
232 }
233 }
234 out.into_iter().collect()
235}
236
237/// The nudge itself, or `None` when the graph knows none of the changed files
238/// — which is what a store built from a different repository, or one that has
239/// never been synced, looks like.
240///
241/// Every line is a fact about the change as a whole rather than about one file
242/// in it: the diff is what the assistant is about to work on, and which of its
243/// files a partner belongs to is a detail the `impact` tool answers on demand.
244/// Partners and importers already in the diff are dropped rather than marked,
245/// because the point of the nudge is what is *not* open yet.
246fn render_nudge(
247 db: &crate::structure::Db,
248 report: &ImpactReport,
249 modified: &BTreeSet<String>,
250 changed: &[String],
251) -> Option<String> {
252 if report.files.is_empty() {
253 return None;
254 }
255 let first = changed.first()?;
256 let mut lines: Vec<String> = Vec::new();
257 let more = changed.len() - 1;
258 lines.push(match more {
259 0 => format!("mushroomdb: you are editing {}", sanitize(first)),
260 n => format!(
261 "mushroomdb: you are editing {} (+{n} more)",
262 sanitize(first)
263 ),
264 });
265
266 // Best co-change score per file across the whole diff, strongest first.
267 let mut partners: BTreeMap<String, f64> = BTreeMap::new();
268 let mut importers: BTreeSet<String> = BTreeSet::new();
269 for f in &report.files {
270 for p in f.partners.iter().filter(|p| !p.modified) {
271 let slot = partners.entry(p.path.clone()).or_insert(p.score);
272 if p.score > *slot {
273 *slot = p.score;
274 }
275 }
276 for p in f.importers.iter().filter(|p| !p.modified) {
277 importers.insert(p.path.clone());
278 }
279 }
280 let mut ranked: Vec<(String, f64)> = partners.into_iter().collect();
281 // Score descending, then key ascending: `BTreeMap` gave us the key order,
282 // and a stable sort keeps it inside a tie.
283 ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
284 if !ranked.is_empty() {
285 let items: Vec<String> = ranked
286 .iter()
287 .take(MAX_NUDGE_PARTNERS)
288 .map(|(path, score)| format!("{path} ({score:.2}, not modified)"))
289 .collect();
290 lines.push(format!(" usually changes with: {}", items.join(", ")));
291 }
292 if !importers.is_empty() {
293 let items: Vec<String> = importers
294 .iter()
295 .take(MAX_NUDGE_IMPORTERS)
296 .map(|path| format!("{path} (not modified)"))
297 .collect();
298 lines.push(format!(" imported by: {}", items.join(", ")));
299 }
300 if let Some(owner) = owner_of(&report.files) {
301 lines.push(format!(" owner: {owner}"));
302 }
303 let stale = stale_concepts_describing(db, modified);
304 if stale > 0 {
305 lines.push(format!(
306 " {stale} concept(s) describe files you changed — say \"re-learn\" to refresh"
307 ));
308 }
309
310 // The framing line and the hint are the two the nudge cannot do without,
311 // so the body gives way to them — first to the line cap, then to the byte
312 // budget the topic digest is held to.
313 lines.truncate(MAX_NUDGE_LINES - 1);
314 loop {
315 let mut out = String::from(UNTRUSTED_FRAMING);
316 for l in &lines {
317 let _ = writeln!(out, "{l}");
318 }
319 out.push_str(HINT);
320 if out.len() <= MAX_OUTPUT_BYTES {
321 return Some(out);
322 }
323 if lines.len() <= 1 {
324 // Not even the first line fits, which takes a pathological path to
325 // manage. The topic digest is the better answer than a truncated
326 // one.
327 return None;
328 }
329 lines.pop();
330 }
331}
332
333/// Who the changed files belong to: the author owning most of them, ties
334/// broken by name so the line is the same on every run. One name, because
335/// "who do I ask about this change" has one useful answer.
336fn owner_of(files: &[FileImpact]) -> Option<String> {
337 let mut counts: BTreeMap<&String, usize> = BTreeMap::new();
338 for owner in files.iter().filter_map(|f| f.owner.as_ref()) {
339 *counts.entry(owner).or_default() += 1;
340 }
341 counts
342 .into_iter()
343 .max_by_key(|(name, count)| (*count, std::cmp::Reverse(*name)))
344 .map(|(name, _)| name.clone())
345}
346
347/// How many stale concepts were learned from a file in this diff.
348///
349/// Staleness is [`stale_concepts`]'s decision — a recorded source hash that no
350/// longer matches the `File` — and the diff narrows it to the concepts this
351/// change is responsible for. A concept that went stale for some other file is
352/// somebody else's re-learn.
353fn stale_concepts_describing(db: &crate::structure::Db, modified: &BTreeSet<String>) -> usize {
354 stale_concepts(db)
355 .iter()
356 .filter(
357 |(key, _)| match db.node_ref(key).and_then(|n| n.prop("source_files")) {
358 Some(Value::List(sources)) => sources.iter().any(|v| match v {
359 Value::Str(s) => modified.contains(s),
360 _ => false,
361 }),
362 _ => false,
363 },
364 )
365 .count()
366}
367
368#[cfg(test)]
369mod tests {
370 use super::{fulltext_or_query, prompt_from_payload};
371 use core_api::repograph::MAX_QUERY_TERMS;
372
373 #[test]
374 fn prompt_is_read_from_any_of_the_three_documented_fields() {
375 for field in ["prompt", "user_prompt", "user_input"] {
376 let payload = format!(r#"{{"{field}":" hello "}}"#);
377 assert_eq!(prompt_from_payload(&payload).as_deref(), Some("hello"));
378 }
379 assert_eq!(prompt_from_payload(r#"{"prompt":" "}"#), None);
380 assert_eq!(prompt_from_payload(r#"{"other":"hi"}"#), None);
381 assert_eq!(prompt_from_payload("not json"), None);
382 }
383
384 #[test]
385 fn prompt_becomes_an_or_query_of_lowercased_alphanumeric_terms() {
386 assert_eq!(
387 fulltext_or_query("What about Person 1 and Project 5?").as_deref(),
388 Some("what OR about OR person OR 1 OR project OR 5"),
389 );
390 }
391
392 #[test]
393 fn or_query_drops_query_keywords_repeats_and_punctuation() {
394 // `and`/`or` are grammar keywords; `-x` would negate and `x*` prefix-match,
395 // so splitting on non-alphanumerics is what keeps them inert.
396 assert_eq!(
397 fulltext_or_query("AND or foo-bar foo baz*").as_deref(),
398 Some("foo OR bar OR baz"),
399 );
400 assert_eq!(fulltext_or_query(" ?! ,, "), None);
401 }
402
403 #[test]
404 fn or_query_caps_the_number_of_terms() {
405 let prompt: String = (0..MAX_QUERY_TERMS + 10)
406 .map(|i| format!("w{i} "))
407 .collect();
408 let q = fulltext_or_query(&prompt).expect("terms");
409 assert_eq!(q.split(" OR ").count(), MAX_QUERY_TERMS);
410 }
411}