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}