1use 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
33fn 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
52pub 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
72pub fn tool_definitions() -> Value {
83 tool_definitions_with(false)
84}
85
86pub 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
371fn 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
393fn 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
405fn 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
426pub 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 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 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 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 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 let palace = resolve_palace(state, &args, "add_alias")?;
672 let handle = open_palace_handle(state, &palace)?;
673 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 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 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 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 let palace = resolve_palace(state, &args, "kg_gaps")?;
869 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 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 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 Ok(Value::String(body))
993 }
994 "discover_aliases" => {
995 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 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 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 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
1098fn 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 #[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 #[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 #[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 #[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 #[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 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 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 #[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 let _ = dispatch_tool(&state, "palace_create", json!({"name": "ctx"}))
1365 .await
1366 .expect("palace_create");
1367
1368 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 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 {
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 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 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 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 #[tokio::test]
1459 async fn get_prompt_context_serves_cache_and_filters() {
1460 let state = test_state();
1461
1462 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 {
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 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 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 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 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 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 #[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 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 {
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 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 #[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 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 assert!(
1650 queried.get("hint").is_none(),
1651 "hint should be absent when triples exist"
1652 );
1653 }
1654
1655 #[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 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 #[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 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 assert!(
1725 predicates.contains(&"has_workspace_member") || predicates.contains(&"has_language"),
1726 "expected workspace/language fact; got {predicates:?}",
1727 );
1728 assert!(
1730 predicates.contains(&"source_repo"),
1731 "expected source_repo from .git/config; got {predicates:?}",
1732 );
1733 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}