1use crate::attribution::{session_tag_from_tags, CreatorInfo, CreatorSource, MCP_CLIENT_NAME};
23use crate::kg_extract::{extract_triples, ExtractInput};
24use crate::{ActivitySource, AppState, DaemonEvent};
25use anyhow::{anyhow, Context, Result};
26use serde_json::{json, Value};
27use trusty_common::memory_core::filter::{FilterConfig, MCP_MIN_TOKENS};
28use trusty_common::memory_core::palace::{Palace, PalaceId, RoomType};
29use trusty_common::memory_core::retrieval::{
30 recall, recall_across_palaces, recall_deep, RememberOptions,
31};
32use trusty_common::memory_core::store::kg::Triple;
33use uuid::Uuid;
34
35fn lookup_palace_name(state: &AppState, palace_id: &str) -> String {
58 state
59 .palace_names
60 .get(palace_id)
61 .map(|entry| entry.value().clone())
62 .unwrap_or_else(|| palace_id.to_string())
63}
64
65const CONTENT_GATE_MIN_WORDS: usize = 4;
77
78fn content_gate(content: &str, context: Option<&str>) -> Option<String> {
94 let trimmed = content.trim();
95 let word_count = trimmed.split_whitespace().count();
96 let context_clean = context.map(str::trim).filter(|s| !s.is_empty());
101 if let Some(ctx) = context_clean {
102 return Some(format!("{ctx}\n\n---\n\n{content}"));
103 }
104 if word_count < CONTENT_GATE_MIN_WORDS {
105 return None;
106 }
107 Some(content.to_string())
108}
109
110const BLOCKLIST_PATTERNS: &[&str] = &[
127 "Tool use: ", "Claude Code session", ];
130
131const DEDUP_WINDOW_MINUTES: i64 = 5;
142
143const DEDUP_SCAN_LIMIT: usize = 50;
154
155const DEDUP_SIMILARITY_THRESHOLD: f64 = 0.92;
167
168fn blocklist_gate(content: &str) -> bool {
181 let trimmed = content.trim_start();
182 BLOCKLIST_PATTERNS.iter().any(|pat| trimmed.contains(pat))
183}
184
185fn dedup_gate(handle: &trusty_common::memory_core::PalaceHandle, content: &str) -> bool {
202 let trimmed = content.trim();
203 if trimmed.is_empty() {
204 return false;
205 }
206 let now = chrono::Utc::now();
207 let window_start = now - chrono::Duration::minutes(DEDUP_WINDOW_MINUTES);
208 let recent = handle.list_drawers(None, None, DEDUP_SCAN_LIMIT);
209 recent
210 .iter()
211 .filter(|d| d.created_at >= window_start)
212 .any(|d| strsim::jaro_winkler(trimmed, d.content.trim()) > DEDUP_SIMILARITY_THRESHOLD)
213}
214
215fn mcp_remember_opts(force: bool) -> RememberOptions {
223 let filter = FilterConfig {
224 min_tokens: MCP_MIN_TOKENS,
225 ..FilterConfig::default()
226 };
227 RememberOptions {
228 filter,
229 force,
230 ..RememberOptions::default()
231 }
232}
233
234pub struct MemoryMcpServer;
241
242impl MemoryMcpServer {
243 pub fn new() -> Self {
244 Self
245 }
246}
247
248impl Default for MemoryMcpServer {
249 fn default() -> Self {
250 Self::new()
251 }
252}
253
254pub fn tool_definitions() -> Value {
265 tool_definitions_with(false)
266}
267
268pub fn tool_definitions_with(has_default: bool) -> Value {
279 let memory_remember_required: Vec<&str> = if has_default {
280 vec!["text"]
281 } else {
282 vec!["palace", "text"]
283 };
284 let memory_recall_required: Vec<&str> = if has_default {
285 vec!["query"]
286 } else {
287 vec!["palace", "query"]
288 };
289 let kg_assert_required: Vec<&str> = if has_default {
290 vec!["subject", "predicate", "object"]
291 } else {
292 vec!["palace", "subject", "predicate", "object"]
293 };
294 let kg_query_required: Vec<&str> = if has_default {
295 vec!["subject"]
296 } else {
297 vec!["palace", "subject"]
298 };
299 let memory_list_required: Vec<&str> = if has_default { vec![] } else { vec!["palace"] };
300 let memory_forget_required: Vec<&str> = if has_default {
301 vec!["drawer_id"]
302 } else {
303 vec!["palace", "drawer_id"]
304 };
305 let palace_info_required: Vec<&str> = if has_default { vec![] } else { vec!["palace"] };
306 let palace_compact_required: Vec<&str> = if has_default { vec![] } else { vec!["palace"] };
307 let memory_note_required: Vec<&str> = if has_default {
308 vec!["content"]
309 } else {
310 vec!["palace", "content"]
311 };
312
313 json!({
314 "tools": [
315 {
316 "name": "memory_remember",
317 "description": "Store a memory (drawer) in a palace room. Content is filtered for signal vs. noise (issue #61): rejects empty/very short content, raw tool/commit output, and code-only blobs. Issue #215: very short standalone content (< 4 words) is silently dropped unless a `context` is supplied, in which case the context is prepended so the stored memory has standalone value. Pass force=true to bypass filtering, or use memory_note for short curated facts.",
318 "inputSchema": {
319 "type": "object",
320 "properties": {
321 "palace": {"type": "string", "description": "Palace ID (optional if server started with --palace)"},
322 "text": {"type": "string", "description": "Memory content"},
323 "room": {"type": "string", "description": "Room type (optional)"},
324 "tags": {"type": "array", "items": {"type": "string"}},
325 "force": {"type": "boolean", "description": "Bypass the signal/noise filter. Use sparingly — intended for explicit operator overrides.", "default": false},
326 "context": {"type": "string", "description": "Optional surrounding context. When supplied alongside very short content (< 4 words), the context is prepended (separated by `---`) so the stored memory has standalone meaning; without it, short content is dropped (issue #215)."}
327 },
328 "required": memory_remember_required,
329 }
330 },
331 {
332 "name": "memory_note",
333 "description": "Curated shortcut for short, high-signal facts (\"User prefers snake_case\", \"Deploy target is prod-east\"). Bypasses the token-length filter but still rejects auto-capture noise. Stored as DrawerType::UserFact with importance 1.0. Issue #215: a `context` argument can be supplied to wrap an otherwise meaningless single-word response.",
334 "inputSchema": {
335 "type": "object",
336 "properties": {
337 "palace": {"type": "string"},
338 "content": {"type": "string", "description": "Brief fact to remember"},
339 "tags": {"type": "array", "items": {"type": "string"}},
340 "context": {"type": "string", "description": "Optional surrounding context. Prepended to `content` (separated by `---`) when supplied; with very short content (< 4 words) and no context the write is skipped (issue #215)."}
341 },
342 "required": memory_note_required,
343 }
344 },
345 {
346 "name": "memory_recall",
347 "description": "Recall memories using L0+L1+L2 progressive retrieval.",
348 "inputSchema": {
349 "type": "object",
350 "properties": {
351 "palace": {"type": "string"},
352 "query": {"type": "string"},
353 "top_k": {"type": "integer", "default": 10}
354 },
355 "required": memory_recall_required,
356 }
357 },
358 {
359 "name": "memory_recall_deep",
360 "description": "Deep recall using L3 full HNSW search.",
361 "inputSchema": {
362 "type": "object",
363 "properties": {
364 "palace": {"type": "string"},
365 "query": {"type": "string"},
366 "top_k": {"type": "integer", "default": 10}
367 },
368 "required": memory_recall_required,
369 }
370 },
371 {
372 "name": "palace_create",
373 "description": "Create a new memory palace.",
374 "inputSchema": {
375 "type": "object",
376 "properties": {
377 "name": {"type": "string"},
378 "description": {"type": "string"},
379 "cwd": {"type": "string", "description": "Optional caller working directory used for palace-name enforcement. Pass the project root (or any path inside it) so the pin file at `.trusty-tools/trusty-memory.yaml` is honoured. When omitted, the daemon's own cwd is used (rarely meaningful for remote calls)."}
380 },
381 "required": ["name"]
382 }
383 },
384 {
385 "name": "palace_list",
386 "description": "List all palaces on this machine.",
387 "inputSchema": {"type": "object", "properties": {}}
388 },
389 {
390 "name": "palace_delete",
391 "description": "Delete an entire memory palace, including its drawers, vectors, and knowledge graph. Refuses to delete a non-empty palace unless `force=true` is set.",
392 "inputSchema": {
393 "type": "object",
394 "properties": {
395 "palace_id": {"type": "string", "description": "Id of the palace to delete."},
396 "force": {"type": "boolean", "description": "Required when the palace still has drawers; defaults to false.", "default": false}
397 },
398 "required": ["palace_id"]
399 }
400 },
401 {
402 "name": "palace_update",
403 "description": "Update the display name of an existing palace. The palace's drawers, vectors, and knowledge graph are preserved; only the human-readable name changes.",
404 "inputSchema": {
405 "type": "object",
406 "properties": {
407 "palace_id": {"type": "string", "description": "Id of the palace to rename."},
408 "name": {"type": "string", "description": "New display name. Trimmed; must be non-empty."}
409 },
410 "required": ["palace_id", "name"]
411 }
412 },
413 {
414 "name": "kg_assert",
415 "description": "Assert a fact in the temporal knowledge graph.",
416 "inputSchema": {
417 "type": "object",
418 "properties": {
419 "palace": {"type": "string"},
420 "subject": {"type": "string"},
421 "predicate": {"type": "string"},
422 "object": {"type": "string"},
423 "confidence": {"type": "number", "default": 1.0},
424 "provenance": {"type": "string"}
425 },
426 "required": kg_assert_required,
427 }
428 },
429 {
430 "name": "kg_query",
431 "description": "Query active knowledge-graph triples for a subject.",
432 "inputSchema": {
433 "type": "object",
434 "properties": {
435 "palace": {"type": "string"},
436 "subject": {"type": "string"}
437 },
438 "required": kg_query_required,
439 }
440 },
441 {
442 "name": "memory_list",
443 "description": "List drawers in a palace, optionally filtered by room type or tag.",
444 "inputSchema": {
445 "type": "object",
446 "properties": {
447 "palace": {"type": "string"},
448 "room": {"type": "string", "description": "Filter by room type (Frontend, Backend, Testing, Planning, Documentation, Research, Configuration, Meetings, General, or custom)"},
449 "tag": {"type": "string", "description": "Filter by tag"},
450 "limit": {"type": "integer", "description": "Max results (default 50)"}
451 },
452 "required": memory_list_required,
453 }
454 },
455 {
456 "name": "memory_forget",
457 "description": "Delete a drawer from a palace by its UUID.",
458 "inputSchema": {
459 "type": "object",
460 "properties": {
461 "palace": {"type": "string"},
462 "drawer_id": {"type": "string", "description": "UUID of the drawer to delete"}
463 },
464 "required": memory_forget_required,
465 }
466 },
467 {
468 "name": "palace_info",
469 "description": "Get metadata and stats for a single palace.",
470 "inputSchema": {
471 "type": "object",
472 "properties": {
473 "palace": {"type": "string"}
474 },
475 "required": palace_info_required,
476 }
477 },
478 {
479 "name": "palace_compact",
480 "description": "Remove orphaned vector index entries (vectors with no matching drawer row). See issue #49.",
481 "inputSchema": {
482 "type": "object",
483 "properties": {
484 "palace": {"type": "string"}
485 },
486 "required": palace_compact_required,
487 }
488 },
489 {
490 "name": "add_alias",
491 "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.",
492 "inputSchema": {
493 "type": "object",
494 "properties": {
495 "short": {"type": "string", "description": "Short name / alias (subject)"},
496 "full": {"type": "string", "description": "Full / canonical name (object)"},
497 "extra": {"type": "string", "description": "Optional extra context appended to the full name"}
498 },
499 "required": ["short", "full"],
500 }
501 },
502 {
503 "name": "list_prompt_facts",
504 "description": "List every active prompt-fact triple (aliases, conventions, facts, shorthands) across all palaces.",
505 "inputSchema": {"type": "object", "properties": {}}
506 },
507 {
508 "name": "remove_prompt_fact",
509 "description": "Retract the active triple for a (subject, predicate) pair from the prompt-facts surface. Closes the interval without inserting a replacement.",
510 "inputSchema": {
511 "type": "object",
512 "properties": {
513 "subject": {"type": "string"},
514 "predicate": {"type": "string", "description": "One of is_alias_for, has_convention, is_fact, is_shorthand_for"}
515 },
516 "required": ["subject", "predicate"],
517 }
518 },
519 {
520 "name": "get_prompt_context",
521 "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).",
522 "inputSchema": {
523 "type": "object",
524 "properties": {
525 "query": {
526 "type": "string",
527 "description": "Optional filter — only return facts whose subject or object contains this string (case-insensitive). Omit to return all hot facts."
528 }
529 }
530 }
531 },
532 {
533 "name": "discover_aliases",
534 "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.",
535 "inputSchema": {
536 "type": "object",
537 "properties": {
538 "project_root": {"type": "string", "description": "Optional filesystem path to scan. Defaults to the process cwd."}
539 }
540 }
541 },
542 {
543 "name": "kg_gaps",
544 "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.",
545 "inputSchema": {
546 "type": "object",
547 "properties": {
548 "palace": {"type": "string", "description": "Palace name (optional, defaults to the active palace)"}
549 }
550 }
551 },
552 {
553 "name": "kg_bootstrap",
554 "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.",
555 "inputSchema": {
556 "type": "object",
557 "properties": {
558 "palace": {"type": "string", "description": "Palace ID (optional if server started with --palace)"},
559 "project_path": {"type": "string", "description": "Filesystem path to scan. Omit to scan the palace's own data dir (temporal metadata only)."}
560 }
561 }
562 },
563 {
564 "name": "memory_recall_all",
565 "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.",
566 "inputSchema": {
567 "type": "object",
568 "properties": {
569 "q": {"type": "string", "description": "Free-text query"},
570 "top_k": {"type": "integer", "default": 10},
571 "deep": {"type": "boolean", "default": false}
572 },
573 "required": ["q"],
574 }
575 },
576 {
577 "name": "memory_send_message",
578 "description": "Send an inter-project message (issue #99). Writes a tagged drawer into the recipient palace; the recipient's SessionStart hook picks it up via `trusty-memory inbox-check`. `to_palace` is the recipient repo slug (e.g. `trusty-tools`, `claude-mpm`). `from_palace` defaults to the calling project's cwd-derived slug when omitted.",
579 "inputSchema": {
580 "type": "object",
581 "properties": {
582 "to_palace": {"type": "string", "description": "Recipient palace id (repo slug)."},
583 "purpose": {"type": "string", "description": "Free-text purpose / category (e.g. `task`, `notify`, `reply`)."},
584 "content": {"type": "string", "description": "Message body — plain text, no length limit. Rendered into the recipient session as a Markdown block."},
585 "from_palace": {"type": "string", "description": "Sender palace id (optional, defaults to cwd-derived slug)."}
586 },
587 "required": ["to_palace", "purpose", "content"],
588 }
589 },
590 {
591 "name": "upgrade",
592 "description": "Check for or install a new version of trusty-memory (issue #537). With check=true (or without confirm): report current vs. available version only — NEVER installs. With confirm=true: install via `cargo install trusty-memory --locked`, run a binary health gate, then restart the daemon under launchd (or print a restart hint when not supervised). The MCP response is returned BEFORE the daemon exits so the client sees the result before reconnecting.",
593 "inputSchema": {
594 "type": "object",
595 "properties": {
596 "check": {"type": "boolean", "description": "Report current and available versions only. No install. Default: true when confirm is absent.", "default": true},
597 "confirm": {"type": "boolean", "description": "Set to true to install the new version. NEVER set automatically — the operator must explicitly pass confirm=true.", "default": false}
598 },
599 "required": []
600 }
601 }
602 ]
603 })
604}
605
606pub(crate) fn room_label(room: &RoomType) -> Option<String> {
617 let label = match room {
618 RoomType::Frontend => "Frontend",
619 RoomType::Backend => "Backend",
620 RoomType::Testing => "Testing",
621 RoomType::Planning => "Planning",
622 RoomType::Documentation => "Documentation",
623 RoomType::Research => "Research",
624 RoomType::Configuration => "Configuration",
625 RoomType::Meetings => "Meetings",
626 RoomType::General => "General",
627 RoomType::Custom(s) => return Some(s.clone()),
628 };
629 Some(label.to_string())
630}
631
632fn parse_room(s: Option<&str>) -> RoomType {
640 match s.unwrap_or("General") {
641 "Frontend" => RoomType::Frontend,
642 "Backend" => RoomType::Backend,
643 "Testing" => RoomType::Testing,
644 "Planning" => RoomType::Planning,
645 "Documentation" => RoomType::Documentation,
646 "Research" => RoomType::Research,
647 "Configuration" => RoomType::Configuration,
648 "Meetings" => RoomType::Meetings,
649 "General" => RoomType::General,
650 other => RoomType::Custom(other.to_string()),
651 }
652}
653
654fn open_palace_handle(
656 state: &AppState,
657 palace_id: &str,
658) -> Result<std::sync::Arc<trusty_common::memory_core::PalaceHandle>> {
659 let pid = PalaceId::new(palace_id);
660 state
661 .registry
662 .open_palace(&state.data_root, &pid)
663 .with_context(|| format!("open palace {palace_id}"))
664}
665
666pub(crate) async fn auto_extract_and_assert(
682 handle: &std::sync::Arc<trusty_common::memory_core::PalaceHandle>,
683 drawer_id: Uuid,
684 content: &str,
685 tags: &[String],
686 room: Option<&str>,
687) {
688 let input = ExtractInput {
689 drawer_id,
690 content,
691 tags,
692 room,
693 };
694 let triples = extract_triples(&input);
695 if triples.is_empty() {
696 return;
697 }
698 for triple in triples {
699 let s = triple.subject.clone();
700 let p = triple.predicate.clone();
701 if let Err(e) = handle.kg.assert(triple).await {
702 tracing::warn!(
703 drawer_id = %drawer_id,
704 subject = %s,
705 predicate = %p,
706 "auto kg extraction: assert failed (non-fatal): {e:#}",
707 );
708 }
709 }
710}
711
712fn resolve_palace<'a>(state: &'a AppState, args: &'a Value, tool: &str) -> Result<String> {
724 if let Some(p) = args.get("palace").and_then(|v| v.as_str()) {
725 return Ok(p.to_string());
726 }
727 state
728 .default_palace
729 .clone()
730 .ok_or_else(|| anyhow!("{tool}: missing 'palace' (no --palace default configured)"))
731}
732
733struct WriteDrawerParams<'a> {
747 palace_id: &'a str,
748 content: String,
749 tags: Vec<String>,
750 room: RoomType,
751 importance: f32,
752 opts: RememberOptions,
753 room_label_for_kg: Option<String>,
754}
755
756async fn write_drawer(state: &AppState, params: WriteDrawerParams<'_>) -> Result<Uuid> {
772 let WriteDrawerParams {
773 palace_id,
774 content,
775 tags,
776 room,
777 importance,
778 opts,
779 room_label_for_kg,
780 } = params;
781
782 let handle = open_palace_handle(state, palace_id)?;
783 let preview = crate::service::drawer_content_preview(&content);
786 let content_for_kg = content.clone();
790 let tags_for_kg = tags.clone();
791 let drawer_id = handle
792 .remember_with_options(content, room, tags, importance, opts)
793 .await
794 .context("PalaceHandle::remember_with_options")?;
795 bm25_index_enqueue(state, palace_id, drawer_id, &content_for_kg);
801 let palace_name = lookup_palace_name(state, palace_id);
804 let drawer_count = handle.drawers.read().len();
805 state.emit(DaemonEvent::DrawerAdded {
806 palace_id: palace_id.to_string(),
807 palace_name,
808 drawer_count,
809 timestamp: chrono::Utc::now(),
810 content_preview: preview,
811 source: ActivitySource::Mcp,
812 });
813 auto_extract_and_assert(
821 &handle,
822 drawer_id,
823 &content_for_kg,
824 &tags_for_kg,
825 room_label_for_kg.as_deref(),
826 )
827 .await;
828 Ok(drawer_id)
829}
830
831fn skipped_envelope(palace_id: &str, reason: &str) -> Value {
843 json!({
844 "palace": palace_id,
845 "status": "skipped",
846 "reason": reason,
847 })
848}
849
850fn parse_tags(args: &Value) -> Vec<String> {
860 args.get("tags")
861 .and_then(|v| v.as_array())
862 .map(|arr| {
863 arr.iter()
864 .filter_map(|t| t.as_str().map(|s| s.to_string()))
865 .collect()
866 })
867 .unwrap_or_default()
868}
869
870fn attach_mcp_attribution(tags: &mut Vec<String>) {
882 if let Some(session_tag) = session_tag_from_tags(tags) {
883 tags.push(session_tag);
884 }
885 CreatorInfo::new_self(MCP_CLIENT_NAME, CreatorSource::Mcp).merge_into(tags);
886}
887
888async fn handle_memory_remember(state: &AppState, args: Value) -> Result<Value> {
898 let palace = resolve_palace(state, &args, "memory_remember")?;
899 let palace = palace.as_str();
900 let raw_text = args
901 .get("text")
902 .and_then(|v| v.as_str())
903 .ok_or_else(|| anyhow!("memory_remember: missing 'text'"))?
904 .to_string();
905 if blocklist_gate(&raw_text) {
910 tracing::debug!(
911 palace = %palace,
912 "content gate: skipped (blocked pattern)",
913 );
914 return Ok(skipped_envelope(
915 palace,
916 "content gate: skipped (blocked pattern)",
917 ));
918 }
919 let ctx = args.get("context").and_then(|v| v.as_str());
925 let text = match content_gate(&raw_text, ctx) {
926 Some(t) => t,
927 None => {
928 return Ok(skipped_envelope(
929 palace,
930 "content gate: skipped (short prompt, no context)",
931 ));
932 }
933 };
934 let room = parse_room(args.get("room").and_then(|v| v.as_str()));
935 let mut tags = parse_tags(&args);
936 attach_mcp_attribution(&mut tags);
944
945 let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
946
947 let write_lock = state.palace_write_lock(palace);
955 let _write_guard = write_lock.lock().await;
956
957 if !force {
962 let handle = open_palace_handle(state, palace)?;
963 if dedup_gate(&handle, &text) {
964 tracing::debug!(
965 palace = %palace,
966 "content gate: skipped (duplicate within window)",
967 );
968 return Ok(skipped_envelope(
969 palace,
970 "content gate: skipped (duplicate within window)",
971 ));
972 }
973 }
974 let room_label_for_kg = room_label(&room);
975 let drawer_id = write_drawer(
976 state,
977 WriteDrawerParams {
978 palace_id: palace,
979 content: text,
980 tags,
981 room,
982 importance: 0.5,
983 opts: mcp_remember_opts(force),
984 room_label_for_kg,
985 },
986 )
987 .await?;
988 Ok(json!({
989 "drawer_id": drawer_id.to_string(),
990 "palace": palace,
991 "status": "stored",
992 }))
993}
994
995async fn handle_memory_note(state: &AppState, args: Value) -> Result<Value> {
996 let palace = resolve_palace(state, &args, "memory_note")?;
1002 let palace = palace.as_str();
1003 let raw_content = args
1004 .get("content")
1005 .and_then(|v| v.as_str())
1006 .ok_or_else(|| anyhow!("memory_note: missing 'content'"))?
1007 .to_string();
1008 if blocklist_gate(&raw_content) {
1013 tracing::debug!(
1014 palace = %palace,
1015 "content gate: skipped (blocked pattern)",
1016 );
1017 return Ok(skipped_envelope(
1018 palace,
1019 "content gate: skipped (blocked pattern)",
1020 ));
1021 }
1022 let ctx = args.get("context").and_then(|v| v.as_str());
1027 let content = match content_gate(&raw_content, ctx) {
1028 Some(c) => c,
1029 None => {
1030 return Ok(skipped_envelope(
1031 palace,
1032 "content gate: skipped (short prompt, no context)",
1033 ));
1034 }
1035 };
1036 let mut tags = parse_tags(&args);
1037 attach_mcp_attribution(&mut tags);
1041 let write_lock = state.palace_write_lock(palace);
1049 let _write_guard = write_lock.lock().await;
1050 {
1055 let handle = open_palace_handle(state, palace)?;
1056 if dedup_gate(&handle, &content) {
1057 tracing::debug!(
1058 palace = %palace,
1059 "content gate: skipped (duplicate within window)",
1060 );
1061 return Ok(skipped_envelope(
1062 palace,
1063 "content gate: skipped (duplicate within window)",
1064 ));
1065 }
1066 }
1067 let drawer_id = write_drawer(
1071 state,
1072 WriteDrawerParams {
1073 palace_id: palace,
1074 content,
1075 tags,
1076 room: RoomType::General,
1077 importance: 1.0,
1078 opts: RememberOptions::note(),
1079 room_label_for_kg: Some("General".to_string()),
1083 },
1084 )
1085 .await
1086 .context("PalaceHandle::remember_with_options (note)")?;
1087 Ok(json!({
1088 "drawer_id": drawer_id.to_string(),
1089 "palace": palace,
1090 "status": "stored",
1091 "drawer_type": "UserFact",
1092 }))
1093}
1094
1095async fn handle_memory_recall(state: &AppState, args: Value) -> Result<Value> {
1096 let palace = resolve_palace(state, &args, "memory_recall")?;
1097 let query = args
1098 .get("query")
1099 .and_then(|v| v.as_str())
1100 .ok_or_else(|| anyhow!("memory_recall: missing 'query'"))?;
1101 let top_k = args.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
1102
1103 let handle = open_palace_handle(state, &palace)?;
1104 let embedder = state.embedder().await?;
1105 let vector_fut = recall(&handle, embedder.as_ref(), query, top_k);
1111 let bm25_fut = bm25_search_optional(state, &palace, query, top_k);
1112 let (vector_res, bm25_res) = tokio::join!(vector_fut, bm25_fut);
1113 let mut results = vector_res.context("recall")?;
1114 if let Some(bm25_hits) = bm25_res {
1115 fuse_bm25_into_recall(&mut results, &bm25_hits, top_k);
1116 }
1117 Ok(serialize_recall(&palace, query, results))
1118}
1119
1120async fn handle_memory_recall_deep(state: &AppState, args: Value) -> Result<Value> {
1121 let palace = resolve_palace(state, &args, "memory_recall_deep")?;
1122 let query = args
1123 .get("query")
1124 .and_then(|v| v.as_str())
1125 .ok_or_else(|| anyhow!("memory_recall_deep: missing 'query'"))?;
1126 let top_k = args.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
1127
1128 let handle = open_palace_handle(state, &palace)?;
1129 let embedder = state.embedder().await?;
1130 let results = recall_deep(&handle, embedder.as_ref(), query, top_k)
1131 .await
1132 .context("recall_deep")?;
1133 Ok(serialize_recall(&palace, query, results))
1134}
1135
1136async fn handle_palace_create(state: &AppState, args: Value) -> Result<Value> {
1137 let palace_name = args
1138 .get("name")
1139 .and_then(|v| v.as_str())
1140 .ok_or_else(|| anyhow!("palace_create: missing 'name'"))?;
1141
1142 let skip_enforcement = std::env::var("TRUSTY_SKIP_PALACE_ENFORCEMENT").as_deref() == Ok("1");
1158 if !skip_enforcement {
1159 let cwd = args
1160 .get("cwd")
1161 .and_then(|v| v.as_str())
1162 .filter(|s| !s.is_empty())
1163 .map(std::path::Path::new)
1164 .map(|p| p.to_path_buf())
1165 .or_else(|| std::env::current_dir().ok())
1166 .unwrap_or_else(|| state.data_root.clone());
1167 crate::project_root::validate_palace_name(palace_name, &cwd)?;
1168 }
1169
1170 let description = args
1171 .get("description")
1172 .and_then(|v| v.as_str())
1173 .map(|s| s.to_string());
1174 let palace = Palace {
1175 id: PalaceId::new(palace_name),
1176 name: palace_name.to_string(),
1177 description,
1178 created_at: chrono::Utc::now(),
1179 data_dir: state.data_root.join(palace_name),
1180 };
1181 let _handle = state
1182 .registry
1183 .create_palace(&state.data_root, palace)
1184 .context("create_palace")?;
1185 state
1189 .palace_names
1190 .insert(palace_name.to_string(), palace_name.to_string());
1191 state.emit(DaemonEvent::PalaceCreated {
1194 id: palace_name.to_string(),
1195 name: palace_name.to_string(),
1196 source: ActivitySource::Mcp,
1197 });
1198 let bootstrap_summary = match crate::bootstrap::bootstrap_palace(state, palace_name, None).await
1206 {
1207 Ok(r) => Some(serde_json::json!({
1208 "triples_asserted": r.triples_asserted,
1209 "project_subject": r.project_subject,
1210 })),
1211 Err(e) => {
1212 tracing::warn!(
1213 palace = %palace_name,
1214 "auto-bootstrap on palace_create failed: {e:#}",
1215 );
1216 None
1217 }
1218 };
1219 Ok(json!({
1220 "palace_id": palace_name,
1221 "status": "created",
1222 "bootstrap": bootstrap_summary,
1223 }))
1224}
1225
1226async fn handle_palace_list(state: &AppState, _args: Value) -> Result<Value> {
1227 let root = state.data_root.clone();
1228 let palaces = tokio::task::spawn_blocking(move || {
1229 trusty_common::memory_core::PalaceRegistry::list_palaces(&root)
1230 })
1231 .await
1232 .context("join list_palaces")??;
1233 let ids: Vec<String> = palaces.iter().map(|p| p.id.as_str().to_string()).collect();
1234 Ok(json!({ "palaces": ids }))
1235}
1236
1237async fn handle_palace_delete(state: &AppState, args: Value) -> Result<Value> {
1238 let palace_id = args
1246 .get("palace_id")
1247 .and_then(|v| v.as_str())
1248 .ok_or_else(|| anyhow!("palace_delete: missing 'palace_id'"))?
1249 .to_string();
1250 let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
1251 use crate::service::{MemoryService, ServiceError};
1252 let svc = MemoryService::new(state.clone());
1253 match svc.delete_palace(&palace_id, force).await {
1254 Ok(()) => Ok(json!({ "deleted": palace_id })),
1255 Err(ServiceError::NotFound(_)) => Err(anyhow!("Palace not found: {palace_id}")),
1256 Err(ServiceError::Conflict(msg)) => Err(anyhow!(msg)),
1257 Err(e) => Err(anyhow!("palace_delete: {e}")),
1258 }
1259}
1260
1261async fn handle_palace_update(state: &AppState, args: Value) -> Result<Value> {
1262 let palace_id = args
1271 .get("palace_id")
1272 .and_then(|v| v.as_str())
1273 .ok_or_else(|| anyhow!("palace_update: missing 'palace_id'"))?
1274 .to_string();
1275 let name = args
1276 .get("name")
1277 .and_then(|v| v.as_str())
1278 .ok_or_else(|| anyhow!("palace_update: missing 'name'"))?
1279 .to_string();
1280 use crate::service::MemoryService;
1281 let svc = MemoryService::new(state.clone());
1282 match svc.update_palace_name(&palace_id, &name).await {
1283 Ok(_info) => Ok(json!({ "updated": palace_id, "name": name.trim() })),
1284 Err(e) => Err(anyhow!("palace_update: {e}")),
1285 }
1286}
1287
1288async fn handle_kg_assert(state: &AppState, args: Value) -> Result<Value> {
1289 let palace = resolve_palace(state, &args, "kg_assert")?;
1290 let palace = palace.as_str();
1291 let subject = args
1292 .get("subject")
1293 .and_then(|v| v.as_str())
1294 .ok_or_else(|| anyhow!("kg_assert: missing 'subject'"))?
1295 .to_string();
1296 let predicate = args
1297 .get("predicate")
1298 .and_then(|v| v.as_str())
1299 .ok_or_else(|| anyhow!("kg_assert: missing 'predicate'"))?
1300 .to_string();
1301 let object = args
1302 .get("object")
1303 .and_then(|v| v.as_str())
1304 .ok_or_else(|| anyhow!("kg_assert: missing 'object'"))?
1305 .to_string();
1306 let confidence = args
1307 .get("confidence")
1308 .and_then(|v| v.as_f64())
1309 .map(|c| (c as f32).clamp(0.0, 1.0))
1310 .unwrap_or(1.0);
1311 let provenance = args
1312 .get("provenance")
1313 .and_then(|v| v.as_str())
1314 .map(|s| s.to_string());
1315
1316 let handle = open_palace_handle(state, palace)?;
1317 let triple = Triple {
1318 subject,
1319 predicate,
1320 object,
1321 valid_from: chrono::Utc::now(),
1322 valid_to: None,
1323 confidence,
1324 provenance,
1325 };
1326 let is_hot = crate::prompt_facts::is_hot_predicate(&triple.predicate);
1327 handle.kg.assert(triple).await.context("kg.assert")?;
1328 if is_hot {
1333 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1334 tracing::warn!("rebuild_prompt_cache after kg_assert failed: {e:#}");
1335 }
1336 }
1337 Ok(json!({ "status": "asserted" }))
1338}
1339
1340async fn handle_add_alias(state: &AppState, args: Value) -> Result<Value> {
1341 let short = args
1342 .get("short")
1343 .and_then(|v| v.as_str())
1344 .ok_or_else(|| anyhow!("add_alias: missing 'short'"))?
1345 .to_string();
1346 let full = args
1347 .get("full")
1348 .and_then(|v| v.as_str())
1349 .ok_or_else(|| anyhow!("add_alias: missing 'full'"))?
1350 .to_string();
1351 let extra = args
1352 .get("extra")
1353 .and_then(|v| v.as_str())
1354 .map(|s| s.to_string());
1355
1356 let palace = resolve_palace(state, &args, "add_alias")?;
1361 let handle = open_palace_handle(state, &palace)?;
1362 let object = match extra.as_deref() {
1364 Some(e) if !e.is_empty() => format!("{full} ({e})"),
1365 _ => full.clone(),
1366 };
1367 let triple = Triple {
1368 subject: short.clone(),
1369 predicate: "is_alias_for".to_string(),
1370 object,
1371 valid_from: chrono::Utc::now(),
1372 valid_to: None,
1373 confidence: 1.0,
1374 provenance: Some("add_alias".to_string()),
1375 };
1376 handle
1377 .kg
1378 .assert(triple)
1379 .await
1380 .context("kg.assert (alias)")?;
1381 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1382 tracing::warn!("rebuild_prompt_cache after add_alias failed: {e:#}");
1383 }
1384 Ok(json!({ "asserted": true, "short": short, "full": full }))
1385}
1386
1387async fn handle_list_prompt_facts(state: &AppState, _args: Value) -> Result<Value> {
1388 let triples = crate::prompt_facts::gather_hot_triples(state).await?;
1389 let payload: Vec<Value> = triples
1390 .into_iter()
1391 .map(|(subject, predicate, object)| {
1392 json!({ "subject": subject, "predicate": predicate, "object": object })
1393 })
1394 .collect();
1395 Ok(json!({ "facts": payload }))
1396}
1397
1398async fn handle_remove_prompt_fact(state: &AppState, args: Value) -> Result<Value> {
1399 let subject = args
1400 .get("subject")
1401 .and_then(|v| v.as_str())
1402 .ok_or_else(|| anyhow!("remove_prompt_fact: missing 'subject'"))?
1403 .to_string();
1404 let predicate = args
1405 .get("predicate")
1406 .and_then(|v| v.as_str())
1407 .ok_or_else(|| anyhow!("remove_prompt_fact: missing 'predicate'"))?
1408 .to_string();
1409
1410 let mut closed_total: usize = 0;
1416 for palace_id in state.registry.list() {
1417 if let Some(handle) = state.registry.get(&palace_id) {
1418 match handle.kg.retract(&subject, &predicate).await {
1419 Ok(n) => closed_total += n,
1420 Err(e) => tracing::warn!(
1421 palace = %palace_id.as_str(),
1422 "retract failed: {e:#}",
1423 ),
1424 }
1425 }
1426 }
1427 if closed_total > 0 {
1428 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1429 tracing::warn!("rebuild_prompt_cache after remove_prompt_fact failed: {e:#}");
1430 }
1431 Ok(json!({ "removed": true, "closed": closed_total }))
1432 } else {
1433 Ok(json!({ "removed": false, "reason": "not found" }))
1434 }
1435}
1436
1437async fn handle_kg_query(state: &AppState, args: Value) -> Result<Value> {
1438 let palace = resolve_palace(state, &args, "kg_query")?;
1439 let subject = args
1440 .get("subject")
1441 .and_then(|v| v.as_str())
1442 .ok_or_else(|| anyhow!("kg_query: missing 'subject'"))?;
1443 let handle = open_palace_handle(state, &palace)?;
1444 let triples = handle
1445 .kg
1446 .query_active(subject)
1447 .await
1448 .context("kg.query_active")?;
1449 let payload: Vec<Value> = triples
1450 .iter()
1451 .map(|t| {
1452 json!({
1453 "subject": t.subject,
1454 "predicate": t.predicate,
1455 "object": t.object,
1456 "valid_from": t.valid_from.to_rfc3339(),
1457 "valid_to": t.valid_to.as_ref().map(|d| d.to_rfc3339()),
1458 "confidence": t.confidence,
1459 "provenance": t.provenance,
1460 })
1461 })
1462 .collect();
1463 let mut response = json!({ "subject": subject, "triples": payload });
1469 if crate::bootstrap::is_kg_empty_for_subject(&triples) {
1470 response["hint"] = Value::String(crate::bootstrap::KG_EMPTY_HINT.to_string());
1471 }
1472 Ok(response)
1473}
1474
1475async fn handle_memory_list(state: &AppState, args: Value) -> Result<Value> {
1476 let palace = resolve_palace(state, &args, "memory_list")?;
1477 let handle = open_palace_handle(state, &palace)?;
1478 let room = args
1479 .get("room")
1480 .and_then(|v| v.as_str())
1481 .map(|s| parse_room(Some(s)));
1482 let tag = args
1483 .get("tag")
1484 .and_then(|v| v.as_str())
1485 .map(|s| s.to_string());
1486 let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
1487 let drawers = handle.list_drawers(room, tag, limit);
1488 let payload: Vec<Value> = drawers
1489 .iter()
1490 .map(|d| {
1491 json!({
1492 "drawer_id": d.id.to_string(),
1493 "content": d.content,
1494 "importance": d.importance,
1495 "tags": d.tags,
1496 "created_at": d.created_at.to_rfc3339(),
1497 "drawer_type": d.drawer_type.as_str(),
1498 "expires_at": d.expires_at.map(|t| t.to_rfc3339()),
1499 })
1500 })
1501 .collect();
1502 Ok(json!({ "palace": palace, "drawers": payload }))
1503}
1504
1505async fn handle_memory_forget(state: &AppState, args: Value) -> Result<Value> {
1506 let palace = resolve_palace(state, &args, "memory_forget")?;
1507 let drawer_id_str = args
1508 .get("drawer_id")
1509 .and_then(|v| v.as_str())
1510 .ok_or_else(|| anyhow!("memory_forget: missing 'drawer_id'"))?;
1511 let drawer_id = Uuid::parse_str(drawer_id_str)
1512 .map_err(|e| anyhow!("memory_forget: invalid drawer_id UUID: {e}"))?;
1513 let handle = open_palace_handle(state, &palace)?;
1514 handle.forget(drawer_id).await.context("forget")?;
1515 let drawer_count = handle.drawers.read().len();
1517 state.emit(DaemonEvent::DrawerDeleted {
1518 palace_id: palace.clone(),
1519 drawer_count,
1520 source: ActivitySource::Mcp,
1521 });
1522 Ok(json!({ "status": "deleted", "drawer_id": drawer_id_str, "palace": palace }))
1525}
1526
1527async fn handle_palace_info(state: &AppState, args: Value) -> Result<Value> {
1528 let palace = resolve_palace(state, &args, "palace_info")?;
1529 let handle = open_palace_handle(state, &palace)?;
1530 let drawer_count = handle.list_drawers(None, None, usize::MAX).len();
1531 let data_dir = handle
1532 .data_dir
1533 .as_ref()
1534 .map(|p| p.to_string_lossy().to_string());
1535 Ok(json!({
1536 "id": handle.id.as_str(),
1537 "name": handle.id.as_str(),
1538 "drawer_count": drawer_count,
1539 "data_dir": data_dir,
1540 }))
1541}
1542
1543async fn handle_palace_compact(state: &AppState, args: Value) -> Result<Value> {
1544 let palace = resolve_palace(state, &args, "palace_compact")?;
1545 let handle = open_palace_handle(state, &palace)?;
1546 let valid_ids: std::collections::HashSet<Uuid> =
1550 handle.drawers.read().iter().map(|d| d.id).collect();
1551 let vector_store = handle.vector_store.clone();
1552 let res = tokio::task::spawn_blocking(move || vector_store.compact_orphans(&valid_ids))
1553 .await
1554 .context("join palace_compact")??;
1555 Ok(json!({
1556 "palace": palace,
1557 "total_checked": res.total_checked,
1558 "orphans_removed": res.orphans_removed,
1559 "index_size_before": res.index_size_before,
1560 "index_size_after": res.index_size_after,
1561 }))
1562}
1563
1564async fn handle_kg_gaps(state: &AppState, args: Value) -> Result<Value> {
1565 let palace = resolve_palace(state, &args, "kg_gaps")?;
1575 let _handle = open_palace_handle(state, &palace)?;
1578 let pid = PalaceId::new(&palace);
1579 let cached = state.registry.get_gaps(&pid).unwrap_or_default();
1580 let payload: Vec<Value> = cached
1581 .into_iter()
1582 .map(|g| {
1583 json!({
1584 "entities": g.entities,
1585 "internal_density": g.internal_density,
1586 "external_bridges": g.external_bridges,
1587 "suggested_exploration": g.suggested_exploration,
1588 })
1589 })
1590 .collect();
1591 Ok(json!({ "palace": palace, "gaps": payload }))
1592}
1593
1594async fn handle_memory_recall_all(state: &AppState, args: Value) -> Result<Value> {
1595 let query = args
1596 .get("q")
1597 .and_then(|v| v.as_str())
1598 .ok_or_else(|| anyhow!("memory_recall_all: missing 'q'"))?;
1599 let top_k = args.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
1600 let deep = args.get("deep").and_then(|v| v.as_bool()).unwrap_or(false);
1601
1602 let root = state.data_root.clone();
1606 let palaces = tokio::task::spawn_blocking(move || {
1607 trusty_common::memory_core::PalaceRegistry::list_palaces(&root)
1608 })
1609 .await
1610 .context("join list_palaces")??;
1611
1612 let mut handles = Vec::with_capacity(palaces.len());
1613 for p in &palaces {
1614 match state.registry.open_palace(&state.data_root, &p.id) {
1615 Ok(h) => handles.push(h),
1616 Err(e) => {
1617 tracing::warn!(palace = %p.id, "memory_recall_all: open failed: {e:#}")
1618 }
1619 }
1620 }
1621
1622 let embedder = state.embedder().await?;
1623 let erased: std::sync::Arc<dyn trusty_common::memory_core::embed::Embedder + Send + Sync> =
1624 embedder;
1625 let results = recall_across_palaces(&handles, &erased, query, top_k, deep)
1626 .await
1627 .context("recall_across_palaces")?;
1628
1629 let payload: Vec<Value> = results
1630 .iter()
1631 .map(|r| {
1632 json!({
1633 "palace_id": r.palace_id,
1634 "drawer_id": r.result.drawer.id.to_string(),
1635 "content": r.result.drawer.content,
1636 "importance": r.result.drawer.importance,
1637 "tags": r.result.drawer.tags,
1638 "score": r.result.score,
1639 "layer": r.result.layer,
1640 "drawer_type": r.result.drawer.drawer_type.as_str(),
1641 })
1642 })
1643 .collect();
1644 Ok(json!({ "query": query, "results": payload }))
1645}
1646
1647async fn handle_get_prompt_context(state: &AppState, args: Value) -> Result<Value> {
1648 let query = args
1659 .get("query")
1660 .and_then(|v| v.as_str())
1661 .map(|s| s.trim().to_string())
1662 .filter(|s| !s.is_empty());
1663
1664 let cache_snapshot = {
1668 let guard = state.prompt_context_cache.read().await;
1669 guard.clone()
1670 };
1671
1672 let body = if let Some(q) = query.as_deref() {
1673 let needle = q.to_lowercase();
1674 let filtered: Vec<(String, String, String)> = cache_snapshot
1675 .triples
1676 .into_iter()
1677 .filter(|(subject, _predicate, object)| {
1678 subject.to_lowercase().contains(&needle) || object.to_lowercase().contains(&needle)
1679 })
1680 .collect();
1681 let formatted = crate::prompt_facts::build_prompt_context(&filtered);
1682 if formatted.is_empty() {
1683 "No project context found matching your query.".to_string()
1684 } else {
1685 formatted
1686 }
1687 } else if cache_snapshot.formatted.is_empty() {
1688 "No prompt facts stored yet.".to_string()
1689 } else {
1690 cache_snapshot.formatted
1691 };
1692
1693 Ok(Value::String(body))
1699}
1700
1701async fn handle_discover_aliases(state: &AppState, args: Value) -> Result<Value> {
1702 let palace = resolve_palace(state, &args, "discover_aliases")?;
1713 let project_root = args
1714 .get("project_root")
1715 .and_then(|v| v.as_str())
1716 .map(std::path::PathBuf::from)
1717 .or_else(|| std::env::current_dir().ok())
1718 .ok_or_else(|| anyhow!("discover_aliases: no project_root and cwd unavailable"))?;
1719
1720 let discoveries = crate::discovery::discover_project_aliases(&project_root).await?;
1721
1722 let handle = open_palace_handle(state, &palace)?;
1723
1724 let mut already_known = 0usize;
1725 let mut newly_asserted = 0usize;
1726 let mut reported: Vec<Value> = Vec::with_capacity(discoveries.len());
1727
1728 for d in &discoveries {
1729 let active = handle
1732 .kg
1733 .query_active(&d.short)
1734 .await
1735 .context("kg.query_active")?;
1736 let exists = active
1737 .iter()
1738 .any(|t| t.predicate == "is_alias_for" && t.object == d.full);
1739 if exists {
1740 already_known += 1;
1741 continue;
1742 }
1743
1744 let triple = Triple {
1745 subject: d.short.clone(),
1746 predicate: "is_alias_for".to_string(),
1747 object: d.full.clone(),
1748 valid_from: chrono::Utc::now(),
1749 valid_to: None,
1750 confidence: 1.0,
1751 provenance: Some(format!("discover_aliases:{}", d.source.as_str())),
1752 };
1753 handle
1754 .kg
1755 .assert(triple)
1756 .await
1757 .context("kg.assert (discover)")?;
1758 newly_asserted += 1;
1759 reported.push(json!({
1760 "short": d.short,
1761 "full": d.full,
1762 "source": d.source.as_str(),
1763 }));
1764 }
1765
1766 if newly_asserted > 0 {
1767 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1768 tracing::warn!("rebuild_prompt_cache after discover_aliases failed: {e:#}");
1769 }
1770 }
1771
1772 Ok(json!({
1773 "discovered": reported,
1774 "already_known": already_known,
1775 "new": newly_asserted,
1776 "palace": palace,
1777 }))
1778}
1779
1780async fn handle_kg_bootstrap(state: &AppState, args: Value) -> Result<Value> {
1781 let palace = resolve_palace(state, &args, "kg_bootstrap")?;
1786 let project_path = args
1787 .get("project_path")
1788 .and_then(|v| v.as_str())
1789 .map(std::path::PathBuf::from);
1790 let result = crate::bootstrap::bootstrap_palace(state, &palace, project_path.as_deref())
1791 .await
1792 .context("bootstrap_palace")?;
1793 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1797 tracing::warn!("rebuild_prompt_cache after kg_bootstrap failed: {e:#}");
1798 }
1799 crate::bootstrap::result_to_json(&result)
1800}
1801
1802async fn handle_memory_send_message(state: &AppState, args: Value) -> Result<Value> {
1803 let to_palace = args
1805 .get("to_palace")
1806 .and_then(|v| v.as_str())
1807 .ok_or_else(|| anyhow!("memory_send_message: missing 'to_palace'"))?
1808 .to_string();
1809 let purpose = args
1810 .get("purpose")
1811 .and_then(|v| v.as_str())
1812 .ok_or_else(|| anyhow!("memory_send_message: missing 'purpose'"))?
1813 .to_string();
1814 let content = args
1815 .get("content")
1816 .and_then(|v| v.as_str())
1817 .ok_or_else(|| anyhow!("memory_send_message: missing 'content'"))?
1818 .to_string();
1819 let from_palace = if let Some(s) = args.get("from_palace").and_then(|v| v.as_str()) {
1822 s.to_string()
1823 } else if let Some(d) = state.default_palace.clone() {
1824 d
1825 } else {
1826 crate::messaging::cwd_palace_slug()
1827 .context("memory_send_message: derive from_palace from cwd")?
1828 };
1829 let drawer_id = crate::messaging::send_message_to_palace(
1830 &state.registry,
1831 &state.data_root,
1832 &from_palace,
1833 &to_palace,
1834 &purpose,
1835 content,
1836 CreatorInfo::new_self(MCP_CLIENT_NAME, CreatorSource::Mcp),
1837 )
1838 .await
1839 .context("memory_send_message")?;
1840 Ok(json!({
1841 "drawer_id": drawer_id.to_string(),
1842 "from_palace": from_palace,
1843 "to_palace": to_palace,
1844 "purpose": purpose,
1845 "status": "sent",
1846 }))
1847}
1848
1849pub async fn dispatch_tool(state: &AppState, name: &str, args: Value) -> Result<Value> {
1861 match name {
1862 "memory_remember" => handle_memory_remember(state, args).await,
1863 "memory_note" => handle_memory_note(state, args).await,
1864 "memory_recall" => handle_memory_recall(state, args).await,
1865 "memory_recall_deep" => handle_memory_recall_deep(state, args).await,
1866 "palace_create" => handle_palace_create(state, args).await,
1867 "palace_list" => handle_palace_list(state, args).await,
1868 "palace_delete" => handle_palace_delete(state, args).await,
1869 "palace_update" => handle_palace_update(state, args).await,
1870 "kg_assert" => handle_kg_assert(state, args).await,
1871 "add_alias" => handle_add_alias(state, args).await,
1872 "list_prompt_facts" => handle_list_prompt_facts(state, args).await,
1873 "remove_prompt_fact" => handle_remove_prompt_fact(state, args).await,
1874 "kg_query" => handle_kg_query(state, args).await,
1875 "memory_list" => handle_memory_list(state, args).await,
1876 "memory_forget" => handle_memory_forget(state, args).await,
1877 "palace_info" => handle_palace_info(state, args).await,
1878 "palace_compact" => handle_palace_compact(state, args).await,
1879 "kg_gaps" => handle_kg_gaps(state, args).await,
1880 "memory_recall_all" => handle_memory_recall_all(state, args).await,
1881 "get_prompt_context" => handle_get_prompt_context(state, args).await,
1882 "discover_aliases" => handle_discover_aliases(state, args).await,
1883 "kg_bootstrap" => handle_kg_bootstrap(state, args).await,
1884 "memory_send_message" => handle_memory_send_message(state, args).await,
1885 "upgrade" => handle_upgrade_tool(state, args).await,
1886 other => anyhow::bail!("unknown tool: {other}"),
1887 }
1888}
1889
1890async fn handle_upgrade_tool(state: &AppState, args: Value) -> Result<Value> {
1910 let check = args.get("check").and_then(Value::as_bool).unwrap_or(true);
1911 let confirm = args
1912 .get("confirm")
1913 .and_then(Value::as_bool)
1914 .unwrap_or(false);
1915
1916 let crate_name = env!("CARGO_PKG_NAME");
1917 let current = env!("CARGO_PKG_VERSION");
1918
1919 let info = trusty_common::update::check_crates_io(crate_name, current).await;
1921
1922 let (latest, is_update) = match &info {
1923 Some(u) => (u.latest.as_str(), true),
1924 None => (current, false),
1925 };
1926
1927 if check || !confirm {
1928 let msg = if is_update {
1929 format!(
1930 "Update available: {crate_name} {latest} (you have {current}). \
1931 Call with confirm=true to install."
1932 )
1933 } else {
1934 format!("{crate_name} {current} is already up to date.")
1935 };
1936 return Ok(
1937 serde_json::json!({ "status": "checked", "current": current, "latest": latest, "update_available": is_update, "message": msg }),
1938 );
1939 }
1940
1941 if !is_update {
1947 return Ok(serde_json::json!({
1948 "status": "up_to_date",
1949 "current": current,
1950 "message": format!("{crate_name} {current} is already up to date — nothing to install.")
1951 }));
1952 }
1953
1954 let upgrade_state = state.update_available.clone();
1955 let latest_owned = latest.to_string();
1956 let crate_name_owned = crate_name.to_string();
1957 let response = serde_json::json!({
1958 "status": "installing",
1959 "current": current,
1960 "latest": latest_owned,
1961 "message": format!(
1962 "Installing {crate_name} {latest_owned} — daemon will restart automatically \
1963 under launchd, or you will be prompted to restart manually."
1964 )
1965 });
1966
1967 tokio::spawn(async move {
1970 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
1972 match trusty_common::update::upgrade_and_restart(&crate_name_owned, &crate_name_owned).await
1973 {
1974 Ok(Some(hint)) => {
1975 tracing::info!("{hint}");
1976 eprintln!("{hint}");
1977 }
1978 Ok(None) => {}
1979 Err(e) => {
1980 tracing::error!("upgrade_and_restart failed: {e:#}");
1981 eprintln!("[trusty-memory] upgrade failed: {e:#}");
1982 if let Ok(mut g) = upgrade_state.lock() {
1985 *g = None;
1986 }
1987 }
1988 }
1989 });
1990
1991 Ok(response)
1992}
1993
1994fn bm25_data_dir_for_palace(state: &AppState, palace: &str) -> std::path::PathBuf {
2007 state.data_root.join(palace).join("bm25")
2008}
2009
2010async fn ensure_bm25_running_for_palace(state: &AppState, palace: &str) -> bool {
2027 let Some(supervisor) = state.bm25_supervisor.as_ref() else {
2028 return true;
2031 };
2032 let data_dir = bm25_data_dir_for_palace(state, palace);
2033 match supervisor.ensure_running(palace, &data_dir).await {
2034 Ok(_socket) => true,
2035 Err(e) => {
2036 tracing::warn!(
2037 palace = %palace,
2038 "bm25 supervisor could not start daemon (degrading to vector-only): {e:#}"
2039 );
2040 false
2041 }
2042 }
2043}
2044
2045pub const BM25_INDEX_QUEUE_CAPACITY: usize = 256;
2061
2062#[derive(Debug)]
2075pub struct Bm25IndexRequest {
2076 pub palace: String,
2078 pub drawer_id: String,
2080 pub content: String,
2082 pub data_dir: std::path::PathBuf,
2086}
2087
2088pub fn spawn_bm25_index_worker(
2109 mut rx: tokio::sync::mpsc::Receiver<Bm25IndexRequest>,
2110 client: Option<std::sync::Arc<trusty_common::bm25_client::Bm25Client>>,
2111 supervisor: Option<std::sync::Arc<crate::bm25_supervisor::Bm25Supervisor>>,
2112) {
2113 tokio::spawn(async move {
2114 while let Some(req) = rx.recv().await {
2115 let Some(client) = client.as_ref() else {
2118 continue;
2119 };
2120 if let Some(sup) = supervisor.as_ref() {
2124 if let Err(e) = sup.ensure_running(&req.palace, &req.data_dir).await {
2125 tracing::warn!(
2126 palace = %req.palace,
2127 "bm25 supervisor failed to start daemon for index (non-fatal): {e:#}"
2128 );
2129 continue;
2130 }
2131 }
2132 if let Err(e) = client.index(&req.drawer_id, &req.content).await {
2133 tracing::warn!(
2134 palace = %req.palace,
2135 drawer_id = %req.drawer_id,
2136 "bm25 daemon index failed (non-fatal): {e:#}"
2137 );
2138 }
2139 }
2140 tracing::debug!("bm25 index worker exiting (channel closed)");
2141 });
2142}
2143
2144fn bm25_index_enqueue(state: &AppState, palace: &str, drawer_id: Uuid, content: &str) {
2161 let req = Bm25IndexRequest {
2162 palace: palace.to_string(),
2163 drawer_id: drawer_id.to_string(),
2164 content: content.to_string(),
2165 data_dir: bm25_data_dir_for_palace(state, palace),
2166 };
2167 match state.bm25_index_tx.try_send(req) {
2168 Ok(()) => {}
2169 Err(tokio::sync::mpsc::error::TrySendError::Full(req)) => {
2170 tracing::warn!(
2171 palace = %req.palace,
2172 drawer_id = %req.drawer_id,
2173 "BM25 index queue full — skipping drawer {}",
2174 req.drawer_id
2175 );
2176 }
2177 Err(tokio::sync::mpsc::error::TrySendError::Closed(req)) => {
2178 tracing::debug!(
2179 palace = %req.palace,
2180 drawer_id = %req.drawer_id,
2181 "BM25 index queue closed — skipping drawer {}",
2182 req.drawer_id
2183 );
2184 }
2185 }
2186}
2187
2188async fn bm25_search_optional(
2202 state: &AppState,
2203 palace: &str,
2204 query: &str,
2205 top_k: usize,
2206) -> Option<Vec<trusty_common::bm25_client::BM25Hit>> {
2207 let client = state.bm25_client.as_ref()?;
2208 if !ensure_bm25_running_for_palace(state, palace).await {
2212 return None;
2213 }
2214 match client.search(query, top_k).await {
2215 Ok(hits) => Some(hits),
2216 Err(e) => {
2217 tracing::warn!(
2218 palace = %palace,
2219 "bm25 daemon search failed (falling back to vector-only): {e:#}"
2220 );
2221 None
2222 }
2223 }
2224}
2225
2226fn fuse_bm25_into_recall(
2241 results: &mut Vec<trusty_common::memory_core::retrieval::RecallResult>,
2242 bm25_hits: &[trusty_common::bm25_client::BM25Hit],
2243 top_k: usize,
2244) {
2245 const RRF_K: f32 = 60.0;
2248 if bm25_hits.is_empty() {
2249 return;
2250 }
2251 for (rank, hit) in bm25_hits.iter().enumerate() {
2253 let bonus = 1.0 / (RRF_K + rank as f32 + 1.0);
2254 if let Some(existing) = results
2255 .iter_mut()
2256 .find(|r| r.drawer.id.to_string() == hit.doc_id)
2257 {
2258 existing.score += bonus;
2259 }
2260 }
2268 results.sort_by(|a, b| {
2271 b.score
2272 .partial_cmp(&a.score)
2273 .unwrap_or(std::cmp::Ordering::Equal)
2274 .then(a.layer.cmp(&b.layer))
2275 });
2276 results.truncate(top_k);
2277}
2278
2279fn serialize_recall(
2281 palace: &str,
2282 query: &str,
2283 results: Vec<trusty_common::memory_core::retrieval::RecallResult>,
2284) -> Value {
2285 let payload: Vec<Value> = results
2286 .iter()
2287 .map(|r| {
2288 json!({
2289 "drawer_id": r.drawer.id.to_string(),
2290 "content": r.drawer.content,
2291 "score": r.score,
2292 "layer": r.layer,
2293 "tags": r.drawer.tags,
2294 "importance": r.drawer.importance,
2295 "drawer_type": r.drawer.drawer_type.as_str(),
2296 })
2297 })
2298 .collect();
2299 json!({
2300 "palace": palace,
2301 "query": query,
2302 "results": payload,
2303 })
2304}
2305
2306#[cfg(test)]
2307mod tests {
2308 use super::*;
2309 use crate::AppState;
2310
2311 fn test_state() -> (AppState, tempfile::TempDir) {
2326 unsafe {
2333 std::env::set_var("TRUSTY_SKIP_PALACE_ENFORCEMENT", "1");
2334 }
2335 let tmp = tempfile::tempdir().expect("tempdir");
2336 let root = tmp.path().to_path_buf();
2337 (AppState::new(root), tmp)
2338 }
2339
2340 #[test]
2345 fn tool_definitions_drops_palace_required_when_default_set() {
2346 let with_default = tool_definitions_with(true);
2347 let without_default = tool_definitions_with(false);
2348 for (name, palace_required_when_no_default) in [
2349 ("memory_remember", true),
2350 ("memory_recall", true),
2351 ("memory_recall_deep", true),
2352 ("memory_list", true),
2353 ("memory_forget", true),
2354 ("palace_info", true),
2355 ("palace_compact", true),
2356 ("kg_assert", true),
2357 ("kg_query", true),
2358 ] {
2359 for (defs, has_default) in [(&with_default, true), (&without_default, false)] {
2360 let tools = defs["tools"].as_array().unwrap();
2361 let tool = tools.iter().find(|t| t["name"] == name).unwrap();
2362 let required: Vec<&str> = tool["inputSchema"]["required"]
2363 .as_array()
2364 .unwrap()
2365 .iter()
2366 .filter_map(|v| v.as_str())
2367 .collect();
2368 let palace_required = required.contains(&"palace");
2369 let expected = palace_required_when_no_default && !has_default;
2370 assert_eq!(
2371 palace_required, expected,
2372 "tool={name} has_default={has_default} required={required:?}"
2373 );
2374 }
2375 }
2376 }
2377
2378 #[test]
2379 fn tool_definitions_lists_all_tools() {
2380 let defs = tool_definitions();
2381 let tools = defs
2382 .get("tools")
2383 .and_then(|t| t.as_array())
2384 .expect("tools array");
2385 assert_eq!(tools.len(), 24);
2386 let names: Vec<&str> = tools
2387 .iter()
2388 .filter_map(|t| t.get("name").and_then(|n| n.as_str()))
2389 .collect();
2390 for expected in [
2391 "memory_remember",
2392 "memory_note",
2393 "memory_recall",
2394 "memory_recall_deep",
2395 "memory_list",
2396 "memory_forget",
2397 "palace_create",
2398 "palace_delete",
2399 "palace_update",
2400 "palace_list",
2401 "palace_info",
2402 "palace_compact",
2403 "kg_assert",
2404 "kg_query",
2405 "memory_recall_all",
2406 "kg_gaps",
2407 "add_alias",
2408 "list_prompt_facts",
2409 "remove_prompt_fact",
2410 "get_prompt_context",
2411 "discover_aliases",
2412 "kg_bootstrap",
2413 "memory_send_message",
2414 "upgrade",
2415 ] {
2416 assert!(names.contains(&expected), "missing tool: {expected}");
2417 }
2418 }
2419
2420 #[tokio::test]
2423 async fn dispatch_palace_create_persists() {
2424 let (state, _tmp) = test_state();
2425 let created = dispatch_tool(&state, "palace_create", json!({"name": "alpha"}))
2426 .await
2427 .expect("palace_create");
2428 assert_eq!(created["palace_id"], "alpha");
2429
2430 let listed = dispatch_tool(&state, "palace_list", json!({}))
2431 .await
2432 .expect("palace_list");
2433 let ids = listed["palaces"].as_array().expect("palaces array");
2434 assert!(ids.iter().any(|v| v.as_str() == Some("alpha")));
2435 }
2436
2437 #[tokio::test]
2440 async fn dispatch_remember_then_recall() {
2441 let (state, _tmp) = test_state();
2442 let _ = dispatch_tool(&state, "palace_create", json!({"name": "beta"}))
2443 .await
2444 .expect("palace_create");
2445
2446 let remembered = dispatch_tool(
2447 &state,
2448 "memory_remember",
2449 json!({
2450 "palace": "beta",
2451 "text": "Quokkas are the happiest marsupials in Australia by general consensus",
2452 "room": "General",
2453 "tags": ["wildlife"],
2454 }),
2455 )
2456 .await
2457 .expect("memory_remember");
2458 assert!(remembered["drawer_id"].as_str().is_some());
2459
2460 let recalled = dispatch_tool(
2461 &state,
2462 "memory_recall",
2463 json!({"palace": "beta", "query": "Quokkas marsupials Australia", "top_k": 5}),
2464 )
2465 .await
2466 .expect("memory_recall");
2467 let results = recalled["results"].as_array().expect("results");
2468 assert!(
2469 results
2470 .iter()
2471 .any(|r| r["content"].as_str().unwrap_or("").contains("Quokkas")),
2472 "expected to recall the Quokkas drawer; got {results:?}"
2473 );
2474 }
2475
2476 #[tokio::test]
2485 async fn auto_kg_extraction_hooks_into_memory_remember() {
2486 let (state, _tmp) = test_state();
2487 let _ = dispatch_tool(&state, "palace_create", json!({"name": "kgauto"}))
2488 .await
2489 .expect("palace_create");
2490
2491 let _ = dispatch_tool(
2492 &state,
2493 "memory_remember",
2494 json!({
2495 "palace": "kgauto",
2496 "text": "Rustc is a compiler for the Rust language; tracks #performance",
2497 "room": "Backend",
2498 "tags": ["compiler", "language"],
2499 }),
2500 )
2501 .await
2502 .expect("memory_remember");
2503
2504 let handle = open_palace_handle(&state, "kgauto").expect("open palace");
2505 let triples = handle.kg.list_active(1000, 0).await.expect("list_active");
2506 let auto: Vec<_> = triples
2507 .iter()
2508 .filter(|t| t.provenance.as_deref() == Some(crate::kg_extract::AUTO_PROVENANCE))
2509 .collect();
2510 assert!(
2511 !auto.is_empty(),
2512 "expected at least one auto-extracted triple after memory_remember; got: {triples:?}"
2513 );
2514 assert!(
2518 auto.iter()
2519 .any(|t| t.subject == "tag:compiler" && t.predicate == "tags"),
2520 "expected tag:compiler edge in auto subset: {auto:?}"
2521 );
2522 assert!(
2523 auto.iter()
2524 .any(|t| t.subject == "tag:language" && t.predicate == "tags"),
2525 "expected tag:language edge in auto subset: {auto:?}"
2526 );
2527 assert!(
2528 auto.iter()
2529 .any(|t| t.subject == "room:Backend" && t.predicate == "contains"),
2530 "expected room:Backend edge in auto subset: {auto:?}"
2531 );
2532 assert!(
2533 auto.iter().any(|t| t.predicate == "mentioned-in"),
2534 "expected at least one #hashtag mention triple in auto subset: {auto:?}"
2535 );
2536 }
2537
2538 #[tokio::test]
2550 async fn auto_kg_extraction_no_op_does_not_fail_remember() {
2551 let (state, _tmp) = test_state();
2552 let _ = dispatch_tool(&state, "palace_create", json!({"name": "kgnoop"}))
2553 .await
2554 .expect("palace_create");
2555
2556 let res = dispatch_tool(
2557 &state,
2558 "memory_remember",
2559 json!({
2560 "palace": "kgnoop",
2561 "text": "The quick brown fox jumped over the lazy dog repeatedly",
2564 }),
2565 )
2566 .await
2567 .expect("memory_remember should succeed even when extraction yields nothing");
2568 assert!(res["drawer_id"].as_str().is_some());
2569 }
2570
2571 #[tokio::test]
2574 async fn dispatch_kg_assert_then_query() {
2575 let (state, _tmp) = test_state();
2576 let _ = dispatch_tool(&state, "palace_create", json!({"name": "gamma"}))
2577 .await
2578 .expect("palace_create");
2579
2580 let _ = dispatch_tool(
2581 &state,
2582 "kg_assert",
2583 json!({
2584 "palace": "gamma",
2585 "subject": "alice",
2586 "predicate": "works_at",
2587 "object": "Acme",
2588 "confidence": 0.9,
2589 "provenance": "test",
2590 }),
2591 )
2592 .await
2593 .expect("kg_assert");
2594
2595 let queried = dispatch_tool(
2596 &state,
2597 "kg_query",
2598 json!({"palace": "gamma", "subject": "alice"}),
2599 )
2600 .await
2601 .expect("kg_query");
2602 let triples = queried["triples"].as_array().expect("triples array");
2603 assert_eq!(triples.len(), 1);
2604 assert_eq!(triples[0]["object"], "Acme");
2605 assert_eq!(triples[0]["predicate"], "works_at");
2606 }
2607
2608 #[tokio::test]
2616 async fn dispatch_kg_gaps_returns_cached() {
2617 use trusty_common::memory_core::community::KnowledgeGap;
2618
2619 let (state, _tmp) = test_state();
2620 let _ = dispatch_tool(&state, "palace_create", json!({"name": "delta"}))
2621 .await
2622 .expect("palace_create");
2623
2624 let initial = dispatch_tool(&state, "kg_gaps", json!({"palace": "delta"}))
2626 .await
2627 .expect("kg_gaps empty");
2628 let gaps = initial["gaps"].as_array().expect("gaps array");
2629 assert_eq!(gaps.len(), 0);
2630
2631 state.registry.set_gaps(
2633 PalaceId::new("delta"),
2634 vec![KnowledgeGap {
2635 entities: vec!["x".to_string(), "y".to_string()],
2636 internal_density: 0.05,
2637 external_bridges: 0,
2638 suggested_exploration: "Explore connections between x and y".to_string(),
2639 }],
2640 );
2641 let seeded = dispatch_tool(&state, "kg_gaps", json!({"palace": "delta"}))
2642 .await
2643 .expect("kg_gaps seeded");
2644 let gaps = seeded["gaps"].as_array().expect("gaps array");
2645 assert_eq!(gaps.len(), 1);
2646 assert_eq!(gaps[0]["entities"][0], "x");
2647 assert_eq!(gaps[0]["external_bridges"], 0);
2648 assert!(gaps[0]["suggested_exploration"]
2649 .as_str()
2650 .unwrap()
2651 .contains("x"));
2652 }
2653
2654 #[tokio::test]
2659 async fn add_alias_round_trip_through_prompt_cache() {
2660 let _tmp = tempfile::tempdir().expect("tempdir");
2663 let root = _tmp.path().to_path_buf();
2664 let state = AppState::new(root).with_default_palace(Some("ctx".to_string()));
2665
2666 let _ = dispatch_tool(&state, "palace_create", json!({"name": "ctx"}))
2668 .await
2669 .expect("palace_create");
2670
2671 let added = dispatch_tool(
2673 &state,
2674 "add_alias",
2675 json!({"short": "tga", "full": "trusty-git-analytics"}),
2676 )
2677 .await
2678 .expect("add_alias");
2679 assert_eq!(added["asserted"], true);
2680 assert_eq!(added["short"], "tga");
2681
2682 let listed = dispatch_tool(&state, "list_prompt_facts", json!({}))
2684 .await
2685 .expect("list_prompt_facts");
2686 let facts = listed["facts"].as_array().expect("facts array");
2687 assert!(
2688 facts.iter().any(|f| f["subject"] == "tga"
2689 && f["predicate"] == "is_alias_for"
2690 && f["object"] == "trusty-git-analytics"),
2691 "expected tga alias in facts; got {facts:?}"
2692 );
2693
2694 {
2696 let guard = state.prompt_context_cache.read().await;
2697 assert!(
2698 guard.formatted.contains("tga → trusty-git-analytics"),
2699 "prompt cache should contain alias; got: {}",
2700 guard.formatted
2701 );
2702 }
2703
2704 let _ = dispatch_tool(
2706 &state,
2707 "add_alias",
2708 json!({"short": "tm", "full": "trusty-memory", "extra": "the MCP frontend"}),
2709 )
2710 .await
2711 .expect("add_alias with extra");
2712 {
2713 let guard = state.prompt_context_cache.read().await;
2714 assert!(
2715 guard
2716 .formatted
2717 .contains("tm → trusty-memory (the MCP frontend)"),
2718 "alias with extra not formatted; got: {}",
2719 guard.formatted
2720 );
2721 }
2722
2723 let removed = dispatch_tool(
2725 &state,
2726 "remove_prompt_fact",
2727 json!({"subject": "tga", "predicate": "is_alias_for"}),
2728 )
2729 .await
2730 .expect("remove_prompt_fact");
2731 assert_eq!(removed["removed"], true);
2732 {
2733 let guard = state.prompt_context_cache.read().await;
2734 assert!(
2735 !guard.formatted.contains("tga → trusty-git-analytics"),
2736 "retracted alias still in cache: {}",
2737 guard.formatted
2738 );
2739 assert!(
2740 guard.formatted.contains("tm → trusty-memory"),
2741 "non-retracted alias missing from cache: {}",
2742 guard.formatted
2743 );
2744 }
2745
2746 let missing = dispatch_tool(
2748 &state,
2749 "remove_prompt_fact",
2750 json!({"subject": "nope", "predicate": "is_alias_for"}),
2751 )
2752 .await
2753 .expect("remove_prompt_fact missing");
2754 assert_eq!(missing["removed"], false);
2755 }
2756
2757 #[tokio::test]
2762 async fn get_prompt_context_serves_cache_and_filters() {
2763 let (state, _tmp) = test_state();
2764
2765 let resp = dispatch_tool(&state, "get_prompt_context", json!({}))
2767 .await
2768 .expect("get_prompt_context empty");
2769 assert_eq!(resp.as_str().unwrap(), "No prompt facts stored yet.");
2770
2771 {
2773 let mut guard = state.prompt_context_cache.write().await;
2774 let triples = vec![
2775 (
2776 "tga".to_string(),
2777 "is_alias_for".to_string(),
2778 "trusty-git-analytics".to_string(),
2779 ),
2780 (
2781 "tm".to_string(),
2782 "is_alias_for".to_string(),
2783 "trusty-memory".to_string(),
2784 ),
2785 (
2786 "fact-1".to_string(),
2787 "is_fact".to_string(),
2788 "MSRV is 1.88".to_string(),
2789 ),
2790 ];
2791 let formatted = crate::prompt_facts::build_prompt_context(&triples);
2792 *guard = crate::prompt_facts::PromptFactsCache { triples, formatted };
2793 }
2794
2795 let resp = dispatch_tool(&state, "get_prompt_context", json!({}))
2797 .await
2798 .expect("get_prompt_context populated");
2799 let text = resp.as_str().expect("string body");
2800 assert!(text.contains("tga → trusty-git-analytics"));
2801 assert!(text.contains("tm → trusty-memory"));
2802 assert!(text.contains("MSRV is 1.88"));
2803
2804 let resp = dispatch_tool(&state, "get_prompt_context", json!({"query": "tga"}))
2806 .await
2807 .expect("get_prompt_context filtered");
2808 let text = resp.as_str().expect("string body");
2809 assert!(text.contains("tga → trusty-git-analytics"));
2810 assert!(!text.contains("tm → trusty-memory"));
2811 assert!(!text.contains("MSRV is 1.88"));
2812
2813 let resp = dispatch_tool(&state, "get_prompt_context", json!({"query": "MEMORY"}))
2815 .await
2816 .expect("get_prompt_context case-insensitive");
2817 let text = resp.as_str().expect("string body");
2818 assert!(text.contains("tm → trusty-memory"));
2819 assert!(!text.contains("tga → trusty-git-analytics"));
2820
2821 let resp = dispatch_tool(
2823 &state,
2824 "get_prompt_context",
2825 json!({"query": "zzz-nonexistent"}),
2826 )
2827 .await
2828 .expect("get_prompt_context no-match");
2829 assert_eq!(
2830 resp.as_str().unwrap(),
2831 "No project context found matching your query."
2832 );
2833
2834 let resp = dispatch_tool(&state, "get_prompt_context", json!({"query": " "}))
2836 .await
2837 .expect("get_prompt_context whitespace");
2838 let text = resp.as_str().expect("string body");
2839 assert!(text.contains("tga → trusty-git-analytics"));
2840 assert!(text.contains("tm → trusty-memory"));
2841 }
2842
2843 #[tokio::test]
2850 async fn dispatch_discover_aliases_inserts_new_and_dedupes() {
2851 let _tmp = tempfile::tempdir().expect("tempdir");
2854 let root = _tmp.path().to_path_buf();
2855 let state = AppState::new(root).with_default_palace(Some("disc".to_string()));
2856 let _ = dispatch_tool(&state, "palace_create", json!({"name": "disc"}))
2857 .await
2858 .expect("palace_create");
2859
2860 let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2864 .parent()
2865 .and_then(|p| p.parent())
2866 .expect("workspace root")
2867 .to_path_buf();
2868
2869 let first = dispatch_tool(
2870 &state,
2871 "discover_aliases",
2872 json!({"project_root": workspace_root.to_string_lossy()}),
2873 )
2874 .await
2875 .expect("discover_aliases first");
2876
2877 let new_count = first["new"].as_u64().expect("new is u64");
2878 assert!(new_count > 0, "expected new discoveries on first call");
2879 let discovered = first["discovered"].as_array().expect("discovered array");
2880 assert!(
2881 discovered
2882 .iter()
2883 .any(|d| d["short"] == "tga" && d["full"] == "trusty-git-analytics"),
2884 "expected tga alias in discoveries; got {discovered:?}"
2885 );
2886
2887 {
2889 let guard = state.prompt_context_cache.read().await;
2890 assert!(
2891 guard.formatted.contains("tga → trusty-git-analytics"),
2892 "prompt cache missing tga alias after discover_aliases; got: {}",
2893 guard.formatted
2894 );
2895 }
2896
2897 let second = dispatch_tool(
2900 &state,
2901 "discover_aliases",
2902 json!({"project_root": workspace_root.to_string_lossy()}),
2903 )
2904 .await
2905 .expect("discover_aliases second");
2906 assert_eq!(second["new"].as_u64(), Some(0), "expected 0 new on rerun");
2907 let already_known = second["already_known"].as_u64().expect("already_known");
2908 assert!(
2909 already_known >= new_count,
2910 "expected already_known >= {new_count}, got {already_known}"
2911 );
2912 }
2913
2914 #[tokio::test]
2921 async fn palace_create_auto_seeds_temporal_metadata() {
2922 let (state, _tmp) = test_state();
2923 let created = dispatch_tool(&state, "palace_create", json!({"name": "auto"}))
2924 .await
2925 .expect("palace_create");
2926 assert_eq!(created["palace_id"], "auto");
2927 let summary = &created["bootstrap"];
2929 assert!(summary.is_object(), "expected bootstrap summary object");
2930 assert!(summary["triples_asserted"].as_u64().unwrap_or(0) >= 2);
2931
2932 let queried = dispatch_tool(
2933 &state,
2934 "kg_query",
2935 json!({"palace": "auto", "subject": "auto"}),
2936 )
2937 .await
2938 .expect("kg_query");
2939 let triples = queried["triples"].as_array().expect("triples");
2940 let predicates: Vec<&str> = triples
2941 .iter()
2942 .filter_map(|t| t["predicate"].as_str())
2943 .collect();
2944 assert!(
2945 predicates.contains(&"created_at"),
2946 "expected created_at after palace_create; got {predicates:?}",
2947 );
2948 assert!(
2949 predicates.contains(&"bootstrapped_at"),
2950 "expected bootstrapped_at after palace_create; got {predicates:?}",
2951 );
2952 assert!(
2954 queried.get("hint").is_none(),
2955 "hint should be absent when triples exist"
2956 );
2957 }
2958
2959 #[tokio::test]
2964 async fn kg_query_emits_hint_when_palace_empty() {
2965 let (state, _tmp) = test_state();
2966 let _ = dispatch_tool(&state, "palace_create", json!({"name": "hinted"}))
2967 .await
2968 .expect("palace_create");
2969 let queried = dispatch_tool(
2971 &state,
2972 "kg_query",
2973 json!({"palace": "hinted", "subject": "unrelated-subject"}),
2974 )
2975 .await
2976 .expect("kg_query");
2977 assert_eq!(queried["triples"].as_array().unwrap().len(), 0);
2978 let hint = queried["hint"].as_str().expect("hint field present");
2979 assert!(hint.contains("kg_bootstrap"));
2980 assert!(hint.contains("kg_assert"));
2981 }
2982
2983 #[tokio::test]
2987 async fn kg_bootstrap_seeds_workspace_facts() {
2988 let (state, _tmp) = test_state();
2989 let _ = dispatch_tool(&state, "palace_create", json!({"name": "ws"}))
2990 .await
2991 .expect("palace_create");
2992
2993 let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2994 .parent()
2995 .and_then(|p| p.parent())
2996 .expect("workspace root")
2997 .to_path_buf();
2998
2999 let result = dispatch_tool(
3000 &state,
3001 "kg_bootstrap",
3002 json!({"palace": "ws", "project_path": workspace_root.to_string_lossy()}),
3003 )
3004 .await
3005 .expect("kg_bootstrap");
3006 assert!(result["triples_asserted"].as_u64().unwrap() > 0);
3007 let subject = result["project_subject"]
3008 .as_str()
3009 .expect("project_subject")
3010 .to_string();
3011
3012 let queried = dispatch_tool(
3014 &state,
3015 "kg_query",
3016 json!({"palace": "ws", "subject": subject}),
3017 )
3018 .await
3019 .expect("kg_query");
3020 let triples = queried["triples"].as_array().expect("triples");
3021 let predicates: Vec<&str> = triples
3022 .iter()
3023 .filter_map(|t| t["predicate"].as_str())
3024 .collect();
3025 assert!(
3029 predicates.contains(&"has_workspace_member") || predicates.contains(&"has_language"),
3030 "expected workspace/language fact; got {predicates:?}",
3031 );
3032 assert!(
3034 predicates.contains(&"source_repo"),
3035 "expected source_repo from .git/config; got {predicates:?}",
3036 );
3037 assert!(predicates.contains(&"bootstrapped_at"));
3039 }
3040
3041 #[test]
3050 fn content_gate_blocks_short_no_context() {
3051 assert_eq!(content_gate("yes", None), None);
3052 assert_eq!(content_gate("ok", None), None);
3053 assert_eq!(
3054 content_gate(" no thanks ", None),
3055 None,
3056 "2 words still < 4"
3057 );
3058 assert_eq!(
3059 content_gate("one two three", None),
3060 None,
3061 "3 words still < 4"
3062 );
3063 }
3064
3065 #[test]
3071 fn content_gate_wraps_short_with_context() {
3072 let combined = content_gate(
3073 "yes",
3074 Some("Do you want to enable auto-bootstrap on new palaces?"),
3075 )
3076 .expect("context should unlock the gate");
3077 assert_eq!(
3078 combined,
3079 "Do you want to enable auto-bootstrap on new palaces?\n\n---\n\nyes",
3080 );
3081 let combined = content_gate(
3084 "the quick brown fox jumps over the lazy dog",
3085 Some("Famous typing pangram"),
3086 )
3087 .expect("long content + context still combines");
3088 assert!(combined.starts_with("Famous typing pangram"));
3089 assert!(combined.contains("\n\n---\n\n"));
3090 assert!(combined.ends_with("the quick brown fox jumps over the lazy dog"));
3091 }
3092
3093 #[test]
3100 fn content_gate_keeps_long() {
3101 let body = "User prefers snake_case for python";
3102 let kept = content_gate(body, None).expect(">= 4 words passes");
3103 assert_eq!(kept, body, "passing content must round-trip verbatim");
3104 let boundary = "one two three four";
3106 assert_eq!(content_gate(boundary, None).as_deref(), Some(boundary));
3107 }
3108
3109 #[test]
3116 fn content_gate_blank_context_treated_as_none() {
3117 assert_eq!(content_gate("yes", Some("")), None);
3118 assert_eq!(content_gate("yes", Some(" ")), None);
3119 assert_eq!(content_gate("yes", Some("\n\t")), None);
3120 }
3121
3122 #[tokio::test]
3128 async fn dispatch_remember_skips_short_no_context() {
3129 let (state, _tmp) = test_state();
3130 let _ = dispatch_tool(&state, "palace_create", json!({"name": "gate"}))
3131 .await
3132 .expect("palace_create");
3133
3134 let res = dispatch_tool(
3135 &state,
3136 "memory_remember",
3137 json!({"palace": "gate", "text": "yes"}),
3138 )
3139 .await
3140 .expect("memory_remember (short)");
3141 assert_eq!(res["status"], "skipped");
3142 assert!(res["reason"]
3143 .as_str()
3144 .unwrap_or("")
3145 .contains("content gate"));
3146 let listed = dispatch_tool(
3148 &state,
3149 "memory_list",
3150 json!({"palace": "gate", "limit": 10}),
3151 )
3152 .await
3153 .expect("memory_list");
3154 let drawers = listed["drawers"].as_array().expect("drawers array");
3155 assert!(
3156 drawers.is_empty(),
3157 "no drawer should be written; got {drawers:?}"
3158 );
3159 }
3160
3161 #[tokio::test]
3169 async fn dispatch_remember_with_context_writes_combined() {
3170 let (state, _tmp) = test_state();
3171 let _ = dispatch_tool(&state, "palace_create", json!({"name": "ctxgate"}))
3172 .await
3173 .expect("palace_create");
3174
3175 let res = dispatch_tool(
3176 &state,
3177 "memory_remember",
3178 json!({
3179 "palace": "ctxgate",
3180 "text": "yes",
3181 "context": "Do you want to enable auto-bootstrap on new palaces?",
3182 "force": true,
3183 }),
3184 )
3185 .await
3186 .expect("memory_remember (with context)");
3187 assert_eq!(res["status"], "stored");
3188
3189 let listed = dispatch_tool(
3190 &state,
3191 "memory_list",
3192 json!({"palace": "ctxgate", "limit": 10}),
3193 )
3194 .await
3195 .expect("memory_list");
3196 let drawers = listed["drawers"].as_array().expect("drawers array");
3197 assert_eq!(drawers.len(), 1);
3198 let body = drawers[0]["content"].as_str().expect("content");
3199 assert!(body.starts_with("Do you want to enable auto-bootstrap"));
3200 assert!(body.contains("\n\n---\n\n"));
3201 assert!(body.ends_with("yes"));
3202 }
3203
3204 #[tokio::test]
3211 async fn dispatch_note_skips_short_no_context() {
3212 let (state, _tmp) = test_state();
3213 let _ = dispatch_tool(&state, "palace_create", json!({"name": "noteg"}))
3214 .await
3215 .expect("palace_create");
3216
3217 let res = dispatch_tool(
3218 &state,
3219 "memory_note",
3220 json!({"palace": "noteg", "content": "ok"}),
3221 )
3222 .await
3223 .expect("memory_note (short)");
3224 assert_eq!(res["status"], "skipped");
3225 let listed = dispatch_tool(
3226 &state,
3227 "memory_list",
3228 json!({"palace": "noteg", "limit": 10}),
3229 )
3230 .await
3231 .expect("memory_list");
3232 assert!(listed["drawers"].as_array().unwrap().is_empty());
3233 }
3234
3235 #[tokio::test]
3236 async fn dispatch_unknown_tool_errors() {
3237 let (state, _tmp) = test_state();
3238 let err = dispatch_tool(&state, "does_not_exist", json!({}))
3239 .await
3240 .expect_err("should error");
3241 assert!(err.to_string().contains("unknown tool"));
3242 }
3243
3244 #[test]
3255 fn blocklist_gate_blocks_tool_use() {
3256 assert!(blocklist_gate("Tool use: Bash"));
3257 assert!(blocklist_gate(
3258 "Tool use: Edit File: /Users/me/Projects/foo/bar.rs"
3259 ));
3260 assert!(blocklist_gate(" Tool use: Read"));
3262 }
3263
3264 #[test]
3269 fn blocklist_gate_blocks_session_ended() {
3270 assert!(blocklist_gate(
3271 "Claude Code session ended: 1d2c3b4a-0000-0000-0000-000000000000"
3272 ));
3273 assert!(blocklist_gate("Claude Code session started"));
3274 }
3275
3276 #[test]
3282 fn blocklist_gate_passes_normal_content() {
3283 assert!(!blocklist_gate("User prefers snake_case for python"));
3284 assert!(!blocklist_gate(
3285 "Quokkas are the happiest marsupials in Australia"
3286 ));
3287 assert!(!blocklist_gate("Note: refactor the dispatcher next sprint"));
3288 assert!(blocklist_gate("I used Tool use: Bash here"));
3293 }
3294
3295 #[tokio::test]
3305 async fn dedup_skips_near_duplicate() {
3306 let (state, _tmp) = test_state();
3307 let _ = dispatch_tool(&state, "palace_create", json!({"name": "dedup1"}))
3308 .await
3309 .expect("palace_create");
3310
3311 let _ = dispatch_tool(
3314 &state,
3315 "memory_remember",
3316 json!({
3317 "palace": "dedup1",
3318 "text": "The quick brown fox jumped over the lazy dog repeatedly today",
3319 }),
3320 )
3321 .await
3322 .expect("memory_remember seed");
3323
3324 let handle = open_palace_handle(&state, "dedup1").expect("open handle");
3325 assert!(
3329 dedup_gate(
3330 &handle,
3331 "The quick brown fox jumped over the lazy dog repeatedly yesterday"
3332 ),
3333 "near-duplicate should be detected"
3334 );
3335 assert!(
3337 dedup_gate(
3338 &handle,
3339 "The quick brown fox jumped over the lazy dog repeatedly today"
3340 ),
3341 "exact match should be detected"
3342 );
3343 }
3344
3345 #[tokio::test]
3351 async fn dedup_allows_different_content() {
3352 let (state, _tmp) = test_state();
3353 let _ = dispatch_tool(&state, "palace_create", json!({"name": "dedup2"}))
3354 .await
3355 .expect("palace_create");
3356
3357 let _ = dispatch_tool(
3358 &state,
3359 "memory_remember",
3360 json!({
3361 "palace": "dedup2",
3362 "text": "Quokkas are the happiest marsupials in Australia by general consensus",
3363 }),
3364 )
3365 .await
3366 .expect("memory_remember seed");
3367
3368 let handle = open_palace_handle(&state, "dedup2").expect("open handle");
3369 assert!(
3371 !dedup_gate(
3372 &handle,
3373 "Rust is a systems programming language focused on safety and concurrency"
3374 ),
3375 "unrelated content should pass the dedup gate"
3376 );
3377 assert!(!dedup_gate(&handle, " "));
3380 }
3381
3382 #[tokio::test]
3397 async fn dedup_gate_blocks_concurrent_duplicate_writes() {
3398 let (state, _tmp) = test_state();
3399 let state = std::sync::Arc::new(state);
3400 let _ = dispatch_tool(&state, "palace_create", json!({"name": "dedup_race"}))
3401 .await
3402 .expect("palace_create");
3403
3404 let text =
3408 "Concurrent identical writes must collapse to a single drawer under the dedup gate";
3409
3410 let s1 = state.clone();
3411 let t1 = tokio::spawn(async move {
3412 dispatch_tool(
3413 &s1,
3414 "memory_remember",
3415 json!({"palace": "dedup_race", "text": text}),
3416 )
3417 .await
3418 });
3419 let s2 = state.clone();
3420 let t2 = tokio::spawn(async move {
3421 dispatch_tool(
3422 &s2,
3423 "memory_remember",
3424 json!({"palace": "dedup_race", "text": text}),
3425 )
3426 .await
3427 });
3428 let r1 = t1.await.expect("join t1").expect("dispatch t1");
3429 let r2 = t2.await.expect("join t2").expect("dispatch t2");
3430
3431 let statuses = [
3434 r1["status"].as_str().unwrap_or(""),
3435 r2["status"].as_str().unwrap_or(""),
3436 ];
3437 let stored = statuses.iter().filter(|s| **s == "stored").count();
3438 let skipped = statuses.iter().filter(|s| **s == "skipped").count();
3439 assert_eq!(
3440 stored, 1,
3441 "exactly one concurrent write should be stored; got responses {r1:?} {r2:?}"
3442 );
3443 assert_eq!(
3444 skipped, 1,
3445 "exactly one concurrent write should be skipped; got responses {r1:?} {r2:?}"
3446 );
3447 let skipped_reason = if r1["status"] == "skipped" {
3448 r1["reason"].as_str().unwrap_or("")
3449 } else {
3450 r2["reason"].as_str().unwrap_or("")
3451 };
3452 assert!(
3453 skipped_reason.contains("duplicate within window"),
3454 "skipped envelope should cite dedup reason; got {skipped_reason:?}"
3455 );
3456
3457 let listed = dispatch_tool(
3459 &state,
3460 "memory_list",
3461 json!({"palace": "dedup_race", "limit": 10}),
3462 )
3463 .await
3464 .expect("memory_list");
3465 let drawers = listed["drawers"].as_array().expect("drawers array");
3466 assert_eq!(
3467 drawers.len(),
3468 1,
3469 "only one drawer should be persisted after concurrent identical writes; got {drawers:?}"
3470 );
3471 }
3472
3473 #[tokio::test]
3481 async fn dispatch_remember_blocks_blocklist_pattern() {
3482 let (state, _tmp) = test_state();
3483 let _ = dispatch_tool(&state, "palace_create", json!({"name": "blk"}))
3484 .await
3485 .expect("palace_create");
3486
3487 let res = dispatch_tool(
3488 &state,
3489 "memory_remember",
3490 json!({"palace": "blk", "text": "Tool use: Bash"}),
3491 )
3492 .await
3493 .expect("memory_remember (blocked)");
3494 assert_eq!(res["status"], "skipped");
3495 assert!(
3496 res["reason"]
3497 .as_str()
3498 .unwrap_or("")
3499 .contains("blocked pattern"),
3500 "reason should mention blocked pattern; got {res:?}"
3501 );
3502
3503 let listed = dispatch_tool(&state, "memory_list", json!({"palace": "blk", "limit": 10}))
3504 .await
3505 .expect("memory_list");
3506 let drawers = listed["drawers"].as_array().expect("drawers array");
3507 assert!(drawers.is_empty(), "no drawer should be written");
3508 }
3509
3510 #[tokio::test]
3524 async fn bm25_index_queue_drops_when_full() {
3525 let (mut state, _tmp) = test_state();
3529 let (tx, _rx_held) =
3530 tokio::sync::mpsc::channel::<Bm25IndexRequest>(BM25_INDEX_QUEUE_CAPACITY);
3531 state.bm25_index_tx = tx;
3532
3533 for i in 0..BM25_INDEX_QUEUE_CAPACITY {
3535 bm25_index_enqueue(
3536 &state,
3537 "default",
3538 Uuid::new_v4(),
3539 &format!("filler content {i}"),
3540 );
3541 }
3542 assert_eq!(
3544 state.bm25_index_tx.capacity(),
3545 0,
3546 "after filling, sender capacity must be 0"
3547 );
3548
3549 for i in 0..16 {
3552 bm25_index_enqueue(
3553 &state,
3554 "default",
3555 Uuid::new_v4(),
3556 &format!("overflow content {i}"),
3557 );
3558 }
3559
3560 let probe_req = Bm25IndexRequest {
3564 palace: "default".to_string(),
3565 drawer_id: Uuid::new_v4().to_string(),
3566 content: "probe".to_string(),
3567 data_dir: state.data_root.join("default").join("bm25"),
3568 };
3569 let probe = state.bm25_index_tx.try_send(probe_req);
3570 match probe {
3571 Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {}
3572 other => panic!("expected Full overflow, got {other:?}"),
3573 }
3574 }
3575}