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