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