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 trusty_common::memory_core::timeouts;
34use uuid::Uuid;
35
36fn lookup_palace_name(state: &AppState, palace_id: &str) -> String {
59 state
60 .palace_names
61 .get(palace_id)
62 .map(|entry| entry.value().clone())
63 .unwrap_or_else(|| palace_id.to_string())
64}
65
66const CONTENT_GATE_MIN_WORDS: usize = 4;
78
79fn content_gate(content: &str, context: Option<&str>) -> Option<String> {
95 let trimmed = content.trim();
96 let word_count = trimmed.split_whitespace().count();
97 let context_clean = context.map(str::trim).filter(|s| !s.is_empty());
102 if let Some(ctx) = context_clean {
103 return Some(format!("{ctx}\n\n---\n\n{content}"));
104 }
105 if word_count < CONTENT_GATE_MIN_WORDS {
106 return None;
107 }
108 Some(content.to_string())
109}
110
111const BLOCKLIST_PATTERNS: &[&str] = &[
128 "Tool use: ", "Claude Code session", ];
131
132const DEDUP_WINDOW_MINUTES: i64 = 5;
143
144const DEDUP_SCAN_LIMIT: usize = 50;
155
156const DEDUP_SIMILARITY_THRESHOLD: f64 = 0.92;
168
169fn blocklist_gate(content: &str) -> bool {
182 let trimmed = content.trim_start();
183 BLOCKLIST_PATTERNS.iter().any(|pat| trimmed.contains(pat))
184}
185
186fn dedup_gate(handle: &trusty_common::memory_core::PalaceHandle, content: &str) -> bool {
203 let trimmed = content.trim();
204 if trimmed.is_empty() {
205 return false;
206 }
207 let now = chrono::Utc::now();
208 let window_start = now - chrono::Duration::minutes(DEDUP_WINDOW_MINUTES);
209 let recent = handle.list_drawers(None, None, DEDUP_SCAN_LIMIT);
210 recent
211 .iter()
212 .filter(|d| d.created_at >= window_start)
213 .any(|d| strsim::jaro_winkler(trimmed, d.content.trim()) > DEDUP_SIMILARITY_THRESHOLD)
214}
215
216fn mcp_remember_opts(force: bool) -> RememberOptions {
224 let filter = FilterConfig {
225 min_tokens: MCP_MIN_TOKENS,
226 ..FilterConfig::default()
227 };
228 RememberOptions {
229 filter,
230 force,
231 ..RememberOptions::default()
232 }
233}
234
235pub struct MemoryMcpServer;
242
243impl MemoryMcpServer {
244 pub fn new() -> Self {
245 Self
246 }
247}
248
249impl Default for MemoryMcpServer {
250 fn default() -> Self {
251 Self::new()
252 }
253}
254
255pub fn tool_definitions() -> Value {
266 tool_definitions_with(false)
267}
268
269pub fn tool_definitions_with(has_default: bool) -> Value {
280 let memory_remember_required: Vec<&str> = if has_default {
281 vec!["text"]
282 } else {
283 vec!["palace", "text"]
284 };
285 let memory_recall_required: Vec<&str> = if has_default {
286 vec!["query"]
287 } else {
288 vec!["palace", "query"]
289 };
290 let kg_assert_required: Vec<&str> = if has_default {
291 vec!["subject", "predicate", "object"]
292 } else {
293 vec!["palace", "subject", "predicate", "object"]
294 };
295 let kg_query_required: Vec<&str> = if has_default {
296 vec!["subject"]
297 } else {
298 vec!["palace", "subject"]
299 };
300 let memory_list_required: Vec<&str> = if has_default { vec![] } else { vec!["palace"] };
301 let memory_forget_required: Vec<&str> = if has_default {
302 vec!["drawer_id"]
303 } else {
304 vec!["palace", "drawer_id"]
305 };
306 let palace_info_required: Vec<&str> = if has_default { vec![] } else { vec!["palace"] };
307 let palace_compact_required: Vec<&str> = if has_default { vec![] } else { vec!["palace"] };
308 let memory_note_required: Vec<&str> = if has_default {
309 vec!["content"]
310 } else {
311 vec!["palace", "content"]
312 };
313 let add_alias_required: Vec<&str> = if has_default {
317 vec!["short", "full"]
318 } else {
319 vec!["palace", "short", "full"]
320 };
321 let discover_aliases_required: Vec<&str> = if has_default { vec![] } else { vec!["palace"] };
322
323 json!({
324 "tools": [
325 {
326 "name": "memory_remember",
327 "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.",
328 "inputSchema": {
329 "type": "object",
330 "properties": {
331 "palace": {"type": "string", "description": "Palace ID (optional if server started with --palace)"},
332 "text": {"type": "string", "description": "Memory content"},
333 "room": {"type": "string", "description": "Room type (optional)"},
334 "tags": {"type": "array", "items": {"type": "string"}},
335 "force": {"type": "boolean", "description": "Bypass the signal/noise filter. Use sparingly — intended for explicit operator overrides.", "default": false},
336 "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)."}
337 },
338 "required": memory_remember_required,
339 }
340 },
341 {
342 "name": "memory_note",
343 "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.",
344 "inputSchema": {
345 "type": "object",
346 "properties": {
347 "palace": {"type": "string"},
348 "content": {"type": "string", "description": "Brief fact to remember"},
349 "tags": {"type": "array", "items": {"type": "string"}},
350 "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)."}
351 },
352 "required": memory_note_required,
353 }
354 },
355 {
356 "name": "memory_recall",
357 "description": "Recall memories using L0+L1+L2 progressive retrieval.",
358 "inputSchema": {
359 "type": "object",
360 "properties": {
361 "palace": {"type": "string"},
362 "query": {"type": "string"},
363 "top_k": {"type": "integer", "default": 10}
364 },
365 "required": memory_recall_required,
366 }
367 },
368 {
369 "name": "memory_recall_deep",
370 "description": "Deep recall using L3 full HNSW search.",
371 "inputSchema": {
372 "type": "object",
373 "properties": {
374 "palace": {"type": "string"},
375 "query": {"type": "string"},
376 "top_k": {"type": "integer", "default": 10}
377 },
378 "required": memory_recall_required,
379 }
380 },
381 {
382 "name": "palace_create",
383 "description": "Create a new memory palace.",
384 "inputSchema": {
385 "type": "object",
386 "properties": {
387 "name": {"type": "string"},
388 "description": {"type": "string"},
389 "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)."}
390 },
391 "required": ["name"]
392 }
393 },
394 {
395 "name": "palace_list",
396 "description": "List all palaces on this machine.",
397 "inputSchema": {"type": "object", "properties": {}}
398 },
399 {
400 "name": "palace_delete",
401 "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.",
402 "inputSchema": {
403 "type": "object",
404 "properties": {
405 "palace_id": {"type": "string", "description": "Id of the palace to delete."},
406 "force": {"type": "boolean", "description": "Required when the palace still has drawers; defaults to false.", "default": false}
407 },
408 "required": ["palace_id"]
409 }
410 },
411 {
412 "name": "palace_update",
413 "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.",
414 "inputSchema": {
415 "type": "object",
416 "properties": {
417 "palace_id": {"type": "string", "description": "Id of the palace to rename."},
418 "name": {"type": "string", "description": "New display name. Trimmed; must be non-empty."}
419 },
420 "required": ["palace_id", "name"]
421 }
422 },
423 {
424 "name": "kg_assert",
425 "description": "Assert a fact in the temporal knowledge graph.",
426 "inputSchema": {
427 "type": "object",
428 "properties": {
429 "palace": {"type": "string"},
430 "subject": {"type": "string"},
431 "predicate": {"type": "string"},
432 "object": {"type": "string"},
433 "confidence": {"type": "number", "default": 1.0},
434 "provenance": {"type": "string"}
435 },
436 "required": kg_assert_required,
437 }
438 },
439 {
440 "name": "kg_query",
441 "description": "Query active knowledge-graph triples for a subject.",
442 "inputSchema": {
443 "type": "object",
444 "properties": {
445 "palace": {"type": "string"},
446 "subject": {"type": "string"}
447 },
448 "required": kg_query_required,
449 }
450 },
451 {
452 "name": "memory_list",
453 "description": "List drawers in a palace, optionally filtered by room type or tag.",
454 "inputSchema": {
455 "type": "object",
456 "properties": {
457 "palace": {"type": "string"},
458 "room": {"type": "string", "description": "Filter by room type (Frontend, Backend, Testing, Planning, Documentation, Research, Configuration, Meetings, General, or custom)"},
459 "tag": {"type": "string", "description": "Filter by tag"},
460 "limit": {"type": "integer", "description": "Max results (default 50)"}
461 },
462 "required": memory_list_required,
463 }
464 },
465 {
466 "name": "memory_forget",
467 "description": "Delete a drawer from a palace by its UUID.",
468 "inputSchema": {
469 "type": "object",
470 "properties": {
471 "palace": {"type": "string"},
472 "drawer_id": {"type": "string", "description": "UUID of the drawer to delete"}
473 },
474 "required": memory_forget_required,
475 }
476 },
477 {
478 "name": "palace_info",
479 "description": "Get metadata and stats for a single palace.",
480 "inputSchema": {
481 "type": "object",
482 "properties": {
483 "palace": {"type": "string"}
484 },
485 "required": palace_info_required,
486 }
487 },
488 {
489 "name": "palace_compact",
490 "description": "Remove orphaned vector index entries (vectors with no matching drawer row). See issue #49.",
491 "inputSchema": {
492 "type": "object",
493 "properties": {
494 "palace": {"type": "string"}
495 },
496 "required": palace_compact_required,
497 }
498 },
499 {
500 "name": "add_alias",
501 "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.",
502 "inputSchema": {
503 "type": "object",
504 "properties": {
505 "palace": {"type": "string", "description": "Palace ID (optional if server started with --palace)"},
506 "short": {"type": "string", "description": "Short name / alias (subject)"},
507 "full": {"type": "string", "description": "Full / canonical name (object)"},
508 "extra": {"type": "string", "description": "Optional extra context appended to the full name"}
509 },
510 "required": add_alias_required,
511 }
512 },
513 {
514 "name": "list_prompt_facts",
515 "description": "List every active prompt-fact triple (aliases, conventions, facts, shorthands) across all palaces.",
516 "inputSchema": {"type": "object", "properties": {}}
517 },
518 {
519 "name": "remove_prompt_fact",
520 "description": "Retract the active triple for a (subject, predicate) pair from the prompt-facts surface. Closes the interval without inserting a replacement.",
521 "inputSchema": {
522 "type": "object",
523 "properties": {
524 "subject": {"type": "string"},
525 "predicate": {"type": "string", "description": "One of is_alias_for, has_convention, is_fact, is_shorthand_for"}
526 },
527 "required": ["subject", "predicate"],
528 }
529 },
530 {
531 "name": "get_prompt_context",
532 "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).",
533 "inputSchema": {
534 "type": "object",
535 "properties": {
536 "query": {
537 "type": "string",
538 "description": "Optional filter — only return facts whose subject or object contains this string (case-insensitive). Omit to return all hot facts."
539 }
540 }
541 }
542 },
543 {
544 "name": "discover_aliases",
545 "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.",
546 "inputSchema": {
547 "type": "object",
548 "properties": {
549 "palace": {"type": "string", "description": "Palace ID (optional if server started with --palace)"},
550 "project_root": {"type": "string", "description": "Optional filesystem path to scan. Defaults to the process cwd."}
551 },
552 "required": discover_aliases_required,
553 }
554 },
555 {
556 "name": "kg_gaps",
557 "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.",
558 "inputSchema": {
559 "type": "object",
560 "properties": {
561 "palace": {"type": "string", "description": "Palace name (optional, defaults to the active palace)"}
562 }
563 }
564 },
565 {
566 "name": "kg_bootstrap",
567 "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.",
568 "inputSchema": {
569 "type": "object",
570 "properties": {
571 "palace": {"type": "string", "description": "Palace ID (optional if server started with --palace)"},
572 "project_path": {"type": "string", "description": "Filesystem path to scan. Omit to scan the palace's own data dir (temporal metadata only)."}
573 }
574 }
575 },
576 {
577 "name": "memory_recall_all",
578 "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.",
579 "inputSchema": {
580 "type": "object",
581 "properties": {
582 "q": {"type": "string", "description": "Free-text query"},
583 "top_k": {"type": "integer", "default": 10},
584 "deep": {"type": "boolean", "default": false}
585 },
586 "required": ["q"],
587 }
588 },
589 {
590 "name": "memory_send_message",
591 "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.",
592 "inputSchema": {
593 "type": "object",
594 "properties": {
595 "to_palace": {"type": "string", "description": "Recipient palace id (repo slug)."},
596 "purpose": {"type": "string", "description": "Free-text purpose / category (e.g. `task`, `notify`, `reply`)."},
597 "content": {"type": "string", "description": "Message body — plain text, no length limit. Rendered into the recipient session as a Markdown block."},
598 "from_palace": {"type": "string", "description": "Sender palace id (optional, defaults to cwd-derived slug)."}
599 },
600 "required": ["to_palace", "purpose", "content"],
601 }
602 },
603 {
604 "name": "upgrade",
605 "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.",
606 "inputSchema": {
607 "type": "object",
608 "properties": {
609 "check": {"type": "boolean", "description": "Report current and available versions only. No install. Default: true when confirm is absent.", "default": true},
610 "confirm": {"type": "boolean", "description": "Set to true to install the new version. NEVER set automatically — the operator must explicitly pass confirm=true.", "default": false}
611 },
612 "required": []
613 }
614 }
615 ]
616 })
617}
618
619pub(crate) fn room_label(room: &RoomType) -> Option<String> {
630 let label = match room {
631 RoomType::Frontend => "Frontend",
632 RoomType::Backend => "Backend",
633 RoomType::Testing => "Testing",
634 RoomType::Planning => "Planning",
635 RoomType::Documentation => "Documentation",
636 RoomType::Research => "Research",
637 RoomType::Configuration => "Configuration",
638 RoomType::Meetings => "Meetings",
639 RoomType::General => "General",
640 RoomType::Custom(s) => return Some(s.clone()),
641 };
642 Some(label.to_string())
643}
644
645fn parse_room(s: Option<&str>) -> RoomType {
653 match s.unwrap_or("General") {
654 "Frontend" => RoomType::Frontend,
655 "Backend" => RoomType::Backend,
656 "Testing" => RoomType::Testing,
657 "Planning" => RoomType::Planning,
658 "Documentation" => RoomType::Documentation,
659 "Research" => RoomType::Research,
660 "Configuration" => RoomType::Configuration,
661 "Meetings" => RoomType::Meetings,
662 "General" => RoomType::General,
663 other => RoomType::Custom(other.to_string()),
664 }
665}
666
667fn open_palace_handle(
669 state: &AppState,
670 palace_id: &str,
671) -> Result<std::sync::Arc<trusty_common::memory_core::PalaceHandle>> {
672 let pid = PalaceId::new(palace_id);
673 state
674 .registry
675 .open_palace(&state.data_root, &pid)
676 .with_context(|| format!("open palace {palace_id}"))
677}
678
679pub(crate) async fn auto_extract_and_assert(
695 handle: &std::sync::Arc<trusty_common::memory_core::PalaceHandle>,
696 drawer_id: Uuid,
697 content: &str,
698 tags: &[String],
699 room: Option<&str>,
700) {
701 let input = ExtractInput {
702 drawer_id,
703 content,
704 tags,
705 room,
706 };
707 let triples = extract_triples(&input);
708 if triples.is_empty() {
709 return;
710 }
711 for triple in triples {
712 let s = triple.subject.clone();
713 let p = triple.predicate.clone();
714 if let Err(e) = handle.kg.assert(triple).await {
715 tracing::warn!(
716 drawer_id = %drawer_id,
717 subject = %s,
718 predicate = %p,
719 "auto kg extraction: assert failed (non-fatal): {e:#}",
720 );
721 }
722 }
723}
724
725fn resolve_palace<'a>(state: &'a AppState, args: &'a Value, tool: &str) -> Result<String> {
737 if let Some(p) = args.get("palace").and_then(|v| v.as_str()) {
738 return Ok(p.to_string());
739 }
740 state
741 .default_palace
742 .clone()
743 .ok_or_else(|| anyhow!("{tool}: missing 'palace' (no --palace default configured)"))
744}
745
746struct WriteDrawerParams<'a> {
760 palace_id: &'a str,
761 content: String,
762 tags: Vec<String>,
763 room: RoomType,
764 importance: f32,
765 opts: RememberOptions,
766 room_label_for_kg: Option<String>,
767}
768
769async fn write_drawer(state: &AppState, params: WriteDrawerParams<'_>) -> Result<Uuid> {
785 let WriteDrawerParams {
786 palace_id,
787 content,
788 tags,
789 room,
790 importance,
791 opts,
792 room_label_for_kg,
793 } = params;
794
795 let handle = open_palace_handle(state, palace_id)?;
796 let preview = crate::service::drawer_content_preview(&content);
799 let content_for_kg = content.clone();
803 let tags_for_kg = tags.clone();
804 let drawer_id = handle
805 .remember_with_options(content, room, tags, importance, opts)
806 .await
807 .context("PalaceHandle::remember_with_options")?;
808 bm25_index_enqueue(state, palace_id, drawer_id, &content_for_kg);
814 let palace_name = lookup_palace_name(state, palace_id);
817 let drawer_count = handle.drawers.read().len();
818 state.emit(DaemonEvent::DrawerAdded {
819 palace_id: palace_id.to_string(),
820 palace_name,
821 drawer_count,
822 timestamp: chrono::Utc::now(),
823 content_preview: preview,
824 source: ActivitySource::Mcp,
825 });
826 auto_extract_and_assert(
834 &handle,
835 drawer_id,
836 &content_for_kg,
837 &tags_for_kg,
838 room_label_for_kg.as_deref(),
839 )
840 .await;
841 Ok(drawer_id)
842}
843
844fn skipped_envelope(palace_id: &str, reason: &str) -> Value {
856 json!({
857 "palace": palace_id,
858 "status": "skipped",
859 "reason": reason,
860 })
861}
862
863fn parse_tags(args: &Value) -> Vec<String> {
873 args.get("tags")
874 .and_then(|v| v.as_array())
875 .map(|arr| {
876 arr.iter()
877 .filter_map(|t| t.as_str().map(|s| s.to_string()))
878 .collect()
879 })
880 .unwrap_or_default()
881}
882
883fn attach_mcp_attribution(tags: &mut Vec<String>) {
895 if let Some(session_tag) = session_tag_from_tags(tags) {
896 tags.push(session_tag);
897 }
898 CreatorInfo::new_self(MCP_CLIENT_NAME, CreatorSource::Mcp).merge_into(tags);
899}
900
901async fn handle_memory_remember(state: &AppState, args: Value) -> Result<Value> {
911 state.readiness_check()?;
915 let palace = resolve_palace(state, &args, "memory_remember")?;
916 let palace = palace.as_str();
917 let raw_text = args
918 .get("text")
919 .and_then(|v| v.as_str())
920 .ok_or_else(|| anyhow!("memory_remember: missing 'text'"))?
921 .to_string();
922 if blocklist_gate(&raw_text) {
927 tracing::debug!(
928 palace = %palace,
929 "content gate: skipped (blocked pattern)",
930 );
931 return Ok(skipped_envelope(
932 palace,
933 "content gate: skipped (blocked pattern)",
934 ));
935 }
936 let ctx = args.get("context").and_then(|v| v.as_str());
942 let text = match content_gate(&raw_text, ctx) {
943 Some(t) => t,
944 None => {
945 return Ok(skipped_envelope(
946 palace,
947 "content gate: skipped (short prompt, no context)",
948 ));
949 }
950 };
951 let room = parse_room(args.get("room").and_then(|v| v.as_str()));
952 let mut tags = parse_tags(&args);
953 attach_mcp_attribution(&mut tags);
961
962 let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
963
964 let write_lock = state.palace_write_lock(palace);
974 let _write_guard =
975 timeouts::lock_with_timeout(&write_lock, timeouts::write_lock_timeout(), palace)
976 .await
977 .map_err(|e| anyhow::anyhow!("memory_remember: {e:#}"))?;
978
979 if !force {
984 let handle = open_palace_handle(state, palace)?;
985 if dedup_gate(&handle, &text) {
986 tracing::debug!(
987 palace = %palace,
988 "content gate: skipped (duplicate within window)",
989 );
990 return Ok(skipped_envelope(
991 palace,
992 "content gate: skipped (duplicate within window)",
993 ));
994 }
995 }
996 let room_label_for_kg = room_label(&room);
997 let drawer_id = write_drawer(
998 state,
999 WriteDrawerParams {
1000 palace_id: palace,
1001 content: text,
1002 tags,
1003 room,
1004 importance: 0.5,
1005 opts: mcp_remember_opts(force),
1006 room_label_for_kg,
1007 },
1008 )
1009 .await?;
1010 Ok(json!({
1011 "drawer_id": drawer_id.to_string(),
1012 "palace": palace,
1013 "status": "stored",
1014 }))
1015}
1016
1017async fn handle_memory_note(state: &AppState, args: Value) -> Result<Value> {
1018 state.readiness_check()?;
1020 let palace = resolve_palace(state, &args, "memory_note")?;
1026 let palace = palace.as_str();
1027 let raw_content = args
1028 .get("content")
1029 .and_then(|v| v.as_str())
1030 .ok_or_else(|| anyhow!("memory_note: missing 'content'"))?
1031 .to_string();
1032 if blocklist_gate(&raw_content) {
1037 tracing::debug!(
1038 palace = %palace,
1039 "content gate: skipped (blocked pattern)",
1040 );
1041 return Ok(skipped_envelope(
1042 palace,
1043 "content gate: skipped (blocked pattern)",
1044 ));
1045 }
1046 let ctx = args.get("context").and_then(|v| v.as_str());
1051 let content = match content_gate(&raw_content, ctx) {
1052 Some(c) => c,
1053 None => {
1054 return Ok(skipped_envelope(
1055 palace,
1056 "content gate: skipped (short prompt, no context)",
1057 ));
1058 }
1059 };
1060 let mut tags = parse_tags(&args);
1061 attach_mcp_attribution(&mut tags);
1065 let write_lock = state.palace_write_lock(palace);
1074 let _write_guard =
1075 timeouts::lock_with_timeout(&write_lock, timeouts::write_lock_timeout(), palace)
1076 .await
1077 .map_err(|e| anyhow::anyhow!("memory_note: {e:#}"))?;
1078 {
1083 let handle = open_palace_handle(state, palace)?;
1084 if dedup_gate(&handle, &content) {
1085 tracing::debug!(
1086 palace = %palace,
1087 "content gate: skipped (duplicate within window)",
1088 );
1089 return Ok(skipped_envelope(
1090 palace,
1091 "content gate: skipped (duplicate within window)",
1092 ));
1093 }
1094 }
1095 let drawer_id = write_drawer(
1099 state,
1100 WriteDrawerParams {
1101 palace_id: palace,
1102 content,
1103 tags,
1104 room: RoomType::General,
1105 importance: 1.0,
1106 opts: RememberOptions::note(),
1107 room_label_for_kg: Some("General".to_string()),
1111 },
1112 )
1113 .await
1114 .context("PalaceHandle::remember_with_options (note)")?;
1115 Ok(json!({
1116 "drawer_id": drawer_id.to_string(),
1117 "palace": palace,
1118 "status": "stored",
1119 "drawer_type": "UserFact",
1120 }))
1121}
1122
1123async fn handle_memory_recall(state: &AppState, args: Value) -> Result<Value> {
1124 state.readiness_check()?;
1126 let palace = resolve_palace(state, &args, "memory_recall")?;
1127 let query = args
1128 .get("query")
1129 .and_then(|v| v.as_str())
1130 .ok_or_else(|| anyhow!("memory_recall: missing 'query'"))?;
1131 let top_k = args.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
1132
1133 let handle = open_palace_handle(state, &palace)?;
1134 let embedder = state.embedder().await?;
1135 let vector_fut = recall(&handle, embedder.as_ref(), query, top_k);
1141 let bm25_fut = bm25_search_optional(state, &palace, query, top_k);
1142 let (vector_res, bm25_res) = tokio::join!(vector_fut, bm25_fut);
1143 let mut results = vector_res.context("recall")?;
1144 if let Some(bm25_hits) = bm25_res {
1145 fuse_bm25_into_recall(&mut results, &bm25_hits, top_k);
1146 }
1147 Ok(serialize_recall(&palace, query, results))
1148}
1149
1150async fn handle_memory_recall_deep(state: &AppState, args: Value) -> Result<Value> {
1151 state.readiness_check()?;
1153 let palace = resolve_palace(state, &args, "memory_recall_deep")?;
1154 let query = args
1155 .get("query")
1156 .and_then(|v| v.as_str())
1157 .ok_or_else(|| anyhow!("memory_recall_deep: missing 'query'"))?;
1158 let top_k = args.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
1159
1160 let handle = open_palace_handle(state, &palace)?;
1161 let embedder = state.embedder().await?;
1162 let results = recall_deep(&handle, embedder.as_ref(), query, top_k)
1163 .await
1164 .context("recall_deep")?;
1165 Ok(serialize_recall(&palace, query, results))
1166}
1167
1168async fn handle_palace_create(state: &AppState, args: Value) -> Result<Value> {
1169 let palace_name = args
1170 .get("name")
1171 .and_then(|v| v.as_str())
1172 .ok_or_else(|| anyhow!("palace_create: missing 'name'"))?;
1173
1174 let skip_enforcement = std::env::var("TRUSTY_SKIP_PALACE_ENFORCEMENT").as_deref() == Ok("1");
1190 if !skip_enforcement {
1191 let cwd = args
1192 .get("cwd")
1193 .and_then(|v| v.as_str())
1194 .filter(|s| !s.is_empty())
1195 .map(std::path::Path::new)
1196 .map(|p| p.to_path_buf())
1197 .or_else(|| std::env::current_dir().ok())
1198 .unwrap_or_else(|| state.data_root.clone());
1199 crate::project_root::validate_palace_name(palace_name, &cwd)?;
1200 }
1201
1202 let description = args
1203 .get("description")
1204 .and_then(|v| v.as_str())
1205 .map(|s| s.to_string());
1206 let palace = Palace {
1207 id: PalaceId::new(palace_name),
1208 name: palace_name.to_string(),
1209 description,
1210 created_at: chrono::Utc::now(),
1211 data_dir: state.data_root.join(palace_name),
1212 };
1213 let _handle = state
1214 .registry
1215 .create_palace(&state.data_root, palace)
1216 .context("create_palace")?;
1217 state
1221 .palace_names
1222 .insert(palace_name.to_string(), palace_name.to_string());
1223 state.emit(DaemonEvent::PalaceCreated {
1226 id: palace_name.to_string(),
1227 name: palace_name.to_string(),
1228 source: ActivitySource::Mcp,
1229 });
1230 let bootstrap_summary = match crate::bootstrap::bootstrap_palace(state, palace_name, None).await
1238 {
1239 Ok(r) => Some(serde_json::json!({
1240 "triples_asserted": r.triples_asserted,
1241 "project_subject": r.project_subject,
1242 })),
1243 Err(e) => {
1244 tracing::warn!(
1245 palace = %palace_name,
1246 "auto-bootstrap on palace_create failed: {e:#}",
1247 );
1248 None
1249 }
1250 };
1251 Ok(json!({
1252 "palace_id": palace_name,
1253 "status": "created",
1254 "bootstrap": bootstrap_summary,
1255 }))
1256}
1257
1258async fn handle_palace_list(state: &AppState, _args: Value) -> Result<Value> {
1259 let root = state.data_root.clone();
1260 let palaces = tokio::task::spawn_blocking(move || {
1261 trusty_common::memory_core::PalaceRegistry::list_palaces(&root)
1262 })
1263 .await
1264 .context("join list_palaces")??;
1265 let ids: Vec<String> = palaces.iter().map(|p| p.id.as_str().to_string()).collect();
1266 Ok(json!({ "palaces": ids }))
1267}
1268
1269async fn handle_palace_delete(state: &AppState, args: Value) -> Result<Value> {
1270 let palace_id = args
1278 .get("palace_id")
1279 .and_then(|v| v.as_str())
1280 .ok_or_else(|| anyhow!("palace_delete: missing 'palace_id'"))?
1281 .to_string();
1282 let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
1283 use crate::service::{MemoryService, ServiceError};
1284 let svc = MemoryService::new(state.clone());
1285 match svc.delete_palace(&palace_id, force).await {
1286 Ok(()) => Ok(json!({ "deleted": palace_id })),
1287 Err(ServiceError::NotFound(_)) => Err(anyhow!("Palace not found: {palace_id}")),
1288 Err(ServiceError::Conflict(msg)) => Err(anyhow!(msg)),
1289 Err(e) => Err(anyhow!("palace_delete: {e}")),
1290 }
1291}
1292
1293async fn handle_palace_update(state: &AppState, args: Value) -> Result<Value> {
1294 let palace_id = args
1303 .get("palace_id")
1304 .and_then(|v| v.as_str())
1305 .ok_or_else(|| anyhow!("palace_update: missing 'palace_id'"))?
1306 .to_string();
1307 let name = args
1308 .get("name")
1309 .and_then(|v| v.as_str())
1310 .ok_or_else(|| anyhow!("palace_update: missing 'name'"))?
1311 .to_string();
1312 use crate::service::MemoryService;
1313 let svc = MemoryService::new(state.clone());
1314 match svc.update_palace_name(&palace_id, &name).await {
1315 Ok(_info) => Ok(json!({ "updated": palace_id, "name": name.trim() })),
1316 Err(e) => Err(anyhow!("palace_update: {e}")),
1317 }
1318}
1319
1320async fn handle_kg_assert(state: &AppState, args: Value) -> Result<Value> {
1321 let palace = resolve_palace(state, &args, "kg_assert")?;
1322 let palace = palace.as_str();
1323 let subject = args
1324 .get("subject")
1325 .and_then(|v| v.as_str())
1326 .ok_or_else(|| anyhow!("kg_assert: missing 'subject'"))?
1327 .to_string();
1328 let predicate = args
1329 .get("predicate")
1330 .and_then(|v| v.as_str())
1331 .ok_or_else(|| anyhow!("kg_assert: missing 'predicate'"))?
1332 .to_string();
1333 let object = args
1334 .get("object")
1335 .and_then(|v| v.as_str())
1336 .ok_or_else(|| anyhow!("kg_assert: missing 'object'"))?
1337 .to_string();
1338 let confidence = args
1339 .get("confidence")
1340 .and_then(|v| v.as_f64())
1341 .map(|c| (c as f32).clamp(0.0, 1.0))
1342 .unwrap_or(1.0);
1343 let provenance = args
1344 .get("provenance")
1345 .and_then(|v| v.as_str())
1346 .map(|s| s.to_string());
1347
1348 let handle = open_palace_handle(state, palace)?;
1349 let triple = Triple {
1350 subject,
1351 predicate,
1352 object,
1353 valid_from: chrono::Utc::now(),
1354 valid_to: None,
1355 confidence,
1356 provenance,
1357 };
1358 let is_hot = crate::prompt_facts::is_hot_predicate(&triple.predicate);
1359 handle.kg.assert(triple).await.context("kg.assert")?;
1360 if is_hot {
1365 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1366 tracing::warn!("rebuild_prompt_cache after kg_assert failed: {e:#}");
1367 }
1368 }
1369 Ok(json!({ "status": "asserted" }))
1370}
1371
1372async fn handle_add_alias(state: &AppState, args: Value) -> Result<Value> {
1373 let short = args
1374 .get("short")
1375 .and_then(|v| v.as_str())
1376 .ok_or_else(|| anyhow!("add_alias: missing 'short'"))?
1377 .to_string();
1378 let full = args
1379 .get("full")
1380 .and_then(|v| v.as_str())
1381 .ok_or_else(|| anyhow!("add_alias: missing 'full'"))?
1382 .to_string();
1383 let extra = args
1384 .get("extra")
1385 .and_then(|v| v.as_str())
1386 .map(|s| s.to_string());
1387
1388 let palace = resolve_palace(state, &args, "add_alias")?;
1393 let handle = open_palace_handle(state, &palace)?;
1394 let object = match extra.as_deref() {
1396 Some(e) if !e.is_empty() => format!("{full} ({e})"),
1397 _ => full.clone(),
1398 };
1399 let triple = Triple {
1400 subject: short.clone(),
1401 predicate: "is_alias_for".to_string(),
1402 object,
1403 valid_from: chrono::Utc::now(),
1404 valid_to: None,
1405 confidence: 1.0,
1406 provenance: Some("add_alias".to_string()),
1407 };
1408 handle
1409 .kg
1410 .assert(triple)
1411 .await
1412 .context("kg.assert (alias)")?;
1413 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1414 tracing::warn!("rebuild_prompt_cache after add_alias failed: {e:#}");
1415 }
1416 Ok(json!({ "asserted": true, "short": short, "full": full }))
1417}
1418
1419async fn handle_list_prompt_facts(state: &AppState, _args: Value) -> Result<Value> {
1420 let triples = crate::prompt_facts::gather_hot_triples(state).await?;
1421 let payload: Vec<Value> = triples
1422 .into_iter()
1423 .map(|(subject, predicate, object)| {
1424 json!({ "subject": subject, "predicate": predicate, "object": object })
1425 })
1426 .collect();
1427 Ok(json!({ "facts": payload }))
1428}
1429
1430async fn handle_remove_prompt_fact(state: &AppState, args: Value) -> Result<Value> {
1431 let subject = args
1432 .get("subject")
1433 .and_then(|v| v.as_str())
1434 .ok_or_else(|| anyhow!("remove_prompt_fact: missing 'subject'"))?
1435 .to_string();
1436 let predicate = args
1437 .get("predicate")
1438 .and_then(|v| v.as_str())
1439 .ok_or_else(|| anyhow!("remove_prompt_fact: missing 'predicate'"))?
1440 .to_string();
1441
1442 let mut closed_total: usize = 0;
1448 for palace_id in state.registry.list() {
1449 if let Some(handle) = state.registry.get(&palace_id) {
1450 match handle.kg.retract(&subject, &predicate).await {
1451 Ok(n) => closed_total += n,
1452 Err(e) => tracing::warn!(
1453 palace = %palace_id.as_str(),
1454 "retract failed: {e:#}",
1455 ),
1456 }
1457 }
1458 }
1459 if closed_total > 0 {
1460 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1461 tracing::warn!("rebuild_prompt_cache after remove_prompt_fact failed: {e:#}");
1462 }
1463 Ok(json!({ "removed": true, "closed": closed_total }))
1464 } else {
1465 Ok(json!({ "removed": false, "reason": "not found" }))
1466 }
1467}
1468
1469async fn handle_kg_query(state: &AppState, args: Value) -> Result<Value> {
1470 let palace = resolve_palace(state, &args, "kg_query")?;
1471 let subject = args
1472 .get("subject")
1473 .and_then(|v| v.as_str())
1474 .ok_or_else(|| anyhow!("kg_query: missing 'subject'"))?;
1475 let handle = open_palace_handle(state, &palace)?;
1476 let triples = handle
1477 .kg
1478 .query_active(subject)
1479 .await
1480 .context("kg.query_active")?;
1481 let payload: Vec<Value> = triples
1482 .iter()
1483 .map(|t| {
1484 json!({
1485 "subject": t.subject,
1486 "predicate": t.predicate,
1487 "object": t.object,
1488 "valid_from": t.valid_from.to_rfc3339(),
1489 "valid_to": t.valid_to.as_ref().map(|d| d.to_rfc3339()),
1490 "confidence": t.confidence,
1491 "provenance": t.provenance,
1492 })
1493 })
1494 .collect();
1495 let mut response = json!({ "subject": subject, "triples": payload });
1501 if crate::bootstrap::is_kg_empty_for_subject(&triples) {
1502 response["hint"] = Value::String(crate::bootstrap::KG_EMPTY_HINT.to_string());
1503 }
1504 Ok(response)
1505}
1506
1507async fn handle_memory_list(state: &AppState, args: Value) -> Result<Value> {
1508 let palace = resolve_palace(state, &args, "memory_list")?;
1509 let handle = open_palace_handle(state, &palace)?;
1510 let room = args
1511 .get("room")
1512 .and_then(|v| v.as_str())
1513 .map(|s| parse_room(Some(s)));
1514 let tag = args
1515 .get("tag")
1516 .and_then(|v| v.as_str())
1517 .map(|s| s.to_string());
1518 let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
1519 let drawers = handle.list_drawers(room, tag, limit);
1520 let payload: Vec<Value> = drawers
1521 .iter()
1522 .map(|d| {
1523 json!({
1524 "drawer_id": d.id.to_string(),
1525 "content": d.content,
1526 "importance": d.importance,
1527 "tags": d.tags,
1528 "created_at": d.created_at.to_rfc3339(),
1529 "drawer_type": d.drawer_type.as_str(),
1530 "expires_at": d.expires_at.map(|t| t.to_rfc3339()),
1531 })
1532 })
1533 .collect();
1534 Ok(json!({ "palace": palace, "drawers": payload }))
1535}
1536
1537async fn handle_memory_forget(state: &AppState, args: Value) -> Result<Value> {
1538 let palace = resolve_palace(state, &args, "memory_forget")?;
1539 let drawer_id_str = args
1540 .get("drawer_id")
1541 .and_then(|v| v.as_str())
1542 .ok_or_else(|| anyhow!("memory_forget: missing 'drawer_id'"))?;
1543 let drawer_id = Uuid::parse_str(drawer_id_str)
1544 .map_err(|e| anyhow!("memory_forget: invalid drawer_id UUID: {e}"))?;
1545 let handle = open_palace_handle(state, &palace)?;
1546 handle.forget(drawer_id).await.context("forget")?;
1547 let drawer_count = handle.drawers.read().len();
1549 state.emit(DaemonEvent::DrawerDeleted {
1550 palace_id: palace.clone(),
1551 drawer_count,
1552 source: ActivitySource::Mcp,
1553 });
1554 Ok(json!({ "status": "deleted", "drawer_id": drawer_id_str, "palace": palace }))
1557}
1558
1559async fn handle_palace_info(state: &AppState, args: Value) -> Result<Value> {
1560 let palace = resolve_palace(state, &args, "palace_info")?;
1561 let handle = open_palace_handle(state, &palace)?;
1562 let drawer_count = handle.list_drawers(None, None, usize::MAX).len();
1563 let data_dir = handle
1564 .data_dir
1565 .as_ref()
1566 .map(|p| p.to_string_lossy().to_string());
1567 Ok(json!({
1568 "id": handle.id.as_str(),
1569 "name": handle.id.as_str(),
1570 "drawer_count": drawer_count,
1571 "data_dir": data_dir,
1572 }))
1573}
1574
1575async fn handle_palace_compact(state: &AppState, args: Value) -> Result<Value> {
1576 let palace = resolve_palace(state, &args, "palace_compact")?;
1577 let handle = open_palace_handle(state, &palace)?;
1578 let valid_ids: std::collections::HashSet<Uuid> =
1582 handle.drawers.read().iter().map(|d| d.id).collect();
1583 let vector_store = handle.vector_store.clone();
1584 let res = tokio::task::spawn_blocking(move || vector_store.compact_orphans(&valid_ids))
1585 .await
1586 .context("join palace_compact")??;
1587 Ok(json!({
1588 "palace": palace,
1589 "total_checked": res.total_checked,
1590 "orphans_removed": res.orphans_removed,
1591 "index_size_before": res.index_size_before,
1592 "index_size_after": res.index_size_after,
1593 }))
1594}
1595
1596async fn handle_kg_gaps(state: &AppState, args: Value) -> Result<Value> {
1597 let palace = resolve_palace(state, &args, "kg_gaps")?;
1607 let _handle = open_palace_handle(state, &palace)?;
1610 let pid = PalaceId::new(&palace);
1611 let cached = state.registry.get_gaps(&pid).unwrap_or_default();
1612 let payload: Vec<Value> = cached
1613 .into_iter()
1614 .map(|g| {
1615 json!({
1616 "entities": g.entities,
1617 "internal_density": g.internal_density,
1618 "external_bridges": g.external_bridges,
1619 "suggested_exploration": g.suggested_exploration,
1620 })
1621 })
1622 .collect();
1623 Ok(json!({ "palace": palace, "gaps": payload }))
1624}
1625
1626async fn handle_memory_recall_all(state: &AppState, args: Value) -> Result<Value> {
1627 state.readiness_check()?;
1632 let query = args
1633 .get("q")
1634 .and_then(|v| v.as_str())
1635 .ok_or_else(|| anyhow!("memory_recall_all: missing 'q'"))?;
1636 let top_k = args.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
1637 let deep = args.get("deep").and_then(|v| v.as_bool()).unwrap_or(false);
1638
1639 let root = state.data_root.clone();
1643 let palaces = tokio::task::spawn_blocking(move || {
1644 trusty_common::memory_core::PalaceRegistry::list_palaces(&root)
1645 })
1646 .await
1647 .context("join list_palaces")??;
1648
1649 let mut handles = Vec::with_capacity(palaces.len());
1650 for p in &palaces {
1651 match state.registry.open_palace(&state.data_root, &p.id) {
1652 Ok(h) => handles.push(h),
1653 Err(e) => {
1654 tracing::warn!(palace = %p.id, "memory_recall_all: open failed: {e:#}")
1655 }
1656 }
1657 }
1658
1659 let embedder = state.embedder().await?;
1660 let erased: std::sync::Arc<dyn trusty_common::memory_core::embed::Embedder + Send + Sync> =
1661 embedder;
1662 let results = recall_across_palaces(&handles, &erased, query, top_k, deep)
1663 .await
1664 .context("recall_across_palaces")?;
1665
1666 let payload: Vec<Value> = results
1667 .iter()
1668 .map(|r| {
1669 json!({
1670 "palace_id": r.palace_id,
1671 "drawer_id": r.result.drawer.id.to_string(),
1672 "content": r.result.drawer.content,
1673 "importance": r.result.drawer.importance,
1674 "tags": r.result.drawer.tags,
1675 "score": r.result.score,
1676 "layer": r.result.layer,
1677 "drawer_type": r.result.drawer.drawer_type.as_str(),
1678 })
1679 })
1680 .collect();
1681 Ok(json!({ "query": query, "results": payload }))
1682}
1683
1684async fn handle_get_prompt_context(state: &AppState, args: Value) -> Result<Value> {
1685 let query = args
1696 .get("query")
1697 .and_then(|v| v.as_str())
1698 .map(|s| s.trim().to_string())
1699 .filter(|s| !s.is_empty());
1700
1701 let cache_snapshot = {
1705 let guard = state.prompt_context_cache.read().await;
1706 guard.clone()
1707 };
1708
1709 let body = if let Some(q) = query.as_deref() {
1710 let needle = q.to_lowercase();
1711 let filtered: Vec<(String, String, String)> = cache_snapshot
1712 .triples
1713 .into_iter()
1714 .filter(|(subject, _predicate, object)| {
1715 subject.to_lowercase().contains(&needle) || object.to_lowercase().contains(&needle)
1716 })
1717 .collect();
1718 let formatted = crate::prompt_facts::build_prompt_context(&filtered);
1719 if formatted.is_empty() {
1720 "No project context found matching your query.".to_string()
1721 } else {
1722 formatted
1723 }
1724 } else if cache_snapshot.formatted.is_empty() {
1725 "No prompt facts stored yet.".to_string()
1726 } else {
1727 cache_snapshot.formatted
1728 };
1729
1730 Ok(Value::String(body))
1736}
1737
1738async fn handle_discover_aliases(state: &AppState, args: Value) -> Result<Value> {
1739 let palace = resolve_palace(state, &args, "discover_aliases")?;
1750 let project_root = args
1751 .get("project_root")
1752 .and_then(|v| v.as_str())
1753 .map(std::path::PathBuf::from)
1754 .or_else(|| std::env::current_dir().ok())
1755 .ok_or_else(|| anyhow!("discover_aliases: no project_root and cwd unavailable"))?;
1756
1757 let discoveries = crate::discovery::discover_project_aliases(&project_root).await?;
1758
1759 let handle = open_palace_handle(state, &palace)?;
1760
1761 let mut already_known = 0usize;
1762 let mut newly_asserted = 0usize;
1763 let mut reported: Vec<Value> = Vec::with_capacity(discoveries.len());
1764
1765 for d in &discoveries {
1766 let active = handle
1769 .kg
1770 .query_active(&d.short)
1771 .await
1772 .context("kg.query_active")?;
1773 let exists = active
1774 .iter()
1775 .any(|t| t.predicate == "is_alias_for" && t.object == d.full);
1776 if exists {
1777 already_known += 1;
1778 continue;
1779 }
1780
1781 let triple = Triple {
1782 subject: d.short.clone(),
1783 predicate: "is_alias_for".to_string(),
1784 object: d.full.clone(),
1785 valid_from: chrono::Utc::now(),
1786 valid_to: None,
1787 confidence: 1.0,
1788 provenance: Some(format!("discover_aliases:{}", d.source.as_str())),
1789 };
1790 handle
1791 .kg
1792 .assert(triple)
1793 .await
1794 .context("kg.assert (discover)")?;
1795 newly_asserted += 1;
1796 reported.push(json!({
1797 "short": d.short,
1798 "full": d.full,
1799 "source": d.source.as_str(),
1800 }));
1801 }
1802
1803 if newly_asserted > 0 {
1804 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1805 tracing::warn!("rebuild_prompt_cache after discover_aliases failed: {e:#}");
1806 }
1807 }
1808
1809 Ok(json!({
1810 "discovered": reported,
1811 "already_known": already_known,
1812 "new": newly_asserted,
1813 "palace": palace,
1814 }))
1815}
1816
1817async fn handle_kg_bootstrap(state: &AppState, args: Value) -> Result<Value> {
1818 let palace = resolve_palace(state, &args, "kg_bootstrap")?;
1823 let project_path = args
1824 .get("project_path")
1825 .and_then(|v| v.as_str())
1826 .map(std::path::PathBuf::from);
1827 let result = crate::bootstrap::bootstrap_palace(state, &palace, project_path.as_deref())
1828 .await
1829 .context("bootstrap_palace")?;
1830 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1834 tracing::warn!("rebuild_prompt_cache after kg_bootstrap failed: {e:#}");
1835 }
1836 crate::bootstrap::result_to_json(&result)
1837}
1838
1839async fn handle_memory_send_message(state: &AppState, args: Value) -> Result<Value> {
1840 let to_palace = args
1842 .get("to_palace")
1843 .and_then(|v| v.as_str())
1844 .ok_or_else(|| anyhow!("memory_send_message: missing 'to_palace'"))?
1845 .to_string();
1846 let purpose = args
1847 .get("purpose")
1848 .and_then(|v| v.as_str())
1849 .ok_or_else(|| anyhow!("memory_send_message: missing 'purpose'"))?
1850 .to_string();
1851 let content = args
1852 .get("content")
1853 .and_then(|v| v.as_str())
1854 .ok_or_else(|| anyhow!("memory_send_message: missing 'content'"))?
1855 .to_string();
1856 let from_palace = if let Some(s) = args.get("from_palace").and_then(|v| v.as_str()) {
1859 s.to_string()
1860 } else if let Some(d) = state.default_palace.clone() {
1861 d
1862 } else {
1863 crate::messaging::cwd_palace_slug()
1864 .context("memory_send_message: derive from_palace from cwd")?
1865 };
1866 let drawer_id = crate::messaging::send_message_to_palace(
1867 &state.registry,
1868 &state.data_root,
1869 &from_palace,
1870 &to_palace,
1871 &purpose,
1872 content,
1873 CreatorInfo::new_self(MCP_CLIENT_NAME, CreatorSource::Mcp),
1874 )
1875 .await
1876 .context("memory_send_message")?;
1877 Ok(json!({
1878 "drawer_id": drawer_id.to_string(),
1879 "from_palace": from_palace,
1880 "to_palace": to_palace,
1881 "purpose": purpose,
1882 "status": "sent",
1883 }))
1884}
1885
1886pub async fn dispatch_tool(state: &AppState, name: &str, args: Value) -> Result<Value> {
1898 match name {
1899 "memory_remember" => handle_memory_remember(state, args).await,
1900 "memory_note" => handle_memory_note(state, args).await,
1901 "memory_recall" => handle_memory_recall(state, args).await,
1902 "memory_recall_deep" => handle_memory_recall_deep(state, args).await,
1903 "palace_create" => handle_palace_create(state, args).await,
1904 "palace_list" => handle_palace_list(state, args).await,
1905 "palace_delete" => handle_palace_delete(state, args).await,
1906 "palace_update" => handle_palace_update(state, args).await,
1907 "kg_assert" => handle_kg_assert(state, args).await,
1908 "add_alias" => handle_add_alias(state, args).await,
1909 "list_prompt_facts" => handle_list_prompt_facts(state, args).await,
1910 "remove_prompt_fact" => handle_remove_prompt_fact(state, args).await,
1911 "kg_query" => handle_kg_query(state, args).await,
1912 "memory_list" => handle_memory_list(state, args).await,
1913 "memory_forget" => handle_memory_forget(state, args).await,
1914 "palace_info" => handle_palace_info(state, args).await,
1915 "palace_compact" => handle_palace_compact(state, args).await,
1916 "kg_gaps" => handle_kg_gaps(state, args).await,
1917 "memory_recall_all" => handle_memory_recall_all(state, args).await,
1918 "get_prompt_context" => handle_get_prompt_context(state, args).await,
1919 "discover_aliases" => handle_discover_aliases(state, args).await,
1920 "kg_bootstrap" => handle_kg_bootstrap(state, args).await,
1921 "memory_send_message" => handle_memory_send_message(state, args).await,
1922 "upgrade" => handle_upgrade_tool(state, args).await,
1923 other => anyhow::bail!("unknown tool: {other}"),
1924 }
1925}
1926
1927async fn handle_upgrade_tool(state: &AppState, args: Value) -> Result<Value> {
1947 let check = args.get("check").and_then(Value::as_bool).unwrap_or(true);
1948 let confirm = args
1949 .get("confirm")
1950 .and_then(Value::as_bool)
1951 .unwrap_or(false);
1952
1953 let crate_name = env!("CARGO_PKG_NAME");
1954 let current = env!("CARGO_PKG_VERSION");
1955
1956 let info = trusty_common::update::check_crates_io(crate_name, current).await;
1958
1959 let (latest, is_update) = match &info {
1960 Some(u) => (u.latest.as_str(), true),
1961 None => (current, false),
1962 };
1963
1964 if check || !confirm {
1965 let msg = if is_update {
1966 format!(
1967 "Update available: {crate_name} {latest} (you have {current}). \
1968 Call with confirm=true to install."
1969 )
1970 } else {
1971 format!("{crate_name} {current} is already up to date.")
1972 };
1973 return Ok(
1974 serde_json::json!({ "status": "checked", "current": current, "latest": latest, "update_available": is_update, "message": msg }),
1975 );
1976 }
1977
1978 if !is_update {
1984 return Ok(serde_json::json!({
1985 "status": "up_to_date",
1986 "current": current,
1987 "message": format!("{crate_name} {current} is already up to date — nothing to install.")
1988 }));
1989 }
1990
1991 let upgrade_state = state.update_available.clone();
1992 let latest_owned = latest.to_string();
1993 let crate_name_owned = crate_name.to_string();
1994 let response = serde_json::json!({
1995 "status": "installing",
1996 "current": current,
1997 "latest": latest_owned,
1998 "message": format!(
1999 "Installing {crate_name} {latest_owned} — daemon will restart automatically \
2000 under launchd, or you will be prompted to restart manually."
2001 )
2002 });
2003
2004 tokio::spawn(async move {
2007 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
2009 match trusty_common::update::upgrade_and_restart(&crate_name_owned, &crate_name_owned).await
2010 {
2011 Ok(Some(hint)) => {
2012 tracing::info!("{hint}");
2013 eprintln!("{hint}");
2014 }
2015 Ok(None) => {}
2016 Err(e) => {
2017 tracing::error!("upgrade_and_restart failed: {e:#}");
2018 eprintln!("[trusty-memory] upgrade failed: {e:#}");
2019 if let Ok(mut g) = upgrade_state.lock() {
2022 *g = None;
2023 }
2024 }
2025 }
2026 });
2027
2028 Ok(response)
2029}
2030
2031fn bm25_data_dir_for_palace(state: &AppState, palace: &str) -> std::path::PathBuf {
2044 state.data_root.join(palace).join("bm25")
2045}
2046
2047async fn ensure_bm25_running_for_palace(state: &AppState, palace: &str) -> bool {
2064 let Some(supervisor) = state.bm25_supervisor.as_ref() else {
2065 return true;
2068 };
2069 let data_dir = bm25_data_dir_for_palace(state, palace);
2070 match supervisor.ensure_running(palace, &data_dir).await {
2071 Ok(_socket) => true,
2072 Err(e) => {
2073 tracing::warn!(
2074 palace = %palace,
2075 "bm25 supervisor could not start daemon (degrading to vector-only): {e:#}"
2076 );
2077 false
2078 }
2079 }
2080}
2081
2082pub const BM25_INDEX_QUEUE_CAPACITY: usize = 256;
2098
2099#[derive(Debug)]
2112pub struct Bm25IndexRequest {
2113 pub palace: String,
2115 pub drawer_id: String,
2117 pub content: String,
2119 pub data_dir: std::path::PathBuf,
2123}
2124
2125pub fn spawn_bm25_index_worker(
2146 mut rx: tokio::sync::mpsc::Receiver<Bm25IndexRequest>,
2147 client: Option<std::sync::Arc<trusty_common::bm25_client::Bm25Client>>,
2148 supervisor: Option<std::sync::Arc<crate::bm25_supervisor::Bm25Supervisor>>,
2149) {
2150 tokio::spawn(async move {
2151 while let Some(req) = rx.recv().await {
2152 let Some(client) = client.as_ref() else {
2155 continue;
2156 };
2157 if let Some(sup) = supervisor.as_ref() {
2161 if let Err(e) = sup.ensure_running(&req.palace, &req.data_dir).await {
2162 tracing::warn!(
2163 palace = %req.palace,
2164 "bm25 supervisor failed to start daemon for index (non-fatal): {e:#}"
2165 );
2166 continue;
2167 }
2168 }
2169 if let Err(e) = client.index(&req.drawer_id, &req.content).await {
2170 tracing::warn!(
2171 palace = %req.palace,
2172 drawer_id = %req.drawer_id,
2173 "bm25 daemon index failed (non-fatal): {e:#}"
2174 );
2175 }
2176 }
2177 tracing::debug!("bm25 index worker exiting (channel closed)");
2178 });
2179}
2180
2181fn bm25_index_enqueue(state: &AppState, palace: &str, drawer_id: Uuid, content: &str) {
2198 let req = Bm25IndexRequest {
2199 palace: palace.to_string(),
2200 drawer_id: drawer_id.to_string(),
2201 content: content.to_string(),
2202 data_dir: bm25_data_dir_for_palace(state, palace),
2203 };
2204 match state.bm25_index_tx.try_send(req) {
2205 Ok(()) => {}
2206 Err(tokio::sync::mpsc::error::TrySendError::Full(req)) => {
2207 tracing::warn!(
2208 palace = %req.palace,
2209 drawer_id = %req.drawer_id,
2210 "BM25 index queue full — skipping drawer {}",
2211 req.drawer_id
2212 );
2213 }
2214 Err(tokio::sync::mpsc::error::TrySendError::Closed(req)) => {
2215 tracing::debug!(
2216 palace = %req.palace,
2217 drawer_id = %req.drawer_id,
2218 "BM25 index queue closed — skipping drawer {}",
2219 req.drawer_id
2220 );
2221 }
2222 }
2223}
2224
2225async fn bm25_search_optional(
2239 state: &AppState,
2240 palace: &str,
2241 query: &str,
2242 top_k: usize,
2243) -> Option<Vec<trusty_common::bm25_client::BM25Hit>> {
2244 let client = state.bm25_client.as_ref()?;
2245 if !ensure_bm25_running_for_palace(state, palace).await {
2249 return None;
2250 }
2251 match client.search(query, top_k).await {
2252 Ok(hits) => Some(hits),
2253 Err(e) => {
2254 tracing::warn!(
2255 palace = %palace,
2256 "bm25 daemon search failed (falling back to vector-only): {e:#}"
2257 );
2258 None
2259 }
2260 }
2261}
2262
2263fn fuse_bm25_into_recall(
2278 results: &mut Vec<trusty_common::memory_core::retrieval::RecallResult>,
2279 bm25_hits: &[trusty_common::bm25_client::BM25Hit],
2280 top_k: usize,
2281) {
2282 const RRF_K: f32 = 60.0;
2285 if bm25_hits.is_empty() {
2286 return;
2287 }
2288 for (rank, hit) in bm25_hits.iter().enumerate() {
2290 let bonus = 1.0 / (RRF_K + rank as f32 + 1.0);
2291 if let Some(existing) = results
2292 .iter_mut()
2293 .find(|r| r.drawer.id.to_string() == hit.doc_id)
2294 {
2295 existing.score += bonus;
2296 }
2297 }
2305 results.sort_by(|a, b| {
2308 b.score
2309 .partial_cmp(&a.score)
2310 .unwrap_or(std::cmp::Ordering::Equal)
2311 .then(a.layer.cmp(&b.layer))
2312 });
2313 results.truncate(top_k);
2314}
2315
2316fn serialize_recall(
2318 palace: &str,
2319 query: &str,
2320 results: Vec<trusty_common::memory_core::retrieval::RecallResult>,
2321) -> Value {
2322 let payload: Vec<Value> = results
2323 .iter()
2324 .map(|r| {
2325 json!({
2326 "drawer_id": r.drawer.id.to_string(),
2327 "content": r.drawer.content,
2328 "score": r.score,
2329 "layer": r.layer,
2330 "tags": r.drawer.tags,
2331 "importance": r.drawer.importance,
2332 "drawer_type": r.drawer.drawer_type.as_str(),
2333 })
2334 })
2335 .collect();
2336 json!({
2337 "palace": palace,
2338 "query": query,
2339 "results": payload,
2340 })
2341}
2342
2343#[cfg(test)]
2344mod tests {
2345 use super::*;
2346 use crate::AppState;
2347
2348 fn test_state() -> (AppState, tempfile::TempDir) {
2363 unsafe {
2370 std::env::set_var("TRUSTY_SKIP_PALACE_ENFORCEMENT", "1");
2371 }
2372 let tmp = tempfile::tempdir().expect("tempdir");
2373 let root = tmp.path().to_path_buf();
2374 let state = AppState::new(root);
2375 state.set_ready();
2378 (state, tmp)
2379 }
2380
2381 fn test_state_warming() -> (crate::AppState, tempfile::TempDir) {
2388 static SKIP_ENFORCEMENT_SET: std::sync::OnceLock<()> = std::sync::OnceLock::new();
2392 SKIP_ENFORCEMENT_SET.get_or_init(|| unsafe {
2393 std::env::set_var("TRUSTY_SKIP_PALACE_ENFORCEMENT", "1");
2394 });
2395 let tmp = tempfile::tempdir().expect("tempdir");
2396 let root = tmp.path().to_path_buf();
2397 let state = crate::AppState::new(root);
2398 (state, tmp)
2400 }
2401
2402 #[test]
2407 fn tool_definitions_drops_palace_required_when_default_set() {
2408 let with_default = tool_definitions_with(true);
2409 let without_default = tool_definitions_with(false);
2410 for (name, palace_required_when_no_default) in [
2411 ("memory_remember", true),
2412 ("memory_recall", true),
2413 ("memory_recall_deep", true),
2414 ("memory_list", true),
2415 ("memory_forget", true),
2416 ("palace_info", true),
2417 ("palace_compact", true),
2418 ("kg_assert", true),
2419 ("kg_query", true),
2420 ("add_alias", true),
2423 ("discover_aliases", true),
2424 ] {
2425 for (defs, has_default) in [(&with_default, true), (&without_default, false)] {
2426 let tools = defs["tools"].as_array().unwrap();
2427 let tool = tools.iter().find(|t| t["name"] == name).unwrap();
2428 let required: Vec<&str> = tool["inputSchema"]["required"]
2429 .as_array()
2430 .unwrap()
2431 .iter()
2432 .filter_map(|v| v.as_str())
2433 .collect();
2434 let palace_required = required.contains(&"palace");
2435 let expected = palace_required_when_no_default && !has_default;
2436 assert_eq!(
2437 palace_required, expected,
2438 "tool={name} has_default={has_default} required={required:?}"
2439 );
2440 }
2441 }
2442 }
2443
2444 #[test]
2445 fn tool_definitions_lists_all_tools() {
2446 let defs = tool_definitions();
2447 let tools = defs
2448 .get("tools")
2449 .and_then(|t| t.as_array())
2450 .expect("tools array");
2451 assert_eq!(tools.len(), 24);
2452 let names: Vec<&str> = tools
2453 .iter()
2454 .filter_map(|t| t.get("name").and_then(|n| n.as_str()))
2455 .collect();
2456 for expected in [
2457 "memory_remember",
2458 "memory_note",
2459 "memory_recall",
2460 "memory_recall_deep",
2461 "memory_list",
2462 "memory_forget",
2463 "palace_create",
2464 "palace_delete",
2465 "palace_update",
2466 "palace_list",
2467 "palace_info",
2468 "palace_compact",
2469 "kg_assert",
2470 "kg_query",
2471 "memory_recall_all",
2472 "kg_gaps",
2473 "add_alias",
2474 "list_prompt_facts",
2475 "remove_prompt_fact",
2476 "get_prompt_context",
2477 "discover_aliases",
2478 "kg_bootstrap",
2479 "memory_send_message",
2480 "upgrade",
2481 ] {
2482 assert!(names.contains(&expected), "missing tool: {expected}");
2483 }
2484 }
2485
2486 #[tokio::test]
2489 async fn dispatch_palace_create_persists() {
2490 let (state, _tmp) = test_state();
2491 let created = dispatch_tool(&state, "palace_create", json!({"name": "alpha"}))
2492 .await
2493 .expect("palace_create");
2494 assert_eq!(created["palace_id"], "alpha");
2495
2496 let listed = dispatch_tool(&state, "palace_list", json!({}))
2497 .await
2498 .expect("palace_list");
2499 let ids = listed["palaces"].as_array().expect("palaces array");
2500 assert!(ids.iter().any(|v| v.as_str() == Some("alpha")));
2501 }
2502
2503 #[tokio::test]
2506 async fn dispatch_remember_then_recall() {
2507 let (state, _tmp) = test_state();
2508 let _ = dispatch_tool(&state, "palace_create", json!({"name": "beta"}))
2509 .await
2510 .expect("palace_create");
2511
2512 let remembered = dispatch_tool(
2513 &state,
2514 "memory_remember",
2515 json!({
2516 "palace": "beta",
2517 "text": "Quokkas are the happiest marsupials in Australia by general consensus",
2518 "room": "General",
2519 "tags": ["wildlife"],
2520 }),
2521 )
2522 .await
2523 .expect("memory_remember");
2524 assert!(remembered["drawer_id"].as_str().is_some());
2525
2526 let recalled = dispatch_tool(
2527 &state,
2528 "memory_recall",
2529 json!({"palace": "beta", "query": "Quokkas marsupials Australia", "top_k": 5}),
2530 )
2531 .await
2532 .expect("memory_recall");
2533 let results = recalled["results"].as_array().expect("results");
2534 assert!(
2535 results
2536 .iter()
2537 .any(|r| r["content"].as_str().unwrap_or("").contains("Quokkas")),
2538 "expected to recall the Quokkas drawer; got {results:?}"
2539 );
2540 }
2541
2542 #[tokio::test]
2551 async fn auto_kg_extraction_hooks_into_memory_remember() {
2552 let (state, _tmp) = test_state();
2553 let _ = dispatch_tool(&state, "palace_create", json!({"name": "kgauto"}))
2554 .await
2555 .expect("palace_create");
2556
2557 let _ = dispatch_tool(
2558 &state,
2559 "memory_remember",
2560 json!({
2561 "palace": "kgauto",
2562 "text": "Rustc is a compiler for the Rust language; tracks #performance",
2563 "room": "Backend",
2564 "tags": ["compiler", "language"],
2565 }),
2566 )
2567 .await
2568 .expect("memory_remember");
2569
2570 let handle = open_palace_handle(&state, "kgauto").expect("open palace");
2571 let triples = handle.kg.list_active(1000, 0).await.expect("list_active");
2572 let auto: Vec<_> = triples
2573 .iter()
2574 .filter(|t| t.provenance.as_deref() == Some(crate::kg_extract::AUTO_PROVENANCE))
2575 .collect();
2576 assert!(
2577 !auto.is_empty(),
2578 "expected at least one auto-extracted triple after memory_remember; got: {triples:?}"
2579 );
2580 assert!(
2584 auto.iter()
2585 .any(|t| t.subject == "tag:compiler" && t.predicate == "tags"),
2586 "expected tag:compiler edge in auto subset: {auto:?}"
2587 );
2588 assert!(
2589 auto.iter()
2590 .any(|t| t.subject == "tag:language" && t.predicate == "tags"),
2591 "expected tag:language edge in auto subset: {auto:?}"
2592 );
2593 assert!(
2594 auto.iter()
2595 .any(|t| t.subject == "room:Backend" && t.predicate == "contains"),
2596 "expected room:Backend edge in auto subset: {auto:?}"
2597 );
2598 assert!(
2599 auto.iter().any(|t| t.predicate == "mentioned-in"),
2600 "expected at least one #hashtag mention triple in auto subset: {auto:?}"
2601 );
2602 }
2603
2604 #[tokio::test]
2616 async fn auto_kg_extraction_no_op_does_not_fail_remember() {
2617 let (state, _tmp) = test_state();
2618 let _ = dispatch_tool(&state, "palace_create", json!({"name": "kgnoop"}))
2619 .await
2620 .expect("palace_create");
2621
2622 let res = dispatch_tool(
2623 &state,
2624 "memory_remember",
2625 json!({
2626 "palace": "kgnoop",
2627 "text": "The quick brown fox jumped over the lazy dog repeatedly",
2630 }),
2631 )
2632 .await
2633 .expect("memory_remember should succeed even when extraction yields nothing");
2634 assert!(res["drawer_id"].as_str().is_some());
2635 }
2636
2637 #[tokio::test]
2640 async fn dispatch_kg_assert_then_query() {
2641 let (state, _tmp) = test_state();
2642 let _ = dispatch_tool(&state, "palace_create", json!({"name": "gamma"}))
2643 .await
2644 .expect("palace_create");
2645
2646 let _ = dispatch_tool(
2647 &state,
2648 "kg_assert",
2649 json!({
2650 "palace": "gamma",
2651 "subject": "alice",
2652 "predicate": "works_at",
2653 "object": "Acme",
2654 "confidence": 0.9,
2655 "provenance": "test",
2656 }),
2657 )
2658 .await
2659 .expect("kg_assert");
2660
2661 let queried = dispatch_tool(
2662 &state,
2663 "kg_query",
2664 json!({"palace": "gamma", "subject": "alice"}),
2665 )
2666 .await
2667 .expect("kg_query");
2668 let triples = queried["triples"].as_array().expect("triples array");
2669 assert_eq!(triples.len(), 1);
2670 assert_eq!(triples[0]["object"], "Acme");
2671 assert_eq!(triples[0]["predicate"], "works_at");
2672 }
2673
2674 #[tokio::test]
2682 async fn dispatch_kg_gaps_returns_cached() {
2683 use trusty_common::memory_core::community::KnowledgeGap;
2684
2685 let (state, _tmp) = test_state();
2686 let _ = dispatch_tool(&state, "palace_create", json!({"name": "delta"}))
2687 .await
2688 .expect("palace_create");
2689
2690 let initial = dispatch_tool(&state, "kg_gaps", json!({"palace": "delta"}))
2692 .await
2693 .expect("kg_gaps empty");
2694 let gaps = initial["gaps"].as_array().expect("gaps array");
2695 assert_eq!(gaps.len(), 0);
2696
2697 state.registry.set_gaps(
2699 PalaceId::new("delta"),
2700 vec![KnowledgeGap {
2701 entities: vec!["x".to_string(), "y".to_string()],
2702 internal_density: 0.05,
2703 external_bridges: 0,
2704 suggested_exploration: "Explore connections between x and y".to_string(),
2705 }],
2706 );
2707 let seeded = dispatch_tool(&state, "kg_gaps", json!({"palace": "delta"}))
2708 .await
2709 .expect("kg_gaps seeded");
2710 let gaps = seeded["gaps"].as_array().expect("gaps array");
2711 assert_eq!(gaps.len(), 1);
2712 assert_eq!(gaps[0]["entities"][0], "x");
2713 assert_eq!(gaps[0]["external_bridges"], 0);
2714 assert!(gaps[0]["suggested_exploration"]
2715 .as_str()
2716 .unwrap()
2717 .contains("x"));
2718 }
2719
2720 #[tokio::test]
2725 async fn add_alias_round_trip_through_prompt_cache() {
2726 let _tmp = tempfile::tempdir().expect("tempdir");
2729 let root = _tmp.path().to_path_buf();
2730 let state = AppState::new(root).with_default_palace(Some("ctx".to_string()));
2731
2732 let _ = dispatch_tool(&state, "palace_create", json!({"name": "ctx"}))
2734 .await
2735 .expect("palace_create");
2736
2737 let added = dispatch_tool(
2739 &state,
2740 "add_alias",
2741 json!({"short": "tga", "full": "trusty-git-analytics"}),
2742 )
2743 .await
2744 .expect("add_alias");
2745 assert_eq!(added["asserted"], true);
2746 assert_eq!(added["short"], "tga");
2747
2748 let listed = dispatch_tool(&state, "list_prompt_facts", json!({}))
2750 .await
2751 .expect("list_prompt_facts");
2752 let facts = listed["facts"].as_array().expect("facts array");
2753 assert!(
2754 facts.iter().any(|f| f["subject"] == "tga"
2755 && f["predicate"] == "is_alias_for"
2756 && f["object"] == "trusty-git-analytics"),
2757 "expected tga alias in facts; got {facts:?}"
2758 );
2759
2760 {
2762 let guard = state.prompt_context_cache.read().await;
2763 assert!(
2764 guard.formatted.contains("tga → trusty-git-analytics"),
2765 "prompt cache should contain alias; got: {}",
2766 guard.formatted
2767 );
2768 }
2769
2770 let _ = dispatch_tool(
2772 &state,
2773 "add_alias",
2774 json!({"short": "tm", "full": "trusty-memory", "extra": "the MCP frontend"}),
2775 )
2776 .await
2777 .expect("add_alias with extra");
2778 {
2779 let guard = state.prompt_context_cache.read().await;
2780 assert!(
2781 guard
2782 .formatted
2783 .contains("tm → trusty-memory (the MCP frontend)"),
2784 "alias with extra not formatted; got: {}",
2785 guard.formatted
2786 );
2787 }
2788
2789 let removed = dispatch_tool(
2791 &state,
2792 "remove_prompt_fact",
2793 json!({"subject": "tga", "predicate": "is_alias_for"}),
2794 )
2795 .await
2796 .expect("remove_prompt_fact");
2797 assert_eq!(removed["removed"], true);
2798 {
2799 let guard = state.prompt_context_cache.read().await;
2800 assert!(
2801 !guard.formatted.contains("tga → trusty-git-analytics"),
2802 "retracted alias still in cache: {}",
2803 guard.formatted
2804 );
2805 assert!(
2806 guard.formatted.contains("tm → trusty-memory"),
2807 "non-retracted alias missing from cache: {}",
2808 guard.formatted
2809 );
2810 }
2811
2812 let missing = dispatch_tool(
2814 &state,
2815 "remove_prompt_fact",
2816 json!({"subject": "nope", "predicate": "is_alias_for"}),
2817 )
2818 .await
2819 .expect("remove_prompt_fact missing");
2820 assert_eq!(missing["removed"], false);
2821 }
2822
2823 #[tokio::test]
2831 async fn add_alias_palace_arg_required_without_server_default() {
2832 let (state, _tmp) = test_state();
2835 dispatch_tool(&state, "palace_create", json!({"name": "p"}))
2836 .await
2837 .expect("palace_create");
2838 let added = dispatch_tool(
2839 &state,
2840 "add_alias",
2841 json!({"palace": "p", "short": "tga", "full": "trusty-git-analytics"}),
2842 )
2843 .await
2844 .expect("add_alias with explicit palace");
2845 assert_eq!(added["asserted"], true);
2846 let guard = state.prompt_context_cache.read().await;
2847 assert!(guard.formatted.contains("tga → trusty-git-analytics"));
2848
2849 drop(guard);
2851 let (state2, _tmp2) = test_state();
2852 let err = dispatch_tool(&state2, "add_alias", json!({"short": "x", "full": "y"}))
2853 .await
2854 .expect_err("should fail without palace");
2855 let msg = format!("{err:#}");
2856 assert!(msg.contains("palace"), "error must mention 'palace': {msg}");
2857 assert!(msg.contains("add_alias"), "error must name tool: {msg}");
2858 }
2859
2860 #[tokio::test]
2865 async fn get_prompt_context_serves_cache_and_filters() {
2866 let (state, _tmp) = test_state();
2867
2868 let resp = dispatch_tool(&state, "get_prompt_context", json!({}))
2870 .await
2871 .expect("get_prompt_context empty");
2872 assert_eq!(resp.as_str().unwrap(), "No prompt facts stored yet.");
2873
2874 {
2876 let mut guard = state.prompt_context_cache.write().await;
2877 let triples = vec![
2878 (
2879 "tga".to_string(),
2880 "is_alias_for".to_string(),
2881 "trusty-git-analytics".to_string(),
2882 ),
2883 (
2884 "tm".to_string(),
2885 "is_alias_for".to_string(),
2886 "trusty-memory".to_string(),
2887 ),
2888 (
2889 "fact-1".to_string(),
2890 "is_fact".to_string(),
2891 "MSRV is 1.88".to_string(),
2892 ),
2893 ];
2894 let formatted = crate::prompt_facts::build_prompt_context(&triples);
2895 *guard = crate::prompt_facts::PromptFactsCache { triples, formatted };
2896 }
2897
2898 let resp = dispatch_tool(&state, "get_prompt_context", json!({}))
2900 .await
2901 .expect("get_prompt_context populated");
2902 let text = resp.as_str().expect("string body");
2903 assert!(text.contains("tga → trusty-git-analytics"));
2904 assert!(text.contains("tm → trusty-memory"));
2905 assert!(text.contains("MSRV is 1.88"));
2906
2907 let resp = dispatch_tool(&state, "get_prompt_context", json!({"query": "tga"}))
2909 .await
2910 .expect("get_prompt_context filtered");
2911 let text = resp.as_str().expect("string body");
2912 assert!(text.contains("tga → trusty-git-analytics"));
2913 assert!(!text.contains("tm → trusty-memory"));
2914 assert!(!text.contains("MSRV is 1.88"));
2915
2916 let resp = dispatch_tool(&state, "get_prompt_context", json!({"query": "MEMORY"}))
2918 .await
2919 .expect("get_prompt_context case-insensitive");
2920 let text = resp.as_str().expect("string body");
2921 assert!(text.contains("tm → trusty-memory"));
2922 assert!(!text.contains("tga → trusty-git-analytics"));
2923
2924 let resp = dispatch_tool(
2926 &state,
2927 "get_prompt_context",
2928 json!({"query": "zzz-nonexistent"}),
2929 )
2930 .await
2931 .expect("get_prompt_context no-match");
2932 assert_eq!(
2933 resp.as_str().unwrap(),
2934 "No project context found matching your query."
2935 );
2936
2937 let resp = dispatch_tool(&state, "get_prompt_context", json!({"query": " "}))
2939 .await
2940 .expect("get_prompt_context whitespace");
2941 let text = resp.as_str().expect("string body");
2942 assert!(text.contains("tga → trusty-git-analytics"));
2943 assert!(text.contains("tm → trusty-memory"));
2944 }
2945
2946 #[tokio::test]
2953 async fn dispatch_discover_aliases_inserts_new_and_dedupes() {
2954 let _tmp = tempfile::tempdir().expect("tempdir");
2957 let root = _tmp.path().to_path_buf();
2958 let state = AppState::new(root).with_default_palace(Some("disc".to_string()));
2959 let _ = dispatch_tool(&state, "palace_create", json!({"name": "disc"}))
2960 .await
2961 .expect("palace_create");
2962
2963 let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2967 .parent()
2968 .and_then(|p| p.parent())
2969 .expect("workspace root")
2970 .to_path_buf();
2971
2972 let first = dispatch_tool(
2973 &state,
2974 "discover_aliases",
2975 json!({"project_root": workspace_root.to_string_lossy()}),
2976 )
2977 .await
2978 .expect("discover_aliases first");
2979
2980 let new_count = first["new"].as_u64().expect("new is u64");
2981 assert!(new_count > 0, "expected new discoveries on first call");
2982 let discovered = first["discovered"].as_array().expect("discovered array");
2983 assert!(
2984 discovered
2985 .iter()
2986 .any(|d| d["short"] == "tga" && d["full"] == "trusty-git-analytics"),
2987 "expected tga alias in discoveries; got {discovered:?}"
2988 );
2989
2990 {
2992 let guard = state.prompt_context_cache.read().await;
2993 assert!(
2994 guard.formatted.contains("tga → trusty-git-analytics"),
2995 "prompt cache missing tga alias after discover_aliases; got: {}",
2996 guard.formatted
2997 );
2998 }
2999
3000 let second = dispatch_tool(
3003 &state,
3004 "discover_aliases",
3005 json!({"project_root": workspace_root.to_string_lossy()}),
3006 )
3007 .await
3008 .expect("discover_aliases second");
3009 assert_eq!(second["new"].as_u64(), Some(0), "expected 0 new on rerun");
3010 let already_known = second["already_known"].as_u64().expect("already_known");
3011 assert!(
3012 already_known >= new_count,
3013 "expected already_known >= {new_count}, got {already_known}"
3014 );
3015 }
3016
3017 #[tokio::test]
3024 async fn palace_create_auto_seeds_temporal_metadata() {
3025 let (state, _tmp) = test_state();
3026 let created = dispatch_tool(&state, "palace_create", json!({"name": "auto"}))
3027 .await
3028 .expect("palace_create");
3029 assert_eq!(created["palace_id"], "auto");
3030 let summary = &created["bootstrap"];
3032 assert!(summary.is_object(), "expected bootstrap summary object");
3033 assert!(summary["triples_asserted"].as_u64().unwrap_or(0) >= 2);
3034
3035 let queried = dispatch_tool(
3036 &state,
3037 "kg_query",
3038 json!({"palace": "auto", "subject": "auto"}),
3039 )
3040 .await
3041 .expect("kg_query");
3042 let triples = queried["triples"].as_array().expect("triples");
3043 let predicates: Vec<&str> = triples
3044 .iter()
3045 .filter_map(|t| t["predicate"].as_str())
3046 .collect();
3047 assert!(
3048 predicates.contains(&"created_at"),
3049 "expected created_at after palace_create; got {predicates:?}",
3050 );
3051 assert!(
3052 predicates.contains(&"bootstrapped_at"),
3053 "expected bootstrapped_at after palace_create; got {predicates:?}",
3054 );
3055 assert!(
3057 queried.get("hint").is_none(),
3058 "hint should be absent when triples exist"
3059 );
3060 }
3061
3062 #[tokio::test]
3067 async fn kg_query_emits_hint_when_palace_empty() {
3068 let (state, _tmp) = test_state();
3069 let _ = dispatch_tool(&state, "palace_create", json!({"name": "hinted"}))
3070 .await
3071 .expect("palace_create");
3072 let queried = dispatch_tool(
3074 &state,
3075 "kg_query",
3076 json!({"palace": "hinted", "subject": "unrelated-subject"}),
3077 )
3078 .await
3079 .expect("kg_query");
3080 assert_eq!(queried["triples"].as_array().unwrap().len(), 0);
3081 let hint = queried["hint"].as_str().expect("hint field present");
3082 assert!(hint.contains("kg_bootstrap"));
3083 assert!(hint.contains("kg_assert"));
3084 }
3085
3086 #[tokio::test]
3090 async fn kg_bootstrap_seeds_workspace_facts() {
3091 let (state, _tmp) = test_state();
3092 let _ = dispatch_tool(&state, "palace_create", json!({"name": "ws"}))
3093 .await
3094 .expect("palace_create");
3095
3096 let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3097 .parent()
3098 .and_then(|p| p.parent())
3099 .expect("workspace root")
3100 .to_path_buf();
3101
3102 let result = dispatch_tool(
3103 &state,
3104 "kg_bootstrap",
3105 json!({"palace": "ws", "project_path": workspace_root.to_string_lossy()}),
3106 )
3107 .await
3108 .expect("kg_bootstrap");
3109 assert!(result["triples_asserted"].as_u64().unwrap() > 0);
3110 let subject = result["project_subject"]
3111 .as_str()
3112 .expect("project_subject")
3113 .to_string();
3114
3115 let queried = dispatch_tool(
3117 &state,
3118 "kg_query",
3119 json!({"palace": "ws", "subject": subject}),
3120 )
3121 .await
3122 .expect("kg_query");
3123 let triples = queried["triples"].as_array().expect("triples");
3124 let predicates: Vec<&str> = triples
3125 .iter()
3126 .filter_map(|t| t["predicate"].as_str())
3127 .collect();
3128 assert!(
3132 predicates.contains(&"has_workspace_member") || predicates.contains(&"has_language"),
3133 "expected workspace/language fact; got {predicates:?}",
3134 );
3135 assert!(
3137 predicates.contains(&"source_repo"),
3138 "expected source_repo from .git/config; got {predicates:?}",
3139 );
3140 assert!(predicates.contains(&"bootstrapped_at"));
3142 }
3143
3144 #[test]
3153 fn content_gate_blocks_short_no_context() {
3154 assert_eq!(content_gate("yes", None), None);
3155 assert_eq!(content_gate("ok", None), None);
3156 assert_eq!(
3157 content_gate(" no thanks ", None),
3158 None,
3159 "2 words still < 4"
3160 );
3161 assert_eq!(
3162 content_gate("one two three", None),
3163 None,
3164 "3 words still < 4"
3165 );
3166 }
3167
3168 #[test]
3174 fn content_gate_wraps_short_with_context() {
3175 let combined = content_gate(
3176 "yes",
3177 Some("Do you want to enable auto-bootstrap on new palaces?"),
3178 )
3179 .expect("context should unlock the gate");
3180 assert_eq!(
3181 combined,
3182 "Do you want to enable auto-bootstrap on new palaces?\n\n---\n\nyes",
3183 );
3184 let combined = content_gate(
3187 "the quick brown fox jumps over the lazy dog",
3188 Some("Famous typing pangram"),
3189 )
3190 .expect("long content + context still combines");
3191 assert!(combined.starts_with("Famous typing pangram"));
3192 assert!(combined.contains("\n\n---\n\n"));
3193 assert!(combined.ends_with("the quick brown fox jumps over the lazy dog"));
3194 }
3195
3196 #[test]
3203 fn content_gate_keeps_long() {
3204 let body = "User prefers snake_case for python";
3205 let kept = content_gate(body, None).expect(">= 4 words passes");
3206 assert_eq!(kept, body, "passing content must round-trip verbatim");
3207 let boundary = "one two three four";
3209 assert_eq!(content_gate(boundary, None).as_deref(), Some(boundary));
3210 }
3211
3212 #[test]
3219 fn content_gate_blank_context_treated_as_none() {
3220 assert_eq!(content_gate("yes", Some("")), None);
3221 assert_eq!(content_gate("yes", Some(" ")), None);
3222 assert_eq!(content_gate("yes", Some("\n\t")), None);
3223 }
3224
3225 #[tokio::test]
3231 async fn dispatch_remember_skips_short_no_context() {
3232 let (state, _tmp) = test_state();
3233 let _ = dispatch_tool(&state, "palace_create", json!({"name": "gate"}))
3234 .await
3235 .expect("palace_create");
3236
3237 let res = dispatch_tool(
3238 &state,
3239 "memory_remember",
3240 json!({"palace": "gate", "text": "yes"}),
3241 )
3242 .await
3243 .expect("memory_remember (short)");
3244 assert_eq!(res["status"], "skipped");
3245 assert!(res["reason"]
3246 .as_str()
3247 .unwrap_or("")
3248 .contains("content gate"));
3249 let listed = dispatch_tool(
3251 &state,
3252 "memory_list",
3253 json!({"palace": "gate", "limit": 10}),
3254 )
3255 .await
3256 .expect("memory_list");
3257 let drawers = listed["drawers"].as_array().expect("drawers array");
3258 assert!(
3259 drawers.is_empty(),
3260 "no drawer should be written; got {drawers:?}"
3261 );
3262 }
3263
3264 #[tokio::test]
3272 async fn dispatch_remember_with_context_writes_combined() {
3273 let (state, _tmp) = test_state();
3274 let _ = dispatch_tool(&state, "palace_create", json!({"name": "ctxgate"}))
3275 .await
3276 .expect("palace_create");
3277
3278 let res = dispatch_tool(
3279 &state,
3280 "memory_remember",
3281 json!({
3282 "palace": "ctxgate",
3283 "text": "yes",
3284 "context": "Do you want to enable auto-bootstrap on new palaces?",
3285 "force": true,
3286 }),
3287 )
3288 .await
3289 .expect("memory_remember (with context)");
3290 assert_eq!(res["status"], "stored");
3291
3292 let listed = dispatch_tool(
3293 &state,
3294 "memory_list",
3295 json!({"palace": "ctxgate", "limit": 10}),
3296 )
3297 .await
3298 .expect("memory_list");
3299 let drawers = listed["drawers"].as_array().expect("drawers array");
3300 assert_eq!(drawers.len(), 1);
3301 let body = drawers[0]["content"].as_str().expect("content");
3302 assert!(body.starts_with("Do you want to enable auto-bootstrap"));
3303 assert!(body.contains("\n\n---\n\n"));
3304 assert!(body.ends_with("yes"));
3305 }
3306
3307 #[tokio::test]
3314 async fn dispatch_note_skips_short_no_context() {
3315 let (state, _tmp) = test_state();
3316 let _ = dispatch_tool(&state, "palace_create", json!({"name": "noteg"}))
3317 .await
3318 .expect("palace_create");
3319
3320 let res = dispatch_tool(
3321 &state,
3322 "memory_note",
3323 json!({"palace": "noteg", "content": "ok"}),
3324 )
3325 .await
3326 .expect("memory_note (short)");
3327 assert_eq!(res["status"], "skipped");
3328 let listed = dispatch_tool(
3329 &state,
3330 "memory_list",
3331 json!({"palace": "noteg", "limit": 10}),
3332 )
3333 .await
3334 .expect("memory_list");
3335 assert!(listed["drawers"].as_array().unwrap().is_empty());
3336 }
3337
3338 #[tokio::test]
3339 async fn dispatch_unknown_tool_errors() {
3340 let (state, _tmp) = test_state();
3341 let err = dispatch_tool(&state, "does_not_exist", json!({}))
3342 .await
3343 .expect_err("should error");
3344 assert!(err.to_string().contains("unknown tool"));
3345 }
3346
3347 #[test]
3358 fn blocklist_gate_blocks_tool_use() {
3359 assert!(blocklist_gate("Tool use: Bash"));
3360 assert!(blocklist_gate(
3361 "Tool use: Edit File: /Users/me/Projects/foo/bar.rs"
3362 ));
3363 assert!(blocklist_gate(" Tool use: Read"));
3365 }
3366
3367 #[test]
3372 fn blocklist_gate_blocks_session_ended() {
3373 assert!(blocklist_gate(
3374 "Claude Code session ended: 1d2c3b4a-0000-0000-0000-000000000000"
3375 ));
3376 assert!(blocklist_gate("Claude Code session started"));
3377 }
3378
3379 #[test]
3385 fn blocklist_gate_passes_normal_content() {
3386 assert!(!blocklist_gate("User prefers snake_case for python"));
3387 assert!(!blocklist_gate(
3388 "Quokkas are the happiest marsupials in Australia"
3389 ));
3390 assert!(!blocklist_gate("Note: refactor the dispatcher next sprint"));
3391 assert!(blocklist_gate("I used Tool use: Bash here"));
3396 }
3397
3398 #[tokio::test]
3408 async fn dedup_skips_near_duplicate() {
3409 let (state, _tmp) = test_state();
3410 let _ = dispatch_tool(&state, "palace_create", json!({"name": "dedup1"}))
3411 .await
3412 .expect("palace_create");
3413
3414 let _ = dispatch_tool(
3417 &state,
3418 "memory_remember",
3419 json!({
3420 "palace": "dedup1",
3421 "text": "The quick brown fox jumped over the lazy dog repeatedly today",
3422 }),
3423 )
3424 .await
3425 .expect("memory_remember seed");
3426
3427 let handle = open_palace_handle(&state, "dedup1").expect("open handle");
3428 assert!(
3432 dedup_gate(
3433 &handle,
3434 "The quick brown fox jumped over the lazy dog repeatedly yesterday"
3435 ),
3436 "near-duplicate should be detected"
3437 );
3438 assert!(
3440 dedup_gate(
3441 &handle,
3442 "The quick brown fox jumped over the lazy dog repeatedly today"
3443 ),
3444 "exact match should be detected"
3445 );
3446 }
3447
3448 #[tokio::test]
3454 async fn dedup_allows_different_content() {
3455 let (state, _tmp) = test_state();
3456 let _ = dispatch_tool(&state, "palace_create", json!({"name": "dedup2"}))
3457 .await
3458 .expect("palace_create");
3459
3460 let _ = dispatch_tool(
3461 &state,
3462 "memory_remember",
3463 json!({
3464 "palace": "dedup2",
3465 "text": "Quokkas are the happiest marsupials in Australia by general consensus",
3466 }),
3467 )
3468 .await
3469 .expect("memory_remember seed");
3470
3471 let handle = open_palace_handle(&state, "dedup2").expect("open handle");
3472 assert!(
3474 !dedup_gate(
3475 &handle,
3476 "Rust is a systems programming language focused on safety and concurrency"
3477 ),
3478 "unrelated content should pass the dedup gate"
3479 );
3480 assert!(!dedup_gate(&handle, " "));
3483 }
3484
3485 #[tokio::test]
3500 async fn dedup_gate_blocks_concurrent_duplicate_writes() {
3501 let (state, _tmp) = test_state();
3502 let state = std::sync::Arc::new(state);
3503 let _ = dispatch_tool(&state, "palace_create", json!({"name": "dedup_race"}))
3504 .await
3505 .expect("palace_create");
3506
3507 let text =
3511 "Concurrent identical writes must collapse to a single drawer under the dedup gate";
3512
3513 let s1 = state.clone();
3514 let t1 = tokio::spawn(async move {
3515 dispatch_tool(
3516 &s1,
3517 "memory_remember",
3518 json!({"palace": "dedup_race", "text": text}),
3519 )
3520 .await
3521 });
3522 let s2 = state.clone();
3523 let t2 = tokio::spawn(async move {
3524 dispatch_tool(
3525 &s2,
3526 "memory_remember",
3527 json!({"palace": "dedup_race", "text": text}),
3528 )
3529 .await
3530 });
3531 let r1 = t1.await.expect("join t1").expect("dispatch t1");
3532 let r2 = t2.await.expect("join t2").expect("dispatch t2");
3533
3534 let statuses = [
3537 r1["status"].as_str().unwrap_or(""),
3538 r2["status"].as_str().unwrap_or(""),
3539 ];
3540 let stored = statuses.iter().filter(|s| **s == "stored").count();
3541 let skipped = statuses.iter().filter(|s| **s == "skipped").count();
3542 assert_eq!(
3543 stored, 1,
3544 "exactly one concurrent write should be stored; got responses {r1:?} {r2:?}"
3545 );
3546 assert_eq!(
3547 skipped, 1,
3548 "exactly one concurrent write should be skipped; got responses {r1:?} {r2:?}"
3549 );
3550 let skipped_reason = if r1["status"] == "skipped" {
3551 r1["reason"].as_str().unwrap_or("")
3552 } else {
3553 r2["reason"].as_str().unwrap_or("")
3554 };
3555 assert!(
3556 skipped_reason.contains("duplicate within window"),
3557 "skipped envelope should cite dedup reason; got {skipped_reason:?}"
3558 );
3559
3560 let listed = dispatch_tool(
3562 &state,
3563 "memory_list",
3564 json!({"palace": "dedup_race", "limit": 10}),
3565 )
3566 .await
3567 .expect("memory_list");
3568 let drawers = listed["drawers"].as_array().expect("drawers array");
3569 assert_eq!(
3570 drawers.len(),
3571 1,
3572 "only one drawer should be persisted after concurrent identical writes; got {drawers:?}"
3573 );
3574 }
3575
3576 #[tokio::test]
3584 async fn dispatch_remember_blocks_blocklist_pattern() {
3585 let (state, _tmp) = test_state();
3586 let _ = dispatch_tool(&state, "palace_create", json!({"name": "blk"}))
3587 .await
3588 .expect("palace_create");
3589
3590 let res = dispatch_tool(
3591 &state,
3592 "memory_remember",
3593 json!({"palace": "blk", "text": "Tool use: Bash"}),
3594 )
3595 .await
3596 .expect("memory_remember (blocked)");
3597 assert_eq!(res["status"], "skipped");
3598 assert!(
3599 res["reason"]
3600 .as_str()
3601 .unwrap_or("")
3602 .contains("blocked pattern"),
3603 "reason should mention blocked pattern; got {res:?}"
3604 );
3605
3606 let listed = dispatch_tool(&state, "memory_list", json!({"palace": "blk", "limit": 10}))
3607 .await
3608 .expect("memory_list");
3609 let drawers = listed["drawers"].as_array().expect("drawers array");
3610 assert!(drawers.is_empty(), "no drawer should be written");
3611 }
3612
3613 #[tokio::test]
3627 async fn bm25_index_queue_drops_when_full() {
3628 let (mut state, _tmp) = test_state();
3632 let (tx, _rx_held) =
3633 tokio::sync::mpsc::channel::<Bm25IndexRequest>(BM25_INDEX_QUEUE_CAPACITY);
3634 state.bm25_index_tx = tx;
3635
3636 for i in 0..BM25_INDEX_QUEUE_CAPACITY {
3638 bm25_index_enqueue(
3639 &state,
3640 "default",
3641 Uuid::new_v4(),
3642 &format!("filler content {i}"),
3643 );
3644 }
3645 assert_eq!(
3647 state.bm25_index_tx.capacity(),
3648 0,
3649 "after filling, sender capacity must be 0"
3650 );
3651
3652 for i in 0..16 {
3655 bm25_index_enqueue(
3656 &state,
3657 "default",
3658 Uuid::new_v4(),
3659 &format!("overflow content {i}"),
3660 );
3661 }
3662
3663 let probe_req = Bm25IndexRequest {
3667 palace: "default".to_string(),
3668 drawer_id: Uuid::new_v4().to_string(),
3669 content: "probe".to_string(),
3670 data_dir: state.data_root.join("default").join("bm25"),
3671 };
3672 let probe = state.bm25_index_tx.try_send(probe_req);
3673 match probe {
3674 Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {}
3675 other => panic!("expected Full overflow, got {other:?}"),
3676 }
3677 }
3678
3679 #[tokio::test]
3690 async fn remember_returns_warming_error_while_state_is_warming() {
3691 let (state, _tmp) = test_state_warming();
3695 let _ = dispatch_tool(
3697 &state,
3698 "palace_create",
3699 serde_json::json!({"name": "warmtest"}),
3700 )
3701 .await;
3702
3703 let result = dispatch_tool(
3704 &state,
3705 "memory_remember",
3706 serde_json::json!({
3707 "palace": "warmtest",
3708 "text": "test memory that should be rejected while warming up"
3709 }),
3710 )
3711 .await;
3712 let err = result.expect_err("memory_remember must fail while Warming");
3713 let msg = err.to_string();
3714 assert!(
3715 msg.contains("warming up"),
3716 "error must mention 'warming up'; got: {msg}"
3717 );
3718 }
3719
3720 #[tokio::test]
3726 async fn recall_returns_warming_error_while_state_is_warming() {
3727 let (state, _tmp) = test_state_warming();
3728 let _ = dispatch_tool(
3729 &state,
3730 "palace_create",
3731 serde_json::json!({"name": "warmtest-recall"}),
3732 )
3733 .await;
3734
3735 let result = dispatch_tool(
3736 &state,
3737 "memory_recall",
3738 serde_json::json!({
3739 "palace": "warmtest-recall",
3740 "query": "test query"
3741 }),
3742 )
3743 .await;
3744 let err = result.expect_err("memory_recall must fail while Warming");
3745 let msg = err.to_string();
3746 assert!(
3747 msg.contains("warming up"),
3748 "error must mention 'warming up'; got: {msg}"
3749 );
3750 }
3751
3752 #[tokio::test]
3756 async fn note_returns_warming_error_while_state_is_warming() {
3757 let (state, _tmp) = test_state_warming();
3758 let _ = dispatch_tool(
3759 &state,
3760 "palace_create",
3761 serde_json::json!({"name": "warmtest-note"}),
3762 )
3763 .await;
3764
3765 let result = dispatch_tool(
3766 &state,
3767 "memory_note",
3768 serde_json::json!({
3769 "palace": "warmtest-note",
3770 "content": "short note content here"
3771 }),
3772 )
3773 .await;
3774 let err = result.expect_err("memory_note must fail while Warming");
3775 let msg = err.to_string();
3776 assert!(
3777 msg.contains("warming up"),
3778 "error must mention 'warming up'; got: {msg}"
3779 );
3780 }
3781
3782 #[tokio::test]
3790 async fn recall_all_returns_warming_error_while_state_is_warming() {
3791 let (state, _tmp) = test_state_warming();
3792
3793 let result = dispatch_tool(
3794 &state,
3795 "memory_recall_all",
3796 serde_json::json!({
3797 "q": "test query that should be rejected while warming up"
3798 }),
3799 )
3800 .await;
3801 let err = result.expect_err("memory_recall_all must fail while Warming");
3802 let msg = err.to_string();
3803 assert!(
3804 msg.contains("warming up"),
3805 "error must mention 'warming up'; got: {msg}"
3806 );
3807 }
3808}