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