1use crate::attribution::{session_tag_from_tags, CreatorInfo, CreatorSource, MCP_CLIENT_NAME};
23use crate::kg_extract::{extract_triples, ExtractInput};
24use crate::{ActivitySource, AppState, DaemonEvent};
25use anyhow::{anyhow, Context, Result};
26use serde_json::{json, Value};
27use trusty_common::memory_core::filter::{FilterConfig, MCP_MIN_TOKENS};
28use trusty_common::memory_core::palace::{Palace, PalaceId, RoomType};
29use trusty_common::memory_core::retrieval::{
30 recall, recall_across_palaces, recall_deep, RememberOptions,
31};
32use trusty_common::memory_core::store::kg::Triple;
33use uuid::Uuid;
34
35fn lookup_palace_name(state: &AppState, palace_id: &str) -> String {
58 state
59 .palace_names
60 .get(palace_id)
61 .map(|entry| entry.value().clone())
62 .unwrap_or_else(|| palace_id.to_string())
63}
64
65const CONTENT_GATE_MIN_WORDS: usize = 4;
77
78fn content_gate(content: &str, context: Option<&str>) -> Option<String> {
94 let trimmed = content.trim();
95 let word_count = trimmed.split_whitespace().count();
96 let context_clean = context.map(str::trim).filter(|s| !s.is_empty());
101 if let Some(ctx) = context_clean {
102 return Some(format!("{ctx}\n\n---\n\n{content}"));
103 }
104 if word_count < CONTENT_GATE_MIN_WORDS {
105 return None;
106 }
107 Some(content.to_string())
108}
109
110const BLOCKLIST_PATTERNS: &[&str] = &[
127 "Tool use: ", "Claude Code session", ];
130
131const DEDUP_WINDOW_MINUTES: i64 = 5;
142
143const DEDUP_SCAN_LIMIT: usize = 50;
154
155const DEDUP_SIMILARITY_THRESHOLD: f64 = 0.92;
167
168fn blocklist_gate(content: &str) -> bool {
181 let trimmed = content.trim_start();
182 BLOCKLIST_PATTERNS.iter().any(|pat| trimmed.contains(pat))
183}
184
185fn dedup_gate(handle: &trusty_common::memory_core::PalaceHandle, content: &str) -> bool {
202 let trimmed = content.trim();
203 if trimmed.is_empty() {
204 return false;
205 }
206 let now = chrono::Utc::now();
207 let window_start = now - chrono::Duration::minutes(DEDUP_WINDOW_MINUTES);
208 let recent = handle.list_drawers(None, None, DEDUP_SCAN_LIMIT);
209 recent
210 .iter()
211 .filter(|d| d.created_at >= window_start)
212 .any(|d| strsim::jaro_winkler(trimmed, d.content.trim()) > DEDUP_SIMILARITY_THRESHOLD)
213}
214
215fn mcp_remember_opts(force: bool) -> RememberOptions {
223 let filter = FilterConfig {
224 min_tokens: MCP_MIN_TOKENS,
225 ..FilterConfig::default()
226 };
227 RememberOptions {
228 filter,
229 force,
230 ..RememberOptions::default()
231 }
232}
233
234pub struct MemoryMcpServer;
241
242impl MemoryMcpServer {
243 pub fn new() -> Self {
244 Self
245 }
246}
247
248impl Default for MemoryMcpServer {
249 fn default() -> Self {
250 Self::new()
251 }
252}
253
254pub fn tool_definitions() -> Value {
265 tool_definitions_with(false)
266}
267
268pub fn tool_definitions_with(has_default: bool) -> Value {
279 let memory_remember_required: Vec<&str> = if has_default {
280 vec!["text"]
281 } else {
282 vec!["palace", "text"]
283 };
284 let memory_recall_required: Vec<&str> = if has_default {
285 vec!["query"]
286 } else {
287 vec!["palace", "query"]
288 };
289 let kg_assert_required: Vec<&str> = if has_default {
290 vec!["subject", "predicate", "object"]
291 } else {
292 vec!["palace", "subject", "predicate", "object"]
293 };
294 let kg_query_required: Vec<&str> = if has_default {
295 vec!["subject"]
296 } else {
297 vec!["palace", "subject"]
298 };
299 let memory_list_required: Vec<&str> = if has_default { vec![] } else { vec!["palace"] };
300 let memory_forget_required: Vec<&str> = if has_default {
301 vec!["drawer_id"]
302 } else {
303 vec!["palace", "drawer_id"]
304 };
305 let palace_info_required: Vec<&str> = if has_default { vec![] } else { vec!["palace"] };
306 let palace_compact_required: Vec<&str> = if has_default { vec![] } else { vec!["palace"] };
307 let memory_note_required: Vec<&str> = if has_default {
308 vec!["content"]
309 } else {
310 vec!["palace", "content"]
311 };
312
313 json!({
314 "tools": [
315 {
316 "name": "memory_remember",
317 "description": "Store a memory (drawer) in a palace room. Content is filtered for signal vs. noise (issue #61): rejects empty/very short content, raw tool/commit output, and code-only blobs. Issue #215: very short standalone content (< 4 words) is silently dropped unless a `context` is supplied, in which case the context is prepended so the stored memory has standalone value. Pass force=true to bypass filtering, or use memory_note for short curated facts.",
318 "inputSchema": {
319 "type": "object",
320 "properties": {
321 "palace": {"type": "string", "description": "Palace ID (optional if server started with --palace)"},
322 "text": {"type": "string", "description": "Memory content"},
323 "room": {"type": "string", "description": "Room type (optional)"},
324 "tags": {"type": "array", "items": {"type": "string"}},
325 "force": {"type": "boolean", "description": "Bypass the signal/noise filter. Use sparingly — intended for explicit operator overrides.", "default": false},
326 "context": {"type": "string", "description": "Optional surrounding context. When supplied alongside very short content (< 4 words), the context is prepended (separated by `---`) so the stored memory has standalone meaning; without it, short content is dropped (issue #215)."}
327 },
328 "required": memory_remember_required,
329 }
330 },
331 {
332 "name": "memory_note",
333 "description": "Curated shortcut for short, high-signal facts (\"User prefers snake_case\", \"Deploy target is prod-east\"). Bypasses the token-length filter but still rejects auto-capture noise. Stored as DrawerType::UserFact with importance 1.0. Issue #215: a `context` argument can be supplied to wrap an otherwise meaningless single-word response.",
334 "inputSchema": {
335 "type": "object",
336 "properties": {
337 "palace": {"type": "string"},
338 "content": {"type": "string", "description": "Brief fact to remember"},
339 "tags": {"type": "array", "items": {"type": "string"}},
340 "context": {"type": "string", "description": "Optional surrounding context. Prepended to `content` (separated by `---`) when supplied; with very short content (< 4 words) and no context the write is skipped (issue #215)."}
341 },
342 "required": memory_note_required,
343 }
344 },
345 {
346 "name": "memory_recall",
347 "description": "Recall memories using L0+L1+L2 progressive retrieval.",
348 "inputSchema": {
349 "type": "object",
350 "properties": {
351 "palace": {"type": "string"},
352 "query": {"type": "string"},
353 "top_k": {"type": "integer", "default": 10}
354 },
355 "required": memory_recall_required,
356 }
357 },
358 {
359 "name": "memory_recall_deep",
360 "description": "Deep recall using L3 full HNSW search.",
361 "inputSchema": {
362 "type": "object",
363 "properties": {
364 "palace": {"type": "string"},
365 "query": {"type": "string"},
366 "top_k": {"type": "integer", "default": 10}
367 },
368 "required": memory_recall_required,
369 }
370 },
371 {
372 "name": "palace_create",
373 "description": "Create a new memory palace.",
374 "inputSchema": {
375 "type": "object",
376 "properties": {
377 "name": {"type": "string"},
378 "description": {"type": "string"},
379 "cwd": {"type": "string", "description": "Optional caller working directory used for palace-name enforcement. Pass the project root (or any path inside it) so the pin file at `.trusty-tools/trusty-memory.yaml` is honoured. When omitted, the daemon's own cwd is used (rarely meaningful for remote calls)."}
380 },
381 "required": ["name"]
382 }
383 },
384 {
385 "name": "palace_list",
386 "description": "List all palaces on this machine.",
387 "inputSchema": {"type": "object", "properties": {}}
388 },
389 {
390 "name": "palace_delete",
391 "description": "Delete an entire memory palace, including its drawers, vectors, and knowledge graph. Refuses to delete a non-empty palace unless `force=true` is set.",
392 "inputSchema": {
393 "type": "object",
394 "properties": {
395 "palace_id": {"type": "string", "description": "Id of the palace to delete."},
396 "force": {"type": "boolean", "description": "Required when the palace still has drawers; defaults to false.", "default": false}
397 },
398 "required": ["palace_id"]
399 }
400 },
401 {
402 "name": "palace_update",
403 "description": "Update the display name of an existing palace. The palace's drawers, vectors, and knowledge graph are preserved; only the human-readable name changes.",
404 "inputSchema": {
405 "type": "object",
406 "properties": {
407 "palace_id": {"type": "string", "description": "Id of the palace to rename."},
408 "name": {"type": "string", "description": "New display name. Trimmed; must be non-empty."}
409 },
410 "required": ["palace_id", "name"]
411 }
412 },
413 {
414 "name": "kg_assert",
415 "description": "Assert a fact in the temporal knowledge graph.",
416 "inputSchema": {
417 "type": "object",
418 "properties": {
419 "palace": {"type": "string"},
420 "subject": {"type": "string"},
421 "predicate": {"type": "string"},
422 "object": {"type": "string"},
423 "confidence": {"type": "number", "default": 1.0},
424 "provenance": {"type": "string"}
425 },
426 "required": kg_assert_required,
427 }
428 },
429 {
430 "name": "kg_query",
431 "description": "Query active knowledge-graph triples for a subject.",
432 "inputSchema": {
433 "type": "object",
434 "properties": {
435 "palace": {"type": "string"},
436 "subject": {"type": "string"}
437 },
438 "required": kg_query_required,
439 }
440 },
441 {
442 "name": "memory_list",
443 "description": "List drawers in a palace, optionally filtered by room type or tag.",
444 "inputSchema": {
445 "type": "object",
446 "properties": {
447 "palace": {"type": "string"},
448 "room": {"type": "string", "description": "Filter by room type (Frontend, Backend, Testing, Planning, Documentation, Research, Configuration, Meetings, General, or custom)"},
449 "tag": {"type": "string", "description": "Filter by tag"},
450 "limit": {"type": "integer", "description": "Max results (default 50)"}
451 },
452 "required": memory_list_required,
453 }
454 },
455 {
456 "name": "memory_forget",
457 "description": "Delete a drawer from a palace by its UUID.",
458 "inputSchema": {
459 "type": "object",
460 "properties": {
461 "palace": {"type": "string"},
462 "drawer_id": {"type": "string", "description": "UUID of the drawer to delete"}
463 },
464 "required": memory_forget_required,
465 }
466 },
467 {
468 "name": "palace_info",
469 "description": "Get metadata and stats for a single palace.",
470 "inputSchema": {
471 "type": "object",
472 "properties": {
473 "palace": {"type": "string"}
474 },
475 "required": palace_info_required,
476 }
477 },
478 {
479 "name": "palace_compact",
480 "description": "Remove orphaned vector index entries (vectors with no matching drawer row). See issue #49.",
481 "inputSchema": {
482 "type": "object",
483 "properties": {
484 "palace": {"type": "string"}
485 },
486 "required": palace_compact_required,
487 }
488 },
489 {
490 "name": "add_alias",
491 "description": "Add a short→full alias (e.g. tga → trusty-git-analytics) to the prompt-facts surface. Asserts the alias as a hot KG triple and refreshes the session-init prompt cache.",
492 "inputSchema": {
493 "type": "object",
494 "properties": {
495 "short": {"type": "string", "description": "Short name / alias (subject)"},
496 "full": {"type": "string", "description": "Full / canonical name (object)"},
497 "extra": {"type": "string", "description": "Optional extra context appended to the full name"}
498 },
499 "required": ["short", "full"],
500 }
501 },
502 {
503 "name": "list_prompt_facts",
504 "description": "List every active prompt-fact triple (aliases, conventions, facts, shorthands) across all palaces.",
505 "inputSchema": {"type": "object", "properties": {}}
506 },
507 {
508 "name": "remove_prompt_fact",
509 "description": "Retract the active triple for a (subject, predicate) pair from the prompt-facts surface. Closes the interval without inserting a replacement.",
510 "inputSchema": {
511 "type": "object",
512 "properties": {
513 "subject": {"type": "string"},
514 "predicate": {"type": "string", "description": "One of is_alias_for, has_convention, is_fact, is_shorthand_for"}
515 },
516 "required": ["subject", "predicate"],
517 }
518 },
519 {
520 "name": "get_prompt_context",
521 "description": "Fetch the current project context (aliases, conventions, facts, shorthands) from the memory palace as a Markdown block ready to drop into the model's working context. Call at the start of each turn. Pass an optional `query` to filter to facts whose subject or object contains the query string (case-insensitive).",
522 "inputSchema": {
523 "type": "object",
524 "properties": {
525 "query": {
526 "type": "string",
527 "description": "Optional filter — only return facts whose subject or object contains this string (case-insensitive). Omit to return all hot facts."
528 }
529 }
530 }
531 },
532 {
533 "name": "discover_aliases",
534 "description": "Auto-discover project aliases by scanning Cargo workspace members, binary names, first-letter abbreviations, and the git remote. Asserts any newly-discovered (short, is_alias_for, full) triples into the resolved palace and rebuilds the prompt cache. Skips triples that already exist active in the KG.",
535 "inputSchema": {
536 "type": "object",
537 "properties": {
538 "project_root": {"type": "string", "description": "Optional filesystem path to scan. Defaults to the process cwd."}
539 }
540 }
541 },
542 {
543 "name": "kg_gaps",
544 "description": "List knowledge gaps detected in the memory palace graph. Returns communities (clusters of related entities) with low internal density that may benefit from additional knowledge. Populated by the dream cycle; an empty list means no cycle has run yet.",
545 "inputSchema": {
546 "type": "object",
547 "properties": {
548 "palace": {"type": "string", "description": "Palace name (optional, defaults to the active palace)"}
549 }
550 }
551 },
552 {
553 "name": "kg_bootstrap",
554 "description": "Seed the knowledge graph from well-known project files (Cargo.toml, package.json, pyproject.toml, go.mod, CLAUDE.md, .git/config). Asserts structured triples (has_language, has_version, source_repo, ...) plus temporal metadata (created_at, bootstrapped_at). Idempotent: re-running refreshes bootstrapped_at without disturbing created_at. See issue #60.",
555 "inputSchema": {
556 "type": "object",
557 "properties": {
558 "palace": {"type": "string", "description": "Palace ID (optional if server started with --palace)"},
559 "project_path": {"type": "string", "description": "Filesystem path to scan. Omit to scan the palace's own data dir (temporal metadata only)."}
560 }
561 }
562 },
563 {
564 "name": "memory_recall_all",
565 "description": "Semantic search across ALL palaces simultaneously. Returns the top-k most relevant drawers ranked by similarity, regardless of which palace they belong to. Each result includes a `palace_id` field identifying its source.",
566 "inputSchema": {
567 "type": "object",
568 "properties": {
569 "q": {"type": "string", "description": "Free-text query"},
570 "top_k": {"type": "integer", "default": 10},
571 "deep": {"type": "boolean", "default": false}
572 },
573 "required": ["q"],
574 }
575 },
576 {
577 "name": "memory_send_message",
578 "description": "Send an inter-project message (issue #99). Writes a tagged drawer into the recipient palace; the recipient's SessionStart hook picks it up via `trusty-memory inbox-check`. `to_palace` is the recipient repo slug (e.g. `trusty-tools`, `claude-mpm`). `from_palace` defaults to the calling project's cwd-derived slug when omitted.",
579 "inputSchema": {
580 "type": "object",
581 "properties": {
582 "to_palace": {"type": "string", "description": "Recipient palace id (repo slug)."},
583 "purpose": {"type": "string", "description": "Free-text purpose / category (e.g. `task`, `notify`, `reply`)."},
584 "content": {"type": "string", "description": "Message body — plain text, no length limit. Rendered into the recipient session as a Markdown block."},
585 "from_palace": {"type": "string", "description": "Sender palace id (optional, defaults to cwd-derived slug)."}
586 },
587 "required": ["to_palace", "purpose", "content"],
588 }
589 }
590 ]
591 })
592}
593
594pub(crate) fn room_label(room: &RoomType) -> Option<String> {
605 let label = match room {
606 RoomType::Frontend => "Frontend",
607 RoomType::Backend => "Backend",
608 RoomType::Testing => "Testing",
609 RoomType::Planning => "Planning",
610 RoomType::Documentation => "Documentation",
611 RoomType::Research => "Research",
612 RoomType::Configuration => "Configuration",
613 RoomType::Meetings => "Meetings",
614 RoomType::General => "General",
615 RoomType::Custom(s) => return Some(s.clone()),
616 };
617 Some(label.to_string())
618}
619
620fn parse_room(s: Option<&str>) -> RoomType {
628 match s.unwrap_or("General") {
629 "Frontend" => RoomType::Frontend,
630 "Backend" => RoomType::Backend,
631 "Testing" => RoomType::Testing,
632 "Planning" => RoomType::Planning,
633 "Documentation" => RoomType::Documentation,
634 "Research" => RoomType::Research,
635 "Configuration" => RoomType::Configuration,
636 "Meetings" => RoomType::Meetings,
637 "General" => RoomType::General,
638 other => RoomType::Custom(other.to_string()),
639 }
640}
641
642fn open_palace_handle(
644 state: &AppState,
645 palace_id: &str,
646) -> Result<std::sync::Arc<trusty_common::memory_core::PalaceHandle>> {
647 let pid = PalaceId::new(palace_id);
648 state
649 .registry
650 .open_palace(&state.data_root, &pid)
651 .with_context(|| format!("open palace {palace_id}"))
652}
653
654pub(crate) async fn auto_extract_and_assert(
670 handle: &std::sync::Arc<trusty_common::memory_core::PalaceHandle>,
671 drawer_id: Uuid,
672 content: &str,
673 tags: &[String],
674 room: Option<&str>,
675) {
676 let input = ExtractInput {
677 drawer_id,
678 content,
679 tags,
680 room,
681 };
682 let triples = extract_triples(&input);
683 if triples.is_empty() {
684 return;
685 }
686 for triple in triples {
687 let s = triple.subject.clone();
688 let p = triple.predicate.clone();
689 if let Err(e) = handle.kg.assert(triple).await {
690 tracing::warn!(
691 drawer_id = %drawer_id,
692 subject = %s,
693 predicate = %p,
694 "auto kg extraction: assert failed (non-fatal): {e:#}",
695 );
696 }
697 }
698}
699
700fn resolve_palace<'a>(state: &'a AppState, args: &'a Value, tool: &str) -> Result<String> {
712 if let Some(p) = args.get("palace").and_then(|v| v.as_str()) {
713 return Ok(p.to_string());
714 }
715 state
716 .default_palace
717 .clone()
718 .ok_or_else(|| anyhow!("{tool}: missing 'palace' (no --palace default configured)"))
719}
720
721struct WriteDrawerParams<'a> {
735 palace_id: &'a str,
736 content: String,
737 tags: Vec<String>,
738 room: RoomType,
739 importance: f32,
740 opts: RememberOptions,
741 room_label_for_kg: Option<String>,
742}
743
744async fn write_drawer(state: &AppState, params: WriteDrawerParams<'_>) -> Result<Uuid> {
760 let WriteDrawerParams {
761 palace_id,
762 content,
763 tags,
764 room,
765 importance,
766 opts,
767 room_label_for_kg,
768 } = params;
769
770 let handle = open_palace_handle(state, palace_id)?;
771 let preview = crate::service::drawer_content_preview(&content);
774 let content_for_kg = content.clone();
778 let tags_for_kg = tags.clone();
779 let drawer_id = handle
780 .remember_with_options(content, room, tags, importance, opts)
781 .await
782 .context("PalaceHandle::remember_with_options")?;
783 bm25_index_enqueue(state, palace_id, drawer_id, &content_for_kg);
789 let palace_name = lookup_palace_name(state, palace_id);
792 let drawer_count = handle.drawers.read().len();
793 state.emit(DaemonEvent::DrawerAdded {
794 palace_id: palace_id.to_string(),
795 palace_name,
796 drawer_count,
797 timestamp: chrono::Utc::now(),
798 content_preview: preview,
799 source: ActivitySource::Mcp,
800 });
801 auto_extract_and_assert(
809 &handle,
810 drawer_id,
811 &content_for_kg,
812 &tags_for_kg,
813 room_label_for_kg.as_deref(),
814 )
815 .await;
816 Ok(drawer_id)
817}
818
819fn skipped_envelope(palace_id: &str, reason: &str) -> Value {
831 json!({
832 "palace": palace_id,
833 "status": "skipped",
834 "reason": reason,
835 })
836}
837
838fn parse_tags(args: &Value) -> Vec<String> {
848 args.get("tags")
849 .and_then(|v| v.as_array())
850 .map(|arr| {
851 arr.iter()
852 .filter_map(|t| t.as_str().map(|s| s.to_string()))
853 .collect()
854 })
855 .unwrap_or_default()
856}
857
858fn attach_mcp_attribution(tags: &mut Vec<String>) {
870 if let Some(session_tag) = session_tag_from_tags(tags) {
871 tags.push(session_tag);
872 }
873 CreatorInfo::new_self(MCP_CLIENT_NAME, CreatorSource::Mcp).merge_into(tags);
874}
875
876async fn handle_memory_remember(state: &AppState, args: Value) -> Result<Value> {
886 let palace = resolve_palace(state, &args, "memory_remember")?;
887 let palace = palace.as_str();
888 let raw_text = args
889 .get("text")
890 .and_then(|v| v.as_str())
891 .ok_or_else(|| anyhow!("memory_remember: missing 'text'"))?
892 .to_string();
893 if blocklist_gate(&raw_text) {
898 tracing::debug!(
899 palace = %palace,
900 "content gate: skipped (blocked pattern)",
901 );
902 return Ok(skipped_envelope(
903 palace,
904 "content gate: skipped (blocked pattern)",
905 ));
906 }
907 let ctx = args.get("context").and_then(|v| v.as_str());
913 let text = match content_gate(&raw_text, ctx) {
914 Some(t) => t,
915 None => {
916 return Ok(skipped_envelope(
917 palace,
918 "content gate: skipped (short prompt, no context)",
919 ));
920 }
921 };
922 let room = parse_room(args.get("room").and_then(|v| v.as_str()));
923 let mut tags = parse_tags(&args);
924 attach_mcp_attribution(&mut tags);
932
933 let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
934
935 let write_lock = state.palace_write_lock(palace);
943 let _write_guard = write_lock.lock().await;
944
945 if !force {
950 let handle = open_palace_handle(state, palace)?;
951 if dedup_gate(&handle, &text) {
952 tracing::debug!(
953 palace = %palace,
954 "content gate: skipped (duplicate within window)",
955 );
956 return Ok(skipped_envelope(
957 palace,
958 "content gate: skipped (duplicate within window)",
959 ));
960 }
961 }
962 let room_label_for_kg = room_label(&room);
963 let drawer_id = write_drawer(
964 state,
965 WriteDrawerParams {
966 palace_id: palace,
967 content: text,
968 tags,
969 room,
970 importance: 0.5,
971 opts: mcp_remember_opts(force),
972 room_label_for_kg,
973 },
974 )
975 .await?;
976 Ok(json!({
977 "drawer_id": drawer_id.to_string(),
978 "palace": palace,
979 "status": "stored",
980 }))
981}
982
983async fn handle_memory_note(state: &AppState, args: Value) -> Result<Value> {
984 let palace = resolve_palace(state, &args, "memory_note")?;
990 let palace = palace.as_str();
991 let raw_content = args
992 .get("content")
993 .and_then(|v| v.as_str())
994 .ok_or_else(|| anyhow!("memory_note: missing 'content'"))?
995 .to_string();
996 if blocklist_gate(&raw_content) {
1001 tracing::debug!(
1002 palace = %palace,
1003 "content gate: skipped (blocked pattern)",
1004 );
1005 return Ok(skipped_envelope(
1006 palace,
1007 "content gate: skipped (blocked pattern)",
1008 ));
1009 }
1010 let ctx = args.get("context").and_then(|v| v.as_str());
1015 let content = match content_gate(&raw_content, ctx) {
1016 Some(c) => c,
1017 None => {
1018 return Ok(skipped_envelope(
1019 palace,
1020 "content gate: skipped (short prompt, no context)",
1021 ));
1022 }
1023 };
1024 let mut tags = parse_tags(&args);
1025 attach_mcp_attribution(&mut tags);
1029 let write_lock = state.palace_write_lock(palace);
1037 let _write_guard = write_lock.lock().await;
1038 {
1043 let handle = open_palace_handle(state, palace)?;
1044 if dedup_gate(&handle, &content) {
1045 tracing::debug!(
1046 palace = %palace,
1047 "content gate: skipped (duplicate within window)",
1048 );
1049 return Ok(skipped_envelope(
1050 palace,
1051 "content gate: skipped (duplicate within window)",
1052 ));
1053 }
1054 }
1055 let drawer_id = write_drawer(
1059 state,
1060 WriteDrawerParams {
1061 palace_id: palace,
1062 content,
1063 tags,
1064 room: RoomType::General,
1065 importance: 1.0,
1066 opts: RememberOptions::note(),
1067 room_label_for_kg: Some("General".to_string()),
1071 },
1072 )
1073 .await
1074 .context("PalaceHandle::remember_with_options (note)")?;
1075 Ok(json!({
1076 "drawer_id": drawer_id.to_string(),
1077 "palace": palace,
1078 "status": "stored",
1079 "drawer_type": "UserFact",
1080 }))
1081}
1082
1083async fn handle_memory_recall(state: &AppState, args: Value) -> Result<Value> {
1084 let palace = resolve_palace(state, &args, "memory_recall")?;
1085 let query = args
1086 .get("query")
1087 .and_then(|v| v.as_str())
1088 .ok_or_else(|| anyhow!("memory_recall: missing 'query'"))?;
1089 let top_k = args.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
1090
1091 let handle = open_palace_handle(state, &palace)?;
1092 let embedder = state.embedder().await?;
1093 let vector_fut = recall(&handle, embedder.as_ref(), query, top_k);
1099 let bm25_fut = bm25_search_optional(state, &palace, query, top_k);
1100 let (vector_res, bm25_res) = tokio::join!(vector_fut, bm25_fut);
1101 let mut results = vector_res.context("recall")?;
1102 if let Some(bm25_hits) = bm25_res {
1103 fuse_bm25_into_recall(&mut results, &bm25_hits, top_k);
1104 }
1105 Ok(serialize_recall(&palace, query, results))
1106}
1107
1108async fn handle_memory_recall_deep(state: &AppState, args: Value) -> Result<Value> {
1109 let palace = resolve_palace(state, &args, "memory_recall_deep")?;
1110 let query = args
1111 .get("query")
1112 .and_then(|v| v.as_str())
1113 .ok_or_else(|| anyhow!("memory_recall_deep: missing 'query'"))?;
1114 let top_k = args.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
1115
1116 let handle = open_palace_handle(state, &palace)?;
1117 let embedder = state.embedder().await?;
1118 let results = recall_deep(&handle, embedder.as_ref(), query, top_k)
1119 .await
1120 .context("recall_deep")?;
1121 Ok(serialize_recall(&palace, query, results))
1122}
1123
1124async fn handle_palace_create(state: &AppState, args: Value) -> Result<Value> {
1125 let palace_name = args
1126 .get("name")
1127 .and_then(|v| v.as_str())
1128 .ok_or_else(|| anyhow!("palace_create: missing 'name'"))?;
1129
1130 let skip_enforcement = std::env::var("TRUSTY_SKIP_PALACE_ENFORCEMENT").as_deref() == Ok("1");
1146 if !skip_enforcement {
1147 let cwd = args
1148 .get("cwd")
1149 .and_then(|v| v.as_str())
1150 .filter(|s| !s.is_empty())
1151 .map(std::path::Path::new)
1152 .map(|p| p.to_path_buf())
1153 .or_else(|| std::env::current_dir().ok())
1154 .unwrap_or_else(|| state.data_root.clone());
1155 crate::project_root::validate_palace_name(palace_name, &cwd)?;
1156 }
1157
1158 let description = args
1159 .get("description")
1160 .and_then(|v| v.as_str())
1161 .map(|s| s.to_string());
1162 let palace = Palace {
1163 id: PalaceId::new(palace_name),
1164 name: palace_name.to_string(),
1165 description,
1166 created_at: chrono::Utc::now(),
1167 data_dir: state.data_root.join(palace_name),
1168 };
1169 let _handle = state
1170 .registry
1171 .create_palace(&state.data_root, palace)
1172 .context("create_palace")?;
1173 state
1177 .palace_names
1178 .insert(palace_name.to_string(), palace_name.to_string());
1179 state.emit(DaemonEvent::PalaceCreated {
1182 id: palace_name.to_string(),
1183 name: palace_name.to_string(),
1184 source: ActivitySource::Mcp,
1185 });
1186 let bootstrap_summary = match crate::bootstrap::bootstrap_palace(state, palace_name, None).await
1194 {
1195 Ok(r) => Some(serde_json::json!({
1196 "triples_asserted": r.triples_asserted,
1197 "project_subject": r.project_subject,
1198 })),
1199 Err(e) => {
1200 tracing::warn!(
1201 palace = %palace_name,
1202 "auto-bootstrap on palace_create failed: {e:#}",
1203 );
1204 None
1205 }
1206 };
1207 Ok(json!({
1208 "palace_id": palace_name,
1209 "status": "created",
1210 "bootstrap": bootstrap_summary,
1211 }))
1212}
1213
1214async fn handle_palace_list(state: &AppState, _args: Value) -> Result<Value> {
1215 let root = state.data_root.clone();
1216 let palaces = tokio::task::spawn_blocking(move || {
1217 trusty_common::memory_core::PalaceRegistry::list_palaces(&root)
1218 })
1219 .await
1220 .context("join list_palaces")??;
1221 let ids: Vec<String> = palaces.iter().map(|p| p.id.as_str().to_string()).collect();
1222 Ok(json!({ "palaces": ids }))
1223}
1224
1225async fn handle_palace_delete(state: &AppState, args: Value) -> Result<Value> {
1226 let palace_id = args
1234 .get("palace_id")
1235 .and_then(|v| v.as_str())
1236 .ok_or_else(|| anyhow!("palace_delete: missing 'palace_id'"))?
1237 .to_string();
1238 let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
1239 use crate::service::{MemoryService, ServiceError};
1240 let svc = MemoryService::new(state.clone());
1241 match svc.delete_palace(&palace_id, force).await {
1242 Ok(()) => Ok(json!({ "deleted": palace_id })),
1243 Err(ServiceError::NotFound(_)) => Err(anyhow!("Palace not found: {palace_id}")),
1244 Err(ServiceError::Conflict(msg)) => Err(anyhow!(msg)),
1245 Err(e) => Err(anyhow!("palace_delete: {e}")),
1246 }
1247}
1248
1249async fn handle_palace_update(state: &AppState, args: Value) -> Result<Value> {
1250 let palace_id = args
1259 .get("palace_id")
1260 .and_then(|v| v.as_str())
1261 .ok_or_else(|| anyhow!("palace_update: missing 'palace_id'"))?
1262 .to_string();
1263 let name = args
1264 .get("name")
1265 .and_then(|v| v.as_str())
1266 .ok_or_else(|| anyhow!("palace_update: missing 'name'"))?
1267 .to_string();
1268 use crate::service::MemoryService;
1269 let svc = MemoryService::new(state.clone());
1270 match svc.update_palace_name(&palace_id, &name).await {
1271 Ok(_info) => Ok(json!({ "updated": palace_id, "name": name.trim() })),
1272 Err(e) => Err(anyhow!("palace_update: {e}")),
1273 }
1274}
1275
1276async fn handle_kg_assert(state: &AppState, args: Value) -> Result<Value> {
1277 let palace = resolve_palace(state, &args, "kg_assert")?;
1278 let palace = palace.as_str();
1279 let subject = args
1280 .get("subject")
1281 .and_then(|v| v.as_str())
1282 .ok_or_else(|| anyhow!("kg_assert: missing 'subject'"))?
1283 .to_string();
1284 let predicate = args
1285 .get("predicate")
1286 .and_then(|v| v.as_str())
1287 .ok_or_else(|| anyhow!("kg_assert: missing 'predicate'"))?
1288 .to_string();
1289 let object = args
1290 .get("object")
1291 .and_then(|v| v.as_str())
1292 .ok_or_else(|| anyhow!("kg_assert: missing 'object'"))?
1293 .to_string();
1294 let confidence = args
1295 .get("confidence")
1296 .and_then(|v| v.as_f64())
1297 .map(|c| (c as f32).clamp(0.0, 1.0))
1298 .unwrap_or(1.0);
1299 let provenance = args
1300 .get("provenance")
1301 .and_then(|v| v.as_str())
1302 .map(|s| s.to_string());
1303
1304 let handle = open_palace_handle(state, palace)?;
1305 let triple = Triple {
1306 subject,
1307 predicate,
1308 object,
1309 valid_from: chrono::Utc::now(),
1310 valid_to: None,
1311 confidence,
1312 provenance,
1313 };
1314 let is_hot = crate::prompt_facts::is_hot_predicate(&triple.predicate);
1315 handle.kg.assert(triple).await.context("kg.assert")?;
1316 if is_hot {
1321 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1322 tracing::warn!("rebuild_prompt_cache after kg_assert failed: {e:#}");
1323 }
1324 }
1325 Ok(json!({ "status": "asserted" }))
1326}
1327
1328async fn handle_add_alias(state: &AppState, args: Value) -> Result<Value> {
1329 let short = args
1330 .get("short")
1331 .and_then(|v| v.as_str())
1332 .ok_or_else(|| anyhow!("add_alias: missing 'short'"))?
1333 .to_string();
1334 let full = args
1335 .get("full")
1336 .and_then(|v| v.as_str())
1337 .ok_or_else(|| anyhow!("add_alias: missing 'full'"))?
1338 .to_string();
1339 let extra = args
1340 .get("extra")
1341 .and_then(|v| v.as_str())
1342 .map(|s| s.to_string());
1343
1344 let palace = resolve_palace(state, &args, "add_alias")?;
1349 let handle = open_palace_handle(state, &palace)?;
1350 let object = match extra.as_deref() {
1352 Some(e) if !e.is_empty() => format!("{full} ({e})"),
1353 _ => full.clone(),
1354 };
1355 let triple = Triple {
1356 subject: short.clone(),
1357 predicate: "is_alias_for".to_string(),
1358 object,
1359 valid_from: chrono::Utc::now(),
1360 valid_to: None,
1361 confidence: 1.0,
1362 provenance: Some("add_alias".to_string()),
1363 };
1364 handle
1365 .kg
1366 .assert(triple)
1367 .await
1368 .context("kg.assert (alias)")?;
1369 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1370 tracing::warn!("rebuild_prompt_cache after add_alias failed: {e:#}");
1371 }
1372 Ok(json!({ "asserted": true, "short": short, "full": full }))
1373}
1374
1375async fn handle_list_prompt_facts(state: &AppState, _args: Value) -> Result<Value> {
1376 let triples = crate::prompt_facts::gather_hot_triples(state).await?;
1377 let payload: Vec<Value> = triples
1378 .into_iter()
1379 .map(|(subject, predicate, object)| {
1380 json!({ "subject": subject, "predicate": predicate, "object": object })
1381 })
1382 .collect();
1383 Ok(json!({ "facts": payload }))
1384}
1385
1386async fn handle_remove_prompt_fact(state: &AppState, args: Value) -> Result<Value> {
1387 let subject = args
1388 .get("subject")
1389 .and_then(|v| v.as_str())
1390 .ok_or_else(|| anyhow!("remove_prompt_fact: missing 'subject'"))?
1391 .to_string();
1392 let predicate = args
1393 .get("predicate")
1394 .and_then(|v| v.as_str())
1395 .ok_or_else(|| anyhow!("remove_prompt_fact: missing 'predicate'"))?
1396 .to_string();
1397
1398 let mut closed_total: usize = 0;
1404 for palace_id in state.registry.list() {
1405 if let Some(handle) = state.registry.get(&palace_id) {
1406 match handle.kg.retract(&subject, &predicate).await {
1407 Ok(n) => closed_total += n,
1408 Err(e) => tracing::warn!(
1409 palace = %palace_id.as_str(),
1410 "retract failed: {e:#}",
1411 ),
1412 }
1413 }
1414 }
1415 if closed_total > 0 {
1416 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1417 tracing::warn!("rebuild_prompt_cache after remove_prompt_fact failed: {e:#}");
1418 }
1419 Ok(json!({ "removed": true, "closed": closed_total }))
1420 } else {
1421 Ok(json!({ "removed": false, "reason": "not found" }))
1422 }
1423}
1424
1425async fn handle_kg_query(state: &AppState, args: Value) -> Result<Value> {
1426 let palace = resolve_palace(state, &args, "kg_query")?;
1427 let subject = args
1428 .get("subject")
1429 .and_then(|v| v.as_str())
1430 .ok_or_else(|| anyhow!("kg_query: missing 'subject'"))?;
1431 let handle = open_palace_handle(state, &palace)?;
1432 let triples = handle
1433 .kg
1434 .query_active(subject)
1435 .await
1436 .context("kg.query_active")?;
1437 let payload: Vec<Value> = triples
1438 .iter()
1439 .map(|t| {
1440 json!({
1441 "subject": t.subject,
1442 "predicate": t.predicate,
1443 "object": t.object,
1444 "valid_from": t.valid_from.to_rfc3339(),
1445 "valid_to": t.valid_to.as_ref().map(|d| d.to_rfc3339()),
1446 "confidence": t.confidence,
1447 "provenance": t.provenance,
1448 })
1449 })
1450 .collect();
1451 let mut response = json!({ "subject": subject, "triples": payload });
1457 if crate::bootstrap::is_kg_empty_for_subject(&triples) {
1458 response["hint"] = Value::String(crate::bootstrap::KG_EMPTY_HINT.to_string());
1459 }
1460 Ok(response)
1461}
1462
1463async fn handle_memory_list(state: &AppState, args: Value) -> Result<Value> {
1464 let palace = resolve_palace(state, &args, "memory_list")?;
1465 let handle = open_palace_handle(state, &palace)?;
1466 let room = args
1467 .get("room")
1468 .and_then(|v| v.as_str())
1469 .map(|s| parse_room(Some(s)));
1470 let tag = args
1471 .get("tag")
1472 .and_then(|v| v.as_str())
1473 .map(|s| s.to_string());
1474 let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
1475 let drawers = handle.list_drawers(room, tag, limit);
1476 let payload: Vec<Value> = drawers
1477 .iter()
1478 .map(|d| {
1479 json!({
1480 "drawer_id": d.id.to_string(),
1481 "content": d.content,
1482 "importance": d.importance,
1483 "tags": d.tags,
1484 "created_at": d.created_at.to_rfc3339(),
1485 "drawer_type": d.drawer_type.as_str(),
1486 "expires_at": d.expires_at.map(|t| t.to_rfc3339()),
1487 })
1488 })
1489 .collect();
1490 Ok(json!({ "palace": palace, "drawers": payload }))
1491}
1492
1493async fn handle_memory_forget(state: &AppState, args: Value) -> Result<Value> {
1494 let palace = resolve_palace(state, &args, "memory_forget")?;
1495 let drawer_id_str = args
1496 .get("drawer_id")
1497 .and_then(|v| v.as_str())
1498 .ok_or_else(|| anyhow!("memory_forget: missing 'drawer_id'"))?;
1499 let drawer_id = Uuid::parse_str(drawer_id_str)
1500 .map_err(|e| anyhow!("memory_forget: invalid drawer_id UUID: {e}"))?;
1501 let handle = open_palace_handle(state, &palace)?;
1502 handle.forget(drawer_id).await.context("forget")?;
1503 let drawer_count = handle.drawers.read().len();
1505 state.emit(DaemonEvent::DrawerDeleted {
1506 palace_id: palace.clone(),
1507 drawer_count,
1508 source: ActivitySource::Mcp,
1509 });
1510 Ok(json!({ "status": "deleted", "drawer_id": drawer_id_str, "palace": palace }))
1513}
1514
1515async fn handle_palace_info(state: &AppState, args: Value) -> Result<Value> {
1516 let palace = resolve_palace(state, &args, "palace_info")?;
1517 let handle = open_palace_handle(state, &palace)?;
1518 let drawer_count = handle.list_drawers(None, None, usize::MAX).len();
1519 let data_dir = handle
1520 .data_dir
1521 .as_ref()
1522 .map(|p| p.to_string_lossy().to_string());
1523 Ok(json!({
1524 "id": handle.id.as_str(),
1525 "name": handle.id.as_str(),
1526 "drawer_count": drawer_count,
1527 "data_dir": data_dir,
1528 }))
1529}
1530
1531async fn handle_palace_compact(state: &AppState, args: Value) -> Result<Value> {
1532 let palace = resolve_palace(state, &args, "palace_compact")?;
1533 let handle = open_palace_handle(state, &palace)?;
1534 let valid_ids: std::collections::HashSet<Uuid> =
1538 handle.drawers.read().iter().map(|d| d.id).collect();
1539 let vector_store = handle.vector_store.clone();
1540 let res = tokio::task::spawn_blocking(move || vector_store.compact_orphans(&valid_ids))
1541 .await
1542 .context("join palace_compact")??;
1543 Ok(json!({
1544 "palace": palace,
1545 "total_checked": res.total_checked,
1546 "orphans_removed": res.orphans_removed,
1547 "index_size_before": res.index_size_before,
1548 "index_size_after": res.index_size_after,
1549 }))
1550}
1551
1552async fn handle_kg_gaps(state: &AppState, args: Value) -> Result<Value> {
1553 let palace = resolve_palace(state, &args, "kg_gaps")?;
1563 let _handle = open_palace_handle(state, &palace)?;
1566 let pid = PalaceId::new(&palace);
1567 let cached = state.registry.get_gaps(&pid).unwrap_or_default();
1568 let payload: Vec<Value> = cached
1569 .into_iter()
1570 .map(|g| {
1571 json!({
1572 "entities": g.entities,
1573 "internal_density": g.internal_density,
1574 "external_bridges": g.external_bridges,
1575 "suggested_exploration": g.suggested_exploration,
1576 })
1577 })
1578 .collect();
1579 Ok(json!({ "palace": palace, "gaps": payload }))
1580}
1581
1582async fn handle_memory_recall_all(state: &AppState, args: Value) -> Result<Value> {
1583 let query = args
1584 .get("q")
1585 .and_then(|v| v.as_str())
1586 .ok_or_else(|| anyhow!("memory_recall_all: missing 'q'"))?;
1587 let top_k = args.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
1588 let deep = args.get("deep").and_then(|v| v.as_bool()).unwrap_or(false);
1589
1590 let root = state.data_root.clone();
1594 let palaces = tokio::task::spawn_blocking(move || {
1595 trusty_common::memory_core::PalaceRegistry::list_palaces(&root)
1596 })
1597 .await
1598 .context("join list_palaces")??;
1599
1600 let mut handles = Vec::with_capacity(palaces.len());
1601 for p in &palaces {
1602 match state.registry.open_palace(&state.data_root, &p.id) {
1603 Ok(h) => handles.push(h),
1604 Err(e) => {
1605 tracing::warn!(palace = %p.id, "memory_recall_all: open failed: {e:#}")
1606 }
1607 }
1608 }
1609
1610 let embedder = state.embedder().await?;
1611 let erased: std::sync::Arc<dyn trusty_common::memory_core::embed::Embedder + Send + Sync> =
1612 embedder;
1613 let results = recall_across_palaces(&handles, &erased, query, top_k, deep)
1614 .await
1615 .context("recall_across_palaces")?;
1616
1617 let payload: Vec<Value> = results
1618 .iter()
1619 .map(|r| {
1620 json!({
1621 "palace_id": r.palace_id,
1622 "drawer_id": r.result.drawer.id.to_string(),
1623 "content": r.result.drawer.content,
1624 "importance": r.result.drawer.importance,
1625 "tags": r.result.drawer.tags,
1626 "score": r.result.score,
1627 "layer": r.result.layer,
1628 "drawer_type": r.result.drawer.drawer_type.as_str(),
1629 })
1630 })
1631 .collect();
1632 Ok(json!({ "query": query, "results": payload }))
1633}
1634
1635async fn handle_get_prompt_context(state: &AppState, args: Value) -> Result<Value> {
1636 let query = args
1647 .get("query")
1648 .and_then(|v| v.as_str())
1649 .map(|s| s.trim().to_string())
1650 .filter(|s| !s.is_empty());
1651
1652 let cache_snapshot = {
1656 let guard = state.prompt_context_cache.read().await;
1657 guard.clone()
1658 };
1659
1660 let body = if let Some(q) = query.as_deref() {
1661 let needle = q.to_lowercase();
1662 let filtered: Vec<(String, String, String)> = cache_snapshot
1663 .triples
1664 .into_iter()
1665 .filter(|(subject, _predicate, object)| {
1666 subject.to_lowercase().contains(&needle) || object.to_lowercase().contains(&needle)
1667 })
1668 .collect();
1669 let formatted = crate::prompt_facts::build_prompt_context(&filtered);
1670 if formatted.is_empty() {
1671 "No project context found matching your query.".to_string()
1672 } else {
1673 formatted
1674 }
1675 } else if cache_snapshot.formatted.is_empty() {
1676 "No prompt facts stored yet.".to_string()
1677 } else {
1678 cache_snapshot.formatted
1679 };
1680
1681 Ok(Value::String(body))
1687}
1688
1689async fn handle_discover_aliases(state: &AppState, args: Value) -> Result<Value> {
1690 let palace = resolve_palace(state, &args, "discover_aliases")?;
1701 let project_root = args
1702 .get("project_root")
1703 .and_then(|v| v.as_str())
1704 .map(std::path::PathBuf::from)
1705 .or_else(|| std::env::current_dir().ok())
1706 .ok_or_else(|| anyhow!("discover_aliases: no project_root and cwd unavailable"))?;
1707
1708 let discoveries = crate::discovery::discover_project_aliases(&project_root).await?;
1709
1710 let handle = open_palace_handle(state, &palace)?;
1711
1712 let mut already_known = 0usize;
1713 let mut newly_asserted = 0usize;
1714 let mut reported: Vec<Value> = Vec::with_capacity(discoveries.len());
1715
1716 for d in &discoveries {
1717 let active = handle
1720 .kg
1721 .query_active(&d.short)
1722 .await
1723 .context("kg.query_active")?;
1724 let exists = active
1725 .iter()
1726 .any(|t| t.predicate == "is_alias_for" && t.object == d.full);
1727 if exists {
1728 already_known += 1;
1729 continue;
1730 }
1731
1732 let triple = Triple {
1733 subject: d.short.clone(),
1734 predicate: "is_alias_for".to_string(),
1735 object: d.full.clone(),
1736 valid_from: chrono::Utc::now(),
1737 valid_to: None,
1738 confidence: 1.0,
1739 provenance: Some(format!("discover_aliases:{}", d.source.as_str())),
1740 };
1741 handle
1742 .kg
1743 .assert(triple)
1744 .await
1745 .context("kg.assert (discover)")?;
1746 newly_asserted += 1;
1747 reported.push(json!({
1748 "short": d.short,
1749 "full": d.full,
1750 "source": d.source.as_str(),
1751 }));
1752 }
1753
1754 if newly_asserted > 0 {
1755 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1756 tracing::warn!("rebuild_prompt_cache after discover_aliases failed: {e:#}");
1757 }
1758 }
1759
1760 Ok(json!({
1761 "discovered": reported,
1762 "already_known": already_known,
1763 "new": newly_asserted,
1764 "palace": palace,
1765 }))
1766}
1767
1768async fn handle_kg_bootstrap(state: &AppState, args: Value) -> Result<Value> {
1769 let palace = resolve_palace(state, &args, "kg_bootstrap")?;
1774 let project_path = args
1775 .get("project_path")
1776 .and_then(|v| v.as_str())
1777 .map(std::path::PathBuf::from);
1778 let result = crate::bootstrap::bootstrap_palace(state, &palace, project_path.as_deref())
1779 .await
1780 .context("bootstrap_palace")?;
1781 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1785 tracing::warn!("rebuild_prompt_cache after kg_bootstrap failed: {e:#}");
1786 }
1787 crate::bootstrap::result_to_json(&result)
1788}
1789
1790async fn handle_memory_send_message(state: &AppState, args: Value) -> Result<Value> {
1791 let to_palace = args
1793 .get("to_palace")
1794 .and_then(|v| v.as_str())
1795 .ok_or_else(|| anyhow!("memory_send_message: missing 'to_palace'"))?
1796 .to_string();
1797 let purpose = args
1798 .get("purpose")
1799 .and_then(|v| v.as_str())
1800 .ok_or_else(|| anyhow!("memory_send_message: missing 'purpose'"))?
1801 .to_string();
1802 let content = args
1803 .get("content")
1804 .and_then(|v| v.as_str())
1805 .ok_or_else(|| anyhow!("memory_send_message: missing 'content'"))?
1806 .to_string();
1807 let from_palace = if let Some(s) = args.get("from_palace").and_then(|v| v.as_str()) {
1810 s.to_string()
1811 } else if let Some(d) = state.default_palace.clone() {
1812 d
1813 } else {
1814 crate::messaging::cwd_palace_slug()
1815 .context("memory_send_message: derive from_palace from cwd")?
1816 };
1817 let drawer_id = crate::messaging::send_message_to_palace(
1818 &state.registry,
1819 &state.data_root,
1820 &from_palace,
1821 &to_palace,
1822 &purpose,
1823 content,
1824 CreatorInfo::new_self(MCP_CLIENT_NAME, CreatorSource::Mcp),
1825 )
1826 .await
1827 .context("memory_send_message")?;
1828 Ok(json!({
1829 "drawer_id": drawer_id.to_string(),
1830 "from_palace": from_palace,
1831 "to_palace": to_palace,
1832 "purpose": purpose,
1833 "status": "sent",
1834 }))
1835}
1836
1837pub async fn dispatch_tool(state: &AppState, name: &str, args: Value) -> Result<Value> {
1849 match name {
1850 "memory_remember" => handle_memory_remember(state, args).await,
1851 "memory_note" => handle_memory_note(state, args).await,
1852 "memory_recall" => handle_memory_recall(state, args).await,
1853 "memory_recall_deep" => handle_memory_recall_deep(state, args).await,
1854 "palace_create" => handle_palace_create(state, args).await,
1855 "palace_list" => handle_palace_list(state, args).await,
1856 "palace_delete" => handle_palace_delete(state, args).await,
1857 "palace_update" => handle_palace_update(state, args).await,
1858 "kg_assert" => handle_kg_assert(state, args).await,
1859 "add_alias" => handle_add_alias(state, args).await,
1860 "list_prompt_facts" => handle_list_prompt_facts(state, args).await,
1861 "remove_prompt_fact" => handle_remove_prompt_fact(state, args).await,
1862 "kg_query" => handle_kg_query(state, args).await,
1863 "memory_list" => handle_memory_list(state, args).await,
1864 "memory_forget" => handle_memory_forget(state, args).await,
1865 "palace_info" => handle_palace_info(state, args).await,
1866 "palace_compact" => handle_palace_compact(state, args).await,
1867 "kg_gaps" => handle_kg_gaps(state, args).await,
1868 "memory_recall_all" => handle_memory_recall_all(state, args).await,
1869 "get_prompt_context" => handle_get_prompt_context(state, args).await,
1870 "discover_aliases" => handle_discover_aliases(state, args).await,
1871 "kg_bootstrap" => handle_kg_bootstrap(state, args).await,
1872 "memory_send_message" => handle_memory_send_message(state, args).await,
1873 other => anyhow::bail!("unknown tool: {other}"),
1874 }
1875}
1876
1877fn bm25_data_dir_for_palace(state: &AppState, palace: &str) -> std::path::PathBuf {
1890 state.data_root.join(palace).join("bm25")
1891}
1892
1893async fn ensure_bm25_running_for_palace(state: &AppState, palace: &str) -> bool {
1910 let Some(supervisor) = state.bm25_supervisor.as_ref() else {
1911 return true;
1914 };
1915 let data_dir = bm25_data_dir_for_palace(state, palace);
1916 match supervisor.ensure_running(palace, &data_dir).await {
1917 Ok(_socket) => true,
1918 Err(e) => {
1919 tracing::warn!(
1920 palace = %palace,
1921 "bm25 supervisor could not start daemon (degrading to vector-only): {e:#}"
1922 );
1923 false
1924 }
1925 }
1926}
1927
1928pub const BM25_INDEX_QUEUE_CAPACITY: usize = 256;
1944
1945#[derive(Debug)]
1958pub struct Bm25IndexRequest {
1959 pub palace: String,
1961 pub drawer_id: String,
1963 pub content: String,
1965 pub data_dir: std::path::PathBuf,
1969}
1970
1971pub fn spawn_bm25_index_worker(
1992 mut rx: tokio::sync::mpsc::Receiver<Bm25IndexRequest>,
1993 client: Option<std::sync::Arc<trusty_common::bm25_client::Bm25Client>>,
1994 supervisor: Option<std::sync::Arc<crate::bm25_supervisor::Bm25Supervisor>>,
1995) {
1996 tokio::spawn(async move {
1997 while let Some(req) = rx.recv().await {
1998 let Some(client) = client.as_ref() else {
2001 continue;
2002 };
2003 if let Some(sup) = supervisor.as_ref() {
2007 if let Err(e) = sup.ensure_running(&req.palace, &req.data_dir).await {
2008 tracing::warn!(
2009 palace = %req.palace,
2010 "bm25 supervisor failed to start daemon for index (non-fatal): {e:#}"
2011 );
2012 continue;
2013 }
2014 }
2015 if let Err(e) = client.index(&req.drawer_id, &req.content).await {
2016 tracing::warn!(
2017 palace = %req.palace,
2018 drawer_id = %req.drawer_id,
2019 "bm25 daemon index failed (non-fatal): {e:#}"
2020 );
2021 }
2022 }
2023 tracing::debug!("bm25 index worker exiting (channel closed)");
2024 });
2025}
2026
2027fn bm25_index_enqueue(state: &AppState, palace: &str, drawer_id: Uuid, content: &str) {
2044 let req = Bm25IndexRequest {
2045 palace: palace.to_string(),
2046 drawer_id: drawer_id.to_string(),
2047 content: content.to_string(),
2048 data_dir: bm25_data_dir_for_palace(state, palace),
2049 };
2050 match state.bm25_index_tx.try_send(req) {
2051 Ok(()) => {}
2052 Err(tokio::sync::mpsc::error::TrySendError::Full(req)) => {
2053 tracing::warn!(
2054 palace = %req.palace,
2055 drawer_id = %req.drawer_id,
2056 "BM25 index queue full — skipping drawer {}",
2057 req.drawer_id
2058 );
2059 }
2060 Err(tokio::sync::mpsc::error::TrySendError::Closed(req)) => {
2061 tracing::debug!(
2062 palace = %req.palace,
2063 drawer_id = %req.drawer_id,
2064 "BM25 index queue closed — skipping drawer {}",
2065 req.drawer_id
2066 );
2067 }
2068 }
2069}
2070
2071async fn bm25_search_optional(
2085 state: &AppState,
2086 palace: &str,
2087 query: &str,
2088 top_k: usize,
2089) -> Option<Vec<trusty_common::bm25_client::BM25Hit>> {
2090 let client = state.bm25_client.as_ref()?;
2091 if !ensure_bm25_running_for_palace(state, palace).await {
2095 return None;
2096 }
2097 match client.search(query, top_k).await {
2098 Ok(hits) => Some(hits),
2099 Err(e) => {
2100 tracing::warn!(
2101 palace = %palace,
2102 "bm25 daemon search failed (falling back to vector-only): {e:#}"
2103 );
2104 None
2105 }
2106 }
2107}
2108
2109fn fuse_bm25_into_recall(
2124 results: &mut Vec<trusty_common::memory_core::retrieval::RecallResult>,
2125 bm25_hits: &[trusty_common::bm25_client::BM25Hit],
2126 top_k: usize,
2127) {
2128 const RRF_K: f32 = 60.0;
2131 if bm25_hits.is_empty() {
2132 return;
2133 }
2134 for (rank, hit) in bm25_hits.iter().enumerate() {
2136 let bonus = 1.0 / (RRF_K + rank as f32 + 1.0);
2137 if let Some(existing) = results
2138 .iter_mut()
2139 .find(|r| r.drawer.id.to_string() == hit.doc_id)
2140 {
2141 existing.score += bonus;
2142 }
2143 }
2151 results.sort_by(|a, b| {
2154 b.score
2155 .partial_cmp(&a.score)
2156 .unwrap_or(std::cmp::Ordering::Equal)
2157 .then(a.layer.cmp(&b.layer))
2158 });
2159 results.truncate(top_k);
2160}
2161
2162fn serialize_recall(
2164 palace: &str,
2165 query: &str,
2166 results: Vec<trusty_common::memory_core::retrieval::RecallResult>,
2167) -> Value {
2168 let payload: Vec<Value> = results
2169 .iter()
2170 .map(|r| {
2171 json!({
2172 "drawer_id": r.drawer.id.to_string(),
2173 "content": r.drawer.content,
2174 "score": r.score,
2175 "layer": r.layer,
2176 "tags": r.drawer.tags,
2177 "importance": r.drawer.importance,
2178 "drawer_type": r.drawer.drawer_type.as_str(),
2179 })
2180 })
2181 .collect();
2182 json!({
2183 "palace": palace,
2184 "query": query,
2185 "results": payload,
2186 })
2187}
2188
2189#[cfg(test)]
2190mod tests {
2191 use super::*;
2192 use crate::AppState;
2193
2194 fn test_state() -> (AppState, tempfile::TempDir) {
2209 unsafe {
2216 std::env::set_var("TRUSTY_SKIP_PALACE_ENFORCEMENT", "1");
2217 }
2218 let tmp = tempfile::tempdir().expect("tempdir");
2219 let root = tmp.path().to_path_buf();
2220 (AppState::new(root), tmp)
2221 }
2222
2223 #[test]
2228 fn tool_definitions_drops_palace_required_when_default_set() {
2229 let with_default = tool_definitions_with(true);
2230 let without_default = tool_definitions_with(false);
2231 for (name, palace_required_when_no_default) in [
2232 ("memory_remember", true),
2233 ("memory_recall", true),
2234 ("memory_recall_deep", true),
2235 ("memory_list", true),
2236 ("memory_forget", true),
2237 ("palace_info", true),
2238 ("palace_compact", true),
2239 ("kg_assert", true),
2240 ("kg_query", true),
2241 ] {
2242 for (defs, has_default) in [(&with_default, true), (&without_default, false)] {
2243 let tools = defs["tools"].as_array().unwrap();
2244 let tool = tools.iter().find(|t| t["name"] == name).unwrap();
2245 let required: Vec<&str> = tool["inputSchema"]["required"]
2246 .as_array()
2247 .unwrap()
2248 .iter()
2249 .filter_map(|v| v.as_str())
2250 .collect();
2251 let palace_required = required.contains(&"palace");
2252 let expected = palace_required_when_no_default && !has_default;
2253 assert_eq!(
2254 palace_required, expected,
2255 "tool={name} has_default={has_default} required={required:?}"
2256 );
2257 }
2258 }
2259 }
2260
2261 #[test]
2262 fn tool_definitions_lists_all_tools() {
2263 let defs = tool_definitions();
2264 let tools = defs
2265 .get("tools")
2266 .and_then(|t| t.as_array())
2267 .expect("tools array");
2268 assert_eq!(tools.len(), 23);
2269 let names: Vec<&str> = tools
2270 .iter()
2271 .filter_map(|t| t.get("name").and_then(|n| n.as_str()))
2272 .collect();
2273 for expected in [
2274 "memory_remember",
2275 "memory_note",
2276 "memory_recall",
2277 "memory_recall_deep",
2278 "memory_list",
2279 "memory_forget",
2280 "palace_create",
2281 "palace_delete",
2282 "palace_update",
2283 "palace_list",
2284 "palace_info",
2285 "palace_compact",
2286 "kg_assert",
2287 "kg_query",
2288 "memory_recall_all",
2289 "kg_gaps",
2290 "add_alias",
2291 "list_prompt_facts",
2292 "remove_prompt_fact",
2293 "get_prompt_context",
2294 "discover_aliases",
2295 "kg_bootstrap",
2296 "memory_send_message",
2297 ] {
2298 assert!(names.contains(&expected), "missing tool: {expected}");
2299 }
2300 }
2301
2302 #[tokio::test]
2305 async fn dispatch_palace_create_persists() {
2306 let (state, _tmp) = test_state();
2307 let created = dispatch_tool(&state, "palace_create", json!({"name": "alpha"}))
2308 .await
2309 .expect("palace_create");
2310 assert_eq!(created["palace_id"], "alpha");
2311
2312 let listed = dispatch_tool(&state, "palace_list", json!({}))
2313 .await
2314 .expect("palace_list");
2315 let ids = listed["palaces"].as_array().expect("palaces array");
2316 assert!(ids.iter().any(|v| v.as_str() == Some("alpha")));
2317 }
2318
2319 #[tokio::test]
2322 async fn dispatch_remember_then_recall() {
2323 let (state, _tmp) = test_state();
2324 let _ = dispatch_tool(&state, "palace_create", json!({"name": "beta"}))
2325 .await
2326 .expect("palace_create");
2327
2328 let remembered = dispatch_tool(
2329 &state,
2330 "memory_remember",
2331 json!({
2332 "palace": "beta",
2333 "text": "Quokkas are the happiest marsupials in Australia by general consensus",
2334 "room": "General",
2335 "tags": ["wildlife"],
2336 }),
2337 )
2338 .await
2339 .expect("memory_remember");
2340 assert!(remembered["drawer_id"].as_str().is_some());
2341
2342 let recalled = dispatch_tool(
2343 &state,
2344 "memory_recall",
2345 json!({"palace": "beta", "query": "Quokkas marsupials Australia", "top_k": 5}),
2346 )
2347 .await
2348 .expect("memory_recall");
2349 let results = recalled["results"].as_array().expect("results");
2350 assert!(
2351 results
2352 .iter()
2353 .any(|r| r["content"].as_str().unwrap_or("").contains("Quokkas")),
2354 "expected to recall the Quokkas drawer; got {results:?}"
2355 );
2356 }
2357
2358 #[tokio::test]
2367 async fn auto_kg_extraction_hooks_into_memory_remember() {
2368 let (state, _tmp) = test_state();
2369 let _ = dispatch_tool(&state, "palace_create", json!({"name": "kgauto"}))
2370 .await
2371 .expect("palace_create");
2372
2373 let _ = dispatch_tool(
2374 &state,
2375 "memory_remember",
2376 json!({
2377 "palace": "kgauto",
2378 "text": "Rustc is a compiler for the Rust language; tracks #performance",
2379 "room": "Backend",
2380 "tags": ["compiler", "language"],
2381 }),
2382 )
2383 .await
2384 .expect("memory_remember");
2385
2386 let handle = open_palace_handle(&state, "kgauto").expect("open palace");
2387 let triples = handle.kg.list_active(1000, 0).await.expect("list_active");
2388 let auto: Vec<_> = triples
2389 .iter()
2390 .filter(|t| t.provenance.as_deref() == Some(crate::kg_extract::AUTO_PROVENANCE))
2391 .collect();
2392 assert!(
2393 !auto.is_empty(),
2394 "expected at least one auto-extracted triple after memory_remember; got: {triples:?}"
2395 );
2396 assert!(
2400 auto.iter()
2401 .any(|t| t.subject == "tag:compiler" && t.predicate == "tags"),
2402 "expected tag:compiler edge in auto subset: {auto:?}"
2403 );
2404 assert!(
2405 auto.iter()
2406 .any(|t| t.subject == "tag:language" && t.predicate == "tags"),
2407 "expected tag:language edge in auto subset: {auto:?}"
2408 );
2409 assert!(
2410 auto.iter()
2411 .any(|t| t.subject == "room:Backend" && t.predicate == "contains"),
2412 "expected room:Backend edge in auto subset: {auto:?}"
2413 );
2414 assert!(
2415 auto.iter().any(|t| t.predicate == "mentioned-in"),
2416 "expected at least one #hashtag mention triple in auto subset: {auto:?}"
2417 );
2418 }
2419
2420 #[tokio::test]
2432 async fn auto_kg_extraction_no_op_does_not_fail_remember() {
2433 let (state, _tmp) = test_state();
2434 let _ = dispatch_tool(&state, "palace_create", json!({"name": "kgnoop"}))
2435 .await
2436 .expect("palace_create");
2437
2438 let res = dispatch_tool(
2439 &state,
2440 "memory_remember",
2441 json!({
2442 "palace": "kgnoop",
2443 "text": "The quick brown fox jumped over the lazy dog repeatedly",
2446 }),
2447 )
2448 .await
2449 .expect("memory_remember should succeed even when extraction yields nothing");
2450 assert!(res["drawer_id"].as_str().is_some());
2451 }
2452
2453 #[tokio::test]
2456 async fn dispatch_kg_assert_then_query() {
2457 let (state, _tmp) = test_state();
2458 let _ = dispatch_tool(&state, "palace_create", json!({"name": "gamma"}))
2459 .await
2460 .expect("palace_create");
2461
2462 let _ = dispatch_tool(
2463 &state,
2464 "kg_assert",
2465 json!({
2466 "palace": "gamma",
2467 "subject": "alice",
2468 "predicate": "works_at",
2469 "object": "Acme",
2470 "confidence": 0.9,
2471 "provenance": "test",
2472 }),
2473 )
2474 .await
2475 .expect("kg_assert");
2476
2477 let queried = dispatch_tool(
2478 &state,
2479 "kg_query",
2480 json!({"palace": "gamma", "subject": "alice"}),
2481 )
2482 .await
2483 .expect("kg_query");
2484 let triples = queried["triples"].as_array().expect("triples array");
2485 assert_eq!(triples.len(), 1);
2486 assert_eq!(triples[0]["object"], "Acme");
2487 assert_eq!(triples[0]["predicate"], "works_at");
2488 }
2489
2490 #[tokio::test]
2498 async fn dispatch_kg_gaps_returns_cached() {
2499 use trusty_common::memory_core::community::KnowledgeGap;
2500
2501 let (state, _tmp) = test_state();
2502 let _ = dispatch_tool(&state, "palace_create", json!({"name": "delta"}))
2503 .await
2504 .expect("palace_create");
2505
2506 let initial = dispatch_tool(&state, "kg_gaps", json!({"palace": "delta"}))
2508 .await
2509 .expect("kg_gaps empty");
2510 let gaps = initial["gaps"].as_array().expect("gaps array");
2511 assert_eq!(gaps.len(), 0);
2512
2513 state.registry.set_gaps(
2515 PalaceId::new("delta"),
2516 vec![KnowledgeGap {
2517 entities: vec!["x".to_string(), "y".to_string()],
2518 internal_density: 0.05,
2519 external_bridges: 0,
2520 suggested_exploration: "Explore connections between x and y".to_string(),
2521 }],
2522 );
2523 let seeded = dispatch_tool(&state, "kg_gaps", json!({"palace": "delta"}))
2524 .await
2525 .expect("kg_gaps seeded");
2526 let gaps = seeded["gaps"].as_array().expect("gaps array");
2527 assert_eq!(gaps.len(), 1);
2528 assert_eq!(gaps[0]["entities"][0], "x");
2529 assert_eq!(gaps[0]["external_bridges"], 0);
2530 assert!(gaps[0]["suggested_exploration"]
2531 .as_str()
2532 .unwrap()
2533 .contains("x"));
2534 }
2535
2536 #[tokio::test]
2541 async fn add_alias_round_trip_through_prompt_cache() {
2542 let _tmp = tempfile::tempdir().expect("tempdir");
2545 let root = _tmp.path().to_path_buf();
2546 let state = AppState::new(root).with_default_palace(Some("ctx".to_string()));
2547
2548 let _ = dispatch_tool(&state, "palace_create", json!({"name": "ctx"}))
2550 .await
2551 .expect("palace_create");
2552
2553 let added = dispatch_tool(
2555 &state,
2556 "add_alias",
2557 json!({"short": "tga", "full": "trusty-git-analytics"}),
2558 )
2559 .await
2560 .expect("add_alias");
2561 assert_eq!(added["asserted"], true);
2562 assert_eq!(added["short"], "tga");
2563
2564 let listed = dispatch_tool(&state, "list_prompt_facts", json!({}))
2566 .await
2567 .expect("list_prompt_facts");
2568 let facts = listed["facts"].as_array().expect("facts array");
2569 assert!(
2570 facts.iter().any(|f| f["subject"] == "tga"
2571 && f["predicate"] == "is_alias_for"
2572 && f["object"] == "trusty-git-analytics"),
2573 "expected tga alias in facts; got {facts:?}"
2574 );
2575
2576 {
2578 let guard = state.prompt_context_cache.read().await;
2579 assert!(
2580 guard.formatted.contains("tga → trusty-git-analytics"),
2581 "prompt cache should contain alias; got: {}",
2582 guard.formatted
2583 );
2584 }
2585
2586 let _ = dispatch_tool(
2588 &state,
2589 "add_alias",
2590 json!({"short": "tm", "full": "trusty-memory", "extra": "the MCP frontend"}),
2591 )
2592 .await
2593 .expect("add_alias with extra");
2594 {
2595 let guard = state.prompt_context_cache.read().await;
2596 assert!(
2597 guard
2598 .formatted
2599 .contains("tm → trusty-memory (the MCP frontend)"),
2600 "alias with extra not formatted; got: {}",
2601 guard.formatted
2602 );
2603 }
2604
2605 let removed = dispatch_tool(
2607 &state,
2608 "remove_prompt_fact",
2609 json!({"subject": "tga", "predicate": "is_alias_for"}),
2610 )
2611 .await
2612 .expect("remove_prompt_fact");
2613 assert_eq!(removed["removed"], true);
2614 {
2615 let guard = state.prompt_context_cache.read().await;
2616 assert!(
2617 !guard.formatted.contains("tga → trusty-git-analytics"),
2618 "retracted alias still in cache: {}",
2619 guard.formatted
2620 );
2621 assert!(
2622 guard.formatted.contains("tm → trusty-memory"),
2623 "non-retracted alias missing from cache: {}",
2624 guard.formatted
2625 );
2626 }
2627
2628 let missing = dispatch_tool(
2630 &state,
2631 "remove_prompt_fact",
2632 json!({"subject": "nope", "predicate": "is_alias_for"}),
2633 )
2634 .await
2635 .expect("remove_prompt_fact missing");
2636 assert_eq!(missing["removed"], false);
2637 }
2638
2639 #[tokio::test]
2644 async fn get_prompt_context_serves_cache_and_filters() {
2645 let (state, _tmp) = test_state();
2646
2647 let resp = dispatch_tool(&state, "get_prompt_context", json!({}))
2649 .await
2650 .expect("get_prompt_context empty");
2651 assert_eq!(resp.as_str().unwrap(), "No prompt facts stored yet.");
2652
2653 {
2655 let mut guard = state.prompt_context_cache.write().await;
2656 let triples = vec![
2657 (
2658 "tga".to_string(),
2659 "is_alias_for".to_string(),
2660 "trusty-git-analytics".to_string(),
2661 ),
2662 (
2663 "tm".to_string(),
2664 "is_alias_for".to_string(),
2665 "trusty-memory".to_string(),
2666 ),
2667 (
2668 "fact-1".to_string(),
2669 "is_fact".to_string(),
2670 "MSRV is 1.88".to_string(),
2671 ),
2672 ];
2673 let formatted = crate::prompt_facts::build_prompt_context(&triples);
2674 *guard = crate::prompt_facts::PromptFactsCache { triples, formatted };
2675 }
2676
2677 let resp = dispatch_tool(&state, "get_prompt_context", json!({}))
2679 .await
2680 .expect("get_prompt_context populated");
2681 let text = resp.as_str().expect("string body");
2682 assert!(text.contains("tga → trusty-git-analytics"));
2683 assert!(text.contains("tm → trusty-memory"));
2684 assert!(text.contains("MSRV is 1.88"));
2685
2686 let resp = dispatch_tool(&state, "get_prompt_context", json!({"query": "tga"}))
2688 .await
2689 .expect("get_prompt_context filtered");
2690 let text = resp.as_str().expect("string body");
2691 assert!(text.contains("tga → trusty-git-analytics"));
2692 assert!(!text.contains("tm → trusty-memory"));
2693 assert!(!text.contains("MSRV is 1.88"));
2694
2695 let resp = dispatch_tool(&state, "get_prompt_context", json!({"query": "MEMORY"}))
2697 .await
2698 .expect("get_prompt_context case-insensitive");
2699 let text = resp.as_str().expect("string body");
2700 assert!(text.contains("tm → trusty-memory"));
2701 assert!(!text.contains("tga → trusty-git-analytics"));
2702
2703 let resp = dispatch_tool(
2705 &state,
2706 "get_prompt_context",
2707 json!({"query": "zzz-nonexistent"}),
2708 )
2709 .await
2710 .expect("get_prompt_context no-match");
2711 assert_eq!(
2712 resp.as_str().unwrap(),
2713 "No project context found matching your query."
2714 );
2715
2716 let resp = dispatch_tool(&state, "get_prompt_context", json!({"query": " "}))
2718 .await
2719 .expect("get_prompt_context whitespace");
2720 let text = resp.as_str().expect("string body");
2721 assert!(text.contains("tga → trusty-git-analytics"));
2722 assert!(text.contains("tm → trusty-memory"));
2723 }
2724
2725 #[tokio::test]
2732 async fn dispatch_discover_aliases_inserts_new_and_dedupes() {
2733 let _tmp = tempfile::tempdir().expect("tempdir");
2736 let root = _tmp.path().to_path_buf();
2737 let state = AppState::new(root).with_default_palace(Some("disc".to_string()));
2738 let _ = dispatch_tool(&state, "palace_create", json!({"name": "disc"}))
2739 .await
2740 .expect("palace_create");
2741
2742 let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2746 .parent()
2747 .and_then(|p| p.parent())
2748 .expect("workspace root")
2749 .to_path_buf();
2750
2751 let first = dispatch_tool(
2752 &state,
2753 "discover_aliases",
2754 json!({"project_root": workspace_root.to_string_lossy()}),
2755 )
2756 .await
2757 .expect("discover_aliases first");
2758
2759 let new_count = first["new"].as_u64().expect("new is u64");
2760 assert!(new_count > 0, "expected new discoveries on first call");
2761 let discovered = first["discovered"].as_array().expect("discovered array");
2762 assert!(
2763 discovered
2764 .iter()
2765 .any(|d| d["short"] == "tga" && d["full"] == "trusty-git-analytics"),
2766 "expected tga alias in discoveries; got {discovered:?}"
2767 );
2768
2769 {
2771 let guard = state.prompt_context_cache.read().await;
2772 assert!(
2773 guard.formatted.contains("tga → trusty-git-analytics"),
2774 "prompt cache missing tga alias after discover_aliases; got: {}",
2775 guard.formatted
2776 );
2777 }
2778
2779 let second = dispatch_tool(
2782 &state,
2783 "discover_aliases",
2784 json!({"project_root": workspace_root.to_string_lossy()}),
2785 )
2786 .await
2787 .expect("discover_aliases second");
2788 assert_eq!(second["new"].as_u64(), Some(0), "expected 0 new on rerun");
2789 let already_known = second["already_known"].as_u64().expect("already_known");
2790 assert!(
2791 already_known >= new_count,
2792 "expected already_known >= {new_count}, got {already_known}"
2793 );
2794 }
2795
2796 #[tokio::test]
2803 async fn palace_create_auto_seeds_temporal_metadata() {
2804 let (state, _tmp) = test_state();
2805 let created = dispatch_tool(&state, "palace_create", json!({"name": "auto"}))
2806 .await
2807 .expect("palace_create");
2808 assert_eq!(created["palace_id"], "auto");
2809 let summary = &created["bootstrap"];
2811 assert!(summary.is_object(), "expected bootstrap summary object");
2812 assert!(summary["triples_asserted"].as_u64().unwrap_or(0) >= 2);
2813
2814 let queried = dispatch_tool(
2815 &state,
2816 "kg_query",
2817 json!({"palace": "auto", "subject": "auto"}),
2818 )
2819 .await
2820 .expect("kg_query");
2821 let triples = queried["triples"].as_array().expect("triples");
2822 let predicates: Vec<&str> = triples
2823 .iter()
2824 .filter_map(|t| t["predicate"].as_str())
2825 .collect();
2826 assert!(
2827 predicates.contains(&"created_at"),
2828 "expected created_at after palace_create; got {predicates:?}",
2829 );
2830 assert!(
2831 predicates.contains(&"bootstrapped_at"),
2832 "expected bootstrapped_at after palace_create; got {predicates:?}",
2833 );
2834 assert!(
2836 queried.get("hint").is_none(),
2837 "hint should be absent when triples exist"
2838 );
2839 }
2840
2841 #[tokio::test]
2846 async fn kg_query_emits_hint_when_palace_empty() {
2847 let (state, _tmp) = test_state();
2848 let _ = dispatch_tool(&state, "palace_create", json!({"name": "hinted"}))
2849 .await
2850 .expect("palace_create");
2851 let queried = dispatch_tool(
2853 &state,
2854 "kg_query",
2855 json!({"palace": "hinted", "subject": "unrelated-subject"}),
2856 )
2857 .await
2858 .expect("kg_query");
2859 assert_eq!(queried["triples"].as_array().unwrap().len(), 0);
2860 let hint = queried["hint"].as_str().expect("hint field present");
2861 assert!(hint.contains("kg_bootstrap"));
2862 assert!(hint.contains("kg_assert"));
2863 }
2864
2865 #[tokio::test]
2869 async fn kg_bootstrap_seeds_workspace_facts() {
2870 let (state, _tmp) = test_state();
2871 let _ = dispatch_tool(&state, "palace_create", json!({"name": "ws"}))
2872 .await
2873 .expect("palace_create");
2874
2875 let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2876 .parent()
2877 .and_then(|p| p.parent())
2878 .expect("workspace root")
2879 .to_path_buf();
2880
2881 let result = dispatch_tool(
2882 &state,
2883 "kg_bootstrap",
2884 json!({"palace": "ws", "project_path": workspace_root.to_string_lossy()}),
2885 )
2886 .await
2887 .expect("kg_bootstrap");
2888 assert!(result["triples_asserted"].as_u64().unwrap() > 0);
2889 let subject = result["project_subject"]
2890 .as_str()
2891 .expect("project_subject")
2892 .to_string();
2893
2894 let queried = dispatch_tool(
2896 &state,
2897 "kg_query",
2898 json!({"palace": "ws", "subject": subject}),
2899 )
2900 .await
2901 .expect("kg_query");
2902 let triples = queried["triples"].as_array().expect("triples");
2903 let predicates: Vec<&str> = triples
2904 .iter()
2905 .filter_map(|t| t["predicate"].as_str())
2906 .collect();
2907 assert!(
2911 predicates.contains(&"has_workspace_member") || predicates.contains(&"has_language"),
2912 "expected workspace/language fact; got {predicates:?}",
2913 );
2914 assert!(
2916 predicates.contains(&"source_repo"),
2917 "expected source_repo from .git/config; got {predicates:?}",
2918 );
2919 assert!(predicates.contains(&"bootstrapped_at"));
2921 }
2922
2923 #[test]
2932 fn content_gate_blocks_short_no_context() {
2933 assert_eq!(content_gate("yes", None), None);
2934 assert_eq!(content_gate("ok", None), None);
2935 assert_eq!(
2936 content_gate(" no thanks ", None),
2937 None,
2938 "2 words still < 4"
2939 );
2940 assert_eq!(
2941 content_gate("one two three", None),
2942 None,
2943 "3 words still < 4"
2944 );
2945 }
2946
2947 #[test]
2953 fn content_gate_wraps_short_with_context() {
2954 let combined = content_gate(
2955 "yes",
2956 Some("Do you want to enable auto-bootstrap on new palaces?"),
2957 )
2958 .expect("context should unlock the gate");
2959 assert_eq!(
2960 combined,
2961 "Do you want to enable auto-bootstrap on new palaces?\n\n---\n\nyes",
2962 );
2963 let combined = content_gate(
2966 "the quick brown fox jumps over the lazy dog",
2967 Some("Famous typing pangram"),
2968 )
2969 .expect("long content + context still combines");
2970 assert!(combined.starts_with("Famous typing pangram"));
2971 assert!(combined.contains("\n\n---\n\n"));
2972 assert!(combined.ends_with("the quick brown fox jumps over the lazy dog"));
2973 }
2974
2975 #[test]
2982 fn content_gate_keeps_long() {
2983 let body = "User prefers snake_case for python";
2984 let kept = content_gate(body, None).expect(">= 4 words passes");
2985 assert_eq!(kept, body, "passing content must round-trip verbatim");
2986 let boundary = "one two three four";
2988 assert_eq!(content_gate(boundary, None).as_deref(), Some(boundary));
2989 }
2990
2991 #[test]
2998 fn content_gate_blank_context_treated_as_none() {
2999 assert_eq!(content_gate("yes", Some("")), None);
3000 assert_eq!(content_gate("yes", Some(" ")), None);
3001 assert_eq!(content_gate("yes", Some("\n\t")), None);
3002 }
3003
3004 #[tokio::test]
3010 async fn dispatch_remember_skips_short_no_context() {
3011 let (state, _tmp) = test_state();
3012 let _ = dispatch_tool(&state, "palace_create", json!({"name": "gate"}))
3013 .await
3014 .expect("palace_create");
3015
3016 let res = dispatch_tool(
3017 &state,
3018 "memory_remember",
3019 json!({"palace": "gate", "text": "yes"}),
3020 )
3021 .await
3022 .expect("memory_remember (short)");
3023 assert_eq!(res["status"], "skipped");
3024 assert!(res["reason"]
3025 .as_str()
3026 .unwrap_or("")
3027 .contains("content gate"));
3028 let listed = dispatch_tool(
3030 &state,
3031 "memory_list",
3032 json!({"palace": "gate", "limit": 10}),
3033 )
3034 .await
3035 .expect("memory_list");
3036 let drawers = listed["drawers"].as_array().expect("drawers array");
3037 assert!(
3038 drawers.is_empty(),
3039 "no drawer should be written; got {drawers:?}"
3040 );
3041 }
3042
3043 #[tokio::test]
3051 async fn dispatch_remember_with_context_writes_combined() {
3052 let (state, _tmp) = test_state();
3053 let _ = dispatch_tool(&state, "palace_create", json!({"name": "ctxgate"}))
3054 .await
3055 .expect("palace_create");
3056
3057 let res = dispatch_tool(
3058 &state,
3059 "memory_remember",
3060 json!({
3061 "palace": "ctxgate",
3062 "text": "yes",
3063 "context": "Do you want to enable auto-bootstrap on new palaces?",
3064 "force": true,
3065 }),
3066 )
3067 .await
3068 .expect("memory_remember (with context)");
3069 assert_eq!(res["status"], "stored");
3070
3071 let listed = dispatch_tool(
3072 &state,
3073 "memory_list",
3074 json!({"palace": "ctxgate", "limit": 10}),
3075 )
3076 .await
3077 .expect("memory_list");
3078 let drawers = listed["drawers"].as_array().expect("drawers array");
3079 assert_eq!(drawers.len(), 1);
3080 let body = drawers[0]["content"].as_str().expect("content");
3081 assert!(body.starts_with("Do you want to enable auto-bootstrap"));
3082 assert!(body.contains("\n\n---\n\n"));
3083 assert!(body.ends_with("yes"));
3084 }
3085
3086 #[tokio::test]
3093 async fn dispatch_note_skips_short_no_context() {
3094 let (state, _tmp) = test_state();
3095 let _ = dispatch_tool(&state, "palace_create", json!({"name": "noteg"}))
3096 .await
3097 .expect("palace_create");
3098
3099 let res = dispatch_tool(
3100 &state,
3101 "memory_note",
3102 json!({"palace": "noteg", "content": "ok"}),
3103 )
3104 .await
3105 .expect("memory_note (short)");
3106 assert_eq!(res["status"], "skipped");
3107 let listed = dispatch_tool(
3108 &state,
3109 "memory_list",
3110 json!({"palace": "noteg", "limit": 10}),
3111 )
3112 .await
3113 .expect("memory_list");
3114 assert!(listed["drawers"].as_array().unwrap().is_empty());
3115 }
3116
3117 #[tokio::test]
3118 async fn dispatch_unknown_tool_errors() {
3119 let (state, _tmp) = test_state();
3120 let err = dispatch_tool(&state, "does_not_exist", json!({}))
3121 .await
3122 .expect_err("should error");
3123 assert!(err.to_string().contains("unknown tool"));
3124 }
3125
3126 #[test]
3137 fn blocklist_gate_blocks_tool_use() {
3138 assert!(blocklist_gate("Tool use: Bash"));
3139 assert!(blocklist_gate(
3140 "Tool use: Edit File: /Users/me/Projects/foo/bar.rs"
3141 ));
3142 assert!(blocklist_gate(" Tool use: Read"));
3144 }
3145
3146 #[test]
3151 fn blocklist_gate_blocks_session_ended() {
3152 assert!(blocklist_gate(
3153 "Claude Code session ended: 1d2c3b4a-0000-0000-0000-000000000000"
3154 ));
3155 assert!(blocklist_gate("Claude Code session started"));
3156 }
3157
3158 #[test]
3164 fn blocklist_gate_passes_normal_content() {
3165 assert!(!blocklist_gate("User prefers snake_case for python"));
3166 assert!(!blocklist_gate(
3167 "Quokkas are the happiest marsupials in Australia"
3168 ));
3169 assert!(!blocklist_gate("Note: refactor the dispatcher next sprint"));
3170 assert!(blocklist_gate("I used Tool use: Bash here"));
3175 }
3176
3177 #[tokio::test]
3187 async fn dedup_skips_near_duplicate() {
3188 let (state, _tmp) = test_state();
3189 let _ = dispatch_tool(&state, "palace_create", json!({"name": "dedup1"}))
3190 .await
3191 .expect("palace_create");
3192
3193 let _ = dispatch_tool(
3196 &state,
3197 "memory_remember",
3198 json!({
3199 "palace": "dedup1",
3200 "text": "The quick brown fox jumped over the lazy dog repeatedly today",
3201 }),
3202 )
3203 .await
3204 .expect("memory_remember seed");
3205
3206 let handle = open_palace_handle(&state, "dedup1").expect("open handle");
3207 assert!(
3211 dedup_gate(
3212 &handle,
3213 "The quick brown fox jumped over the lazy dog repeatedly yesterday"
3214 ),
3215 "near-duplicate should be detected"
3216 );
3217 assert!(
3219 dedup_gate(
3220 &handle,
3221 "The quick brown fox jumped over the lazy dog repeatedly today"
3222 ),
3223 "exact match should be detected"
3224 );
3225 }
3226
3227 #[tokio::test]
3233 async fn dedup_allows_different_content() {
3234 let (state, _tmp) = test_state();
3235 let _ = dispatch_tool(&state, "palace_create", json!({"name": "dedup2"}))
3236 .await
3237 .expect("palace_create");
3238
3239 let _ = dispatch_tool(
3240 &state,
3241 "memory_remember",
3242 json!({
3243 "palace": "dedup2",
3244 "text": "Quokkas are the happiest marsupials in Australia by general consensus",
3245 }),
3246 )
3247 .await
3248 .expect("memory_remember seed");
3249
3250 let handle = open_palace_handle(&state, "dedup2").expect("open handle");
3251 assert!(
3253 !dedup_gate(
3254 &handle,
3255 "Rust is a systems programming language focused on safety and concurrency"
3256 ),
3257 "unrelated content should pass the dedup gate"
3258 );
3259 assert!(!dedup_gate(&handle, " "));
3262 }
3263
3264 #[tokio::test]
3279 async fn dedup_gate_blocks_concurrent_duplicate_writes() {
3280 let (state, _tmp) = test_state();
3281 let state = std::sync::Arc::new(state);
3282 let _ = dispatch_tool(&state, "palace_create", json!({"name": "dedup_race"}))
3283 .await
3284 .expect("palace_create");
3285
3286 let text =
3290 "Concurrent identical writes must collapse to a single drawer under the dedup gate";
3291
3292 let s1 = state.clone();
3293 let t1 = tokio::spawn(async move {
3294 dispatch_tool(
3295 &s1,
3296 "memory_remember",
3297 json!({"palace": "dedup_race", "text": text}),
3298 )
3299 .await
3300 });
3301 let s2 = state.clone();
3302 let t2 = tokio::spawn(async move {
3303 dispatch_tool(
3304 &s2,
3305 "memory_remember",
3306 json!({"palace": "dedup_race", "text": text}),
3307 )
3308 .await
3309 });
3310 let r1 = t1.await.expect("join t1").expect("dispatch t1");
3311 let r2 = t2.await.expect("join t2").expect("dispatch t2");
3312
3313 let statuses = [
3316 r1["status"].as_str().unwrap_or(""),
3317 r2["status"].as_str().unwrap_or(""),
3318 ];
3319 let stored = statuses.iter().filter(|s| **s == "stored").count();
3320 let skipped = statuses.iter().filter(|s| **s == "skipped").count();
3321 assert_eq!(
3322 stored, 1,
3323 "exactly one concurrent write should be stored; got responses {r1:?} {r2:?}"
3324 );
3325 assert_eq!(
3326 skipped, 1,
3327 "exactly one concurrent write should be skipped; got responses {r1:?} {r2:?}"
3328 );
3329 let skipped_reason = if r1["status"] == "skipped" {
3330 r1["reason"].as_str().unwrap_or("")
3331 } else {
3332 r2["reason"].as_str().unwrap_or("")
3333 };
3334 assert!(
3335 skipped_reason.contains("duplicate within window"),
3336 "skipped envelope should cite dedup reason; got {skipped_reason:?}"
3337 );
3338
3339 let listed = dispatch_tool(
3341 &state,
3342 "memory_list",
3343 json!({"palace": "dedup_race", "limit": 10}),
3344 )
3345 .await
3346 .expect("memory_list");
3347 let drawers = listed["drawers"].as_array().expect("drawers array");
3348 assert_eq!(
3349 drawers.len(),
3350 1,
3351 "only one drawer should be persisted after concurrent identical writes; got {drawers:?}"
3352 );
3353 }
3354
3355 #[tokio::test]
3363 async fn dispatch_remember_blocks_blocklist_pattern() {
3364 let (state, _tmp) = test_state();
3365 let _ = dispatch_tool(&state, "palace_create", json!({"name": "blk"}))
3366 .await
3367 .expect("palace_create");
3368
3369 let res = dispatch_tool(
3370 &state,
3371 "memory_remember",
3372 json!({"palace": "blk", "text": "Tool use: Bash"}),
3373 )
3374 .await
3375 .expect("memory_remember (blocked)");
3376 assert_eq!(res["status"], "skipped");
3377 assert!(
3378 res["reason"]
3379 .as_str()
3380 .unwrap_or("")
3381 .contains("blocked pattern"),
3382 "reason should mention blocked pattern; got {res:?}"
3383 );
3384
3385 let listed = dispatch_tool(&state, "memory_list", json!({"palace": "blk", "limit": 10}))
3386 .await
3387 .expect("memory_list");
3388 let drawers = listed["drawers"].as_array().expect("drawers array");
3389 assert!(drawers.is_empty(), "no drawer should be written");
3390 }
3391
3392 #[tokio::test]
3406 async fn bm25_index_queue_drops_when_full() {
3407 let (mut state, _tmp) = test_state();
3411 let (tx, _rx_held) =
3412 tokio::sync::mpsc::channel::<Bm25IndexRequest>(BM25_INDEX_QUEUE_CAPACITY);
3413 state.bm25_index_tx = tx;
3414
3415 for i in 0..BM25_INDEX_QUEUE_CAPACITY {
3417 bm25_index_enqueue(
3418 &state,
3419 "default",
3420 Uuid::new_v4(),
3421 &format!("filler content {i}"),
3422 );
3423 }
3424 assert_eq!(
3426 state.bm25_index_tx.capacity(),
3427 0,
3428 "after filling, sender capacity must be 0"
3429 );
3430
3431 for i in 0..16 {
3434 bm25_index_enqueue(
3435 &state,
3436 "default",
3437 Uuid::new_v4(),
3438 &format!("overflow content {i}"),
3439 );
3440 }
3441
3442 let probe_req = Bm25IndexRequest {
3446 palace: "default".to_string(),
3447 drawer_id: Uuid::new_v4().to_string(),
3448 content: "probe".to_string(),
3449 data_dir: state.data_root.join("default").join("bm25"),
3450 };
3451 let probe = state.bm25_index_tx.try_send(probe_req);
3452 match probe {
3453 Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {}
3454 other => panic!("expected Full overflow, got {other:?}"),
3455 }
3456 }
3457}