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::AppState;
23use anyhow::{anyhow, Context, Result};
24use serde_json::{json, Value};
25use trusty_common::memory_core::filter::{FilterConfig, MCP_MIN_TOKENS};
26use trusty_common::memory_core::palace::{Palace, PalaceId, RoomType};
27use trusty_common::memory_core::retrieval::{
28    recall, recall_across_palaces, recall_deep, RememberOptions,
29};
30use trusty_common::memory_core::store::kg::Triple;
31use uuid::Uuid;
32
33/// Build the strict MCP-level `RememberOptions`.
34///
35/// Why: Issue #61 — the MCP boundary is where auto-capture hooks deposit
36/// raw tool/commit/prompt data; we want the 8-token threshold there even
37/// though the library default is more permissive for direct callers.
38/// What: Clones the default filter and bumps `min_tokens` to `MCP_MIN_TOKENS`.
39/// Test: `dispatch_remember_rejects_short_content`.
40fn mcp_remember_opts(force: bool) -> RememberOptions {
41    let filter = FilterConfig {
42        min_tokens: MCP_MIN_TOKENS,
43        ..FilterConfig::default()
44    };
45    RememberOptions {
46        filter,
47        force,
48        ..RememberOptions::default()
49    }
50}
51
52/// Marker server type. Reserved for future stateful MCP server impls.
53///
54/// Why: Keep a stable type name while the protocol-loop is implemented at
55/// module level, so external callers can still depend on a server symbol.
56/// What: Zero-sized struct with `new` / `Default`.
57/// Test: `MemoryMcpServer::default()` constructs without panic.
58pub struct MemoryMcpServer;
59
60impl MemoryMcpServer {
61    pub fn new() -> Self {
62        Self
63    }
64}
65
66impl Default for MemoryMcpServer {
67    fn default() -> Self {
68        Self::new()
69    }
70}
71
72/// MCP `tools/list` response payload.
73///
74/// Why: Claude Code calls `tools/list` once on connect and uses the schema
75/// to drive the tool picker; the schema is the source of truth for arg names.
76/// `palace` is required only when the server has no `--palace` default
77/// configured — when a default is set, the schema omits `palace` from
78/// `required` so clients can drop it.
79/// What: Returns a JSON object `{ "tools": [...] }` with all 10 tool defs.
80/// Test: `tool_definitions_lists_all_tools`,
81/// `tool_definitions_drops_palace_required_when_default_set`.
82pub fn tool_definitions() -> Value {
83    tool_definitions_with(false)
84}
85
86/// Variant of `tool_definitions` aware of whether a default palace is
87/// configured. When `has_default` is true, the `palace` argument is moved
88/// out of the `required` list for every tool that takes it.
89///
90/// Why: Lets `handle_message` emit a schema that matches the running
91/// server's actual contract — clients reading the schema should see exactly
92/// what they need to send.
93/// What: Builds the same shape as `tool_definitions` but with conditional
94/// `required` arrays.
95/// Test: `tool_definitions_drops_palace_required_when_default_set`.
96pub fn tool_definitions_with(has_default: bool) -> Value {
97    let memory_remember_required: Vec<&str> = if has_default {
98        vec!["text"]
99    } else {
100        vec!["palace", "text"]
101    };
102    let memory_recall_required: Vec<&str> = if has_default {
103        vec!["query"]
104    } else {
105        vec!["palace", "query"]
106    };
107    let kg_assert_required: Vec<&str> = if has_default {
108        vec!["subject", "predicate", "object"]
109    } else {
110        vec!["palace", "subject", "predicate", "object"]
111    };
112    let kg_query_required: Vec<&str> = if has_default {
113        vec!["subject"]
114    } else {
115        vec!["palace", "subject"]
116    };
117    let memory_list_required: Vec<&str> = if has_default { vec![] } else { vec!["palace"] };
118    let memory_forget_required: Vec<&str> = if has_default {
119        vec!["drawer_id"]
120    } else {
121        vec!["palace", "drawer_id"]
122    };
123    let palace_info_required: Vec<&str> = if has_default { vec![] } else { vec!["palace"] };
124    let palace_compact_required: Vec<&str> = if has_default { vec![] } else { vec!["palace"] };
125    let memory_note_required: Vec<&str> = if has_default {
126        vec!["content"]
127    } else {
128        vec!["palace", "content"]
129    };
130
131    json!({
132        "tools": [
133            {
134                "name": "memory_remember",
135                "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. Pass force=true to bypass filtering, or use memory_note for short curated facts.",
136                "inputSchema": {
137                    "type": "object",
138                    "properties": {
139                        "palace": {"type": "string", "description": "Palace ID (optional if server started with --palace)"},
140                        "text":   {"type": "string", "description": "Memory content"},
141                        "room":   {"type": "string", "description": "Room type (optional)"},
142                        "tags":   {"type": "array", "items": {"type": "string"}},
143                        "force":  {"type": "boolean", "description": "Bypass the signal/noise filter. Use sparingly — intended for explicit operator overrides.", "default": false}
144                    },
145                    "required": memory_remember_required,
146                }
147            },
148            {
149                "name": "memory_note",
150                "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.",
151                "inputSchema": {
152                    "type": "object",
153                    "properties": {
154                        "palace":  {"type": "string"},
155                        "content": {"type": "string", "description": "Brief fact to remember"},
156                        "tags":    {"type": "array", "items": {"type": "string"}}
157                    },
158                    "required": memory_note_required,
159                }
160            },
161            {
162                "name": "memory_recall",
163                "description": "Recall memories using L0+L1+L2 progressive retrieval.",
164                "inputSchema": {
165                    "type": "object",
166                    "properties": {
167                        "palace": {"type": "string"},
168                        "query":  {"type": "string"},
169                        "top_k":  {"type": "integer", "default": 10}
170                    },
171                    "required": memory_recall_required,
172                }
173            },
174            {
175                "name": "memory_recall_deep",
176                "description": "Deep recall using L3 full HNSW search.",
177                "inputSchema": {
178                    "type": "object",
179                    "properties": {
180                        "palace": {"type": "string"},
181                        "query":  {"type": "string"},
182                        "top_k":  {"type": "integer", "default": 10}
183                    },
184                    "required": memory_recall_required,
185                }
186            },
187            {
188                "name": "palace_create",
189                "description": "Create a new memory palace.",
190                "inputSchema": {
191                    "type": "object",
192                    "properties": {
193                        "name":        {"type": "string"},
194                        "description": {"type": "string"}
195                    },
196                    "required": ["name"]
197                }
198            },
199            {
200                "name": "palace_list",
201                "description": "List all palaces on this machine.",
202                "inputSchema": {"type": "object", "properties": {}}
203            },
204            {
205                "name": "kg_assert",
206                "description": "Assert a fact in the temporal knowledge graph.",
207                "inputSchema": {
208                    "type": "object",
209                    "properties": {
210                        "palace":     {"type": "string"},
211                        "subject":    {"type": "string"},
212                        "predicate":  {"type": "string"},
213                        "object":     {"type": "string"},
214                        "confidence": {"type": "number", "default": 1.0},
215                        "provenance": {"type": "string"}
216                    },
217                    "required": kg_assert_required,
218                }
219            },
220            {
221                "name": "kg_query",
222                "description": "Query active knowledge-graph triples for a subject.",
223                "inputSchema": {
224                    "type": "object",
225                    "properties": {
226                        "palace":  {"type": "string"},
227                        "subject": {"type": "string"}
228                    },
229                    "required": kg_query_required,
230                }
231            },
232            {
233                "name": "memory_list",
234                "description": "List drawers in a palace, optionally filtered by room type or tag.",
235                "inputSchema": {
236                    "type": "object",
237                    "properties": {
238                        "palace": {"type": "string"},
239                        "room":   {"type": "string", "description": "Filter by room type (Frontend, Backend, Testing, Planning, Documentation, Research, Configuration, Meetings, General, or custom)"},
240                        "tag":    {"type": "string", "description": "Filter by tag"},
241                        "limit":  {"type": "integer", "description": "Max results (default 50)"}
242                    },
243                    "required": memory_list_required,
244                }
245            },
246            {
247                "name": "memory_forget",
248                "description": "Delete a drawer from a palace by its UUID.",
249                "inputSchema": {
250                    "type": "object",
251                    "properties": {
252                        "palace":    {"type": "string"},
253                        "drawer_id": {"type": "string", "description": "UUID of the drawer to delete"}
254                    },
255                    "required": memory_forget_required,
256                }
257            },
258            {
259                "name": "palace_info",
260                "description": "Get metadata and stats for a single palace.",
261                "inputSchema": {
262                    "type": "object",
263                    "properties": {
264                        "palace": {"type": "string"}
265                    },
266                    "required": palace_info_required,
267                }
268            },
269            {
270                "name": "palace_compact",
271                "description": "Remove orphaned vector index entries (vectors with no matching drawer row). See issue #49.",
272                "inputSchema": {
273                    "type": "object",
274                    "properties": {
275                        "palace": {"type": "string"}
276                    },
277                    "required": palace_compact_required,
278                }
279            },
280            {
281                "name": "add_alias",
282                "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.",
283                "inputSchema": {
284                    "type": "object",
285                    "properties": {
286                        "short": {"type": "string", "description": "Short name / alias (subject)"},
287                        "full":  {"type": "string", "description": "Full / canonical name (object)"},
288                        "extra": {"type": "string", "description": "Optional extra context appended to the full name"}
289                    },
290                    "required": ["short", "full"],
291                }
292            },
293            {
294                "name": "list_prompt_facts",
295                "description": "List every active prompt-fact triple (aliases, conventions, facts, shorthands) across all palaces.",
296                "inputSchema": {"type": "object", "properties": {}}
297            },
298            {
299                "name": "remove_prompt_fact",
300                "description": "Retract the active triple for a (subject, predicate) pair from the prompt-facts surface. Closes the interval without inserting a replacement.",
301                "inputSchema": {
302                    "type": "object",
303                    "properties": {
304                        "subject":   {"type": "string"},
305                        "predicate": {"type": "string", "description": "One of is_alias_for, has_convention, is_fact, is_shorthand_for"}
306                    },
307                    "required": ["subject", "predicate"],
308                }
309            },
310            {
311                "name": "get_prompt_context",
312                "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).",
313                "inputSchema": {
314                    "type": "object",
315                    "properties": {
316                        "query": {
317                            "type": "string",
318                            "description": "Optional filter — only return facts whose subject or object contains this string (case-insensitive). Omit to return all hot facts."
319                        }
320                    }
321                }
322            },
323            {
324                "name": "discover_aliases",
325                "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.",
326                "inputSchema": {
327                    "type": "object",
328                    "properties": {
329                        "project_root": {"type": "string", "description": "Optional filesystem path to scan. Defaults to the process cwd."}
330                    }
331                }
332            },
333            {
334                "name": "kg_gaps",
335                "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.",
336                "inputSchema": {
337                    "type": "object",
338                    "properties": {
339                        "palace": {"type": "string", "description": "Palace name (optional, defaults to the active palace)"}
340                    }
341                }
342            },
343            {
344                "name": "kg_bootstrap",
345                "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.",
346                "inputSchema": {
347                    "type": "object",
348                    "properties": {
349                        "palace":       {"type": "string", "description": "Palace ID (optional if server started with --palace)"},
350                        "project_path": {"type": "string", "description": "Filesystem path to scan. Omit to scan the palace's own data dir (temporal metadata only)."}
351                    }
352                }
353            },
354            {
355                "name": "memory_recall_all",
356                "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.",
357                "inputSchema": {
358                    "type": "object",
359                    "properties": {
360                        "q":     {"type": "string", "description": "Free-text query"},
361                        "top_k": {"type": "integer", "default": 10},
362                        "deep":  {"type": "boolean", "default": false}
363                    },
364                    "required": ["q"],
365                }
366            }
367        ]
368    })
369}
370
371/// Parse a `RoomType` from an optional string (`"Backend"`, `"Frontend"`,
372/// etc.) — falls back to `RoomType::General` when unset or unknown.
373///
374/// Why: MCP arguments are JSON; we accept the friendly enum-name forms so
375/// callers don't have to learn an internal serialization.
376/// What: Match-on-string returning the corresponding `RoomType`.
377/// Test: Indirectly via `dispatch_remember_then_recall`.
378fn parse_room(s: Option<&str>) -> RoomType {
379    match s.unwrap_or("General") {
380        "Frontend" => RoomType::Frontend,
381        "Backend" => RoomType::Backend,
382        "Testing" => RoomType::Testing,
383        "Planning" => RoomType::Planning,
384        "Documentation" => RoomType::Documentation,
385        "Research" => RoomType::Research,
386        "Configuration" => RoomType::Configuration,
387        "Meetings" => RoomType::Meetings,
388        "General" => RoomType::General,
389        other => RoomType::Custom(other.to_string()),
390    }
391}
392
393/// Resolve (or lazily open) the palace handle for a tool call.
394fn open_palace_handle(
395    state: &AppState,
396    palace_id: &str,
397) -> Result<std::sync::Arc<trusty_common::memory_core::PalaceHandle>> {
398    let pid = PalaceId::new(palace_id);
399    state
400        .registry
401        .open_palace(&state.data_root, &pid)
402        .with_context(|| format!("open palace {palace_id}"))
403}
404
405/// Resolve a palace argument, falling back to `state.default_palace` when
406/// the caller omitted `palace`.
407///
408/// Why: `serve --palace <name>` lets the operator bind a process to a single
409/// project namespace; tool calls then no longer need to repeat the palace
410/// every time. This helper centralises the precedence rule (explicit arg
411/// wins over default) and produces a uniform error when neither is set.
412/// What: Returns the explicit `args["palace"]` string if present, otherwise
413/// `state.default_palace`. Errors with a helpful message if both are absent.
414/// Test: `default_palace_used_when_arg_omitted` and
415/// `dispatch_unknown_tool_errors`.
416fn resolve_palace<'a>(state: &'a AppState, args: &'a Value, tool: &str) -> Result<String> {
417    if let Some(p) = args.get("palace").and_then(|v| v.as_str()) {
418        return Ok(p.to_string());
419    }
420    state
421        .default_palace
422        .clone()
423        .ok_or_else(|| anyhow!("{tool}: missing 'palace' (no --palace default configured)"))
424}
425
426/// Dispatch a tool call by name to its real handler.
427///
428/// Why: Centralises the name → handler mapping; every handler now performs a
429/// real read/write against the live `PalaceRegistry` instead of returning a
430/// stub.
431/// What: Returns `Ok(Value)` on success, `Err` on unknown tool / bad args /
432/// underlying failure.
433/// Test: `dispatch_palace_create_persists`, `dispatch_remember_then_recall`,
434/// `dispatch_kg_assert_then_query`, `dispatch_unknown_tool_errors`.
435pub async fn dispatch_tool(state: &AppState, name: &str, args: Value) -> Result<Value> {
436    match name {
437        "memory_remember" => {
438            let palace = resolve_palace(state, &args, "memory_remember")?;
439            let palace = palace.as_str();
440            let text = args
441                .get("text")
442                .and_then(|v| v.as_str())
443                .ok_or_else(|| anyhow!("memory_remember: missing 'text'"))?
444                .to_string();
445            let room = parse_room(args.get("room").and_then(|v| v.as_str()));
446            let tags: Vec<String> = args
447                .get("tags")
448                .and_then(|v| v.as_array())
449                .map(|arr| {
450                    arr.iter()
451                        .filter_map(|t| t.as_str().map(|s| s.to_string()))
452                        .collect()
453                })
454                .unwrap_or_default();
455
456            let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
457
458            let handle = open_palace_handle(state, palace)?;
459            let opts = mcp_remember_opts(force);
460            let drawer_id = handle
461                .remember_with_options(text, room, tags, 0.5, opts)
462                .await
463                .context("PalaceHandle::remember_with_options")?;
464            Ok(json!({
465                "drawer_id": drawer_id.to_string(),
466                "palace": palace,
467                "status": "stored",
468            }))
469        }
470        "memory_note" => {
471            // Issue #61: curated short-fact shortcut. Bypasses the token
472            // threshold (so "User prefers snake_case" is accepted) but still
473            // applies noise-pattern rejects so the tool can't be used to
474            // smuggle in auto-capture garbage. Pinned `DrawerType::UserFact`
475            // and `importance = 1.0` so the entry surfaces in L1 essentials.
476            let palace = resolve_palace(state, &args, "memory_note")?;
477            let palace = palace.as_str();
478            let content = args
479                .get("content")
480                .and_then(|v| v.as_str())
481                .ok_or_else(|| anyhow!("memory_note: missing 'content'"))?
482                .to_string();
483            let tags: Vec<String> = args
484                .get("tags")
485                .and_then(|v| v.as_array())
486                .map(|arr| {
487                    arr.iter()
488                        .filter_map(|t| t.as_str().map(|s| s.to_string()))
489                        .collect()
490                })
491                .unwrap_or_default();
492            let handle = open_palace_handle(state, palace)?;
493            // note() preset skips the token threshold; we keep the default
494            // filter for noise patterns. No MCP-stricter min_tokens override
495            // is needed because `enforce_min_tokens = false`.
496            let drawer_id = handle
497                .remember_with_options(
498                    content,
499                    RoomType::General,
500                    tags,
501                    1.0,
502                    RememberOptions::note(),
503                )
504                .await
505                .context("PalaceHandle::remember_with_options (note)")?;
506            Ok(json!({
507                "drawer_id": drawer_id.to_string(),
508                "palace": palace,
509                "status": "stored",
510                "drawer_type": "UserFact",
511            }))
512        }
513        "memory_recall" => {
514            let palace = resolve_palace(state, &args, "memory_recall")?;
515            let query = args
516                .get("query")
517                .and_then(|v| v.as_str())
518                .ok_or_else(|| anyhow!("memory_recall: missing 'query'"))?;
519            let top_k = args.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
520
521            let handle = open_palace_handle(state, &palace)?;
522            let embedder = state.embedder().await?;
523            let results = recall(&handle, embedder.as_ref(), query, top_k)
524                .await
525                .context("recall")?;
526            Ok(serialize_recall(&palace, query, results))
527        }
528        "memory_recall_deep" => {
529            let palace = resolve_palace(state, &args, "memory_recall_deep")?;
530            let query = args
531                .get("query")
532                .and_then(|v| v.as_str())
533                .ok_or_else(|| anyhow!("memory_recall_deep: missing 'query'"))?;
534            let top_k = args.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
535
536            let handle = open_palace_handle(state, &palace)?;
537            let embedder = state.embedder().await?;
538            let results = recall_deep(&handle, embedder.as_ref(), query, top_k)
539                .await
540                .context("recall_deep")?;
541            Ok(serialize_recall(&palace, query, results))
542        }
543        "palace_create" => {
544            let palace_name = args
545                .get("name")
546                .and_then(|v| v.as_str())
547                .ok_or_else(|| anyhow!("palace_create: missing 'name'"))?;
548            let description = args
549                .get("description")
550                .and_then(|v| v.as_str())
551                .map(|s| s.to_string());
552            let palace = Palace {
553                id: PalaceId::new(palace_name),
554                name: palace_name.to_string(),
555                description,
556                created_at: chrono::Utc::now(),
557                data_dir: state.data_root.join(palace_name),
558            };
559            let _handle = state
560                .registry
561                .create_palace(&state.data_root, palace)
562                .context("create_palace")?;
563            // Issue #60: auto-seed the KG with temporal metadata so every
564            // new palace has at least `created_at` + `bootstrapped_at`
565            // triples anchored to the palace name. We deliberately do NOT
566            // pass a project_path here — that requires an explicit user
567            // decision (which directory belongs to this palace?). Failures
568            // are non-fatal: the palace was already created, and the user
569            // can re-run `kg_bootstrap` manually if needed.
570            let bootstrap_summary =
571                match crate::bootstrap::bootstrap_palace(state, palace_name, None).await {
572                    Ok(r) => Some(serde_json::json!({
573                        "triples_asserted": r.triples_asserted,
574                        "project_subject": r.project_subject,
575                    })),
576                    Err(e) => {
577                        tracing::warn!(
578                            palace = %palace_name,
579                            "auto-bootstrap on palace_create failed: {e:#}",
580                        );
581                        None
582                    }
583                };
584            Ok(json!({
585                "palace_id": palace_name,
586                "status": "created",
587                "bootstrap": bootstrap_summary,
588            }))
589        }
590        "palace_list" => {
591            let root = state.data_root.clone();
592            let palaces = tokio::task::spawn_blocking(move || {
593                trusty_common::memory_core::PalaceRegistry::list_palaces(&root)
594            })
595            .await
596            .context("join list_palaces")??;
597            let ids: Vec<String> = palaces.iter().map(|p| p.id.as_str().to_string()).collect();
598            Ok(json!({"palaces": ids}))
599        }
600        "kg_assert" => {
601            let palace = resolve_palace(state, &args, "kg_assert")?;
602            let palace = palace.as_str();
603            let subject = args
604                .get("subject")
605                .and_then(|v| v.as_str())
606                .ok_or_else(|| anyhow!("kg_assert: missing 'subject'"))?
607                .to_string();
608            let predicate = args
609                .get("predicate")
610                .and_then(|v| v.as_str())
611                .ok_or_else(|| anyhow!("kg_assert: missing 'predicate'"))?
612                .to_string();
613            let object = args
614                .get("object")
615                .and_then(|v| v.as_str())
616                .ok_or_else(|| anyhow!("kg_assert: missing 'object'"))?
617                .to_string();
618            let confidence = args
619                .get("confidence")
620                .and_then(|v| v.as_f64())
621                .map(|c| (c as f32).clamp(0.0, 1.0))
622                .unwrap_or(1.0);
623            let provenance = args
624                .get("provenance")
625                .and_then(|v| v.as_str())
626                .map(|s| s.to_string());
627
628            let handle = open_palace_handle(state, palace)?;
629            let triple = Triple {
630                subject,
631                predicate,
632                object,
633                valid_from: chrono::Utc::now(),
634                valid_to: None,
635                confidence,
636                provenance,
637            };
638            let is_hot = crate::prompt_facts::is_hot_predicate(&triple.predicate);
639            handle.kg.assert(triple).await.context("kg.assert")?;
640            // Rebuild the prompt cache if this assertion touched a hot
641            // predicate; otherwise the cache stays valid and we skip the
642            // gather/format pass. Failures are logged but non-fatal — the
643            // write succeeded, the cache is only a denormalisation.
644            if is_hot {
645                if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
646                    tracing::warn!("rebuild_prompt_cache after kg_assert failed: {e:#}");
647                }
648            }
649            Ok(json!({"status": "asserted"}))
650        }
651        "add_alias" => {
652            let short = args
653                .get("short")
654                .and_then(|v| v.as_str())
655                .ok_or_else(|| anyhow!("add_alias: missing 'short'"))?
656                .to_string();
657            let full = args
658                .get("full")
659                .and_then(|v| v.as_str())
660                .ok_or_else(|| anyhow!("add_alias: missing 'full'"))?
661                .to_string();
662            let extra = args
663                .get("extra")
664                .and_then(|v| v.as_str())
665                .map(|s| s.to_string());
666
667            // `add_alias` is bound to the default palace when configured;
668            // otherwise it lands in whatever palace the caller names. This
669            // mirrors `resolve_palace`'s rule but without the helpful error
670            // — aliases are typically project-scoped via `--palace`.
671            let palace = resolve_palace(state, &args, "add_alias")?;
672            let handle = open_palace_handle(state, &palace)?;
673            // Compose the object: "<full>" or "<full> (<extra>)".
674            let object = match extra.as_deref() {
675                Some(e) if !e.is_empty() => format!("{full} ({e})"),
676                _ => full.clone(),
677            };
678            let triple = Triple {
679                subject: short.clone(),
680                predicate: "is_alias_for".to_string(),
681                object,
682                valid_from: chrono::Utc::now(),
683                valid_to: None,
684                confidence: 1.0,
685                provenance: Some("add_alias".to_string()),
686            };
687            handle
688                .kg
689                .assert(triple)
690                .await
691                .context("kg.assert (alias)")?;
692            if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
693                tracing::warn!("rebuild_prompt_cache after add_alias failed: {e:#}");
694            }
695            Ok(json!({"asserted": true, "short": short, "full": full}))
696        }
697        "list_prompt_facts" => {
698            let triples = crate::prompt_facts::gather_hot_triples(state).await?;
699            let payload: Vec<Value> = triples
700                .into_iter()
701                .map(|(subject, predicate, object)| {
702                    json!({"subject": subject, "predicate": predicate, "object": object})
703                })
704                .collect();
705            Ok(json!({"facts": payload}))
706        }
707        "remove_prompt_fact" => {
708            let subject = args
709                .get("subject")
710                .and_then(|v| v.as_str())
711                .ok_or_else(|| anyhow!("remove_prompt_fact: missing 'subject'"))?
712                .to_string();
713            let predicate = args
714                .get("predicate")
715                .and_then(|v| v.as_str())
716                .ok_or_else(|| anyhow!("remove_prompt_fact: missing 'predicate'"))?
717                .to_string();
718
719            // The prompt-fact surface spans every palace, so try retracting
720            // across all of them and report `true` if any palace closed an
721            // active interval. This matches `list_prompt_facts`' scope so
722            // round-tripping list→remove never silently no-ops because the
723            // caller didn't name the right palace.
724            let mut closed_total: usize = 0;
725            for palace_id in state.registry.list() {
726                if let Some(handle) = state.registry.get(&palace_id) {
727                    match handle.kg.retract(&subject, &predicate).await {
728                        Ok(n) => closed_total += n,
729                        Err(e) => tracing::warn!(
730                            palace = %palace_id.as_str(),
731                            "retract failed: {e:#}",
732                        ),
733                    }
734                }
735            }
736            if closed_total > 0 {
737                if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
738                    tracing::warn!("rebuild_prompt_cache after remove_prompt_fact failed: {e:#}");
739                }
740                Ok(json!({"removed": true, "closed": closed_total}))
741            } else {
742                Ok(json!({"removed": false, "reason": "not found"}))
743            }
744        }
745        "kg_query" => {
746            let palace = resolve_palace(state, &args, "kg_query")?;
747            let subject = args
748                .get("subject")
749                .and_then(|v| v.as_str())
750                .ok_or_else(|| anyhow!("kg_query: missing 'subject'"))?;
751            let handle = open_palace_handle(state, &palace)?;
752            let triples = handle
753                .kg
754                .query_active(subject)
755                .await
756                .context("kg.query_active")?;
757            let payload: Vec<Value> = triples
758                .iter()
759                .map(|t| {
760                    json!({
761                        "subject": t.subject,
762                        "predicate": t.predicate,
763                        "object": t.object,
764                        "valid_from": t.valid_from.to_rfc3339(),
765                        "valid_to": t.valid_to.as_ref().map(|d| d.to_rfc3339()),
766                        "confidence": t.confidence,
767                        "provenance": t.provenance,
768                    })
769                })
770                .collect();
771            // Issue #60: surface a hint when the requested subject has no
772            // active triples so the model knows `kg_bootstrap` and
773            // `kg_assert` exist. Empty payload is the only signal we have
774            // at the per-subject query layer; that's the user-visible
775            // "nothing here" case the hint is for.
776            let mut response = json!({"subject": subject, "triples": payload});
777            if crate::bootstrap::is_kg_empty_for_subject(&triples) {
778                response["hint"] = Value::String(crate::bootstrap::KG_EMPTY_HINT.to_string());
779            }
780            Ok(response)
781        }
782        "memory_list" => {
783            let palace = resolve_palace(state, &args, "memory_list")?;
784            let handle = open_palace_handle(state, &palace)?;
785            let room = args
786                .get("room")
787                .and_then(|v| v.as_str())
788                .map(|s| parse_room(Some(s)));
789            let tag = args
790                .get("tag")
791                .and_then(|v| v.as_str())
792                .map(|s| s.to_string());
793            let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
794            let drawers = handle.list_drawers(room, tag, limit);
795            let payload: Vec<Value> = drawers
796                .iter()
797                .map(|d| {
798                    json!({
799                        "drawer_id": d.id.to_string(),
800                        "content": d.content,
801                        "importance": d.importance,
802                        "tags": d.tags,
803                        "created_at": d.created_at.to_rfc3339(),
804                        "drawer_type": d.drawer_type.as_str(),
805                        "expires_at": d.expires_at.map(|t| t.to_rfc3339()),
806                    })
807                })
808                .collect();
809            Ok(json!({"palace": palace, "drawers": payload}))
810        }
811        "memory_forget" => {
812            let palace = resolve_palace(state, &args, "memory_forget")?;
813            let drawer_id_str = args
814                .get("drawer_id")
815                .and_then(|v| v.as_str())
816                .ok_or_else(|| anyhow!("memory_forget: missing 'drawer_id'"))?;
817            let drawer_id = Uuid::parse_str(drawer_id_str)
818                .map_err(|e| anyhow!("memory_forget: invalid drawer_id UUID: {e}"))?;
819            let handle = open_palace_handle(state, &palace)?;
820            handle.forget(drawer_id).await.context("forget")?;
821            Ok(json!({"status": "deleted", "drawer_id": drawer_id_str, "palace": palace}))
822        }
823        "palace_info" => {
824            let palace = resolve_palace(state, &args, "palace_info")?;
825            let handle = open_palace_handle(state, &palace)?;
826            let drawer_count = handle.list_drawers(None, None, usize::MAX).len();
827            let data_dir = handle
828                .data_dir
829                .as_ref()
830                .map(|p| p.to_string_lossy().to_string());
831            Ok(json!({
832                "id": handle.id.as_str(),
833                "name": handle.id.as_str(),
834                "drawer_count": drawer_count,
835                "data_dir": data_dir,
836            }))
837        }
838        "palace_compact" => {
839            let palace = resolve_palace(state, &args, "palace_compact")?;
840            let handle = open_palace_handle(state, &palace)?;
841            // Use the live drawer table (sourced from SQLite at palace open) as
842            // the authoritative valid-id set, then run the vector store's
843            // synchronous compaction on a blocking thread.
844            let valid_ids: std::collections::HashSet<Uuid> =
845                handle.drawers.read().iter().map(|d| d.id).collect();
846            let vector_store = handle.vector_store.clone();
847            let res = tokio::task::spawn_blocking(move || vector_store.compact_orphans(&valid_ids))
848                .await
849                .context("join palace_compact")??;
850            Ok(json!({
851                "palace": palace,
852                "total_checked": res.total_checked,
853                "orphans_removed": res.orphans_removed,
854                "index_size_before": res.index_size_before,
855                "index_size_after": res.index_size_after,
856            }))
857        }
858        "kg_gaps" => {
859            // Why (issue #53): Surface the cached community-detection output
860            // so the model can plan exploration without re-running Louvain.
861            // We deliberately do NOT recompute on the read path; the cache is
862            // refreshed by the dream cycle.
863            // What: Resolves the palace (explicit arg or daemon default),
864            // validates it exists by opening the handle, and returns the
865            // cached vec (an empty array when the dream cycle has not yet
866            // populated it).
867            // Test: `dispatch_kg_gaps_returns_cached`.
868            let palace = resolve_palace(state, &args, "kg_gaps")?;
869            // Ensure the palace exists; this also surfaces a useful error for
870            // typos in the palace argument.
871            let _handle = open_palace_handle(state, &palace)?;
872            let pid = PalaceId::new(&palace);
873            let cached = state.registry.get_gaps(&pid).unwrap_or_default();
874            let payload: Vec<Value> = cached
875                .into_iter()
876                .map(|g| {
877                    json!({
878                        "entities": g.entities,
879                        "internal_density": g.internal_density,
880                        "external_bridges": g.external_bridges,
881                        "suggested_exploration": g.suggested_exploration,
882                    })
883                })
884                .collect();
885            Ok(json!({ "palace": palace, "gaps": payload }))
886        }
887        "memory_recall_all" => {
888            let query = args
889                .get("q")
890                .and_then(|v| v.as_str())
891                .ok_or_else(|| anyhow!("memory_recall_all: missing 'q'"))?;
892            let top_k = args.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
893            let deep = args.get("deep").and_then(|v| v.as_bool()).unwrap_or(false);
894
895            // List every palace on disk and open a handle for each. Palaces
896            // that fail to open are skipped with a warning so a single bad
897            // namespace cannot fail the whole fan-out.
898            let root = state.data_root.clone();
899            let palaces = tokio::task::spawn_blocking(move || {
900                trusty_common::memory_core::PalaceRegistry::list_palaces(&root)
901            })
902            .await
903            .context("join list_palaces")??;
904
905            let mut handles = Vec::with_capacity(palaces.len());
906            for p in &palaces {
907                match state.registry.open_palace(&state.data_root, &p.id) {
908                    Ok(h) => handles.push(h),
909                    Err(e) => {
910                        tracing::warn!(palace = %p.id, "memory_recall_all: open failed: {e:#}")
911                    }
912                }
913            }
914
915            let embedder = state.embedder().await?;
916            let erased: std::sync::Arc<
917                dyn trusty_common::memory_core::embed::Embedder + Send + Sync,
918            > = embedder;
919            let results = recall_across_palaces(&handles, &erased, query, top_k, deep)
920                .await
921                .context("recall_across_palaces")?;
922
923            let payload: Vec<Value> = results
924                .iter()
925                .map(|r| {
926                    json!({
927                        "palace_id":  r.palace_id,
928                        "drawer_id":  r.result.drawer.id.to_string(),
929                        "content":    r.result.drawer.content,
930                        "importance": r.result.drawer.importance,
931                        "tags":       r.result.drawer.tags,
932                        "score":      r.result.score,
933                        "layer":      r.result.layer,
934                        "drawer_type": r.result.drawer.drawer_type.as_str(),
935                    })
936                })
937                .collect();
938            Ok(json!({ "query": query, "results": payload }))
939        }
940        "get_prompt_context" => {
941            // Why (issue #42): the model calls this at the start of each
942            // turn to pull aliases/conventions/facts into its working
943            // context. A `query` filter lets it scope the result to just
944            // the facts that matter for the current task — cheap on the
945            // wire and keeps the prompt focused.
946            // What: read-locks the cache once, clones the snapshot, then
947            // releases the lock so the formatter runs without blocking
948            // concurrent readers. When `query` is set we re-format a
949            // filtered subset of the raw triples; otherwise we serve the
950            // pre-formatted string directly.
951            let query = args
952                .get("query")
953                .and_then(|v| v.as_str())
954                .map(|s| s.trim().to_string())
955                .filter(|s| !s.is_empty());
956
957            let cache_snapshot = {
958                let guard = state
959                    .prompt_context_cache
960                    .read()
961                    .map_err(|e| anyhow!("prompt cache lock poisoned: {e}"))?;
962                guard.clone()
963            };
964
965            let body = if let Some(q) = query.as_deref() {
966                let needle = q.to_lowercase();
967                let filtered: Vec<(String, String, String)> = cache_snapshot
968                    .triples
969                    .into_iter()
970                    .filter(|(subject, _predicate, object)| {
971                        subject.to_lowercase().contains(&needle)
972                            || object.to_lowercase().contains(&needle)
973                    })
974                    .collect();
975                let formatted = crate::prompt_facts::build_prompt_context(&filtered);
976                if formatted.is_empty() {
977                    "No project context found matching your query.".to_string()
978                } else {
979                    formatted
980                }
981            } else if cache_snapshot.formatted.is_empty() {
982                "No prompt facts stored yet.".to_string()
983            } else {
984                cache_snapshot.formatted
985            };
986
987            // Return the body as a bare JSON string so the MCP envelope's
988            // `content[0].text` carries the formatted Markdown verbatim
989            // (ready to paste into the model's working context) without an
990            // extra `{"context": "..."}` wrapper that callers would have
991            // to strip.
992            Ok(Value::String(body))
993        }
994        "discover_aliases" => {
995            // Why (issue #42): Surface project shorthand automatically so the
996            // model never has to be told `tga == trusty-git-analytics`. The
997            // tool resolves a palace (default or argument), runs the
998            // pure-discovery scanner against the requested root (or cwd),
999            // checks each candidate against the palace's active KG, and
1000            // asserts only the new ones. The prompt cache is rebuilt once
1001            // at the end iff anything was actually asserted.
1002            // What: returns `{ discovered: [...], already_known: N, new: M }`
1003            // so callers can audit the delta.
1004            // Test: `dispatch_discover_aliases_inserts_new_and_dedupes`.
1005            let palace = resolve_palace(state, &args, "discover_aliases")?;
1006            let project_root = args
1007                .get("project_root")
1008                .and_then(|v| v.as_str())
1009                .map(std::path::PathBuf::from)
1010                .or_else(|| std::env::current_dir().ok())
1011                .ok_or_else(|| anyhow!("discover_aliases: no project_root and cwd unavailable"))?;
1012
1013            let discoveries = crate::discovery::discover_project_aliases(&project_root).await?;
1014
1015            let handle = open_palace_handle(state, &palace)?;
1016
1017            let mut already_known = 0usize;
1018            let mut newly_asserted = 0usize;
1019            let mut reported: Vec<Value> = Vec::with_capacity(discoveries.len());
1020
1021            for d in &discoveries {
1022                // Check active triples for the subject; if any matches the
1023                // same predicate + object, skip the assertion.
1024                let active = handle
1025                    .kg
1026                    .query_active(&d.short)
1027                    .await
1028                    .context("kg.query_active")?;
1029                let exists = active
1030                    .iter()
1031                    .any(|t| t.predicate == "is_alias_for" && t.object == d.full);
1032                if exists {
1033                    already_known += 1;
1034                    continue;
1035                }
1036
1037                let triple = Triple {
1038                    subject: d.short.clone(),
1039                    predicate: "is_alias_for".to_string(),
1040                    object: d.full.clone(),
1041                    valid_from: chrono::Utc::now(),
1042                    valid_to: None,
1043                    confidence: 1.0,
1044                    provenance: Some(format!("discover_aliases:{}", d.source.as_str())),
1045                };
1046                handle
1047                    .kg
1048                    .assert(triple)
1049                    .await
1050                    .context("kg.assert (discover)")?;
1051                newly_asserted += 1;
1052                reported.push(json!({
1053                    "short": d.short,
1054                    "full": d.full,
1055                    "source": d.source.as_str(),
1056                }));
1057            }
1058
1059            if newly_asserted > 0 {
1060                if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1061                    tracing::warn!("rebuild_prompt_cache after discover_aliases failed: {e:#}");
1062                }
1063            }
1064
1065            Ok(json!({
1066                "discovered": reported,
1067                "already_known": already_known,
1068                "new": newly_asserted,
1069                "palace": palace,
1070            }))
1071        }
1072        "kg_bootstrap" => {
1073            // Issue #60: scan well-known project files and seed the KG with
1074            // structured triples + temporal metadata. The handler resolves
1075            // the palace (explicit arg or daemon default) and forwards the
1076            // optional `project_path` to the bootstrap helper.
1077            let palace = resolve_palace(state, &args, "kg_bootstrap")?;
1078            let project_path = args
1079                .get("project_path")
1080                .and_then(|v| v.as_str())
1081                .map(std::path::PathBuf::from);
1082            let result =
1083                crate::bootstrap::bootstrap_palace(state, &palace, project_path.as_deref())
1084                    .await
1085                    .context("bootstrap_palace")?;
1086            // Rebuild the prompt cache: bootstrap can land hot predicates
1087            // (descriptions, language tags) that affect the prompt-facts
1088            // surface. Cache failures are non-fatal.
1089            if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1090                tracing::warn!("rebuild_prompt_cache after kg_bootstrap failed: {e:#}");
1091            }
1092            crate::bootstrap::result_to_json(&result)
1093        }
1094        other => anyhow::bail!("unknown tool: {other}"),
1095    }
1096}
1097
1098/// Serialize `recall` results into a JSON shape the MCP client can render.
1099fn serialize_recall(
1100    palace: &str,
1101    query: &str,
1102    results: Vec<trusty_common::memory_core::retrieval::RecallResult>,
1103) -> Value {
1104    let payload: Vec<Value> = results
1105        .iter()
1106        .map(|r| {
1107            json!({
1108                "drawer_id": r.drawer.id.to_string(),
1109                "content":   r.drawer.content,
1110                "score":     r.score,
1111                "layer":     r.layer,
1112                "tags":      r.drawer.tags,
1113                "importance": r.drawer.importance,
1114                "drawer_type": r.drawer.drawer_type.as_str(),
1115            })
1116        })
1117        .collect();
1118    json!({
1119        "palace": palace,
1120        "query": query,
1121        "results": payload,
1122    })
1123}
1124
1125#[cfg(test)]
1126mod tests {
1127    use super::*;
1128    use crate::AppState;
1129
1130    fn test_state() -> AppState {
1131        let tmp = tempfile::tempdir().expect("tempdir");
1132        let root = tmp.path().to_path_buf();
1133        std::mem::forget(tmp);
1134        AppState::new(root)
1135    }
1136
1137    /// Why: Issue #26 — when the server is started with `--palace`, the
1138    /// `tools/list` schema must drop `palace` from the `required` array for
1139    /// every tool that accepts it, so MCP clients know it's optional.
1140    /// Test: Build the schema both ways and check the required arrays.
1141    #[test]
1142    fn tool_definitions_drops_palace_required_when_default_set() {
1143        let with_default = tool_definitions_with(true);
1144        let without_default = tool_definitions_with(false);
1145        for (name, palace_required_when_no_default) in [
1146            ("memory_remember", true),
1147            ("memory_recall", true),
1148            ("memory_recall_deep", true),
1149            ("memory_list", true),
1150            ("memory_forget", true),
1151            ("palace_info", true),
1152            ("palace_compact", true),
1153            ("kg_assert", true),
1154            ("kg_query", true),
1155        ] {
1156            for (defs, has_default) in [(&with_default, true), (&without_default, false)] {
1157                let tools = defs["tools"].as_array().unwrap();
1158                let tool = tools.iter().find(|t| t["name"] == name).unwrap();
1159                let required: Vec<&str> = tool["inputSchema"]["required"]
1160                    .as_array()
1161                    .unwrap()
1162                    .iter()
1163                    .filter_map(|v| v.as_str())
1164                    .collect();
1165                let palace_required = required.contains(&"palace");
1166                let expected = palace_required_when_no_default && !has_default;
1167                assert_eq!(
1168                    palace_required, expected,
1169                    "tool={name} has_default={has_default} required={required:?}"
1170                );
1171            }
1172        }
1173    }
1174
1175    #[test]
1176    fn tool_definitions_lists_all_tools() {
1177        let defs = tool_definitions();
1178        let tools = defs
1179            .get("tools")
1180            .and_then(|t| t.as_array())
1181            .expect("tools array");
1182        assert_eq!(tools.len(), 20);
1183        let names: Vec<&str> = tools
1184            .iter()
1185            .filter_map(|t| t.get("name").and_then(|n| n.as_str()))
1186            .collect();
1187        for expected in [
1188            "memory_remember",
1189            "memory_note",
1190            "memory_recall",
1191            "memory_recall_deep",
1192            "memory_list",
1193            "memory_forget",
1194            "palace_create",
1195            "palace_list",
1196            "palace_info",
1197            "palace_compact",
1198            "kg_assert",
1199            "kg_query",
1200            "memory_recall_all",
1201            "kg_gaps",
1202            "add_alias",
1203            "list_prompt_facts",
1204            "remove_prompt_fact",
1205            "get_prompt_context",
1206            "discover_aliases",
1207            "kg_bootstrap",
1208        ] {
1209            assert!(names.contains(&expected), "missing tool: {expected}");
1210        }
1211    }
1212
1213    /// Why: Confirm `palace_create` actually persists a palace under the
1214    /// configured data root and `palace_list` then sees it.
1215    #[tokio::test]
1216    async fn dispatch_palace_create_persists() {
1217        let state = test_state();
1218        let created = dispatch_tool(&state, "palace_create", json!({"name": "alpha"}))
1219            .await
1220            .expect("palace_create");
1221        assert_eq!(created["palace_id"], "alpha");
1222
1223        let listed = dispatch_tool(&state, "palace_list", json!({}))
1224            .await
1225            .expect("palace_list");
1226        let ids = listed["palaces"].as_array().expect("palaces array");
1227        assert!(ids.iter().any(|v| v.as_str() == Some("alpha")));
1228    }
1229
1230    /// Why: End-to-end confirmation that a remembered drawer is recallable
1231    /// through the MCP tool surface using the real embedder + retrieval path.
1232    #[tokio::test]
1233    async fn dispatch_remember_then_recall() {
1234        let state = test_state();
1235        let _ = dispatch_tool(&state, "palace_create", json!({"name": "beta"}))
1236            .await
1237            .expect("palace_create");
1238
1239        let remembered = dispatch_tool(
1240            &state,
1241            "memory_remember",
1242            json!({
1243                "palace": "beta",
1244                "text": "Quokkas are the happiest marsupials in Australia by general consensus",
1245                "room": "General",
1246                "tags": ["wildlife"],
1247            }),
1248        )
1249        .await
1250        .expect("memory_remember");
1251        assert!(remembered["drawer_id"].as_str().is_some());
1252
1253        let recalled = dispatch_tool(
1254            &state,
1255            "memory_recall",
1256            json!({"palace": "beta", "query": "Quokkas marsupials Australia", "top_k": 5}),
1257        )
1258        .await
1259        .expect("memory_recall");
1260        let results = recalled["results"].as_array().expect("results");
1261        assert!(
1262            results
1263                .iter()
1264                .any(|r| r["content"].as_str().unwrap_or("").contains("Quokkas")),
1265            "expected to recall the Quokkas drawer; got {results:?}"
1266        );
1267    }
1268
1269    /// Why: Confirm `kg_assert` writes a triple and `kg_query` returns it
1270    /// through the MCP tool surface.
1271    #[tokio::test]
1272    async fn dispatch_kg_assert_then_query() {
1273        let state = test_state();
1274        let _ = dispatch_tool(&state, "palace_create", json!({"name": "gamma"}))
1275            .await
1276            .expect("palace_create");
1277
1278        let _ = dispatch_tool(
1279            &state,
1280            "kg_assert",
1281            json!({
1282                "palace": "gamma",
1283                "subject": "alice",
1284                "predicate": "works_at",
1285                "object": "Acme",
1286                "confidence": 0.9,
1287                "provenance": "test",
1288            }),
1289        )
1290        .await
1291        .expect("kg_assert");
1292
1293        let queried = dispatch_tool(
1294            &state,
1295            "kg_query",
1296            json!({"palace": "gamma", "subject": "alice"}),
1297        )
1298        .await
1299        .expect("kg_query");
1300        let triples = queried["triples"].as_array().expect("triples array");
1301        assert_eq!(triples.len(), 1);
1302        assert_eq!(triples[0]["object"], "Acme");
1303        assert_eq!(triples[0]["predicate"], "works_at");
1304    }
1305
1306    /// Why: Issue #53 — verify the MCP `kg_gaps` tool returns whatever was
1307    /// last cached on the registry. Two cases: empty cache returns an empty
1308    /// array, and a seeded cache returns the cached entries verbatim.
1309    /// What: Creates a palace, dispatches `kg_gaps` (expects empty), then
1310    /// directly seeds the registry cache via `set_gaps` and dispatches again
1311    /// to confirm the entry round-trips through serialization.
1312    /// Test: This test itself.
1313    #[tokio::test]
1314    async fn dispatch_kg_gaps_returns_cached() {
1315        use trusty_common::memory_core::community::KnowledgeGap;
1316
1317        let state = test_state();
1318        let _ = dispatch_tool(&state, "palace_create", json!({"name": "delta"}))
1319            .await
1320            .expect("palace_create");
1321
1322        // Empty cache → empty gaps list (not an error).
1323        let initial = dispatch_tool(&state, "kg_gaps", json!({"palace": "delta"}))
1324            .await
1325            .expect("kg_gaps empty");
1326        let gaps = initial["gaps"].as_array().expect("gaps array");
1327        assert_eq!(gaps.len(), 0);
1328
1329        // Seed the cache and re-dispatch.
1330        state.registry.set_gaps(
1331            PalaceId::new("delta"),
1332            vec![KnowledgeGap {
1333                entities: vec!["x".to_string(), "y".to_string()],
1334                internal_density: 0.05,
1335                external_bridges: 0,
1336                suggested_exploration: "Explore connections between x and y".to_string(),
1337            }],
1338        );
1339        let seeded = dispatch_tool(&state, "kg_gaps", json!({"palace": "delta"}))
1340            .await
1341            .expect("kg_gaps seeded");
1342        let gaps = seeded["gaps"].as_array().expect("gaps array");
1343        assert_eq!(gaps.len(), 1);
1344        assert_eq!(gaps[0]["entities"][0], "x");
1345        assert_eq!(gaps[0]["external_bridges"], 0);
1346        assert!(gaps[0]["suggested_exploration"]
1347            .as_str()
1348            .unwrap()
1349            .contains("x"));
1350    }
1351
1352    /// Why: Issue #42 — `add_alias` must (a) assert the triple in the KG,
1353    /// (b) cause `list_prompt_facts` to surface it, (c) refresh the prompt
1354    /// cache so `prompts/get` returns it, and (d) be reversible via
1355    /// `remove_prompt_fact`.
1356    #[tokio::test]
1357    async fn add_alias_round_trip_through_prompt_cache() {
1358        let tmp = tempfile::tempdir().expect("tempdir");
1359        let root = tmp.path().to_path_buf();
1360        std::mem::forget(tmp);
1361        let state = AppState::new(root).with_default_palace(Some("ctx".to_string()));
1362
1363        // Pre-create the default palace.
1364        let _ = dispatch_tool(&state, "palace_create", json!({"name": "ctx"}))
1365            .await
1366            .expect("palace_create");
1367
1368        // (a) add_alias asserts the triple.
1369        let added = dispatch_tool(
1370            &state,
1371            "add_alias",
1372            json!({"short": "tga", "full": "trusty-git-analytics"}),
1373        )
1374        .await
1375        .expect("add_alias");
1376        assert_eq!(added["asserted"], true);
1377        assert_eq!(added["short"], "tga");
1378
1379        // (b) list_prompt_facts surfaces it.
1380        let listed = dispatch_tool(&state, "list_prompt_facts", json!({}))
1381            .await
1382            .expect("list_prompt_facts");
1383        let facts = listed["facts"].as_array().expect("facts array");
1384        assert!(
1385            facts.iter().any(|f| f["subject"] == "tga"
1386                && f["predicate"] == "is_alias_for"
1387                && f["object"] == "trusty-git-analytics"),
1388            "expected tga alias in facts; got {facts:?}"
1389        );
1390
1391        // (c) prompt cache has been refreshed with the formatted block.
1392        {
1393            let guard = state.prompt_context_cache.read().expect("read lock");
1394            assert!(
1395                guard.formatted.contains("tga → trusty-git-analytics"),
1396                "prompt cache should contain alias; got: {}",
1397                guard.formatted
1398            );
1399        }
1400
1401        // add_alias with `extra` appends parenthetical context.
1402        let _ = dispatch_tool(
1403            &state,
1404            "add_alias",
1405            json!({"short": "tm", "full": "trusty-memory", "extra": "the MCP frontend"}),
1406        )
1407        .await
1408        .expect("add_alias with extra");
1409        {
1410            let guard = state.prompt_context_cache.read().expect("read lock");
1411            assert!(
1412                guard
1413                    .formatted
1414                    .contains("tm → trusty-memory (the MCP frontend)"),
1415                "alias with extra not formatted; got: {}",
1416                guard.formatted
1417            );
1418        }
1419
1420        // (d) remove_prompt_fact retracts and refreshes.
1421        let removed = dispatch_tool(
1422            &state,
1423            "remove_prompt_fact",
1424            json!({"subject": "tga", "predicate": "is_alias_for"}),
1425        )
1426        .await
1427        .expect("remove_prompt_fact");
1428        assert_eq!(removed["removed"], true);
1429        {
1430            let guard = state.prompt_context_cache.read().expect("read lock");
1431            assert!(
1432                !guard.formatted.contains("tga → trusty-git-analytics"),
1433                "retracted alias still in cache: {}",
1434                guard.formatted
1435            );
1436            assert!(
1437                guard.formatted.contains("tm → trusty-memory"),
1438                "non-retracted alias missing from cache: {}",
1439                guard.formatted
1440            );
1441        }
1442
1443        // Removing a non-existent fact reports not found.
1444        let missing = dispatch_tool(
1445            &state,
1446            "remove_prompt_fact",
1447            json!({"subject": "nope", "predicate": "is_alias_for"}),
1448        )
1449        .await
1450        .expect("remove_prompt_fact missing");
1451        assert_eq!(missing["removed"], false);
1452    }
1453
1454    /// Why (issue #42): `get_prompt_context` is the per-message replacement
1455    /// for the deprecated `prompts/get` flow. It must (a) return a hint when
1456    /// the cache is empty, (b) return the formatted block when populated,
1457    /// and (c) filter by `query` against subject/object case-insensitively.
1458    #[tokio::test]
1459    async fn get_prompt_context_serves_cache_and_filters() {
1460        let state = test_state();
1461
1462        // (a) empty cache -> "No prompt facts stored yet."
1463        let resp = dispatch_tool(&state, "get_prompt_context", json!({}))
1464            .await
1465            .expect("get_prompt_context empty");
1466        assert_eq!(resp.as_str().unwrap(), "No prompt facts stored yet.");
1467
1468        // Populate the cache by hand with a known triple set.
1469        {
1470            let mut guard = state.prompt_context_cache.write().expect("write lock");
1471            let triples = vec![
1472                (
1473                    "tga".to_string(),
1474                    "is_alias_for".to_string(),
1475                    "trusty-git-analytics".to_string(),
1476                ),
1477                (
1478                    "tm".to_string(),
1479                    "is_alias_for".to_string(),
1480                    "trusty-memory".to_string(),
1481                ),
1482                (
1483                    "fact-1".to_string(),
1484                    "is_fact".to_string(),
1485                    "MSRV is 1.88".to_string(),
1486                ),
1487            ];
1488            let formatted = crate::prompt_facts::build_prompt_context(&triples);
1489            *guard = crate::prompt_facts::PromptFactsCache { triples, formatted };
1490        }
1491
1492        // (b) unfiltered -> serves the full formatted block.
1493        let resp = dispatch_tool(&state, "get_prompt_context", json!({}))
1494            .await
1495            .expect("get_prompt_context populated");
1496        let text = resp.as_str().expect("string body");
1497        assert!(text.contains("tga → trusty-git-analytics"));
1498        assert!(text.contains("tm → trusty-memory"));
1499        assert!(text.contains("MSRV is 1.88"));
1500
1501        // (c) filtered to "tga" -> only the matching alias.
1502        let resp = dispatch_tool(&state, "get_prompt_context", json!({"query": "tga"}))
1503            .await
1504            .expect("get_prompt_context filtered");
1505        let text = resp.as_str().expect("string body");
1506        assert!(text.contains("tga → trusty-git-analytics"));
1507        assert!(!text.contains("tm → trusty-memory"));
1508        assert!(!text.contains("MSRV is 1.88"));
1509
1510        // Case-insensitive match on the object side.
1511        let resp = dispatch_tool(&state, "get_prompt_context", json!({"query": "MEMORY"}))
1512            .await
1513            .expect("get_prompt_context case-insensitive");
1514        let text = resp.as_str().expect("string body");
1515        assert!(text.contains("tm → trusty-memory"));
1516        assert!(!text.contains("tga → trusty-git-analytics"));
1517
1518        // No match -> "No project context found matching your query."
1519        let resp = dispatch_tool(
1520            &state,
1521            "get_prompt_context",
1522            json!({"query": "zzz-nonexistent"}),
1523        )
1524        .await
1525        .expect("get_prompt_context no-match");
1526        assert_eq!(
1527            resp.as_str().unwrap(),
1528            "No project context found matching your query."
1529        );
1530
1531        // Empty/whitespace `query` is treated as no filter.
1532        let resp = dispatch_tool(&state, "get_prompt_context", json!({"query": "   "}))
1533            .await
1534            .expect("get_prompt_context whitespace");
1535        let text = resp.as_str().expect("string body");
1536        assert!(text.contains("tga → trusty-git-analytics"));
1537        assert!(text.contains("tm → trusty-memory"));
1538    }
1539
1540    /// Why (issue #42): `discover_aliases` must (a) auto-discover the
1541    /// canonical workspace shorthand (`tga → trusty-git-analytics`),
1542    /// (b) assert each discovery as an `is_alias_for` triple, (c) refresh
1543    /// the prompt cache, and (d) dedupe on a second invocation — the second
1544    /// call should report zero new and N already_known.
1545    /// Test: this test itself.
1546    #[tokio::test]
1547    async fn dispatch_discover_aliases_inserts_new_and_dedupes() {
1548        let tmp = tempfile::tempdir().expect("tempdir");
1549        let root = tmp.path().to_path_buf();
1550        std::mem::forget(tmp);
1551        let state = AppState::new(root).with_default_palace(Some("disc".to_string()));
1552        let _ = dispatch_tool(&state, "palace_create", json!({"name": "disc"}))
1553            .await
1554            .expect("palace_create");
1555
1556        // Use the live workspace root so the discovery actually finds
1557        // something. CARGO_MANIFEST_DIR points at the crate dir; walk up
1558        // twice to the workspace root.
1559        let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1560            .parent()
1561            .and_then(|p| p.parent())
1562            .expect("workspace root")
1563            .to_path_buf();
1564
1565        let first = dispatch_tool(
1566            &state,
1567            "discover_aliases",
1568            json!({"project_root": workspace_root.to_string_lossy()}),
1569        )
1570        .await
1571        .expect("discover_aliases first");
1572
1573        let new_count = first["new"].as_u64().expect("new is u64");
1574        assert!(new_count > 0, "expected new discoveries on first call");
1575        let discovered = first["discovered"].as_array().expect("discovered array");
1576        assert!(
1577            discovered
1578                .iter()
1579                .any(|d| d["short"] == "tga" && d["full"] == "trusty-git-analytics"),
1580            "expected tga alias in discoveries; got {discovered:?}"
1581        );
1582
1583        // The prompt cache must contain the new alias after discovery.
1584        {
1585            let guard = state.prompt_context_cache.read().expect("read lock");
1586            assert!(
1587                guard.formatted.contains("tga → trusty-git-analytics"),
1588                "prompt cache missing tga alias after discover_aliases; got: {}",
1589                guard.formatted
1590            );
1591        }
1592
1593        // Second invocation should report zero new and at least `new_count`
1594        // already_known — the same discoveries are now in the KG.
1595        let second = dispatch_tool(
1596            &state,
1597            "discover_aliases",
1598            json!({"project_root": workspace_root.to_string_lossy()}),
1599        )
1600        .await
1601        .expect("discover_aliases second");
1602        assert_eq!(second["new"].as_u64(), Some(0), "expected 0 new on rerun");
1603        let already_known = second["already_known"].as_u64().expect("already_known");
1604        assert!(
1605            already_known >= new_count,
1606            "expected already_known >= {new_count}, got {already_known}"
1607        );
1608    }
1609
1610    /// Why (issue #60): `palace_create` must auto-seed temporal metadata so
1611    /// every new palace has at least `created_at` + `bootstrapped_at`
1612    /// triples — without auto-bootstrap, brand-new palaces had a zero-triple
1613    /// KG and no signal to users that they were supposed to seed it.
1614    /// Test: create a palace, then query the seeded subject (the palace id)
1615    /// and confirm the temporal triples are present.
1616    #[tokio::test]
1617    async fn palace_create_auto_seeds_temporal_metadata() {
1618        let state = test_state();
1619        let created = dispatch_tool(&state, "palace_create", json!({"name": "auto"}))
1620            .await
1621            .expect("palace_create");
1622        assert_eq!(created["palace_id"], "auto");
1623        // bootstrap summary is present on success
1624        let summary = &created["bootstrap"];
1625        assert!(summary.is_object(), "expected bootstrap summary object");
1626        assert!(summary["triples_asserted"].as_u64().unwrap_or(0) >= 2);
1627
1628        let queried = dispatch_tool(
1629            &state,
1630            "kg_query",
1631            json!({"palace": "auto", "subject": "auto"}),
1632        )
1633        .await
1634        .expect("kg_query");
1635        let triples = queried["triples"].as_array().expect("triples");
1636        let predicates: Vec<&str> = triples
1637            .iter()
1638            .filter_map(|t| t["predicate"].as_str())
1639            .collect();
1640        assert!(
1641            predicates.contains(&"created_at"),
1642            "expected created_at after palace_create; got {predicates:?}",
1643        );
1644        assert!(
1645            predicates.contains(&"bootstrapped_at"),
1646            "expected bootstrapped_at after palace_create; got {predicates:?}",
1647        );
1648        // Hint must NOT appear when triples are present.
1649        assert!(
1650            queried.get("hint").is_none(),
1651            "hint should be absent when triples exist"
1652        );
1653    }
1654
1655    /// Why (issue #60): `kg_query` against a subject with no triples must
1656    /// surface a `hint` field pointing the user at `kg_bootstrap` /
1657    /// `kg_assert`. Without the hint, brand-new palaces returned empty
1658    /// arrays with no breadcrumb back to the seeding tools.
1659    #[tokio::test]
1660    async fn kg_query_emits_hint_when_palace_empty() {
1661        let state = test_state();
1662        let _ = dispatch_tool(&state, "palace_create", json!({"name": "hinted"}))
1663            .await
1664            .expect("palace_create");
1665        // Query a subject that auto-bootstrap did NOT seed.
1666        let queried = dispatch_tool(
1667            &state,
1668            "kg_query",
1669            json!({"palace": "hinted", "subject": "unrelated-subject"}),
1670        )
1671        .await
1672        .expect("kg_query");
1673        assert_eq!(queried["triples"].as_array().unwrap().len(), 0);
1674        let hint = queried["hint"].as_str().expect("hint field present");
1675        assert!(hint.contains("kg_bootstrap"));
1676        assert!(hint.contains("kg_assert"));
1677    }
1678
1679    /// Why (issue #60): `kg_bootstrap` against the live workspace root must
1680    /// extract Cargo facts (language, version, rust-version) and the git
1681    /// origin URL, then make them queryable through `kg_query`.
1682    #[tokio::test]
1683    async fn kg_bootstrap_seeds_workspace_facts() {
1684        let state = test_state();
1685        let _ = dispatch_tool(&state, "palace_create", json!({"name": "ws"}))
1686            .await
1687            .expect("palace_create");
1688
1689        let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1690            .parent()
1691            .and_then(|p| p.parent())
1692            .expect("workspace root")
1693            .to_path_buf();
1694
1695        let result = dispatch_tool(
1696            &state,
1697            "kg_bootstrap",
1698            json!({"palace": "ws", "project_path": workspace_root.to_string_lossy()}),
1699        )
1700        .await
1701        .expect("kg_bootstrap");
1702        assert!(result["triples_asserted"].as_u64().unwrap() > 0);
1703        let subject = result["project_subject"]
1704            .as_str()
1705            .expect("project_subject")
1706            .to_string();
1707
1708        // Verify the workspace facts are queryable.
1709        let queried = dispatch_tool(
1710            &state,
1711            "kg_query",
1712            json!({"palace": "ws", "subject": subject}),
1713        )
1714        .await
1715        .expect("kg_query");
1716        let triples = queried["triples"].as_array().expect("triples");
1717        let predicates: Vec<&str> = triples
1718            .iter()
1719            .filter_map(|t| t["predicate"].as_str())
1720            .collect();
1721        // Either Rust language (single-crate manifest) or workspace member
1722        // triples must appear; the trusty-tools root manifest is a workspace
1723        // so we expect has_workspace_member.
1724        assert!(
1725            predicates.contains(&"has_workspace_member") || predicates.contains(&"has_language"),
1726            "expected workspace/language fact; got {predicates:?}",
1727        );
1728        // source_repo from .git/config.
1729        assert!(
1730            predicates.contains(&"source_repo"),
1731            "expected source_repo from .git/config; got {predicates:?}",
1732        );
1733        // Temporal metadata always.
1734        assert!(predicates.contains(&"bootstrapped_at"));
1735    }
1736
1737    #[tokio::test]
1738    async fn dispatch_unknown_tool_errors() {
1739        let state = test_state();
1740        let err = dispatch_tool(&state, "does_not_exist", json!({}))
1741            .await
1742            .expect_err("should error");
1743        assert!(err.to_string().contains("unknown tool"));
1744    }
1745}