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