Skip to main content

trusty_memory/
tools.rs

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