Skip to main content

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}