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