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.
246/// How strong an association is, for the nudge's one line of partners.
247///
248/// Scored partners rank above counted ones, because a similarity the co-change
249/// rule was willing to write an edge for is the stronger claim. Within each
250/// group the measure itself orders them — and the count has to be *in* the key,
251/// or two counted partners compare equal on `(false, 0.0)` and the one that
252/// happened to come first in the report wins instead of the larger count.
253fn rank_key(score: f64, shared: Option<usize>) -> (bool, usize, f64) {
254 (shared.is_none(), shared.unwrap_or(0), score)
255}
256
257fn render_nudge(
258 db: &crate::structure::Db,
259 report: &ImpactReport,
260 modified: &BTreeSet<String>,
261 changed: &[String],
262) -> Option<String> {
263 if report.files.is_empty() {
264 return None;
265 }
266 let first = changed.first()?;
267 let mut lines: Vec<String> = Vec::new();
268 let more = changed.len() - 1;
269 lines.push(match more {
270 0 => format!("mushroomdb: you are editing {}", sanitize(first)),
271 n => format!(
272 "mushroomdb: you are editing {} (+{n} more)",
273 sanitize(first)
274 ),
275 });
276
277 // Strongest association per file across the whole diff. A partner the
278 // co-change rule scored and one found by how many commits the two share are
279 // both worth the line, but they are different measures: the scored ones
280 // rank first and each is labelled with the measure it came from.
281 let mut partners: BTreeMap<String, (f64, Option<usize>)> = BTreeMap::new();
282 let mut importers: BTreeSet<String> = BTreeSet::new();
283 for f in &report.files {
284 for p in f.partners.iter().filter(|p| !p.modified) {
285 let slot = partners
286 .entry(p.path.clone())
287 .or_insert((p.score, p.shared_commits));
288 if rank_key(p.score, p.shared_commits) > rank_key(slot.0, slot.1) {
289 *slot = (p.score, p.shared_commits);
290 }
291 }
292 for p in f.importers.iter().filter(|p| !p.modified) {
293 importers.insert(p.path.clone());
294 }
295 }
296 let mut ranked: Vec<(String, (f64, Option<usize>))> = partners.into_iter().collect();
297 // Scored partners first, each measure descending within its own group, then
298 // key ascending: `BTreeMap` gave us the key order and a stable sort keeps it
299 // inside a tie.
300 ranked.sort_by(|a, b| {
301 rank_key(b.1 .0, b.1 .1)
302 .partial_cmp(&rank_key(a.1 .0, a.1 .1))
303 .unwrap_or(std::cmp::Ordering::Equal)
304 });
305 if !ranked.is_empty() {
306 let items: Vec<String> = ranked
307 .iter()
308 .take(MAX_NUDGE_PARTNERS)
309 .map(|(path, (score, shared))| match shared {
310 Some(n) => format!("{path} ({n} shared commits, not modified)"),
311 None => format!("{path} ({score:.2}, not modified)"),
312 })
313 .collect();
314 lines.push(format!(" usually changes with: {}", items.join(", ")));
315 }
316 if !importers.is_empty() {
317 let items: Vec<String> = importers
318 .iter()
319 .take(MAX_NUDGE_IMPORTERS)
320 .map(|path| format!("{path} (not modified)"))
321 .collect();
322 lines.push(format!(" imported by: {}", items.join(", ")));
323 }
324 if let Some(owner) = owner_of(&report.files) {
325 lines.push(format!(" owner: {owner}"));
326 }
327 let stale = stale_concepts_describing(db, modified);
328 if stale > 0 {
329 lines.push(format!(
330 " {stale} concept(s) describe files you changed — say \"re-learn\" to refresh"
331 ));
332 }
333
334 // The framing line and the hint are the two the nudge cannot do without,
335 // so the body gives way to them — first to the line cap, then to the byte
336 // budget the topic digest is held to.
337 lines.truncate(MAX_NUDGE_LINES - 1);
338 loop {
339 let mut out = String::from(UNTRUSTED_FRAMING);
340 for l in &lines {
341 let _ = writeln!(out, "{l}");
342 }
343 out.push_str(HINT);
344 if out.len() <= MAX_OUTPUT_BYTES {
345 return Some(out);
346 }
347 if lines.len() <= 1 {
348 // Not even the first line fits, which takes a pathological path to
349 // manage. The topic digest is the better answer than a truncated
350 // one.
351 return None;
352 }
353 lines.pop();
354 }
355}
356
357/// Who the changed files belong to: the author owning most of them, ties
358/// broken by name so the line is the same on every run. One name, because
359/// "who do I ask about this change" has one useful answer.
360fn owner_of(files: &[FileImpact]) -> Option<String> {
361 let mut counts: BTreeMap<&String, usize> = BTreeMap::new();
362 for owner in files.iter().filter_map(|f| f.owner.as_ref()) {
363 *counts.entry(owner).or_default() += 1;
364 }
365 counts
366 .into_iter()
367 .max_by_key(|(name, count)| (*count, std::cmp::Reverse(*name)))
368 .map(|(name, _)| name.clone())
369}
370
371/// How many stale concepts were learned from a file in this diff.
372///
373/// Staleness is [`stale_concepts`]'s decision — a recorded source hash that no
374/// longer matches the `File` — and the diff narrows it to the concepts this
375/// change is responsible for. A concept that went stale for some other file is
376/// somebody else's re-learn.
377fn stale_concepts_describing(db: &crate::structure::Db, modified: &BTreeSet<String>) -> usize {
378 stale_concepts(db)
379 .iter()
380 .filter(
381 |(key, _)| match db.node_ref(key).and_then(|n| n.prop("source_files")) {
382 Some(Value::List(sources)) => sources.iter().any(|v| match v {
383 Value::Str(s) => modified.contains(s),
384 _ => false,
385 }),
386 _ => false,
387 },
388 )
389 .count()
390}
391
392#[cfg(test)]
393mod tests {
394 use super::{fulltext_or_query, prompt_from_payload};
395 use core_api::repograph::MAX_QUERY_TERMS;
396
397 #[test]
398 fn prompt_is_read_from_any_of_the_three_documented_fields() {
399 for field in ["prompt", "user_prompt", "user_input"] {
400 let payload = format!(r#"{{"{field}":" hello "}}"#);
401 assert_eq!(prompt_from_payload(&payload).as_deref(), Some("hello"));
402 }
403 assert_eq!(prompt_from_payload(r#"{"prompt":" "}"#), None);
404 assert_eq!(prompt_from_payload(r#"{"other":"hi"}"#), None);
405 assert_eq!(prompt_from_payload("not json"), None);
406 }
407
408 #[test]
409 fn prompt_becomes_an_or_query_of_lowercased_alphanumeric_terms() {
410 assert_eq!(
411 fulltext_or_query("What about Person 1 and Project 5?").as_deref(),
412 Some("person OR 1 OR project OR 5"),
413 );
414 }
415
416 #[test]
417 fn or_query_drops_query_keywords_repeats_and_punctuation() {
418 // `and`/`or` are grammar keywords; `-x` would negate and `x*` prefix-match,
419 // so splitting on non-alphanumerics is what keeps them inert.
420 assert_eq!(
421 fulltext_or_query("AND or foo-bar foo baz*").as_deref(),
422 Some("foo OR bar OR baz"),
423 );
424 assert_eq!(fulltext_or_query(" ?! ,, "), None);
425 }
426
427 /// Binding: a prompt made only of function words leaves nothing to search
428 /// for, so the hook has nothing to print. An `OR` of stopwords matched
429 /// essentially every indexed document, which is how `the` used to produce
430 /// a full digest of six unrelated nodes.
431 #[test]
432 fn or_query_is_none_for_a_prompt_that_is_all_glue() {
433 for prompt in [
434 "the",
435 "is it done",
436 "ok thanks",
437 "can you do that please",
438 "what do you think about it",
439 "which file has the code",
440 ] {
441 assert_eq!(fulltext_or_query(prompt), None, "{prompt:?}");
442 }
443 }
444
445 /// A prompt can survive the stopwords and still be about nothing the graph
446 /// holds. `weather` is a word, not glue, so it is searched for — and a code
447 /// graph has no hit for it, which is the other way the hook falls silent.
448 #[test]
449 fn or_query_keeps_a_real_word_the_graph_will_not_match() {
450 assert_eq!(
451 fulltext_or_query("what is the weather today?").as_deref(),
452 Some("weather OR today"),
453 );
454 }
455
456 /// Binding: the glue goes and the subject stays — including the words a
457 /// repository question turns on, which are ordinary English too.
458 #[test]
459 fn or_query_keeps_the_subject_of_a_real_question() {
460 assert_eq!(
461 fulltext_or_query("why does install.rs change with tests/install.rs").as_deref(),
462 Some("install OR rs OR change OR tests"),
463 );
464 assert_eq!(
465 fulltext_or_query("please fix the failing test in recall").as_deref(),
466 Some("fix OR failing OR test OR recall"),
467 );
468 assert_eq!(
469 fulltext_or_query("who owns the parser").as_deref(),
470 Some("owns OR parser"),
471 );
472 }
473
474 #[test]
475 fn or_query_caps_the_number_of_terms() {
476 let prompt: String = (0..MAX_QUERY_TERMS + 10)
477 .map(|i| format!("w{i} "))
478 .collect();
479 let q = fulltext_or_query(&prompt).expect("terms");
480 assert_eq!(q.split(" OR ").count(), MAX_QUERY_TERMS);
481 }
482}