Skip to main content

trusty_memory/
prompt_facts.rs

1//! Prompt-facts surface: hot KG predicates exposed via a per-message tool.
2//!
3//! Why: Certain KG triples — aliases, project conventions, ambient facts —
4//! belong in the model's working context so it doesn't have to discover them
5//! via blind searches. The original design surfaced them via MCP prompts
6//! (`prompts/list` + `prompts/get`) at session init, but hosts only read
7//! those once per connection. Switching to a tool (`get_prompt_context`) the
8//! model can invoke per-turn lets it pull fresh, query-filtered context on
9//! demand without the staleness of a session-init snapshot.
10//! What: Defines the `HOT_PREDICATES` allow-list, the grouping/formatting
11//! logic that turns `(subject, predicate, object)` triples into a Markdown
12//! context block, the `PromptFactsCache` struct holding raw triples + a
13//! pre-formatted string, and helpers used by the MCP `get_prompt_context`
14//! tool to fetch (and optionally filter) the cached context.
15//! Test: see the `tests` module — covers `is_hot_predicate`, the formatter
16//! grouping/sections, and the empty-input shortcut.
17
18use crate::AppState;
19use anyhow::Result;
20
21/// Cached prompt-facts surface: raw triples and a pre-formatted Markdown block.
22///
23/// Why: The `get_prompt_context` tool serves two access modes — unfiltered
24/// (returns the pre-formatted block directly) and filtered (re-runs the
25/// formatter on a `query`-matching subset). Caching only the formatted string
26/// would force a fresh `gather_hot_triples` pass for every filtered call;
27/// caching only the triples would force re-formatting for every unfiltered
28/// call. Holding both lets the hot path stay O(1) and the filtered path stay
29/// O(n) without ever re-walking the KG.
30/// What: A plain `Default + Clone` struct. `triples` holds the active
31/// `(subject, predicate, object)` rows for every hot predicate across every
32/// palace; `formatted` is `build_prompt_context(&triples)` cached for the
33/// no-filter case.
34/// Test: `rebuild_prompt_cache_populates_triples_and_formatted` (in
35/// `tools::tests`); `get_prompt_context_filters_by_query`.
36#[derive(Default, Clone)]
37pub struct PromptFactsCache {
38    /// All active hot-predicate triples: (subject, predicate, object).
39    pub triples: Vec<(String, String, String)>,
40    /// Pre-formatted string of all triples (used when no query filter).
41    pub formatted: String,
42}
43
44/// Predicates whose currently-active triples are always included in the
45/// session-init prompt context.
46///
47/// Why: Aliases, conventions, and standalone facts are the categories users
48/// reach for when they want a model to "just know" something at the start of
49/// every conversation. Other predicates (`works_at`, `lives_in`, …) are
50/// retrieval-driven and don't belong in the always-on prompt.
51/// What: A static slice of predicate strings; order here drives section
52/// order in `build_prompt_context`.
53/// Test: `is_hot_predicate_matches_listed`.
54pub const HOT_PREDICATES: &[&str] = &[
55    "is_alias_for",
56    "has_convention",
57    "is_fact",
58    "is_shorthand_for",
59];
60
61/// Check whether `p` is one of the hot predicates surfaced via the prompt.
62///
63/// Why: Callers (the `kg_assert` dispatch, the `add_alias` tool) need to
64/// decide whether a write should invalidate the prompt cache. A free
65/// function avoids `HashSet` allocation for a four-element constant list.
66/// What: Linear scan over `HOT_PREDICATES` — at four entries this is faster
67/// than any hashed alternative and keeps the API copy-free.
68/// Test: `is_hot_predicate_matches_listed`.
69pub fn is_hot_predicate(p: &str) -> bool {
70    HOT_PREDICATES.contains(&p)
71}
72
73/// Friendly section heading for each hot predicate.
74///
75/// Why: Predicate identifiers (`is_alias_for`) are machine-friendly but read
76/// poorly in a prompt; "Aliases" reads naturally to a model and a human
77/// auditing the prompt content.
78/// What: Maps each known predicate to its display heading. Unknown
79/// predicates fall back to the predicate name itself so an accidentally
80/// added hot predicate still renders coherently.
81/// Test: indirectly via `build_prompt_context_groups_and_formats`.
82fn section_heading(predicate: &str) -> &str {
83    match predicate {
84        "is_alias_for" => "Aliases",
85        "has_convention" => "Conventions",
86        "is_fact" => "Facts",
87        "is_shorthand_for" => "Shorthands",
88        other => other,
89    }
90}
91
92/// Build the prompt-context Markdown block from a flat list of triples.
93///
94/// Why: The MCP `prompts/get` handler returns a single text block; keeping
95/// the formatter pure (in: `(subject, predicate, object)` tuples; out:
96/// `String`) makes the cache rebuild trivial and the unit tests cheap.
97/// What: Filters to hot predicates, groups by predicate in `HOT_PREDICATES`
98/// order, emits a top-level header followed by a `###` section per
99/// non-empty group with `- subject → object` bullets (for aliases /
100/// shorthands) or `- object` bullets (for conventions / facts). Returns an
101/// empty `String` when no hot triples are present, so the caller can fall
102/// back to a "no context stored yet" message without inspecting the
103/// internals.
104/// Test: `build_prompt_context_groups_and_formats`,
105/// `build_prompt_context_empty_when_no_hot_triples`.
106pub fn build_prompt_context(triples: &[(String, String, String)]) -> String {
107    // Filter and group preserving HOT_PREDICATES ordering.
108    // `(predicate, triples-in-that-section)`; aliased to satisfy clippy's
109    // `type_complexity` lint.
110    type Section<'a> = (&'a str, Vec<&'a (String, String, String)>);
111    let mut sections: Vec<Section<'_>> = HOT_PREDICATES.iter().map(|p| (*p, Vec::new())).collect();
112    for triple in triples {
113        if let Some(slot) = sections.iter_mut().find(|(p, _)| *p == triple.1.as_str()) {
114            slot.1.push(triple);
115        }
116    }
117
118    // Bail early when nothing matched — callers render a placeholder.
119    if sections.iter().all(|(_, v)| v.is_empty()) {
120        return String::new();
121    }
122
123    let mut out = String::new();
124    out.push_str("## Project Context (from memory palace)\n");
125    for (predicate, items) in sections {
126        if items.is_empty() {
127            continue;
128        }
129        out.push('\n');
130        out.push_str("### ");
131        out.push_str(section_heading(predicate));
132        out.push('\n');
133        for (subject, _predicate, object) in items {
134            // Aliases / shorthands read best as "short → full"; conventions
135            // and facts are self-contained so we drop the subject (which is
136            // typically a synthetic "convention-1" id with no value to the
137            // model).
138            match predicate {
139                "is_alias_for" | "is_shorthand_for" => {
140                    out.push_str("- ");
141                    out.push_str(subject);
142                    out.push_str(" → ");
143                    out.push_str(object);
144                    out.push('\n');
145                }
146                _ => {
147                    out.push_str("- ");
148                    out.push_str(object);
149                    out.push('\n');
150                }
151            }
152        }
153    }
154    out
155}
156
157/// Fetch every currently-active hot-predicate triple across every palace in
158/// the registry.
159///
160/// Why: The prompt cache surfaces context regardless of which palace stored
161/// the fact, so a single MCP connection sees aliases / conventions from
162/// every project namespace. Reading once into a `Vec<(String, String,
163/// String)>` keeps the formatter side-effect-free and lets tests build
164/// fixtures without touching redb.
165/// What: Iterates every palace handle currently registered, calls
166/// `list_active` with a generous limit, and filters each batch through
167/// `is_hot_predicate`. A palace whose KG fails to read is logged at `warn`
168/// and skipped — one bad palace must not blank the prompt context.
169/// Test: `gather_hot_triples_skips_non_hot` (integration in `tools::tests`).
170pub async fn gather_hot_triples(state: &AppState) -> Result<Vec<(String, String, String)>> {
171    // Why: `list_active` requires a finite limit; HOT_PREDICATES facts are
172    // small in count by design (aliases / conventions, not free-form
173    // memory), so 1024 is generous without risking unbounded reads on a
174    // misuse where someone stores thousands of "facts".
175    const PER_PALACE_LIMIT: usize = 1024;
176
177    let mut out = Vec::new();
178    for palace_id in state.registry.list() {
179        let handle = match state.registry.get(&palace_id) {
180            Some(h) => h,
181            None => continue, // raced with removal; nothing to read
182        };
183        match handle.kg.list_active(PER_PALACE_LIMIT, 0).await {
184            Ok(triples) => {
185                for t in triples {
186                    if is_hot_predicate(&t.predicate) {
187                        out.push((t.subject, t.predicate, t.object));
188                    }
189                }
190            }
191            Err(e) => {
192                tracing::warn!(
193                    palace = %palace_id.as_str(),
194                    "skipping palace during prompt-fact gather: {e:#}",
195                );
196            }
197        }
198    }
199    Ok(out)
200}
201
202/// Refresh `AppState.prompt_context_cache` from the live palace registry.
203///
204/// Why: Every write that touches a hot predicate (`kg_assert`, `add_alias`,
205/// `remove_prompt_fact`) must update the cache so the next
206/// `get_prompt_context` tool call returns the fresh content. Centralising
207/// the refresh here means the dispatch sites only have to call one function.
208/// The lock is `tokio::sync::RwLock` (issue #229) so the rebuild yields to
209/// the runtime instead of blocking a worker thread for the full KG walk.
210/// What: Calls `gather_hot_triples`, formats via `build_prompt_context`,
211/// then takes the cache's async write lock and replaces both the raw
212/// triples and the pre-formatted string in a single assignment. The write
213/// is non-blocking from the caller's perspective: the lock is held only
214/// for the assignment, not the gather/format work.
215/// Test: `rebuild_prompt_cache_reflects_writes` (in `tools::tests`).
216pub async fn rebuild_prompt_cache(state: &AppState) -> Result<()> {
217    let triples = gather_hot_triples(state).await?;
218    let formatted = build_prompt_context(&triples);
219    let cache = state.prompt_context_cache.clone();
220    let mut guard = cache.write().await;
221    *guard = PromptFactsCache { triples, formatted };
222    Ok(())
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn is_hot_predicate_matches_listed() {
231        for p in HOT_PREDICATES {
232            assert!(is_hot_predicate(p), "expected hot: {p}");
233        }
234        assert!(!is_hot_predicate("works_at"));
235        assert!(!is_hot_predicate(""));
236    }
237
238    #[test]
239    fn build_prompt_context_empty_when_no_hot_triples() {
240        let triples: Vec<(String, String, String)> = vec![
241            ("alice".into(), "works_at".into(), "Acme".into()),
242            ("bob".into(), "lives_in".into(), "Paris".into()),
243        ];
244        assert_eq!(build_prompt_context(&triples), "");
245    }
246
247    #[test]
248    fn build_prompt_context_groups_and_formats() {
249        let triples: Vec<(String, String, String)> = vec![
250            (
251                "tga".into(),
252                "is_alias_for".into(),
253                "trusty-git-analytics".into(),
254            ),
255            ("tm".into(), "is_alias_for".into(), "trusty-memory".into()),
256            (
257                "conv-1".into(),
258                "has_convention".into(),
259                "No unwrap() in library code".into(),
260            ),
261            ("fact-1".into(), "is_fact".into(), "MSRV is 1.88".into()),
262            // Non-hot — must be ignored entirely.
263            ("alice".into(), "works_at".into(), "Acme".into()),
264        ];
265        let out = build_prompt_context(&triples);
266        assert!(out.starts_with("## Project Context (from memory palace)"));
267        assert!(out.contains("### Aliases"));
268        assert!(out.contains("- tga → trusty-git-analytics"));
269        assert!(out.contains("- tm → trusty-memory"));
270        assert!(out.contains("### Conventions"));
271        assert!(out.contains("- No unwrap() in library code"));
272        assert!(out.contains("### Facts"));
273        assert!(out.contains("- MSRV is 1.88"));
274        // Non-hot triple omitted.
275        assert!(!out.contains("Acme"));
276        // Aliases section must come before Conventions (HOT_PREDICATES order).
277        let aliases_idx = out.find("### Aliases").unwrap();
278        let conventions_idx = out.find("### Conventions").unwrap();
279        let facts_idx = out.find("### Facts").unwrap();
280        assert!(aliases_idx < conventions_idx);
281        assert!(conventions_idx < facts_idx);
282    }
283}