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 let add_alias_required: Vec<&str> = if has_default {
316 vec!["short", "full"]
317 } else {
318 vec!["palace", "short", "full"]
319 };
320 let discover_aliases_required: Vec<&str> = if has_default { vec![] } else { vec!["palace"] };
321
322 json!({
323 "tools": [
324 {
325 "name": "memory_remember",
326 "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.",
327 "inputSchema": {
328 "type": "object",
329 "properties": {
330 "palace": {"type": "string", "description": "Palace ID (optional if server started with --palace)"},
331 "text": {"type": "string", "description": "Memory content"},
332 "room": {"type": "string", "description": "Room type (optional)"},
333 "tags": {"type": "array", "items": {"type": "string"}},
334 "force": {"type": "boolean", "description": "Bypass the signal/noise filter. Use sparingly — intended for explicit operator overrides.", "default": false},
335 "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)."}
336 },
337 "required": memory_remember_required,
338 }
339 },
340 {
341 "name": "memory_note",
342 "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.",
343 "inputSchema": {
344 "type": "object",
345 "properties": {
346 "palace": {"type": "string"},
347 "content": {"type": "string", "description": "Brief fact to remember"},
348 "tags": {"type": "array", "items": {"type": "string"}},
349 "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)."}
350 },
351 "required": memory_note_required,
352 }
353 },
354 {
355 "name": "memory_recall",
356 "description": "Recall memories using L0+L1+L2 progressive retrieval.",
357 "inputSchema": {
358 "type": "object",
359 "properties": {
360 "palace": {"type": "string"},
361 "query": {"type": "string"},
362 "top_k": {"type": "integer", "default": 10}
363 },
364 "required": memory_recall_required,
365 }
366 },
367 {
368 "name": "memory_recall_deep",
369 "description": "Deep recall using L3 full HNSW search.",
370 "inputSchema": {
371 "type": "object",
372 "properties": {
373 "palace": {"type": "string"},
374 "query": {"type": "string"},
375 "top_k": {"type": "integer", "default": 10}
376 },
377 "required": memory_recall_required,
378 }
379 },
380 {
381 "name": "palace_create",
382 "description": "Create a new memory palace.",
383 "inputSchema": {
384 "type": "object",
385 "properties": {
386 "name": {"type": "string"},
387 "description": {"type": "string"},
388 "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)."}
389 },
390 "required": ["name"]
391 }
392 },
393 {
394 "name": "palace_list",
395 "description": "List all palaces on this machine.",
396 "inputSchema": {"type": "object", "properties": {}}
397 },
398 {
399 "name": "palace_delete",
400 "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.",
401 "inputSchema": {
402 "type": "object",
403 "properties": {
404 "palace_id": {"type": "string", "description": "Id of the palace to delete."},
405 "force": {"type": "boolean", "description": "Required when the palace still has drawers; defaults to false.", "default": false}
406 },
407 "required": ["palace_id"]
408 }
409 },
410 {
411 "name": "palace_update",
412 "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.",
413 "inputSchema": {
414 "type": "object",
415 "properties": {
416 "palace_id": {"type": "string", "description": "Id of the palace to rename."},
417 "name": {"type": "string", "description": "New display name. Trimmed; must be non-empty."}
418 },
419 "required": ["palace_id", "name"]
420 }
421 },
422 {
423 "name": "kg_assert",
424 "description": "Assert a fact in the temporal knowledge graph.",
425 "inputSchema": {
426 "type": "object",
427 "properties": {
428 "palace": {"type": "string"},
429 "subject": {"type": "string"},
430 "predicate": {"type": "string"},
431 "object": {"type": "string"},
432 "confidence": {"type": "number", "default": 1.0},
433 "provenance": {"type": "string"}
434 },
435 "required": kg_assert_required,
436 }
437 },
438 {
439 "name": "kg_query",
440 "description": "Query active knowledge-graph triples for a subject.",
441 "inputSchema": {
442 "type": "object",
443 "properties": {
444 "palace": {"type": "string"},
445 "subject": {"type": "string"}
446 },
447 "required": kg_query_required,
448 }
449 },
450 {
451 "name": "memory_list",
452 "description": "List drawers in a palace, optionally filtered by room type or tag.",
453 "inputSchema": {
454 "type": "object",
455 "properties": {
456 "palace": {"type": "string"},
457 "room": {"type": "string", "description": "Filter by room type (Frontend, Backend, Testing, Planning, Documentation, Research, Configuration, Meetings, General, or custom)"},
458 "tag": {"type": "string", "description": "Filter by tag"},
459 "limit": {"type": "integer", "description": "Max results (default 50)"}
460 },
461 "required": memory_list_required,
462 }
463 },
464 {
465 "name": "memory_forget",
466 "description": "Delete a drawer from a palace by its UUID.",
467 "inputSchema": {
468 "type": "object",
469 "properties": {
470 "palace": {"type": "string"},
471 "drawer_id": {"type": "string", "description": "UUID of the drawer to delete"}
472 },
473 "required": memory_forget_required,
474 }
475 },
476 {
477 "name": "palace_info",
478 "description": "Get metadata and stats for a single palace.",
479 "inputSchema": {
480 "type": "object",
481 "properties": {
482 "palace": {"type": "string"}
483 },
484 "required": palace_info_required,
485 }
486 },
487 {
488 "name": "palace_compact",
489 "description": "Remove orphaned vector index entries (vectors with no matching drawer row). See issue #49.",
490 "inputSchema": {
491 "type": "object",
492 "properties": {
493 "palace": {"type": "string"}
494 },
495 "required": palace_compact_required,
496 }
497 },
498 {
499 "name": "add_alias",
500 "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.",
501 "inputSchema": {
502 "type": "object",
503 "properties": {
504 "palace": {"type": "string", "description": "Palace ID (optional if server started with --palace)"},
505 "short": {"type": "string", "description": "Short name / alias (subject)"},
506 "full": {"type": "string", "description": "Full / canonical name (object)"},
507 "extra": {"type": "string", "description": "Optional extra context appended to the full name"}
508 },
509 "required": add_alias_required,
510 }
511 },
512 {
513 "name": "list_prompt_facts",
514 "description": "List every active prompt-fact triple (aliases, conventions, facts, shorthands) across all palaces.",
515 "inputSchema": {"type": "object", "properties": {}}
516 },
517 {
518 "name": "remove_prompt_fact",
519 "description": "Retract the active triple for a (subject, predicate) pair from the prompt-facts surface. Closes the interval without inserting a replacement.",
520 "inputSchema": {
521 "type": "object",
522 "properties": {
523 "subject": {"type": "string"},
524 "predicate": {"type": "string", "description": "One of is_alias_for, has_convention, is_fact, is_shorthand_for"}
525 },
526 "required": ["subject", "predicate"],
527 }
528 },
529 {
530 "name": "get_prompt_context",
531 "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).",
532 "inputSchema": {
533 "type": "object",
534 "properties": {
535 "query": {
536 "type": "string",
537 "description": "Optional filter — only return facts whose subject or object contains this string (case-insensitive). Omit to return all hot facts."
538 }
539 }
540 }
541 },
542 {
543 "name": "discover_aliases",
544 "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.",
545 "inputSchema": {
546 "type": "object",
547 "properties": {
548 "palace": {"type": "string", "description": "Palace ID (optional if server started with --palace)"},
549 "project_root": {"type": "string", "description": "Optional filesystem path to scan. Defaults to the process cwd."}
550 },
551 "required": discover_aliases_required,
552 }
553 },
554 {
555 "name": "kg_gaps",
556 "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.",
557 "inputSchema": {
558 "type": "object",
559 "properties": {
560 "palace": {"type": "string", "description": "Palace name (optional, defaults to the active palace)"}
561 }
562 }
563 },
564 {
565 "name": "kg_bootstrap",
566 "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.",
567 "inputSchema": {
568 "type": "object",
569 "properties": {
570 "palace": {"type": "string", "description": "Palace ID (optional if server started with --palace)"},
571 "project_path": {"type": "string", "description": "Filesystem path to scan. Omit to scan the palace's own data dir (temporal metadata only)."}
572 }
573 }
574 },
575 {
576 "name": "memory_recall_all",
577 "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.",
578 "inputSchema": {
579 "type": "object",
580 "properties": {
581 "q": {"type": "string", "description": "Free-text query"},
582 "top_k": {"type": "integer", "default": 10},
583 "deep": {"type": "boolean", "default": false}
584 },
585 "required": ["q"],
586 }
587 },
588 {
589 "name": "memory_send_message",
590 "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.",
591 "inputSchema": {
592 "type": "object",
593 "properties": {
594 "to_palace": {"type": "string", "description": "Recipient palace id (repo slug)."},
595 "purpose": {"type": "string", "description": "Free-text purpose / category (e.g. `task`, `notify`, `reply`)."},
596 "content": {"type": "string", "description": "Message body — plain text, no length limit. Rendered into the recipient session as a Markdown block."},
597 "from_palace": {"type": "string", "description": "Sender palace id (optional, defaults to cwd-derived slug)."}
598 },
599 "required": ["to_palace", "purpose", "content"],
600 }
601 },
602 {
603 "name": "upgrade",
604 "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.",
605 "inputSchema": {
606 "type": "object",
607 "properties": {
608 "check": {"type": "boolean", "description": "Report current and available versions only. No install. Default: true when confirm is absent.", "default": true},
609 "confirm": {"type": "boolean", "description": "Set to true to install the new version. NEVER set automatically — the operator must explicitly pass confirm=true.", "default": false}
610 },
611 "required": []
612 }
613 }
614 ]
615 })
616}
617
618pub(crate) fn room_label(room: &RoomType) -> Option<String> {
629 let label = match room {
630 RoomType::Frontend => "Frontend",
631 RoomType::Backend => "Backend",
632 RoomType::Testing => "Testing",
633 RoomType::Planning => "Planning",
634 RoomType::Documentation => "Documentation",
635 RoomType::Research => "Research",
636 RoomType::Configuration => "Configuration",
637 RoomType::Meetings => "Meetings",
638 RoomType::General => "General",
639 RoomType::Custom(s) => return Some(s.clone()),
640 };
641 Some(label.to_string())
642}
643
644fn parse_room(s: Option<&str>) -> RoomType {
652 match s.unwrap_or("General") {
653 "Frontend" => RoomType::Frontend,
654 "Backend" => RoomType::Backend,
655 "Testing" => RoomType::Testing,
656 "Planning" => RoomType::Planning,
657 "Documentation" => RoomType::Documentation,
658 "Research" => RoomType::Research,
659 "Configuration" => RoomType::Configuration,
660 "Meetings" => RoomType::Meetings,
661 "General" => RoomType::General,
662 other => RoomType::Custom(other.to_string()),
663 }
664}
665
666fn open_palace_handle(
668 state: &AppState,
669 palace_id: &str,
670) -> Result<std::sync::Arc<trusty_common::memory_core::PalaceHandle>> {
671 let pid = PalaceId::new(palace_id);
672 state
673 .registry
674 .open_palace(&state.data_root, &pid)
675 .with_context(|| format!("open palace {palace_id}"))
676}
677
678pub(crate) async fn auto_extract_and_assert(
694 handle: &std::sync::Arc<trusty_common::memory_core::PalaceHandle>,
695 drawer_id: Uuid,
696 content: &str,
697 tags: &[String],
698 room: Option<&str>,
699) {
700 let input = ExtractInput {
701 drawer_id,
702 content,
703 tags,
704 room,
705 };
706 let triples = extract_triples(&input);
707 if triples.is_empty() {
708 return;
709 }
710 for triple in triples {
711 let s = triple.subject.clone();
712 let p = triple.predicate.clone();
713 if let Err(e) = handle.kg.assert(triple).await {
714 tracing::warn!(
715 drawer_id = %drawer_id,
716 subject = %s,
717 predicate = %p,
718 "auto kg extraction: assert failed (non-fatal): {e:#}",
719 );
720 }
721 }
722}
723
724fn resolve_palace<'a>(state: &'a AppState, args: &'a Value, tool: &str) -> Result<String> {
736 if let Some(p) = args.get("palace").and_then(|v| v.as_str()) {
737 return Ok(p.to_string());
738 }
739 state
740 .default_palace
741 .clone()
742 .ok_or_else(|| anyhow!("{tool}: missing 'palace' (no --palace default configured)"))
743}
744
745struct WriteDrawerParams<'a> {
759 palace_id: &'a str,
760 content: String,
761 tags: Vec<String>,
762 room: RoomType,
763 importance: f32,
764 opts: RememberOptions,
765 room_label_for_kg: Option<String>,
766}
767
768async fn write_drawer(state: &AppState, params: WriteDrawerParams<'_>) -> Result<Uuid> {
784 let WriteDrawerParams {
785 palace_id,
786 content,
787 tags,
788 room,
789 importance,
790 opts,
791 room_label_for_kg,
792 } = params;
793
794 let handle = open_palace_handle(state, palace_id)?;
795 let preview = crate::service::drawer_content_preview(&content);
798 let content_for_kg = content.clone();
802 let tags_for_kg = tags.clone();
803 let drawer_id = handle
804 .remember_with_options(content, room, tags, importance, opts)
805 .await
806 .context("PalaceHandle::remember_with_options")?;
807 bm25_index_enqueue(state, palace_id, drawer_id, &content_for_kg);
813 let palace_name = lookup_palace_name(state, palace_id);
816 let drawer_count = handle.drawers.read().len();
817 state.emit(DaemonEvent::DrawerAdded {
818 palace_id: palace_id.to_string(),
819 palace_name,
820 drawer_count,
821 timestamp: chrono::Utc::now(),
822 content_preview: preview,
823 source: ActivitySource::Mcp,
824 });
825 auto_extract_and_assert(
833 &handle,
834 drawer_id,
835 &content_for_kg,
836 &tags_for_kg,
837 room_label_for_kg.as_deref(),
838 )
839 .await;
840 Ok(drawer_id)
841}
842
843fn skipped_envelope(palace_id: &str, reason: &str) -> Value {
855 json!({
856 "palace": palace_id,
857 "status": "skipped",
858 "reason": reason,
859 })
860}
861
862fn parse_tags(args: &Value) -> Vec<String> {
872 args.get("tags")
873 .and_then(|v| v.as_array())
874 .map(|arr| {
875 arr.iter()
876 .filter_map(|t| t.as_str().map(|s| s.to_string()))
877 .collect()
878 })
879 .unwrap_or_default()
880}
881
882fn attach_mcp_attribution(tags: &mut Vec<String>) {
894 if let Some(session_tag) = session_tag_from_tags(tags) {
895 tags.push(session_tag);
896 }
897 CreatorInfo::new_self(MCP_CLIENT_NAME, CreatorSource::Mcp).merge_into(tags);
898}
899
900async fn handle_memory_remember(state: &AppState, args: Value) -> Result<Value> {
910 let palace = resolve_palace(state, &args, "memory_remember")?;
911 let palace = palace.as_str();
912 let raw_text = args
913 .get("text")
914 .and_then(|v| v.as_str())
915 .ok_or_else(|| anyhow!("memory_remember: missing 'text'"))?
916 .to_string();
917 if blocklist_gate(&raw_text) {
922 tracing::debug!(
923 palace = %palace,
924 "content gate: skipped (blocked pattern)",
925 );
926 return Ok(skipped_envelope(
927 palace,
928 "content gate: skipped (blocked pattern)",
929 ));
930 }
931 let ctx = args.get("context").and_then(|v| v.as_str());
937 let text = match content_gate(&raw_text, ctx) {
938 Some(t) => t,
939 None => {
940 return Ok(skipped_envelope(
941 palace,
942 "content gate: skipped (short prompt, no context)",
943 ));
944 }
945 };
946 let room = parse_room(args.get("room").and_then(|v| v.as_str()));
947 let mut tags = parse_tags(&args);
948 attach_mcp_attribution(&mut tags);
956
957 let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
958
959 let write_lock = state.palace_write_lock(palace);
967 let _write_guard = write_lock.lock().await;
968
969 if !force {
974 let handle = open_palace_handle(state, palace)?;
975 if dedup_gate(&handle, &text) {
976 tracing::debug!(
977 palace = %palace,
978 "content gate: skipped (duplicate within window)",
979 );
980 return Ok(skipped_envelope(
981 palace,
982 "content gate: skipped (duplicate within window)",
983 ));
984 }
985 }
986 let room_label_for_kg = room_label(&room);
987 let drawer_id = write_drawer(
988 state,
989 WriteDrawerParams {
990 palace_id: palace,
991 content: text,
992 tags,
993 room,
994 importance: 0.5,
995 opts: mcp_remember_opts(force),
996 room_label_for_kg,
997 },
998 )
999 .await?;
1000 Ok(json!({
1001 "drawer_id": drawer_id.to_string(),
1002 "palace": palace,
1003 "status": "stored",
1004 }))
1005}
1006
1007async fn handle_memory_note(state: &AppState, args: Value) -> Result<Value> {
1008 let palace = resolve_palace(state, &args, "memory_note")?;
1014 let palace = palace.as_str();
1015 let raw_content = args
1016 .get("content")
1017 .and_then(|v| v.as_str())
1018 .ok_or_else(|| anyhow!("memory_note: missing 'content'"))?
1019 .to_string();
1020 if blocklist_gate(&raw_content) {
1025 tracing::debug!(
1026 palace = %palace,
1027 "content gate: skipped (blocked pattern)",
1028 );
1029 return Ok(skipped_envelope(
1030 palace,
1031 "content gate: skipped (blocked pattern)",
1032 ));
1033 }
1034 let ctx = args.get("context").and_then(|v| v.as_str());
1039 let content = match content_gate(&raw_content, ctx) {
1040 Some(c) => c,
1041 None => {
1042 return Ok(skipped_envelope(
1043 palace,
1044 "content gate: skipped (short prompt, no context)",
1045 ));
1046 }
1047 };
1048 let mut tags = parse_tags(&args);
1049 attach_mcp_attribution(&mut tags);
1053 let write_lock = state.palace_write_lock(palace);
1061 let _write_guard = write_lock.lock().await;
1062 {
1067 let handle = open_palace_handle(state, palace)?;
1068 if dedup_gate(&handle, &content) {
1069 tracing::debug!(
1070 palace = %palace,
1071 "content gate: skipped (duplicate within window)",
1072 );
1073 return Ok(skipped_envelope(
1074 palace,
1075 "content gate: skipped (duplicate within window)",
1076 ));
1077 }
1078 }
1079 let drawer_id = write_drawer(
1083 state,
1084 WriteDrawerParams {
1085 palace_id: palace,
1086 content,
1087 tags,
1088 room: RoomType::General,
1089 importance: 1.0,
1090 opts: RememberOptions::note(),
1091 room_label_for_kg: Some("General".to_string()),
1095 },
1096 )
1097 .await
1098 .context("PalaceHandle::remember_with_options (note)")?;
1099 Ok(json!({
1100 "drawer_id": drawer_id.to_string(),
1101 "palace": palace,
1102 "status": "stored",
1103 "drawer_type": "UserFact",
1104 }))
1105}
1106
1107async fn handle_memory_recall(state: &AppState, args: Value) -> Result<Value> {
1108 let palace = resolve_palace(state, &args, "memory_recall")?;
1109 let query = args
1110 .get("query")
1111 .and_then(|v| v.as_str())
1112 .ok_or_else(|| anyhow!("memory_recall: missing 'query'"))?;
1113 let top_k = args.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
1114
1115 let handle = open_palace_handle(state, &palace)?;
1116 let embedder = state.embedder().await?;
1117 let vector_fut = recall(&handle, embedder.as_ref(), query, top_k);
1123 let bm25_fut = bm25_search_optional(state, &palace, query, top_k);
1124 let (vector_res, bm25_res) = tokio::join!(vector_fut, bm25_fut);
1125 let mut results = vector_res.context("recall")?;
1126 if let Some(bm25_hits) = bm25_res {
1127 fuse_bm25_into_recall(&mut results, &bm25_hits, top_k);
1128 }
1129 Ok(serialize_recall(&palace, query, results))
1130}
1131
1132async fn handle_memory_recall_deep(state: &AppState, args: Value) -> Result<Value> {
1133 let palace = resolve_palace(state, &args, "memory_recall_deep")?;
1134 let query = args
1135 .get("query")
1136 .and_then(|v| v.as_str())
1137 .ok_or_else(|| anyhow!("memory_recall_deep: missing 'query'"))?;
1138 let top_k = args.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
1139
1140 let handle = open_palace_handle(state, &palace)?;
1141 let embedder = state.embedder().await?;
1142 let results = recall_deep(&handle, embedder.as_ref(), query, top_k)
1143 .await
1144 .context("recall_deep")?;
1145 Ok(serialize_recall(&palace, query, results))
1146}
1147
1148async fn handle_palace_create(state: &AppState, args: Value) -> Result<Value> {
1149 let palace_name = args
1150 .get("name")
1151 .and_then(|v| v.as_str())
1152 .ok_or_else(|| anyhow!("palace_create: missing 'name'"))?;
1153
1154 let skip_enforcement = std::env::var("TRUSTY_SKIP_PALACE_ENFORCEMENT").as_deref() == Ok("1");
1170 if !skip_enforcement {
1171 let cwd = args
1172 .get("cwd")
1173 .and_then(|v| v.as_str())
1174 .filter(|s| !s.is_empty())
1175 .map(std::path::Path::new)
1176 .map(|p| p.to_path_buf())
1177 .or_else(|| std::env::current_dir().ok())
1178 .unwrap_or_else(|| state.data_root.clone());
1179 crate::project_root::validate_palace_name(palace_name, &cwd)?;
1180 }
1181
1182 let description = args
1183 .get("description")
1184 .and_then(|v| v.as_str())
1185 .map(|s| s.to_string());
1186 let palace = Palace {
1187 id: PalaceId::new(palace_name),
1188 name: palace_name.to_string(),
1189 description,
1190 created_at: chrono::Utc::now(),
1191 data_dir: state.data_root.join(palace_name),
1192 };
1193 let _handle = state
1194 .registry
1195 .create_palace(&state.data_root, palace)
1196 .context("create_palace")?;
1197 state
1201 .palace_names
1202 .insert(palace_name.to_string(), palace_name.to_string());
1203 state.emit(DaemonEvent::PalaceCreated {
1206 id: palace_name.to_string(),
1207 name: palace_name.to_string(),
1208 source: ActivitySource::Mcp,
1209 });
1210 let bootstrap_summary = match crate::bootstrap::bootstrap_palace(state, palace_name, None).await
1218 {
1219 Ok(r) => Some(serde_json::json!({
1220 "triples_asserted": r.triples_asserted,
1221 "project_subject": r.project_subject,
1222 })),
1223 Err(e) => {
1224 tracing::warn!(
1225 palace = %palace_name,
1226 "auto-bootstrap on palace_create failed: {e:#}",
1227 );
1228 None
1229 }
1230 };
1231 Ok(json!({
1232 "palace_id": palace_name,
1233 "status": "created",
1234 "bootstrap": bootstrap_summary,
1235 }))
1236}
1237
1238async fn handle_palace_list(state: &AppState, _args: Value) -> Result<Value> {
1239 let root = state.data_root.clone();
1240 let palaces = tokio::task::spawn_blocking(move || {
1241 trusty_common::memory_core::PalaceRegistry::list_palaces(&root)
1242 })
1243 .await
1244 .context("join list_palaces")??;
1245 let ids: Vec<String> = palaces.iter().map(|p| p.id.as_str().to_string()).collect();
1246 Ok(json!({ "palaces": ids }))
1247}
1248
1249async fn handle_palace_delete(state: &AppState, args: Value) -> Result<Value> {
1250 let palace_id = args
1258 .get("palace_id")
1259 .and_then(|v| v.as_str())
1260 .ok_or_else(|| anyhow!("palace_delete: missing 'palace_id'"))?
1261 .to_string();
1262 let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
1263 use crate::service::{MemoryService, ServiceError};
1264 let svc = MemoryService::new(state.clone());
1265 match svc.delete_palace(&palace_id, force).await {
1266 Ok(()) => Ok(json!({ "deleted": palace_id })),
1267 Err(ServiceError::NotFound(_)) => Err(anyhow!("Palace not found: {palace_id}")),
1268 Err(ServiceError::Conflict(msg)) => Err(anyhow!(msg)),
1269 Err(e) => Err(anyhow!("palace_delete: {e}")),
1270 }
1271}
1272
1273async fn handle_palace_update(state: &AppState, args: Value) -> Result<Value> {
1274 let palace_id = args
1283 .get("palace_id")
1284 .and_then(|v| v.as_str())
1285 .ok_or_else(|| anyhow!("palace_update: missing 'palace_id'"))?
1286 .to_string();
1287 let name = args
1288 .get("name")
1289 .and_then(|v| v.as_str())
1290 .ok_or_else(|| anyhow!("palace_update: missing 'name'"))?
1291 .to_string();
1292 use crate::service::MemoryService;
1293 let svc = MemoryService::new(state.clone());
1294 match svc.update_palace_name(&palace_id, &name).await {
1295 Ok(_info) => Ok(json!({ "updated": palace_id, "name": name.trim() })),
1296 Err(e) => Err(anyhow!("palace_update: {e}")),
1297 }
1298}
1299
1300async fn handle_kg_assert(state: &AppState, args: Value) -> Result<Value> {
1301 let palace = resolve_palace(state, &args, "kg_assert")?;
1302 let palace = palace.as_str();
1303 let subject = args
1304 .get("subject")
1305 .and_then(|v| v.as_str())
1306 .ok_or_else(|| anyhow!("kg_assert: missing 'subject'"))?
1307 .to_string();
1308 let predicate = args
1309 .get("predicate")
1310 .and_then(|v| v.as_str())
1311 .ok_or_else(|| anyhow!("kg_assert: missing 'predicate'"))?
1312 .to_string();
1313 let object = args
1314 .get("object")
1315 .and_then(|v| v.as_str())
1316 .ok_or_else(|| anyhow!("kg_assert: missing 'object'"))?
1317 .to_string();
1318 let confidence = args
1319 .get("confidence")
1320 .and_then(|v| v.as_f64())
1321 .map(|c| (c as f32).clamp(0.0, 1.0))
1322 .unwrap_or(1.0);
1323 let provenance = args
1324 .get("provenance")
1325 .and_then(|v| v.as_str())
1326 .map(|s| s.to_string());
1327
1328 let handle = open_palace_handle(state, palace)?;
1329 let triple = Triple {
1330 subject,
1331 predicate,
1332 object,
1333 valid_from: chrono::Utc::now(),
1334 valid_to: None,
1335 confidence,
1336 provenance,
1337 };
1338 let is_hot = crate::prompt_facts::is_hot_predicate(&triple.predicate);
1339 handle.kg.assert(triple).await.context("kg.assert")?;
1340 if is_hot {
1345 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1346 tracing::warn!("rebuild_prompt_cache after kg_assert failed: {e:#}");
1347 }
1348 }
1349 Ok(json!({ "status": "asserted" }))
1350}
1351
1352async fn handle_add_alias(state: &AppState, args: Value) -> Result<Value> {
1353 let short = args
1354 .get("short")
1355 .and_then(|v| v.as_str())
1356 .ok_or_else(|| anyhow!("add_alias: missing 'short'"))?
1357 .to_string();
1358 let full = args
1359 .get("full")
1360 .and_then(|v| v.as_str())
1361 .ok_or_else(|| anyhow!("add_alias: missing 'full'"))?
1362 .to_string();
1363 let extra = args
1364 .get("extra")
1365 .and_then(|v| v.as_str())
1366 .map(|s| s.to_string());
1367
1368 let palace = resolve_palace(state, &args, "add_alias")?;
1373 let handle = open_palace_handle(state, &palace)?;
1374 let object = match extra.as_deref() {
1376 Some(e) if !e.is_empty() => format!("{full} ({e})"),
1377 _ => full.clone(),
1378 };
1379 let triple = Triple {
1380 subject: short.clone(),
1381 predicate: "is_alias_for".to_string(),
1382 object,
1383 valid_from: chrono::Utc::now(),
1384 valid_to: None,
1385 confidence: 1.0,
1386 provenance: Some("add_alias".to_string()),
1387 };
1388 handle
1389 .kg
1390 .assert(triple)
1391 .await
1392 .context("kg.assert (alias)")?;
1393 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1394 tracing::warn!("rebuild_prompt_cache after add_alias failed: {e:#}");
1395 }
1396 Ok(json!({ "asserted": true, "short": short, "full": full }))
1397}
1398
1399async fn handle_list_prompt_facts(state: &AppState, _args: Value) -> Result<Value> {
1400 let triples = crate::prompt_facts::gather_hot_triples(state).await?;
1401 let payload: Vec<Value> = triples
1402 .into_iter()
1403 .map(|(subject, predicate, object)| {
1404 json!({ "subject": subject, "predicate": predicate, "object": object })
1405 })
1406 .collect();
1407 Ok(json!({ "facts": payload }))
1408}
1409
1410async fn handle_remove_prompt_fact(state: &AppState, args: Value) -> Result<Value> {
1411 let subject = args
1412 .get("subject")
1413 .and_then(|v| v.as_str())
1414 .ok_or_else(|| anyhow!("remove_prompt_fact: missing 'subject'"))?
1415 .to_string();
1416 let predicate = args
1417 .get("predicate")
1418 .and_then(|v| v.as_str())
1419 .ok_or_else(|| anyhow!("remove_prompt_fact: missing 'predicate'"))?
1420 .to_string();
1421
1422 let mut closed_total: usize = 0;
1428 for palace_id in state.registry.list() {
1429 if let Some(handle) = state.registry.get(&palace_id) {
1430 match handle.kg.retract(&subject, &predicate).await {
1431 Ok(n) => closed_total += n,
1432 Err(e) => tracing::warn!(
1433 palace = %palace_id.as_str(),
1434 "retract failed: {e:#}",
1435 ),
1436 }
1437 }
1438 }
1439 if closed_total > 0 {
1440 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1441 tracing::warn!("rebuild_prompt_cache after remove_prompt_fact failed: {e:#}");
1442 }
1443 Ok(json!({ "removed": true, "closed": closed_total }))
1444 } else {
1445 Ok(json!({ "removed": false, "reason": "not found" }))
1446 }
1447}
1448
1449async fn handle_kg_query(state: &AppState, args: Value) -> Result<Value> {
1450 let palace = resolve_palace(state, &args, "kg_query")?;
1451 let subject = args
1452 .get("subject")
1453 .and_then(|v| v.as_str())
1454 .ok_or_else(|| anyhow!("kg_query: missing 'subject'"))?;
1455 let handle = open_palace_handle(state, &palace)?;
1456 let triples = handle
1457 .kg
1458 .query_active(subject)
1459 .await
1460 .context("kg.query_active")?;
1461 let payload: Vec<Value> = triples
1462 .iter()
1463 .map(|t| {
1464 json!({
1465 "subject": t.subject,
1466 "predicate": t.predicate,
1467 "object": t.object,
1468 "valid_from": t.valid_from.to_rfc3339(),
1469 "valid_to": t.valid_to.as_ref().map(|d| d.to_rfc3339()),
1470 "confidence": t.confidence,
1471 "provenance": t.provenance,
1472 })
1473 })
1474 .collect();
1475 let mut response = json!({ "subject": subject, "triples": payload });
1481 if crate::bootstrap::is_kg_empty_for_subject(&triples) {
1482 response["hint"] = Value::String(crate::bootstrap::KG_EMPTY_HINT.to_string());
1483 }
1484 Ok(response)
1485}
1486
1487async fn handle_memory_list(state: &AppState, args: Value) -> Result<Value> {
1488 let palace = resolve_palace(state, &args, "memory_list")?;
1489 let handle = open_palace_handle(state, &palace)?;
1490 let room = args
1491 .get("room")
1492 .and_then(|v| v.as_str())
1493 .map(|s| parse_room(Some(s)));
1494 let tag = args
1495 .get("tag")
1496 .and_then(|v| v.as_str())
1497 .map(|s| s.to_string());
1498 let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
1499 let drawers = handle.list_drawers(room, tag, limit);
1500 let payload: Vec<Value> = drawers
1501 .iter()
1502 .map(|d| {
1503 json!({
1504 "drawer_id": d.id.to_string(),
1505 "content": d.content,
1506 "importance": d.importance,
1507 "tags": d.tags,
1508 "created_at": d.created_at.to_rfc3339(),
1509 "drawer_type": d.drawer_type.as_str(),
1510 "expires_at": d.expires_at.map(|t| t.to_rfc3339()),
1511 })
1512 })
1513 .collect();
1514 Ok(json!({ "palace": palace, "drawers": payload }))
1515}
1516
1517async fn handle_memory_forget(state: &AppState, args: Value) -> Result<Value> {
1518 let palace = resolve_palace(state, &args, "memory_forget")?;
1519 let drawer_id_str = args
1520 .get("drawer_id")
1521 .and_then(|v| v.as_str())
1522 .ok_or_else(|| anyhow!("memory_forget: missing 'drawer_id'"))?;
1523 let drawer_id = Uuid::parse_str(drawer_id_str)
1524 .map_err(|e| anyhow!("memory_forget: invalid drawer_id UUID: {e}"))?;
1525 let handle = open_palace_handle(state, &palace)?;
1526 handle.forget(drawer_id).await.context("forget")?;
1527 let drawer_count = handle.drawers.read().len();
1529 state.emit(DaemonEvent::DrawerDeleted {
1530 palace_id: palace.clone(),
1531 drawer_count,
1532 source: ActivitySource::Mcp,
1533 });
1534 Ok(json!({ "status": "deleted", "drawer_id": drawer_id_str, "palace": palace }))
1537}
1538
1539async fn handle_palace_info(state: &AppState, args: Value) -> Result<Value> {
1540 let palace = resolve_palace(state, &args, "palace_info")?;
1541 let handle = open_palace_handle(state, &palace)?;
1542 let drawer_count = handle.list_drawers(None, None, usize::MAX).len();
1543 let data_dir = handle
1544 .data_dir
1545 .as_ref()
1546 .map(|p| p.to_string_lossy().to_string());
1547 Ok(json!({
1548 "id": handle.id.as_str(),
1549 "name": handle.id.as_str(),
1550 "drawer_count": drawer_count,
1551 "data_dir": data_dir,
1552 }))
1553}
1554
1555async fn handle_palace_compact(state: &AppState, args: Value) -> Result<Value> {
1556 let palace = resolve_palace(state, &args, "palace_compact")?;
1557 let handle = open_palace_handle(state, &palace)?;
1558 let valid_ids: std::collections::HashSet<Uuid> =
1562 handle.drawers.read().iter().map(|d| d.id).collect();
1563 let vector_store = handle.vector_store.clone();
1564 let res = tokio::task::spawn_blocking(move || vector_store.compact_orphans(&valid_ids))
1565 .await
1566 .context("join palace_compact")??;
1567 Ok(json!({
1568 "palace": palace,
1569 "total_checked": res.total_checked,
1570 "orphans_removed": res.orphans_removed,
1571 "index_size_before": res.index_size_before,
1572 "index_size_after": res.index_size_after,
1573 }))
1574}
1575
1576async fn handle_kg_gaps(state: &AppState, args: Value) -> Result<Value> {
1577 let palace = resolve_palace(state, &args, "kg_gaps")?;
1587 let _handle = open_palace_handle(state, &palace)?;
1590 let pid = PalaceId::new(&palace);
1591 let cached = state.registry.get_gaps(&pid).unwrap_or_default();
1592 let payload: Vec<Value> = cached
1593 .into_iter()
1594 .map(|g| {
1595 json!({
1596 "entities": g.entities,
1597 "internal_density": g.internal_density,
1598 "external_bridges": g.external_bridges,
1599 "suggested_exploration": g.suggested_exploration,
1600 })
1601 })
1602 .collect();
1603 Ok(json!({ "palace": palace, "gaps": payload }))
1604}
1605
1606async fn handle_memory_recall_all(state: &AppState, args: Value) -> Result<Value> {
1607 let query = args
1608 .get("q")
1609 .and_then(|v| v.as_str())
1610 .ok_or_else(|| anyhow!("memory_recall_all: missing 'q'"))?;
1611 let top_k = args.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
1612 let deep = args.get("deep").and_then(|v| v.as_bool()).unwrap_or(false);
1613
1614 let root = state.data_root.clone();
1618 let palaces = tokio::task::spawn_blocking(move || {
1619 trusty_common::memory_core::PalaceRegistry::list_palaces(&root)
1620 })
1621 .await
1622 .context("join list_palaces")??;
1623
1624 let mut handles = Vec::with_capacity(palaces.len());
1625 for p in &palaces {
1626 match state.registry.open_palace(&state.data_root, &p.id) {
1627 Ok(h) => handles.push(h),
1628 Err(e) => {
1629 tracing::warn!(palace = %p.id, "memory_recall_all: open failed: {e:#}")
1630 }
1631 }
1632 }
1633
1634 let embedder = state.embedder().await?;
1635 let erased: std::sync::Arc<dyn trusty_common::memory_core::embed::Embedder + Send + Sync> =
1636 embedder;
1637 let results = recall_across_palaces(&handles, &erased, query, top_k, deep)
1638 .await
1639 .context("recall_across_palaces")?;
1640
1641 let payload: Vec<Value> = results
1642 .iter()
1643 .map(|r| {
1644 json!({
1645 "palace_id": r.palace_id,
1646 "drawer_id": r.result.drawer.id.to_string(),
1647 "content": r.result.drawer.content,
1648 "importance": r.result.drawer.importance,
1649 "tags": r.result.drawer.tags,
1650 "score": r.result.score,
1651 "layer": r.result.layer,
1652 "drawer_type": r.result.drawer.drawer_type.as_str(),
1653 })
1654 })
1655 .collect();
1656 Ok(json!({ "query": query, "results": payload }))
1657}
1658
1659async fn handle_get_prompt_context(state: &AppState, args: Value) -> Result<Value> {
1660 let query = args
1671 .get("query")
1672 .and_then(|v| v.as_str())
1673 .map(|s| s.trim().to_string())
1674 .filter(|s| !s.is_empty());
1675
1676 let cache_snapshot = {
1680 let guard = state.prompt_context_cache.read().await;
1681 guard.clone()
1682 };
1683
1684 let body = if let Some(q) = query.as_deref() {
1685 let needle = q.to_lowercase();
1686 let filtered: Vec<(String, String, String)> = cache_snapshot
1687 .triples
1688 .into_iter()
1689 .filter(|(subject, _predicate, object)| {
1690 subject.to_lowercase().contains(&needle) || object.to_lowercase().contains(&needle)
1691 })
1692 .collect();
1693 let formatted = crate::prompt_facts::build_prompt_context(&filtered);
1694 if formatted.is_empty() {
1695 "No project context found matching your query.".to_string()
1696 } else {
1697 formatted
1698 }
1699 } else if cache_snapshot.formatted.is_empty() {
1700 "No prompt facts stored yet.".to_string()
1701 } else {
1702 cache_snapshot.formatted
1703 };
1704
1705 Ok(Value::String(body))
1711}
1712
1713async fn handle_discover_aliases(state: &AppState, args: Value) -> Result<Value> {
1714 let palace = resolve_palace(state, &args, "discover_aliases")?;
1725 let project_root = args
1726 .get("project_root")
1727 .and_then(|v| v.as_str())
1728 .map(std::path::PathBuf::from)
1729 .or_else(|| std::env::current_dir().ok())
1730 .ok_or_else(|| anyhow!("discover_aliases: no project_root and cwd unavailable"))?;
1731
1732 let discoveries = crate::discovery::discover_project_aliases(&project_root).await?;
1733
1734 let handle = open_palace_handle(state, &palace)?;
1735
1736 let mut already_known = 0usize;
1737 let mut newly_asserted = 0usize;
1738 let mut reported: Vec<Value> = Vec::with_capacity(discoveries.len());
1739
1740 for d in &discoveries {
1741 let active = handle
1744 .kg
1745 .query_active(&d.short)
1746 .await
1747 .context("kg.query_active")?;
1748 let exists = active
1749 .iter()
1750 .any(|t| t.predicate == "is_alias_for" && t.object == d.full);
1751 if exists {
1752 already_known += 1;
1753 continue;
1754 }
1755
1756 let triple = Triple {
1757 subject: d.short.clone(),
1758 predicate: "is_alias_for".to_string(),
1759 object: d.full.clone(),
1760 valid_from: chrono::Utc::now(),
1761 valid_to: None,
1762 confidence: 1.0,
1763 provenance: Some(format!("discover_aliases:{}", d.source.as_str())),
1764 };
1765 handle
1766 .kg
1767 .assert(triple)
1768 .await
1769 .context("kg.assert (discover)")?;
1770 newly_asserted += 1;
1771 reported.push(json!({
1772 "short": d.short,
1773 "full": d.full,
1774 "source": d.source.as_str(),
1775 }));
1776 }
1777
1778 if newly_asserted > 0 {
1779 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1780 tracing::warn!("rebuild_prompt_cache after discover_aliases failed: {e:#}");
1781 }
1782 }
1783
1784 Ok(json!({
1785 "discovered": reported,
1786 "already_known": already_known,
1787 "new": newly_asserted,
1788 "palace": palace,
1789 }))
1790}
1791
1792async fn handle_kg_bootstrap(state: &AppState, args: Value) -> Result<Value> {
1793 let palace = resolve_palace(state, &args, "kg_bootstrap")?;
1798 let project_path = args
1799 .get("project_path")
1800 .and_then(|v| v.as_str())
1801 .map(std::path::PathBuf::from);
1802 let result = crate::bootstrap::bootstrap_palace(state, &palace, project_path.as_deref())
1803 .await
1804 .context("bootstrap_palace")?;
1805 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1809 tracing::warn!("rebuild_prompt_cache after kg_bootstrap failed: {e:#}");
1810 }
1811 crate::bootstrap::result_to_json(&result)
1812}
1813
1814async fn handle_memory_send_message(state: &AppState, args: Value) -> Result<Value> {
1815 let to_palace = args
1817 .get("to_palace")
1818 .and_then(|v| v.as_str())
1819 .ok_or_else(|| anyhow!("memory_send_message: missing 'to_palace'"))?
1820 .to_string();
1821 let purpose = args
1822 .get("purpose")
1823 .and_then(|v| v.as_str())
1824 .ok_or_else(|| anyhow!("memory_send_message: missing 'purpose'"))?
1825 .to_string();
1826 let content = args
1827 .get("content")
1828 .and_then(|v| v.as_str())
1829 .ok_or_else(|| anyhow!("memory_send_message: missing 'content'"))?
1830 .to_string();
1831 let from_palace = if let Some(s) = args.get("from_palace").and_then(|v| v.as_str()) {
1834 s.to_string()
1835 } else if let Some(d) = state.default_palace.clone() {
1836 d
1837 } else {
1838 crate::messaging::cwd_palace_slug()
1839 .context("memory_send_message: derive from_palace from cwd")?
1840 };
1841 let drawer_id = crate::messaging::send_message_to_palace(
1842 &state.registry,
1843 &state.data_root,
1844 &from_palace,
1845 &to_palace,
1846 &purpose,
1847 content,
1848 CreatorInfo::new_self(MCP_CLIENT_NAME, CreatorSource::Mcp),
1849 )
1850 .await
1851 .context("memory_send_message")?;
1852 Ok(json!({
1853 "drawer_id": drawer_id.to_string(),
1854 "from_palace": from_palace,
1855 "to_palace": to_palace,
1856 "purpose": purpose,
1857 "status": "sent",
1858 }))
1859}
1860
1861pub async fn dispatch_tool(state: &AppState, name: &str, args: Value) -> Result<Value> {
1873 match name {
1874 "memory_remember" => handle_memory_remember(state, args).await,
1875 "memory_note" => handle_memory_note(state, args).await,
1876 "memory_recall" => handle_memory_recall(state, args).await,
1877 "memory_recall_deep" => handle_memory_recall_deep(state, args).await,
1878 "palace_create" => handle_palace_create(state, args).await,
1879 "palace_list" => handle_palace_list(state, args).await,
1880 "palace_delete" => handle_palace_delete(state, args).await,
1881 "palace_update" => handle_palace_update(state, args).await,
1882 "kg_assert" => handle_kg_assert(state, args).await,
1883 "add_alias" => handle_add_alias(state, args).await,
1884 "list_prompt_facts" => handle_list_prompt_facts(state, args).await,
1885 "remove_prompt_fact" => handle_remove_prompt_fact(state, args).await,
1886 "kg_query" => handle_kg_query(state, args).await,
1887 "memory_list" => handle_memory_list(state, args).await,
1888 "memory_forget" => handle_memory_forget(state, args).await,
1889 "palace_info" => handle_palace_info(state, args).await,
1890 "palace_compact" => handle_palace_compact(state, args).await,
1891 "kg_gaps" => handle_kg_gaps(state, args).await,
1892 "memory_recall_all" => handle_memory_recall_all(state, args).await,
1893 "get_prompt_context" => handle_get_prompt_context(state, args).await,
1894 "discover_aliases" => handle_discover_aliases(state, args).await,
1895 "kg_bootstrap" => handle_kg_bootstrap(state, args).await,
1896 "memory_send_message" => handle_memory_send_message(state, args).await,
1897 "upgrade" => handle_upgrade_tool(state, args).await,
1898 other => anyhow::bail!("unknown tool: {other}"),
1899 }
1900}
1901
1902async fn handle_upgrade_tool(state: &AppState, args: Value) -> Result<Value> {
1922 let check = args.get("check").and_then(Value::as_bool).unwrap_or(true);
1923 let confirm = args
1924 .get("confirm")
1925 .and_then(Value::as_bool)
1926 .unwrap_or(false);
1927
1928 let crate_name = env!("CARGO_PKG_NAME");
1929 let current = env!("CARGO_PKG_VERSION");
1930
1931 let info = trusty_common::update::check_crates_io(crate_name, current).await;
1933
1934 let (latest, is_update) = match &info {
1935 Some(u) => (u.latest.as_str(), true),
1936 None => (current, false),
1937 };
1938
1939 if check || !confirm {
1940 let msg = if is_update {
1941 format!(
1942 "Update available: {crate_name} {latest} (you have {current}). \
1943 Call with confirm=true to install."
1944 )
1945 } else {
1946 format!("{crate_name} {current} is already up to date.")
1947 };
1948 return Ok(
1949 serde_json::json!({ "status": "checked", "current": current, "latest": latest, "update_available": is_update, "message": msg }),
1950 );
1951 }
1952
1953 if !is_update {
1959 return Ok(serde_json::json!({
1960 "status": "up_to_date",
1961 "current": current,
1962 "message": format!("{crate_name} {current} is already up to date — nothing to install.")
1963 }));
1964 }
1965
1966 let upgrade_state = state.update_available.clone();
1967 let latest_owned = latest.to_string();
1968 let crate_name_owned = crate_name.to_string();
1969 let response = serde_json::json!({
1970 "status": "installing",
1971 "current": current,
1972 "latest": latest_owned,
1973 "message": format!(
1974 "Installing {crate_name} {latest_owned} — daemon will restart automatically \
1975 under launchd, or you will be prompted to restart manually."
1976 )
1977 });
1978
1979 tokio::spawn(async move {
1982 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
1984 match trusty_common::update::upgrade_and_restart(&crate_name_owned, &crate_name_owned).await
1985 {
1986 Ok(Some(hint)) => {
1987 tracing::info!("{hint}");
1988 eprintln!("{hint}");
1989 }
1990 Ok(None) => {}
1991 Err(e) => {
1992 tracing::error!("upgrade_and_restart failed: {e:#}");
1993 eprintln!("[trusty-memory] upgrade failed: {e:#}");
1994 if let Ok(mut g) = upgrade_state.lock() {
1997 *g = None;
1998 }
1999 }
2000 }
2001 });
2002
2003 Ok(response)
2004}
2005
2006fn bm25_data_dir_for_palace(state: &AppState, palace: &str) -> std::path::PathBuf {
2019 state.data_root.join(palace).join("bm25")
2020}
2021
2022async fn ensure_bm25_running_for_palace(state: &AppState, palace: &str) -> bool {
2039 let Some(supervisor) = state.bm25_supervisor.as_ref() else {
2040 return true;
2043 };
2044 let data_dir = bm25_data_dir_for_palace(state, palace);
2045 match supervisor.ensure_running(palace, &data_dir).await {
2046 Ok(_socket) => true,
2047 Err(e) => {
2048 tracing::warn!(
2049 palace = %palace,
2050 "bm25 supervisor could not start daemon (degrading to vector-only): {e:#}"
2051 );
2052 false
2053 }
2054 }
2055}
2056
2057pub const BM25_INDEX_QUEUE_CAPACITY: usize = 256;
2073
2074#[derive(Debug)]
2087pub struct Bm25IndexRequest {
2088 pub palace: String,
2090 pub drawer_id: String,
2092 pub content: String,
2094 pub data_dir: std::path::PathBuf,
2098}
2099
2100pub fn spawn_bm25_index_worker(
2121 mut rx: tokio::sync::mpsc::Receiver<Bm25IndexRequest>,
2122 client: Option<std::sync::Arc<trusty_common::bm25_client::Bm25Client>>,
2123 supervisor: Option<std::sync::Arc<crate::bm25_supervisor::Bm25Supervisor>>,
2124) {
2125 tokio::spawn(async move {
2126 while let Some(req) = rx.recv().await {
2127 let Some(client) = client.as_ref() else {
2130 continue;
2131 };
2132 if let Some(sup) = supervisor.as_ref() {
2136 if let Err(e) = sup.ensure_running(&req.palace, &req.data_dir).await {
2137 tracing::warn!(
2138 palace = %req.palace,
2139 "bm25 supervisor failed to start daemon for index (non-fatal): {e:#}"
2140 );
2141 continue;
2142 }
2143 }
2144 if let Err(e) = client.index(&req.drawer_id, &req.content).await {
2145 tracing::warn!(
2146 palace = %req.palace,
2147 drawer_id = %req.drawer_id,
2148 "bm25 daemon index failed (non-fatal): {e:#}"
2149 );
2150 }
2151 }
2152 tracing::debug!("bm25 index worker exiting (channel closed)");
2153 });
2154}
2155
2156fn bm25_index_enqueue(state: &AppState, palace: &str, drawer_id: Uuid, content: &str) {
2173 let req = Bm25IndexRequest {
2174 palace: palace.to_string(),
2175 drawer_id: drawer_id.to_string(),
2176 content: content.to_string(),
2177 data_dir: bm25_data_dir_for_palace(state, palace),
2178 };
2179 match state.bm25_index_tx.try_send(req) {
2180 Ok(()) => {}
2181 Err(tokio::sync::mpsc::error::TrySendError::Full(req)) => {
2182 tracing::warn!(
2183 palace = %req.palace,
2184 drawer_id = %req.drawer_id,
2185 "BM25 index queue full — skipping drawer {}",
2186 req.drawer_id
2187 );
2188 }
2189 Err(tokio::sync::mpsc::error::TrySendError::Closed(req)) => {
2190 tracing::debug!(
2191 palace = %req.palace,
2192 drawer_id = %req.drawer_id,
2193 "BM25 index queue closed — skipping drawer {}",
2194 req.drawer_id
2195 );
2196 }
2197 }
2198}
2199
2200async fn bm25_search_optional(
2214 state: &AppState,
2215 palace: &str,
2216 query: &str,
2217 top_k: usize,
2218) -> Option<Vec<trusty_common::bm25_client::BM25Hit>> {
2219 let client = state.bm25_client.as_ref()?;
2220 if !ensure_bm25_running_for_palace(state, palace).await {
2224 return None;
2225 }
2226 match client.search(query, top_k).await {
2227 Ok(hits) => Some(hits),
2228 Err(e) => {
2229 tracing::warn!(
2230 palace = %palace,
2231 "bm25 daemon search failed (falling back to vector-only): {e:#}"
2232 );
2233 None
2234 }
2235 }
2236}
2237
2238fn fuse_bm25_into_recall(
2253 results: &mut Vec<trusty_common::memory_core::retrieval::RecallResult>,
2254 bm25_hits: &[trusty_common::bm25_client::BM25Hit],
2255 top_k: usize,
2256) {
2257 const RRF_K: f32 = 60.0;
2260 if bm25_hits.is_empty() {
2261 return;
2262 }
2263 for (rank, hit) in bm25_hits.iter().enumerate() {
2265 let bonus = 1.0 / (RRF_K + rank as f32 + 1.0);
2266 if let Some(existing) = results
2267 .iter_mut()
2268 .find(|r| r.drawer.id.to_string() == hit.doc_id)
2269 {
2270 existing.score += bonus;
2271 }
2272 }
2280 results.sort_by(|a, b| {
2283 b.score
2284 .partial_cmp(&a.score)
2285 .unwrap_or(std::cmp::Ordering::Equal)
2286 .then(a.layer.cmp(&b.layer))
2287 });
2288 results.truncate(top_k);
2289}
2290
2291fn serialize_recall(
2293 palace: &str,
2294 query: &str,
2295 results: Vec<trusty_common::memory_core::retrieval::RecallResult>,
2296) -> Value {
2297 let payload: Vec<Value> = results
2298 .iter()
2299 .map(|r| {
2300 json!({
2301 "drawer_id": r.drawer.id.to_string(),
2302 "content": r.drawer.content,
2303 "score": r.score,
2304 "layer": r.layer,
2305 "tags": r.drawer.tags,
2306 "importance": r.drawer.importance,
2307 "drawer_type": r.drawer.drawer_type.as_str(),
2308 })
2309 })
2310 .collect();
2311 json!({
2312 "palace": palace,
2313 "query": query,
2314 "results": payload,
2315 })
2316}
2317
2318#[cfg(test)]
2319mod tests {
2320 use super::*;
2321 use crate::AppState;
2322
2323 fn test_state() -> (AppState, tempfile::TempDir) {
2338 unsafe {
2345 std::env::set_var("TRUSTY_SKIP_PALACE_ENFORCEMENT", "1");
2346 }
2347 let tmp = tempfile::tempdir().expect("tempdir");
2348 let root = tmp.path().to_path_buf();
2349 (AppState::new(root), tmp)
2350 }
2351
2352 #[test]
2357 fn tool_definitions_drops_palace_required_when_default_set() {
2358 let with_default = tool_definitions_with(true);
2359 let without_default = tool_definitions_with(false);
2360 for (name, palace_required_when_no_default) in [
2361 ("memory_remember", true),
2362 ("memory_recall", true),
2363 ("memory_recall_deep", true),
2364 ("memory_list", true),
2365 ("memory_forget", true),
2366 ("palace_info", true),
2367 ("palace_compact", true),
2368 ("kg_assert", true),
2369 ("kg_query", true),
2370 ("add_alias", true),
2373 ("discover_aliases", true),
2374 ] {
2375 for (defs, has_default) in [(&with_default, true), (&without_default, false)] {
2376 let tools = defs["tools"].as_array().unwrap();
2377 let tool = tools.iter().find(|t| t["name"] == name).unwrap();
2378 let required: Vec<&str> = tool["inputSchema"]["required"]
2379 .as_array()
2380 .unwrap()
2381 .iter()
2382 .filter_map(|v| v.as_str())
2383 .collect();
2384 let palace_required = required.contains(&"palace");
2385 let expected = palace_required_when_no_default && !has_default;
2386 assert_eq!(
2387 palace_required, expected,
2388 "tool={name} has_default={has_default} required={required:?}"
2389 );
2390 }
2391 }
2392 }
2393
2394 #[test]
2395 fn tool_definitions_lists_all_tools() {
2396 let defs = tool_definitions();
2397 let tools = defs
2398 .get("tools")
2399 .and_then(|t| t.as_array())
2400 .expect("tools array");
2401 assert_eq!(tools.len(), 24);
2402 let names: Vec<&str> = tools
2403 .iter()
2404 .filter_map(|t| t.get("name").and_then(|n| n.as_str()))
2405 .collect();
2406 for expected in [
2407 "memory_remember",
2408 "memory_note",
2409 "memory_recall",
2410 "memory_recall_deep",
2411 "memory_list",
2412 "memory_forget",
2413 "palace_create",
2414 "palace_delete",
2415 "palace_update",
2416 "palace_list",
2417 "palace_info",
2418 "palace_compact",
2419 "kg_assert",
2420 "kg_query",
2421 "memory_recall_all",
2422 "kg_gaps",
2423 "add_alias",
2424 "list_prompt_facts",
2425 "remove_prompt_fact",
2426 "get_prompt_context",
2427 "discover_aliases",
2428 "kg_bootstrap",
2429 "memory_send_message",
2430 "upgrade",
2431 ] {
2432 assert!(names.contains(&expected), "missing tool: {expected}");
2433 }
2434 }
2435
2436 #[tokio::test]
2439 async fn dispatch_palace_create_persists() {
2440 let (state, _tmp) = test_state();
2441 let created = dispatch_tool(&state, "palace_create", json!({"name": "alpha"}))
2442 .await
2443 .expect("palace_create");
2444 assert_eq!(created["palace_id"], "alpha");
2445
2446 let listed = dispatch_tool(&state, "palace_list", json!({}))
2447 .await
2448 .expect("palace_list");
2449 let ids = listed["palaces"].as_array().expect("palaces array");
2450 assert!(ids.iter().any(|v| v.as_str() == Some("alpha")));
2451 }
2452
2453 #[tokio::test]
2456 async fn dispatch_remember_then_recall() {
2457 let (state, _tmp) = test_state();
2458 let _ = dispatch_tool(&state, "palace_create", json!({"name": "beta"}))
2459 .await
2460 .expect("palace_create");
2461
2462 let remembered = dispatch_tool(
2463 &state,
2464 "memory_remember",
2465 json!({
2466 "palace": "beta",
2467 "text": "Quokkas are the happiest marsupials in Australia by general consensus",
2468 "room": "General",
2469 "tags": ["wildlife"],
2470 }),
2471 )
2472 .await
2473 .expect("memory_remember");
2474 assert!(remembered["drawer_id"].as_str().is_some());
2475
2476 let recalled = dispatch_tool(
2477 &state,
2478 "memory_recall",
2479 json!({"palace": "beta", "query": "Quokkas marsupials Australia", "top_k": 5}),
2480 )
2481 .await
2482 .expect("memory_recall");
2483 let results = recalled["results"].as_array().expect("results");
2484 assert!(
2485 results
2486 .iter()
2487 .any(|r| r["content"].as_str().unwrap_or("").contains("Quokkas")),
2488 "expected to recall the Quokkas drawer; got {results:?}"
2489 );
2490 }
2491
2492 #[tokio::test]
2501 async fn auto_kg_extraction_hooks_into_memory_remember() {
2502 let (state, _tmp) = test_state();
2503 let _ = dispatch_tool(&state, "palace_create", json!({"name": "kgauto"}))
2504 .await
2505 .expect("palace_create");
2506
2507 let _ = dispatch_tool(
2508 &state,
2509 "memory_remember",
2510 json!({
2511 "palace": "kgauto",
2512 "text": "Rustc is a compiler for the Rust language; tracks #performance",
2513 "room": "Backend",
2514 "tags": ["compiler", "language"],
2515 }),
2516 )
2517 .await
2518 .expect("memory_remember");
2519
2520 let handle = open_palace_handle(&state, "kgauto").expect("open palace");
2521 let triples = handle.kg.list_active(1000, 0).await.expect("list_active");
2522 let auto: Vec<_> = triples
2523 .iter()
2524 .filter(|t| t.provenance.as_deref() == Some(crate::kg_extract::AUTO_PROVENANCE))
2525 .collect();
2526 assert!(
2527 !auto.is_empty(),
2528 "expected at least one auto-extracted triple after memory_remember; got: {triples:?}"
2529 );
2530 assert!(
2534 auto.iter()
2535 .any(|t| t.subject == "tag:compiler" && t.predicate == "tags"),
2536 "expected tag:compiler edge in auto subset: {auto:?}"
2537 );
2538 assert!(
2539 auto.iter()
2540 .any(|t| t.subject == "tag:language" && t.predicate == "tags"),
2541 "expected tag:language edge in auto subset: {auto:?}"
2542 );
2543 assert!(
2544 auto.iter()
2545 .any(|t| t.subject == "room:Backend" && t.predicate == "contains"),
2546 "expected room:Backend edge in auto subset: {auto:?}"
2547 );
2548 assert!(
2549 auto.iter().any(|t| t.predicate == "mentioned-in"),
2550 "expected at least one #hashtag mention triple in auto subset: {auto:?}"
2551 );
2552 }
2553
2554 #[tokio::test]
2566 async fn auto_kg_extraction_no_op_does_not_fail_remember() {
2567 let (state, _tmp) = test_state();
2568 let _ = dispatch_tool(&state, "palace_create", json!({"name": "kgnoop"}))
2569 .await
2570 .expect("palace_create");
2571
2572 let res = dispatch_tool(
2573 &state,
2574 "memory_remember",
2575 json!({
2576 "palace": "kgnoop",
2577 "text": "The quick brown fox jumped over the lazy dog repeatedly",
2580 }),
2581 )
2582 .await
2583 .expect("memory_remember should succeed even when extraction yields nothing");
2584 assert!(res["drawer_id"].as_str().is_some());
2585 }
2586
2587 #[tokio::test]
2590 async fn dispatch_kg_assert_then_query() {
2591 let (state, _tmp) = test_state();
2592 let _ = dispatch_tool(&state, "palace_create", json!({"name": "gamma"}))
2593 .await
2594 .expect("palace_create");
2595
2596 let _ = dispatch_tool(
2597 &state,
2598 "kg_assert",
2599 json!({
2600 "palace": "gamma",
2601 "subject": "alice",
2602 "predicate": "works_at",
2603 "object": "Acme",
2604 "confidence": 0.9,
2605 "provenance": "test",
2606 }),
2607 )
2608 .await
2609 .expect("kg_assert");
2610
2611 let queried = dispatch_tool(
2612 &state,
2613 "kg_query",
2614 json!({"palace": "gamma", "subject": "alice"}),
2615 )
2616 .await
2617 .expect("kg_query");
2618 let triples = queried["triples"].as_array().expect("triples array");
2619 assert_eq!(triples.len(), 1);
2620 assert_eq!(triples[0]["object"], "Acme");
2621 assert_eq!(triples[0]["predicate"], "works_at");
2622 }
2623
2624 #[tokio::test]
2632 async fn dispatch_kg_gaps_returns_cached() {
2633 use trusty_common::memory_core::community::KnowledgeGap;
2634
2635 let (state, _tmp) = test_state();
2636 let _ = dispatch_tool(&state, "palace_create", json!({"name": "delta"}))
2637 .await
2638 .expect("palace_create");
2639
2640 let initial = dispatch_tool(&state, "kg_gaps", json!({"palace": "delta"}))
2642 .await
2643 .expect("kg_gaps empty");
2644 let gaps = initial["gaps"].as_array().expect("gaps array");
2645 assert_eq!(gaps.len(), 0);
2646
2647 state.registry.set_gaps(
2649 PalaceId::new("delta"),
2650 vec![KnowledgeGap {
2651 entities: vec!["x".to_string(), "y".to_string()],
2652 internal_density: 0.05,
2653 external_bridges: 0,
2654 suggested_exploration: "Explore connections between x and y".to_string(),
2655 }],
2656 );
2657 let seeded = dispatch_tool(&state, "kg_gaps", json!({"palace": "delta"}))
2658 .await
2659 .expect("kg_gaps seeded");
2660 let gaps = seeded["gaps"].as_array().expect("gaps array");
2661 assert_eq!(gaps.len(), 1);
2662 assert_eq!(gaps[0]["entities"][0], "x");
2663 assert_eq!(gaps[0]["external_bridges"], 0);
2664 assert!(gaps[0]["suggested_exploration"]
2665 .as_str()
2666 .unwrap()
2667 .contains("x"));
2668 }
2669
2670 #[tokio::test]
2675 async fn add_alias_round_trip_through_prompt_cache() {
2676 let _tmp = tempfile::tempdir().expect("tempdir");
2679 let root = _tmp.path().to_path_buf();
2680 let state = AppState::new(root).with_default_palace(Some("ctx".to_string()));
2681
2682 let _ = dispatch_tool(&state, "palace_create", json!({"name": "ctx"}))
2684 .await
2685 .expect("palace_create");
2686
2687 let added = dispatch_tool(
2689 &state,
2690 "add_alias",
2691 json!({"short": "tga", "full": "trusty-git-analytics"}),
2692 )
2693 .await
2694 .expect("add_alias");
2695 assert_eq!(added["asserted"], true);
2696 assert_eq!(added["short"], "tga");
2697
2698 let listed = dispatch_tool(&state, "list_prompt_facts", json!({}))
2700 .await
2701 .expect("list_prompt_facts");
2702 let facts = listed["facts"].as_array().expect("facts array");
2703 assert!(
2704 facts.iter().any(|f| f["subject"] == "tga"
2705 && f["predicate"] == "is_alias_for"
2706 && f["object"] == "trusty-git-analytics"),
2707 "expected tga alias in facts; got {facts:?}"
2708 );
2709
2710 {
2712 let guard = state.prompt_context_cache.read().await;
2713 assert!(
2714 guard.formatted.contains("tga → trusty-git-analytics"),
2715 "prompt cache should contain alias; got: {}",
2716 guard.formatted
2717 );
2718 }
2719
2720 let _ = dispatch_tool(
2722 &state,
2723 "add_alias",
2724 json!({"short": "tm", "full": "trusty-memory", "extra": "the MCP frontend"}),
2725 )
2726 .await
2727 .expect("add_alias with extra");
2728 {
2729 let guard = state.prompt_context_cache.read().await;
2730 assert!(
2731 guard
2732 .formatted
2733 .contains("tm → trusty-memory (the MCP frontend)"),
2734 "alias with extra not formatted; got: {}",
2735 guard.formatted
2736 );
2737 }
2738
2739 let removed = dispatch_tool(
2741 &state,
2742 "remove_prompt_fact",
2743 json!({"subject": "tga", "predicate": "is_alias_for"}),
2744 )
2745 .await
2746 .expect("remove_prompt_fact");
2747 assert_eq!(removed["removed"], true);
2748 {
2749 let guard = state.prompt_context_cache.read().await;
2750 assert!(
2751 !guard.formatted.contains("tga → trusty-git-analytics"),
2752 "retracted alias still in cache: {}",
2753 guard.formatted
2754 );
2755 assert!(
2756 guard.formatted.contains("tm → trusty-memory"),
2757 "non-retracted alias missing from cache: {}",
2758 guard.formatted
2759 );
2760 }
2761
2762 let missing = dispatch_tool(
2764 &state,
2765 "remove_prompt_fact",
2766 json!({"subject": "nope", "predicate": "is_alias_for"}),
2767 )
2768 .await
2769 .expect("remove_prompt_fact missing");
2770 assert_eq!(missing["removed"], false);
2771 }
2772
2773 #[tokio::test]
2781 async fn add_alias_palace_arg_required_without_server_default() {
2782 let (state, _tmp) = test_state();
2785 dispatch_tool(&state, "palace_create", json!({"name": "p"}))
2786 .await
2787 .expect("palace_create");
2788 let added = dispatch_tool(
2789 &state,
2790 "add_alias",
2791 json!({"palace": "p", "short": "tga", "full": "trusty-git-analytics"}),
2792 )
2793 .await
2794 .expect("add_alias with explicit palace");
2795 assert_eq!(added["asserted"], true);
2796 let guard = state.prompt_context_cache.read().await;
2797 assert!(guard.formatted.contains("tga → trusty-git-analytics"));
2798
2799 drop(guard);
2801 let (state2, _tmp2) = test_state();
2802 let err = dispatch_tool(&state2, "add_alias", json!({"short": "x", "full": "y"}))
2803 .await
2804 .expect_err("should fail without palace");
2805 let msg = format!("{err:#}");
2806 assert!(msg.contains("palace"), "error must mention 'palace': {msg}");
2807 assert!(msg.contains("add_alias"), "error must name tool: {msg}");
2808 }
2809
2810 #[tokio::test]
2815 async fn get_prompt_context_serves_cache_and_filters() {
2816 let (state, _tmp) = test_state();
2817
2818 let resp = dispatch_tool(&state, "get_prompt_context", json!({}))
2820 .await
2821 .expect("get_prompt_context empty");
2822 assert_eq!(resp.as_str().unwrap(), "No prompt facts stored yet.");
2823
2824 {
2826 let mut guard = state.prompt_context_cache.write().await;
2827 let triples = vec![
2828 (
2829 "tga".to_string(),
2830 "is_alias_for".to_string(),
2831 "trusty-git-analytics".to_string(),
2832 ),
2833 (
2834 "tm".to_string(),
2835 "is_alias_for".to_string(),
2836 "trusty-memory".to_string(),
2837 ),
2838 (
2839 "fact-1".to_string(),
2840 "is_fact".to_string(),
2841 "MSRV is 1.88".to_string(),
2842 ),
2843 ];
2844 let formatted = crate::prompt_facts::build_prompt_context(&triples);
2845 *guard = crate::prompt_facts::PromptFactsCache { triples, formatted };
2846 }
2847
2848 let resp = dispatch_tool(&state, "get_prompt_context", json!({}))
2850 .await
2851 .expect("get_prompt_context populated");
2852 let text = resp.as_str().expect("string body");
2853 assert!(text.contains("tga → trusty-git-analytics"));
2854 assert!(text.contains("tm → trusty-memory"));
2855 assert!(text.contains("MSRV is 1.88"));
2856
2857 let resp = dispatch_tool(&state, "get_prompt_context", json!({"query": "tga"}))
2859 .await
2860 .expect("get_prompt_context filtered");
2861 let text = resp.as_str().expect("string body");
2862 assert!(text.contains("tga → trusty-git-analytics"));
2863 assert!(!text.contains("tm → trusty-memory"));
2864 assert!(!text.contains("MSRV is 1.88"));
2865
2866 let resp = dispatch_tool(&state, "get_prompt_context", json!({"query": "MEMORY"}))
2868 .await
2869 .expect("get_prompt_context case-insensitive");
2870 let text = resp.as_str().expect("string body");
2871 assert!(text.contains("tm → trusty-memory"));
2872 assert!(!text.contains("tga → trusty-git-analytics"));
2873
2874 let resp = dispatch_tool(
2876 &state,
2877 "get_prompt_context",
2878 json!({"query": "zzz-nonexistent"}),
2879 )
2880 .await
2881 .expect("get_prompt_context no-match");
2882 assert_eq!(
2883 resp.as_str().unwrap(),
2884 "No project context found matching your query."
2885 );
2886
2887 let resp = dispatch_tool(&state, "get_prompt_context", json!({"query": " "}))
2889 .await
2890 .expect("get_prompt_context whitespace");
2891 let text = resp.as_str().expect("string body");
2892 assert!(text.contains("tga → trusty-git-analytics"));
2893 assert!(text.contains("tm → trusty-memory"));
2894 }
2895
2896 #[tokio::test]
2903 async fn dispatch_discover_aliases_inserts_new_and_dedupes() {
2904 let _tmp = tempfile::tempdir().expect("tempdir");
2907 let root = _tmp.path().to_path_buf();
2908 let state = AppState::new(root).with_default_palace(Some("disc".to_string()));
2909 let _ = dispatch_tool(&state, "palace_create", json!({"name": "disc"}))
2910 .await
2911 .expect("palace_create");
2912
2913 let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2917 .parent()
2918 .and_then(|p| p.parent())
2919 .expect("workspace root")
2920 .to_path_buf();
2921
2922 let first = dispatch_tool(
2923 &state,
2924 "discover_aliases",
2925 json!({"project_root": workspace_root.to_string_lossy()}),
2926 )
2927 .await
2928 .expect("discover_aliases first");
2929
2930 let new_count = first["new"].as_u64().expect("new is u64");
2931 assert!(new_count > 0, "expected new discoveries on first call");
2932 let discovered = first["discovered"].as_array().expect("discovered array");
2933 assert!(
2934 discovered
2935 .iter()
2936 .any(|d| d["short"] == "tga" && d["full"] == "trusty-git-analytics"),
2937 "expected tga alias in discoveries; got {discovered:?}"
2938 );
2939
2940 {
2942 let guard = state.prompt_context_cache.read().await;
2943 assert!(
2944 guard.formatted.contains("tga → trusty-git-analytics"),
2945 "prompt cache missing tga alias after discover_aliases; got: {}",
2946 guard.formatted
2947 );
2948 }
2949
2950 let second = dispatch_tool(
2953 &state,
2954 "discover_aliases",
2955 json!({"project_root": workspace_root.to_string_lossy()}),
2956 )
2957 .await
2958 .expect("discover_aliases second");
2959 assert_eq!(second["new"].as_u64(), Some(0), "expected 0 new on rerun");
2960 let already_known = second["already_known"].as_u64().expect("already_known");
2961 assert!(
2962 already_known >= new_count,
2963 "expected already_known >= {new_count}, got {already_known}"
2964 );
2965 }
2966
2967 #[tokio::test]
2974 async fn palace_create_auto_seeds_temporal_metadata() {
2975 let (state, _tmp) = test_state();
2976 let created = dispatch_tool(&state, "palace_create", json!({"name": "auto"}))
2977 .await
2978 .expect("palace_create");
2979 assert_eq!(created["palace_id"], "auto");
2980 let summary = &created["bootstrap"];
2982 assert!(summary.is_object(), "expected bootstrap summary object");
2983 assert!(summary["triples_asserted"].as_u64().unwrap_or(0) >= 2);
2984
2985 let queried = dispatch_tool(
2986 &state,
2987 "kg_query",
2988 json!({"palace": "auto", "subject": "auto"}),
2989 )
2990 .await
2991 .expect("kg_query");
2992 let triples = queried["triples"].as_array().expect("triples");
2993 let predicates: Vec<&str> = triples
2994 .iter()
2995 .filter_map(|t| t["predicate"].as_str())
2996 .collect();
2997 assert!(
2998 predicates.contains(&"created_at"),
2999 "expected created_at after palace_create; got {predicates:?}",
3000 );
3001 assert!(
3002 predicates.contains(&"bootstrapped_at"),
3003 "expected bootstrapped_at after palace_create; got {predicates:?}",
3004 );
3005 assert!(
3007 queried.get("hint").is_none(),
3008 "hint should be absent when triples exist"
3009 );
3010 }
3011
3012 #[tokio::test]
3017 async fn kg_query_emits_hint_when_palace_empty() {
3018 let (state, _tmp) = test_state();
3019 let _ = dispatch_tool(&state, "palace_create", json!({"name": "hinted"}))
3020 .await
3021 .expect("palace_create");
3022 let queried = dispatch_tool(
3024 &state,
3025 "kg_query",
3026 json!({"palace": "hinted", "subject": "unrelated-subject"}),
3027 )
3028 .await
3029 .expect("kg_query");
3030 assert_eq!(queried["triples"].as_array().unwrap().len(), 0);
3031 let hint = queried["hint"].as_str().expect("hint field present");
3032 assert!(hint.contains("kg_bootstrap"));
3033 assert!(hint.contains("kg_assert"));
3034 }
3035
3036 #[tokio::test]
3040 async fn kg_bootstrap_seeds_workspace_facts() {
3041 let (state, _tmp) = test_state();
3042 let _ = dispatch_tool(&state, "palace_create", json!({"name": "ws"}))
3043 .await
3044 .expect("palace_create");
3045
3046 let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3047 .parent()
3048 .and_then(|p| p.parent())
3049 .expect("workspace root")
3050 .to_path_buf();
3051
3052 let result = dispatch_tool(
3053 &state,
3054 "kg_bootstrap",
3055 json!({"palace": "ws", "project_path": workspace_root.to_string_lossy()}),
3056 )
3057 .await
3058 .expect("kg_bootstrap");
3059 assert!(result["triples_asserted"].as_u64().unwrap() > 0);
3060 let subject = result["project_subject"]
3061 .as_str()
3062 .expect("project_subject")
3063 .to_string();
3064
3065 let queried = dispatch_tool(
3067 &state,
3068 "kg_query",
3069 json!({"palace": "ws", "subject": subject}),
3070 )
3071 .await
3072 .expect("kg_query");
3073 let triples = queried["triples"].as_array().expect("triples");
3074 let predicates: Vec<&str> = triples
3075 .iter()
3076 .filter_map(|t| t["predicate"].as_str())
3077 .collect();
3078 assert!(
3082 predicates.contains(&"has_workspace_member") || predicates.contains(&"has_language"),
3083 "expected workspace/language fact; got {predicates:?}",
3084 );
3085 assert!(
3087 predicates.contains(&"source_repo"),
3088 "expected source_repo from .git/config; got {predicates:?}",
3089 );
3090 assert!(predicates.contains(&"bootstrapped_at"));
3092 }
3093
3094 #[test]
3103 fn content_gate_blocks_short_no_context() {
3104 assert_eq!(content_gate("yes", None), None);
3105 assert_eq!(content_gate("ok", None), None);
3106 assert_eq!(
3107 content_gate(" no thanks ", None),
3108 None,
3109 "2 words still < 4"
3110 );
3111 assert_eq!(
3112 content_gate("one two three", None),
3113 None,
3114 "3 words still < 4"
3115 );
3116 }
3117
3118 #[test]
3124 fn content_gate_wraps_short_with_context() {
3125 let combined = content_gate(
3126 "yes",
3127 Some("Do you want to enable auto-bootstrap on new palaces?"),
3128 )
3129 .expect("context should unlock the gate");
3130 assert_eq!(
3131 combined,
3132 "Do you want to enable auto-bootstrap on new palaces?\n\n---\n\nyes",
3133 );
3134 let combined = content_gate(
3137 "the quick brown fox jumps over the lazy dog",
3138 Some("Famous typing pangram"),
3139 )
3140 .expect("long content + context still combines");
3141 assert!(combined.starts_with("Famous typing pangram"));
3142 assert!(combined.contains("\n\n---\n\n"));
3143 assert!(combined.ends_with("the quick brown fox jumps over the lazy dog"));
3144 }
3145
3146 #[test]
3153 fn content_gate_keeps_long() {
3154 let body = "User prefers snake_case for python";
3155 let kept = content_gate(body, None).expect(">= 4 words passes");
3156 assert_eq!(kept, body, "passing content must round-trip verbatim");
3157 let boundary = "one two three four";
3159 assert_eq!(content_gate(boundary, None).as_deref(), Some(boundary));
3160 }
3161
3162 #[test]
3169 fn content_gate_blank_context_treated_as_none() {
3170 assert_eq!(content_gate("yes", Some("")), None);
3171 assert_eq!(content_gate("yes", Some(" ")), None);
3172 assert_eq!(content_gate("yes", Some("\n\t")), None);
3173 }
3174
3175 #[tokio::test]
3181 async fn dispatch_remember_skips_short_no_context() {
3182 let (state, _tmp) = test_state();
3183 let _ = dispatch_tool(&state, "palace_create", json!({"name": "gate"}))
3184 .await
3185 .expect("palace_create");
3186
3187 let res = dispatch_tool(
3188 &state,
3189 "memory_remember",
3190 json!({"palace": "gate", "text": "yes"}),
3191 )
3192 .await
3193 .expect("memory_remember (short)");
3194 assert_eq!(res["status"], "skipped");
3195 assert!(res["reason"]
3196 .as_str()
3197 .unwrap_or("")
3198 .contains("content gate"));
3199 let listed = dispatch_tool(
3201 &state,
3202 "memory_list",
3203 json!({"palace": "gate", "limit": 10}),
3204 )
3205 .await
3206 .expect("memory_list");
3207 let drawers = listed["drawers"].as_array().expect("drawers array");
3208 assert!(
3209 drawers.is_empty(),
3210 "no drawer should be written; got {drawers:?}"
3211 );
3212 }
3213
3214 #[tokio::test]
3222 async fn dispatch_remember_with_context_writes_combined() {
3223 let (state, _tmp) = test_state();
3224 let _ = dispatch_tool(&state, "palace_create", json!({"name": "ctxgate"}))
3225 .await
3226 .expect("palace_create");
3227
3228 let res = dispatch_tool(
3229 &state,
3230 "memory_remember",
3231 json!({
3232 "palace": "ctxgate",
3233 "text": "yes",
3234 "context": "Do you want to enable auto-bootstrap on new palaces?",
3235 "force": true,
3236 }),
3237 )
3238 .await
3239 .expect("memory_remember (with context)");
3240 assert_eq!(res["status"], "stored");
3241
3242 let listed = dispatch_tool(
3243 &state,
3244 "memory_list",
3245 json!({"palace": "ctxgate", "limit": 10}),
3246 )
3247 .await
3248 .expect("memory_list");
3249 let drawers = listed["drawers"].as_array().expect("drawers array");
3250 assert_eq!(drawers.len(), 1);
3251 let body = drawers[0]["content"].as_str().expect("content");
3252 assert!(body.starts_with("Do you want to enable auto-bootstrap"));
3253 assert!(body.contains("\n\n---\n\n"));
3254 assert!(body.ends_with("yes"));
3255 }
3256
3257 #[tokio::test]
3264 async fn dispatch_note_skips_short_no_context() {
3265 let (state, _tmp) = test_state();
3266 let _ = dispatch_tool(&state, "palace_create", json!({"name": "noteg"}))
3267 .await
3268 .expect("palace_create");
3269
3270 let res = dispatch_tool(
3271 &state,
3272 "memory_note",
3273 json!({"palace": "noteg", "content": "ok"}),
3274 )
3275 .await
3276 .expect("memory_note (short)");
3277 assert_eq!(res["status"], "skipped");
3278 let listed = dispatch_tool(
3279 &state,
3280 "memory_list",
3281 json!({"palace": "noteg", "limit": 10}),
3282 )
3283 .await
3284 .expect("memory_list");
3285 assert!(listed["drawers"].as_array().unwrap().is_empty());
3286 }
3287
3288 #[tokio::test]
3289 async fn dispatch_unknown_tool_errors() {
3290 let (state, _tmp) = test_state();
3291 let err = dispatch_tool(&state, "does_not_exist", json!({}))
3292 .await
3293 .expect_err("should error");
3294 assert!(err.to_string().contains("unknown tool"));
3295 }
3296
3297 #[test]
3308 fn blocklist_gate_blocks_tool_use() {
3309 assert!(blocklist_gate("Tool use: Bash"));
3310 assert!(blocklist_gate(
3311 "Tool use: Edit File: /Users/me/Projects/foo/bar.rs"
3312 ));
3313 assert!(blocklist_gate(" Tool use: Read"));
3315 }
3316
3317 #[test]
3322 fn blocklist_gate_blocks_session_ended() {
3323 assert!(blocklist_gate(
3324 "Claude Code session ended: 1d2c3b4a-0000-0000-0000-000000000000"
3325 ));
3326 assert!(blocklist_gate("Claude Code session started"));
3327 }
3328
3329 #[test]
3335 fn blocklist_gate_passes_normal_content() {
3336 assert!(!blocklist_gate("User prefers snake_case for python"));
3337 assert!(!blocklist_gate(
3338 "Quokkas are the happiest marsupials in Australia"
3339 ));
3340 assert!(!blocklist_gate("Note: refactor the dispatcher next sprint"));
3341 assert!(blocklist_gate("I used Tool use: Bash here"));
3346 }
3347
3348 #[tokio::test]
3358 async fn dedup_skips_near_duplicate() {
3359 let (state, _tmp) = test_state();
3360 let _ = dispatch_tool(&state, "palace_create", json!({"name": "dedup1"}))
3361 .await
3362 .expect("palace_create");
3363
3364 let _ = dispatch_tool(
3367 &state,
3368 "memory_remember",
3369 json!({
3370 "palace": "dedup1",
3371 "text": "The quick brown fox jumped over the lazy dog repeatedly today",
3372 }),
3373 )
3374 .await
3375 .expect("memory_remember seed");
3376
3377 let handle = open_palace_handle(&state, "dedup1").expect("open handle");
3378 assert!(
3382 dedup_gate(
3383 &handle,
3384 "The quick brown fox jumped over the lazy dog repeatedly yesterday"
3385 ),
3386 "near-duplicate should be detected"
3387 );
3388 assert!(
3390 dedup_gate(
3391 &handle,
3392 "The quick brown fox jumped over the lazy dog repeatedly today"
3393 ),
3394 "exact match should be detected"
3395 );
3396 }
3397
3398 #[tokio::test]
3404 async fn dedup_allows_different_content() {
3405 let (state, _tmp) = test_state();
3406 let _ = dispatch_tool(&state, "palace_create", json!({"name": "dedup2"}))
3407 .await
3408 .expect("palace_create");
3409
3410 let _ = dispatch_tool(
3411 &state,
3412 "memory_remember",
3413 json!({
3414 "palace": "dedup2",
3415 "text": "Quokkas are the happiest marsupials in Australia by general consensus",
3416 }),
3417 )
3418 .await
3419 .expect("memory_remember seed");
3420
3421 let handle = open_palace_handle(&state, "dedup2").expect("open handle");
3422 assert!(
3424 !dedup_gate(
3425 &handle,
3426 "Rust is a systems programming language focused on safety and concurrency"
3427 ),
3428 "unrelated content should pass the dedup gate"
3429 );
3430 assert!(!dedup_gate(&handle, " "));
3433 }
3434
3435 #[tokio::test]
3450 async fn dedup_gate_blocks_concurrent_duplicate_writes() {
3451 let (state, _tmp) = test_state();
3452 let state = std::sync::Arc::new(state);
3453 let _ = dispatch_tool(&state, "palace_create", json!({"name": "dedup_race"}))
3454 .await
3455 .expect("palace_create");
3456
3457 let text =
3461 "Concurrent identical writes must collapse to a single drawer under the dedup gate";
3462
3463 let s1 = state.clone();
3464 let t1 = tokio::spawn(async move {
3465 dispatch_tool(
3466 &s1,
3467 "memory_remember",
3468 json!({"palace": "dedup_race", "text": text}),
3469 )
3470 .await
3471 });
3472 let s2 = state.clone();
3473 let t2 = tokio::spawn(async move {
3474 dispatch_tool(
3475 &s2,
3476 "memory_remember",
3477 json!({"palace": "dedup_race", "text": text}),
3478 )
3479 .await
3480 });
3481 let r1 = t1.await.expect("join t1").expect("dispatch t1");
3482 let r2 = t2.await.expect("join t2").expect("dispatch t2");
3483
3484 let statuses = [
3487 r1["status"].as_str().unwrap_or(""),
3488 r2["status"].as_str().unwrap_or(""),
3489 ];
3490 let stored = statuses.iter().filter(|s| **s == "stored").count();
3491 let skipped = statuses.iter().filter(|s| **s == "skipped").count();
3492 assert_eq!(
3493 stored, 1,
3494 "exactly one concurrent write should be stored; got responses {r1:?} {r2:?}"
3495 );
3496 assert_eq!(
3497 skipped, 1,
3498 "exactly one concurrent write should be skipped; got responses {r1:?} {r2:?}"
3499 );
3500 let skipped_reason = if r1["status"] == "skipped" {
3501 r1["reason"].as_str().unwrap_or("")
3502 } else {
3503 r2["reason"].as_str().unwrap_or("")
3504 };
3505 assert!(
3506 skipped_reason.contains("duplicate within window"),
3507 "skipped envelope should cite dedup reason; got {skipped_reason:?}"
3508 );
3509
3510 let listed = dispatch_tool(
3512 &state,
3513 "memory_list",
3514 json!({"palace": "dedup_race", "limit": 10}),
3515 )
3516 .await
3517 .expect("memory_list");
3518 let drawers = listed["drawers"].as_array().expect("drawers array");
3519 assert_eq!(
3520 drawers.len(),
3521 1,
3522 "only one drawer should be persisted after concurrent identical writes; got {drawers:?}"
3523 );
3524 }
3525
3526 #[tokio::test]
3534 async fn dispatch_remember_blocks_blocklist_pattern() {
3535 let (state, _tmp) = test_state();
3536 let _ = dispatch_tool(&state, "palace_create", json!({"name": "blk"}))
3537 .await
3538 .expect("palace_create");
3539
3540 let res = dispatch_tool(
3541 &state,
3542 "memory_remember",
3543 json!({"palace": "blk", "text": "Tool use: Bash"}),
3544 )
3545 .await
3546 .expect("memory_remember (blocked)");
3547 assert_eq!(res["status"], "skipped");
3548 assert!(
3549 res["reason"]
3550 .as_str()
3551 .unwrap_or("")
3552 .contains("blocked pattern"),
3553 "reason should mention blocked pattern; got {res:?}"
3554 );
3555
3556 let listed = dispatch_tool(&state, "memory_list", json!({"palace": "blk", "limit": 10}))
3557 .await
3558 .expect("memory_list");
3559 let drawers = listed["drawers"].as_array().expect("drawers array");
3560 assert!(drawers.is_empty(), "no drawer should be written");
3561 }
3562
3563 #[tokio::test]
3577 async fn bm25_index_queue_drops_when_full() {
3578 let (mut state, _tmp) = test_state();
3582 let (tx, _rx_held) =
3583 tokio::sync::mpsc::channel::<Bm25IndexRequest>(BM25_INDEX_QUEUE_CAPACITY);
3584 state.bm25_index_tx = tx;
3585
3586 for i in 0..BM25_INDEX_QUEUE_CAPACITY {
3588 bm25_index_enqueue(
3589 &state,
3590 "default",
3591 Uuid::new_v4(),
3592 &format!("filler content {i}"),
3593 );
3594 }
3595 assert_eq!(
3597 state.bm25_index_tx.capacity(),
3598 0,
3599 "after filling, sender capacity must be 0"
3600 );
3601
3602 for i in 0..16 {
3605 bm25_index_enqueue(
3606 &state,
3607 "default",
3608 Uuid::new_v4(),
3609 &format!("overflow content {i}"),
3610 );
3611 }
3612
3613 let probe_req = Bm25IndexRequest {
3617 palace: "default".to_string(),
3618 drawer_id: Uuid::new_v4().to_string(),
3619 content: "probe".to_string(),
3620 data_dir: state.data_root.join("default").join("bm25"),
3621 };
3622 let probe = state.bm25_index_tx.try_send(probe_req);
3623 match probe {
3624 Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {}
3625 other => panic!("expected Full overflow, got {other:?}"),
3626 }
3627 }
3628}