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 crate::console_metrics::descriptor()
616 ]
617 })
618}
619
620pub(crate) fn room_label(room: &RoomType) -> Option<String> {
631 let label = match room {
632 RoomType::Frontend => "Frontend",
633 RoomType::Backend => "Backend",
634 RoomType::Testing => "Testing",
635 RoomType::Planning => "Planning",
636 RoomType::Documentation => "Documentation",
637 RoomType::Research => "Research",
638 RoomType::Configuration => "Configuration",
639 RoomType::Meetings => "Meetings",
640 RoomType::General => "General",
641 RoomType::Custom(s) => return Some(s.clone()),
642 };
643 Some(label.to_string())
644}
645
646fn parse_room(s: Option<&str>) -> RoomType {
654 match s.unwrap_or("General") {
655 "Frontend" => RoomType::Frontend,
656 "Backend" => RoomType::Backend,
657 "Testing" => RoomType::Testing,
658 "Planning" => RoomType::Planning,
659 "Documentation" => RoomType::Documentation,
660 "Research" => RoomType::Research,
661 "Configuration" => RoomType::Configuration,
662 "Meetings" => RoomType::Meetings,
663 "General" => RoomType::General,
664 other => RoomType::Custom(other.to_string()),
665 }
666}
667
668fn open_palace_handle(
670 state: &AppState,
671 palace_id: &str,
672) -> Result<std::sync::Arc<trusty_common::memory_core::PalaceHandle>> {
673 let pid = PalaceId::new(palace_id);
674 state
675 .registry
676 .open_palace(&state.data_root, &pid)
677 .with_context(|| format!("open palace {palace_id}"))
678}
679
680pub(crate) async fn auto_extract_and_assert(
696 handle: &std::sync::Arc<trusty_common::memory_core::PalaceHandle>,
697 drawer_id: Uuid,
698 content: &str,
699 tags: &[String],
700 room: Option<&str>,
701) {
702 let input = ExtractInput {
703 drawer_id,
704 content,
705 tags,
706 room,
707 };
708 let triples = extract_triples(&input);
709 if triples.is_empty() {
710 return;
711 }
712 for triple in triples {
713 let s = triple.subject.clone();
714 let p = triple.predicate.clone();
715 if let Err(e) = handle.kg.assert(triple).await {
716 tracing::warn!(
717 drawer_id = %drawer_id,
718 subject = %s,
719 predicate = %p,
720 "auto kg extraction: assert failed (non-fatal): {e:#}",
721 );
722 }
723 }
724}
725
726fn resolve_palace<'a>(state: &'a AppState, args: &'a Value, tool: &str) -> Result<String> {
738 if let Some(p) = args.get("palace").and_then(|v| v.as_str()) {
739 return Ok(p.to_string());
740 }
741 state
742 .default_palace
743 .clone()
744 .ok_or_else(|| anyhow!("{tool}: missing 'palace' (no --palace default configured)"))
745}
746
747struct WriteDrawerParams<'a> {
761 palace_id: &'a str,
762 content: String,
763 tags: Vec<String>,
764 room: RoomType,
765 importance: f32,
766 opts: RememberOptions,
767 room_label_for_kg: Option<String>,
768}
769
770async fn write_drawer(state: &AppState, params: WriteDrawerParams<'_>) -> Result<Uuid> {
786 let WriteDrawerParams {
787 palace_id,
788 content,
789 tags,
790 room,
791 importance,
792 opts,
793 room_label_for_kg,
794 } = params;
795
796 let handle = open_palace_handle(state, palace_id)?;
797 let preview = crate::service::drawer_content_preview(&content);
800 let content_for_kg = content.clone();
804 let tags_for_kg = tags.clone();
805 let drawer_id = handle
806 .remember_with_options(content, room, tags, importance, opts)
807 .await
808 .context("PalaceHandle::remember_with_options")?;
809 bm25_index_enqueue(state, palace_id, drawer_id, &content_for_kg);
815 let palace_name = lookup_palace_name(state, palace_id);
818 let drawer_count = handle.drawers.read().len();
819 state.emit(DaemonEvent::DrawerAdded {
820 palace_id: palace_id.to_string(),
821 palace_name,
822 drawer_count,
823 timestamp: chrono::Utc::now(),
824 content_preview: preview,
825 source: ActivitySource::Mcp,
826 });
827 auto_extract_and_assert(
835 &handle,
836 drawer_id,
837 &content_for_kg,
838 &tags_for_kg,
839 room_label_for_kg.as_deref(),
840 )
841 .await;
842 Ok(drawer_id)
843}
844
845fn skipped_envelope(palace_id: &str, reason: &str) -> Value {
857 json!({
858 "palace": palace_id,
859 "status": "skipped",
860 "reason": reason,
861 })
862}
863
864fn parse_tags(args: &Value) -> Vec<String> {
874 args.get("tags")
875 .and_then(|v| v.as_array())
876 .map(|arr| {
877 arr.iter()
878 .filter_map(|t| t.as_str().map(|s| s.to_string()))
879 .collect()
880 })
881 .unwrap_or_default()
882}
883
884fn attach_mcp_attribution(tags: &mut Vec<String>) {
896 if let Some(session_tag) = session_tag_from_tags(tags) {
897 tags.push(session_tag);
898 }
899 CreatorInfo::new_self(MCP_CLIENT_NAME, CreatorSource::Mcp).merge_into(tags);
900}
901
902async fn handle_memory_remember(state: &AppState, args: Value) -> Result<Value> {
912 state.readiness_check()?;
916 let palace = resolve_palace(state, &args, "memory_remember")?;
917 let palace = palace.as_str();
918 let raw_text = args
919 .get("text")
920 .and_then(|v| v.as_str())
921 .ok_or_else(|| anyhow!("memory_remember: missing 'text'"))?
922 .to_string();
923 if blocklist_gate(&raw_text) {
928 tracing::debug!(
929 palace = %palace,
930 "content gate: skipped (blocked pattern)",
931 );
932 return Ok(skipped_envelope(
933 palace,
934 "content gate: skipped (blocked pattern)",
935 ));
936 }
937 let ctx = args.get("context").and_then(|v| v.as_str());
943 let text = match content_gate(&raw_text, ctx) {
944 Some(t) => t,
945 None => {
946 return Ok(skipped_envelope(
947 palace,
948 "content gate: skipped (short prompt, no context)",
949 ));
950 }
951 };
952 let room = parse_room(args.get("room").and_then(|v| v.as_str()));
953 let mut tags = parse_tags(&args);
954 attach_mcp_attribution(&mut tags);
962
963 let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
964
965 let write_lock = state.palace_write_lock(palace);
975 let _write_guard =
976 timeouts::lock_with_timeout(&write_lock, timeouts::write_lock_timeout(), palace)
977 .await
978 .map_err(|e| anyhow::anyhow!("memory_remember: {e:#}"))?;
979
980 if !force {
985 let handle = open_palace_handle(state, palace)?;
986 if dedup_gate(&handle, &text) {
987 tracing::debug!(
988 palace = %palace,
989 "content gate: skipped (duplicate within window)",
990 );
991 return Ok(skipped_envelope(
992 palace,
993 "content gate: skipped (duplicate within window)",
994 ));
995 }
996 }
997 let room_label_for_kg = room_label(&room);
998 let drawer_id = write_drawer(
999 state,
1000 WriteDrawerParams {
1001 palace_id: palace,
1002 content: text,
1003 tags,
1004 room,
1005 importance: 0.5,
1006 opts: mcp_remember_opts(force),
1007 room_label_for_kg,
1008 },
1009 )
1010 .await?;
1011 Ok(json!({
1012 "drawer_id": drawer_id.to_string(),
1013 "palace": palace,
1014 "status": "stored",
1015 }))
1016}
1017
1018async fn handle_memory_note(state: &AppState, args: Value) -> Result<Value> {
1019 state.readiness_check()?;
1021 let palace = resolve_palace(state, &args, "memory_note")?;
1027 let palace = palace.as_str();
1028 let raw_content = args
1029 .get("content")
1030 .and_then(|v| v.as_str())
1031 .ok_or_else(|| anyhow!("memory_note: missing 'content'"))?
1032 .to_string();
1033 if blocklist_gate(&raw_content) {
1038 tracing::debug!(
1039 palace = %palace,
1040 "content gate: skipped (blocked pattern)",
1041 );
1042 return Ok(skipped_envelope(
1043 palace,
1044 "content gate: skipped (blocked pattern)",
1045 ));
1046 }
1047 let ctx = args.get("context").and_then(|v| v.as_str());
1052 let content = match content_gate(&raw_content, ctx) {
1053 Some(c) => c,
1054 None => {
1055 return Ok(skipped_envelope(
1056 palace,
1057 "content gate: skipped (short prompt, no context)",
1058 ));
1059 }
1060 };
1061 let mut tags = parse_tags(&args);
1062 attach_mcp_attribution(&mut tags);
1066 let write_lock = state.palace_write_lock(palace);
1075 let _write_guard =
1076 timeouts::lock_with_timeout(&write_lock, timeouts::write_lock_timeout(), palace)
1077 .await
1078 .map_err(|e| anyhow::anyhow!("memory_note: {e:#}"))?;
1079 {
1084 let handle = open_palace_handle(state, palace)?;
1085 if dedup_gate(&handle, &content) {
1086 tracing::debug!(
1087 palace = %palace,
1088 "content gate: skipped (duplicate within window)",
1089 );
1090 return Ok(skipped_envelope(
1091 palace,
1092 "content gate: skipped (duplicate within window)",
1093 ));
1094 }
1095 }
1096 let drawer_id = write_drawer(
1100 state,
1101 WriteDrawerParams {
1102 palace_id: palace,
1103 content,
1104 tags,
1105 room: RoomType::General,
1106 importance: 1.0,
1107 opts: RememberOptions::note(),
1108 room_label_for_kg: Some("General".to_string()),
1112 },
1113 )
1114 .await
1115 .context("PalaceHandle::remember_with_options (note)")?;
1116 Ok(json!({
1117 "drawer_id": drawer_id.to_string(),
1118 "palace": palace,
1119 "status": "stored",
1120 "drawer_type": "UserFact",
1121 }))
1122}
1123
1124async fn handle_memory_recall(state: &AppState, args: Value) -> Result<Value> {
1125 state.readiness_check()?;
1127 let palace = resolve_palace(state, &args, "memory_recall")?;
1128 let query = args
1129 .get("query")
1130 .and_then(|v| v.as_str())
1131 .ok_or_else(|| anyhow!("memory_recall: missing 'query'"))?;
1132 let top_k = args.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
1133
1134 let handle = open_palace_handle(state, &palace)?;
1135 let embedder = state.embedder().await?;
1136 let vector_fut = recall(&handle, embedder.as_ref(), query, top_k);
1142 let bm25_fut = bm25_search_optional(state, &palace, query, top_k);
1143 let (vector_res, bm25_res) = tokio::join!(vector_fut, bm25_fut);
1144 let mut results = vector_res.context("recall")?;
1145 if let Some(bm25_hits) = bm25_res {
1146 fuse_bm25_into_recall(&mut results, &bm25_hits, top_k);
1147 }
1148 Ok(serialize_recall(&palace, query, results))
1149}
1150
1151async fn handle_memory_recall_deep(state: &AppState, args: Value) -> Result<Value> {
1152 state.readiness_check()?;
1154 let palace = resolve_palace(state, &args, "memory_recall_deep")?;
1155 let query = args
1156 .get("query")
1157 .and_then(|v| v.as_str())
1158 .ok_or_else(|| anyhow!("memory_recall_deep: missing 'query'"))?;
1159 let top_k = args.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
1160
1161 let handle = open_palace_handle(state, &palace)?;
1162 let embedder = state.embedder().await?;
1163 let results = recall_deep(&handle, embedder.as_ref(), query, top_k)
1164 .await
1165 .context("recall_deep")?;
1166 Ok(serialize_recall(&palace, query, results))
1167}
1168
1169async fn handle_palace_create(state: &AppState, args: Value) -> Result<Value> {
1170 let palace_name = args
1171 .get("name")
1172 .and_then(|v| v.as_str())
1173 .ok_or_else(|| anyhow!("palace_create: missing 'name'"))?;
1174
1175 let skip_enforcement = std::env::var("TRUSTY_SKIP_PALACE_ENFORCEMENT").as_deref() == Ok("1");
1191 if !skip_enforcement {
1192 let cwd = args
1193 .get("cwd")
1194 .and_then(|v| v.as_str())
1195 .filter(|s| !s.is_empty())
1196 .map(std::path::Path::new)
1197 .map(|p| p.to_path_buf())
1198 .or_else(|| std::env::current_dir().ok())
1199 .unwrap_or_else(|| state.data_root.clone());
1200 crate::project_root::validate_palace_name(palace_name, &cwd)?;
1201 }
1202
1203 let description = args
1204 .get("description")
1205 .and_then(|v| v.as_str())
1206 .map(|s| s.to_string());
1207 let palace = Palace {
1208 id: PalaceId::new(palace_name),
1209 name: palace_name.to_string(),
1210 description,
1211 created_at: chrono::Utc::now(),
1212 data_dir: state.data_root.join(palace_name),
1213 };
1214 let _handle = state
1215 .registry
1216 .create_palace(&state.data_root, palace)
1217 .context("create_palace")?;
1218 state
1222 .palace_names
1223 .insert(palace_name.to_string(), palace_name.to_string());
1224 state.emit(DaemonEvent::PalaceCreated {
1227 id: palace_name.to_string(),
1228 name: palace_name.to_string(),
1229 source: ActivitySource::Mcp,
1230 });
1231 let bootstrap_summary = match crate::bootstrap::bootstrap_palace(state, palace_name, None).await
1239 {
1240 Ok(r) => Some(serde_json::json!({
1241 "triples_asserted": r.triples_asserted,
1242 "project_subject": r.project_subject,
1243 })),
1244 Err(e) => {
1245 tracing::warn!(
1246 palace = %palace_name,
1247 "auto-bootstrap on palace_create failed: {e:#}",
1248 );
1249 None
1250 }
1251 };
1252 Ok(json!({
1253 "palace_id": palace_name,
1254 "status": "created",
1255 "bootstrap": bootstrap_summary,
1256 }))
1257}
1258
1259async fn handle_palace_list(state: &AppState, _args: Value) -> Result<Value> {
1260 let root = state.data_root.clone();
1261 let palaces = tokio::task::spawn_blocking(move || {
1262 trusty_common::memory_core::PalaceRegistry::list_palaces(&root)
1263 })
1264 .await
1265 .context("join list_palaces")??;
1266 let ids: Vec<String> = palaces.iter().map(|p| p.id.as_str().to_string()).collect();
1267 Ok(json!({ "palaces": ids }))
1268}
1269
1270async fn handle_palace_delete(state: &AppState, args: Value) -> Result<Value> {
1271 let palace_id = args
1279 .get("palace_id")
1280 .and_then(|v| v.as_str())
1281 .ok_or_else(|| anyhow!("palace_delete: missing 'palace_id'"))?
1282 .to_string();
1283 let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
1284 use crate::service::{MemoryService, ServiceError};
1285 let svc = MemoryService::new(state.clone());
1286 match svc.delete_palace(&palace_id, force).await {
1287 Ok(()) => Ok(json!({ "deleted": palace_id })),
1288 Err(ServiceError::NotFound(_)) => Err(anyhow!("Palace not found: {palace_id}")),
1289 Err(ServiceError::Conflict(msg)) => Err(anyhow!(msg)),
1290 Err(e) => Err(anyhow!("palace_delete: {e}")),
1291 }
1292}
1293
1294async fn handle_palace_update(state: &AppState, args: Value) -> Result<Value> {
1295 let palace_id = args
1304 .get("palace_id")
1305 .and_then(|v| v.as_str())
1306 .ok_or_else(|| anyhow!("palace_update: missing 'palace_id'"))?
1307 .to_string();
1308 let name = args
1309 .get("name")
1310 .and_then(|v| v.as_str())
1311 .ok_or_else(|| anyhow!("palace_update: missing 'name'"))?
1312 .to_string();
1313 use crate::service::MemoryService;
1314 let svc = MemoryService::new(state.clone());
1315 match svc.update_palace_name(&palace_id, &name).await {
1316 Ok(_info) => Ok(json!({ "updated": palace_id, "name": name.trim() })),
1317 Err(e) => Err(anyhow!("palace_update: {e}")),
1318 }
1319}
1320
1321async fn handle_kg_assert(state: &AppState, args: Value) -> Result<Value> {
1322 let palace = resolve_palace(state, &args, "kg_assert")?;
1323 let palace = palace.as_str();
1324 let subject = args
1325 .get("subject")
1326 .and_then(|v| v.as_str())
1327 .ok_or_else(|| anyhow!("kg_assert: missing 'subject'"))?
1328 .to_string();
1329 let predicate = args
1330 .get("predicate")
1331 .and_then(|v| v.as_str())
1332 .ok_or_else(|| anyhow!("kg_assert: missing 'predicate'"))?
1333 .to_string();
1334 let object = args
1335 .get("object")
1336 .and_then(|v| v.as_str())
1337 .ok_or_else(|| anyhow!("kg_assert: missing 'object'"))?
1338 .to_string();
1339 let confidence = args
1340 .get("confidence")
1341 .and_then(|v| v.as_f64())
1342 .map(|c| (c as f32).clamp(0.0, 1.0))
1343 .unwrap_or(1.0);
1344 let provenance = args
1345 .get("provenance")
1346 .and_then(|v| v.as_str())
1347 .map(|s| s.to_string());
1348
1349 let handle = open_palace_handle(state, palace)?;
1350 let triple = Triple {
1351 subject,
1352 predicate,
1353 object,
1354 valid_from: chrono::Utc::now(),
1355 valid_to: None,
1356 confidence,
1357 provenance,
1358 };
1359 let is_hot = crate::prompt_facts::is_hot_predicate(&triple.predicate);
1360 handle.kg.assert(triple).await.context("kg.assert")?;
1361 if is_hot {
1366 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1367 tracing::warn!("rebuild_prompt_cache after kg_assert failed: {e:#}");
1368 }
1369 }
1370 Ok(json!({ "status": "asserted" }))
1371}
1372
1373async fn handle_add_alias(state: &AppState, args: Value) -> Result<Value> {
1374 let short = args
1375 .get("short")
1376 .and_then(|v| v.as_str())
1377 .ok_or_else(|| anyhow!("add_alias: missing 'short'"))?
1378 .to_string();
1379 let full = args
1380 .get("full")
1381 .and_then(|v| v.as_str())
1382 .ok_or_else(|| anyhow!("add_alias: missing 'full'"))?
1383 .to_string();
1384 let extra = args
1385 .get("extra")
1386 .and_then(|v| v.as_str())
1387 .map(|s| s.to_string());
1388
1389 let palace = resolve_palace(state, &args, "add_alias")?;
1394 let handle = open_palace_handle(state, &palace)?;
1395 let object = match extra.as_deref() {
1397 Some(e) if !e.is_empty() => format!("{full} ({e})"),
1398 _ => full.clone(),
1399 };
1400 let triple = Triple {
1401 subject: short.clone(),
1402 predicate: "is_alias_for".to_string(),
1403 object,
1404 valid_from: chrono::Utc::now(),
1405 valid_to: None,
1406 confidence: 1.0,
1407 provenance: Some("add_alias".to_string()),
1408 };
1409 handle
1410 .kg
1411 .assert(triple)
1412 .await
1413 .context("kg.assert (alias)")?;
1414 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1415 tracing::warn!("rebuild_prompt_cache after add_alias failed: {e:#}");
1416 }
1417 Ok(json!({ "asserted": true, "short": short, "full": full }))
1418}
1419
1420async fn handle_list_prompt_facts(state: &AppState, _args: Value) -> Result<Value> {
1421 let triples = crate::prompt_facts::gather_hot_triples(state).await?;
1422 let payload: Vec<Value> = triples
1423 .into_iter()
1424 .map(|(subject, predicate, object)| {
1425 json!({ "subject": subject, "predicate": predicate, "object": object })
1426 })
1427 .collect();
1428 Ok(json!({ "facts": payload }))
1429}
1430
1431async fn handle_remove_prompt_fact(state: &AppState, args: Value) -> Result<Value> {
1432 let subject = args
1433 .get("subject")
1434 .and_then(|v| v.as_str())
1435 .ok_or_else(|| anyhow!("remove_prompt_fact: missing 'subject'"))?
1436 .to_string();
1437 let predicate = args
1438 .get("predicate")
1439 .and_then(|v| v.as_str())
1440 .ok_or_else(|| anyhow!("remove_prompt_fact: missing 'predicate'"))?
1441 .to_string();
1442
1443 let mut closed_total: usize = 0;
1449 for palace_id in state.registry.list() {
1450 if let Some(handle) = state.registry.get(&palace_id) {
1451 match handle.kg.retract(&subject, &predicate).await {
1452 Ok(n) => closed_total += n,
1453 Err(e) => tracing::warn!(
1454 palace = %palace_id.as_str(),
1455 "retract failed: {e:#}",
1456 ),
1457 }
1458 }
1459 }
1460 if closed_total > 0 {
1461 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1462 tracing::warn!("rebuild_prompt_cache after remove_prompt_fact failed: {e:#}");
1463 }
1464 Ok(json!({ "removed": true, "closed": closed_total }))
1465 } else {
1466 Ok(json!({ "removed": false, "reason": "not found" }))
1467 }
1468}
1469
1470async fn handle_kg_query(state: &AppState, args: Value) -> Result<Value> {
1471 let palace = resolve_palace(state, &args, "kg_query")?;
1472 let subject = args
1473 .get("subject")
1474 .and_then(|v| v.as_str())
1475 .ok_or_else(|| anyhow!("kg_query: missing 'subject'"))?;
1476 let handle = open_palace_handle(state, &palace)?;
1477 let triples = handle
1478 .kg
1479 .query_active(subject)
1480 .await
1481 .context("kg.query_active")?;
1482 let payload: Vec<Value> = triples
1483 .iter()
1484 .map(|t| {
1485 json!({
1486 "subject": t.subject,
1487 "predicate": t.predicate,
1488 "object": t.object,
1489 "valid_from": t.valid_from.to_rfc3339(),
1490 "valid_to": t.valid_to.as_ref().map(|d| d.to_rfc3339()),
1491 "confidence": t.confidence,
1492 "provenance": t.provenance,
1493 })
1494 })
1495 .collect();
1496 let mut response = json!({ "subject": subject, "triples": payload });
1502 if crate::bootstrap::is_kg_empty_for_subject(&triples) {
1503 response["hint"] = Value::String(crate::bootstrap::KG_EMPTY_HINT.to_string());
1504 }
1505 Ok(response)
1506}
1507
1508async fn handle_memory_list(state: &AppState, args: Value) -> Result<Value> {
1509 let palace = resolve_palace(state, &args, "memory_list")?;
1510 let handle = open_palace_handle(state, &palace)?;
1511 let room = args
1512 .get("room")
1513 .and_then(|v| v.as_str())
1514 .map(|s| parse_room(Some(s)));
1515 let tag = args
1516 .get("tag")
1517 .and_then(|v| v.as_str())
1518 .map(|s| s.to_string());
1519 let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
1520 let drawers = handle.list_drawers(room, tag, limit);
1521 let payload: Vec<Value> = drawers
1522 .iter()
1523 .map(|d| {
1524 json!({
1525 "drawer_id": d.id.to_string(),
1526 "content": d.content,
1527 "importance": d.importance,
1528 "tags": d.tags,
1529 "created_at": d.created_at.to_rfc3339(),
1530 "drawer_type": d.drawer_type.as_str(),
1531 "expires_at": d.expires_at.map(|t| t.to_rfc3339()),
1532 })
1533 })
1534 .collect();
1535 Ok(json!({ "palace": palace, "drawers": payload }))
1536}
1537
1538async fn handle_memory_forget(state: &AppState, args: Value) -> Result<Value> {
1539 let palace = resolve_palace(state, &args, "memory_forget")?;
1540 let drawer_id_str = args
1541 .get("drawer_id")
1542 .and_then(|v| v.as_str())
1543 .ok_or_else(|| anyhow!("memory_forget: missing 'drawer_id'"))?;
1544 let drawer_id = Uuid::parse_str(drawer_id_str)
1545 .map_err(|e| anyhow!("memory_forget: invalid drawer_id UUID: {e}"))?;
1546 let handle = open_palace_handle(state, &palace)?;
1547 handle.forget(drawer_id).await.context("forget")?;
1548 let drawer_count = handle.drawers.read().len();
1550 state.emit(DaemonEvent::DrawerDeleted {
1551 palace_id: palace.clone(),
1552 drawer_count,
1553 source: ActivitySource::Mcp,
1554 });
1555 Ok(json!({ "status": "deleted", "drawer_id": drawer_id_str, "palace": palace }))
1558}
1559
1560async fn handle_palace_info(state: &AppState, args: Value) -> Result<Value> {
1561 let palace = resolve_palace(state, &args, "palace_info")?;
1562 let handle = open_palace_handle(state, &palace)?;
1563 let drawer_count = handle.list_drawers(None, None, usize::MAX).len();
1564 let data_dir = handle
1565 .data_dir
1566 .as_ref()
1567 .map(|p| p.to_string_lossy().to_string());
1568 Ok(json!({
1569 "id": handle.id.as_str(),
1570 "name": handle.id.as_str(),
1571 "drawer_count": drawer_count,
1572 "data_dir": data_dir,
1573 }))
1574}
1575
1576async fn handle_palace_compact(state: &AppState, args: Value) -> Result<Value> {
1577 let palace = resolve_palace(state, &args, "palace_compact")?;
1578 let handle = open_palace_handle(state, &palace)?;
1579 let valid_ids: std::collections::HashSet<Uuid> =
1583 handle.drawers.read().iter().map(|d| d.id).collect();
1584 let vector_store = handle.vector_store.clone();
1585 let res = tokio::task::spawn_blocking(move || vector_store.compact_orphans(&valid_ids))
1586 .await
1587 .context("join palace_compact")??;
1588 Ok(json!({
1589 "palace": palace,
1590 "total_checked": res.total_checked,
1591 "orphans_removed": res.orphans_removed,
1592 "index_size_before": res.index_size_before,
1593 "index_size_after": res.index_size_after,
1594 }))
1595}
1596
1597async fn handle_kg_gaps(state: &AppState, args: Value) -> Result<Value> {
1598 let palace = resolve_palace(state, &args, "kg_gaps")?;
1608 let _handle = open_palace_handle(state, &palace)?;
1611 let pid = PalaceId::new(&palace);
1612 let cached = state.registry.get_gaps(&pid).unwrap_or_default();
1613 let payload: Vec<Value> = cached
1614 .into_iter()
1615 .map(|g| {
1616 json!({
1617 "entities": g.entities,
1618 "internal_density": g.internal_density,
1619 "external_bridges": g.external_bridges,
1620 "suggested_exploration": g.suggested_exploration,
1621 })
1622 })
1623 .collect();
1624 Ok(json!({ "palace": palace, "gaps": payload }))
1625}
1626
1627async fn handle_memory_recall_all(state: &AppState, args: Value) -> Result<Value> {
1628 state.readiness_check()?;
1633 let query = args
1634 .get("q")
1635 .and_then(|v| v.as_str())
1636 .ok_or_else(|| anyhow!("memory_recall_all: missing 'q'"))?;
1637 let top_k = args.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
1638 let deep = args.get("deep").and_then(|v| v.as_bool()).unwrap_or(false);
1639
1640 let root = state.data_root.clone();
1644 let palaces = tokio::task::spawn_blocking(move || {
1645 trusty_common::memory_core::PalaceRegistry::list_palaces(&root)
1646 })
1647 .await
1648 .context("join list_palaces")??;
1649
1650 let mut handles = Vec::with_capacity(palaces.len());
1651 for p in &palaces {
1652 match state.registry.open_palace(&state.data_root, &p.id) {
1653 Ok(h) => handles.push(h),
1654 Err(e) => {
1655 tracing::warn!(palace = %p.id, "memory_recall_all: open failed: {e:#}")
1656 }
1657 }
1658 }
1659
1660 let embedder = state.embedder().await?;
1661 let erased: std::sync::Arc<dyn trusty_common::memory_core::embed::Embedder + Send + Sync> =
1662 embedder;
1663 let results = recall_across_palaces(&handles, &erased, query, top_k, deep)
1664 .await
1665 .context("recall_across_palaces")?;
1666
1667 let payload: Vec<Value> = results
1668 .iter()
1669 .map(|r| {
1670 json!({
1671 "palace_id": r.palace_id,
1672 "drawer_id": r.result.drawer.id.to_string(),
1673 "content": r.result.drawer.content,
1674 "importance": r.result.drawer.importance,
1675 "tags": r.result.drawer.tags,
1676 "score": r.result.score,
1677 "layer": r.result.layer,
1678 "drawer_type": r.result.drawer.drawer_type.as_str(),
1679 })
1680 })
1681 .collect();
1682 Ok(json!({ "query": query, "results": payload }))
1683}
1684
1685async fn handle_get_prompt_context(state: &AppState, args: Value) -> Result<Value> {
1686 let query = args
1697 .get("query")
1698 .and_then(|v| v.as_str())
1699 .map(|s| s.trim().to_string())
1700 .filter(|s| !s.is_empty());
1701
1702 let cache_snapshot = {
1706 let guard = state.prompt_context_cache.read().await;
1707 guard.clone()
1708 };
1709
1710 let body = if let Some(q) = query.as_deref() {
1711 let needle = q.to_lowercase();
1712 let filtered: Vec<(String, String, String)> = cache_snapshot
1713 .triples
1714 .into_iter()
1715 .filter(|(subject, _predicate, object)| {
1716 subject.to_lowercase().contains(&needle) || object.to_lowercase().contains(&needle)
1717 })
1718 .collect();
1719 let formatted = crate::prompt_facts::build_prompt_context(&filtered);
1720 if formatted.is_empty() {
1721 "No project context found matching your query.".to_string()
1722 } else {
1723 formatted
1724 }
1725 } else if cache_snapshot.formatted.is_empty() {
1726 "No prompt facts stored yet.".to_string()
1727 } else {
1728 cache_snapshot.formatted
1729 };
1730
1731 Ok(Value::String(body))
1737}
1738
1739async fn handle_discover_aliases(state: &AppState, args: Value) -> Result<Value> {
1740 let palace = resolve_palace(state, &args, "discover_aliases")?;
1751 let project_root = args
1752 .get("project_root")
1753 .and_then(|v| v.as_str())
1754 .map(std::path::PathBuf::from)
1755 .or_else(|| std::env::current_dir().ok())
1756 .ok_or_else(|| anyhow!("discover_aliases: no project_root and cwd unavailable"))?;
1757
1758 let discoveries = crate::discovery::discover_project_aliases(&project_root).await?;
1759
1760 let handle = open_palace_handle(state, &palace)?;
1761
1762 let mut already_known = 0usize;
1763 let mut newly_asserted = 0usize;
1764 let mut reported: Vec<Value> = Vec::with_capacity(discoveries.len());
1765
1766 for d in &discoveries {
1767 let active = handle
1770 .kg
1771 .query_active(&d.short)
1772 .await
1773 .context("kg.query_active")?;
1774 let exists = active
1775 .iter()
1776 .any(|t| t.predicate == "is_alias_for" && t.object == d.full);
1777 if exists {
1778 already_known += 1;
1779 continue;
1780 }
1781
1782 let triple = Triple {
1783 subject: d.short.clone(),
1784 predicate: "is_alias_for".to_string(),
1785 object: d.full.clone(),
1786 valid_from: chrono::Utc::now(),
1787 valid_to: None,
1788 confidence: 1.0,
1789 provenance: Some(format!("discover_aliases:{}", d.source.as_str())),
1790 };
1791 handle
1792 .kg
1793 .assert(triple)
1794 .await
1795 .context("kg.assert (discover)")?;
1796 newly_asserted += 1;
1797 reported.push(json!({
1798 "short": d.short,
1799 "full": d.full,
1800 "source": d.source.as_str(),
1801 }));
1802 }
1803
1804 if newly_asserted > 0 {
1805 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1806 tracing::warn!("rebuild_prompt_cache after discover_aliases failed: {e:#}");
1807 }
1808 }
1809
1810 Ok(json!({
1811 "discovered": reported,
1812 "already_known": already_known,
1813 "new": newly_asserted,
1814 "palace": palace,
1815 }))
1816}
1817
1818async fn handle_kg_bootstrap(state: &AppState, args: Value) -> Result<Value> {
1819 let palace = resolve_palace(state, &args, "kg_bootstrap")?;
1824 let project_path = args
1825 .get("project_path")
1826 .and_then(|v| v.as_str())
1827 .map(std::path::PathBuf::from);
1828 let result = crate::bootstrap::bootstrap_palace(state, &palace, project_path.as_deref())
1829 .await
1830 .context("bootstrap_palace")?;
1831 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1835 tracing::warn!("rebuild_prompt_cache after kg_bootstrap failed: {e:#}");
1836 }
1837 crate::bootstrap::result_to_json(&result)
1838}
1839
1840async fn handle_memory_send_message(state: &AppState, args: Value) -> Result<Value> {
1841 let to_palace = args
1843 .get("to_palace")
1844 .and_then(|v| v.as_str())
1845 .ok_or_else(|| anyhow!("memory_send_message: missing 'to_palace'"))?
1846 .to_string();
1847 let purpose = args
1848 .get("purpose")
1849 .and_then(|v| v.as_str())
1850 .ok_or_else(|| anyhow!("memory_send_message: missing 'purpose'"))?
1851 .to_string();
1852 let content = args
1853 .get("content")
1854 .and_then(|v| v.as_str())
1855 .ok_or_else(|| anyhow!("memory_send_message: missing 'content'"))?
1856 .to_string();
1857 let from_palace = if let Some(s) = args.get("from_palace").and_then(|v| v.as_str()) {
1860 s.to_string()
1861 } else if let Some(d) = state.default_palace.clone() {
1862 d
1863 } else {
1864 crate::messaging::cwd_palace_slug()
1865 .context("memory_send_message: derive from_palace from cwd")?
1866 };
1867 let drawer_id = crate::messaging::send_message_to_palace(
1868 &state.registry,
1869 &state.data_root,
1870 &from_palace,
1871 &to_palace,
1872 &purpose,
1873 content,
1874 CreatorInfo::new_self(MCP_CLIENT_NAME, CreatorSource::Mcp),
1875 )
1876 .await
1877 .context("memory_send_message")?;
1878 Ok(json!({
1879 "drawer_id": drawer_id.to_string(),
1880 "from_palace": from_palace,
1881 "to_palace": to_palace,
1882 "purpose": purpose,
1883 "status": "sent",
1884 }))
1885}
1886
1887pub async fn dispatch_tool(state: &AppState, name: &str, args: Value) -> Result<Value> {
1899 match name {
1900 "memory_remember" => handle_memory_remember(state, args).await,
1901 "memory_note" => handle_memory_note(state, args).await,
1902 "memory_recall" => handle_memory_recall(state, args).await,
1903 "memory_recall_deep" => handle_memory_recall_deep(state, args).await,
1904 "palace_create" => handle_palace_create(state, args).await,
1905 "palace_list" => handle_palace_list(state, args).await,
1906 "palace_delete" => handle_palace_delete(state, args).await,
1907 "palace_update" => handle_palace_update(state, args).await,
1908 "kg_assert" => handle_kg_assert(state, args).await,
1909 "add_alias" => handle_add_alias(state, args).await,
1910 "list_prompt_facts" => handle_list_prompt_facts(state, args).await,
1911 "remove_prompt_fact" => handle_remove_prompt_fact(state, args).await,
1912 "kg_query" => handle_kg_query(state, args).await,
1913 "memory_list" => handle_memory_list(state, args).await,
1914 "memory_forget" => handle_memory_forget(state, args).await,
1915 "palace_info" => handle_palace_info(state, args).await,
1916 "palace_compact" => handle_palace_compact(state, args).await,
1917 "kg_gaps" => handle_kg_gaps(state, args).await,
1918 "memory_recall_all" => handle_memory_recall_all(state, args).await,
1919 "get_prompt_context" => handle_get_prompt_context(state, args).await,
1920 "discover_aliases" => handle_discover_aliases(state, args).await,
1921 "kg_bootstrap" => handle_kg_bootstrap(state, args).await,
1922 "memory_send_message" => handle_memory_send_message(state, args).await,
1923 "upgrade" => handle_upgrade_tool(state, args).await,
1924 "console_metrics" => crate::console_metrics::handle_console_metrics(state, args).await,
1925 other => anyhow::bail!("unknown tool: {other}"),
1926 }
1927}
1928
1929async fn handle_upgrade_tool(state: &AppState, args: Value) -> Result<Value> {
1949 let check = args.get("check").and_then(Value::as_bool).unwrap_or(true);
1950 let confirm = args
1951 .get("confirm")
1952 .and_then(Value::as_bool)
1953 .unwrap_or(false);
1954
1955 let crate_name = env!("CARGO_PKG_NAME");
1956 let current = env!("CARGO_PKG_VERSION");
1957
1958 let info = trusty_common::update::check_crates_io(crate_name, current).await;
1960
1961 let (latest, is_update) = match &info {
1962 Some(u) => (u.latest.as_str(), true),
1963 None => (current, false),
1964 };
1965
1966 if check || !confirm {
1967 let msg = if is_update {
1968 format!(
1969 "Update available: {crate_name} {latest} (you have {current}). \
1970 Call with confirm=true to install."
1971 )
1972 } else {
1973 format!("{crate_name} {current} is already up to date.")
1974 };
1975 return Ok(
1976 serde_json::json!({ "status": "checked", "current": current, "latest": latest, "update_available": is_update, "message": msg }),
1977 );
1978 }
1979
1980 if !is_update {
1986 return Ok(serde_json::json!({
1987 "status": "up_to_date",
1988 "current": current,
1989 "message": format!("{crate_name} {current} is already up to date — nothing to install.")
1990 }));
1991 }
1992
1993 let upgrade_state = state.update_available.clone();
1994 let latest_owned = latest.to_string();
1995 let crate_name_owned = crate_name.to_string();
1996 let response = serde_json::json!({
1997 "status": "installing",
1998 "current": current,
1999 "latest": latest_owned,
2000 "message": format!(
2001 "Installing {crate_name} {latest_owned} — daemon will restart automatically \
2002 under launchd, or you will be prompted to restart manually."
2003 )
2004 });
2005
2006 tokio::spawn(async move {
2009 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
2011 match trusty_common::update::upgrade_and_restart(&crate_name_owned, &crate_name_owned).await
2012 {
2013 Ok(Some(hint)) => {
2014 tracing::info!("{hint}");
2015 eprintln!("{hint}");
2016 }
2017 Ok(None) => {}
2018 Err(e) => {
2019 tracing::error!("upgrade_and_restart failed: {e:#}");
2020 eprintln!("[trusty-memory] upgrade failed: {e:#}");
2021 if let Ok(mut g) = upgrade_state.lock() {
2024 *g = None;
2025 }
2026 }
2027 }
2028 });
2029
2030 Ok(response)
2031}
2032
2033fn bm25_data_dir_for_palace(state: &AppState, palace: &str) -> std::path::PathBuf {
2046 state.data_root.join(palace).join("bm25")
2047}
2048
2049async fn ensure_bm25_running_for_palace(state: &AppState, palace: &str) -> bool {
2066 let Some(supervisor) = state.bm25_supervisor.as_ref() else {
2067 return true;
2070 };
2071 let data_dir = bm25_data_dir_for_palace(state, palace);
2072 match supervisor.ensure_running(palace, &data_dir).await {
2073 Ok(_socket) => true,
2074 Err(e) => {
2075 tracing::warn!(
2076 palace = %palace,
2077 "bm25 supervisor could not start daemon (degrading to vector-only): {e:#}"
2078 );
2079 false
2080 }
2081 }
2082}
2083
2084pub const BM25_INDEX_QUEUE_CAPACITY: usize = 256;
2100
2101#[derive(Debug)]
2114pub struct Bm25IndexRequest {
2115 pub palace: String,
2117 pub drawer_id: String,
2119 pub content: String,
2121 pub data_dir: std::path::PathBuf,
2125}
2126
2127pub fn spawn_bm25_index_worker(
2148 mut rx: tokio::sync::mpsc::Receiver<Bm25IndexRequest>,
2149 client: Option<std::sync::Arc<trusty_common::bm25_client::Bm25Client>>,
2150 supervisor: Option<std::sync::Arc<crate::bm25_supervisor::Bm25Supervisor>>,
2151) {
2152 tokio::spawn(async move {
2153 while let Some(req) = rx.recv().await {
2154 let Some(client) = client.as_ref() else {
2157 continue;
2158 };
2159 if let Some(sup) = supervisor.as_ref() {
2163 if let Err(e) = sup.ensure_running(&req.palace, &req.data_dir).await {
2164 tracing::warn!(
2165 palace = %req.palace,
2166 "bm25 supervisor failed to start daemon for index (non-fatal): {e:#}"
2167 );
2168 continue;
2169 }
2170 }
2171 if let Err(e) = client.index(&req.drawer_id, &req.content).await {
2172 tracing::warn!(
2173 palace = %req.palace,
2174 drawer_id = %req.drawer_id,
2175 "bm25 daemon index failed (non-fatal): {e:#}"
2176 );
2177 }
2178 }
2179 tracing::debug!("bm25 index worker exiting (channel closed)");
2180 });
2181}
2182
2183fn bm25_index_enqueue(state: &AppState, palace: &str, drawer_id: Uuid, content: &str) {
2200 let req = Bm25IndexRequest {
2201 palace: palace.to_string(),
2202 drawer_id: drawer_id.to_string(),
2203 content: content.to_string(),
2204 data_dir: bm25_data_dir_for_palace(state, palace),
2205 };
2206 match state.bm25_index_tx.try_send(req) {
2207 Ok(()) => {}
2208 Err(tokio::sync::mpsc::error::TrySendError::Full(req)) => {
2209 tracing::warn!(
2210 palace = %req.palace,
2211 drawer_id = %req.drawer_id,
2212 "BM25 index queue full — skipping drawer {}",
2213 req.drawer_id
2214 );
2215 }
2216 Err(tokio::sync::mpsc::error::TrySendError::Closed(req)) => {
2217 tracing::debug!(
2218 palace = %req.palace,
2219 drawer_id = %req.drawer_id,
2220 "BM25 index queue closed — skipping drawer {}",
2221 req.drawer_id
2222 );
2223 }
2224 }
2225}
2226
2227async fn bm25_search_optional(
2241 state: &AppState,
2242 palace: &str,
2243 query: &str,
2244 top_k: usize,
2245) -> Option<Vec<trusty_common::bm25_client::BM25Hit>> {
2246 let client = state.bm25_client.as_ref()?;
2247 if !ensure_bm25_running_for_palace(state, palace).await {
2251 return None;
2252 }
2253 match client.search(query, top_k).await {
2254 Ok(hits) => Some(hits),
2255 Err(e) => {
2256 tracing::warn!(
2257 palace = %palace,
2258 "bm25 daemon search failed (falling back to vector-only): {e:#}"
2259 );
2260 None
2261 }
2262 }
2263}
2264
2265fn fuse_bm25_into_recall(
2280 results: &mut Vec<trusty_common::memory_core::retrieval::RecallResult>,
2281 bm25_hits: &[trusty_common::bm25_client::BM25Hit],
2282 top_k: usize,
2283) {
2284 const RRF_K: f32 = 60.0;
2287 if bm25_hits.is_empty() {
2288 return;
2289 }
2290 for (rank, hit) in bm25_hits.iter().enumerate() {
2292 let bonus = 1.0 / (RRF_K + rank as f32 + 1.0);
2293 if let Some(existing) = results
2294 .iter_mut()
2295 .find(|r| r.drawer.id.to_string() == hit.doc_id)
2296 {
2297 existing.score += bonus;
2298 }
2299 }
2307 results.sort_by(|a, b| {
2310 b.score
2311 .partial_cmp(&a.score)
2312 .unwrap_or(std::cmp::Ordering::Equal)
2313 .then(a.layer.cmp(&b.layer))
2314 });
2315 results.truncate(top_k);
2316}
2317
2318fn serialize_recall(
2320 palace: &str,
2321 query: &str,
2322 results: Vec<trusty_common::memory_core::retrieval::RecallResult>,
2323) -> Value {
2324 let payload: Vec<Value> = results
2325 .iter()
2326 .map(|r| {
2327 json!({
2328 "drawer_id": r.drawer.id.to_string(),
2329 "content": r.drawer.content,
2330 "score": r.score,
2331 "layer": r.layer,
2332 "tags": r.drawer.tags,
2333 "importance": r.drawer.importance,
2334 "drawer_type": r.drawer.drawer_type.as_str(),
2335 })
2336 })
2337 .collect();
2338 json!({
2339 "palace": palace,
2340 "query": query,
2341 "results": payload,
2342 })
2343}
2344
2345#[cfg(test)]
2346mod tests {
2347 use super::*;
2348 use crate::AppState;
2349
2350 fn test_state() -> (AppState, tempfile::TempDir) {
2365 unsafe {
2372 std::env::set_var("TRUSTY_SKIP_PALACE_ENFORCEMENT", "1");
2373 }
2374 let tmp = tempfile::tempdir().expect("tempdir");
2375 let root = tmp.path().to_path_buf();
2376 let state = AppState::new(root);
2377 state.set_ready();
2380 (state, tmp)
2381 }
2382
2383 fn test_state_warming() -> (crate::AppState, tempfile::TempDir) {
2390 static SKIP_ENFORCEMENT_SET: std::sync::OnceLock<()> = std::sync::OnceLock::new();
2394 SKIP_ENFORCEMENT_SET.get_or_init(|| unsafe {
2395 std::env::set_var("TRUSTY_SKIP_PALACE_ENFORCEMENT", "1");
2396 });
2397 let tmp = tempfile::tempdir().expect("tempdir");
2398 let root = tmp.path().to_path_buf();
2399 let state = crate::AppState::new(root);
2400 (state, tmp)
2402 }
2403
2404 #[test]
2409 fn tool_definitions_drops_palace_required_when_default_set() {
2410 let with_default = tool_definitions_with(true);
2411 let without_default = tool_definitions_with(false);
2412 for (name, palace_required_when_no_default) in [
2413 ("memory_remember", true),
2414 ("memory_recall", true),
2415 ("memory_recall_deep", true),
2416 ("memory_list", true),
2417 ("memory_forget", true),
2418 ("palace_info", true),
2419 ("palace_compact", true),
2420 ("kg_assert", true),
2421 ("kg_query", true),
2422 ("add_alias", true),
2425 ("discover_aliases", true),
2426 ] {
2427 for (defs, has_default) in [(&with_default, true), (&without_default, false)] {
2428 let tools = defs["tools"].as_array().unwrap();
2429 let tool = tools.iter().find(|t| t["name"] == name).unwrap();
2430 let required: Vec<&str> = tool["inputSchema"]["required"]
2431 .as_array()
2432 .unwrap()
2433 .iter()
2434 .filter_map(|v| v.as_str())
2435 .collect();
2436 let palace_required = required.contains(&"palace");
2437 let expected = palace_required_when_no_default && !has_default;
2438 assert_eq!(
2439 palace_required, expected,
2440 "tool={name} has_default={has_default} required={required:?}"
2441 );
2442 }
2443 }
2444 }
2445
2446 #[test]
2447 fn tool_definitions_lists_all_tools() {
2448 let defs = tool_definitions();
2449 let tools = defs
2450 .get("tools")
2451 .and_then(|t| t.as_array())
2452 .expect("tools array");
2453 assert_eq!(tools.len(), 25);
2454 let names: Vec<&str> = tools
2455 .iter()
2456 .filter_map(|t| t.get("name").and_then(|n| n.as_str()))
2457 .collect();
2458 for expected in [
2459 "memory_remember",
2460 "memory_note",
2461 "memory_recall",
2462 "memory_recall_deep",
2463 "memory_list",
2464 "memory_forget",
2465 "palace_create",
2466 "palace_delete",
2467 "palace_update",
2468 "palace_list",
2469 "palace_info",
2470 "palace_compact",
2471 "kg_assert",
2472 "kg_query",
2473 "memory_recall_all",
2474 "kg_gaps",
2475 "add_alias",
2476 "list_prompt_facts",
2477 "remove_prompt_fact",
2478 "get_prompt_context",
2479 "discover_aliases",
2480 "kg_bootstrap",
2481 "memory_send_message",
2482 "upgrade",
2483 "console_metrics",
2484 ] {
2485 assert!(names.contains(&expected), "missing tool: {expected}");
2486 }
2487 }
2488
2489 #[tokio::test]
2492 async fn dispatch_palace_create_persists() {
2493 let (state, _tmp) = test_state();
2494 let created = dispatch_tool(&state, "palace_create", json!({"name": "alpha"}))
2495 .await
2496 .expect("palace_create");
2497 assert_eq!(created["palace_id"], "alpha");
2498
2499 let listed = dispatch_tool(&state, "palace_list", json!({}))
2500 .await
2501 .expect("palace_list");
2502 let ids = listed["palaces"].as_array().expect("palaces array");
2503 assert!(ids.iter().any(|v| v.as_str() == Some("alpha")));
2504 }
2505
2506 #[tokio::test]
2509 async fn dispatch_remember_then_recall() {
2510 let (state, _tmp) = test_state();
2511 let _ = dispatch_tool(&state, "palace_create", json!({"name": "beta"}))
2512 .await
2513 .expect("palace_create");
2514
2515 let remembered = dispatch_tool(
2516 &state,
2517 "memory_remember",
2518 json!({
2519 "palace": "beta",
2520 "text": "Quokkas are the happiest marsupials in Australia by general consensus",
2521 "room": "General",
2522 "tags": ["wildlife"],
2523 }),
2524 )
2525 .await
2526 .expect("memory_remember");
2527 assert!(remembered["drawer_id"].as_str().is_some());
2528
2529 let recalled = dispatch_tool(
2530 &state,
2531 "memory_recall",
2532 json!({"palace": "beta", "query": "Quokkas marsupials Australia", "top_k": 5}),
2533 )
2534 .await
2535 .expect("memory_recall");
2536 let results = recalled["results"].as_array().expect("results");
2537 assert!(
2538 results
2539 .iter()
2540 .any(|r| r["content"].as_str().unwrap_or("").contains("Quokkas")),
2541 "expected to recall the Quokkas drawer; got {results:?}"
2542 );
2543 }
2544
2545 #[tokio::test]
2554 async fn auto_kg_extraction_hooks_into_memory_remember() {
2555 let (state, _tmp) = test_state();
2556 let _ = dispatch_tool(&state, "palace_create", json!({"name": "kgauto"}))
2557 .await
2558 .expect("palace_create");
2559
2560 let _ = dispatch_tool(
2561 &state,
2562 "memory_remember",
2563 json!({
2564 "palace": "kgauto",
2565 "text": "Rustc is a compiler for the Rust language; tracks #performance",
2566 "room": "Backend",
2567 "tags": ["compiler", "language"],
2568 }),
2569 )
2570 .await
2571 .expect("memory_remember");
2572
2573 let handle = open_palace_handle(&state, "kgauto").expect("open palace");
2574 let triples = handle.kg.list_active(1000, 0).await.expect("list_active");
2575 let auto: Vec<_> = triples
2576 .iter()
2577 .filter(|t| t.provenance.as_deref() == Some(crate::kg_extract::AUTO_PROVENANCE))
2578 .collect();
2579 assert!(
2580 !auto.is_empty(),
2581 "expected at least one auto-extracted triple after memory_remember; got: {triples:?}"
2582 );
2583 assert!(
2587 auto.iter()
2588 .any(|t| t.subject == "tag:compiler" && t.predicate == "tags"),
2589 "expected tag:compiler edge in auto subset: {auto:?}"
2590 );
2591 assert!(
2592 auto.iter()
2593 .any(|t| t.subject == "tag:language" && t.predicate == "tags"),
2594 "expected tag:language edge in auto subset: {auto:?}"
2595 );
2596 assert!(
2597 auto.iter()
2598 .any(|t| t.subject == "room:Backend" && t.predicate == "contains"),
2599 "expected room:Backend edge in auto subset: {auto:?}"
2600 );
2601 assert!(
2602 auto.iter().any(|t| t.predicate == "mentioned-in"),
2603 "expected at least one #hashtag mention triple in auto subset: {auto:?}"
2604 );
2605 }
2606
2607 #[tokio::test]
2619 async fn auto_kg_extraction_no_op_does_not_fail_remember() {
2620 let (state, _tmp) = test_state();
2621 let _ = dispatch_tool(&state, "palace_create", json!({"name": "kgnoop"}))
2622 .await
2623 .expect("palace_create");
2624
2625 let res = dispatch_tool(
2626 &state,
2627 "memory_remember",
2628 json!({
2629 "palace": "kgnoop",
2630 "text": "The quick brown fox jumped over the lazy dog repeatedly",
2633 }),
2634 )
2635 .await
2636 .expect("memory_remember should succeed even when extraction yields nothing");
2637 assert!(res["drawer_id"].as_str().is_some());
2638 }
2639
2640 #[tokio::test]
2643 async fn dispatch_kg_assert_then_query() {
2644 let (state, _tmp) = test_state();
2645 let _ = dispatch_tool(&state, "palace_create", json!({"name": "gamma"}))
2646 .await
2647 .expect("palace_create");
2648
2649 let _ = dispatch_tool(
2650 &state,
2651 "kg_assert",
2652 json!({
2653 "palace": "gamma",
2654 "subject": "alice",
2655 "predicate": "works_at",
2656 "object": "Acme",
2657 "confidence": 0.9,
2658 "provenance": "test",
2659 }),
2660 )
2661 .await
2662 .expect("kg_assert");
2663
2664 let queried = dispatch_tool(
2665 &state,
2666 "kg_query",
2667 json!({"palace": "gamma", "subject": "alice"}),
2668 )
2669 .await
2670 .expect("kg_query");
2671 let triples = queried["triples"].as_array().expect("triples array");
2672 assert_eq!(triples.len(), 1);
2673 assert_eq!(triples[0]["object"], "Acme");
2674 assert_eq!(triples[0]["predicate"], "works_at");
2675 }
2676
2677 #[tokio::test]
2685 async fn dispatch_kg_gaps_returns_cached() {
2686 use trusty_common::memory_core::community::KnowledgeGap;
2687
2688 let (state, _tmp) = test_state();
2689 let _ = dispatch_tool(&state, "palace_create", json!({"name": "delta"}))
2690 .await
2691 .expect("palace_create");
2692
2693 let initial = dispatch_tool(&state, "kg_gaps", json!({"palace": "delta"}))
2695 .await
2696 .expect("kg_gaps empty");
2697 let gaps = initial["gaps"].as_array().expect("gaps array");
2698 assert_eq!(gaps.len(), 0);
2699
2700 state.registry.set_gaps(
2702 PalaceId::new("delta"),
2703 vec![KnowledgeGap {
2704 entities: vec!["x".to_string(), "y".to_string()],
2705 internal_density: 0.05,
2706 external_bridges: 0,
2707 suggested_exploration: "Explore connections between x and y".to_string(),
2708 }],
2709 );
2710 let seeded = dispatch_tool(&state, "kg_gaps", json!({"palace": "delta"}))
2711 .await
2712 .expect("kg_gaps seeded");
2713 let gaps = seeded["gaps"].as_array().expect("gaps array");
2714 assert_eq!(gaps.len(), 1);
2715 assert_eq!(gaps[0]["entities"][0], "x");
2716 assert_eq!(gaps[0]["external_bridges"], 0);
2717 assert!(gaps[0]["suggested_exploration"]
2718 .as_str()
2719 .unwrap()
2720 .contains("x"));
2721 }
2722
2723 #[tokio::test]
2728 async fn add_alias_round_trip_through_prompt_cache() {
2729 let _tmp = tempfile::tempdir().expect("tempdir");
2732 let root = _tmp.path().to_path_buf();
2733 let state = AppState::new(root).with_default_palace(Some("ctx".to_string()));
2734
2735 let _ = dispatch_tool(&state, "palace_create", json!({"name": "ctx"}))
2737 .await
2738 .expect("palace_create");
2739
2740 let added = dispatch_tool(
2742 &state,
2743 "add_alias",
2744 json!({"short": "tga", "full": "trusty-git-analytics"}),
2745 )
2746 .await
2747 .expect("add_alias");
2748 assert_eq!(added["asserted"], true);
2749 assert_eq!(added["short"], "tga");
2750
2751 let listed = dispatch_tool(&state, "list_prompt_facts", json!({}))
2753 .await
2754 .expect("list_prompt_facts");
2755 let facts = listed["facts"].as_array().expect("facts array");
2756 assert!(
2757 facts.iter().any(|f| f["subject"] == "tga"
2758 && f["predicate"] == "is_alias_for"
2759 && f["object"] == "trusty-git-analytics"),
2760 "expected tga alias in facts; got {facts:?}"
2761 );
2762
2763 {
2765 let guard = state.prompt_context_cache.read().await;
2766 assert!(
2767 guard.formatted.contains("tga → trusty-git-analytics"),
2768 "prompt cache should contain alias; got: {}",
2769 guard.formatted
2770 );
2771 }
2772
2773 let _ = dispatch_tool(
2775 &state,
2776 "add_alias",
2777 json!({"short": "tm", "full": "trusty-memory", "extra": "the MCP frontend"}),
2778 )
2779 .await
2780 .expect("add_alias with extra");
2781 {
2782 let guard = state.prompt_context_cache.read().await;
2783 assert!(
2784 guard
2785 .formatted
2786 .contains("tm → trusty-memory (the MCP frontend)"),
2787 "alias with extra not formatted; got: {}",
2788 guard.formatted
2789 );
2790 }
2791
2792 let removed = dispatch_tool(
2794 &state,
2795 "remove_prompt_fact",
2796 json!({"subject": "tga", "predicate": "is_alias_for"}),
2797 )
2798 .await
2799 .expect("remove_prompt_fact");
2800 assert_eq!(removed["removed"], true);
2801 {
2802 let guard = state.prompt_context_cache.read().await;
2803 assert!(
2804 !guard.formatted.contains("tga → trusty-git-analytics"),
2805 "retracted alias still in cache: {}",
2806 guard.formatted
2807 );
2808 assert!(
2809 guard.formatted.contains("tm → trusty-memory"),
2810 "non-retracted alias missing from cache: {}",
2811 guard.formatted
2812 );
2813 }
2814
2815 let missing = dispatch_tool(
2817 &state,
2818 "remove_prompt_fact",
2819 json!({"subject": "nope", "predicate": "is_alias_for"}),
2820 )
2821 .await
2822 .expect("remove_prompt_fact missing");
2823 assert_eq!(missing["removed"], false);
2824 }
2825
2826 #[tokio::test]
2834 async fn add_alias_palace_arg_required_without_server_default() {
2835 let (state, _tmp) = test_state();
2838 dispatch_tool(&state, "palace_create", json!({"name": "p"}))
2839 .await
2840 .expect("palace_create");
2841 let added = dispatch_tool(
2842 &state,
2843 "add_alias",
2844 json!({"palace": "p", "short": "tga", "full": "trusty-git-analytics"}),
2845 )
2846 .await
2847 .expect("add_alias with explicit palace");
2848 assert_eq!(added["asserted"], true);
2849 let guard = state.prompt_context_cache.read().await;
2850 assert!(guard.formatted.contains("tga → trusty-git-analytics"));
2851
2852 drop(guard);
2854 let (state2, _tmp2) = test_state();
2855 let err = dispatch_tool(&state2, "add_alias", json!({"short": "x", "full": "y"}))
2856 .await
2857 .expect_err("should fail without palace");
2858 let msg = format!("{err:#}");
2859 assert!(msg.contains("palace"), "error must mention 'palace': {msg}");
2860 assert!(msg.contains("add_alias"), "error must name tool: {msg}");
2861 }
2862
2863 #[tokio::test]
2868 async fn get_prompt_context_serves_cache_and_filters() {
2869 let (state, _tmp) = test_state();
2870
2871 let resp = dispatch_tool(&state, "get_prompt_context", json!({}))
2873 .await
2874 .expect("get_prompt_context empty");
2875 assert_eq!(resp.as_str().unwrap(), "No prompt facts stored yet.");
2876
2877 {
2879 let mut guard = state.prompt_context_cache.write().await;
2880 let triples = vec![
2881 (
2882 "tga".to_string(),
2883 "is_alias_for".to_string(),
2884 "trusty-git-analytics".to_string(),
2885 ),
2886 (
2887 "tm".to_string(),
2888 "is_alias_for".to_string(),
2889 "trusty-memory".to_string(),
2890 ),
2891 (
2892 "fact-1".to_string(),
2893 "is_fact".to_string(),
2894 "MSRV is 1.88".to_string(),
2895 ),
2896 ];
2897 let formatted = crate::prompt_facts::build_prompt_context(&triples);
2898 *guard = crate::prompt_facts::PromptFactsCache { triples, formatted };
2899 }
2900
2901 let resp = dispatch_tool(&state, "get_prompt_context", json!({}))
2903 .await
2904 .expect("get_prompt_context populated");
2905 let text = resp.as_str().expect("string body");
2906 assert!(text.contains("tga → trusty-git-analytics"));
2907 assert!(text.contains("tm → trusty-memory"));
2908 assert!(text.contains("MSRV is 1.88"));
2909
2910 let resp = dispatch_tool(&state, "get_prompt_context", json!({"query": "tga"}))
2912 .await
2913 .expect("get_prompt_context filtered");
2914 let text = resp.as_str().expect("string body");
2915 assert!(text.contains("tga → trusty-git-analytics"));
2916 assert!(!text.contains("tm → trusty-memory"));
2917 assert!(!text.contains("MSRV is 1.88"));
2918
2919 let resp = dispatch_tool(&state, "get_prompt_context", json!({"query": "MEMORY"}))
2921 .await
2922 .expect("get_prompt_context case-insensitive");
2923 let text = resp.as_str().expect("string body");
2924 assert!(text.contains("tm → trusty-memory"));
2925 assert!(!text.contains("tga → trusty-git-analytics"));
2926
2927 let resp = dispatch_tool(
2929 &state,
2930 "get_prompt_context",
2931 json!({"query": "zzz-nonexistent"}),
2932 )
2933 .await
2934 .expect("get_prompt_context no-match");
2935 assert_eq!(
2936 resp.as_str().unwrap(),
2937 "No project context found matching your query."
2938 );
2939
2940 let resp = dispatch_tool(&state, "get_prompt_context", json!({"query": " "}))
2942 .await
2943 .expect("get_prompt_context whitespace");
2944 let text = resp.as_str().expect("string body");
2945 assert!(text.contains("tga → trusty-git-analytics"));
2946 assert!(text.contains("tm → trusty-memory"));
2947 }
2948
2949 #[tokio::test]
2956 async fn dispatch_discover_aliases_inserts_new_and_dedupes() {
2957 let _tmp = tempfile::tempdir().expect("tempdir");
2960 let root = _tmp.path().to_path_buf();
2961 let state = AppState::new(root).with_default_palace(Some("disc".to_string()));
2962 let _ = dispatch_tool(&state, "palace_create", json!({"name": "disc"}))
2963 .await
2964 .expect("palace_create");
2965
2966 let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2970 .parent()
2971 .and_then(|p| p.parent())
2972 .expect("workspace root")
2973 .to_path_buf();
2974
2975 let first = dispatch_tool(
2976 &state,
2977 "discover_aliases",
2978 json!({"project_root": workspace_root.to_string_lossy()}),
2979 )
2980 .await
2981 .expect("discover_aliases first");
2982
2983 let new_count = first["new"].as_u64().expect("new is u64");
2984 assert!(new_count > 0, "expected new discoveries on first call");
2985 let discovered = first["discovered"].as_array().expect("discovered array");
2986 assert!(
2987 discovered
2988 .iter()
2989 .any(|d| d["short"] == "tga" && d["full"] == "trusty-git-analytics"),
2990 "expected tga alias in discoveries; got {discovered:?}"
2991 );
2992
2993 {
2995 let guard = state.prompt_context_cache.read().await;
2996 assert!(
2997 guard.formatted.contains("tga → trusty-git-analytics"),
2998 "prompt cache missing tga alias after discover_aliases; got: {}",
2999 guard.formatted
3000 );
3001 }
3002
3003 let second = dispatch_tool(
3006 &state,
3007 "discover_aliases",
3008 json!({"project_root": workspace_root.to_string_lossy()}),
3009 )
3010 .await
3011 .expect("discover_aliases second");
3012 assert_eq!(second["new"].as_u64(), Some(0), "expected 0 new on rerun");
3013 let already_known = second["already_known"].as_u64().expect("already_known");
3014 assert!(
3015 already_known >= new_count,
3016 "expected already_known >= {new_count}, got {already_known}"
3017 );
3018 }
3019
3020 #[tokio::test]
3027 async fn palace_create_auto_seeds_temporal_metadata() {
3028 let (state, _tmp) = test_state();
3029 let created = dispatch_tool(&state, "palace_create", json!({"name": "auto"}))
3030 .await
3031 .expect("palace_create");
3032 assert_eq!(created["palace_id"], "auto");
3033 let summary = &created["bootstrap"];
3035 assert!(summary.is_object(), "expected bootstrap summary object");
3036 assert!(summary["triples_asserted"].as_u64().unwrap_or(0) >= 2);
3037
3038 let queried = dispatch_tool(
3039 &state,
3040 "kg_query",
3041 json!({"palace": "auto", "subject": "auto"}),
3042 )
3043 .await
3044 .expect("kg_query");
3045 let triples = queried["triples"].as_array().expect("triples");
3046 let predicates: Vec<&str> = triples
3047 .iter()
3048 .filter_map(|t| t["predicate"].as_str())
3049 .collect();
3050 assert!(
3051 predicates.contains(&"created_at"),
3052 "expected created_at after palace_create; got {predicates:?}",
3053 );
3054 assert!(
3055 predicates.contains(&"bootstrapped_at"),
3056 "expected bootstrapped_at after palace_create; got {predicates:?}",
3057 );
3058 assert!(
3060 queried.get("hint").is_none(),
3061 "hint should be absent when triples exist"
3062 );
3063 }
3064
3065 #[tokio::test]
3070 async fn kg_query_emits_hint_when_palace_empty() {
3071 let (state, _tmp) = test_state();
3072 let _ = dispatch_tool(&state, "palace_create", json!({"name": "hinted"}))
3073 .await
3074 .expect("palace_create");
3075 let queried = dispatch_tool(
3077 &state,
3078 "kg_query",
3079 json!({"palace": "hinted", "subject": "unrelated-subject"}),
3080 )
3081 .await
3082 .expect("kg_query");
3083 assert_eq!(queried["triples"].as_array().unwrap().len(), 0);
3084 let hint = queried["hint"].as_str().expect("hint field present");
3085 assert!(hint.contains("kg_bootstrap"));
3086 assert!(hint.contains("kg_assert"));
3087 }
3088
3089 #[tokio::test]
3093 async fn kg_bootstrap_seeds_workspace_facts() {
3094 let (state, _tmp) = test_state();
3095 let _ = dispatch_tool(&state, "palace_create", json!({"name": "ws"}))
3096 .await
3097 .expect("palace_create");
3098
3099 let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3100 .parent()
3101 .and_then(|p| p.parent())
3102 .expect("workspace root")
3103 .to_path_buf();
3104
3105 let result = dispatch_tool(
3106 &state,
3107 "kg_bootstrap",
3108 json!({"palace": "ws", "project_path": workspace_root.to_string_lossy()}),
3109 )
3110 .await
3111 .expect("kg_bootstrap");
3112 assert!(result["triples_asserted"].as_u64().unwrap() > 0);
3113 let subject = result["project_subject"]
3114 .as_str()
3115 .expect("project_subject")
3116 .to_string();
3117
3118 let queried = dispatch_tool(
3120 &state,
3121 "kg_query",
3122 json!({"palace": "ws", "subject": subject}),
3123 )
3124 .await
3125 .expect("kg_query");
3126 let triples = queried["triples"].as_array().expect("triples");
3127 let predicates: Vec<&str> = triples
3128 .iter()
3129 .filter_map(|t| t["predicate"].as_str())
3130 .collect();
3131 assert!(
3135 predicates.contains(&"has_workspace_member") || predicates.contains(&"has_language"),
3136 "expected workspace/language fact; got {predicates:?}",
3137 );
3138 assert!(
3140 predicates.contains(&"source_repo"),
3141 "expected source_repo from .git/config; got {predicates:?}",
3142 );
3143 assert!(predicates.contains(&"bootstrapped_at"));
3145 }
3146
3147 #[test]
3156 fn content_gate_blocks_short_no_context() {
3157 assert_eq!(content_gate("yes", None), None);
3158 assert_eq!(content_gate("ok", None), None);
3159 assert_eq!(
3160 content_gate(" no thanks ", None),
3161 None,
3162 "2 words still < 4"
3163 );
3164 assert_eq!(
3165 content_gate("one two three", None),
3166 None,
3167 "3 words still < 4"
3168 );
3169 }
3170
3171 #[test]
3177 fn content_gate_wraps_short_with_context() {
3178 let combined = content_gate(
3179 "yes",
3180 Some("Do you want to enable auto-bootstrap on new palaces?"),
3181 )
3182 .expect("context should unlock the gate");
3183 assert_eq!(
3184 combined,
3185 "Do you want to enable auto-bootstrap on new palaces?\n\n---\n\nyes",
3186 );
3187 let combined = content_gate(
3190 "the quick brown fox jumps over the lazy dog",
3191 Some("Famous typing pangram"),
3192 )
3193 .expect("long content + context still combines");
3194 assert!(combined.starts_with("Famous typing pangram"));
3195 assert!(combined.contains("\n\n---\n\n"));
3196 assert!(combined.ends_with("the quick brown fox jumps over the lazy dog"));
3197 }
3198
3199 #[test]
3206 fn content_gate_keeps_long() {
3207 let body = "User prefers snake_case for python";
3208 let kept = content_gate(body, None).expect(">= 4 words passes");
3209 assert_eq!(kept, body, "passing content must round-trip verbatim");
3210 let boundary = "one two three four";
3212 assert_eq!(content_gate(boundary, None).as_deref(), Some(boundary));
3213 }
3214
3215 #[test]
3222 fn content_gate_blank_context_treated_as_none() {
3223 assert_eq!(content_gate("yes", Some("")), None);
3224 assert_eq!(content_gate("yes", Some(" ")), None);
3225 assert_eq!(content_gate("yes", Some("\n\t")), None);
3226 }
3227
3228 #[tokio::test]
3234 async fn dispatch_remember_skips_short_no_context() {
3235 let (state, _tmp) = test_state();
3236 let _ = dispatch_tool(&state, "palace_create", json!({"name": "gate"}))
3237 .await
3238 .expect("palace_create");
3239
3240 let res = dispatch_tool(
3241 &state,
3242 "memory_remember",
3243 json!({"palace": "gate", "text": "yes"}),
3244 )
3245 .await
3246 .expect("memory_remember (short)");
3247 assert_eq!(res["status"], "skipped");
3248 assert!(res["reason"]
3249 .as_str()
3250 .unwrap_or("")
3251 .contains("content gate"));
3252 let listed = dispatch_tool(
3254 &state,
3255 "memory_list",
3256 json!({"palace": "gate", "limit": 10}),
3257 )
3258 .await
3259 .expect("memory_list");
3260 let drawers = listed["drawers"].as_array().expect("drawers array");
3261 assert!(
3262 drawers.is_empty(),
3263 "no drawer should be written; got {drawers:?}"
3264 );
3265 }
3266
3267 #[tokio::test]
3275 async fn dispatch_remember_with_context_writes_combined() {
3276 let (state, _tmp) = test_state();
3277 let _ = dispatch_tool(&state, "palace_create", json!({"name": "ctxgate"}))
3278 .await
3279 .expect("palace_create");
3280
3281 let res = dispatch_tool(
3282 &state,
3283 "memory_remember",
3284 json!({
3285 "palace": "ctxgate",
3286 "text": "yes",
3287 "context": "Do you want to enable auto-bootstrap on new palaces?",
3288 "force": true,
3289 }),
3290 )
3291 .await
3292 .expect("memory_remember (with context)");
3293 assert_eq!(res["status"], "stored");
3294
3295 let listed = dispatch_tool(
3296 &state,
3297 "memory_list",
3298 json!({"palace": "ctxgate", "limit": 10}),
3299 )
3300 .await
3301 .expect("memory_list");
3302 let drawers = listed["drawers"].as_array().expect("drawers array");
3303 assert_eq!(drawers.len(), 1);
3304 let body = drawers[0]["content"].as_str().expect("content");
3305 assert!(body.starts_with("Do you want to enable auto-bootstrap"));
3306 assert!(body.contains("\n\n---\n\n"));
3307 assert!(body.ends_with("yes"));
3308 }
3309
3310 #[tokio::test]
3317 async fn dispatch_note_skips_short_no_context() {
3318 let (state, _tmp) = test_state();
3319 let _ = dispatch_tool(&state, "palace_create", json!({"name": "noteg"}))
3320 .await
3321 .expect("palace_create");
3322
3323 let res = dispatch_tool(
3324 &state,
3325 "memory_note",
3326 json!({"palace": "noteg", "content": "ok"}),
3327 )
3328 .await
3329 .expect("memory_note (short)");
3330 assert_eq!(res["status"], "skipped");
3331 let listed = dispatch_tool(
3332 &state,
3333 "memory_list",
3334 json!({"palace": "noteg", "limit": 10}),
3335 )
3336 .await
3337 .expect("memory_list");
3338 assert!(listed["drawers"].as_array().unwrap().is_empty());
3339 }
3340
3341 #[tokio::test]
3342 async fn dispatch_unknown_tool_errors() {
3343 let (state, _tmp) = test_state();
3344 let err = dispatch_tool(&state, "does_not_exist", json!({}))
3345 .await
3346 .expect_err("should error");
3347 assert!(err.to_string().contains("unknown tool"));
3348 }
3349
3350 #[test]
3361 fn blocklist_gate_blocks_tool_use() {
3362 assert!(blocklist_gate("Tool use: Bash"));
3363 assert!(blocklist_gate(
3364 "Tool use: Edit File: /Users/me/Projects/foo/bar.rs"
3365 ));
3366 assert!(blocklist_gate(" Tool use: Read"));
3368 }
3369
3370 #[test]
3375 fn blocklist_gate_blocks_session_ended() {
3376 assert!(blocklist_gate(
3377 "Claude Code session ended: 1d2c3b4a-0000-0000-0000-000000000000"
3378 ));
3379 assert!(blocklist_gate("Claude Code session started"));
3380 }
3381
3382 #[test]
3388 fn blocklist_gate_passes_normal_content() {
3389 assert!(!blocklist_gate("User prefers snake_case for python"));
3390 assert!(!blocklist_gate(
3391 "Quokkas are the happiest marsupials in Australia"
3392 ));
3393 assert!(!blocklist_gate("Note: refactor the dispatcher next sprint"));
3394 assert!(blocklist_gate("I used Tool use: Bash here"));
3399 }
3400
3401 #[tokio::test]
3411 async fn dedup_skips_near_duplicate() {
3412 let (state, _tmp) = test_state();
3413 let _ = dispatch_tool(&state, "palace_create", json!({"name": "dedup1"}))
3414 .await
3415 .expect("palace_create");
3416
3417 let _ = dispatch_tool(
3420 &state,
3421 "memory_remember",
3422 json!({
3423 "palace": "dedup1",
3424 "text": "The quick brown fox jumped over the lazy dog repeatedly today",
3425 }),
3426 )
3427 .await
3428 .expect("memory_remember seed");
3429
3430 let handle = open_palace_handle(&state, "dedup1").expect("open handle");
3431 assert!(
3435 dedup_gate(
3436 &handle,
3437 "The quick brown fox jumped over the lazy dog repeatedly yesterday"
3438 ),
3439 "near-duplicate should be detected"
3440 );
3441 assert!(
3443 dedup_gate(
3444 &handle,
3445 "The quick brown fox jumped over the lazy dog repeatedly today"
3446 ),
3447 "exact match should be detected"
3448 );
3449 }
3450
3451 #[tokio::test]
3457 async fn dedup_allows_different_content() {
3458 let (state, _tmp) = test_state();
3459 let _ = dispatch_tool(&state, "palace_create", json!({"name": "dedup2"}))
3460 .await
3461 .expect("palace_create");
3462
3463 let _ = dispatch_tool(
3464 &state,
3465 "memory_remember",
3466 json!({
3467 "palace": "dedup2",
3468 "text": "Quokkas are the happiest marsupials in Australia by general consensus",
3469 }),
3470 )
3471 .await
3472 .expect("memory_remember seed");
3473
3474 let handle = open_palace_handle(&state, "dedup2").expect("open handle");
3475 assert!(
3477 !dedup_gate(
3478 &handle,
3479 "Rust is a systems programming language focused on safety and concurrency"
3480 ),
3481 "unrelated content should pass the dedup gate"
3482 );
3483 assert!(!dedup_gate(&handle, " "));
3486 }
3487
3488 #[tokio::test]
3503 async fn dedup_gate_blocks_concurrent_duplicate_writes() {
3504 let (state, _tmp) = test_state();
3505 let state = std::sync::Arc::new(state);
3506 let _ = dispatch_tool(&state, "palace_create", json!({"name": "dedup_race"}))
3507 .await
3508 .expect("palace_create");
3509
3510 let text =
3514 "Concurrent identical writes must collapse to a single drawer under the dedup gate";
3515
3516 let s1 = state.clone();
3517 let t1 = tokio::spawn(async move {
3518 dispatch_tool(
3519 &s1,
3520 "memory_remember",
3521 json!({"palace": "dedup_race", "text": text}),
3522 )
3523 .await
3524 });
3525 let s2 = state.clone();
3526 let t2 = tokio::spawn(async move {
3527 dispatch_tool(
3528 &s2,
3529 "memory_remember",
3530 json!({"palace": "dedup_race", "text": text}),
3531 )
3532 .await
3533 });
3534 let r1 = t1.await.expect("join t1").expect("dispatch t1");
3535 let r2 = t2.await.expect("join t2").expect("dispatch t2");
3536
3537 let statuses = [
3540 r1["status"].as_str().unwrap_or(""),
3541 r2["status"].as_str().unwrap_or(""),
3542 ];
3543 let stored = statuses.iter().filter(|s| **s == "stored").count();
3544 let skipped = statuses.iter().filter(|s| **s == "skipped").count();
3545 assert_eq!(
3546 stored, 1,
3547 "exactly one concurrent write should be stored; got responses {r1:?} {r2:?}"
3548 );
3549 assert_eq!(
3550 skipped, 1,
3551 "exactly one concurrent write should be skipped; got responses {r1:?} {r2:?}"
3552 );
3553 let skipped_reason = if r1["status"] == "skipped" {
3554 r1["reason"].as_str().unwrap_or("")
3555 } else {
3556 r2["reason"].as_str().unwrap_or("")
3557 };
3558 assert!(
3559 skipped_reason.contains("duplicate within window"),
3560 "skipped envelope should cite dedup reason; got {skipped_reason:?}"
3561 );
3562
3563 let listed = dispatch_tool(
3565 &state,
3566 "memory_list",
3567 json!({"palace": "dedup_race", "limit": 10}),
3568 )
3569 .await
3570 .expect("memory_list");
3571 let drawers = listed["drawers"].as_array().expect("drawers array");
3572 assert_eq!(
3573 drawers.len(),
3574 1,
3575 "only one drawer should be persisted after concurrent identical writes; got {drawers:?}"
3576 );
3577 }
3578
3579 #[tokio::test]
3587 async fn dispatch_remember_blocks_blocklist_pattern() {
3588 let (state, _tmp) = test_state();
3589 let _ = dispatch_tool(&state, "palace_create", json!({"name": "blk"}))
3590 .await
3591 .expect("palace_create");
3592
3593 let res = dispatch_tool(
3594 &state,
3595 "memory_remember",
3596 json!({"palace": "blk", "text": "Tool use: Bash"}),
3597 )
3598 .await
3599 .expect("memory_remember (blocked)");
3600 assert_eq!(res["status"], "skipped");
3601 assert!(
3602 res["reason"]
3603 .as_str()
3604 .unwrap_or("")
3605 .contains("blocked pattern"),
3606 "reason should mention blocked pattern; got {res:?}"
3607 );
3608
3609 let listed = dispatch_tool(&state, "memory_list", json!({"palace": "blk", "limit": 10}))
3610 .await
3611 .expect("memory_list");
3612 let drawers = listed["drawers"].as_array().expect("drawers array");
3613 assert!(drawers.is_empty(), "no drawer should be written");
3614 }
3615
3616 #[tokio::test]
3630 async fn bm25_index_queue_drops_when_full() {
3631 let (mut state, _tmp) = test_state();
3635 let (tx, _rx_held) =
3636 tokio::sync::mpsc::channel::<Bm25IndexRequest>(BM25_INDEX_QUEUE_CAPACITY);
3637 state.bm25_index_tx = tx;
3638
3639 for i in 0..BM25_INDEX_QUEUE_CAPACITY {
3641 bm25_index_enqueue(
3642 &state,
3643 "default",
3644 Uuid::new_v4(),
3645 &format!("filler content {i}"),
3646 );
3647 }
3648 assert_eq!(
3650 state.bm25_index_tx.capacity(),
3651 0,
3652 "after filling, sender capacity must be 0"
3653 );
3654
3655 for i in 0..16 {
3658 bm25_index_enqueue(
3659 &state,
3660 "default",
3661 Uuid::new_v4(),
3662 &format!("overflow content {i}"),
3663 );
3664 }
3665
3666 let probe_req = Bm25IndexRequest {
3670 palace: "default".to_string(),
3671 drawer_id: Uuid::new_v4().to_string(),
3672 content: "probe".to_string(),
3673 data_dir: state.data_root.join("default").join("bm25"),
3674 };
3675 let probe = state.bm25_index_tx.try_send(probe_req);
3676 match probe {
3677 Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {}
3678 other => panic!("expected Full overflow, got {other:?}"),
3679 }
3680 }
3681
3682 #[tokio::test]
3693 async fn remember_returns_warming_error_while_state_is_warming() {
3694 let (state, _tmp) = test_state_warming();
3698 let _ = dispatch_tool(
3700 &state,
3701 "palace_create",
3702 serde_json::json!({"name": "warmtest"}),
3703 )
3704 .await;
3705
3706 let result = dispatch_tool(
3707 &state,
3708 "memory_remember",
3709 serde_json::json!({
3710 "palace": "warmtest",
3711 "text": "test memory that should be rejected while warming up"
3712 }),
3713 )
3714 .await;
3715 let err = result.expect_err("memory_remember must fail while Warming");
3716 let msg = err.to_string();
3717 assert!(
3718 msg.contains("warming up"),
3719 "error must mention 'warming up'; got: {msg}"
3720 );
3721 }
3722
3723 #[tokio::test]
3729 async fn recall_returns_warming_error_while_state_is_warming() {
3730 let (state, _tmp) = test_state_warming();
3731 let _ = dispatch_tool(
3732 &state,
3733 "palace_create",
3734 serde_json::json!({"name": "warmtest-recall"}),
3735 )
3736 .await;
3737
3738 let result = dispatch_tool(
3739 &state,
3740 "memory_recall",
3741 serde_json::json!({
3742 "palace": "warmtest-recall",
3743 "query": "test query"
3744 }),
3745 )
3746 .await;
3747 let err = result.expect_err("memory_recall must fail while Warming");
3748 let msg = err.to_string();
3749 assert!(
3750 msg.contains("warming up"),
3751 "error must mention 'warming up'; got: {msg}"
3752 );
3753 }
3754
3755 #[tokio::test]
3759 async fn note_returns_warming_error_while_state_is_warming() {
3760 let (state, _tmp) = test_state_warming();
3761 let _ = dispatch_tool(
3762 &state,
3763 "palace_create",
3764 serde_json::json!({"name": "warmtest-note"}),
3765 )
3766 .await;
3767
3768 let result = dispatch_tool(
3769 &state,
3770 "memory_note",
3771 serde_json::json!({
3772 "palace": "warmtest-note",
3773 "content": "short note content here"
3774 }),
3775 )
3776 .await;
3777 let err = result.expect_err("memory_note must fail while Warming");
3778 let msg = err.to_string();
3779 assert!(
3780 msg.contains("warming up"),
3781 "error must mention 'warming up'; got: {msg}"
3782 );
3783 }
3784
3785 #[tokio::test]
3793 async fn recall_all_returns_warming_error_while_state_is_warming() {
3794 let (state, _tmp) = test_state_warming();
3795
3796 let result = dispatch_tool(
3797 &state,
3798 "memory_recall_all",
3799 serde_json::json!({
3800 "q": "test query that should be rejected while warming up"
3801 }),
3802 )
3803 .await;
3804 let err = result.expect_err("memory_recall_all must fail while Warming");
3805 let msg = err.to_string();
3806 assert!(
3807 msg.contains("warming up"),
3808 "error must mention 'warming up'; got: {msg}"
3809 );
3810 }
3811}