Skip to main content

trusty_memory/
tools.rs

1//! MCP tool surface for trusty-memory.
2//!
3//! Why: Concentrates the public tool contract in one file so changes are
4//! auditable and the MCP schema stays in sync with the implementation.
5//! What: Defines `MemoryMcpServer`, `tool_definitions()` (the MCP
6//! `tools/list` payload), and the in-process tool dispatcher wired to the
7//! real `PalaceRegistry` + retrieval / KG APIs.
8//! Test: `cargo test -p trusty-memory-mcp` validates the schema and dispatch.
9//!
10//! Tools exposed:
11//! - `memory_remember(palace, text, room?, tags?)` -> drawer_id
12//! - `memory_recall(palace, query, top_k?)`        -> Vec<Drawer> (L0+L1+L2)
13//! - `memory_recall_deep(palace, query, top_k?)`   -> Vec<Drawer> (L3 deep)
14//! - `memory_list(palace, room?, tag?, limit?)`    -> Vec<Drawer>
15//! - `memory_forget(palace, drawer_id)`            -> ()
16//! - `palace_create(name, description?)`           -> PalaceId
17//! - `palace_list()`                                -> Vec<PalaceId>
18//! - `palace_info(palace)`                          -> palace metadata + stats
19//! - `kg_assert(palace, subject, predicate, object, confidence?, provenance?)` -> ()
20//! - `kg_query(palace, subject)`                    -> Vec<Triple>
21
22use crate::attribution::{session_tag_from_tags, CreatorInfo, CreatorSource, MCP_CLIENT_NAME};
23use crate::kg_extract::{extract_triples, ExtractInput};
24use crate::{ActivitySource, AppState, DaemonEvent};
25use anyhow::{anyhow, Context, Result};
26use serde_json::{json, Value};
27use trusty_common::memory_core::filter::{FilterConfig, MCP_MIN_TOKENS};
28use trusty_common::memory_core::palace::{Palace, PalaceId, RoomType};
29use trusty_common::memory_core::retrieval::{
30    recall, recall_across_palaces, recall_deep, RememberOptions,
31};
32use trusty_common::memory_core::store::kg::Triple;
33use uuid::Uuid;
34
35/// Look up the friendly palace name (Palace.name) from the in-memory cache,
36/// falling back to the id when the cache misses.
37///
38/// Why (issue #96): MCP-side emit calls need the same `palace_name` field
39/// the HTTP path emits so the activity feed renders identical labels
40/// regardless of origin.
41/// Why (issue #228): the previous implementation called
42/// `PalaceRegistry::list_palaces` — a synchronous filesystem walk — on every
43/// `memory_remember` / `memory_note` write. With N palaces on disk that was
44/// O(N) opendirs + `palace.json` reads per write, blocking the async runtime
45/// thread (this helper had no `spawn_blocking` wrapper, unlike `palace_list`).
46/// The replacement reads `state.palace_names` (a `DashMap` populated at
47/// hydration / create time), which is a lock-free read and never touches
48/// disk.
49/// What: looks up `palace_id` in `state.palace_names`; on miss, returns the
50/// id verbatim so emit calls never fail. Cache misses are non-fatal —
51/// rename / create paths keep the cache in sync, but a fresh-after-restart
52/// palace will hit the miss branch only until hydration completes.
53/// Test: implicit — the MCP emit tests assert the `palace_id` matches; the
54/// fallback is the same id-as-name behaviour the HTTP path uses. The cache
55/// invariant is covered by `palace_name_cache_populated_after_hydration`
56/// and `palace_name_cache_updates_on_create` in lib.rs.
57fn lookup_palace_name(state: &AppState, palace_id: &str) -> String {
58    state
59        .palace_names
60        .get(palace_id)
61        .map(|entry| entry.value().clone())
62        .unwrap_or_else(|| palace_id.to_string())
63}
64
65/// Minimum standalone-content word count enforced by [`content_gate`].
66///
67/// Why (issue #215): single-word user replies ("yes", "ok", "no thanks") have
68/// no standalone memory value when the surrounding turn isn't captured
69/// alongside them — they end up in the palace as orphan fragments that
70/// pollute recall results. Requiring at least four whitespace-separated tokens
71/// is a cheap heuristic that matches the natural boundary between "just a
72/// reaction" and "an actual statement".
73/// What: the threshold the gate compares against. Tokens are counted via
74/// `split_whitespace().count()`, so punctuation does not inflate the count.
75/// Test: `content_gate_blocks_short_no_context`, `content_gate_keeps_long`.
76const CONTENT_GATE_MIN_WORDS: usize = 4;
77
78/// Gate short standalone content unless a `context` wrapper is supplied.
79///
80/// Why: single-word or very-short standalone user responses ("yes", "ok")
81/// have no standalone memory value (issue #215). Gate them unless a context
82/// is provided.
83/// What: returns `None` if `content` has fewer than [`CONTENT_GATE_MIN_WORDS`]
84/// whitespace-separated tokens AND `context` is `None` (the write should be
85/// skipped). Returns `Some(combined)` where `combined = "<context>\n\n---\n\n<content>"`
86/// when `context` is `Some` and non-empty after trimming. Returns
87/// `Some(content)` unchanged when `content` has at least
88/// [`CONTENT_GATE_MIN_WORDS`] tokens. Tokens are counted on the trimmed
89/// `content` so trailing whitespace doesn't inflate the count.
90/// Test: `content_gate_blocks_short_no_context`,
91/// `content_gate_wraps_short_with_context`,
92/// `content_gate_keeps_long`, `content_gate_blank_context_treated_as_none`.
93fn content_gate(content: &str, context: Option<&str>) -> Option<String> {
94    let trimmed = content.trim();
95    let word_count = trimmed.split_whitespace().count();
96    // Treat a context that is empty or whitespace-only as "no context" — a
97    // caller passing `""` should not unlock a write the gate would otherwise
98    // drop, and the combined output would otherwise begin with a meaningless
99    // separator.
100    let context_clean = context.map(str::trim).filter(|s| !s.is_empty());
101    if let Some(ctx) = context_clean {
102        return Some(format!("{ctx}\n\n---\n\n{content}"));
103    }
104    if word_count < CONTENT_GATE_MIN_WORDS {
105        return None;
106    }
107    Some(content.to_string())
108}
109
110/// Patterns whose content should never be stored as standalone memories.
111///
112/// Why (issue #220): the activity panel was being flooded with low-value
113/// Claude Code auto-captures — `Tool use: Bash`, `Tool use: Edit File: …`,
114/// `Claude Code session ended: <uuid>` — that carry no semantic value once
115/// the surrounding turn is gone. They pollute recall results and burn UI
116/// real estate. A blocklist is the cheapest way to filter them at write
117/// time without coordinating with the auto-capture hook source.
118/// What: substring patterns (not regexes) checked via `str::contains` so
119/// the matcher stays branch-predictable and never panics on malformed
120/// input. Patterns are intentionally lower-case-friendly but matched
121/// case-sensitively because the auto-capture hooks always emit the exact
122/// English prefix.
123/// Test: `blocklist_gate_blocks_tool_use`,
124/// `blocklist_gate_blocks_session_ended`,
125/// `blocklist_gate_passes_normal_content`.
126const BLOCKLIST_PATTERNS: &[&str] = &[
127    "Tool use: ",          // Claude Code tool-use captures
128    "Claude Code session", // Session lifecycle events
129];
130
131/// Rolling-window horizon for the dedup gate.
132///
133/// Why (issue #220): identical content is often emitted multiple times in
134/// quick succession (auto-capture hook bursts, retries, copy-paste). A
135/// 5-minute window catches the burst without rejecting deliberate user
136/// re-statements hours later.
137/// What: `chrono::Duration` value. Drawers created before
138/// `now - DEDUP_WINDOW` are ignored by the dedup pass.
139/// Test: indirect via `dedup_skips_near_duplicate` and
140/// `dedup_allows_different_content` (use the helper directly).
141const DEDUP_WINDOW_MINUTES: i64 = 5;
142
143/// Maximum number of recent drawers the dedup pass scans.
144///
145/// Why: a palace can hold tens of thousands of drawers; we never need to
146/// compare the new write against more than the most-recent handful to
147/// catch the bursty-duplicate case. Capping the scan keeps the hot path
148/// O(1) in the palace size.
149/// What: ceiling on the candidate list pulled from
150/// `PalaceHandle::list_drawers` before the time-window filter.
151/// Test: `dedup_skips_near_duplicate` exercises the scan against a small
152/// candidate set; the cap is enforced by `list_drawers`'s `limit` arg.
153const DEDUP_SCAN_LIMIT: usize = 50;
154
155/// Jaro-Winkler similarity threshold above which a candidate counts as a
156/// near-duplicate of the new content.
157///
158/// Why: 0.92 is the empirically-chosen cutoff documented in the issue —
159/// high enough to allow distinct facts to coexist, low enough to catch
160/// trivial whitespace / punctuation / suffix variation. Jaro-Winkler is
161/// preferred over plain Jaro because the auto-capture noise tends to share
162/// the same prefix (`Tool use: …`, `Edit File: …`), which Jaro-Winkler
163/// weights heavily.
164/// What: `f64` threshold compared against `strsim::jaro_winkler`'s output.
165/// Test: `dedup_skips_near_duplicate`, `dedup_allows_different_content`.
166const DEDUP_SIMILARITY_THRESHOLD: f64 = 0.92;
167
168/// Blocklist gate: returns true when the content should be silently
169/// skipped because it matches a known low-value auto-capture pattern.
170///
171/// Why (issue #220): Centralises the pattern-match logic so both
172/// `memory_remember` and `memory_note` go through the same filter. Trims
173/// leading whitespace before matching so indented variants still hit.
174/// What: returns `true` iff `content.contains(pat)` for any pattern in
175/// `BLOCKLIST_PATTERNS`. Trimming uses `str::trim_start` to keep the
176/// substring check predictable (the suffixes after the prefix can vary).
177/// Test: `blocklist_gate_blocks_tool_use`,
178/// `blocklist_gate_blocks_session_ended`,
179/// `blocklist_gate_passes_normal_content`.
180fn blocklist_gate(content: &str) -> bool {
181    let trimmed = content.trim_start();
182    BLOCKLIST_PATTERNS.iter().any(|pat| trimmed.contains(pat))
183}
184
185/// Dedup gate: returns true when the new content is a near-duplicate of a
186/// drawer written to the same palace within the rolling window.
187///
188/// Why (issue #220): bursts of identical or near-identical content (auto-
189/// capture retries, hook re-emissions, copy-paste artefacts) were
190/// inflating the palace with no recall benefit. A short rolling window
191/// catches the burst without rejecting deliberate re-statements hours
192/// later.
193/// What: pulls up to `DEDUP_SCAN_LIMIT` recent drawers from the live
194/// in-memory table via `list_drawers` (a cheap snapshot, no I/O), filters
195/// to those created within `DEDUP_WINDOW_MINUTES` of `now`, then computes
196/// `strsim::jaro_winkler` against each. Returns `true` on the first match
197/// above `DEDUP_SIMILARITY_THRESHOLD`. Returns `false` if `content` is
198/// empty after trimming (the content gate handles that case separately)
199/// or if the palace has no recent drawers.
200/// Test: `dedup_skips_near_duplicate`, `dedup_allows_different_content`.
201fn dedup_gate(handle: &trusty_common::memory_core::PalaceHandle, content: &str) -> bool {
202    let trimmed = content.trim();
203    if trimmed.is_empty() {
204        return false;
205    }
206    let now = chrono::Utc::now();
207    let window_start = now - chrono::Duration::minutes(DEDUP_WINDOW_MINUTES);
208    let recent = handle.list_drawers(None, None, DEDUP_SCAN_LIMIT);
209    recent
210        .iter()
211        .filter(|d| d.created_at >= window_start)
212        .any(|d| strsim::jaro_winkler(trimmed, d.content.trim()) > DEDUP_SIMILARITY_THRESHOLD)
213}
214
215/// Build the strict MCP-level `RememberOptions`.
216///
217/// Why: Issue #61 — the MCP boundary is where auto-capture hooks deposit
218/// raw tool/commit/prompt data; we want the 8-token threshold there even
219/// though the library default is more permissive for direct callers.
220/// What: Clones the default filter and bumps `min_tokens` to `MCP_MIN_TOKENS`.
221/// Test: `dispatch_remember_rejects_short_content`.
222fn mcp_remember_opts(force: bool) -> RememberOptions {
223    let filter = FilterConfig {
224        min_tokens: MCP_MIN_TOKENS,
225        ..FilterConfig::default()
226    };
227    RememberOptions {
228        filter,
229        force,
230        ..RememberOptions::default()
231    }
232}
233
234/// Marker server type. Reserved for future stateful MCP server impls.
235///
236/// Why: Keep a stable type name while the protocol-loop is implemented at
237/// module level, so external callers can still depend on a server symbol.
238/// What: Zero-sized struct with `new` / `Default`.
239/// Test: `MemoryMcpServer::default()` constructs without panic.
240pub struct MemoryMcpServer;
241
242impl MemoryMcpServer {
243    pub fn new() -> Self {
244        Self
245    }
246}
247
248impl Default for MemoryMcpServer {
249    fn default() -> Self {
250        Self::new()
251    }
252}
253
254/// MCP `tools/list` response payload.
255///
256/// Why: Claude Code calls `tools/list` once on connect and uses the schema
257/// to drive the tool picker; the schema is the source of truth for arg names.
258/// `palace` is required only when the server has no `--palace` default
259/// configured — when a default is set, the schema omits `palace` from
260/// `required` so clients can drop it.
261/// What: Returns a JSON object `{ "tools": [...] }` with all 10 tool defs.
262/// Test: `tool_definitions_lists_all_tools`,
263/// `tool_definitions_drops_palace_required_when_default_set`.
264pub fn tool_definitions() -> Value {
265    tool_definitions_with(false)
266}
267
268/// Variant of `tool_definitions` aware of whether a default palace is
269/// configured. When `has_default` is true, the `palace` argument is moved
270/// out of the `required` list for every tool that takes it.
271///
272/// Why: Lets `handle_message` emit a schema that matches the running
273/// server's actual contract — clients reading the schema should see exactly
274/// what they need to send.
275/// What: Builds the same shape as `tool_definitions` but with conditional
276/// `required` arrays.
277/// Test: `tool_definitions_drops_palace_required_when_default_set`.
278pub fn tool_definitions_with(has_default: bool) -> Value {
279    let memory_remember_required: Vec<&str> = if has_default {
280        vec!["text"]
281    } else {
282        vec!["palace", "text"]
283    };
284    let memory_recall_required: Vec<&str> = if has_default {
285        vec!["query"]
286    } else {
287        vec!["palace", "query"]
288    };
289    let kg_assert_required: Vec<&str> = if has_default {
290        vec!["subject", "predicate", "object"]
291    } else {
292        vec!["palace", "subject", "predicate", "object"]
293    };
294    let kg_query_required: Vec<&str> = if has_default {
295        vec!["subject"]
296    } else {
297        vec!["palace", "subject"]
298    };
299    let memory_list_required: Vec<&str> = if has_default { vec![] } else { vec!["palace"] };
300    let memory_forget_required: Vec<&str> = if has_default {
301        vec!["drawer_id"]
302    } else {
303        vec!["palace", "drawer_id"]
304    };
305    let palace_info_required: Vec<&str> = if has_default { vec![] } else { vec!["palace"] };
306    let palace_compact_required: Vec<&str> = if has_default { vec![] } else { vec!["palace"] };
307    let memory_note_required: Vec<&str> = if has_default {
308        vec!["content"]
309    } else {
310        vec!["palace", "content"]
311    };
312
313    json!({
314        "tools": [
315            {
316                "name": "memory_remember",
317                "description": "Store a memory (drawer) in a palace room. Content is filtered for signal vs. noise (issue #61): rejects empty/very short content, raw tool/commit output, and code-only blobs. Issue #215: very short standalone content (< 4 words) is silently dropped unless a `context` is supplied, in which case the context is prepended so the stored memory has standalone value. Pass force=true to bypass filtering, or use memory_note for short curated facts.",
318                "inputSchema": {
319                    "type": "object",
320                    "properties": {
321                        "palace":  {"type": "string", "description": "Palace ID (optional if server started with --palace)"},
322                        "text":    {"type": "string", "description": "Memory content"},
323                        "room":    {"type": "string", "description": "Room type (optional)"},
324                        "tags":    {"type": "array", "items": {"type": "string"}},
325                        "force":   {"type": "boolean", "description": "Bypass the signal/noise filter. Use sparingly — intended for explicit operator overrides.", "default": false},
326                        "context": {"type": "string", "description": "Optional surrounding context. When supplied alongside very short content (< 4 words), the context is prepended (separated by `---`) so the stored memory has standalone meaning; without it, short content is dropped (issue #215)."}
327                    },
328                    "required": memory_remember_required,
329                }
330            },
331            {
332                "name": "memory_note",
333                "description": "Curated shortcut for short, high-signal facts (\"User prefers snake_case\", \"Deploy target is prod-east\"). Bypasses the token-length filter but still rejects auto-capture noise. Stored as DrawerType::UserFact with importance 1.0. Issue #215: a `context` argument can be supplied to wrap an otherwise meaningless single-word response.",
334                "inputSchema": {
335                    "type": "object",
336                    "properties": {
337                        "palace":  {"type": "string"},
338                        "content": {"type": "string", "description": "Brief fact to remember"},
339                        "tags":    {"type": "array", "items": {"type": "string"}},
340                        "context": {"type": "string", "description": "Optional surrounding context. Prepended to `content` (separated by `---`) when supplied; with very short content (< 4 words) and no context the write is skipped (issue #215)."}
341                    },
342                    "required": memory_note_required,
343                }
344            },
345            {
346                "name": "memory_recall",
347                "description": "Recall memories using L0+L1+L2 progressive retrieval.",
348                "inputSchema": {
349                    "type": "object",
350                    "properties": {
351                        "palace": {"type": "string"},
352                        "query":  {"type": "string"},
353                        "top_k":  {"type": "integer", "default": 10}
354                    },
355                    "required": memory_recall_required,
356                }
357            },
358            {
359                "name": "memory_recall_deep",
360                "description": "Deep recall using L3 full HNSW search.",
361                "inputSchema": {
362                    "type": "object",
363                    "properties": {
364                        "palace": {"type": "string"},
365                        "query":  {"type": "string"},
366                        "top_k":  {"type": "integer", "default": 10}
367                    },
368                    "required": memory_recall_required,
369                }
370            },
371            {
372                "name": "palace_create",
373                "description": "Create a new memory palace.",
374                "inputSchema": {
375                    "type": "object",
376                    "properties": {
377                        "name":        {"type": "string"},
378                        "description": {"type": "string"},
379                        "cwd":         {"type": "string", "description": "Optional caller working directory used for palace-name enforcement. Pass the project root (or any path inside it) so the pin file at `.trusty-tools/trusty-memory.yaml` is honoured. When omitted, the daemon's own cwd is used (rarely meaningful for remote calls)."}
380                    },
381                    "required": ["name"]
382                }
383            },
384            {
385                "name": "palace_list",
386                "description": "List all palaces on this machine.",
387                "inputSchema": {"type": "object", "properties": {}}
388            },
389            {
390                "name": "palace_delete",
391                "description": "Delete an entire memory palace, including its drawers, vectors, and knowledge graph. Refuses to delete a non-empty palace unless `force=true` is set.",
392                "inputSchema": {
393                    "type": "object",
394                    "properties": {
395                        "palace_id": {"type": "string", "description": "Id of the palace to delete."},
396                        "force":     {"type": "boolean", "description": "Required when the palace still has drawers; defaults to false.", "default": false}
397                    },
398                    "required": ["palace_id"]
399                }
400            },
401            {
402                "name": "palace_update",
403                "description": "Update the display name of an existing palace. The palace's drawers, vectors, and knowledge graph are preserved; only the human-readable name changes.",
404                "inputSchema": {
405                    "type": "object",
406                    "properties": {
407                        "palace_id": {"type": "string", "description": "Id of the palace to rename."},
408                        "name":      {"type": "string", "description": "New display name. Trimmed; must be non-empty."}
409                    },
410                    "required": ["palace_id", "name"]
411                }
412            },
413            {
414                "name": "kg_assert",
415                "description": "Assert a fact in the temporal knowledge graph.",
416                "inputSchema": {
417                    "type": "object",
418                    "properties": {
419                        "palace":     {"type": "string"},
420                        "subject":    {"type": "string"},
421                        "predicate":  {"type": "string"},
422                        "object":     {"type": "string"},
423                        "confidence": {"type": "number", "default": 1.0},
424                        "provenance": {"type": "string"}
425                    },
426                    "required": kg_assert_required,
427                }
428            },
429            {
430                "name": "kg_query",
431                "description": "Query active knowledge-graph triples for a subject.",
432                "inputSchema": {
433                    "type": "object",
434                    "properties": {
435                        "palace":  {"type": "string"},
436                        "subject": {"type": "string"}
437                    },
438                    "required": kg_query_required,
439                }
440            },
441            {
442                "name": "memory_list",
443                "description": "List drawers in a palace, optionally filtered by room type or tag.",
444                "inputSchema": {
445                    "type": "object",
446                    "properties": {
447                        "palace": {"type": "string"},
448                        "room":   {"type": "string", "description": "Filter by room type (Frontend, Backend, Testing, Planning, Documentation, Research, Configuration, Meetings, General, or custom)"},
449                        "tag":    {"type": "string", "description": "Filter by tag"},
450                        "limit":  {"type": "integer", "description": "Max results (default 50)"}
451                    },
452                    "required": memory_list_required,
453                }
454            },
455            {
456                "name": "memory_forget",
457                "description": "Delete a drawer from a palace by its UUID.",
458                "inputSchema": {
459                    "type": "object",
460                    "properties": {
461                        "palace":    {"type": "string"},
462                        "drawer_id": {"type": "string", "description": "UUID of the drawer to delete"}
463                    },
464                    "required": memory_forget_required,
465                }
466            },
467            {
468                "name": "palace_info",
469                "description": "Get metadata and stats for a single palace.",
470                "inputSchema": {
471                    "type": "object",
472                    "properties": {
473                        "palace": {"type": "string"}
474                    },
475                    "required": palace_info_required,
476                }
477            },
478            {
479                "name": "palace_compact",
480                "description": "Remove orphaned vector index entries (vectors with no matching drawer row). See issue #49.",
481                "inputSchema": {
482                    "type": "object",
483                    "properties": {
484                        "palace": {"type": "string"}
485                    },
486                    "required": palace_compact_required,
487                }
488            },
489            {
490                "name": "add_alias",
491                "description": "Add a short→full alias (e.g. tga → trusty-git-analytics) to the prompt-facts surface. Asserts the alias as a hot KG triple and refreshes the session-init prompt cache.",
492                "inputSchema": {
493                    "type": "object",
494                    "properties": {
495                        "short": {"type": "string", "description": "Short name / alias (subject)"},
496                        "full":  {"type": "string", "description": "Full / canonical name (object)"},
497                        "extra": {"type": "string", "description": "Optional extra context appended to the full name"}
498                    },
499                    "required": ["short", "full"],
500                }
501            },
502            {
503                "name": "list_prompt_facts",
504                "description": "List every active prompt-fact triple (aliases, conventions, facts, shorthands) across all palaces.",
505                "inputSchema": {"type": "object", "properties": {}}
506            },
507            {
508                "name": "remove_prompt_fact",
509                "description": "Retract the active triple for a (subject, predicate) pair from the prompt-facts surface. Closes the interval without inserting a replacement.",
510                "inputSchema": {
511                    "type": "object",
512                    "properties": {
513                        "subject":   {"type": "string"},
514                        "predicate": {"type": "string", "description": "One of is_alias_for, has_convention, is_fact, is_shorthand_for"}
515                    },
516                    "required": ["subject", "predicate"],
517                }
518            },
519            {
520                "name": "get_prompt_context",
521                "description": "Fetch the current project context (aliases, conventions, facts, shorthands) from the memory palace as a Markdown block ready to drop into the model's working context. Call at the start of each turn. Pass an optional `query` to filter to facts whose subject or object contains the query string (case-insensitive).",
522                "inputSchema": {
523                    "type": "object",
524                    "properties": {
525                        "query": {
526                            "type": "string",
527                            "description": "Optional filter — only return facts whose subject or object contains this string (case-insensitive). Omit to return all hot facts."
528                        }
529                    }
530                }
531            },
532            {
533                "name": "discover_aliases",
534                "description": "Auto-discover project aliases by scanning Cargo workspace members, binary names, first-letter abbreviations, and the git remote. Asserts any newly-discovered (short, is_alias_for, full) triples into the resolved palace and rebuilds the prompt cache. Skips triples that already exist active in the KG.",
535                "inputSchema": {
536                    "type": "object",
537                    "properties": {
538                        "project_root": {"type": "string", "description": "Optional filesystem path to scan. Defaults to the process cwd."}
539                    }
540                }
541            },
542            {
543                "name": "kg_gaps",
544                "description": "List knowledge gaps detected in the memory palace graph. Returns communities (clusters of related entities) with low internal density that may benefit from additional knowledge. Populated by the dream cycle; an empty list means no cycle has run yet.",
545                "inputSchema": {
546                    "type": "object",
547                    "properties": {
548                        "palace": {"type": "string", "description": "Palace name (optional, defaults to the active palace)"}
549                    }
550                }
551            },
552            {
553                "name": "kg_bootstrap",
554                "description": "Seed the knowledge graph from well-known project files (Cargo.toml, package.json, pyproject.toml, go.mod, CLAUDE.md, .git/config). Asserts structured triples (has_language, has_version, source_repo, ...) plus temporal metadata (created_at, bootstrapped_at). Idempotent: re-running refreshes bootstrapped_at without disturbing created_at. See issue #60.",
555                "inputSchema": {
556                    "type": "object",
557                    "properties": {
558                        "palace":       {"type": "string", "description": "Palace ID (optional if server started with --palace)"},
559                        "project_path": {"type": "string", "description": "Filesystem path to scan. Omit to scan the palace's own data dir (temporal metadata only)."}
560                    }
561                }
562            },
563            {
564                "name": "memory_recall_all",
565                "description": "Semantic search across ALL palaces simultaneously. Returns the top-k most relevant drawers ranked by similarity, regardless of which palace they belong to. Each result includes a `palace_id` field identifying its source.",
566                "inputSchema": {
567                    "type": "object",
568                    "properties": {
569                        "q":     {"type": "string", "description": "Free-text query"},
570                        "top_k": {"type": "integer", "default": 10},
571                        "deep":  {"type": "boolean", "default": false}
572                    },
573                    "required": ["q"],
574                }
575            },
576            {
577                "name": "memory_send_message",
578                "description": "Send an inter-project message (issue #99). Writes a tagged drawer into the recipient palace; the recipient's SessionStart hook picks it up via `trusty-memory inbox-check`. `to_palace` is the recipient repo slug (e.g. `trusty-tools`, `claude-mpm`). `from_palace` defaults to the calling project's cwd-derived slug when omitted.",
579                "inputSchema": {
580                    "type": "object",
581                    "properties": {
582                        "to_palace":   {"type": "string", "description": "Recipient palace id (repo slug)."},
583                        "purpose":     {"type": "string", "description": "Free-text purpose / category (e.g. `task`, `notify`, `reply`)."},
584                        "content":     {"type": "string", "description": "Message body — plain text, no length limit. Rendered into the recipient session as a Markdown block."},
585                        "from_palace": {"type": "string", "description": "Sender palace id (optional, defaults to cwd-derived slug)."}
586                    },
587                    "required": ["to_palace", "purpose", "content"],
588                }
589            }
590        ]
591    })
592}
593
594/// Reverse of `parse_room`: produce a stable label for KG `in-room`
595/// extraction.
596///
597/// Why: The auto-extractor wants the same friendly label the caller passed
598/// (`"Backend"`, `"General"`, …) so the graph stays consistent across
599/// remember calls regardless of how the MCP client spelled the argument.
600/// What: Returns the canonical enum-name string for the built-in variants
601/// and the inner string for `Custom`.
602/// Test: Indirect — `auto_kg_extraction_hooks_into_memory_remember`
603/// round-trips a known room label.
604pub(crate) fn room_label(room: &RoomType) -> Option<String> {
605    let label = match room {
606        RoomType::Frontend => "Frontend",
607        RoomType::Backend => "Backend",
608        RoomType::Testing => "Testing",
609        RoomType::Planning => "Planning",
610        RoomType::Documentation => "Documentation",
611        RoomType::Research => "Research",
612        RoomType::Configuration => "Configuration",
613        RoomType::Meetings => "Meetings",
614        RoomType::General => "General",
615        RoomType::Custom(s) => return Some(s.clone()),
616    };
617    Some(label.to_string())
618}
619
620/// Parse a `RoomType` from an optional string (`"Backend"`, `"Frontend"`,
621/// etc.) — falls back to `RoomType::General` when unset or unknown.
622///
623/// Why: MCP arguments are JSON; we accept the friendly enum-name forms so
624/// callers don't have to learn an internal serialization.
625/// What: Match-on-string returning the corresponding `RoomType`.
626/// Test: Indirectly via `dispatch_remember_then_recall`.
627fn parse_room(s: Option<&str>) -> RoomType {
628    match s.unwrap_or("General") {
629        "Frontend" => RoomType::Frontend,
630        "Backend" => RoomType::Backend,
631        "Testing" => RoomType::Testing,
632        "Planning" => RoomType::Planning,
633        "Documentation" => RoomType::Documentation,
634        "Research" => RoomType::Research,
635        "Configuration" => RoomType::Configuration,
636        "Meetings" => RoomType::Meetings,
637        "General" => RoomType::General,
638        other => RoomType::Custom(other.to_string()),
639    }
640}
641
642/// Resolve (or lazily open) the palace handle for a tool call.
643fn open_palace_handle(
644    state: &AppState,
645    palace_id: &str,
646) -> Result<std::sync::Arc<trusty_common::memory_core::PalaceHandle>> {
647    let pid = PalaceId::new(palace_id);
648    state
649        .registry
650        .open_palace(&state.data_root, &pid)
651        .with_context(|| format!("open palace {palace_id}"))
652}
653
654/// Run deterministic KG extraction over a freshly-written drawer and assert
655/// any resulting triples through the palace's `KnowledgeGraph`.
656///
657/// Why: Issue #97 — `memory_remember` and `memory_note` should auto-populate
658/// the KG so palaces with drawers always have a graph. The extractor is pure
659/// and offline so the write hot path stays fast; failures *must never* fail
660/// the parent write (the drawer is already on disk), so this function logs
661/// and swallows every error.
662/// What: Builds an `ExtractInput`, runs `extract_triples`, then calls
663/// `handle.kg.assert` for each triple. Any failure during assertion is
664/// captured as a `tracing::warn!` and the rest of the triples are still
665/// attempted; the function returns nothing.
666/// Test: `auto_kg_extraction_hooks_into_memory_remember`,
667/// `auto_kg_extraction_no_op_does_not_fail_remember`,
668/// `web::tests::http_create_drawer_runs_auto_kg_extraction`.
669pub(crate) async fn auto_extract_and_assert(
670    handle: &std::sync::Arc<trusty_common::memory_core::PalaceHandle>,
671    drawer_id: Uuid,
672    content: &str,
673    tags: &[String],
674    room: Option<&str>,
675) {
676    let input = ExtractInput {
677        drawer_id,
678        content,
679        tags,
680        room,
681    };
682    let triples = extract_triples(&input);
683    if triples.is_empty() {
684        return;
685    }
686    for triple in triples {
687        let s = triple.subject.clone();
688        let p = triple.predicate.clone();
689        if let Err(e) = handle.kg.assert(triple).await {
690            tracing::warn!(
691                drawer_id = %drawer_id,
692                subject = %s,
693                predicate = %p,
694                "auto kg extraction: assert failed (non-fatal): {e:#}",
695            );
696        }
697    }
698}
699
700/// Resolve a palace argument, falling back to `state.default_palace` when
701/// the caller omitted `palace`.
702///
703/// Why: `serve --palace <name>` lets the operator bind a process to a single
704/// project namespace; tool calls then no longer need to repeat the palace
705/// every time. This helper centralises the precedence rule (explicit arg
706/// wins over default) and produces a uniform error when neither is set.
707/// What: Returns the explicit `args["palace"]` string if present, otherwise
708/// `state.default_palace`. Errors with a helpful message if both are absent.
709/// Test: `default_palace_used_when_arg_omitted` and
710/// `dispatch_unknown_tool_errors`.
711fn resolve_palace<'a>(state: &'a AppState, args: &'a Value, tool: &str) -> Result<String> {
712    if let Some(p) = args.get("palace").and_then(|v| v.as_str()) {
713        return Ok(p.to_string());
714    }
715    state
716        .default_palace
717        .clone()
718        .ok_or_else(|| anyhow!("{tool}: missing 'palace' (no --palace default configured)"))
719}
720
721/// Inputs to the shared write-drawer pipeline.
722///
723/// Why (issue #227): `memory_remember` and `memory_note` share the same
724/// "open palace → write drawer → fan-out side effects" tail. Capturing those
725/// inputs in one struct keeps the handler call sites flat and makes the
726/// shared pipeline a single function — every behavioural divergence between
727/// the two tools is now visible in their handlers, not buried in a
728/// 60-line block of duplicated post-write fan-out.
729/// What: bundles every value the post-gate pipeline needs. `room_label_for_kg`
730/// is pre-computed by the handler (memory_note pins it to `"General"`;
731/// memory_remember derives it from `RoomType` via [`room_label`]).
732/// Test: exercised end-to-end by `dispatch_remember_then_recall`,
733/// `dispatch_remember_with_context_writes_combined`, and the note tests.
734struct WriteDrawerParams<'a> {
735    palace_id: &'a str,
736    content: String,
737    tags: Vec<String>,
738    room: RoomType,
739    importance: f32,
740    opts: RememberOptions,
741    room_label_for_kg: Option<String>,
742}
743
744/// Run the shared write pipeline after content has been gated and attribution
745/// applied.
746///
747/// Why (issue #227): centralises the open-palace → remember → BM25 → emit →
748/// auto-KG-extract tail that `memory_remember` and `memory_note` both run.
749/// Hosting it in one place keeps the side-effect ordering identical across
750/// the two tools and makes future write-side hooks land in one location.
751/// What: opens the palace handle, calls `remember_with_options`, fires the
752/// BM25 index task, emits `DrawerAdded` + the aggregate status event, and
753/// runs the auto-KG-extraction pass (best-effort). Returns the new drawer
754/// id on success; any underlying error propagates via `anyhow::Result`.
755/// Test: covered through `dispatch_remember_then_recall`,
756/// `dispatch_remember_with_context_writes_combined`,
757/// `dispatch_note_skips_short_no_context` (negative path before this runs),
758/// and `auto_kg_extraction_hooks_into_memory_remember`.
759async fn write_drawer(state: &AppState, params: WriteDrawerParams<'_>) -> Result<Uuid> {
760    let WriteDrawerParams {
761        palace_id,
762        content,
763        tags,
764        room,
765        importance,
766        opts,
767        room_label_for_kg,
768    } = params;
769
770    let handle = open_palace_handle(state, palace_id)?;
771    // Snapshot the preview before `content` is moved into the write so the
772    // activity feed shows what landed on disk (matches the HTTP path).
773    let preview = crate::service::drawer_content_preview(&content);
774    // Issue #97: keep originals so the auto-KG extractor sees the same
775    // content / tags that landed in the drawer. `remember_with_options`
776    // consumes them, so clone before the call.
777    let content_for_kg = content.clone();
778    let tags_for_kg = tags.clone();
779    let drawer_id = handle
780        .remember_with_options(content, room, tags, importance, opts)
781        .await
782        .context("PalaceHandle::remember_with_options")?;
783    // Issue #156 + #231: opt-in BM25 lexical lane. Enqueue onto the
784    // bounded indexer channel so the redb write returns immediately;
785    // a full queue is dropped + logged rather than allowed to grow
786    // unbounded behind a slow daemon (#231). Daemon errors observed
787    // by the worker are logged but never block the MCP response.
788    bm25_index_enqueue(state, palace_id, drawer_id, &content_for_kg);
789    // Issue #96: emit a DrawerAdded so the activity feed shows
790    // MCP-origin writes with `source = Mcp`.
791    let palace_name = lookup_palace_name(state, palace_id);
792    let drawer_count = handle.drawers.read().len();
793    state.emit(DaemonEvent::DrawerAdded {
794        palace_id: palace_id.to_string(),
795        palace_name,
796        drawer_count,
797        timestamp: chrono::Utc::now(),
798        content_preview: preview,
799        source: ActivitySource::Mcp,
800    });
801    // Issue #228: do NOT emit `StatusChanged` on every write — the
802    // aggregate-recompute was O(N palaces) of disk I/O on the hot path.
803    // The periodic ticker spawned by `run_http_on` refreshes dashboard
804    // totals on a fixed cadence; mutations themselves still surface via
805    // the `DrawerAdded` SSE frame above.
806    // Issue #97: best-effort auto-extraction. Failures never fail the
807    // write — the drawer is already on disk.
808    auto_extract_and_assert(
809        &handle,
810        drawer_id,
811        &content_for_kg,
812        &tags_for_kg,
813        room_label_for_kg.as_deref(),
814    )
815    .await;
816    Ok(drawer_id)
817}
818
819/// Build a JSON "skipped" envelope used by both write handlers when a gate
820/// rejects the input.
821///
822/// Why (issue #227): keeps the three skip reasons (`blocked pattern`,
823/// `short prompt, no context`, `duplicate within window`) emitted as a
824/// uniform shape so callers can parse the envelope without per-tool
825/// branching.
826/// What: returns `{"palace": <id>, "status": "skipped", "reason": <reason>}`.
827/// Test: exercised by `dispatch_remember_skips_short_no_context`,
828/// `dispatch_note_skips_short_no_context`,
829/// `dispatch_remember_blocks_blocklist_pattern`.
830fn skipped_envelope(palace_id: &str, reason: &str) -> Value {
831    json!({
832        "palace": palace_id,
833        "status": "skipped",
834        "reason": reason,
835    })
836}
837
838/// Extract a `tags` argument (JSON array of strings) into a `Vec<String>`.
839///
840/// Why: every write-side handler accepts an optional `tags` argument with
841/// identical shape; centralising the parse keeps the handlers focused on
842/// their tool-specific logic.
843/// What: returns the strings in order; non-string entries are silently
844/// dropped (matches pre-refactor behaviour).
845/// Test: covered indirectly by `dispatch_remember_then_recall` and
846/// `auto_kg_extraction_hooks_into_memory_remember`.
847fn parse_tags(args: &Value) -> Vec<String> {
848    args.get("tags")
849        .and_then(|v| v.as_array())
850        .map(|arr| {
851            arr.iter()
852                .filter_map(|t| t.as_str().map(|s| s.to_string()))
853                .collect()
854        })
855        .unwrap_or_default()
856}
857
858/// Attach the MCP attribution tags (`creator:*` and the bare-UUID session
859/// projection) to the caller-supplied tag list.
860///
861/// Why (Submission-logging Part B + issue #202): every MCP-origin drawer must
862/// carry the writer identity so the activity panel and audit logs can attribute
863/// the write. Issue #202 also projects a bare-UUID session tag into the
864/// reserved `creator:session=<first-8>` slot when present.
865/// What: appends the session-tag projection (when one is found in the input
866/// tags) then merges the canonical `CreatorInfo::new_self(MCP, Mcp)` into the
867/// vec. Mutates in place to match the original code path.
868/// Test: covered indirectly by `dispatch_remember_then_recall`.
869fn attach_mcp_attribution(tags: &mut Vec<String>) {
870    if let Some(session_tag) = session_tag_from_tags(tags) {
871        tags.push(session_tag);
872    }
873    CreatorInfo::new_self(MCP_CLIENT_NAME, CreatorSource::Mcp).merge_into(tags);
874}
875
876// ----------------------------------------------------------------------
877// Per-tool handlers (issue #227)
878//
879// Each `handle_*` function owns one MCP tool. Handlers parse their
880// arguments, apply tool-specific gates, then either return a `skipped`
881// envelope or delegate to `write_drawer` / the underlying registry call.
882// `dispatch_tool` is now a thin router.
883// ----------------------------------------------------------------------
884
885async fn handle_memory_remember(state: &AppState, args: Value) -> Result<Value> {
886    let palace = resolve_palace(state, &args, "memory_remember")?;
887    let palace = palace.as_str();
888    let raw_text = args
889        .get("text")
890        .and_then(|v| v.as_str())
891        .ok_or_else(|| anyhow!("memory_remember: missing 'text'"))?
892        .to_string();
893    // Issue #220: blocklist gate — silently drop content matching
894    // known low-value auto-capture patterns (e.g. `Tool use: Bash`,
895    // `Claude Code session ended: …`). Logged at debug so operators
896    // can audit when investigating missing writes.
897    if blocklist_gate(&raw_text) {
898        tracing::debug!(
899            palace = %palace,
900            "content gate: skipped (blocked pattern)",
901        );
902        return Ok(skipped_envelope(
903            palace,
904            "content gate: skipped (blocked pattern)",
905        ));
906    }
907    // Issue #215: content gate — drop very short standalone content
908    // unless the caller supplied a `context` wrapper. When skipped,
909    // return a success envelope with an explanatory status so the
910    // caller can see the write was a no-op without having to parse
911    // a custom error shape.
912    let ctx = args.get("context").and_then(|v| v.as_str());
913    let text = match content_gate(&raw_text, ctx) {
914        Some(t) => t,
915        None => {
916            return Ok(skipped_envelope(
917                palace,
918                "content gate: skipped (short prompt, no context)",
919            ));
920        }
921    };
922    let room = parse_room(args.get("room").and_then(|v| v.as_str()));
923    let mut tags = parse_tags(&args);
924    // Submission-logging Part B: attach `creator:*` attribution so
925    // every MCP-origin drawer carries the writer identity (client
926    // = `trusty-memory-mcp`, source = `mcp`, version + cwd of the
927    // MCP server process). Issue #202: also project a bare-UUID
928    // session tag (when present in the caller's tags) into the
929    // reserved `creator:session=<first-8>` slot so the activity
930    // panel can surface it without inspecting every tag.
931    attach_mcp_attribution(&mut tags);
932
933    let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
934
935    // Issue #230: serialise the dedup-check + write sequence per-palace
936    // so two concurrent identical writes can't both pass the gate. The
937    // lock is scoped to the palace id — writes to different palaces
938    // still run in parallel. The guard is held across the gate check
939    // and the `write_drawer` call so the redb write inside
940    // `remember_with_options` happens with the gate snapshot still
941    // visible to subsequent waiters.
942    let write_lock = state.palace_write_lock(palace);
943    let _write_guard = write_lock.lock().await;
944
945    // Issue #220: rolling dedup window — skip when a near-duplicate
946    // landed in the same palace within the last 5 minutes. The
947    // `force=true` operator override bypasses the gate so
948    // intentional re-writes are not silently dropped.
949    if !force {
950        let handle = open_palace_handle(state, palace)?;
951        if dedup_gate(&handle, &text) {
952            tracing::debug!(
953                palace = %palace,
954                "content gate: skipped (duplicate within window)",
955            );
956            return Ok(skipped_envelope(
957                palace,
958                "content gate: skipped (duplicate within window)",
959            ));
960        }
961    }
962    let room_label_for_kg = room_label(&room);
963    let drawer_id = write_drawer(
964        state,
965        WriteDrawerParams {
966            palace_id: palace,
967            content: text,
968            tags,
969            room,
970            importance: 0.5,
971            opts: mcp_remember_opts(force),
972            room_label_for_kg,
973        },
974    )
975    .await?;
976    Ok(json!({
977        "drawer_id": drawer_id.to_string(),
978        "palace": palace,
979        "status": "stored",
980    }))
981}
982
983async fn handle_memory_note(state: &AppState, args: Value) -> Result<Value> {
984    // Issue #61: curated short-fact shortcut. Bypasses the token
985    // threshold (so "User prefers snake_case" is accepted) but still
986    // applies noise-pattern rejects so the tool can't be used to
987    // smuggle in auto-capture garbage. Pinned `DrawerType::UserFact`
988    // and `importance = 1.0` so the entry surfaces in L1 essentials.
989    let palace = resolve_palace(state, &args, "memory_note")?;
990    let palace = palace.as_str();
991    let raw_content = args
992        .get("content")
993        .and_then(|v| v.as_str())
994        .ok_or_else(|| anyhow!("memory_note: missing 'content'"))?
995        .to_string();
996    // Issue #220: blocklist gate — silently drop content matching
997    // known low-value auto-capture patterns. Same filter as
998    // `memory_remember` so the gate is uniform across the write
999    // surface.
1000    if blocklist_gate(&raw_content) {
1001        tracing::debug!(
1002            palace = %palace,
1003            "content gate: skipped (blocked pattern)",
1004        );
1005        return Ok(skipped_envelope(
1006            palace,
1007            "content gate: skipped (blocked pattern)",
1008        ));
1009    }
1010    // Issue #215: same content gate as `memory_remember`. A `context`
1011    // arg can be passed to wrap a one-word answer; otherwise short
1012    // standalone content is silently dropped with an explanatory
1013    // status envelope.
1014    let ctx = args.get("context").and_then(|v| v.as_str());
1015    let content = match content_gate(&raw_content, ctx) {
1016        Some(c) => c,
1017        None => {
1018            return Ok(skipped_envelope(
1019                palace,
1020                "content gate: skipped (short prompt, no context)",
1021            ));
1022        }
1023    };
1024    let mut tags = parse_tags(&args);
1025    // Submission-logging Part B: same attribution as memory_remember.
1026    // Issue #202: project a bare-UUID session tag (when present)
1027    // into the reserved `creator:session=<first-8>` slot.
1028    attach_mcp_attribution(&mut tags);
1029    // Issue #230: serialise the dedup-check + write sequence per-palace
1030    // so two concurrent identical writes can't both pass the gate. The
1031    // lock is scoped to the palace id — writes to different palaces
1032    // still run in parallel. Held across the gate check and the
1033    // `write_drawer` call so the redb write inside
1034    // `remember_with_options` is visible to subsequent waiters before
1035    // they snapshot.
1036    let write_lock = state.palace_write_lock(palace);
1037    let _write_guard = write_lock.lock().await;
1038    // Issue #220: rolling dedup window — same gate as
1039    // `memory_remember`. `memory_note` has no `force` arg, so the
1040    // gate is unconditional: curated short-fact writes that happen
1041    // to duplicate an existing recent note are still skipped.
1042    {
1043        let handle = open_palace_handle(state, palace)?;
1044        if dedup_gate(&handle, &content) {
1045            tracing::debug!(
1046                palace = %palace,
1047                "content gate: skipped (duplicate within window)",
1048            );
1049            return Ok(skipped_envelope(
1050                palace,
1051                "content gate: skipped (duplicate within window)",
1052            ));
1053        }
1054    }
1055    // note() preset skips the token threshold; we keep the default
1056    // filter for noise patterns. No MCP-stricter min_tokens override
1057    // is needed because `enforce_min_tokens = false`.
1058    let drawer_id = write_drawer(
1059        state,
1060        WriteDrawerParams {
1061            palace_id: palace,
1062            content,
1063            tags,
1064            room: RoomType::General,
1065            importance: 1.0,
1066            opts: RememberOptions::note(),
1067            // memory_note is pinned to the General room; mirror that for
1068            // the KG extractor so the auto-extracted triples carry the
1069            // same room label as the drawer.
1070            room_label_for_kg: Some("General".to_string()),
1071        },
1072    )
1073    .await
1074    .context("PalaceHandle::remember_with_options (note)")?;
1075    Ok(json!({
1076        "drawer_id": drawer_id.to_string(),
1077        "palace": palace,
1078        "status": "stored",
1079        "drawer_type": "UserFact",
1080    }))
1081}
1082
1083async fn handle_memory_recall(state: &AppState, args: Value) -> Result<Value> {
1084    let palace = resolve_palace(state, &args, "memory_recall")?;
1085    let query = args
1086        .get("query")
1087        .and_then(|v| v.as_str())
1088        .ok_or_else(|| anyhow!("memory_recall: missing 'query'"))?;
1089    let top_k = args.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
1090
1091    let handle = open_palace_handle(state, &palace)?;
1092    let embedder = state.embedder().await?;
1093    // Issue #156: when the BM25 lane is enabled, run it in parallel
1094    // with the vector recall and RRF-fuse the two ranked lists.
1095    // When the daemon is unavailable or the env var is unset, the
1096    // helper returns `None` and we return the vector-only results
1097    // verbatim — zero behavioural change for existing deployments.
1098    let vector_fut = recall(&handle, embedder.as_ref(), query, top_k);
1099    let bm25_fut = bm25_search_optional(state, &palace, query, top_k);
1100    let (vector_res, bm25_res) = tokio::join!(vector_fut, bm25_fut);
1101    let mut results = vector_res.context("recall")?;
1102    if let Some(bm25_hits) = bm25_res {
1103        fuse_bm25_into_recall(&mut results, &bm25_hits, top_k);
1104    }
1105    Ok(serialize_recall(&palace, query, results))
1106}
1107
1108async fn handle_memory_recall_deep(state: &AppState, args: Value) -> Result<Value> {
1109    let palace = resolve_palace(state, &args, "memory_recall_deep")?;
1110    let query = args
1111        .get("query")
1112        .and_then(|v| v.as_str())
1113        .ok_or_else(|| anyhow!("memory_recall_deep: missing 'query'"))?;
1114    let top_k = args.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
1115
1116    let handle = open_palace_handle(state, &palace)?;
1117    let embedder = state.embedder().await?;
1118    let results = recall_deep(&handle, embedder.as_ref(), query, top_k)
1119        .await
1120        .context("recall_deep")?;
1121    Ok(serialize_recall(&palace, query, results))
1122}
1123
1124async fn handle_palace_create(state: &AppState, args: Value) -> Result<Value> {
1125    let palace_name = args
1126        .get("name")
1127        .and_then(|v| v.as_str())
1128        .ok_or_else(|| anyhow!("palace_create: missing 'name'"))?;
1129
1130    // Issue #88 / Change 2: enforce palace = project mapping. New palaces must
1131    // be named after the current project slug (derived by walking up from CWD)
1132    // or the special `personal` sentinel. Existing palaces are unaffected —
1133    // this gate only applies to NEW creation requests.
1134    //
1135    // The validation cwd is, in order of preference:
1136    //   a. `args["cwd"]` — the MCP caller's project path. When present and the
1137    //      project has a `.trusty-tools/trusty-memory.yaml` pin file, the
1138    //      pinned slug is used for validation (correct even after a drive reorg).
1139    //   b. `std::env::current_dir()` — daemon's own cwd, pre-Change-2 fallback.
1140    //
1141    // Skip enforcement when invoked from a test context (tests use arbitrary
1142    // names against tempdir roots that are not real projects). The bypass is
1143    // keyed on an env var (`TRUSTY_SKIP_PALACE_ENFORCEMENT=1`) that tests set
1144    // locally; production deployments never set it.
1145    let skip_enforcement = std::env::var("TRUSTY_SKIP_PALACE_ENFORCEMENT").as_deref() == Ok("1");
1146    if !skip_enforcement {
1147        let cwd = args
1148            .get("cwd")
1149            .and_then(|v| v.as_str())
1150            .filter(|s| !s.is_empty())
1151            .map(std::path::Path::new)
1152            .map(|p| p.to_path_buf())
1153            .or_else(|| std::env::current_dir().ok())
1154            .unwrap_or_else(|| state.data_root.clone());
1155        crate::project_root::validate_palace_name(palace_name, &cwd)?;
1156    }
1157
1158    let description = args
1159        .get("description")
1160        .and_then(|v| v.as_str())
1161        .map(|s| s.to_string());
1162    let palace = Palace {
1163        id: PalaceId::new(palace_name),
1164        name: palace_name.to_string(),
1165        description,
1166        created_at: chrono::Utc::now(),
1167        data_dir: state.data_root.join(palace_name),
1168    };
1169    let _handle = state
1170        .registry
1171        .create_palace(&state.data_root, palace)
1172        .context("create_palace")?;
1173    // Issue #228: keep the in-memory palace-name cache in sync so
1174    // subsequent writes can resolve the friendly name without a disk
1175    // walk. The id == name pairing matches what the registry persisted.
1176    state
1177        .palace_names
1178        .insert(palace_name.to_string(), palace_name.to_string());
1179    // Issue #96: emit so MCP-driven palace creation lands in the
1180    // dashboard activity feed alongside HTTP-origin creates.
1181    state.emit(DaemonEvent::PalaceCreated {
1182        id: palace_name.to_string(),
1183        name: palace_name.to_string(),
1184        source: ActivitySource::Mcp,
1185    });
1186    // Issue #60: auto-seed the KG with temporal metadata so every
1187    // new palace has at least `created_at` + `bootstrapped_at`
1188    // triples anchored to the palace name. We deliberately do NOT
1189    // pass a project_path here — that requires an explicit user
1190    // decision (which directory belongs to this palace?). Failures
1191    // are non-fatal: the palace was already created, and the user
1192    // can re-run `kg_bootstrap` manually if needed.
1193    let bootstrap_summary = match crate::bootstrap::bootstrap_palace(state, palace_name, None).await
1194    {
1195        Ok(r) => Some(serde_json::json!({
1196            "triples_asserted": r.triples_asserted,
1197            "project_subject": r.project_subject,
1198        })),
1199        Err(e) => {
1200            tracing::warn!(
1201                palace = %palace_name,
1202                "auto-bootstrap on palace_create failed: {e:#}",
1203            );
1204            None
1205        }
1206    };
1207    Ok(json!({
1208        "palace_id": palace_name,
1209        "status": "created",
1210        "bootstrap": bootstrap_summary,
1211    }))
1212}
1213
1214async fn handle_palace_list(state: &AppState, _args: Value) -> Result<Value> {
1215    let root = state.data_root.clone();
1216    let palaces = tokio::task::spawn_blocking(move || {
1217        trusty_common::memory_core::PalaceRegistry::list_palaces(&root)
1218    })
1219    .await
1220    .context("join list_palaces")??;
1221    let ids: Vec<String> = palaces.iter().map(|p| p.id.as_str().to_string()).collect();
1222    Ok(json!({ "palaces": ids }))
1223}
1224
1225async fn handle_palace_delete(state: &AppState, args: Value) -> Result<Value> {
1226    // Issue #180: full palace teardown. The HTTP layer is the
1227    // canonical implementation; we just delegate to the same
1228    // `MemoryService::delete_palace` method to keep behaviour
1229    // (and the conflict / not-found / 204 split) identical
1230    // across surfaces. ServiceError variants are folded into
1231    // anyhow here so the MCP wire shape matches every other
1232    // tool's error contract.
1233    let palace_id = args
1234        .get("palace_id")
1235        .and_then(|v| v.as_str())
1236        .ok_or_else(|| anyhow!("palace_delete: missing 'palace_id'"))?
1237        .to_string();
1238    let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
1239    use crate::service::{MemoryService, ServiceError};
1240    let svc = MemoryService::new(state.clone());
1241    match svc.delete_palace(&palace_id, force).await {
1242        Ok(()) => Ok(json!({ "deleted": palace_id })),
1243        Err(ServiceError::NotFound(_)) => Err(anyhow!("Palace not found: {palace_id}")),
1244        Err(ServiceError::Conflict(msg)) => Err(anyhow!(msg)),
1245        Err(e) => Err(anyhow!("palace_delete: {e}")),
1246    }
1247}
1248
1249async fn handle_palace_update(state: &AppState, args: Value) -> Result<Value> {
1250    // Issue #180 follow-up: rename a palace's display name. The HTTP
1251    // layer is the canonical implementation; we delegate to the
1252    // same `MemoryService::update_palace_name` so the
1253    // load-mutate-save-emit chain stays consistent across surfaces.
1254    // The MCP wire shape is the minimal acknowledgement payload —
1255    // callers needing the enriched palace info should use
1256    // `palace_info` (or the HTTP endpoint, which returns the full
1257    // shape).
1258    let palace_id = args
1259        .get("palace_id")
1260        .and_then(|v| v.as_str())
1261        .ok_or_else(|| anyhow!("palace_update: missing 'palace_id'"))?
1262        .to_string();
1263    let name = args
1264        .get("name")
1265        .and_then(|v| v.as_str())
1266        .ok_or_else(|| anyhow!("palace_update: missing 'name'"))?
1267        .to_string();
1268    use crate::service::MemoryService;
1269    let svc = MemoryService::new(state.clone());
1270    match svc.update_palace_name(&palace_id, &name).await {
1271        Ok(_info) => Ok(json!({ "updated": palace_id, "name": name.trim() })),
1272        Err(e) => Err(anyhow!("palace_update: {e}")),
1273    }
1274}
1275
1276async fn handle_kg_assert(state: &AppState, args: Value) -> Result<Value> {
1277    let palace = resolve_palace(state, &args, "kg_assert")?;
1278    let palace = palace.as_str();
1279    let subject = args
1280        .get("subject")
1281        .and_then(|v| v.as_str())
1282        .ok_or_else(|| anyhow!("kg_assert: missing 'subject'"))?
1283        .to_string();
1284    let predicate = args
1285        .get("predicate")
1286        .and_then(|v| v.as_str())
1287        .ok_or_else(|| anyhow!("kg_assert: missing 'predicate'"))?
1288        .to_string();
1289    let object = args
1290        .get("object")
1291        .and_then(|v| v.as_str())
1292        .ok_or_else(|| anyhow!("kg_assert: missing 'object'"))?
1293        .to_string();
1294    let confidence = args
1295        .get("confidence")
1296        .and_then(|v| v.as_f64())
1297        .map(|c| (c as f32).clamp(0.0, 1.0))
1298        .unwrap_or(1.0);
1299    let provenance = args
1300        .get("provenance")
1301        .and_then(|v| v.as_str())
1302        .map(|s| s.to_string());
1303
1304    let handle = open_palace_handle(state, palace)?;
1305    let triple = Triple {
1306        subject,
1307        predicate,
1308        object,
1309        valid_from: chrono::Utc::now(),
1310        valid_to: None,
1311        confidence,
1312        provenance,
1313    };
1314    let is_hot = crate::prompt_facts::is_hot_predicate(&triple.predicate);
1315    handle.kg.assert(triple).await.context("kg.assert")?;
1316    // Rebuild the prompt cache if this assertion touched a hot
1317    // predicate; otherwise the cache stays valid and we skip the
1318    // gather/format pass. Failures are logged but non-fatal — the
1319    // write succeeded, the cache is only a denormalisation.
1320    if is_hot {
1321        if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1322            tracing::warn!("rebuild_prompt_cache after kg_assert failed: {e:#}");
1323        }
1324    }
1325    Ok(json!({ "status": "asserted" }))
1326}
1327
1328async fn handle_add_alias(state: &AppState, args: Value) -> Result<Value> {
1329    let short = args
1330        .get("short")
1331        .and_then(|v| v.as_str())
1332        .ok_or_else(|| anyhow!("add_alias: missing 'short'"))?
1333        .to_string();
1334    let full = args
1335        .get("full")
1336        .and_then(|v| v.as_str())
1337        .ok_or_else(|| anyhow!("add_alias: missing 'full'"))?
1338        .to_string();
1339    let extra = args
1340        .get("extra")
1341        .and_then(|v| v.as_str())
1342        .map(|s| s.to_string());
1343
1344    // `add_alias` is bound to the default palace when configured;
1345    // otherwise it lands in whatever palace the caller names. This
1346    // mirrors `resolve_palace`'s rule but without the helpful error
1347    // — aliases are typically project-scoped via `--palace`.
1348    let palace = resolve_palace(state, &args, "add_alias")?;
1349    let handle = open_palace_handle(state, &palace)?;
1350    // Compose the object: "<full>" or "<full> (<extra>)".
1351    let object = match extra.as_deref() {
1352        Some(e) if !e.is_empty() => format!("{full} ({e})"),
1353        _ => full.clone(),
1354    };
1355    let triple = Triple {
1356        subject: short.clone(),
1357        predicate: "is_alias_for".to_string(),
1358        object,
1359        valid_from: chrono::Utc::now(),
1360        valid_to: None,
1361        confidence: 1.0,
1362        provenance: Some("add_alias".to_string()),
1363    };
1364    handle
1365        .kg
1366        .assert(triple)
1367        .await
1368        .context("kg.assert (alias)")?;
1369    if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1370        tracing::warn!("rebuild_prompt_cache after add_alias failed: {e:#}");
1371    }
1372    Ok(json!({ "asserted": true, "short": short, "full": full }))
1373}
1374
1375async fn handle_list_prompt_facts(state: &AppState, _args: Value) -> Result<Value> {
1376    let triples = crate::prompt_facts::gather_hot_triples(state).await?;
1377    let payload: Vec<Value> = triples
1378        .into_iter()
1379        .map(|(subject, predicate, object)| {
1380            json!({ "subject": subject, "predicate": predicate, "object": object })
1381        })
1382        .collect();
1383    Ok(json!({ "facts": payload }))
1384}
1385
1386async fn handle_remove_prompt_fact(state: &AppState, args: Value) -> Result<Value> {
1387    let subject = args
1388        .get("subject")
1389        .and_then(|v| v.as_str())
1390        .ok_or_else(|| anyhow!("remove_prompt_fact: missing 'subject'"))?
1391        .to_string();
1392    let predicate = args
1393        .get("predicate")
1394        .and_then(|v| v.as_str())
1395        .ok_or_else(|| anyhow!("remove_prompt_fact: missing 'predicate'"))?
1396        .to_string();
1397
1398    // The prompt-fact surface spans every palace, so try retracting
1399    // across all of them and report `true` if any palace closed an
1400    // active interval. This matches `list_prompt_facts`' scope so
1401    // round-tripping list→remove never silently no-ops because the
1402    // caller didn't name the right palace.
1403    let mut closed_total: usize = 0;
1404    for palace_id in state.registry.list() {
1405        if let Some(handle) = state.registry.get(&palace_id) {
1406            match handle.kg.retract(&subject, &predicate).await {
1407                Ok(n) => closed_total += n,
1408                Err(e) => tracing::warn!(
1409                    palace = %palace_id.as_str(),
1410                    "retract failed: {e:#}",
1411                ),
1412            }
1413        }
1414    }
1415    if closed_total > 0 {
1416        if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1417            tracing::warn!("rebuild_prompt_cache after remove_prompt_fact failed: {e:#}");
1418        }
1419        Ok(json!({ "removed": true, "closed": closed_total }))
1420    } else {
1421        Ok(json!({ "removed": false, "reason": "not found" }))
1422    }
1423}
1424
1425async fn handle_kg_query(state: &AppState, args: Value) -> Result<Value> {
1426    let palace = resolve_palace(state, &args, "kg_query")?;
1427    let subject = args
1428        .get("subject")
1429        .and_then(|v| v.as_str())
1430        .ok_or_else(|| anyhow!("kg_query: missing 'subject'"))?;
1431    let handle = open_palace_handle(state, &palace)?;
1432    let triples = handle
1433        .kg
1434        .query_active(subject)
1435        .await
1436        .context("kg.query_active")?;
1437    let payload: Vec<Value> = triples
1438        .iter()
1439        .map(|t| {
1440            json!({
1441                "subject": t.subject,
1442                "predicate": t.predicate,
1443                "object": t.object,
1444                "valid_from": t.valid_from.to_rfc3339(),
1445                "valid_to": t.valid_to.as_ref().map(|d| d.to_rfc3339()),
1446                "confidence": t.confidence,
1447                "provenance": t.provenance,
1448            })
1449        })
1450        .collect();
1451    // Issue #60: surface a hint when the requested subject has no
1452    // active triples so the model knows `kg_bootstrap` and
1453    // `kg_assert` exist. Empty payload is the only signal we have
1454    // at the per-subject query layer; that's the user-visible
1455    // "nothing here" case the hint is for.
1456    let mut response = json!({ "subject": subject, "triples": payload });
1457    if crate::bootstrap::is_kg_empty_for_subject(&triples) {
1458        response["hint"] = Value::String(crate::bootstrap::KG_EMPTY_HINT.to_string());
1459    }
1460    Ok(response)
1461}
1462
1463async fn handle_memory_list(state: &AppState, args: Value) -> Result<Value> {
1464    let palace = resolve_palace(state, &args, "memory_list")?;
1465    let handle = open_palace_handle(state, &palace)?;
1466    let room = args
1467        .get("room")
1468        .and_then(|v| v.as_str())
1469        .map(|s| parse_room(Some(s)));
1470    let tag = args
1471        .get("tag")
1472        .and_then(|v| v.as_str())
1473        .map(|s| s.to_string());
1474    let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
1475    let drawers = handle.list_drawers(room, tag, limit);
1476    let payload: Vec<Value> = drawers
1477        .iter()
1478        .map(|d| {
1479            json!({
1480                "drawer_id": d.id.to_string(),
1481                "content": d.content,
1482                "importance": d.importance,
1483                "tags": d.tags,
1484                "created_at": d.created_at.to_rfc3339(),
1485                "drawer_type": d.drawer_type.as_str(),
1486                "expires_at": d.expires_at.map(|t| t.to_rfc3339()),
1487            })
1488        })
1489        .collect();
1490    Ok(json!({ "palace": palace, "drawers": payload }))
1491}
1492
1493async fn handle_memory_forget(state: &AppState, args: Value) -> Result<Value> {
1494    let palace = resolve_palace(state, &args, "memory_forget")?;
1495    let drawer_id_str = args
1496        .get("drawer_id")
1497        .and_then(|v| v.as_str())
1498        .ok_or_else(|| anyhow!("memory_forget: missing 'drawer_id'"))?;
1499    let drawer_id = Uuid::parse_str(drawer_id_str)
1500        .map_err(|e| anyhow!("memory_forget: invalid drawer_id UUID: {e}"))?;
1501    let handle = open_palace_handle(state, &palace)?;
1502    handle.forget(drawer_id).await.context("forget")?;
1503    // Issue #96: emit so MCP-driven deletes are visible in the feed.
1504    let drawer_count = handle.drawers.read().len();
1505    state.emit(DaemonEvent::DrawerDeleted {
1506        palace_id: palace.clone(),
1507        drawer_count,
1508        source: ActivitySource::Mcp,
1509    });
1510    // Issue #228: skip the per-write `StatusChanged` emit — the ticker
1511    // handles aggregate roll-ups.
1512    Ok(json!({ "status": "deleted", "drawer_id": drawer_id_str, "palace": palace }))
1513}
1514
1515async fn handle_palace_info(state: &AppState, args: Value) -> Result<Value> {
1516    let palace = resolve_palace(state, &args, "palace_info")?;
1517    let handle = open_palace_handle(state, &palace)?;
1518    let drawer_count = handle.list_drawers(None, None, usize::MAX).len();
1519    let data_dir = handle
1520        .data_dir
1521        .as_ref()
1522        .map(|p| p.to_string_lossy().to_string());
1523    Ok(json!({
1524        "id": handle.id.as_str(),
1525        "name": handle.id.as_str(),
1526        "drawer_count": drawer_count,
1527        "data_dir": data_dir,
1528    }))
1529}
1530
1531async fn handle_palace_compact(state: &AppState, args: Value) -> Result<Value> {
1532    let palace = resolve_palace(state, &args, "palace_compact")?;
1533    let handle = open_palace_handle(state, &palace)?;
1534    // Use the live drawer table (sourced from SQLite at palace open) as
1535    // the authoritative valid-id set, then run the vector store's
1536    // synchronous compaction on a blocking thread.
1537    let valid_ids: std::collections::HashSet<Uuid> =
1538        handle.drawers.read().iter().map(|d| d.id).collect();
1539    let vector_store = handle.vector_store.clone();
1540    let res = tokio::task::spawn_blocking(move || vector_store.compact_orphans(&valid_ids))
1541        .await
1542        .context("join palace_compact")??;
1543    Ok(json!({
1544        "palace": palace,
1545        "total_checked": res.total_checked,
1546        "orphans_removed": res.orphans_removed,
1547        "index_size_before": res.index_size_before,
1548        "index_size_after": res.index_size_after,
1549    }))
1550}
1551
1552async fn handle_kg_gaps(state: &AppState, args: Value) -> Result<Value> {
1553    // Why (issue #53): Surface the cached community-detection output
1554    // so the model can plan exploration without re-running Louvain.
1555    // We deliberately do NOT recompute on the read path; the cache is
1556    // refreshed by the dream cycle.
1557    // What: Resolves the palace (explicit arg or daemon default),
1558    // validates it exists by opening the handle, and returns the
1559    // cached vec (an empty array when the dream cycle has not yet
1560    // populated it).
1561    // Test: `dispatch_kg_gaps_returns_cached`.
1562    let palace = resolve_palace(state, &args, "kg_gaps")?;
1563    // Ensure the palace exists; this also surfaces a useful error for
1564    // typos in the palace argument.
1565    let _handle = open_palace_handle(state, &palace)?;
1566    let pid = PalaceId::new(&palace);
1567    let cached = state.registry.get_gaps(&pid).unwrap_or_default();
1568    let payload: Vec<Value> = cached
1569        .into_iter()
1570        .map(|g| {
1571            json!({
1572                "entities": g.entities,
1573                "internal_density": g.internal_density,
1574                "external_bridges": g.external_bridges,
1575                "suggested_exploration": g.suggested_exploration,
1576            })
1577        })
1578        .collect();
1579    Ok(json!({ "palace": palace, "gaps": payload }))
1580}
1581
1582async fn handle_memory_recall_all(state: &AppState, args: Value) -> Result<Value> {
1583    let query = args
1584        .get("q")
1585        .and_then(|v| v.as_str())
1586        .ok_or_else(|| anyhow!("memory_recall_all: missing 'q'"))?;
1587    let top_k = args.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
1588    let deep = args.get("deep").and_then(|v| v.as_bool()).unwrap_or(false);
1589
1590    // List every palace on disk and open a handle for each. Palaces
1591    // that fail to open are skipped with a warning so a single bad
1592    // namespace cannot fail the whole fan-out.
1593    let root = state.data_root.clone();
1594    let palaces = tokio::task::spawn_blocking(move || {
1595        trusty_common::memory_core::PalaceRegistry::list_palaces(&root)
1596    })
1597    .await
1598    .context("join list_palaces")??;
1599
1600    let mut handles = Vec::with_capacity(palaces.len());
1601    for p in &palaces {
1602        match state.registry.open_palace(&state.data_root, &p.id) {
1603            Ok(h) => handles.push(h),
1604            Err(e) => {
1605                tracing::warn!(palace = %p.id, "memory_recall_all: open failed: {e:#}")
1606            }
1607        }
1608    }
1609
1610    let embedder = state.embedder().await?;
1611    let erased: std::sync::Arc<dyn trusty_common::memory_core::embed::Embedder + Send + Sync> =
1612        embedder;
1613    let results = recall_across_palaces(&handles, &erased, query, top_k, deep)
1614        .await
1615        .context("recall_across_palaces")?;
1616
1617    let payload: Vec<Value> = results
1618        .iter()
1619        .map(|r| {
1620            json!({
1621                "palace_id":  r.palace_id,
1622                "drawer_id":  r.result.drawer.id.to_string(),
1623                "content":    r.result.drawer.content,
1624                "importance": r.result.drawer.importance,
1625                "tags":       r.result.drawer.tags,
1626                "score":      r.result.score,
1627                "layer":      r.result.layer,
1628                "drawer_type": r.result.drawer.drawer_type.as_str(),
1629            })
1630        })
1631        .collect();
1632    Ok(json!({ "query": query, "results": payload }))
1633}
1634
1635async fn handle_get_prompt_context(state: &AppState, args: Value) -> Result<Value> {
1636    // Why (issue #42): the model calls this at the start of each
1637    // turn to pull aliases/conventions/facts into its working
1638    // context. A `query` filter lets it scope the result to just
1639    // the facts that matter for the current task — cheap on the
1640    // wire and keeps the prompt focused.
1641    // What: read-locks the cache once, clones the snapshot, then
1642    // releases the lock so the formatter runs without blocking
1643    // concurrent readers. When `query` is set we re-format a
1644    // filtered subset of the raw triples; otherwise we serve the
1645    // pre-formatted string directly.
1646    let query = args
1647        .get("query")
1648        .and_then(|v| v.as_str())
1649        .map(|s| s.trim().to_string())
1650        .filter(|s| !s.is_empty());
1651
1652    // Issue #229: tokio::sync::RwLock is async-aware — `.read()` returns a
1653    // future that resolves to the guard, so no poison handling is needed
1654    // (tokio locks are not poisoned by panics).
1655    let cache_snapshot = {
1656        let guard = state.prompt_context_cache.read().await;
1657        guard.clone()
1658    };
1659
1660    let body = if let Some(q) = query.as_deref() {
1661        let needle = q.to_lowercase();
1662        let filtered: Vec<(String, String, String)> = cache_snapshot
1663            .triples
1664            .into_iter()
1665            .filter(|(subject, _predicate, object)| {
1666                subject.to_lowercase().contains(&needle) || object.to_lowercase().contains(&needle)
1667            })
1668            .collect();
1669        let formatted = crate::prompt_facts::build_prompt_context(&filtered);
1670        if formatted.is_empty() {
1671            "No project context found matching your query.".to_string()
1672        } else {
1673            formatted
1674        }
1675    } else if cache_snapshot.formatted.is_empty() {
1676        "No prompt facts stored yet.".to_string()
1677    } else {
1678        cache_snapshot.formatted
1679    };
1680
1681    // Return the body as a bare JSON string so the MCP envelope's
1682    // `content[0].text` carries the formatted Markdown verbatim
1683    // (ready to paste into the model's working context) without an
1684    // extra `{"context": "..."}` wrapper that callers would have
1685    // to strip.
1686    Ok(Value::String(body))
1687}
1688
1689async fn handle_discover_aliases(state: &AppState, args: Value) -> Result<Value> {
1690    // Why (issue #42): Surface project shorthand automatically so the
1691    // model never has to be told `tga == trusty-git-analytics`. The
1692    // tool resolves a palace (default or argument), runs the
1693    // pure-discovery scanner against the requested root (or cwd),
1694    // checks each candidate against the palace's active KG, and
1695    // asserts only the new ones. The prompt cache is rebuilt once
1696    // at the end iff anything was actually asserted.
1697    // What: returns `{ discovered: [...], already_known: N, new: M }`
1698    // so callers can audit the delta.
1699    // Test: `dispatch_discover_aliases_inserts_new_and_dedupes`.
1700    let palace = resolve_palace(state, &args, "discover_aliases")?;
1701    let project_root = args
1702        .get("project_root")
1703        .and_then(|v| v.as_str())
1704        .map(std::path::PathBuf::from)
1705        .or_else(|| std::env::current_dir().ok())
1706        .ok_or_else(|| anyhow!("discover_aliases: no project_root and cwd unavailable"))?;
1707
1708    let discoveries = crate::discovery::discover_project_aliases(&project_root).await?;
1709
1710    let handle = open_palace_handle(state, &palace)?;
1711
1712    let mut already_known = 0usize;
1713    let mut newly_asserted = 0usize;
1714    let mut reported: Vec<Value> = Vec::with_capacity(discoveries.len());
1715
1716    for d in &discoveries {
1717        // Check active triples for the subject; if any matches the
1718        // same predicate + object, skip the assertion.
1719        let active = handle
1720            .kg
1721            .query_active(&d.short)
1722            .await
1723            .context("kg.query_active")?;
1724        let exists = active
1725            .iter()
1726            .any(|t| t.predicate == "is_alias_for" && t.object == d.full);
1727        if exists {
1728            already_known += 1;
1729            continue;
1730        }
1731
1732        let triple = Triple {
1733            subject: d.short.clone(),
1734            predicate: "is_alias_for".to_string(),
1735            object: d.full.clone(),
1736            valid_from: chrono::Utc::now(),
1737            valid_to: None,
1738            confidence: 1.0,
1739            provenance: Some(format!("discover_aliases:{}", d.source.as_str())),
1740        };
1741        handle
1742            .kg
1743            .assert(triple)
1744            .await
1745            .context("kg.assert (discover)")?;
1746        newly_asserted += 1;
1747        reported.push(json!({
1748            "short": d.short,
1749            "full": d.full,
1750            "source": d.source.as_str(),
1751        }));
1752    }
1753
1754    if newly_asserted > 0 {
1755        if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1756            tracing::warn!("rebuild_prompt_cache after discover_aliases failed: {e:#}");
1757        }
1758    }
1759
1760    Ok(json!({
1761        "discovered": reported,
1762        "already_known": already_known,
1763        "new": newly_asserted,
1764        "palace": palace,
1765    }))
1766}
1767
1768async fn handle_kg_bootstrap(state: &AppState, args: Value) -> Result<Value> {
1769    // Issue #60: scan well-known project files and seed the KG with
1770    // structured triples + temporal metadata. The handler resolves
1771    // the palace (explicit arg or daemon default) and forwards the
1772    // optional `project_path` to the bootstrap helper.
1773    let palace = resolve_palace(state, &args, "kg_bootstrap")?;
1774    let project_path = args
1775        .get("project_path")
1776        .and_then(|v| v.as_str())
1777        .map(std::path::PathBuf::from);
1778    let result = crate::bootstrap::bootstrap_palace(state, &palace, project_path.as_deref())
1779        .await
1780        .context("bootstrap_palace")?;
1781    // Rebuild the prompt cache: bootstrap can land hot predicates
1782    // (descriptions, language tags) that affect the prompt-facts
1783    // surface. Cache failures are non-fatal.
1784    if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1785        tracing::warn!("rebuild_prompt_cache after kg_bootstrap failed: {e:#}");
1786    }
1787    crate::bootstrap::result_to_json(&result)
1788}
1789
1790async fn handle_memory_send_message(state: &AppState, args: Value) -> Result<Value> {
1791    // Issue #99: inter-project messaging via palace memories.
1792    let to_palace = args
1793        .get("to_palace")
1794        .and_then(|v| v.as_str())
1795        .ok_or_else(|| anyhow!("memory_send_message: missing 'to_palace'"))?
1796        .to_string();
1797    let purpose = args
1798        .get("purpose")
1799        .and_then(|v| v.as_str())
1800        .ok_or_else(|| anyhow!("memory_send_message: missing 'purpose'"))?
1801        .to_string();
1802    let content = args
1803        .get("content")
1804        .and_then(|v| v.as_str())
1805        .ok_or_else(|| anyhow!("memory_send_message: missing 'content'"))?
1806        .to_string();
1807    // from_palace defaults to the explicit `from_palace` arg, then
1808    // the server's --palace default, then the cwd-derived slug.
1809    let from_palace = if let Some(s) = args.get("from_palace").and_then(|v| v.as_str()) {
1810        s.to_string()
1811    } else if let Some(d) = state.default_palace.clone() {
1812        d
1813    } else {
1814        crate::messaging::cwd_palace_slug()
1815            .context("memory_send_message: derive from_palace from cwd")?
1816    };
1817    let drawer_id = crate::messaging::send_message_to_palace(
1818        &state.registry,
1819        &state.data_root,
1820        &from_palace,
1821        &to_palace,
1822        &purpose,
1823        content,
1824        CreatorInfo::new_self(MCP_CLIENT_NAME, CreatorSource::Mcp),
1825    )
1826    .await
1827    .context("memory_send_message")?;
1828    Ok(json!({
1829        "drawer_id": drawer_id.to_string(),
1830        "from_palace": from_palace,
1831        "to_palace": to_palace,
1832        "purpose": purpose,
1833        "status": "sent",
1834    }))
1835}
1836
1837/// Dispatch a tool call by name to its real handler.
1838///
1839/// Why: Centralises the name → handler mapping; every handler now performs a
1840/// real read/write against the live `PalaceRegistry` instead of returning a
1841/// stub. After issue #227 the body is a thin router — every tool's logic
1842/// lives in its own `handle_*` function above so the dispatcher itself is
1843/// auditable at a glance.
1844/// What: Returns `Ok(Value)` on success, `Err` on unknown tool / bad args /
1845/// underlying failure.
1846/// Test: `dispatch_palace_create_persists`, `dispatch_remember_then_recall`,
1847/// `dispatch_kg_assert_then_query`, `dispatch_unknown_tool_errors`.
1848pub async fn dispatch_tool(state: &AppState, name: &str, args: Value) -> Result<Value> {
1849    match name {
1850        "memory_remember" => handle_memory_remember(state, args).await,
1851        "memory_note" => handle_memory_note(state, args).await,
1852        "memory_recall" => handle_memory_recall(state, args).await,
1853        "memory_recall_deep" => handle_memory_recall_deep(state, args).await,
1854        "palace_create" => handle_palace_create(state, args).await,
1855        "palace_list" => handle_palace_list(state, args).await,
1856        "palace_delete" => handle_palace_delete(state, args).await,
1857        "palace_update" => handle_palace_update(state, args).await,
1858        "kg_assert" => handle_kg_assert(state, args).await,
1859        "add_alias" => handle_add_alias(state, args).await,
1860        "list_prompt_facts" => handle_list_prompt_facts(state, args).await,
1861        "remove_prompt_fact" => handle_remove_prompt_fact(state, args).await,
1862        "kg_query" => handle_kg_query(state, args).await,
1863        "memory_list" => handle_memory_list(state, args).await,
1864        "memory_forget" => handle_memory_forget(state, args).await,
1865        "palace_info" => handle_palace_info(state, args).await,
1866        "palace_compact" => handle_palace_compact(state, args).await,
1867        "kg_gaps" => handle_kg_gaps(state, args).await,
1868        "memory_recall_all" => handle_memory_recall_all(state, args).await,
1869        "get_prompt_context" => handle_get_prompt_context(state, args).await,
1870        "discover_aliases" => handle_discover_aliases(state, args).await,
1871        "kg_bootstrap" => handle_kg_bootstrap(state, args).await,
1872        "memory_send_message" => handle_memory_send_message(state, args).await,
1873        other => anyhow::bail!("unknown tool: {other}"),
1874    }
1875}
1876
1877/// Per-palace BM25 data directory derived from the daemon's data root.
1878///
1879/// Why (issue #193): the spawn supervisor must hand the BM25 daemon a
1880/// data-dir argument so each palace's BM25 snapshot lives next to its
1881/// other palace data (redb, kg.db, embeddings) — not in a shared scratch
1882/// directory. The convention is `<data_root>/<palace>/bm25/`, which is
1883/// stable across daemon restarts and lets operators inspect the snapshot
1884/// file alongside everything else in the palace.
1885/// What: appends `<palace>/bm25` to the daemon's `data_root`. Pure path
1886/// arithmetic — no I/O. The supervisor itself creates the directory
1887/// before spawning the child.
1888/// Test: implicitly via the spawn supervisor's integration test.
1889fn bm25_data_dir_for_palace(state: &AppState, palace: &str) -> std::path::PathBuf {
1890    state.data_root.join(palace).join("bm25")
1891}
1892
1893/// Try to ensure the BM25 daemon for `palace` is running. Returns `true`
1894/// when the daemon is (now) reachable.
1895///
1896/// Why (issue #193): callers want a single yes/no — should I send a BM25
1897/// op to this palace right now? — without each having to thread the
1898/// supervisor's `Result` through every code path. When the supervisor
1899/// returns an error (binary not found, spawn rejected, socket never
1900/// appeared) we log and return `false` so the caller degrades to
1901/// vector-only behaviour, exactly as it did before #193 when the daemon
1902/// simply wasn't running.
1903/// What: when `state.bm25_supervisor` is `None`, returns `true` (the
1904/// caller falls back to the original "use the env-var-only socket path"
1905/// behaviour). When `Some`, delegates to `ensure_running` and treats any
1906/// error as a soft failure — the supervisor's logs explain why.
1907/// Test: covered indirectly by the spawn supervisor's unit tests and the
1908/// `bm25_supervisor_e2e` integration test.
1909async fn ensure_bm25_running_for_palace(state: &AppState, palace: &str) -> bool {
1910    let Some(supervisor) = state.bm25_supervisor.as_ref() else {
1911        // No supervisor — the client (if present) connects to whatever
1912        // socket happens to be live. This matches pre-#193 behaviour.
1913        return true;
1914    };
1915    let data_dir = bm25_data_dir_for_palace(state, palace);
1916    match supervisor.ensure_running(palace, &data_dir).await {
1917        Ok(_socket) => true,
1918        Err(e) => {
1919            tracing::warn!(
1920                palace = %palace,
1921                "bm25 supervisor could not start daemon (degrading to vector-only): {e:#}"
1922            );
1923            false
1924        }
1925    }
1926}
1927
1928/// Bounded-queue capacity for the BM25 index worker (issue #231).
1929///
1930/// Why: the previous fire-and-forget design called `tokio::spawn` for every
1931/// drawer write, so a burst of `memory_remember` / `memory_note` calls while
1932/// the BM25 daemon was slow or unreachable could grow an unbounded number of
1933/// in-flight tasks — silent unbounded memory growth and a DoS vector against
1934/// the runtime. A bounded mpsc channel caps how many index requests can be
1935/// queued at once; once full, additional requests are dropped with a `warn!`
1936/// rather than blocking or buffering forever.
1937/// What: an arbitrary "comfortable burst" capacity. 256 is large enough that
1938/// a normal flurry of writes never spills (and the BM25 daemon's RTT is
1939/// typically sub-ms on the loopback socket), but small enough that a wedged
1940/// daemon caps memory consumption at a few MB of queued payloads.
1941/// Test: implicitly covered by `bm25_index_enqueue` not panicking when the
1942/// channel is full and by `bm25_index_queue_drops_when_full` (added below).
1943pub const BM25_INDEX_QUEUE_CAPACITY: usize = 256;
1944
1945/// One pending BM25 index op enqueued by `memory_remember` / `memory_note`
1946/// for the per-`AppState` indexer worker to drain (issue #231).
1947///
1948/// Why: replacing the per-write `tokio::spawn` with a single long-lived
1949/// worker task requires a self-contained "do this index call" payload that
1950/// can travel through an mpsc channel without borrowing from `AppState`.
1951/// Capturing the palace, drawer id, and content here lets the worker
1952/// reconstruct the call without re-reading any state.
1953/// What: a plain owned-data struct. `Clone` is not derived — the worker
1954/// consumes each request exactly once.
1955/// Test: exercised end-to-end by `bm25_index_queue_drops_when_full` and
1956/// the integration tests in `trusty-bm25-daemon/tests/`.
1957#[derive(Debug)]
1958pub struct Bm25IndexRequest {
1959    /// Palace id whose daemon should index the drawer.
1960    pub palace: String,
1961    /// Drawer id (stringified) — the daemon uses this as the BM25 doc id.
1962    pub drawer_id: String,
1963    /// Drawer text content to index.
1964    pub content: String,
1965    /// On-disk data directory for the palace's BM25 daemon — passed to the
1966    /// spawn supervisor's `ensure_running` so the daemon writes its snapshot
1967    /// next to the rest of the palace's data.
1968    pub data_dir: std::path::PathBuf,
1969}
1970
1971/// Spawn the single long-lived BM25 indexer worker that drains
1972/// `bm25_index_rx` and forwards each request to the daemon (issue #231).
1973///
1974/// Why: previously every `memory_remember` / `memory_note` write spawned a
1975/// detached `tokio::task` that called the BM25 daemon — under a write burst
1976/// with a slow/unreachable daemon the unbounded task queue grew silently.
1977/// A single worker + bounded channel caps back-pressure: when the channel
1978/// is full, writers `try_send` instead of `send`, and a full queue causes
1979/// a logged drop rather than memory growth. The worker exits gracefully
1980/// once the last sender clone (held in `AppState`) is dropped.
1981/// What: takes ownership of the receiver and the optional BM25 client +
1982/// supervisor `Arc`s, then loops on `rx.recv().await`. For each request,
1983/// `ensure_running`s the per-palace daemon (logging + skipping on failure)
1984/// and calls `client.index()`. Errors are logged at `warn!` and dropped —
1985/// BM25 indexing is best-effort and the drawer is durable in redb regardless.
1986/// If `client` is `None` (env var not set at startup) the worker still runs
1987/// and silently drops every request, which keeps the channel drained.
1988/// Test: indirectly covered by the integration tests in
1989/// `trusty-bm25-daemon/tests/`; `bm25_index_queue_drops_when_full` covers the
1990/// back-pressure behaviour.
1991pub fn spawn_bm25_index_worker(
1992    mut rx: tokio::sync::mpsc::Receiver<Bm25IndexRequest>,
1993    client: Option<std::sync::Arc<trusty_common::bm25_client::Bm25Client>>,
1994    supervisor: Option<std::sync::Arc<crate::bm25_supervisor::Bm25Supervisor>>,
1995) {
1996    tokio::spawn(async move {
1997        while let Some(req) = rx.recv().await {
1998            // No client means the BM25 lane is disabled — drain the queue
1999            // (so senders never block) and silently drop every request.
2000            let Some(client) = client.as_ref() else {
2001                continue;
2002            };
2003            // Issue #193: try to start the daemon before the first index
2004            // call. If the supervisor returns an error we skip this op;
2005            // the daemon will be retried on the next request.
2006            if let Some(sup) = supervisor.as_ref() {
2007                if let Err(e) = sup.ensure_running(&req.palace, &req.data_dir).await {
2008                    tracing::warn!(
2009                        palace = %req.palace,
2010                        "bm25 supervisor failed to start daemon for index (non-fatal): {e:#}"
2011                    );
2012                    continue;
2013                }
2014            }
2015            if let Err(e) = client.index(&req.drawer_id, &req.content).await {
2016                tracing::warn!(
2017                    palace = %req.palace,
2018                    drawer_id = %req.drawer_id,
2019                    "bm25 daemon index failed (non-fatal): {e:#}"
2020                );
2021            }
2022        }
2023        tracing::debug!("bm25 index worker exiting (channel closed)");
2024    });
2025}
2026
2027/// Enqueue a BM25 index request onto the bounded indexer channel (issue
2028/// #231; supersedes the per-write `tokio::spawn` from issue #156).
2029///
2030/// Why: `memory_remember` / `memory_note` must return as fast as the redb
2031/// write completes; the daemon RTT must stay off the response path. Routing
2032/// each request through a bounded mpsc channel keeps that property *and*
2033/// caps in-flight indexing work — under a sustained burst with a slow daemon
2034/// the previous design grew an unbounded task queue, which #231 fixes here.
2035/// What: builds a `Bm25IndexRequest` from the caller's data and calls
2036/// `try_send` so the caller is never blocked. On `TrySendError::Full` we
2037/// log at `warn!` and drop the request — BM25 indexing is best-effort and
2038/// the drawer is durable in redb regardless of whether the BM25 lane saw it.
2039/// `TrySendError::Closed` shouldn't happen in practice (the worker holds the
2040/// receiver for the daemon's lifetime), but if it does we log at `debug!`
2041/// and continue — we never let a BM25 hiccup fail a write.
2042/// Test: `bm25_index_queue_drops_when_full` covers the full-queue branch.
2043fn bm25_index_enqueue(state: &AppState, palace: &str, drawer_id: Uuid, content: &str) {
2044    let req = Bm25IndexRequest {
2045        palace: palace.to_string(),
2046        drawer_id: drawer_id.to_string(),
2047        content: content.to_string(),
2048        data_dir: bm25_data_dir_for_palace(state, palace),
2049    };
2050    match state.bm25_index_tx.try_send(req) {
2051        Ok(()) => {}
2052        Err(tokio::sync::mpsc::error::TrySendError::Full(req)) => {
2053            tracing::warn!(
2054                palace = %req.palace,
2055                drawer_id = %req.drawer_id,
2056                "BM25 index queue full — skipping drawer {}",
2057                req.drawer_id
2058            );
2059        }
2060        Err(tokio::sync::mpsc::error::TrySendError::Closed(req)) => {
2061            tracing::debug!(
2062                palace = %req.palace,
2063                drawer_id = %req.drawer_id,
2064                "BM25 index queue closed — skipping drawer {}",
2065                req.drawer_id
2066            );
2067        }
2068    }
2069}
2070
2071/// Optional BM25 search lane used by `memory_recall` (issue #156).
2072///
2073/// Why: lets the recall handler join a BM25 future with the vector future
2074/// without sprinkling `if state.bm25_client.is_some()` checks across the
2075/// call site. Returning `Option<Vec<_>>` makes the "daemon unavailable"
2076/// branch explicit at the consumer.
2077/// What: returns `None` when the env-var-gated client is absent OR when the
2078/// daemon errors (treated as a graceful degradation — the caller falls back
2079/// to vector-only results). Otherwise ensures the daemon is running via the
2080/// spawn supervisor (issue #193), then returns the BM25 hits the daemon
2081/// served. `top_k` is forwarded verbatim.
2082/// Test: integration coverage via the daemon's `tests/bm25_daemon.rs`; the
2083/// `None` path is covered by `bm25_client_disabled_by_default`.
2084async fn bm25_search_optional(
2085    state: &AppState,
2086    palace: &str,
2087    query: &str,
2088    top_k: usize,
2089) -> Option<Vec<trusty_common::bm25_client::BM25Hit>> {
2090    let client = state.bm25_client.as_ref()?;
2091    // Issue #193: spawn the daemon if it isn't already running. On error
2092    // we fall through to vector-only behaviour exactly as we did before
2093    // #193 when the operator forgot to start the daemon manually.
2094    if !ensure_bm25_running_for_palace(state, palace).await {
2095        return None;
2096    }
2097    match client.search(query, top_k).await {
2098        Ok(hits) => Some(hits),
2099        Err(e) => {
2100            tracing::warn!(
2101                palace = %palace,
2102                "bm25 daemon search failed (falling back to vector-only): {e:#}"
2103            );
2104            None
2105        }
2106    }
2107}
2108
2109/// Reciprocal Rank Fusion (RRF) blender for BM25 hits + vector recall hits.
2110///
2111/// Why: BM25 wins on identifier-heavy queries ("cargo test", "PalaceHandle"),
2112/// the vector lane wins on conceptual queries. RRF is the canonical fusion
2113/// because it is parameter-light, rank-only, and robust to scale differences
2114/// between the two lanes.
2115/// What: walks the BM25 ranked list once and adds `1 / (k + rank)` to the
2116/// matching drawer's vector score (RRF with `k = 60`, the IR-literature
2117/// default). Drawers that appear in BM25 but not in the vector list are
2118/// appended with `layer = 4` so the caller knows they came from the lexical
2119/// lane (L0/L1/L2/L3 are reserved). The combined list is re-sorted by score
2120/// desc and truncated to `top_k`.
2121/// Test: integration coverage via the daemon's `tests/bm25_daemon.rs` plus
2122/// downstream RRF behaviour observed end-to-end.
2123fn fuse_bm25_into_recall(
2124    results: &mut Vec<trusty_common::memory_core::retrieval::RecallResult>,
2125    bm25_hits: &[trusty_common::bm25_client::BM25Hit],
2126    top_k: usize,
2127) {
2128    /// RRF damping constant (Cormack et al. 2009). 60 is the literature
2129    /// default and what trusty-search uses in its hybrid pipeline.
2130    const RRF_K: f32 = 60.0;
2131    if bm25_hits.is_empty() {
2132        return;
2133    }
2134    // Boost existing vector hits whose drawer id appears in BM25.
2135    for (rank, hit) in bm25_hits.iter().enumerate() {
2136        let bonus = 1.0 / (RRF_K + rank as f32 + 1.0);
2137        if let Some(existing) = results
2138            .iter_mut()
2139            .find(|r| r.drawer.id.to_string() == hit.doc_id)
2140        {
2141            existing.score += bonus;
2142        }
2143        // BM25-only hits (those that don't appear in the vector list) are
2144        // intentionally NOT appended here — without hydrating the drawer
2145        // payload (content, tags, importance) from disk we cannot construct
2146        // a `RecallResult`, and the per-call disk walk would defeat the
2147        // whole purpose of the daemon. The hits that already appear in the
2148        // vector list still benefit from the RRF boost, which is enough to
2149        // improve identifier-heavy queries.
2150    }
2151    // Re-sort by score desc; preserve layer for tie-breaking (lower layer
2152    // wins because L0/L1 are pinned identity/essentials).
2153    results.sort_by(|a, b| {
2154        b.score
2155            .partial_cmp(&a.score)
2156            .unwrap_or(std::cmp::Ordering::Equal)
2157            .then(a.layer.cmp(&b.layer))
2158    });
2159    results.truncate(top_k);
2160}
2161
2162/// Serialize `recall` results into a JSON shape the MCP client can render.
2163fn serialize_recall(
2164    palace: &str,
2165    query: &str,
2166    results: Vec<trusty_common::memory_core::retrieval::RecallResult>,
2167) -> Value {
2168    let payload: Vec<Value> = results
2169        .iter()
2170        .map(|r| {
2171            json!({
2172                "drawer_id": r.drawer.id.to_string(),
2173                "content":   r.drawer.content,
2174                "score":     r.score,
2175                "layer":     r.layer,
2176                "tags":      r.drawer.tags,
2177                "importance": r.drawer.importance,
2178                "drawer_type": r.drawer.drawer_type.as_str(),
2179            })
2180        })
2181        .collect();
2182    json!({
2183        "palace": palace,
2184        "query": query,
2185        "results": payload,
2186    })
2187}
2188
2189#[cfg(test)]
2190mod tests {
2191    use super::*;
2192    use crate::AppState;
2193
2194    /// Why: Issue #234 — previously we `mem::forget`ed the `TempDir` so tests
2195    /// could keep using `AppState` without juggling the directory handle, but
2196    /// that leaked one temp directory per test (262+ accumulated each run).
2197    /// What: Returns the `TempDir` alongside the `AppState` so the caller can
2198    /// bind it (`let (state, _tmp) = ...;`) and let drop semantics clean up
2199    /// when the test scope ends.
2200    /// Test: Every test in this module that constructs state.
2201    ///
2202    /// Why (issue #88): sets `TRUSTY_SKIP_PALACE_ENFORCEMENT=1` so that
2203    /// existing tests that call `palace_create` with arbitrary names continue
2204    /// to work. The enforcement gate in `handle_palace_create` bypasses the
2205    /// project-slug check when this env var is set, which is the correct
2206    /// behaviour for test helpers that point at isolated tempdirs. Production
2207    /// processes never set this variable.
2208    fn test_state() -> (AppState, tempfile::TempDir) {
2209        // SAFETY: tests in this module run in-process; setting the bypass var
2210        // here races with any test that reads env before or after, but since
2211        // the value is "set to the same constant forever" once any test runs,
2212        // the race is benign — all tests should see "1" within the first
2213        // iteration. Tests that need stricter serialisation already use
2214        // `env_test_lock()`.
2215        unsafe {
2216            std::env::set_var("TRUSTY_SKIP_PALACE_ENFORCEMENT", "1");
2217        }
2218        let tmp = tempfile::tempdir().expect("tempdir");
2219        let root = tmp.path().to_path_buf();
2220        (AppState::new(root), tmp)
2221    }
2222
2223    /// Why: Issue #26 — when the server is started with `--palace`, the
2224    /// `tools/list` schema must drop `palace` from the `required` array for
2225    /// every tool that accepts it, so MCP clients know it's optional.
2226    /// Test: Build the schema both ways and check the required arrays.
2227    #[test]
2228    fn tool_definitions_drops_palace_required_when_default_set() {
2229        let with_default = tool_definitions_with(true);
2230        let without_default = tool_definitions_with(false);
2231        for (name, palace_required_when_no_default) in [
2232            ("memory_remember", true),
2233            ("memory_recall", true),
2234            ("memory_recall_deep", true),
2235            ("memory_list", true),
2236            ("memory_forget", true),
2237            ("palace_info", true),
2238            ("palace_compact", true),
2239            ("kg_assert", true),
2240            ("kg_query", true),
2241        ] {
2242            for (defs, has_default) in [(&with_default, true), (&without_default, false)] {
2243                let tools = defs["tools"].as_array().unwrap();
2244                let tool = tools.iter().find(|t| t["name"] == name).unwrap();
2245                let required: Vec<&str> = tool["inputSchema"]["required"]
2246                    .as_array()
2247                    .unwrap()
2248                    .iter()
2249                    .filter_map(|v| v.as_str())
2250                    .collect();
2251                let palace_required = required.contains(&"palace");
2252                let expected = palace_required_when_no_default && !has_default;
2253                assert_eq!(
2254                    palace_required, expected,
2255                    "tool={name} has_default={has_default} required={required:?}"
2256                );
2257            }
2258        }
2259    }
2260
2261    #[test]
2262    fn tool_definitions_lists_all_tools() {
2263        let defs = tool_definitions();
2264        let tools = defs
2265            .get("tools")
2266            .and_then(|t| t.as_array())
2267            .expect("tools array");
2268        assert_eq!(tools.len(), 23);
2269        let names: Vec<&str> = tools
2270            .iter()
2271            .filter_map(|t| t.get("name").and_then(|n| n.as_str()))
2272            .collect();
2273        for expected in [
2274            "memory_remember",
2275            "memory_note",
2276            "memory_recall",
2277            "memory_recall_deep",
2278            "memory_list",
2279            "memory_forget",
2280            "palace_create",
2281            "palace_delete",
2282            "palace_update",
2283            "palace_list",
2284            "palace_info",
2285            "palace_compact",
2286            "kg_assert",
2287            "kg_query",
2288            "memory_recall_all",
2289            "kg_gaps",
2290            "add_alias",
2291            "list_prompt_facts",
2292            "remove_prompt_fact",
2293            "get_prompt_context",
2294            "discover_aliases",
2295            "kg_bootstrap",
2296            "memory_send_message",
2297        ] {
2298            assert!(names.contains(&expected), "missing tool: {expected}");
2299        }
2300    }
2301
2302    /// Why: Confirm `palace_create` actually persists a palace under the
2303    /// configured data root and `palace_list` then sees it.
2304    #[tokio::test]
2305    async fn dispatch_palace_create_persists() {
2306        let (state, _tmp) = test_state();
2307        let created = dispatch_tool(&state, "palace_create", json!({"name": "alpha"}))
2308            .await
2309            .expect("palace_create");
2310        assert_eq!(created["palace_id"], "alpha");
2311
2312        let listed = dispatch_tool(&state, "palace_list", json!({}))
2313            .await
2314            .expect("palace_list");
2315        let ids = listed["palaces"].as_array().expect("palaces array");
2316        assert!(ids.iter().any(|v| v.as_str() == Some("alpha")));
2317    }
2318
2319    /// Why: End-to-end confirmation that a remembered drawer is recallable
2320    /// through the MCP tool surface using the real embedder + retrieval path.
2321    #[tokio::test]
2322    async fn dispatch_remember_then_recall() {
2323        let (state, _tmp) = test_state();
2324        let _ = dispatch_tool(&state, "palace_create", json!({"name": "beta"}))
2325            .await
2326            .expect("palace_create");
2327
2328        let remembered = dispatch_tool(
2329            &state,
2330            "memory_remember",
2331            json!({
2332                "palace": "beta",
2333                "text": "Quokkas are the happiest marsupials in Australia by general consensus",
2334                "room": "General",
2335                "tags": ["wildlife"],
2336            }),
2337        )
2338        .await
2339        .expect("memory_remember");
2340        assert!(remembered["drawer_id"].as_str().is_some());
2341
2342        let recalled = dispatch_tool(
2343            &state,
2344            "memory_recall",
2345            json!({"palace": "beta", "query": "Quokkas marsupials Australia", "top_k": 5}),
2346        )
2347        .await
2348        .expect("memory_recall");
2349        let results = recalled["results"].as_array().expect("results");
2350        assert!(
2351            results
2352                .iter()
2353                .any(|r| r["content"].as_str().unwrap_or("").contains("Quokkas")),
2354            "expected to recall the Quokkas drawer; got {results:?}"
2355        );
2356    }
2357
2358    /// Why: Issue #97 — `memory_remember` should auto-populate the KG so
2359    /// every drawer leaves a graph trail. Confirm a freshly remembered
2360    /// drawer leaves `has-tag`/`in-room`/`mentions` triples (using the
2361    /// tag-as-subject encoding) in the palace KG.
2362    /// What: Create a palace, write one drawer with known tags + room +
2363    /// recognisable pattern content, then read all active triples and
2364    /// assert the expected auto-extracted shapes show up.
2365    /// Test: This test.
2366    #[tokio::test]
2367    async fn auto_kg_extraction_hooks_into_memory_remember() {
2368        let (state, _tmp) = test_state();
2369        let _ = dispatch_tool(&state, "palace_create", json!({"name": "kgauto"}))
2370            .await
2371            .expect("palace_create");
2372
2373        let _ = dispatch_tool(
2374            &state,
2375            "memory_remember",
2376            json!({
2377                "palace": "kgauto",
2378                "text": "Rustc is a compiler for the Rust language; tracks #performance",
2379                "room": "Backend",
2380                "tags": ["compiler", "language"],
2381            }),
2382        )
2383        .await
2384        .expect("memory_remember");
2385
2386        let handle = open_palace_handle(&state, "kgauto").expect("open palace");
2387        let triples = handle.kg.list_active(1000, 0).await.expect("list_active");
2388        let auto: Vec<_> = triples
2389            .iter()
2390            .filter(|t| t.provenance.as_deref() == Some(crate::kg_extract::AUTO_PROVENANCE))
2391            .collect();
2392        assert!(
2393            !auto.is_empty(),
2394            "expected at least one auto-extracted triple after memory_remember; got: {triples:?}"
2395        );
2396        // Tag/room/topic encoding: each metadata category becomes its own
2397        // subject so multiple tags coexist under the KG's "one active
2398        // triple per (s, p)" invariant. Confirm both tags survive.
2399        assert!(
2400            auto.iter()
2401                .any(|t| t.subject == "tag:compiler" && t.predicate == "tags"),
2402            "expected tag:compiler edge in auto subset: {auto:?}"
2403        );
2404        assert!(
2405            auto.iter()
2406                .any(|t| t.subject == "tag:language" && t.predicate == "tags"),
2407            "expected tag:language edge in auto subset: {auto:?}"
2408        );
2409        assert!(
2410            auto.iter()
2411                .any(|t| t.subject == "room:Backend" && t.predicate == "contains"),
2412            "expected room:Backend edge in auto subset: {auto:?}"
2413        );
2414        assert!(
2415            auto.iter().any(|t| t.predicate == "mentioned-in"),
2416            "expected at least one #hashtag mention triple in auto subset: {auto:?}"
2417        );
2418    }
2419
2420    /// Why: Issue #97 — failures inside the auto-extraction pass must
2421    /// never fail the parent write. We can't easily inject a failure into
2422    /// the live `KnowledgeGraph::assert`, so this test exercises the
2423    /// documented contract by verifying the parent `memory_remember`
2424    /// succeeds even when the content produces zero auto-extracted triples
2425    /// (the closest natural no-op to "extraction failed").
2426    /// What: Remember a drawer with empty tags + minimal patternless
2427    /// content; confirm `memory_remember` returns a drawer id and no
2428    /// auto-extracted triples are emitted (the only built-in auto triples
2429    /// would have come from tags/room/hashtags/patterns).
2430    /// Test: This test.
2431    #[tokio::test]
2432    async fn auto_kg_extraction_no_op_does_not_fail_remember() {
2433        let (state, _tmp) = test_state();
2434        let _ = dispatch_tool(&state, "palace_create", json!({"name": "kgnoop"}))
2435            .await
2436            .expect("palace_create");
2437
2438        let res = dispatch_tool(
2439            &state,
2440            "memory_remember",
2441            json!({
2442                "palace": "kgnoop",
2443                // 8+ tokens to clear MCP_MIN_TOKENS; no tags, no room, no
2444                // hashtags, no pattern triggers.
2445                "text": "The quick brown fox jumped over the lazy dog repeatedly",
2446            }),
2447        )
2448        .await
2449        .expect("memory_remember should succeed even when extraction yields nothing");
2450        assert!(res["drawer_id"].as_str().is_some());
2451    }
2452
2453    /// Why: Confirm `kg_assert` writes a triple and `kg_query` returns it
2454    /// through the MCP tool surface.
2455    #[tokio::test]
2456    async fn dispatch_kg_assert_then_query() {
2457        let (state, _tmp) = test_state();
2458        let _ = dispatch_tool(&state, "palace_create", json!({"name": "gamma"}))
2459            .await
2460            .expect("palace_create");
2461
2462        let _ = dispatch_tool(
2463            &state,
2464            "kg_assert",
2465            json!({
2466                "palace": "gamma",
2467                "subject": "alice",
2468                "predicate": "works_at",
2469                "object": "Acme",
2470                "confidence": 0.9,
2471                "provenance": "test",
2472            }),
2473        )
2474        .await
2475        .expect("kg_assert");
2476
2477        let queried = dispatch_tool(
2478            &state,
2479            "kg_query",
2480            json!({"palace": "gamma", "subject": "alice"}),
2481        )
2482        .await
2483        .expect("kg_query");
2484        let triples = queried["triples"].as_array().expect("triples array");
2485        assert_eq!(triples.len(), 1);
2486        assert_eq!(triples[0]["object"], "Acme");
2487        assert_eq!(triples[0]["predicate"], "works_at");
2488    }
2489
2490    /// Why: Issue #53 — verify the MCP `kg_gaps` tool returns whatever was
2491    /// last cached on the registry. Two cases: empty cache returns an empty
2492    /// array, and a seeded cache returns the cached entries verbatim.
2493    /// What: Creates a palace, dispatches `kg_gaps` (expects empty), then
2494    /// directly seeds the registry cache via `set_gaps` and dispatches again
2495    /// to confirm the entry round-trips through serialization.
2496    /// Test: This test itself.
2497    #[tokio::test]
2498    async fn dispatch_kg_gaps_returns_cached() {
2499        use trusty_common::memory_core::community::KnowledgeGap;
2500
2501        let (state, _tmp) = test_state();
2502        let _ = dispatch_tool(&state, "palace_create", json!({"name": "delta"}))
2503            .await
2504            .expect("palace_create");
2505
2506        // Empty cache → empty gaps list (not an error).
2507        let initial = dispatch_tool(&state, "kg_gaps", json!({"palace": "delta"}))
2508            .await
2509            .expect("kg_gaps empty");
2510        let gaps = initial["gaps"].as_array().expect("gaps array");
2511        assert_eq!(gaps.len(), 0);
2512
2513        // Seed the cache and re-dispatch.
2514        state.registry.set_gaps(
2515            PalaceId::new("delta"),
2516            vec![KnowledgeGap {
2517                entities: vec!["x".to_string(), "y".to_string()],
2518                internal_density: 0.05,
2519                external_bridges: 0,
2520                suggested_exploration: "Explore connections between x and y".to_string(),
2521            }],
2522        );
2523        let seeded = dispatch_tool(&state, "kg_gaps", json!({"palace": "delta"}))
2524            .await
2525            .expect("kg_gaps seeded");
2526        let gaps = seeded["gaps"].as_array().expect("gaps array");
2527        assert_eq!(gaps.len(), 1);
2528        assert_eq!(gaps[0]["entities"][0], "x");
2529        assert_eq!(gaps[0]["external_bridges"], 0);
2530        assert!(gaps[0]["suggested_exploration"]
2531            .as_str()
2532            .unwrap()
2533            .contains("x"));
2534    }
2535
2536    /// Why: Issue #42 — `add_alias` must (a) assert the triple in the KG,
2537    /// (b) cause `list_prompt_facts` to surface it, (c) refresh the prompt
2538    /// cache so `prompts/get` returns it, and (d) be reversible via
2539    /// `remove_prompt_fact`.
2540    #[tokio::test]
2541    async fn add_alias_round_trip_through_prompt_cache() {
2542        // Issue #234: bind `_tmp` so the directory is cleaned up on drop at
2543        // end of scope (previously we leaked via `std::mem::forget`).
2544        let _tmp = tempfile::tempdir().expect("tempdir");
2545        let root = _tmp.path().to_path_buf();
2546        let state = AppState::new(root).with_default_palace(Some("ctx".to_string()));
2547
2548        // Pre-create the default palace.
2549        let _ = dispatch_tool(&state, "palace_create", json!({"name": "ctx"}))
2550            .await
2551            .expect("palace_create");
2552
2553        // (a) add_alias asserts the triple.
2554        let added = dispatch_tool(
2555            &state,
2556            "add_alias",
2557            json!({"short": "tga", "full": "trusty-git-analytics"}),
2558        )
2559        .await
2560        .expect("add_alias");
2561        assert_eq!(added["asserted"], true);
2562        assert_eq!(added["short"], "tga");
2563
2564        // (b) list_prompt_facts surfaces it.
2565        let listed = dispatch_tool(&state, "list_prompt_facts", json!({}))
2566            .await
2567            .expect("list_prompt_facts");
2568        let facts = listed["facts"].as_array().expect("facts array");
2569        assert!(
2570            facts.iter().any(|f| f["subject"] == "tga"
2571                && f["predicate"] == "is_alias_for"
2572                && f["object"] == "trusty-git-analytics"),
2573            "expected tga alias in facts; got {facts:?}"
2574        );
2575
2576        // (c) prompt cache has been refreshed with the formatted block.
2577        {
2578            let guard = state.prompt_context_cache.read().await;
2579            assert!(
2580                guard.formatted.contains("tga → trusty-git-analytics"),
2581                "prompt cache should contain alias; got: {}",
2582                guard.formatted
2583            );
2584        }
2585
2586        // add_alias with `extra` appends parenthetical context.
2587        let _ = dispatch_tool(
2588            &state,
2589            "add_alias",
2590            json!({"short": "tm", "full": "trusty-memory", "extra": "the MCP frontend"}),
2591        )
2592        .await
2593        .expect("add_alias with extra");
2594        {
2595            let guard = state.prompt_context_cache.read().await;
2596            assert!(
2597                guard
2598                    .formatted
2599                    .contains("tm → trusty-memory (the MCP frontend)"),
2600                "alias with extra not formatted; got: {}",
2601                guard.formatted
2602            );
2603        }
2604
2605        // (d) remove_prompt_fact retracts and refreshes.
2606        let removed = dispatch_tool(
2607            &state,
2608            "remove_prompt_fact",
2609            json!({"subject": "tga", "predicate": "is_alias_for"}),
2610        )
2611        .await
2612        .expect("remove_prompt_fact");
2613        assert_eq!(removed["removed"], true);
2614        {
2615            let guard = state.prompt_context_cache.read().await;
2616            assert!(
2617                !guard.formatted.contains("tga → trusty-git-analytics"),
2618                "retracted alias still in cache: {}",
2619                guard.formatted
2620            );
2621            assert!(
2622                guard.formatted.contains("tm → trusty-memory"),
2623                "non-retracted alias missing from cache: {}",
2624                guard.formatted
2625            );
2626        }
2627
2628        // Removing a non-existent fact reports not found.
2629        let missing = dispatch_tool(
2630            &state,
2631            "remove_prompt_fact",
2632            json!({"subject": "nope", "predicate": "is_alias_for"}),
2633        )
2634        .await
2635        .expect("remove_prompt_fact missing");
2636        assert_eq!(missing["removed"], false);
2637    }
2638
2639    /// Why (issue #42): `get_prompt_context` is the per-message replacement
2640    /// for the deprecated `prompts/get` flow. It must (a) return a hint when
2641    /// the cache is empty, (b) return the formatted block when populated,
2642    /// and (c) filter by `query` against subject/object case-insensitively.
2643    #[tokio::test]
2644    async fn get_prompt_context_serves_cache_and_filters() {
2645        let (state, _tmp) = test_state();
2646
2647        // (a) empty cache -> "No prompt facts stored yet."
2648        let resp = dispatch_tool(&state, "get_prompt_context", json!({}))
2649            .await
2650            .expect("get_prompt_context empty");
2651        assert_eq!(resp.as_str().unwrap(), "No prompt facts stored yet.");
2652
2653        // Populate the cache by hand with a known triple set.
2654        {
2655            let mut guard = state.prompt_context_cache.write().await;
2656            let triples = vec![
2657                (
2658                    "tga".to_string(),
2659                    "is_alias_for".to_string(),
2660                    "trusty-git-analytics".to_string(),
2661                ),
2662                (
2663                    "tm".to_string(),
2664                    "is_alias_for".to_string(),
2665                    "trusty-memory".to_string(),
2666                ),
2667                (
2668                    "fact-1".to_string(),
2669                    "is_fact".to_string(),
2670                    "MSRV is 1.88".to_string(),
2671                ),
2672            ];
2673            let formatted = crate::prompt_facts::build_prompt_context(&triples);
2674            *guard = crate::prompt_facts::PromptFactsCache { triples, formatted };
2675        }
2676
2677        // (b) unfiltered -> serves the full formatted block.
2678        let resp = dispatch_tool(&state, "get_prompt_context", json!({}))
2679            .await
2680            .expect("get_prompt_context populated");
2681        let text = resp.as_str().expect("string body");
2682        assert!(text.contains("tga → trusty-git-analytics"));
2683        assert!(text.contains("tm → trusty-memory"));
2684        assert!(text.contains("MSRV is 1.88"));
2685
2686        // (c) filtered to "tga" -> only the matching alias.
2687        let resp = dispatch_tool(&state, "get_prompt_context", json!({"query": "tga"}))
2688            .await
2689            .expect("get_prompt_context filtered");
2690        let text = resp.as_str().expect("string body");
2691        assert!(text.contains("tga → trusty-git-analytics"));
2692        assert!(!text.contains("tm → trusty-memory"));
2693        assert!(!text.contains("MSRV is 1.88"));
2694
2695        // Case-insensitive match on the object side.
2696        let resp = dispatch_tool(&state, "get_prompt_context", json!({"query": "MEMORY"}))
2697            .await
2698            .expect("get_prompt_context case-insensitive");
2699        let text = resp.as_str().expect("string body");
2700        assert!(text.contains("tm → trusty-memory"));
2701        assert!(!text.contains("tga → trusty-git-analytics"));
2702
2703        // No match -> "No project context found matching your query."
2704        let resp = dispatch_tool(
2705            &state,
2706            "get_prompt_context",
2707            json!({"query": "zzz-nonexistent"}),
2708        )
2709        .await
2710        .expect("get_prompt_context no-match");
2711        assert_eq!(
2712            resp.as_str().unwrap(),
2713            "No project context found matching your query."
2714        );
2715
2716        // Empty/whitespace `query` is treated as no filter.
2717        let resp = dispatch_tool(&state, "get_prompt_context", json!({"query": "   "}))
2718            .await
2719            .expect("get_prompt_context whitespace");
2720        let text = resp.as_str().expect("string body");
2721        assert!(text.contains("tga → trusty-git-analytics"));
2722        assert!(text.contains("tm → trusty-memory"));
2723    }
2724
2725    /// Why (issue #42): `discover_aliases` must (a) auto-discover the
2726    /// canonical workspace shorthand (`tga → trusty-git-analytics`),
2727    /// (b) assert each discovery as an `is_alias_for` triple, (c) refresh
2728    /// the prompt cache, and (d) dedupe on a second invocation — the second
2729    /// call should report zero new and N already_known.
2730    /// Test: this test itself.
2731    #[tokio::test]
2732    async fn dispatch_discover_aliases_inserts_new_and_dedupes() {
2733        // Issue #234: bind `_tmp` so the directory is cleaned up on drop at
2734        // end of scope (previously we leaked via `std::mem::forget`).
2735        let _tmp = tempfile::tempdir().expect("tempdir");
2736        let root = _tmp.path().to_path_buf();
2737        let state = AppState::new(root).with_default_palace(Some("disc".to_string()));
2738        let _ = dispatch_tool(&state, "palace_create", json!({"name": "disc"}))
2739            .await
2740            .expect("palace_create");
2741
2742        // Use the live workspace root so the discovery actually finds
2743        // something. CARGO_MANIFEST_DIR points at the crate dir; walk up
2744        // twice to the workspace root.
2745        let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2746            .parent()
2747            .and_then(|p| p.parent())
2748            .expect("workspace root")
2749            .to_path_buf();
2750
2751        let first = dispatch_tool(
2752            &state,
2753            "discover_aliases",
2754            json!({"project_root": workspace_root.to_string_lossy()}),
2755        )
2756        .await
2757        .expect("discover_aliases first");
2758
2759        let new_count = first["new"].as_u64().expect("new is u64");
2760        assert!(new_count > 0, "expected new discoveries on first call");
2761        let discovered = first["discovered"].as_array().expect("discovered array");
2762        assert!(
2763            discovered
2764                .iter()
2765                .any(|d| d["short"] == "tga" && d["full"] == "trusty-git-analytics"),
2766            "expected tga alias in discoveries; got {discovered:?}"
2767        );
2768
2769        // The prompt cache must contain the new alias after discovery.
2770        {
2771            let guard = state.prompt_context_cache.read().await;
2772            assert!(
2773                guard.formatted.contains("tga → trusty-git-analytics"),
2774                "prompt cache missing tga alias after discover_aliases; got: {}",
2775                guard.formatted
2776            );
2777        }
2778
2779        // Second invocation should report zero new and at least `new_count`
2780        // already_known — the same discoveries are now in the KG.
2781        let second = dispatch_tool(
2782            &state,
2783            "discover_aliases",
2784            json!({"project_root": workspace_root.to_string_lossy()}),
2785        )
2786        .await
2787        .expect("discover_aliases second");
2788        assert_eq!(second["new"].as_u64(), Some(0), "expected 0 new on rerun");
2789        let already_known = second["already_known"].as_u64().expect("already_known");
2790        assert!(
2791            already_known >= new_count,
2792            "expected already_known >= {new_count}, got {already_known}"
2793        );
2794    }
2795
2796    /// Why (issue #60): `palace_create` must auto-seed temporal metadata so
2797    /// every new palace has at least `created_at` + `bootstrapped_at`
2798    /// triples — without auto-bootstrap, brand-new palaces had a zero-triple
2799    /// KG and no signal to users that they were supposed to seed it.
2800    /// Test: create a palace, then query the seeded subject (the palace id)
2801    /// and confirm the temporal triples are present.
2802    #[tokio::test]
2803    async fn palace_create_auto_seeds_temporal_metadata() {
2804        let (state, _tmp) = test_state();
2805        let created = dispatch_tool(&state, "palace_create", json!({"name": "auto"}))
2806            .await
2807            .expect("palace_create");
2808        assert_eq!(created["palace_id"], "auto");
2809        // bootstrap summary is present on success
2810        let summary = &created["bootstrap"];
2811        assert!(summary.is_object(), "expected bootstrap summary object");
2812        assert!(summary["triples_asserted"].as_u64().unwrap_or(0) >= 2);
2813
2814        let queried = dispatch_tool(
2815            &state,
2816            "kg_query",
2817            json!({"palace": "auto", "subject": "auto"}),
2818        )
2819        .await
2820        .expect("kg_query");
2821        let triples = queried["triples"].as_array().expect("triples");
2822        let predicates: Vec<&str> = triples
2823            .iter()
2824            .filter_map(|t| t["predicate"].as_str())
2825            .collect();
2826        assert!(
2827            predicates.contains(&"created_at"),
2828            "expected created_at after palace_create; got {predicates:?}",
2829        );
2830        assert!(
2831            predicates.contains(&"bootstrapped_at"),
2832            "expected bootstrapped_at after palace_create; got {predicates:?}",
2833        );
2834        // Hint must NOT appear when triples are present.
2835        assert!(
2836            queried.get("hint").is_none(),
2837            "hint should be absent when triples exist"
2838        );
2839    }
2840
2841    /// Why (issue #60): `kg_query` against a subject with no triples must
2842    /// surface a `hint` field pointing the user at `kg_bootstrap` /
2843    /// `kg_assert`. Without the hint, brand-new palaces returned empty
2844    /// arrays with no breadcrumb back to the seeding tools.
2845    #[tokio::test]
2846    async fn kg_query_emits_hint_when_palace_empty() {
2847        let (state, _tmp) = test_state();
2848        let _ = dispatch_tool(&state, "palace_create", json!({"name": "hinted"}))
2849            .await
2850            .expect("palace_create");
2851        // Query a subject that auto-bootstrap did NOT seed.
2852        let queried = dispatch_tool(
2853            &state,
2854            "kg_query",
2855            json!({"palace": "hinted", "subject": "unrelated-subject"}),
2856        )
2857        .await
2858        .expect("kg_query");
2859        assert_eq!(queried["triples"].as_array().unwrap().len(), 0);
2860        let hint = queried["hint"].as_str().expect("hint field present");
2861        assert!(hint.contains("kg_bootstrap"));
2862        assert!(hint.contains("kg_assert"));
2863    }
2864
2865    /// Why (issue #60): `kg_bootstrap` against the live workspace root must
2866    /// extract Cargo facts (language, version, rust-version) and the git
2867    /// origin URL, then make them queryable through `kg_query`.
2868    #[tokio::test]
2869    async fn kg_bootstrap_seeds_workspace_facts() {
2870        let (state, _tmp) = test_state();
2871        let _ = dispatch_tool(&state, "palace_create", json!({"name": "ws"}))
2872            .await
2873            .expect("palace_create");
2874
2875        let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2876            .parent()
2877            .and_then(|p| p.parent())
2878            .expect("workspace root")
2879            .to_path_buf();
2880
2881        let result = dispatch_tool(
2882            &state,
2883            "kg_bootstrap",
2884            json!({"palace": "ws", "project_path": workspace_root.to_string_lossy()}),
2885        )
2886        .await
2887        .expect("kg_bootstrap");
2888        assert!(result["triples_asserted"].as_u64().unwrap() > 0);
2889        let subject = result["project_subject"]
2890            .as_str()
2891            .expect("project_subject")
2892            .to_string();
2893
2894        // Verify the workspace facts are queryable.
2895        let queried = dispatch_tool(
2896            &state,
2897            "kg_query",
2898            json!({"palace": "ws", "subject": subject}),
2899        )
2900        .await
2901        .expect("kg_query");
2902        let triples = queried["triples"].as_array().expect("triples");
2903        let predicates: Vec<&str> = triples
2904            .iter()
2905            .filter_map(|t| t["predicate"].as_str())
2906            .collect();
2907        // Either Rust language (single-crate manifest) or workspace member
2908        // triples must appear; the trusty-tools root manifest is a workspace
2909        // so we expect has_workspace_member.
2910        assert!(
2911            predicates.contains(&"has_workspace_member") || predicates.contains(&"has_language"),
2912            "expected workspace/language fact; got {predicates:?}",
2913        );
2914        // source_repo from .git/config.
2915        assert!(
2916            predicates.contains(&"source_repo"),
2917            "expected source_repo from .git/config; got {predicates:?}",
2918        );
2919        // Temporal metadata always.
2920        assert!(predicates.contains(&"bootstrapped_at"));
2921    }
2922
2923    // -----------------------------------------------------------------
2924    // Issue #215 — content gate for short prompts
2925    // -----------------------------------------------------------------
2926
2927    /// Why: short single-word content with no `context` must be skipped so
2928    /// the palace doesn't accumulate orphan "yes"/"ok" fragments.
2929    /// What: passes "yes" through the gate and asserts `None`.
2930    /// Test: itself.
2931    #[test]
2932    fn content_gate_blocks_short_no_context() {
2933        assert_eq!(content_gate("yes", None), None);
2934        assert_eq!(content_gate("ok", None), None);
2935        assert_eq!(
2936            content_gate("  no thanks  ", None),
2937            None,
2938            "2 words still < 4"
2939        );
2940        assert_eq!(
2941            content_gate("one two three", None),
2942            None,
2943            "3 words still < 4"
2944        );
2945    }
2946
2947    /// Why: when the caller wraps a short answer with `context`, the gate
2948    /// must keep the content but prepend the context with a `---` separator
2949    /// so the stored memory has standalone value.
2950    /// What: passes "yes" + context, asserts the combined shape.
2951    /// Test: itself.
2952    #[test]
2953    fn content_gate_wraps_short_with_context() {
2954        let combined = content_gate(
2955            "yes",
2956            Some("Do you want to enable auto-bootstrap on new palaces?"),
2957        )
2958        .expect("context should unlock the gate");
2959        assert_eq!(
2960            combined,
2961            "Do you want to enable auto-bootstrap on new palaces?\n\n---\n\nyes",
2962        );
2963        // Even content that would otherwise pass the threshold is wrapped
2964        // when context is supplied — the caller is explicit.
2965        let combined = content_gate(
2966            "the quick brown fox jumps over the lazy dog",
2967            Some("Famous typing pangram"),
2968        )
2969        .expect("long content + context still combines");
2970        assert!(combined.starts_with("Famous typing pangram"));
2971        assert!(combined.contains("\n\n---\n\n"));
2972        assert!(combined.ends_with("the quick brown fox jumps over the lazy dog"));
2973    }
2974
2975    /// Why: content that meets the threshold should pass through untouched
2976    /// when no context is supplied — the gate must not rewrite or reformat
2977    /// passing content.
2978    /// What: passes a 5-word string through and asserts the output equals
2979    /// the input verbatim.
2980    /// Test: itself.
2981    #[test]
2982    fn content_gate_keeps_long() {
2983        let body = "User prefers snake_case for python";
2984        let kept = content_gate(body, None).expect(">= 4 words passes");
2985        assert_eq!(kept, body, "passing content must round-trip verbatim");
2986        // Exactly four words is the boundary — it must pass.
2987        let boundary = "one two three four";
2988        assert_eq!(content_gate(boundary, None).as_deref(), Some(boundary));
2989    }
2990
2991    /// Why: an empty or whitespace-only `context` argument must be treated
2992    /// the same as `None` so callers can't accidentally smuggle short
2993    /// content through by passing `""`.
2994    /// What: passes blank context with short content and asserts the gate
2995    /// still skips the write.
2996    /// Test: itself.
2997    #[test]
2998    fn content_gate_blank_context_treated_as_none() {
2999        assert_eq!(content_gate("yes", Some("")), None);
3000        assert_eq!(content_gate("yes", Some("   ")), None);
3001        assert_eq!(content_gate("yes", Some("\n\t")), None);
3002    }
3003
3004    /// Why: the dispatch path must return a structured "skipped" envelope
3005    /// without writing to the store when the gate fires on `memory_remember`.
3006    /// What: dispatch with single-word `text` and no `context`; assert the
3007    /// response carries `status = "skipped"` and that no drawer landed.
3008    /// Test: itself.
3009    #[tokio::test]
3010    async fn dispatch_remember_skips_short_no_context() {
3011        let (state, _tmp) = test_state();
3012        let _ = dispatch_tool(&state, "palace_create", json!({"name": "gate"}))
3013            .await
3014            .expect("palace_create");
3015
3016        let res = dispatch_tool(
3017            &state,
3018            "memory_remember",
3019            json!({"palace": "gate", "text": "yes"}),
3020        )
3021        .await
3022        .expect("memory_remember (short)");
3023        assert_eq!(res["status"], "skipped");
3024        assert!(res["reason"]
3025            .as_str()
3026            .unwrap_or("")
3027            .contains("content gate"));
3028        // No drawer was written.
3029        let listed = dispatch_tool(
3030            &state,
3031            "memory_list",
3032            json!({"palace": "gate", "limit": 10}),
3033        )
3034        .await
3035        .expect("memory_list");
3036        let drawers = listed["drawers"].as_array().expect("drawers array");
3037        assert!(
3038            drawers.is_empty(),
3039            "no drawer should be written; got {drawers:?}"
3040        );
3041    }
3042
3043    /// Why: confirm the `context` argument unlocks a short content write —
3044    /// the resulting drawer must carry the combined `context + content`
3045    /// body so downstream recall sees the wrapping.
3046    /// What: dispatch with one-word text plus a context arg, then list and
3047    /// assert the stored content begins with the context and ends with the
3048    /// original short body.
3049    /// Test: itself.
3050    #[tokio::test]
3051    async fn dispatch_remember_with_context_writes_combined() {
3052        let (state, _tmp) = test_state();
3053        let _ = dispatch_tool(&state, "palace_create", json!({"name": "ctxgate"}))
3054            .await
3055            .expect("palace_create");
3056
3057        let res = dispatch_tool(
3058            &state,
3059            "memory_remember",
3060            json!({
3061                "palace": "ctxgate",
3062                "text": "yes",
3063                "context": "Do you want to enable auto-bootstrap on new palaces?",
3064                "force": true,
3065            }),
3066        )
3067        .await
3068        .expect("memory_remember (with context)");
3069        assert_eq!(res["status"], "stored");
3070
3071        let listed = dispatch_tool(
3072            &state,
3073            "memory_list",
3074            json!({"palace": "ctxgate", "limit": 10}),
3075        )
3076        .await
3077        .expect("memory_list");
3078        let drawers = listed["drawers"].as_array().expect("drawers array");
3079        assert_eq!(drawers.len(), 1);
3080        let body = drawers[0]["content"].as_str().expect("content");
3081        assert!(body.starts_with("Do you want to enable auto-bootstrap"));
3082        assert!(body.contains("\n\n---\n\n"));
3083        assert!(body.ends_with("yes"));
3084    }
3085
3086    /// Why: `memory_note` must respect the same content gate as
3087    /// `memory_remember` so the short-prompt protection is uniform across
3088    /// the write surface.
3089    /// What: dispatch `memory_note` with a one-word content and no context;
3090    /// assert it returns a skipped envelope and no drawer is written.
3091    /// Test: itself.
3092    #[tokio::test]
3093    async fn dispatch_note_skips_short_no_context() {
3094        let (state, _tmp) = test_state();
3095        let _ = dispatch_tool(&state, "palace_create", json!({"name": "noteg"}))
3096            .await
3097            .expect("palace_create");
3098
3099        let res = dispatch_tool(
3100            &state,
3101            "memory_note",
3102            json!({"palace": "noteg", "content": "ok"}),
3103        )
3104        .await
3105        .expect("memory_note (short)");
3106        assert_eq!(res["status"], "skipped");
3107        let listed = dispatch_tool(
3108            &state,
3109            "memory_list",
3110            json!({"palace": "noteg", "limit": 10}),
3111        )
3112        .await
3113        .expect("memory_list");
3114        assert!(listed["drawers"].as_array().unwrap().is_empty());
3115    }
3116
3117    #[tokio::test]
3118    async fn dispatch_unknown_tool_errors() {
3119        let (state, _tmp) = test_state();
3120        let err = dispatch_tool(&state, "does_not_exist", json!({}))
3121            .await
3122            .expect_err("should error");
3123        assert!(err.to_string().contains("unknown tool"));
3124    }
3125
3126    // -----------------------------------------------------------------
3127    // Issue #220 — blocklist pattern + rolling dedup window
3128    // -----------------------------------------------------------------
3129
3130    /// Why: the blocklist gate must reject Claude Code tool-use captures
3131    /// (`Tool use: Bash`, `Tool use: Edit File: …`) because those entries
3132    /// have no standalone semantic value.
3133    /// What: passes the literal prefix and a realistic example through
3134    /// the gate and asserts `true` (blocked).
3135    /// Test: itself.
3136    #[test]
3137    fn blocklist_gate_blocks_tool_use() {
3138        assert!(blocklist_gate("Tool use: Bash"));
3139        assert!(blocklist_gate(
3140            "Tool use: Edit File: /Users/me/Projects/foo/bar.rs"
3141        ));
3142        // Leading whitespace should not let it through.
3143        assert!(blocklist_gate("   Tool use: Read"));
3144    }
3145
3146    /// Why: session-lifecycle events are auto-emitted by Claude Code and
3147    /// should not pollute the palace.
3148    /// What: passes the prefix through the gate and asserts `true`.
3149    /// Test: itself.
3150    #[test]
3151    fn blocklist_gate_blocks_session_ended() {
3152        assert!(blocklist_gate(
3153            "Claude Code session ended: 1d2c3b4a-0000-0000-0000-000000000000"
3154        ));
3155        assert!(blocklist_gate("Claude Code session started"));
3156    }
3157
3158    /// Why: normal user content (with no blocklist substring) must pass
3159    /// the gate untouched so the regular content gate (issue #215) gets
3160    /// to make the next decision.
3161    /// What: passes normal prose / facts through and asserts `false`.
3162    /// Test: itself.
3163    #[test]
3164    fn blocklist_gate_passes_normal_content() {
3165        assert!(!blocklist_gate("User prefers snake_case for python"));
3166        assert!(!blocklist_gate(
3167            "Quokkas are the happiest marsupials in Australia"
3168        ));
3169        assert!(!blocklist_gate("Note: refactor the dispatcher next sprint"));
3170        // Substring-only — a tool-use mention inside legitimate prose is
3171        // still blocked. This is intentional: the prefix is rare enough
3172        // outside the auto-capture path that the false-positive rate is
3173        // acceptable, and a future regex upgrade can tighten it.
3174        assert!(blocklist_gate("I used Tool use: Bash here"));
3175    }
3176
3177    /// Why: the dedup gate must reject a fresh write whose content is a
3178    /// near-duplicate (Jaro-Winkler > 0.92) of a drawer landed inside the
3179    /// rolling window. Without this gate, bursty auto-captures inflate
3180    /// the palace with no recall benefit (issue #220).
3181    /// What: creates a palace, writes one drawer through the MCP path,
3182    /// then runs the gate directly against a string that differs by one
3183    /// trailing word — Jaro-Winkler should score that above 0.92 and the
3184    /// gate should return `true`.
3185    /// Test: itself.
3186    #[tokio::test]
3187    async fn dedup_skips_near_duplicate() {
3188        let (state, _tmp) = test_state();
3189        let _ = dispatch_tool(&state, "palace_create", json!({"name": "dedup1"}))
3190            .await
3191            .expect("palace_create");
3192
3193        // Land the seed drawer through the real write path so its
3194        // `created_at` is `Utc::now()` and falls inside the dedup window.
3195        let _ = dispatch_tool(
3196            &state,
3197            "memory_remember",
3198            json!({
3199                "palace": "dedup1",
3200                "text": "The quick brown fox jumped over the lazy dog repeatedly today",
3201            }),
3202        )
3203        .await
3204        .expect("memory_remember seed");
3205
3206        let handle = open_palace_handle(&state, "dedup1").expect("open handle");
3207        // Near-duplicate: same prefix, trailing word replaced. Jaro-Winkler
3208        // weights the shared prefix heavily so this should clear the 0.92
3209        // bar comfortably.
3210        assert!(
3211            dedup_gate(
3212                &handle,
3213                "The quick brown fox jumped over the lazy dog repeatedly yesterday"
3214            ),
3215            "near-duplicate should be detected"
3216        );
3217        // Exact match also blocks.
3218        assert!(
3219            dedup_gate(
3220                &handle,
3221                "The quick brown fox jumped over the lazy dog repeatedly today"
3222            ),
3223            "exact match should be detected"
3224        );
3225    }
3226
3227    /// Why: a write whose content is genuinely different from every drawer
3228    /// in the window must pass the dedup gate so the palace can grow.
3229    /// What: writes one seed drawer, then runs the gate against an
3230    /// unrelated string. Asserts `false`.
3231    /// Test: itself.
3232    #[tokio::test]
3233    async fn dedup_allows_different_content() {
3234        let (state, _tmp) = test_state();
3235        let _ = dispatch_tool(&state, "palace_create", json!({"name": "dedup2"}))
3236            .await
3237            .expect("palace_create");
3238
3239        let _ = dispatch_tool(
3240            &state,
3241            "memory_remember",
3242            json!({
3243                "palace": "dedup2",
3244                "text": "Quokkas are the happiest marsupials in Australia by general consensus",
3245            }),
3246        )
3247        .await
3248        .expect("memory_remember seed");
3249
3250        let handle = open_palace_handle(&state, "dedup2").expect("open handle");
3251        // Completely different content — far below 0.92.
3252        assert!(
3253            !dedup_gate(
3254                &handle,
3255                "Rust is a systems programming language focused on safety and concurrency"
3256            ),
3257            "unrelated content should pass the dedup gate"
3258        );
3259        // Empty/whitespace content is also a pass — the content gate
3260        // handles the empty case upstream.
3261        assert!(!dedup_gate(&handle, "   "));
3262    }
3263
3264    /// Why (issue #230): the dedup gate previously had a TOCTOU race —
3265    /// two concurrent `memory_remember` calls with identical content
3266    /// both saw the empty pre-write snapshot, both passed the gate, and
3267    /// both wrote duplicate drawers. The per-palace write mutex on
3268    /// `AppState` now serialises the gate-then-write sequence so the
3269    /// second writer observes the first writer's drawer in
3270    /// `list_drawers` and bails. This test would have failed before the
3271    /// fix and passes after.
3272    /// What: spawns two `tokio` tasks that race to write the same long
3273    /// content into a fresh palace, joins both, then asserts that
3274    /// `memory_list` returns exactly one drawer (the loser's envelope
3275    /// carries `status = "skipped"` with a `duplicate within window`
3276    /// reason).
3277    /// Test: itself — fail-then-pass on this commit.
3278    #[tokio::test]
3279    async fn dedup_gate_blocks_concurrent_duplicate_writes() {
3280        let (state, _tmp) = test_state();
3281        let state = std::sync::Arc::new(state);
3282        let _ = dispatch_tool(&state, "palace_create", json!({"name": "dedup_race"}))
3283            .await
3284            .expect("palace_create");
3285
3286        // Long enough to clear the 8-token MCP filter; identical content
3287        // in both racers so the dedup gate is the only thing keeping
3288        // them from both landing.
3289        let text =
3290            "Concurrent identical writes must collapse to a single drawer under the dedup gate";
3291
3292        let s1 = state.clone();
3293        let t1 = tokio::spawn(async move {
3294            dispatch_tool(
3295                &s1,
3296                "memory_remember",
3297                json!({"palace": "dedup_race", "text": text}),
3298            )
3299            .await
3300        });
3301        let s2 = state.clone();
3302        let t2 = tokio::spawn(async move {
3303            dispatch_tool(
3304                &s2,
3305                "memory_remember",
3306                json!({"palace": "dedup_race", "text": text}),
3307            )
3308            .await
3309        });
3310        let r1 = t1.await.expect("join t1").expect("dispatch t1");
3311        let r2 = t2.await.expect("join t2").expect("dispatch t2");
3312
3313        // Exactly one of the two should be `stored`; the other should be
3314        // `skipped` with the documented duplicate-window reason.
3315        let statuses = [
3316            r1["status"].as_str().unwrap_or(""),
3317            r2["status"].as_str().unwrap_or(""),
3318        ];
3319        let stored = statuses.iter().filter(|s| **s == "stored").count();
3320        let skipped = statuses.iter().filter(|s| **s == "skipped").count();
3321        assert_eq!(
3322            stored, 1,
3323            "exactly one concurrent write should be stored; got responses {r1:?} {r2:?}"
3324        );
3325        assert_eq!(
3326            skipped, 1,
3327            "exactly one concurrent write should be skipped; got responses {r1:?} {r2:?}"
3328        );
3329        let skipped_reason = if r1["status"] == "skipped" {
3330            r1["reason"].as_str().unwrap_or("")
3331        } else {
3332            r2["reason"].as_str().unwrap_or("")
3333        };
3334        assert!(
3335            skipped_reason.contains("duplicate within window"),
3336            "skipped envelope should cite dedup reason; got {skipped_reason:?}"
3337        );
3338
3339        // Belt-and-braces: confirm the palace contains exactly one drawer.
3340        let listed = dispatch_tool(
3341            &state,
3342            "memory_list",
3343            json!({"palace": "dedup_race", "limit": 10}),
3344        )
3345        .await
3346        .expect("memory_list");
3347        let drawers = listed["drawers"].as_array().expect("drawers array");
3348        assert_eq!(
3349            drawers.len(),
3350            1,
3351            "only one drawer should be persisted after concurrent identical writes; got {drawers:?}"
3352        );
3353    }
3354
3355    /// Why: end-to-end confirmation that the blocklist short-circuits the
3356    /// MCP `memory_remember` dispatch — no drawer is written, the
3357    /// response envelope carries the documented `status = "skipped"` and
3358    /// reason. Mirrors the issue-215 short-prompt test.
3359    /// What: dispatch a `Tool use:` payload through `memory_remember`,
3360    /// then `memory_list` and assert no drawer landed.
3361    /// Test: itself.
3362    #[tokio::test]
3363    async fn dispatch_remember_blocks_blocklist_pattern() {
3364        let (state, _tmp) = test_state();
3365        let _ = dispatch_tool(&state, "palace_create", json!({"name": "blk"}))
3366            .await
3367            .expect("palace_create");
3368
3369        let res = dispatch_tool(
3370            &state,
3371            "memory_remember",
3372            json!({"palace": "blk", "text": "Tool use: Bash"}),
3373        )
3374        .await
3375        .expect("memory_remember (blocked)");
3376        assert_eq!(res["status"], "skipped");
3377        assert!(
3378            res["reason"]
3379                .as_str()
3380                .unwrap_or("")
3381                .contains("blocked pattern"),
3382            "reason should mention blocked pattern; got {res:?}"
3383        );
3384
3385        let listed = dispatch_tool(&state, "memory_list", json!({"palace": "blk", "limit": 10}))
3386            .await
3387            .expect("memory_list");
3388        let drawers = listed["drawers"].as_array().expect("drawers array");
3389        assert!(drawers.is_empty(), "no drawer should be written");
3390    }
3391
3392    /// Why (issue #231): the bounded BM25 indexer channel must drop excess
3393    /// requests with a logged `warn!` rather than block the writer or grow
3394    /// unbounded behind a slow daemon. Verifying this directly at the
3395    /// `bm25_index_enqueue` boundary protects the back-pressure contract
3396    /// without needing a real BM25 daemon in the test loop.
3397    /// What: builds an `AppState` whose worker can't drain (we replace
3398    /// `bm25_index_tx` with a fresh, deliberately-unattended channel), then
3399    /// hammers `bm25_index_enqueue` past the bound and asserts the channel
3400    /// reports `Full` for the overflow. We assert behaviour by inspecting
3401    /// the channel state after the burst — the function is `void` so
3402    /// observable evidence is "the sender stayed open and the writer never
3403    /// blocked even when we shoved >capacity items at it."
3404    /// Test: this test.
3405    #[tokio::test]
3406    async fn bm25_index_queue_drops_when_full() {
3407        // Build a normal AppState, then swap in a fresh bounded channel
3408        // *without* spawning a drain worker so we can deterministically
3409        // observe overflow at `try_send`.
3410        let (mut state, _tmp) = test_state();
3411        let (tx, _rx_held) =
3412            tokio::sync::mpsc::channel::<Bm25IndexRequest>(BM25_INDEX_QUEUE_CAPACITY);
3413        state.bm25_index_tx = tx;
3414
3415        // Push CAPACITY items — these must all succeed.
3416        for i in 0..BM25_INDEX_QUEUE_CAPACITY {
3417            bm25_index_enqueue(
3418                &state,
3419                "default",
3420                Uuid::new_v4(),
3421                &format!("filler content {i}"),
3422            );
3423        }
3424        // Sender capacity reports 0 once filled.
3425        assert_eq!(
3426            state.bm25_index_tx.capacity(),
3427            0,
3428            "after filling, sender capacity must be 0"
3429        );
3430
3431        // Now push another batch — these must be dropped (logged warn) and
3432        // must not panic, block, or close the channel.
3433        for i in 0..16 {
3434            bm25_index_enqueue(
3435                &state,
3436                "default",
3437                Uuid::new_v4(),
3438                &format!("overflow content {i}"),
3439            );
3440        }
3441
3442        // The sender must still be live — the channel is not closed by a
3443        // full-queue drop. A subsequent send-attempt to the live receiver
3444        // must still return `TrySendError::Full`, not `Closed`.
3445        let probe_req = Bm25IndexRequest {
3446            palace: "default".to_string(),
3447            drawer_id: Uuid::new_v4().to_string(),
3448            content: "probe".to_string(),
3449            data_dir: state.data_root.join("default").join("bm25"),
3450        };
3451        let probe = state.bm25_index_tx.try_send(probe_req);
3452        match probe {
3453            Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {}
3454            other => panic!("expected Full overflow, got {other:?}"),
3455        }
3456    }
3457}