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 },
380 "required": ["name"]
381 }
382 },
383 {
384 "name": "palace_list",
385 "description": "List all palaces on this machine.",
386 "inputSchema": {"type": "object", "properties": {}}
387 },
388 {
389 "name": "palace_delete",
390 "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.",
391 "inputSchema": {
392 "type": "object",
393 "properties": {
394 "palace_id": {"type": "string", "description": "Id of the palace to delete."},
395 "force": {"type": "boolean", "description": "Required when the palace still has drawers; defaults to false.", "default": false}
396 },
397 "required": ["palace_id"]
398 }
399 },
400 {
401 "name": "palace_update",
402 "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.",
403 "inputSchema": {
404 "type": "object",
405 "properties": {
406 "palace_id": {"type": "string", "description": "Id of the palace to rename."},
407 "name": {"type": "string", "description": "New display name. Trimmed; must be non-empty."}
408 },
409 "required": ["palace_id", "name"]
410 }
411 },
412 {
413 "name": "kg_assert",
414 "description": "Assert a fact in the temporal knowledge graph.",
415 "inputSchema": {
416 "type": "object",
417 "properties": {
418 "palace": {"type": "string"},
419 "subject": {"type": "string"},
420 "predicate": {"type": "string"},
421 "object": {"type": "string"},
422 "confidence": {"type": "number", "default": 1.0},
423 "provenance": {"type": "string"}
424 },
425 "required": kg_assert_required,
426 }
427 },
428 {
429 "name": "kg_query",
430 "description": "Query active knowledge-graph triples for a subject.",
431 "inputSchema": {
432 "type": "object",
433 "properties": {
434 "palace": {"type": "string"},
435 "subject": {"type": "string"}
436 },
437 "required": kg_query_required,
438 }
439 },
440 {
441 "name": "memory_list",
442 "description": "List drawers in a palace, optionally filtered by room type or tag.",
443 "inputSchema": {
444 "type": "object",
445 "properties": {
446 "palace": {"type": "string"},
447 "room": {"type": "string", "description": "Filter by room type (Frontend, Backend, Testing, Planning, Documentation, Research, Configuration, Meetings, General, or custom)"},
448 "tag": {"type": "string", "description": "Filter by tag"},
449 "limit": {"type": "integer", "description": "Max results (default 50)"}
450 },
451 "required": memory_list_required,
452 }
453 },
454 {
455 "name": "memory_forget",
456 "description": "Delete a drawer from a palace by its UUID.",
457 "inputSchema": {
458 "type": "object",
459 "properties": {
460 "palace": {"type": "string"},
461 "drawer_id": {"type": "string", "description": "UUID of the drawer to delete"}
462 },
463 "required": memory_forget_required,
464 }
465 },
466 {
467 "name": "palace_info",
468 "description": "Get metadata and stats for a single palace.",
469 "inputSchema": {
470 "type": "object",
471 "properties": {
472 "palace": {"type": "string"}
473 },
474 "required": palace_info_required,
475 }
476 },
477 {
478 "name": "palace_compact",
479 "description": "Remove orphaned vector index entries (vectors with no matching drawer row). See issue #49.",
480 "inputSchema": {
481 "type": "object",
482 "properties": {
483 "palace": {"type": "string"}
484 },
485 "required": palace_compact_required,
486 }
487 },
488 {
489 "name": "add_alias",
490 "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.",
491 "inputSchema": {
492 "type": "object",
493 "properties": {
494 "short": {"type": "string", "description": "Short name / alias (subject)"},
495 "full": {"type": "string", "description": "Full / canonical name (object)"},
496 "extra": {"type": "string", "description": "Optional extra context appended to the full name"}
497 },
498 "required": ["short", "full"],
499 }
500 },
501 {
502 "name": "list_prompt_facts",
503 "description": "List every active prompt-fact triple (aliases, conventions, facts, shorthands) across all palaces.",
504 "inputSchema": {"type": "object", "properties": {}}
505 },
506 {
507 "name": "remove_prompt_fact",
508 "description": "Retract the active triple for a (subject, predicate) pair from the prompt-facts surface. Closes the interval without inserting a replacement.",
509 "inputSchema": {
510 "type": "object",
511 "properties": {
512 "subject": {"type": "string"},
513 "predicate": {"type": "string", "description": "One of is_alias_for, has_convention, is_fact, is_shorthand_for"}
514 },
515 "required": ["subject", "predicate"],
516 }
517 },
518 {
519 "name": "get_prompt_context",
520 "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).",
521 "inputSchema": {
522 "type": "object",
523 "properties": {
524 "query": {
525 "type": "string",
526 "description": "Optional filter — only return facts whose subject or object contains this string (case-insensitive). Omit to return all hot facts."
527 }
528 }
529 }
530 },
531 {
532 "name": "discover_aliases",
533 "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.",
534 "inputSchema": {
535 "type": "object",
536 "properties": {
537 "project_root": {"type": "string", "description": "Optional filesystem path to scan. Defaults to the process cwd."}
538 }
539 }
540 },
541 {
542 "name": "kg_gaps",
543 "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.",
544 "inputSchema": {
545 "type": "object",
546 "properties": {
547 "palace": {"type": "string", "description": "Palace name (optional, defaults to the active palace)"}
548 }
549 }
550 },
551 {
552 "name": "kg_bootstrap",
553 "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.",
554 "inputSchema": {
555 "type": "object",
556 "properties": {
557 "palace": {"type": "string", "description": "Palace ID (optional if server started with --palace)"},
558 "project_path": {"type": "string", "description": "Filesystem path to scan. Omit to scan the palace's own data dir (temporal metadata only)."}
559 }
560 }
561 },
562 {
563 "name": "memory_recall_all",
564 "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.",
565 "inputSchema": {
566 "type": "object",
567 "properties": {
568 "q": {"type": "string", "description": "Free-text query"},
569 "top_k": {"type": "integer", "default": 10},
570 "deep": {"type": "boolean", "default": false}
571 },
572 "required": ["q"],
573 }
574 },
575 {
576 "name": "memory_send_message",
577 "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.",
578 "inputSchema": {
579 "type": "object",
580 "properties": {
581 "to_palace": {"type": "string", "description": "Recipient palace id (repo slug)."},
582 "purpose": {"type": "string", "description": "Free-text purpose / category (e.g. `task`, `notify`, `reply`)."},
583 "content": {"type": "string", "description": "Message body — plain text, no length limit. Rendered into the recipient session as a Markdown block."},
584 "from_palace": {"type": "string", "description": "Sender palace id (optional, defaults to cwd-derived slug)."}
585 },
586 "required": ["to_palace", "purpose", "content"],
587 }
588 }
589 ]
590 })
591}
592
593pub(crate) fn room_label(room: &RoomType) -> Option<String> {
604 let label = match room {
605 RoomType::Frontend => "Frontend",
606 RoomType::Backend => "Backend",
607 RoomType::Testing => "Testing",
608 RoomType::Planning => "Planning",
609 RoomType::Documentation => "Documentation",
610 RoomType::Research => "Research",
611 RoomType::Configuration => "Configuration",
612 RoomType::Meetings => "Meetings",
613 RoomType::General => "General",
614 RoomType::Custom(s) => return Some(s.clone()),
615 };
616 Some(label.to_string())
617}
618
619fn parse_room(s: Option<&str>) -> RoomType {
627 match s.unwrap_or("General") {
628 "Frontend" => RoomType::Frontend,
629 "Backend" => RoomType::Backend,
630 "Testing" => RoomType::Testing,
631 "Planning" => RoomType::Planning,
632 "Documentation" => RoomType::Documentation,
633 "Research" => RoomType::Research,
634 "Configuration" => RoomType::Configuration,
635 "Meetings" => RoomType::Meetings,
636 "General" => RoomType::General,
637 other => RoomType::Custom(other.to_string()),
638 }
639}
640
641fn open_palace_handle(
643 state: &AppState,
644 palace_id: &str,
645) -> Result<std::sync::Arc<trusty_common::memory_core::PalaceHandle>> {
646 let pid = PalaceId::new(palace_id);
647 state
648 .registry
649 .open_palace(&state.data_root, &pid)
650 .with_context(|| format!("open palace {palace_id}"))
651}
652
653pub(crate) async fn auto_extract_and_assert(
669 handle: &std::sync::Arc<trusty_common::memory_core::PalaceHandle>,
670 drawer_id: Uuid,
671 content: &str,
672 tags: &[String],
673 room: Option<&str>,
674) {
675 let input = ExtractInput {
676 drawer_id,
677 content,
678 tags,
679 room,
680 };
681 let triples = extract_triples(&input);
682 if triples.is_empty() {
683 return;
684 }
685 for triple in triples {
686 let s = triple.subject.clone();
687 let p = triple.predicate.clone();
688 if let Err(e) = handle.kg.assert(triple).await {
689 tracing::warn!(
690 drawer_id = %drawer_id,
691 subject = %s,
692 predicate = %p,
693 "auto kg extraction: assert failed (non-fatal): {e:#}",
694 );
695 }
696 }
697}
698
699fn resolve_palace<'a>(state: &'a AppState, args: &'a Value, tool: &str) -> Result<String> {
711 if let Some(p) = args.get("palace").and_then(|v| v.as_str()) {
712 return Ok(p.to_string());
713 }
714 state
715 .default_palace
716 .clone()
717 .ok_or_else(|| anyhow!("{tool}: missing 'palace' (no --palace default configured)"))
718}
719
720struct WriteDrawerParams<'a> {
734 palace_id: &'a str,
735 content: String,
736 tags: Vec<String>,
737 room: RoomType,
738 importance: f32,
739 opts: RememberOptions,
740 room_label_for_kg: Option<String>,
741}
742
743async fn write_drawer(state: &AppState, params: WriteDrawerParams<'_>) -> Result<Uuid> {
759 let WriteDrawerParams {
760 palace_id,
761 content,
762 tags,
763 room,
764 importance,
765 opts,
766 room_label_for_kg,
767 } = params;
768
769 let handle = open_palace_handle(state, palace_id)?;
770 let preview = crate::service::drawer_content_preview(&content);
773 let content_for_kg = content.clone();
777 let tags_for_kg = tags.clone();
778 let drawer_id = handle
779 .remember_with_options(content, room, tags, importance, opts)
780 .await
781 .context("PalaceHandle::remember_with_options")?;
782 bm25_index_enqueue(state, palace_id, drawer_id, &content_for_kg);
788 let palace_name = lookup_palace_name(state, palace_id);
791 let drawer_count = handle.drawers.read().len();
792 state.emit(DaemonEvent::DrawerAdded {
793 palace_id: palace_id.to_string(),
794 palace_name,
795 drawer_count,
796 timestamp: chrono::Utc::now(),
797 content_preview: preview,
798 source: ActivitySource::Mcp,
799 });
800 auto_extract_and_assert(
808 &handle,
809 drawer_id,
810 &content_for_kg,
811 &tags_for_kg,
812 room_label_for_kg.as_deref(),
813 )
814 .await;
815 Ok(drawer_id)
816}
817
818fn skipped_envelope(palace_id: &str, reason: &str) -> Value {
830 json!({
831 "palace": palace_id,
832 "status": "skipped",
833 "reason": reason,
834 })
835}
836
837fn parse_tags(args: &Value) -> Vec<String> {
847 args.get("tags")
848 .and_then(|v| v.as_array())
849 .map(|arr| {
850 arr.iter()
851 .filter_map(|t| t.as_str().map(|s| s.to_string()))
852 .collect()
853 })
854 .unwrap_or_default()
855}
856
857fn attach_mcp_attribution(tags: &mut Vec<String>) {
869 if let Some(session_tag) = session_tag_from_tags(tags) {
870 tags.push(session_tag);
871 }
872 CreatorInfo::new_self(MCP_CLIENT_NAME, CreatorSource::Mcp).merge_into(tags);
873}
874
875async fn handle_memory_remember(state: &AppState, args: Value) -> Result<Value> {
885 let palace = resolve_palace(state, &args, "memory_remember")?;
886 let palace = palace.as_str();
887 let raw_text = args
888 .get("text")
889 .and_then(|v| v.as_str())
890 .ok_or_else(|| anyhow!("memory_remember: missing 'text'"))?
891 .to_string();
892 if blocklist_gate(&raw_text) {
897 tracing::debug!(
898 palace = %palace,
899 "content gate: skipped (blocked pattern)",
900 );
901 return Ok(skipped_envelope(
902 palace,
903 "content gate: skipped (blocked pattern)",
904 ));
905 }
906 let ctx = args.get("context").and_then(|v| v.as_str());
912 let text = match content_gate(&raw_text, ctx) {
913 Some(t) => t,
914 None => {
915 return Ok(skipped_envelope(
916 palace,
917 "content gate: skipped (short prompt, no context)",
918 ));
919 }
920 };
921 let room = parse_room(args.get("room").and_then(|v| v.as_str()));
922 let mut tags = parse_tags(&args);
923 attach_mcp_attribution(&mut tags);
931
932 let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
933
934 let write_lock = state.palace_write_lock(palace);
942 let _write_guard = write_lock.lock().await;
943
944 if !force {
949 let handle = open_palace_handle(state, palace)?;
950 if dedup_gate(&handle, &text) {
951 tracing::debug!(
952 palace = %palace,
953 "content gate: skipped (duplicate within window)",
954 );
955 return Ok(skipped_envelope(
956 palace,
957 "content gate: skipped (duplicate within window)",
958 ));
959 }
960 }
961 let room_label_for_kg = room_label(&room);
962 let drawer_id = write_drawer(
963 state,
964 WriteDrawerParams {
965 palace_id: palace,
966 content: text,
967 tags,
968 room,
969 importance: 0.5,
970 opts: mcp_remember_opts(force),
971 room_label_for_kg,
972 },
973 )
974 .await?;
975 Ok(json!({
976 "drawer_id": drawer_id.to_string(),
977 "palace": palace,
978 "status": "stored",
979 }))
980}
981
982async fn handle_memory_note(state: &AppState, args: Value) -> Result<Value> {
983 let palace = resolve_palace(state, &args, "memory_note")?;
989 let palace = palace.as_str();
990 let raw_content = args
991 .get("content")
992 .and_then(|v| v.as_str())
993 .ok_or_else(|| anyhow!("memory_note: missing 'content'"))?
994 .to_string();
995 if blocklist_gate(&raw_content) {
1000 tracing::debug!(
1001 palace = %palace,
1002 "content gate: skipped (blocked pattern)",
1003 );
1004 return Ok(skipped_envelope(
1005 palace,
1006 "content gate: skipped (blocked pattern)",
1007 ));
1008 }
1009 let ctx = args.get("context").and_then(|v| v.as_str());
1014 let content = match content_gate(&raw_content, ctx) {
1015 Some(c) => c,
1016 None => {
1017 return Ok(skipped_envelope(
1018 palace,
1019 "content gate: skipped (short prompt, no context)",
1020 ));
1021 }
1022 };
1023 let mut tags = parse_tags(&args);
1024 attach_mcp_attribution(&mut tags);
1028 let write_lock = state.palace_write_lock(palace);
1036 let _write_guard = write_lock.lock().await;
1037 {
1042 let handle = open_palace_handle(state, palace)?;
1043 if dedup_gate(&handle, &content) {
1044 tracing::debug!(
1045 palace = %palace,
1046 "content gate: skipped (duplicate within window)",
1047 );
1048 return Ok(skipped_envelope(
1049 palace,
1050 "content gate: skipped (duplicate within window)",
1051 ));
1052 }
1053 }
1054 let drawer_id = write_drawer(
1058 state,
1059 WriteDrawerParams {
1060 palace_id: palace,
1061 content,
1062 tags,
1063 room: RoomType::General,
1064 importance: 1.0,
1065 opts: RememberOptions::note(),
1066 room_label_for_kg: Some("General".to_string()),
1070 },
1071 )
1072 .await
1073 .context("PalaceHandle::remember_with_options (note)")?;
1074 Ok(json!({
1075 "drawer_id": drawer_id.to_string(),
1076 "palace": palace,
1077 "status": "stored",
1078 "drawer_type": "UserFact",
1079 }))
1080}
1081
1082async fn handle_memory_recall(state: &AppState, args: Value) -> Result<Value> {
1083 let palace = resolve_palace(state, &args, "memory_recall")?;
1084 let query = args
1085 .get("query")
1086 .and_then(|v| v.as_str())
1087 .ok_or_else(|| anyhow!("memory_recall: missing 'query'"))?;
1088 let top_k = args.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
1089
1090 let handle = open_palace_handle(state, &palace)?;
1091 let embedder = state.embedder().await?;
1092 let vector_fut = recall(&handle, embedder.as_ref(), query, top_k);
1098 let bm25_fut = bm25_search_optional(state, &palace, query, top_k);
1099 let (vector_res, bm25_res) = tokio::join!(vector_fut, bm25_fut);
1100 let mut results = vector_res.context("recall")?;
1101 if let Some(bm25_hits) = bm25_res {
1102 fuse_bm25_into_recall(&mut results, &bm25_hits, top_k);
1103 }
1104 Ok(serialize_recall(&palace, query, results))
1105}
1106
1107async fn handle_memory_recall_deep(state: &AppState, args: Value) -> Result<Value> {
1108 let palace = resolve_palace(state, &args, "memory_recall_deep")?;
1109 let query = args
1110 .get("query")
1111 .and_then(|v| v.as_str())
1112 .ok_or_else(|| anyhow!("memory_recall_deep: missing 'query'"))?;
1113 let top_k = args.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
1114
1115 let handle = open_palace_handle(state, &palace)?;
1116 let embedder = state.embedder().await?;
1117 let results = recall_deep(&handle, embedder.as_ref(), query, top_k)
1118 .await
1119 .context("recall_deep")?;
1120 Ok(serialize_recall(&palace, query, results))
1121}
1122
1123async fn handle_palace_create(state: &AppState, args: Value) -> Result<Value> {
1124 let palace_name = args
1125 .get("name")
1126 .and_then(|v| v.as_str())
1127 .ok_or_else(|| anyhow!("palace_create: missing 'name'"))?;
1128 let description = args
1129 .get("description")
1130 .and_then(|v| v.as_str())
1131 .map(|s| s.to_string());
1132 let palace = Palace {
1133 id: PalaceId::new(palace_name),
1134 name: palace_name.to_string(),
1135 description,
1136 created_at: chrono::Utc::now(),
1137 data_dir: state.data_root.join(palace_name),
1138 };
1139 let _handle = state
1140 .registry
1141 .create_palace(&state.data_root, palace)
1142 .context("create_palace")?;
1143 state
1147 .palace_names
1148 .insert(palace_name.to_string(), palace_name.to_string());
1149 state.emit(DaemonEvent::PalaceCreated {
1152 id: palace_name.to_string(),
1153 name: palace_name.to_string(),
1154 source: ActivitySource::Mcp,
1155 });
1156 let bootstrap_summary = match crate::bootstrap::bootstrap_palace(state, palace_name, None).await
1164 {
1165 Ok(r) => Some(serde_json::json!({
1166 "triples_asserted": r.triples_asserted,
1167 "project_subject": r.project_subject,
1168 })),
1169 Err(e) => {
1170 tracing::warn!(
1171 palace = %palace_name,
1172 "auto-bootstrap on palace_create failed: {e:#}",
1173 );
1174 None
1175 }
1176 };
1177 Ok(json!({
1178 "palace_id": palace_name,
1179 "status": "created",
1180 "bootstrap": bootstrap_summary,
1181 }))
1182}
1183
1184async fn handle_palace_list(state: &AppState, _args: Value) -> Result<Value> {
1185 let root = state.data_root.clone();
1186 let palaces = tokio::task::spawn_blocking(move || {
1187 trusty_common::memory_core::PalaceRegistry::list_palaces(&root)
1188 })
1189 .await
1190 .context("join list_palaces")??;
1191 let ids: Vec<String> = palaces.iter().map(|p| p.id.as_str().to_string()).collect();
1192 Ok(json!({ "palaces": ids }))
1193}
1194
1195async fn handle_palace_delete(state: &AppState, args: Value) -> Result<Value> {
1196 let palace_id = args
1204 .get("palace_id")
1205 .and_then(|v| v.as_str())
1206 .ok_or_else(|| anyhow!("palace_delete: missing 'palace_id'"))?
1207 .to_string();
1208 let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
1209 use crate::service::{MemoryService, ServiceError};
1210 let svc = MemoryService::new(state.clone());
1211 match svc.delete_palace(&palace_id, force).await {
1212 Ok(()) => Ok(json!({ "deleted": palace_id })),
1213 Err(ServiceError::NotFound(_)) => Err(anyhow!("Palace not found: {palace_id}")),
1214 Err(ServiceError::Conflict(msg)) => Err(anyhow!(msg)),
1215 Err(e) => Err(anyhow!("palace_delete: {e}")),
1216 }
1217}
1218
1219async fn handle_palace_update(state: &AppState, args: Value) -> Result<Value> {
1220 let palace_id = args
1229 .get("palace_id")
1230 .and_then(|v| v.as_str())
1231 .ok_or_else(|| anyhow!("palace_update: missing 'palace_id'"))?
1232 .to_string();
1233 let name = args
1234 .get("name")
1235 .and_then(|v| v.as_str())
1236 .ok_or_else(|| anyhow!("palace_update: missing 'name'"))?
1237 .to_string();
1238 use crate::service::MemoryService;
1239 let svc = MemoryService::new(state.clone());
1240 match svc.update_palace_name(&palace_id, &name).await {
1241 Ok(_info) => Ok(json!({ "updated": palace_id, "name": name.trim() })),
1242 Err(e) => Err(anyhow!("palace_update: {e}")),
1243 }
1244}
1245
1246async fn handle_kg_assert(state: &AppState, args: Value) -> Result<Value> {
1247 let palace = resolve_palace(state, &args, "kg_assert")?;
1248 let palace = palace.as_str();
1249 let subject = args
1250 .get("subject")
1251 .and_then(|v| v.as_str())
1252 .ok_or_else(|| anyhow!("kg_assert: missing 'subject'"))?
1253 .to_string();
1254 let predicate = args
1255 .get("predicate")
1256 .and_then(|v| v.as_str())
1257 .ok_or_else(|| anyhow!("kg_assert: missing 'predicate'"))?
1258 .to_string();
1259 let object = args
1260 .get("object")
1261 .and_then(|v| v.as_str())
1262 .ok_or_else(|| anyhow!("kg_assert: missing 'object'"))?
1263 .to_string();
1264 let confidence = args
1265 .get("confidence")
1266 .and_then(|v| v.as_f64())
1267 .map(|c| (c as f32).clamp(0.0, 1.0))
1268 .unwrap_or(1.0);
1269 let provenance = args
1270 .get("provenance")
1271 .and_then(|v| v.as_str())
1272 .map(|s| s.to_string());
1273
1274 let handle = open_palace_handle(state, palace)?;
1275 let triple = Triple {
1276 subject,
1277 predicate,
1278 object,
1279 valid_from: chrono::Utc::now(),
1280 valid_to: None,
1281 confidence,
1282 provenance,
1283 };
1284 let is_hot = crate::prompt_facts::is_hot_predicate(&triple.predicate);
1285 handle.kg.assert(triple).await.context("kg.assert")?;
1286 if is_hot {
1291 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1292 tracing::warn!("rebuild_prompt_cache after kg_assert failed: {e:#}");
1293 }
1294 }
1295 Ok(json!({ "status": "asserted" }))
1296}
1297
1298async fn handle_add_alias(state: &AppState, args: Value) -> Result<Value> {
1299 let short = args
1300 .get("short")
1301 .and_then(|v| v.as_str())
1302 .ok_or_else(|| anyhow!("add_alias: missing 'short'"))?
1303 .to_string();
1304 let full = args
1305 .get("full")
1306 .and_then(|v| v.as_str())
1307 .ok_or_else(|| anyhow!("add_alias: missing 'full'"))?
1308 .to_string();
1309 let extra = args
1310 .get("extra")
1311 .and_then(|v| v.as_str())
1312 .map(|s| s.to_string());
1313
1314 let palace = resolve_palace(state, &args, "add_alias")?;
1319 let handle = open_palace_handle(state, &palace)?;
1320 let object = match extra.as_deref() {
1322 Some(e) if !e.is_empty() => format!("{full} ({e})"),
1323 _ => full.clone(),
1324 };
1325 let triple = Triple {
1326 subject: short.clone(),
1327 predicate: "is_alias_for".to_string(),
1328 object,
1329 valid_from: chrono::Utc::now(),
1330 valid_to: None,
1331 confidence: 1.0,
1332 provenance: Some("add_alias".to_string()),
1333 };
1334 handle
1335 .kg
1336 .assert(triple)
1337 .await
1338 .context("kg.assert (alias)")?;
1339 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1340 tracing::warn!("rebuild_prompt_cache after add_alias failed: {e:#}");
1341 }
1342 Ok(json!({ "asserted": true, "short": short, "full": full }))
1343}
1344
1345async fn handle_list_prompt_facts(state: &AppState, _args: Value) -> Result<Value> {
1346 let triples = crate::prompt_facts::gather_hot_triples(state).await?;
1347 let payload: Vec<Value> = triples
1348 .into_iter()
1349 .map(|(subject, predicate, object)| {
1350 json!({ "subject": subject, "predicate": predicate, "object": object })
1351 })
1352 .collect();
1353 Ok(json!({ "facts": payload }))
1354}
1355
1356async fn handle_remove_prompt_fact(state: &AppState, args: Value) -> Result<Value> {
1357 let subject = args
1358 .get("subject")
1359 .and_then(|v| v.as_str())
1360 .ok_or_else(|| anyhow!("remove_prompt_fact: missing 'subject'"))?
1361 .to_string();
1362 let predicate = args
1363 .get("predicate")
1364 .and_then(|v| v.as_str())
1365 .ok_or_else(|| anyhow!("remove_prompt_fact: missing 'predicate'"))?
1366 .to_string();
1367
1368 let mut closed_total: usize = 0;
1374 for palace_id in state.registry.list() {
1375 if let Some(handle) = state.registry.get(&palace_id) {
1376 match handle.kg.retract(&subject, &predicate).await {
1377 Ok(n) => closed_total += n,
1378 Err(e) => tracing::warn!(
1379 palace = %palace_id.as_str(),
1380 "retract failed: {e:#}",
1381 ),
1382 }
1383 }
1384 }
1385 if closed_total > 0 {
1386 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1387 tracing::warn!("rebuild_prompt_cache after remove_prompt_fact failed: {e:#}");
1388 }
1389 Ok(json!({ "removed": true, "closed": closed_total }))
1390 } else {
1391 Ok(json!({ "removed": false, "reason": "not found" }))
1392 }
1393}
1394
1395async fn handle_kg_query(state: &AppState, args: Value) -> Result<Value> {
1396 let palace = resolve_palace(state, &args, "kg_query")?;
1397 let subject = args
1398 .get("subject")
1399 .and_then(|v| v.as_str())
1400 .ok_or_else(|| anyhow!("kg_query: missing 'subject'"))?;
1401 let handle = open_palace_handle(state, &palace)?;
1402 let triples = handle
1403 .kg
1404 .query_active(subject)
1405 .await
1406 .context("kg.query_active")?;
1407 let payload: Vec<Value> = triples
1408 .iter()
1409 .map(|t| {
1410 json!({
1411 "subject": t.subject,
1412 "predicate": t.predicate,
1413 "object": t.object,
1414 "valid_from": t.valid_from.to_rfc3339(),
1415 "valid_to": t.valid_to.as_ref().map(|d| d.to_rfc3339()),
1416 "confidence": t.confidence,
1417 "provenance": t.provenance,
1418 })
1419 })
1420 .collect();
1421 let mut response = json!({ "subject": subject, "triples": payload });
1427 if crate::bootstrap::is_kg_empty_for_subject(&triples) {
1428 response["hint"] = Value::String(crate::bootstrap::KG_EMPTY_HINT.to_string());
1429 }
1430 Ok(response)
1431}
1432
1433async fn handle_memory_list(state: &AppState, args: Value) -> Result<Value> {
1434 let palace = resolve_palace(state, &args, "memory_list")?;
1435 let handle = open_palace_handle(state, &palace)?;
1436 let room = args
1437 .get("room")
1438 .and_then(|v| v.as_str())
1439 .map(|s| parse_room(Some(s)));
1440 let tag = args
1441 .get("tag")
1442 .and_then(|v| v.as_str())
1443 .map(|s| s.to_string());
1444 let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
1445 let drawers = handle.list_drawers(room, tag, limit);
1446 let payload: Vec<Value> = drawers
1447 .iter()
1448 .map(|d| {
1449 json!({
1450 "drawer_id": d.id.to_string(),
1451 "content": d.content,
1452 "importance": d.importance,
1453 "tags": d.tags,
1454 "created_at": d.created_at.to_rfc3339(),
1455 "drawer_type": d.drawer_type.as_str(),
1456 "expires_at": d.expires_at.map(|t| t.to_rfc3339()),
1457 })
1458 })
1459 .collect();
1460 Ok(json!({ "palace": palace, "drawers": payload }))
1461}
1462
1463async fn handle_memory_forget(state: &AppState, args: Value) -> Result<Value> {
1464 let palace = resolve_palace(state, &args, "memory_forget")?;
1465 let drawer_id_str = args
1466 .get("drawer_id")
1467 .and_then(|v| v.as_str())
1468 .ok_or_else(|| anyhow!("memory_forget: missing 'drawer_id'"))?;
1469 let drawer_id = Uuid::parse_str(drawer_id_str)
1470 .map_err(|e| anyhow!("memory_forget: invalid drawer_id UUID: {e}"))?;
1471 let handle = open_palace_handle(state, &palace)?;
1472 handle.forget(drawer_id).await.context("forget")?;
1473 let drawer_count = handle.drawers.read().len();
1475 state.emit(DaemonEvent::DrawerDeleted {
1476 palace_id: palace.clone(),
1477 drawer_count,
1478 source: ActivitySource::Mcp,
1479 });
1480 Ok(json!({ "status": "deleted", "drawer_id": drawer_id_str, "palace": palace }))
1483}
1484
1485async fn handle_palace_info(state: &AppState, args: Value) -> Result<Value> {
1486 let palace = resolve_palace(state, &args, "palace_info")?;
1487 let handle = open_palace_handle(state, &palace)?;
1488 let drawer_count = handle.list_drawers(None, None, usize::MAX).len();
1489 let data_dir = handle
1490 .data_dir
1491 .as_ref()
1492 .map(|p| p.to_string_lossy().to_string());
1493 Ok(json!({
1494 "id": handle.id.as_str(),
1495 "name": handle.id.as_str(),
1496 "drawer_count": drawer_count,
1497 "data_dir": data_dir,
1498 }))
1499}
1500
1501async fn handle_palace_compact(state: &AppState, args: Value) -> Result<Value> {
1502 let palace = resolve_palace(state, &args, "palace_compact")?;
1503 let handle = open_palace_handle(state, &palace)?;
1504 let valid_ids: std::collections::HashSet<Uuid> =
1508 handle.drawers.read().iter().map(|d| d.id).collect();
1509 let vector_store = handle.vector_store.clone();
1510 let res = tokio::task::spawn_blocking(move || vector_store.compact_orphans(&valid_ids))
1511 .await
1512 .context("join palace_compact")??;
1513 Ok(json!({
1514 "palace": palace,
1515 "total_checked": res.total_checked,
1516 "orphans_removed": res.orphans_removed,
1517 "index_size_before": res.index_size_before,
1518 "index_size_after": res.index_size_after,
1519 }))
1520}
1521
1522async fn handle_kg_gaps(state: &AppState, args: Value) -> Result<Value> {
1523 let palace = resolve_palace(state, &args, "kg_gaps")?;
1533 let _handle = open_palace_handle(state, &palace)?;
1536 let pid = PalaceId::new(&palace);
1537 let cached = state.registry.get_gaps(&pid).unwrap_or_default();
1538 let payload: Vec<Value> = cached
1539 .into_iter()
1540 .map(|g| {
1541 json!({
1542 "entities": g.entities,
1543 "internal_density": g.internal_density,
1544 "external_bridges": g.external_bridges,
1545 "suggested_exploration": g.suggested_exploration,
1546 })
1547 })
1548 .collect();
1549 Ok(json!({ "palace": palace, "gaps": payload }))
1550}
1551
1552async fn handle_memory_recall_all(state: &AppState, args: Value) -> Result<Value> {
1553 let query = args
1554 .get("q")
1555 .and_then(|v| v.as_str())
1556 .ok_or_else(|| anyhow!("memory_recall_all: missing 'q'"))?;
1557 let top_k = args.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
1558 let deep = args.get("deep").and_then(|v| v.as_bool()).unwrap_or(false);
1559
1560 let root = state.data_root.clone();
1564 let palaces = tokio::task::spawn_blocking(move || {
1565 trusty_common::memory_core::PalaceRegistry::list_palaces(&root)
1566 })
1567 .await
1568 .context("join list_palaces")??;
1569
1570 let mut handles = Vec::with_capacity(palaces.len());
1571 for p in &palaces {
1572 match state.registry.open_palace(&state.data_root, &p.id) {
1573 Ok(h) => handles.push(h),
1574 Err(e) => {
1575 tracing::warn!(palace = %p.id, "memory_recall_all: open failed: {e:#}")
1576 }
1577 }
1578 }
1579
1580 let embedder = state.embedder().await?;
1581 let erased: std::sync::Arc<dyn trusty_common::memory_core::embed::Embedder + Send + Sync> =
1582 embedder;
1583 let results = recall_across_palaces(&handles, &erased, query, top_k, deep)
1584 .await
1585 .context("recall_across_palaces")?;
1586
1587 let payload: Vec<Value> = results
1588 .iter()
1589 .map(|r| {
1590 json!({
1591 "palace_id": r.palace_id,
1592 "drawer_id": r.result.drawer.id.to_string(),
1593 "content": r.result.drawer.content,
1594 "importance": r.result.drawer.importance,
1595 "tags": r.result.drawer.tags,
1596 "score": r.result.score,
1597 "layer": r.result.layer,
1598 "drawer_type": r.result.drawer.drawer_type.as_str(),
1599 })
1600 })
1601 .collect();
1602 Ok(json!({ "query": query, "results": payload }))
1603}
1604
1605async fn handle_get_prompt_context(state: &AppState, args: Value) -> Result<Value> {
1606 let query = args
1617 .get("query")
1618 .and_then(|v| v.as_str())
1619 .map(|s| s.trim().to_string())
1620 .filter(|s| !s.is_empty());
1621
1622 let cache_snapshot = {
1626 let guard = state.prompt_context_cache.read().await;
1627 guard.clone()
1628 };
1629
1630 let body = if let Some(q) = query.as_deref() {
1631 let needle = q.to_lowercase();
1632 let filtered: Vec<(String, String, String)> = cache_snapshot
1633 .triples
1634 .into_iter()
1635 .filter(|(subject, _predicate, object)| {
1636 subject.to_lowercase().contains(&needle) || object.to_lowercase().contains(&needle)
1637 })
1638 .collect();
1639 let formatted = crate::prompt_facts::build_prompt_context(&filtered);
1640 if formatted.is_empty() {
1641 "No project context found matching your query.".to_string()
1642 } else {
1643 formatted
1644 }
1645 } else if cache_snapshot.formatted.is_empty() {
1646 "No prompt facts stored yet.".to_string()
1647 } else {
1648 cache_snapshot.formatted
1649 };
1650
1651 Ok(Value::String(body))
1657}
1658
1659async fn handle_discover_aliases(state: &AppState, args: Value) -> Result<Value> {
1660 let palace = resolve_palace(state, &args, "discover_aliases")?;
1671 let project_root = args
1672 .get("project_root")
1673 .and_then(|v| v.as_str())
1674 .map(std::path::PathBuf::from)
1675 .or_else(|| std::env::current_dir().ok())
1676 .ok_or_else(|| anyhow!("discover_aliases: no project_root and cwd unavailable"))?;
1677
1678 let discoveries = crate::discovery::discover_project_aliases(&project_root).await?;
1679
1680 let handle = open_palace_handle(state, &palace)?;
1681
1682 let mut already_known = 0usize;
1683 let mut newly_asserted = 0usize;
1684 let mut reported: Vec<Value> = Vec::with_capacity(discoveries.len());
1685
1686 for d in &discoveries {
1687 let active = handle
1690 .kg
1691 .query_active(&d.short)
1692 .await
1693 .context("kg.query_active")?;
1694 let exists = active
1695 .iter()
1696 .any(|t| t.predicate == "is_alias_for" && t.object == d.full);
1697 if exists {
1698 already_known += 1;
1699 continue;
1700 }
1701
1702 let triple = Triple {
1703 subject: d.short.clone(),
1704 predicate: "is_alias_for".to_string(),
1705 object: d.full.clone(),
1706 valid_from: chrono::Utc::now(),
1707 valid_to: None,
1708 confidence: 1.0,
1709 provenance: Some(format!("discover_aliases:{}", d.source.as_str())),
1710 };
1711 handle
1712 .kg
1713 .assert(triple)
1714 .await
1715 .context("kg.assert (discover)")?;
1716 newly_asserted += 1;
1717 reported.push(json!({
1718 "short": d.short,
1719 "full": d.full,
1720 "source": d.source.as_str(),
1721 }));
1722 }
1723
1724 if newly_asserted > 0 {
1725 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1726 tracing::warn!("rebuild_prompt_cache after discover_aliases failed: {e:#}");
1727 }
1728 }
1729
1730 Ok(json!({
1731 "discovered": reported,
1732 "already_known": already_known,
1733 "new": newly_asserted,
1734 "palace": palace,
1735 }))
1736}
1737
1738async fn handle_kg_bootstrap(state: &AppState, args: Value) -> Result<Value> {
1739 let palace = resolve_palace(state, &args, "kg_bootstrap")?;
1744 let project_path = args
1745 .get("project_path")
1746 .and_then(|v| v.as_str())
1747 .map(std::path::PathBuf::from);
1748 let result = crate::bootstrap::bootstrap_palace(state, &palace, project_path.as_deref())
1749 .await
1750 .context("bootstrap_palace")?;
1751 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1755 tracing::warn!("rebuild_prompt_cache after kg_bootstrap failed: {e:#}");
1756 }
1757 crate::bootstrap::result_to_json(&result)
1758}
1759
1760async fn handle_memory_send_message(state: &AppState, args: Value) -> Result<Value> {
1761 let to_palace = args
1763 .get("to_palace")
1764 .and_then(|v| v.as_str())
1765 .ok_or_else(|| anyhow!("memory_send_message: missing 'to_palace'"))?
1766 .to_string();
1767 let purpose = args
1768 .get("purpose")
1769 .and_then(|v| v.as_str())
1770 .ok_or_else(|| anyhow!("memory_send_message: missing 'purpose'"))?
1771 .to_string();
1772 let content = args
1773 .get("content")
1774 .and_then(|v| v.as_str())
1775 .ok_or_else(|| anyhow!("memory_send_message: missing 'content'"))?
1776 .to_string();
1777 let from_palace = if let Some(s) = args.get("from_palace").and_then(|v| v.as_str()) {
1780 s.to_string()
1781 } else if let Some(d) = state.default_palace.clone() {
1782 d
1783 } else {
1784 crate::messaging::cwd_palace_slug()
1785 .context("memory_send_message: derive from_palace from cwd")?
1786 };
1787 let drawer_id = crate::messaging::send_message_to_palace(
1788 &state.registry,
1789 &state.data_root,
1790 &from_palace,
1791 &to_palace,
1792 &purpose,
1793 content,
1794 CreatorInfo::new_self(MCP_CLIENT_NAME, CreatorSource::Mcp),
1795 )
1796 .await
1797 .context("memory_send_message")?;
1798 Ok(json!({
1799 "drawer_id": drawer_id.to_string(),
1800 "from_palace": from_palace,
1801 "to_palace": to_palace,
1802 "purpose": purpose,
1803 "status": "sent",
1804 }))
1805}
1806
1807pub async fn dispatch_tool(state: &AppState, name: &str, args: Value) -> Result<Value> {
1819 match name {
1820 "memory_remember" => handle_memory_remember(state, args).await,
1821 "memory_note" => handle_memory_note(state, args).await,
1822 "memory_recall" => handle_memory_recall(state, args).await,
1823 "memory_recall_deep" => handle_memory_recall_deep(state, args).await,
1824 "palace_create" => handle_palace_create(state, args).await,
1825 "palace_list" => handle_palace_list(state, args).await,
1826 "palace_delete" => handle_palace_delete(state, args).await,
1827 "palace_update" => handle_palace_update(state, args).await,
1828 "kg_assert" => handle_kg_assert(state, args).await,
1829 "add_alias" => handle_add_alias(state, args).await,
1830 "list_prompt_facts" => handle_list_prompt_facts(state, args).await,
1831 "remove_prompt_fact" => handle_remove_prompt_fact(state, args).await,
1832 "kg_query" => handle_kg_query(state, args).await,
1833 "memory_list" => handle_memory_list(state, args).await,
1834 "memory_forget" => handle_memory_forget(state, args).await,
1835 "palace_info" => handle_palace_info(state, args).await,
1836 "palace_compact" => handle_palace_compact(state, args).await,
1837 "kg_gaps" => handle_kg_gaps(state, args).await,
1838 "memory_recall_all" => handle_memory_recall_all(state, args).await,
1839 "get_prompt_context" => handle_get_prompt_context(state, args).await,
1840 "discover_aliases" => handle_discover_aliases(state, args).await,
1841 "kg_bootstrap" => handle_kg_bootstrap(state, args).await,
1842 "memory_send_message" => handle_memory_send_message(state, args).await,
1843 other => anyhow::bail!("unknown tool: {other}"),
1844 }
1845}
1846
1847fn bm25_data_dir_for_palace(state: &AppState, palace: &str) -> std::path::PathBuf {
1860 state.data_root.join(palace).join("bm25")
1861}
1862
1863async fn ensure_bm25_running_for_palace(state: &AppState, palace: &str) -> bool {
1880 let Some(supervisor) = state.bm25_supervisor.as_ref() else {
1881 return true;
1884 };
1885 let data_dir = bm25_data_dir_for_palace(state, palace);
1886 match supervisor.ensure_running(palace, &data_dir).await {
1887 Ok(_socket) => true,
1888 Err(e) => {
1889 tracing::warn!(
1890 palace = %palace,
1891 "bm25 supervisor could not start daemon (degrading to vector-only): {e:#}"
1892 );
1893 false
1894 }
1895 }
1896}
1897
1898pub const BM25_INDEX_QUEUE_CAPACITY: usize = 256;
1914
1915#[derive(Debug)]
1928pub struct Bm25IndexRequest {
1929 pub palace: String,
1931 pub drawer_id: String,
1933 pub content: String,
1935 pub data_dir: std::path::PathBuf,
1939}
1940
1941pub fn spawn_bm25_index_worker(
1962 mut rx: tokio::sync::mpsc::Receiver<Bm25IndexRequest>,
1963 client: Option<std::sync::Arc<trusty_common::bm25_client::Bm25Client>>,
1964 supervisor: Option<std::sync::Arc<crate::bm25_supervisor::Bm25Supervisor>>,
1965) {
1966 tokio::spawn(async move {
1967 while let Some(req) = rx.recv().await {
1968 let Some(client) = client.as_ref() else {
1971 continue;
1972 };
1973 if let Some(sup) = supervisor.as_ref() {
1977 if let Err(e) = sup.ensure_running(&req.palace, &req.data_dir).await {
1978 tracing::warn!(
1979 palace = %req.palace,
1980 "bm25 supervisor failed to start daemon for index (non-fatal): {e:#}"
1981 );
1982 continue;
1983 }
1984 }
1985 if let Err(e) = client.index(&req.drawer_id, &req.content).await {
1986 tracing::warn!(
1987 palace = %req.palace,
1988 drawer_id = %req.drawer_id,
1989 "bm25 daemon index failed (non-fatal): {e:#}"
1990 );
1991 }
1992 }
1993 tracing::debug!("bm25 index worker exiting (channel closed)");
1994 });
1995}
1996
1997fn bm25_index_enqueue(state: &AppState, palace: &str, drawer_id: Uuid, content: &str) {
2014 let req = Bm25IndexRequest {
2015 palace: palace.to_string(),
2016 drawer_id: drawer_id.to_string(),
2017 content: content.to_string(),
2018 data_dir: bm25_data_dir_for_palace(state, palace),
2019 };
2020 match state.bm25_index_tx.try_send(req) {
2021 Ok(()) => {}
2022 Err(tokio::sync::mpsc::error::TrySendError::Full(req)) => {
2023 tracing::warn!(
2024 palace = %req.palace,
2025 drawer_id = %req.drawer_id,
2026 "BM25 index queue full — skipping drawer {}",
2027 req.drawer_id
2028 );
2029 }
2030 Err(tokio::sync::mpsc::error::TrySendError::Closed(req)) => {
2031 tracing::debug!(
2032 palace = %req.palace,
2033 drawer_id = %req.drawer_id,
2034 "BM25 index queue closed — skipping drawer {}",
2035 req.drawer_id
2036 );
2037 }
2038 }
2039}
2040
2041async fn bm25_search_optional(
2055 state: &AppState,
2056 palace: &str,
2057 query: &str,
2058 top_k: usize,
2059) -> Option<Vec<trusty_common::bm25_client::BM25Hit>> {
2060 let client = state.bm25_client.as_ref()?;
2061 if !ensure_bm25_running_for_palace(state, palace).await {
2065 return None;
2066 }
2067 match client.search(query, top_k).await {
2068 Ok(hits) => Some(hits),
2069 Err(e) => {
2070 tracing::warn!(
2071 palace = %palace,
2072 "bm25 daemon search failed (falling back to vector-only): {e:#}"
2073 );
2074 None
2075 }
2076 }
2077}
2078
2079fn fuse_bm25_into_recall(
2094 results: &mut Vec<trusty_common::memory_core::retrieval::RecallResult>,
2095 bm25_hits: &[trusty_common::bm25_client::BM25Hit],
2096 top_k: usize,
2097) {
2098 const RRF_K: f32 = 60.0;
2101 if bm25_hits.is_empty() {
2102 return;
2103 }
2104 for (rank, hit) in bm25_hits.iter().enumerate() {
2106 let bonus = 1.0 / (RRF_K + rank as f32 + 1.0);
2107 if let Some(existing) = results
2108 .iter_mut()
2109 .find(|r| r.drawer.id.to_string() == hit.doc_id)
2110 {
2111 existing.score += bonus;
2112 }
2113 }
2121 results.sort_by(|a, b| {
2124 b.score
2125 .partial_cmp(&a.score)
2126 .unwrap_or(std::cmp::Ordering::Equal)
2127 .then(a.layer.cmp(&b.layer))
2128 });
2129 results.truncate(top_k);
2130}
2131
2132fn serialize_recall(
2134 palace: &str,
2135 query: &str,
2136 results: Vec<trusty_common::memory_core::retrieval::RecallResult>,
2137) -> Value {
2138 let payload: Vec<Value> = results
2139 .iter()
2140 .map(|r| {
2141 json!({
2142 "drawer_id": r.drawer.id.to_string(),
2143 "content": r.drawer.content,
2144 "score": r.score,
2145 "layer": r.layer,
2146 "tags": r.drawer.tags,
2147 "importance": r.drawer.importance,
2148 "drawer_type": r.drawer.drawer_type.as_str(),
2149 })
2150 })
2151 .collect();
2152 json!({
2153 "palace": palace,
2154 "query": query,
2155 "results": payload,
2156 })
2157}
2158
2159#[cfg(test)]
2160mod tests {
2161 use super::*;
2162 use crate::AppState;
2163
2164 fn test_state() -> (AppState, tempfile::TempDir) {
2172 let tmp = tempfile::tempdir().expect("tempdir");
2173 let root = tmp.path().to_path_buf();
2174 (AppState::new(root), tmp)
2175 }
2176
2177 #[test]
2182 fn tool_definitions_drops_palace_required_when_default_set() {
2183 let with_default = tool_definitions_with(true);
2184 let without_default = tool_definitions_with(false);
2185 for (name, palace_required_when_no_default) in [
2186 ("memory_remember", true),
2187 ("memory_recall", true),
2188 ("memory_recall_deep", true),
2189 ("memory_list", true),
2190 ("memory_forget", true),
2191 ("palace_info", true),
2192 ("palace_compact", true),
2193 ("kg_assert", true),
2194 ("kg_query", true),
2195 ] {
2196 for (defs, has_default) in [(&with_default, true), (&without_default, false)] {
2197 let tools = defs["tools"].as_array().unwrap();
2198 let tool = tools.iter().find(|t| t["name"] == name).unwrap();
2199 let required: Vec<&str> = tool["inputSchema"]["required"]
2200 .as_array()
2201 .unwrap()
2202 .iter()
2203 .filter_map(|v| v.as_str())
2204 .collect();
2205 let palace_required = required.contains(&"palace");
2206 let expected = palace_required_when_no_default && !has_default;
2207 assert_eq!(
2208 palace_required, expected,
2209 "tool={name} has_default={has_default} required={required:?}"
2210 );
2211 }
2212 }
2213 }
2214
2215 #[test]
2216 fn tool_definitions_lists_all_tools() {
2217 let defs = tool_definitions();
2218 let tools = defs
2219 .get("tools")
2220 .and_then(|t| t.as_array())
2221 .expect("tools array");
2222 assert_eq!(tools.len(), 23);
2223 let names: Vec<&str> = tools
2224 .iter()
2225 .filter_map(|t| t.get("name").and_then(|n| n.as_str()))
2226 .collect();
2227 for expected in [
2228 "memory_remember",
2229 "memory_note",
2230 "memory_recall",
2231 "memory_recall_deep",
2232 "memory_list",
2233 "memory_forget",
2234 "palace_create",
2235 "palace_delete",
2236 "palace_update",
2237 "palace_list",
2238 "palace_info",
2239 "palace_compact",
2240 "kg_assert",
2241 "kg_query",
2242 "memory_recall_all",
2243 "kg_gaps",
2244 "add_alias",
2245 "list_prompt_facts",
2246 "remove_prompt_fact",
2247 "get_prompt_context",
2248 "discover_aliases",
2249 "kg_bootstrap",
2250 "memory_send_message",
2251 ] {
2252 assert!(names.contains(&expected), "missing tool: {expected}");
2253 }
2254 }
2255
2256 #[tokio::test]
2259 async fn dispatch_palace_create_persists() {
2260 let (state, _tmp) = test_state();
2261 let created = dispatch_tool(&state, "palace_create", json!({"name": "alpha"}))
2262 .await
2263 .expect("palace_create");
2264 assert_eq!(created["palace_id"], "alpha");
2265
2266 let listed = dispatch_tool(&state, "palace_list", json!({}))
2267 .await
2268 .expect("palace_list");
2269 let ids = listed["palaces"].as_array().expect("palaces array");
2270 assert!(ids.iter().any(|v| v.as_str() == Some("alpha")));
2271 }
2272
2273 #[tokio::test]
2276 async fn dispatch_remember_then_recall() {
2277 let (state, _tmp) = test_state();
2278 let _ = dispatch_tool(&state, "palace_create", json!({"name": "beta"}))
2279 .await
2280 .expect("palace_create");
2281
2282 let remembered = dispatch_tool(
2283 &state,
2284 "memory_remember",
2285 json!({
2286 "palace": "beta",
2287 "text": "Quokkas are the happiest marsupials in Australia by general consensus",
2288 "room": "General",
2289 "tags": ["wildlife"],
2290 }),
2291 )
2292 .await
2293 .expect("memory_remember");
2294 assert!(remembered["drawer_id"].as_str().is_some());
2295
2296 let recalled = dispatch_tool(
2297 &state,
2298 "memory_recall",
2299 json!({"palace": "beta", "query": "Quokkas marsupials Australia", "top_k": 5}),
2300 )
2301 .await
2302 .expect("memory_recall");
2303 let results = recalled["results"].as_array().expect("results");
2304 assert!(
2305 results
2306 .iter()
2307 .any(|r| r["content"].as_str().unwrap_or("").contains("Quokkas")),
2308 "expected to recall the Quokkas drawer; got {results:?}"
2309 );
2310 }
2311
2312 #[tokio::test]
2321 async fn auto_kg_extraction_hooks_into_memory_remember() {
2322 let (state, _tmp) = test_state();
2323 let _ = dispatch_tool(&state, "palace_create", json!({"name": "kgauto"}))
2324 .await
2325 .expect("palace_create");
2326
2327 let _ = dispatch_tool(
2328 &state,
2329 "memory_remember",
2330 json!({
2331 "palace": "kgauto",
2332 "text": "Rustc is a compiler for the Rust language; tracks #performance",
2333 "room": "Backend",
2334 "tags": ["compiler", "language"],
2335 }),
2336 )
2337 .await
2338 .expect("memory_remember");
2339
2340 let handle = open_palace_handle(&state, "kgauto").expect("open palace");
2341 let triples = handle.kg.list_active(1000, 0).await.expect("list_active");
2342 let auto: Vec<_> = triples
2343 .iter()
2344 .filter(|t| t.provenance.as_deref() == Some(crate::kg_extract::AUTO_PROVENANCE))
2345 .collect();
2346 assert!(
2347 !auto.is_empty(),
2348 "expected at least one auto-extracted triple after memory_remember; got: {triples:?}"
2349 );
2350 assert!(
2354 auto.iter()
2355 .any(|t| t.subject == "tag:compiler" && t.predicate == "tags"),
2356 "expected tag:compiler edge in auto subset: {auto:?}"
2357 );
2358 assert!(
2359 auto.iter()
2360 .any(|t| t.subject == "tag:language" && t.predicate == "tags"),
2361 "expected tag:language edge in auto subset: {auto:?}"
2362 );
2363 assert!(
2364 auto.iter()
2365 .any(|t| t.subject == "room:Backend" && t.predicate == "contains"),
2366 "expected room:Backend edge in auto subset: {auto:?}"
2367 );
2368 assert!(
2369 auto.iter().any(|t| t.predicate == "mentioned-in"),
2370 "expected at least one #hashtag mention triple in auto subset: {auto:?}"
2371 );
2372 }
2373
2374 #[tokio::test]
2386 async fn auto_kg_extraction_no_op_does_not_fail_remember() {
2387 let (state, _tmp) = test_state();
2388 let _ = dispatch_tool(&state, "palace_create", json!({"name": "kgnoop"}))
2389 .await
2390 .expect("palace_create");
2391
2392 let res = dispatch_tool(
2393 &state,
2394 "memory_remember",
2395 json!({
2396 "palace": "kgnoop",
2397 "text": "The quick brown fox jumped over the lazy dog repeatedly",
2400 }),
2401 )
2402 .await
2403 .expect("memory_remember should succeed even when extraction yields nothing");
2404 assert!(res["drawer_id"].as_str().is_some());
2405 }
2406
2407 #[tokio::test]
2410 async fn dispatch_kg_assert_then_query() {
2411 let (state, _tmp) = test_state();
2412 let _ = dispatch_tool(&state, "palace_create", json!({"name": "gamma"}))
2413 .await
2414 .expect("palace_create");
2415
2416 let _ = dispatch_tool(
2417 &state,
2418 "kg_assert",
2419 json!({
2420 "palace": "gamma",
2421 "subject": "alice",
2422 "predicate": "works_at",
2423 "object": "Acme",
2424 "confidence": 0.9,
2425 "provenance": "test",
2426 }),
2427 )
2428 .await
2429 .expect("kg_assert");
2430
2431 let queried = dispatch_tool(
2432 &state,
2433 "kg_query",
2434 json!({"palace": "gamma", "subject": "alice"}),
2435 )
2436 .await
2437 .expect("kg_query");
2438 let triples = queried["triples"].as_array().expect("triples array");
2439 assert_eq!(triples.len(), 1);
2440 assert_eq!(triples[0]["object"], "Acme");
2441 assert_eq!(triples[0]["predicate"], "works_at");
2442 }
2443
2444 #[tokio::test]
2452 async fn dispatch_kg_gaps_returns_cached() {
2453 use trusty_common::memory_core::community::KnowledgeGap;
2454
2455 let (state, _tmp) = test_state();
2456 let _ = dispatch_tool(&state, "palace_create", json!({"name": "delta"}))
2457 .await
2458 .expect("palace_create");
2459
2460 let initial = dispatch_tool(&state, "kg_gaps", json!({"palace": "delta"}))
2462 .await
2463 .expect("kg_gaps empty");
2464 let gaps = initial["gaps"].as_array().expect("gaps array");
2465 assert_eq!(gaps.len(), 0);
2466
2467 state.registry.set_gaps(
2469 PalaceId::new("delta"),
2470 vec![KnowledgeGap {
2471 entities: vec!["x".to_string(), "y".to_string()],
2472 internal_density: 0.05,
2473 external_bridges: 0,
2474 suggested_exploration: "Explore connections between x and y".to_string(),
2475 }],
2476 );
2477 let seeded = dispatch_tool(&state, "kg_gaps", json!({"palace": "delta"}))
2478 .await
2479 .expect("kg_gaps seeded");
2480 let gaps = seeded["gaps"].as_array().expect("gaps array");
2481 assert_eq!(gaps.len(), 1);
2482 assert_eq!(gaps[0]["entities"][0], "x");
2483 assert_eq!(gaps[0]["external_bridges"], 0);
2484 assert!(gaps[0]["suggested_exploration"]
2485 .as_str()
2486 .unwrap()
2487 .contains("x"));
2488 }
2489
2490 #[tokio::test]
2495 async fn add_alias_round_trip_through_prompt_cache() {
2496 let _tmp = tempfile::tempdir().expect("tempdir");
2499 let root = _tmp.path().to_path_buf();
2500 let state = AppState::new(root).with_default_palace(Some("ctx".to_string()));
2501
2502 let _ = dispatch_tool(&state, "palace_create", json!({"name": "ctx"}))
2504 .await
2505 .expect("palace_create");
2506
2507 let added = dispatch_tool(
2509 &state,
2510 "add_alias",
2511 json!({"short": "tga", "full": "trusty-git-analytics"}),
2512 )
2513 .await
2514 .expect("add_alias");
2515 assert_eq!(added["asserted"], true);
2516 assert_eq!(added["short"], "tga");
2517
2518 let listed = dispatch_tool(&state, "list_prompt_facts", json!({}))
2520 .await
2521 .expect("list_prompt_facts");
2522 let facts = listed["facts"].as_array().expect("facts array");
2523 assert!(
2524 facts.iter().any(|f| f["subject"] == "tga"
2525 && f["predicate"] == "is_alias_for"
2526 && f["object"] == "trusty-git-analytics"),
2527 "expected tga alias in facts; got {facts:?}"
2528 );
2529
2530 {
2532 let guard = state.prompt_context_cache.read().await;
2533 assert!(
2534 guard.formatted.contains("tga → trusty-git-analytics"),
2535 "prompt cache should contain alias; got: {}",
2536 guard.formatted
2537 );
2538 }
2539
2540 let _ = dispatch_tool(
2542 &state,
2543 "add_alias",
2544 json!({"short": "tm", "full": "trusty-memory", "extra": "the MCP frontend"}),
2545 )
2546 .await
2547 .expect("add_alias with extra");
2548 {
2549 let guard = state.prompt_context_cache.read().await;
2550 assert!(
2551 guard
2552 .formatted
2553 .contains("tm → trusty-memory (the MCP frontend)"),
2554 "alias with extra not formatted; got: {}",
2555 guard.formatted
2556 );
2557 }
2558
2559 let removed = dispatch_tool(
2561 &state,
2562 "remove_prompt_fact",
2563 json!({"subject": "tga", "predicate": "is_alias_for"}),
2564 )
2565 .await
2566 .expect("remove_prompt_fact");
2567 assert_eq!(removed["removed"], true);
2568 {
2569 let guard = state.prompt_context_cache.read().await;
2570 assert!(
2571 !guard.formatted.contains("tga → trusty-git-analytics"),
2572 "retracted alias still in cache: {}",
2573 guard.formatted
2574 );
2575 assert!(
2576 guard.formatted.contains("tm → trusty-memory"),
2577 "non-retracted alias missing from cache: {}",
2578 guard.formatted
2579 );
2580 }
2581
2582 let missing = dispatch_tool(
2584 &state,
2585 "remove_prompt_fact",
2586 json!({"subject": "nope", "predicate": "is_alias_for"}),
2587 )
2588 .await
2589 .expect("remove_prompt_fact missing");
2590 assert_eq!(missing["removed"], false);
2591 }
2592
2593 #[tokio::test]
2598 async fn get_prompt_context_serves_cache_and_filters() {
2599 let (state, _tmp) = test_state();
2600
2601 let resp = dispatch_tool(&state, "get_prompt_context", json!({}))
2603 .await
2604 .expect("get_prompt_context empty");
2605 assert_eq!(resp.as_str().unwrap(), "No prompt facts stored yet.");
2606
2607 {
2609 let mut guard = state.prompt_context_cache.write().await;
2610 let triples = vec![
2611 (
2612 "tga".to_string(),
2613 "is_alias_for".to_string(),
2614 "trusty-git-analytics".to_string(),
2615 ),
2616 (
2617 "tm".to_string(),
2618 "is_alias_for".to_string(),
2619 "trusty-memory".to_string(),
2620 ),
2621 (
2622 "fact-1".to_string(),
2623 "is_fact".to_string(),
2624 "MSRV is 1.88".to_string(),
2625 ),
2626 ];
2627 let formatted = crate::prompt_facts::build_prompt_context(&triples);
2628 *guard = crate::prompt_facts::PromptFactsCache { triples, formatted };
2629 }
2630
2631 let resp = dispatch_tool(&state, "get_prompt_context", json!({}))
2633 .await
2634 .expect("get_prompt_context populated");
2635 let text = resp.as_str().expect("string body");
2636 assert!(text.contains("tga → trusty-git-analytics"));
2637 assert!(text.contains("tm → trusty-memory"));
2638 assert!(text.contains("MSRV is 1.88"));
2639
2640 let resp = dispatch_tool(&state, "get_prompt_context", json!({"query": "tga"}))
2642 .await
2643 .expect("get_prompt_context filtered");
2644 let text = resp.as_str().expect("string body");
2645 assert!(text.contains("tga → trusty-git-analytics"));
2646 assert!(!text.contains("tm → trusty-memory"));
2647 assert!(!text.contains("MSRV is 1.88"));
2648
2649 let resp = dispatch_tool(&state, "get_prompt_context", json!({"query": "MEMORY"}))
2651 .await
2652 .expect("get_prompt_context case-insensitive");
2653 let text = resp.as_str().expect("string body");
2654 assert!(text.contains("tm → trusty-memory"));
2655 assert!(!text.contains("tga → trusty-git-analytics"));
2656
2657 let resp = dispatch_tool(
2659 &state,
2660 "get_prompt_context",
2661 json!({"query": "zzz-nonexistent"}),
2662 )
2663 .await
2664 .expect("get_prompt_context no-match");
2665 assert_eq!(
2666 resp.as_str().unwrap(),
2667 "No project context found matching your query."
2668 );
2669
2670 let resp = dispatch_tool(&state, "get_prompt_context", json!({"query": " "}))
2672 .await
2673 .expect("get_prompt_context whitespace");
2674 let text = resp.as_str().expect("string body");
2675 assert!(text.contains("tga → trusty-git-analytics"));
2676 assert!(text.contains("tm → trusty-memory"));
2677 }
2678
2679 #[tokio::test]
2686 async fn dispatch_discover_aliases_inserts_new_and_dedupes() {
2687 let _tmp = tempfile::tempdir().expect("tempdir");
2690 let root = _tmp.path().to_path_buf();
2691 let state = AppState::new(root).with_default_palace(Some("disc".to_string()));
2692 let _ = dispatch_tool(&state, "palace_create", json!({"name": "disc"}))
2693 .await
2694 .expect("palace_create");
2695
2696 let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2700 .parent()
2701 .and_then(|p| p.parent())
2702 .expect("workspace root")
2703 .to_path_buf();
2704
2705 let first = dispatch_tool(
2706 &state,
2707 "discover_aliases",
2708 json!({"project_root": workspace_root.to_string_lossy()}),
2709 )
2710 .await
2711 .expect("discover_aliases first");
2712
2713 let new_count = first["new"].as_u64().expect("new is u64");
2714 assert!(new_count > 0, "expected new discoveries on first call");
2715 let discovered = first["discovered"].as_array().expect("discovered array");
2716 assert!(
2717 discovered
2718 .iter()
2719 .any(|d| d["short"] == "tga" && d["full"] == "trusty-git-analytics"),
2720 "expected tga alias in discoveries; got {discovered:?}"
2721 );
2722
2723 {
2725 let guard = state.prompt_context_cache.read().await;
2726 assert!(
2727 guard.formatted.contains("tga → trusty-git-analytics"),
2728 "prompt cache missing tga alias after discover_aliases; got: {}",
2729 guard.formatted
2730 );
2731 }
2732
2733 let second = dispatch_tool(
2736 &state,
2737 "discover_aliases",
2738 json!({"project_root": workspace_root.to_string_lossy()}),
2739 )
2740 .await
2741 .expect("discover_aliases second");
2742 assert_eq!(second["new"].as_u64(), Some(0), "expected 0 new on rerun");
2743 let already_known = second["already_known"].as_u64().expect("already_known");
2744 assert!(
2745 already_known >= new_count,
2746 "expected already_known >= {new_count}, got {already_known}"
2747 );
2748 }
2749
2750 #[tokio::test]
2757 async fn palace_create_auto_seeds_temporal_metadata() {
2758 let (state, _tmp) = test_state();
2759 let created = dispatch_tool(&state, "palace_create", json!({"name": "auto"}))
2760 .await
2761 .expect("palace_create");
2762 assert_eq!(created["palace_id"], "auto");
2763 let summary = &created["bootstrap"];
2765 assert!(summary.is_object(), "expected bootstrap summary object");
2766 assert!(summary["triples_asserted"].as_u64().unwrap_or(0) >= 2);
2767
2768 let queried = dispatch_tool(
2769 &state,
2770 "kg_query",
2771 json!({"palace": "auto", "subject": "auto"}),
2772 )
2773 .await
2774 .expect("kg_query");
2775 let triples = queried["triples"].as_array().expect("triples");
2776 let predicates: Vec<&str> = triples
2777 .iter()
2778 .filter_map(|t| t["predicate"].as_str())
2779 .collect();
2780 assert!(
2781 predicates.contains(&"created_at"),
2782 "expected created_at after palace_create; got {predicates:?}",
2783 );
2784 assert!(
2785 predicates.contains(&"bootstrapped_at"),
2786 "expected bootstrapped_at after palace_create; got {predicates:?}",
2787 );
2788 assert!(
2790 queried.get("hint").is_none(),
2791 "hint should be absent when triples exist"
2792 );
2793 }
2794
2795 #[tokio::test]
2800 async fn kg_query_emits_hint_when_palace_empty() {
2801 let (state, _tmp) = test_state();
2802 let _ = dispatch_tool(&state, "palace_create", json!({"name": "hinted"}))
2803 .await
2804 .expect("palace_create");
2805 let queried = dispatch_tool(
2807 &state,
2808 "kg_query",
2809 json!({"palace": "hinted", "subject": "unrelated-subject"}),
2810 )
2811 .await
2812 .expect("kg_query");
2813 assert_eq!(queried["triples"].as_array().unwrap().len(), 0);
2814 let hint = queried["hint"].as_str().expect("hint field present");
2815 assert!(hint.contains("kg_bootstrap"));
2816 assert!(hint.contains("kg_assert"));
2817 }
2818
2819 #[tokio::test]
2823 async fn kg_bootstrap_seeds_workspace_facts() {
2824 let (state, _tmp) = test_state();
2825 let _ = dispatch_tool(&state, "palace_create", json!({"name": "ws"}))
2826 .await
2827 .expect("palace_create");
2828
2829 let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2830 .parent()
2831 .and_then(|p| p.parent())
2832 .expect("workspace root")
2833 .to_path_buf();
2834
2835 let result = dispatch_tool(
2836 &state,
2837 "kg_bootstrap",
2838 json!({"palace": "ws", "project_path": workspace_root.to_string_lossy()}),
2839 )
2840 .await
2841 .expect("kg_bootstrap");
2842 assert!(result["triples_asserted"].as_u64().unwrap() > 0);
2843 let subject = result["project_subject"]
2844 .as_str()
2845 .expect("project_subject")
2846 .to_string();
2847
2848 let queried = dispatch_tool(
2850 &state,
2851 "kg_query",
2852 json!({"palace": "ws", "subject": subject}),
2853 )
2854 .await
2855 .expect("kg_query");
2856 let triples = queried["triples"].as_array().expect("triples");
2857 let predicates: Vec<&str> = triples
2858 .iter()
2859 .filter_map(|t| t["predicate"].as_str())
2860 .collect();
2861 assert!(
2865 predicates.contains(&"has_workspace_member") || predicates.contains(&"has_language"),
2866 "expected workspace/language fact; got {predicates:?}",
2867 );
2868 assert!(
2870 predicates.contains(&"source_repo"),
2871 "expected source_repo from .git/config; got {predicates:?}",
2872 );
2873 assert!(predicates.contains(&"bootstrapped_at"));
2875 }
2876
2877 #[test]
2886 fn content_gate_blocks_short_no_context() {
2887 assert_eq!(content_gate("yes", None), None);
2888 assert_eq!(content_gate("ok", None), None);
2889 assert_eq!(
2890 content_gate(" no thanks ", None),
2891 None,
2892 "2 words still < 4"
2893 );
2894 assert_eq!(
2895 content_gate("one two three", None),
2896 None,
2897 "3 words still < 4"
2898 );
2899 }
2900
2901 #[test]
2907 fn content_gate_wraps_short_with_context() {
2908 let combined = content_gate(
2909 "yes",
2910 Some("Do you want to enable auto-bootstrap on new palaces?"),
2911 )
2912 .expect("context should unlock the gate");
2913 assert_eq!(
2914 combined,
2915 "Do you want to enable auto-bootstrap on new palaces?\n\n---\n\nyes",
2916 );
2917 let combined = content_gate(
2920 "the quick brown fox jumps over the lazy dog",
2921 Some("Famous typing pangram"),
2922 )
2923 .expect("long content + context still combines");
2924 assert!(combined.starts_with("Famous typing pangram"));
2925 assert!(combined.contains("\n\n---\n\n"));
2926 assert!(combined.ends_with("the quick brown fox jumps over the lazy dog"));
2927 }
2928
2929 #[test]
2936 fn content_gate_keeps_long() {
2937 let body = "User prefers snake_case for python";
2938 let kept = content_gate(body, None).expect(">= 4 words passes");
2939 assert_eq!(kept, body, "passing content must round-trip verbatim");
2940 let boundary = "one two three four";
2942 assert_eq!(content_gate(boundary, None).as_deref(), Some(boundary));
2943 }
2944
2945 #[test]
2952 fn content_gate_blank_context_treated_as_none() {
2953 assert_eq!(content_gate("yes", Some("")), None);
2954 assert_eq!(content_gate("yes", Some(" ")), None);
2955 assert_eq!(content_gate("yes", Some("\n\t")), None);
2956 }
2957
2958 #[tokio::test]
2964 async fn dispatch_remember_skips_short_no_context() {
2965 let (state, _tmp) = test_state();
2966 let _ = dispatch_tool(&state, "palace_create", json!({"name": "gate"}))
2967 .await
2968 .expect("palace_create");
2969
2970 let res = dispatch_tool(
2971 &state,
2972 "memory_remember",
2973 json!({"palace": "gate", "text": "yes"}),
2974 )
2975 .await
2976 .expect("memory_remember (short)");
2977 assert_eq!(res["status"], "skipped");
2978 assert!(res["reason"]
2979 .as_str()
2980 .unwrap_or("")
2981 .contains("content gate"));
2982 let listed = dispatch_tool(
2984 &state,
2985 "memory_list",
2986 json!({"palace": "gate", "limit": 10}),
2987 )
2988 .await
2989 .expect("memory_list");
2990 let drawers = listed["drawers"].as_array().expect("drawers array");
2991 assert!(
2992 drawers.is_empty(),
2993 "no drawer should be written; got {drawers:?}"
2994 );
2995 }
2996
2997 #[tokio::test]
3005 async fn dispatch_remember_with_context_writes_combined() {
3006 let (state, _tmp) = test_state();
3007 let _ = dispatch_tool(&state, "palace_create", json!({"name": "ctxgate"}))
3008 .await
3009 .expect("palace_create");
3010
3011 let res = dispatch_tool(
3012 &state,
3013 "memory_remember",
3014 json!({
3015 "palace": "ctxgate",
3016 "text": "yes",
3017 "context": "Do you want to enable auto-bootstrap on new palaces?",
3018 "force": true,
3019 }),
3020 )
3021 .await
3022 .expect("memory_remember (with context)");
3023 assert_eq!(res["status"], "stored");
3024
3025 let listed = dispatch_tool(
3026 &state,
3027 "memory_list",
3028 json!({"palace": "ctxgate", "limit": 10}),
3029 )
3030 .await
3031 .expect("memory_list");
3032 let drawers = listed["drawers"].as_array().expect("drawers array");
3033 assert_eq!(drawers.len(), 1);
3034 let body = drawers[0]["content"].as_str().expect("content");
3035 assert!(body.starts_with("Do you want to enable auto-bootstrap"));
3036 assert!(body.contains("\n\n---\n\n"));
3037 assert!(body.ends_with("yes"));
3038 }
3039
3040 #[tokio::test]
3047 async fn dispatch_note_skips_short_no_context() {
3048 let (state, _tmp) = test_state();
3049 let _ = dispatch_tool(&state, "palace_create", json!({"name": "noteg"}))
3050 .await
3051 .expect("palace_create");
3052
3053 let res = dispatch_tool(
3054 &state,
3055 "memory_note",
3056 json!({"palace": "noteg", "content": "ok"}),
3057 )
3058 .await
3059 .expect("memory_note (short)");
3060 assert_eq!(res["status"], "skipped");
3061 let listed = dispatch_tool(
3062 &state,
3063 "memory_list",
3064 json!({"palace": "noteg", "limit": 10}),
3065 )
3066 .await
3067 .expect("memory_list");
3068 assert!(listed["drawers"].as_array().unwrap().is_empty());
3069 }
3070
3071 #[tokio::test]
3072 async fn dispatch_unknown_tool_errors() {
3073 let (state, _tmp) = test_state();
3074 let err = dispatch_tool(&state, "does_not_exist", json!({}))
3075 .await
3076 .expect_err("should error");
3077 assert!(err.to_string().contains("unknown tool"));
3078 }
3079
3080 #[test]
3091 fn blocklist_gate_blocks_tool_use() {
3092 assert!(blocklist_gate("Tool use: Bash"));
3093 assert!(blocklist_gate(
3094 "Tool use: Edit File: /Users/me/Projects/foo/bar.rs"
3095 ));
3096 assert!(blocklist_gate(" Tool use: Read"));
3098 }
3099
3100 #[test]
3105 fn blocklist_gate_blocks_session_ended() {
3106 assert!(blocklist_gate(
3107 "Claude Code session ended: 1d2c3b4a-0000-0000-0000-000000000000"
3108 ));
3109 assert!(blocklist_gate("Claude Code session started"));
3110 }
3111
3112 #[test]
3118 fn blocklist_gate_passes_normal_content() {
3119 assert!(!blocklist_gate("User prefers snake_case for python"));
3120 assert!(!blocklist_gate(
3121 "Quokkas are the happiest marsupials in Australia"
3122 ));
3123 assert!(!blocklist_gate("Note: refactor the dispatcher next sprint"));
3124 assert!(blocklist_gate("I used Tool use: Bash here"));
3129 }
3130
3131 #[tokio::test]
3141 async fn dedup_skips_near_duplicate() {
3142 let (state, _tmp) = test_state();
3143 let _ = dispatch_tool(&state, "palace_create", json!({"name": "dedup1"}))
3144 .await
3145 .expect("palace_create");
3146
3147 let _ = dispatch_tool(
3150 &state,
3151 "memory_remember",
3152 json!({
3153 "palace": "dedup1",
3154 "text": "The quick brown fox jumped over the lazy dog repeatedly today",
3155 }),
3156 )
3157 .await
3158 .expect("memory_remember seed");
3159
3160 let handle = open_palace_handle(&state, "dedup1").expect("open handle");
3161 assert!(
3165 dedup_gate(
3166 &handle,
3167 "The quick brown fox jumped over the lazy dog repeatedly yesterday"
3168 ),
3169 "near-duplicate should be detected"
3170 );
3171 assert!(
3173 dedup_gate(
3174 &handle,
3175 "The quick brown fox jumped over the lazy dog repeatedly today"
3176 ),
3177 "exact match should be detected"
3178 );
3179 }
3180
3181 #[tokio::test]
3187 async fn dedup_allows_different_content() {
3188 let (state, _tmp) = test_state();
3189 let _ = dispatch_tool(&state, "palace_create", json!({"name": "dedup2"}))
3190 .await
3191 .expect("palace_create");
3192
3193 let _ = dispatch_tool(
3194 &state,
3195 "memory_remember",
3196 json!({
3197 "palace": "dedup2",
3198 "text": "Quokkas are the happiest marsupials in Australia by general consensus",
3199 }),
3200 )
3201 .await
3202 .expect("memory_remember seed");
3203
3204 let handle = open_palace_handle(&state, "dedup2").expect("open handle");
3205 assert!(
3207 !dedup_gate(
3208 &handle,
3209 "Rust is a systems programming language focused on safety and concurrency"
3210 ),
3211 "unrelated content should pass the dedup gate"
3212 );
3213 assert!(!dedup_gate(&handle, " "));
3216 }
3217
3218 #[tokio::test]
3233 async fn dedup_gate_blocks_concurrent_duplicate_writes() {
3234 let (state, _tmp) = test_state();
3235 let state = std::sync::Arc::new(state);
3236 let _ = dispatch_tool(&state, "palace_create", json!({"name": "dedup_race"}))
3237 .await
3238 .expect("palace_create");
3239
3240 let text =
3244 "Concurrent identical writes must collapse to a single drawer under the dedup gate";
3245
3246 let s1 = state.clone();
3247 let t1 = tokio::spawn(async move {
3248 dispatch_tool(
3249 &s1,
3250 "memory_remember",
3251 json!({"palace": "dedup_race", "text": text}),
3252 )
3253 .await
3254 });
3255 let s2 = state.clone();
3256 let t2 = tokio::spawn(async move {
3257 dispatch_tool(
3258 &s2,
3259 "memory_remember",
3260 json!({"palace": "dedup_race", "text": text}),
3261 )
3262 .await
3263 });
3264 let r1 = t1.await.expect("join t1").expect("dispatch t1");
3265 let r2 = t2.await.expect("join t2").expect("dispatch t2");
3266
3267 let statuses = [
3270 r1["status"].as_str().unwrap_or(""),
3271 r2["status"].as_str().unwrap_or(""),
3272 ];
3273 let stored = statuses.iter().filter(|s| **s == "stored").count();
3274 let skipped = statuses.iter().filter(|s| **s == "skipped").count();
3275 assert_eq!(
3276 stored, 1,
3277 "exactly one concurrent write should be stored; got responses {r1:?} {r2:?}"
3278 );
3279 assert_eq!(
3280 skipped, 1,
3281 "exactly one concurrent write should be skipped; got responses {r1:?} {r2:?}"
3282 );
3283 let skipped_reason = if r1["status"] == "skipped" {
3284 r1["reason"].as_str().unwrap_or("")
3285 } else {
3286 r2["reason"].as_str().unwrap_or("")
3287 };
3288 assert!(
3289 skipped_reason.contains("duplicate within window"),
3290 "skipped envelope should cite dedup reason; got {skipped_reason:?}"
3291 );
3292
3293 let listed = dispatch_tool(
3295 &state,
3296 "memory_list",
3297 json!({"palace": "dedup_race", "limit": 10}),
3298 )
3299 .await
3300 .expect("memory_list");
3301 let drawers = listed["drawers"].as_array().expect("drawers array");
3302 assert_eq!(
3303 drawers.len(),
3304 1,
3305 "only one drawer should be persisted after concurrent identical writes; got {drawers:?}"
3306 );
3307 }
3308
3309 #[tokio::test]
3317 async fn dispatch_remember_blocks_blocklist_pattern() {
3318 let (state, _tmp) = test_state();
3319 let _ = dispatch_tool(&state, "palace_create", json!({"name": "blk"}))
3320 .await
3321 .expect("palace_create");
3322
3323 let res = dispatch_tool(
3324 &state,
3325 "memory_remember",
3326 json!({"palace": "blk", "text": "Tool use: Bash"}),
3327 )
3328 .await
3329 .expect("memory_remember (blocked)");
3330 assert_eq!(res["status"], "skipped");
3331 assert!(
3332 res["reason"]
3333 .as_str()
3334 .unwrap_or("")
3335 .contains("blocked pattern"),
3336 "reason should mention blocked pattern; got {res:?}"
3337 );
3338
3339 let listed = dispatch_tool(&state, "memory_list", json!({"palace": "blk", "limit": 10}))
3340 .await
3341 .expect("memory_list");
3342 let drawers = listed["drawers"].as_array().expect("drawers array");
3343 assert!(drawers.is_empty(), "no drawer should be written");
3344 }
3345
3346 #[tokio::test]
3360 async fn bm25_index_queue_drops_when_full() {
3361 let (mut state, _tmp) = test_state();
3365 let (tx, _rx_held) =
3366 tokio::sync::mpsc::channel::<Bm25IndexRequest>(BM25_INDEX_QUEUE_CAPACITY);
3367 state.bm25_index_tx = tx;
3368
3369 for i in 0..BM25_INDEX_QUEUE_CAPACITY {
3371 bm25_index_enqueue(
3372 &state,
3373 "default",
3374 Uuid::new_v4(),
3375 &format!("filler content {i}"),
3376 );
3377 }
3378 assert_eq!(
3380 state.bm25_index_tx.capacity(),
3381 0,
3382 "after filling, sender capacity must be 0"
3383 );
3384
3385 for i in 0..16 {
3388 bm25_index_enqueue(
3389 &state,
3390 "default",
3391 Uuid::new_v4(),
3392 &format!("overflow content {i}"),
3393 );
3394 }
3395
3396 let probe_req = Bm25IndexRequest {
3400 palace: "default".to_string(),
3401 drawer_id: Uuid::new_v4().to_string(),
3402 content: "probe".to_string(),
3403 data_dir: state.data_root.join("default").join("bm25"),
3404 };
3405 let probe = state.bm25_index_tx.try_send(probe_req);
3406 match probe {
3407 Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {}
3408 other => panic!("expected Full overflow, got {other:?}"),
3409 }
3410 }
3411}