Skip to main content

cli/
recall.rs

1//! `mushroomdb recall <db>`: the body of the `UserPromptSubmit` hook.
2//!
3//! The hook reads the prompt off stdin, asks the store what it already knows
4//! about it, and prints a digest or nothing at all. Everything specific to
5//! being a hook stays here: reading the payload, opening the store read-only,
6//! staying inside one byte budget, and staying silent on any error. A recall
7//! hook must never block or slow a user's prompt.
8//!
9//! Until 0.7 this gated on the prompt naming a *code identifier* — a path, a
10//! `mod::name`, a snake_case or backticked word — and said nothing otherwise.
11//! On a memory store, whose prompts are sentences about people and projects,
12//! that was silence on every prompt. The digest itself is
13//! [`core_api::memory::recall::recall_digest`], which has no identifier gate.
14//!
15//! The dirty-working-tree nudge — what the changed files reach, who owns them,
16//! which learned concepts went stale — read edges only `ingest-git` writes. It
17//! left with the rest of the code-graph door in 0.7.
18use crate::hook::{cut_to, open_for_hook};
19use core_api::digest::{MAX_OUTPUT_BYTES, UNTRUSTED_FRAMING};
20use core_api::memory::recall::{recall_digest, RecallOutcome};
21use std::path::Path;
22
23/// Extract the prompt text from a hook payload. Accepts `prompt`,
24/// `user_prompt`, and `user_input` (the docs disagree on the field name).
25fn prompt_from_payload(raw: &str) -> Option<String> {
26    let v: serde_json::Value = serde_json::from_str(raw).ok()?;
27    for k in ["prompt", "user_prompt", "user_input"] {
28        if let Some(s) = v.get(k).and_then(|x| x.as_str()) {
29            let s = s.trim();
30            if !s.is_empty() {
31                return Some(s.to_string());
32            }
33        }
34    }
35    None
36}
37
38/// The hook's whole output. Never an error, never a panic: an empty string is
39/// how this hook says "nothing to add", and every failure path says that.
40#[must_use]
41pub fn run_recall(db_dir: &Path, hook_stdin: &str) -> String {
42    let Some(prompt) = prompt_from_payload(hook_stdin) else {
43        return String::new();
44    };
45    let Some(db) = open_for_hook(db_dir) else {
46        return String::new();
47    };
48    // The digest is stored content on its way into an assistant's context, so
49    // it is marked as data before its first line — the same marker the MCP
50    // tools put on the same digest at their own output layer. The marker is
51    // charged against the one byte budget.
52    let budget = MAX_OUTPUT_BYTES.saturating_sub(UNTRUSTED_FRAMING.len());
53    match recall_digest(&db, &prompt, &db_dir.display().to_string(), budget) {
54        RecallOutcome::Hits(digest) => {
55            cut_to(format!("{UNTRUSTED_FRAMING}{digest}"), MAX_OUTPUT_BYTES)
56        }
57        // The tool answers this because someone asked it a question. This
58        // hook was not asked; it fires on every prompt, so the same line here
59        // nags every turn until the store is fixed. The brief says it once per
60        // session instead.
61        RecallOutcome::NoIndex | RecallOutcome::NoMatch => String::new(),
62    }
63}
64
65#[cfg(test)]
66mod tests {
67    use super::prompt_from_payload;
68
69    #[test]
70    fn prompt_is_read_from_any_of_the_three_documented_fields() {
71        for field in ["prompt", "user_prompt", "user_input"] {
72            let payload = format!(r#"{{"{field}":"  hello  "}}"#);
73            assert_eq!(prompt_from_payload(&payload).as_deref(), Some("hello"));
74        }
75        assert_eq!(prompt_from_payload(r#"{"prompt":"   "}"#), None);
76        assert_eq!(prompt_from_payload(r#"{"other":"hi"}"#), None);
77        assert_eq!(prompt_from_payload("not json"), None);
78    }
79}