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}