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