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
1129 let skip_enforcement = std::env::var("TRUSTY_SKIP_PALACE_ENFORCEMENT").as_deref() == Ok("1");
1139 if !skip_enforcement {
1140 let cwd = std::env::current_dir().unwrap_or_else(|_| state.data_root.clone());
1141 crate::project_root::validate_palace_name(palace_name, &cwd)?;
1142 }
1143
1144 let description = args
1145 .get("description")
1146 .and_then(|v| v.as_str())
1147 .map(|s| s.to_string());
1148 let palace = Palace {
1149 id: PalaceId::new(palace_name),
1150 name: palace_name.to_string(),
1151 description,
1152 created_at: chrono::Utc::now(),
1153 data_dir: state.data_root.join(palace_name),
1154 };
1155 let _handle = state
1156 .registry
1157 .create_palace(&state.data_root, palace)
1158 .context("create_palace")?;
1159 state
1163 .palace_names
1164 .insert(palace_name.to_string(), palace_name.to_string());
1165 state.emit(DaemonEvent::PalaceCreated {
1168 id: palace_name.to_string(),
1169 name: palace_name.to_string(),
1170 source: ActivitySource::Mcp,
1171 });
1172 let bootstrap_summary = match crate::bootstrap::bootstrap_palace(state, palace_name, None).await
1180 {
1181 Ok(r) => Some(serde_json::json!({
1182 "triples_asserted": r.triples_asserted,
1183 "project_subject": r.project_subject,
1184 })),
1185 Err(e) => {
1186 tracing::warn!(
1187 palace = %palace_name,
1188 "auto-bootstrap on palace_create failed: {e:#}",
1189 );
1190 None
1191 }
1192 };
1193 Ok(json!({
1194 "palace_id": palace_name,
1195 "status": "created",
1196 "bootstrap": bootstrap_summary,
1197 }))
1198}
1199
1200async fn handle_palace_list(state: &AppState, _args: Value) -> Result<Value> {
1201 let root = state.data_root.clone();
1202 let palaces = tokio::task::spawn_blocking(move || {
1203 trusty_common::memory_core::PalaceRegistry::list_palaces(&root)
1204 })
1205 .await
1206 .context("join list_palaces")??;
1207 let ids: Vec<String> = palaces.iter().map(|p| p.id.as_str().to_string()).collect();
1208 Ok(json!({ "palaces": ids }))
1209}
1210
1211async fn handle_palace_delete(state: &AppState, args: Value) -> Result<Value> {
1212 let palace_id = args
1220 .get("palace_id")
1221 .and_then(|v| v.as_str())
1222 .ok_or_else(|| anyhow!("palace_delete: missing 'palace_id'"))?
1223 .to_string();
1224 let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
1225 use crate::service::{MemoryService, ServiceError};
1226 let svc = MemoryService::new(state.clone());
1227 match svc.delete_palace(&palace_id, force).await {
1228 Ok(()) => Ok(json!({ "deleted": palace_id })),
1229 Err(ServiceError::NotFound(_)) => Err(anyhow!("Palace not found: {palace_id}")),
1230 Err(ServiceError::Conflict(msg)) => Err(anyhow!(msg)),
1231 Err(e) => Err(anyhow!("palace_delete: {e}")),
1232 }
1233}
1234
1235async fn handle_palace_update(state: &AppState, args: Value) -> Result<Value> {
1236 let palace_id = args
1245 .get("palace_id")
1246 .and_then(|v| v.as_str())
1247 .ok_or_else(|| anyhow!("palace_update: missing 'palace_id'"))?
1248 .to_string();
1249 let name = args
1250 .get("name")
1251 .and_then(|v| v.as_str())
1252 .ok_or_else(|| anyhow!("palace_update: missing 'name'"))?
1253 .to_string();
1254 use crate::service::MemoryService;
1255 let svc = MemoryService::new(state.clone());
1256 match svc.update_palace_name(&palace_id, &name).await {
1257 Ok(_info) => Ok(json!({ "updated": palace_id, "name": name.trim() })),
1258 Err(e) => Err(anyhow!("palace_update: {e}")),
1259 }
1260}
1261
1262async fn handle_kg_assert(state: &AppState, args: Value) -> Result<Value> {
1263 let palace = resolve_palace(state, &args, "kg_assert")?;
1264 let palace = palace.as_str();
1265 let subject = args
1266 .get("subject")
1267 .and_then(|v| v.as_str())
1268 .ok_or_else(|| anyhow!("kg_assert: missing 'subject'"))?
1269 .to_string();
1270 let predicate = args
1271 .get("predicate")
1272 .and_then(|v| v.as_str())
1273 .ok_or_else(|| anyhow!("kg_assert: missing 'predicate'"))?
1274 .to_string();
1275 let object = args
1276 .get("object")
1277 .and_then(|v| v.as_str())
1278 .ok_or_else(|| anyhow!("kg_assert: missing 'object'"))?
1279 .to_string();
1280 let confidence = args
1281 .get("confidence")
1282 .and_then(|v| v.as_f64())
1283 .map(|c| (c as f32).clamp(0.0, 1.0))
1284 .unwrap_or(1.0);
1285 let provenance = args
1286 .get("provenance")
1287 .and_then(|v| v.as_str())
1288 .map(|s| s.to_string());
1289
1290 let handle = open_palace_handle(state, palace)?;
1291 let triple = Triple {
1292 subject,
1293 predicate,
1294 object,
1295 valid_from: chrono::Utc::now(),
1296 valid_to: None,
1297 confidence,
1298 provenance,
1299 };
1300 let is_hot = crate::prompt_facts::is_hot_predicate(&triple.predicate);
1301 handle.kg.assert(triple).await.context("kg.assert")?;
1302 if is_hot {
1307 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1308 tracing::warn!("rebuild_prompt_cache after kg_assert failed: {e:#}");
1309 }
1310 }
1311 Ok(json!({ "status": "asserted" }))
1312}
1313
1314async fn handle_add_alias(state: &AppState, args: Value) -> Result<Value> {
1315 let short = args
1316 .get("short")
1317 .and_then(|v| v.as_str())
1318 .ok_or_else(|| anyhow!("add_alias: missing 'short'"))?
1319 .to_string();
1320 let full = args
1321 .get("full")
1322 .and_then(|v| v.as_str())
1323 .ok_or_else(|| anyhow!("add_alias: missing 'full'"))?
1324 .to_string();
1325 let extra = args
1326 .get("extra")
1327 .and_then(|v| v.as_str())
1328 .map(|s| s.to_string());
1329
1330 let palace = resolve_palace(state, &args, "add_alias")?;
1335 let handle = open_palace_handle(state, &palace)?;
1336 let object = match extra.as_deref() {
1338 Some(e) if !e.is_empty() => format!("{full} ({e})"),
1339 _ => full.clone(),
1340 };
1341 let triple = Triple {
1342 subject: short.clone(),
1343 predicate: "is_alias_for".to_string(),
1344 object,
1345 valid_from: chrono::Utc::now(),
1346 valid_to: None,
1347 confidence: 1.0,
1348 provenance: Some("add_alias".to_string()),
1349 };
1350 handle
1351 .kg
1352 .assert(triple)
1353 .await
1354 .context("kg.assert (alias)")?;
1355 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1356 tracing::warn!("rebuild_prompt_cache after add_alias failed: {e:#}");
1357 }
1358 Ok(json!({ "asserted": true, "short": short, "full": full }))
1359}
1360
1361async fn handle_list_prompt_facts(state: &AppState, _args: Value) -> Result<Value> {
1362 let triples = crate::prompt_facts::gather_hot_triples(state).await?;
1363 let payload: Vec<Value> = triples
1364 .into_iter()
1365 .map(|(subject, predicate, object)| {
1366 json!({ "subject": subject, "predicate": predicate, "object": object })
1367 })
1368 .collect();
1369 Ok(json!({ "facts": payload }))
1370}
1371
1372async fn handle_remove_prompt_fact(state: &AppState, args: Value) -> Result<Value> {
1373 let subject = args
1374 .get("subject")
1375 .and_then(|v| v.as_str())
1376 .ok_or_else(|| anyhow!("remove_prompt_fact: missing 'subject'"))?
1377 .to_string();
1378 let predicate = args
1379 .get("predicate")
1380 .and_then(|v| v.as_str())
1381 .ok_or_else(|| anyhow!("remove_prompt_fact: missing 'predicate'"))?
1382 .to_string();
1383
1384 let mut closed_total: usize = 0;
1390 for palace_id in state.registry.list() {
1391 if let Some(handle) = state.registry.get(&palace_id) {
1392 match handle.kg.retract(&subject, &predicate).await {
1393 Ok(n) => closed_total += n,
1394 Err(e) => tracing::warn!(
1395 palace = %palace_id.as_str(),
1396 "retract failed: {e:#}",
1397 ),
1398 }
1399 }
1400 }
1401 if closed_total > 0 {
1402 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1403 tracing::warn!("rebuild_prompt_cache after remove_prompt_fact failed: {e:#}");
1404 }
1405 Ok(json!({ "removed": true, "closed": closed_total }))
1406 } else {
1407 Ok(json!({ "removed": false, "reason": "not found" }))
1408 }
1409}
1410
1411async fn handle_kg_query(state: &AppState, args: Value) -> Result<Value> {
1412 let palace = resolve_palace(state, &args, "kg_query")?;
1413 let subject = args
1414 .get("subject")
1415 .and_then(|v| v.as_str())
1416 .ok_or_else(|| anyhow!("kg_query: missing 'subject'"))?;
1417 let handle = open_palace_handle(state, &palace)?;
1418 let triples = handle
1419 .kg
1420 .query_active(subject)
1421 .await
1422 .context("kg.query_active")?;
1423 let payload: Vec<Value> = triples
1424 .iter()
1425 .map(|t| {
1426 json!({
1427 "subject": t.subject,
1428 "predicate": t.predicate,
1429 "object": t.object,
1430 "valid_from": t.valid_from.to_rfc3339(),
1431 "valid_to": t.valid_to.as_ref().map(|d| d.to_rfc3339()),
1432 "confidence": t.confidence,
1433 "provenance": t.provenance,
1434 })
1435 })
1436 .collect();
1437 let mut response = json!({ "subject": subject, "triples": payload });
1443 if crate::bootstrap::is_kg_empty_for_subject(&triples) {
1444 response["hint"] = Value::String(crate::bootstrap::KG_EMPTY_HINT.to_string());
1445 }
1446 Ok(response)
1447}
1448
1449async fn handle_memory_list(state: &AppState, args: Value) -> Result<Value> {
1450 let palace = resolve_palace(state, &args, "memory_list")?;
1451 let handle = open_palace_handle(state, &palace)?;
1452 let room = args
1453 .get("room")
1454 .and_then(|v| v.as_str())
1455 .map(|s| parse_room(Some(s)));
1456 let tag = args
1457 .get("tag")
1458 .and_then(|v| v.as_str())
1459 .map(|s| s.to_string());
1460 let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
1461 let drawers = handle.list_drawers(room, tag, limit);
1462 let payload: Vec<Value> = drawers
1463 .iter()
1464 .map(|d| {
1465 json!({
1466 "drawer_id": d.id.to_string(),
1467 "content": d.content,
1468 "importance": d.importance,
1469 "tags": d.tags,
1470 "created_at": d.created_at.to_rfc3339(),
1471 "drawer_type": d.drawer_type.as_str(),
1472 "expires_at": d.expires_at.map(|t| t.to_rfc3339()),
1473 })
1474 })
1475 .collect();
1476 Ok(json!({ "palace": palace, "drawers": payload }))
1477}
1478
1479async fn handle_memory_forget(state: &AppState, args: Value) -> Result<Value> {
1480 let palace = resolve_palace(state, &args, "memory_forget")?;
1481 let drawer_id_str = args
1482 .get("drawer_id")
1483 .and_then(|v| v.as_str())
1484 .ok_or_else(|| anyhow!("memory_forget: missing 'drawer_id'"))?;
1485 let drawer_id = Uuid::parse_str(drawer_id_str)
1486 .map_err(|e| anyhow!("memory_forget: invalid drawer_id UUID: {e}"))?;
1487 let handle = open_palace_handle(state, &palace)?;
1488 handle.forget(drawer_id).await.context("forget")?;
1489 let drawer_count = handle.drawers.read().len();
1491 state.emit(DaemonEvent::DrawerDeleted {
1492 palace_id: palace.clone(),
1493 drawer_count,
1494 source: ActivitySource::Mcp,
1495 });
1496 Ok(json!({ "status": "deleted", "drawer_id": drawer_id_str, "palace": palace }))
1499}
1500
1501async fn handle_palace_info(state: &AppState, args: Value) -> Result<Value> {
1502 let palace = resolve_palace(state, &args, "palace_info")?;
1503 let handle = open_palace_handle(state, &palace)?;
1504 let drawer_count = handle.list_drawers(None, None, usize::MAX).len();
1505 let data_dir = handle
1506 .data_dir
1507 .as_ref()
1508 .map(|p| p.to_string_lossy().to_string());
1509 Ok(json!({
1510 "id": handle.id.as_str(),
1511 "name": handle.id.as_str(),
1512 "drawer_count": drawer_count,
1513 "data_dir": data_dir,
1514 }))
1515}
1516
1517async fn handle_palace_compact(state: &AppState, args: Value) -> Result<Value> {
1518 let palace = resolve_palace(state, &args, "palace_compact")?;
1519 let handle = open_palace_handle(state, &palace)?;
1520 let valid_ids: std::collections::HashSet<Uuid> =
1524 handle.drawers.read().iter().map(|d| d.id).collect();
1525 let vector_store = handle.vector_store.clone();
1526 let res = tokio::task::spawn_blocking(move || vector_store.compact_orphans(&valid_ids))
1527 .await
1528 .context("join palace_compact")??;
1529 Ok(json!({
1530 "palace": palace,
1531 "total_checked": res.total_checked,
1532 "orphans_removed": res.orphans_removed,
1533 "index_size_before": res.index_size_before,
1534 "index_size_after": res.index_size_after,
1535 }))
1536}
1537
1538async fn handle_kg_gaps(state: &AppState, args: Value) -> Result<Value> {
1539 let palace = resolve_palace(state, &args, "kg_gaps")?;
1549 let _handle = open_palace_handle(state, &palace)?;
1552 let pid = PalaceId::new(&palace);
1553 let cached = state.registry.get_gaps(&pid).unwrap_or_default();
1554 let payload: Vec<Value> = cached
1555 .into_iter()
1556 .map(|g| {
1557 json!({
1558 "entities": g.entities,
1559 "internal_density": g.internal_density,
1560 "external_bridges": g.external_bridges,
1561 "suggested_exploration": g.suggested_exploration,
1562 })
1563 })
1564 .collect();
1565 Ok(json!({ "palace": palace, "gaps": payload }))
1566}
1567
1568async fn handle_memory_recall_all(state: &AppState, args: Value) -> Result<Value> {
1569 let query = args
1570 .get("q")
1571 .and_then(|v| v.as_str())
1572 .ok_or_else(|| anyhow!("memory_recall_all: missing 'q'"))?;
1573 let top_k = args.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
1574 let deep = args.get("deep").and_then(|v| v.as_bool()).unwrap_or(false);
1575
1576 let root = state.data_root.clone();
1580 let palaces = tokio::task::spawn_blocking(move || {
1581 trusty_common::memory_core::PalaceRegistry::list_palaces(&root)
1582 })
1583 .await
1584 .context("join list_palaces")??;
1585
1586 let mut handles = Vec::with_capacity(palaces.len());
1587 for p in &palaces {
1588 match state.registry.open_palace(&state.data_root, &p.id) {
1589 Ok(h) => handles.push(h),
1590 Err(e) => {
1591 tracing::warn!(palace = %p.id, "memory_recall_all: open failed: {e:#}")
1592 }
1593 }
1594 }
1595
1596 let embedder = state.embedder().await?;
1597 let erased: std::sync::Arc<dyn trusty_common::memory_core::embed::Embedder + Send + Sync> =
1598 embedder;
1599 let results = recall_across_palaces(&handles, &erased, query, top_k, deep)
1600 .await
1601 .context("recall_across_palaces")?;
1602
1603 let payload: Vec<Value> = results
1604 .iter()
1605 .map(|r| {
1606 json!({
1607 "palace_id": r.palace_id,
1608 "drawer_id": r.result.drawer.id.to_string(),
1609 "content": r.result.drawer.content,
1610 "importance": r.result.drawer.importance,
1611 "tags": r.result.drawer.tags,
1612 "score": r.result.score,
1613 "layer": r.result.layer,
1614 "drawer_type": r.result.drawer.drawer_type.as_str(),
1615 })
1616 })
1617 .collect();
1618 Ok(json!({ "query": query, "results": payload }))
1619}
1620
1621async fn handle_get_prompt_context(state: &AppState, args: Value) -> Result<Value> {
1622 let query = args
1633 .get("query")
1634 .and_then(|v| v.as_str())
1635 .map(|s| s.trim().to_string())
1636 .filter(|s| !s.is_empty());
1637
1638 let cache_snapshot = {
1642 let guard = state.prompt_context_cache.read().await;
1643 guard.clone()
1644 };
1645
1646 let body = if let Some(q) = query.as_deref() {
1647 let needle = q.to_lowercase();
1648 let filtered: Vec<(String, String, String)> = cache_snapshot
1649 .triples
1650 .into_iter()
1651 .filter(|(subject, _predicate, object)| {
1652 subject.to_lowercase().contains(&needle) || object.to_lowercase().contains(&needle)
1653 })
1654 .collect();
1655 let formatted = crate::prompt_facts::build_prompt_context(&filtered);
1656 if formatted.is_empty() {
1657 "No project context found matching your query.".to_string()
1658 } else {
1659 formatted
1660 }
1661 } else if cache_snapshot.formatted.is_empty() {
1662 "No prompt facts stored yet.".to_string()
1663 } else {
1664 cache_snapshot.formatted
1665 };
1666
1667 Ok(Value::String(body))
1673}
1674
1675async fn handle_discover_aliases(state: &AppState, args: Value) -> Result<Value> {
1676 let palace = resolve_palace(state, &args, "discover_aliases")?;
1687 let project_root = args
1688 .get("project_root")
1689 .and_then(|v| v.as_str())
1690 .map(std::path::PathBuf::from)
1691 .or_else(|| std::env::current_dir().ok())
1692 .ok_or_else(|| anyhow!("discover_aliases: no project_root and cwd unavailable"))?;
1693
1694 let discoveries = crate::discovery::discover_project_aliases(&project_root).await?;
1695
1696 let handle = open_palace_handle(state, &palace)?;
1697
1698 let mut already_known = 0usize;
1699 let mut newly_asserted = 0usize;
1700 let mut reported: Vec<Value> = Vec::with_capacity(discoveries.len());
1701
1702 for d in &discoveries {
1703 let active = handle
1706 .kg
1707 .query_active(&d.short)
1708 .await
1709 .context("kg.query_active")?;
1710 let exists = active
1711 .iter()
1712 .any(|t| t.predicate == "is_alias_for" && t.object == d.full);
1713 if exists {
1714 already_known += 1;
1715 continue;
1716 }
1717
1718 let triple = Triple {
1719 subject: d.short.clone(),
1720 predicate: "is_alias_for".to_string(),
1721 object: d.full.clone(),
1722 valid_from: chrono::Utc::now(),
1723 valid_to: None,
1724 confidence: 1.0,
1725 provenance: Some(format!("discover_aliases:{}", d.source.as_str())),
1726 };
1727 handle
1728 .kg
1729 .assert(triple)
1730 .await
1731 .context("kg.assert (discover)")?;
1732 newly_asserted += 1;
1733 reported.push(json!({
1734 "short": d.short,
1735 "full": d.full,
1736 "source": d.source.as_str(),
1737 }));
1738 }
1739
1740 if newly_asserted > 0 {
1741 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1742 tracing::warn!("rebuild_prompt_cache after discover_aliases failed: {e:#}");
1743 }
1744 }
1745
1746 Ok(json!({
1747 "discovered": reported,
1748 "already_known": already_known,
1749 "new": newly_asserted,
1750 "palace": palace,
1751 }))
1752}
1753
1754async fn handle_kg_bootstrap(state: &AppState, args: Value) -> Result<Value> {
1755 let palace = resolve_palace(state, &args, "kg_bootstrap")?;
1760 let project_path = args
1761 .get("project_path")
1762 .and_then(|v| v.as_str())
1763 .map(std::path::PathBuf::from);
1764 let result = crate::bootstrap::bootstrap_palace(state, &palace, project_path.as_deref())
1765 .await
1766 .context("bootstrap_palace")?;
1767 if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
1771 tracing::warn!("rebuild_prompt_cache after kg_bootstrap failed: {e:#}");
1772 }
1773 crate::bootstrap::result_to_json(&result)
1774}
1775
1776async fn handle_memory_send_message(state: &AppState, args: Value) -> Result<Value> {
1777 let to_palace = args
1779 .get("to_palace")
1780 .and_then(|v| v.as_str())
1781 .ok_or_else(|| anyhow!("memory_send_message: missing 'to_palace'"))?
1782 .to_string();
1783 let purpose = args
1784 .get("purpose")
1785 .and_then(|v| v.as_str())
1786 .ok_or_else(|| anyhow!("memory_send_message: missing 'purpose'"))?
1787 .to_string();
1788 let content = args
1789 .get("content")
1790 .and_then(|v| v.as_str())
1791 .ok_or_else(|| anyhow!("memory_send_message: missing 'content'"))?
1792 .to_string();
1793 let from_palace = if let Some(s) = args.get("from_palace").and_then(|v| v.as_str()) {
1796 s.to_string()
1797 } else if let Some(d) = state.default_palace.clone() {
1798 d
1799 } else {
1800 crate::messaging::cwd_palace_slug()
1801 .context("memory_send_message: derive from_palace from cwd")?
1802 };
1803 let drawer_id = crate::messaging::send_message_to_palace(
1804 &state.registry,
1805 &state.data_root,
1806 &from_palace,
1807 &to_palace,
1808 &purpose,
1809 content,
1810 CreatorInfo::new_self(MCP_CLIENT_NAME, CreatorSource::Mcp),
1811 )
1812 .await
1813 .context("memory_send_message")?;
1814 Ok(json!({
1815 "drawer_id": drawer_id.to_string(),
1816 "from_palace": from_palace,
1817 "to_palace": to_palace,
1818 "purpose": purpose,
1819 "status": "sent",
1820 }))
1821}
1822
1823pub async fn dispatch_tool(state: &AppState, name: &str, args: Value) -> Result<Value> {
1835 match name {
1836 "memory_remember" => handle_memory_remember(state, args).await,
1837 "memory_note" => handle_memory_note(state, args).await,
1838 "memory_recall" => handle_memory_recall(state, args).await,
1839 "memory_recall_deep" => handle_memory_recall_deep(state, args).await,
1840 "palace_create" => handle_palace_create(state, args).await,
1841 "palace_list" => handle_palace_list(state, args).await,
1842 "palace_delete" => handle_palace_delete(state, args).await,
1843 "palace_update" => handle_palace_update(state, args).await,
1844 "kg_assert" => handle_kg_assert(state, args).await,
1845 "add_alias" => handle_add_alias(state, args).await,
1846 "list_prompt_facts" => handle_list_prompt_facts(state, args).await,
1847 "remove_prompt_fact" => handle_remove_prompt_fact(state, args).await,
1848 "kg_query" => handle_kg_query(state, args).await,
1849 "memory_list" => handle_memory_list(state, args).await,
1850 "memory_forget" => handle_memory_forget(state, args).await,
1851 "palace_info" => handle_palace_info(state, args).await,
1852 "palace_compact" => handle_palace_compact(state, args).await,
1853 "kg_gaps" => handle_kg_gaps(state, args).await,
1854 "memory_recall_all" => handle_memory_recall_all(state, args).await,
1855 "get_prompt_context" => handle_get_prompt_context(state, args).await,
1856 "discover_aliases" => handle_discover_aliases(state, args).await,
1857 "kg_bootstrap" => handle_kg_bootstrap(state, args).await,
1858 "memory_send_message" => handle_memory_send_message(state, args).await,
1859 other => anyhow::bail!("unknown tool: {other}"),
1860 }
1861}
1862
1863fn bm25_data_dir_for_palace(state: &AppState, palace: &str) -> std::path::PathBuf {
1876 state.data_root.join(palace).join("bm25")
1877}
1878
1879async fn ensure_bm25_running_for_palace(state: &AppState, palace: &str) -> bool {
1896 let Some(supervisor) = state.bm25_supervisor.as_ref() else {
1897 return true;
1900 };
1901 let data_dir = bm25_data_dir_for_palace(state, palace);
1902 match supervisor.ensure_running(palace, &data_dir).await {
1903 Ok(_socket) => true,
1904 Err(e) => {
1905 tracing::warn!(
1906 palace = %palace,
1907 "bm25 supervisor could not start daemon (degrading to vector-only): {e:#}"
1908 );
1909 false
1910 }
1911 }
1912}
1913
1914pub const BM25_INDEX_QUEUE_CAPACITY: usize = 256;
1930
1931#[derive(Debug)]
1944pub struct Bm25IndexRequest {
1945 pub palace: String,
1947 pub drawer_id: String,
1949 pub content: String,
1951 pub data_dir: std::path::PathBuf,
1955}
1956
1957pub fn spawn_bm25_index_worker(
1978 mut rx: tokio::sync::mpsc::Receiver<Bm25IndexRequest>,
1979 client: Option<std::sync::Arc<trusty_common::bm25_client::Bm25Client>>,
1980 supervisor: Option<std::sync::Arc<crate::bm25_supervisor::Bm25Supervisor>>,
1981) {
1982 tokio::spawn(async move {
1983 while let Some(req) = rx.recv().await {
1984 let Some(client) = client.as_ref() else {
1987 continue;
1988 };
1989 if let Some(sup) = supervisor.as_ref() {
1993 if let Err(e) = sup.ensure_running(&req.palace, &req.data_dir).await {
1994 tracing::warn!(
1995 palace = %req.palace,
1996 "bm25 supervisor failed to start daemon for index (non-fatal): {e:#}"
1997 );
1998 continue;
1999 }
2000 }
2001 if let Err(e) = client.index(&req.drawer_id, &req.content).await {
2002 tracing::warn!(
2003 palace = %req.palace,
2004 drawer_id = %req.drawer_id,
2005 "bm25 daemon index failed (non-fatal): {e:#}"
2006 );
2007 }
2008 }
2009 tracing::debug!("bm25 index worker exiting (channel closed)");
2010 });
2011}
2012
2013fn bm25_index_enqueue(state: &AppState, palace: &str, drawer_id: Uuid, content: &str) {
2030 let req = Bm25IndexRequest {
2031 palace: palace.to_string(),
2032 drawer_id: drawer_id.to_string(),
2033 content: content.to_string(),
2034 data_dir: bm25_data_dir_for_palace(state, palace),
2035 };
2036 match state.bm25_index_tx.try_send(req) {
2037 Ok(()) => {}
2038 Err(tokio::sync::mpsc::error::TrySendError::Full(req)) => {
2039 tracing::warn!(
2040 palace = %req.palace,
2041 drawer_id = %req.drawer_id,
2042 "BM25 index queue full — skipping drawer {}",
2043 req.drawer_id
2044 );
2045 }
2046 Err(tokio::sync::mpsc::error::TrySendError::Closed(req)) => {
2047 tracing::debug!(
2048 palace = %req.palace,
2049 drawer_id = %req.drawer_id,
2050 "BM25 index queue closed — skipping drawer {}",
2051 req.drawer_id
2052 );
2053 }
2054 }
2055}
2056
2057async fn bm25_search_optional(
2071 state: &AppState,
2072 palace: &str,
2073 query: &str,
2074 top_k: usize,
2075) -> Option<Vec<trusty_common::bm25_client::BM25Hit>> {
2076 let client = state.bm25_client.as_ref()?;
2077 if !ensure_bm25_running_for_palace(state, palace).await {
2081 return None;
2082 }
2083 match client.search(query, top_k).await {
2084 Ok(hits) => Some(hits),
2085 Err(e) => {
2086 tracing::warn!(
2087 palace = %palace,
2088 "bm25 daemon search failed (falling back to vector-only): {e:#}"
2089 );
2090 None
2091 }
2092 }
2093}
2094
2095fn fuse_bm25_into_recall(
2110 results: &mut Vec<trusty_common::memory_core::retrieval::RecallResult>,
2111 bm25_hits: &[trusty_common::bm25_client::BM25Hit],
2112 top_k: usize,
2113) {
2114 const RRF_K: f32 = 60.0;
2117 if bm25_hits.is_empty() {
2118 return;
2119 }
2120 for (rank, hit) in bm25_hits.iter().enumerate() {
2122 let bonus = 1.0 / (RRF_K + rank as f32 + 1.0);
2123 if let Some(existing) = results
2124 .iter_mut()
2125 .find(|r| r.drawer.id.to_string() == hit.doc_id)
2126 {
2127 existing.score += bonus;
2128 }
2129 }
2137 results.sort_by(|a, b| {
2140 b.score
2141 .partial_cmp(&a.score)
2142 .unwrap_or(std::cmp::Ordering::Equal)
2143 .then(a.layer.cmp(&b.layer))
2144 });
2145 results.truncate(top_k);
2146}
2147
2148fn serialize_recall(
2150 palace: &str,
2151 query: &str,
2152 results: Vec<trusty_common::memory_core::retrieval::RecallResult>,
2153) -> Value {
2154 let payload: Vec<Value> = results
2155 .iter()
2156 .map(|r| {
2157 json!({
2158 "drawer_id": r.drawer.id.to_string(),
2159 "content": r.drawer.content,
2160 "score": r.score,
2161 "layer": r.layer,
2162 "tags": r.drawer.tags,
2163 "importance": r.drawer.importance,
2164 "drawer_type": r.drawer.drawer_type.as_str(),
2165 })
2166 })
2167 .collect();
2168 json!({
2169 "palace": palace,
2170 "query": query,
2171 "results": payload,
2172 })
2173}
2174
2175#[cfg(test)]
2176mod tests {
2177 use super::*;
2178 use crate::AppState;
2179
2180 fn test_state() -> (AppState, tempfile::TempDir) {
2195 unsafe {
2202 std::env::set_var("TRUSTY_SKIP_PALACE_ENFORCEMENT", "1");
2203 }
2204 let tmp = tempfile::tempdir().expect("tempdir");
2205 let root = tmp.path().to_path_buf();
2206 (AppState::new(root), tmp)
2207 }
2208
2209 #[test]
2214 fn tool_definitions_drops_palace_required_when_default_set() {
2215 let with_default = tool_definitions_with(true);
2216 let without_default = tool_definitions_with(false);
2217 for (name, palace_required_when_no_default) in [
2218 ("memory_remember", true),
2219 ("memory_recall", true),
2220 ("memory_recall_deep", true),
2221 ("memory_list", true),
2222 ("memory_forget", true),
2223 ("palace_info", true),
2224 ("palace_compact", true),
2225 ("kg_assert", true),
2226 ("kg_query", true),
2227 ] {
2228 for (defs, has_default) in [(&with_default, true), (&without_default, false)] {
2229 let tools = defs["tools"].as_array().unwrap();
2230 let tool = tools.iter().find(|t| t["name"] == name).unwrap();
2231 let required: Vec<&str> = tool["inputSchema"]["required"]
2232 .as_array()
2233 .unwrap()
2234 .iter()
2235 .filter_map(|v| v.as_str())
2236 .collect();
2237 let palace_required = required.contains(&"palace");
2238 let expected = palace_required_when_no_default && !has_default;
2239 assert_eq!(
2240 palace_required, expected,
2241 "tool={name} has_default={has_default} required={required:?}"
2242 );
2243 }
2244 }
2245 }
2246
2247 #[test]
2248 fn tool_definitions_lists_all_tools() {
2249 let defs = tool_definitions();
2250 let tools = defs
2251 .get("tools")
2252 .and_then(|t| t.as_array())
2253 .expect("tools array");
2254 assert_eq!(tools.len(), 23);
2255 let names: Vec<&str> = tools
2256 .iter()
2257 .filter_map(|t| t.get("name").and_then(|n| n.as_str()))
2258 .collect();
2259 for expected in [
2260 "memory_remember",
2261 "memory_note",
2262 "memory_recall",
2263 "memory_recall_deep",
2264 "memory_list",
2265 "memory_forget",
2266 "palace_create",
2267 "palace_delete",
2268 "palace_update",
2269 "palace_list",
2270 "palace_info",
2271 "palace_compact",
2272 "kg_assert",
2273 "kg_query",
2274 "memory_recall_all",
2275 "kg_gaps",
2276 "add_alias",
2277 "list_prompt_facts",
2278 "remove_prompt_fact",
2279 "get_prompt_context",
2280 "discover_aliases",
2281 "kg_bootstrap",
2282 "memory_send_message",
2283 ] {
2284 assert!(names.contains(&expected), "missing tool: {expected}");
2285 }
2286 }
2287
2288 #[tokio::test]
2291 async fn dispatch_palace_create_persists() {
2292 let (state, _tmp) = test_state();
2293 let created = dispatch_tool(&state, "palace_create", json!({"name": "alpha"}))
2294 .await
2295 .expect("palace_create");
2296 assert_eq!(created["palace_id"], "alpha");
2297
2298 let listed = dispatch_tool(&state, "palace_list", json!({}))
2299 .await
2300 .expect("palace_list");
2301 let ids = listed["palaces"].as_array().expect("palaces array");
2302 assert!(ids.iter().any(|v| v.as_str() == Some("alpha")));
2303 }
2304
2305 #[tokio::test]
2308 async fn dispatch_remember_then_recall() {
2309 let (state, _tmp) = test_state();
2310 let _ = dispatch_tool(&state, "palace_create", json!({"name": "beta"}))
2311 .await
2312 .expect("palace_create");
2313
2314 let remembered = dispatch_tool(
2315 &state,
2316 "memory_remember",
2317 json!({
2318 "palace": "beta",
2319 "text": "Quokkas are the happiest marsupials in Australia by general consensus",
2320 "room": "General",
2321 "tags": ["wildlife"],
2322 }),
2323 )
2324 .await
2325 .expect("memory_remember");
2326 assert!(remembered["drawer_id"].as_str().is_some());
2327
2328 let recalled = dispatch_tool(
2329 &state,
2330 "memory_recall",
2331 json!({"palace": "beta", "query": "Quokkas marsupials Australia", "top_k": 5}),
2332 )
2333 .await
2334 .expect("memory_recall");
2335 let results = recalled["results"].as_array().expect("results");
2336 assert!(
2337 results
2338 .iter()
2339 .any(|r| r["content"].as_str().unwrap_or("").contains("Quokkas")),
2340 "expected to recall the Quokkas drawer; got {results:?}"
2341 );
2342 }
2343
2344 #[tokio::test]
2353 async fn auto_kg_extraction_hooks_into_memory_remember() {
2354 let (state, _tmp) = test_state();
2355 let _ = dispatch_tool(&state, "palace_create", json!({"name": "kgauto"}))
2356 .await
2357 .expect("palace_create");
2358
2359 let _ = dispatch_tool(
2360 &state,
2361 "memory_remember",
2362 json!({
2363 "palace": "kgauto",
2364 "text": "Rustc is a compiler for the Rust language; tracks #performance",
2365 "room": "Backend",
2366 "tags": ["compiler", "language"],
2367 }),
2368 )
2369 .await
2370 .expect("memory_remember");
2371
2372 let handle = open_palace_handle(&state, "kgauto").expect("open palace");
2373 let triples = handle.kg.list_active(1000, 0).await.expect("list_active");
2374 let auto: Vec<_> = triples
2375 .iter()
2376 .filter(|t| t.provenance.as_deref() == Some(crate::kg_extract::AUTO_PROVENANCE))
2377 .collect();
2378 assert!(
2379 !auto.is_empty(),
2380 "expected at least one auto-extracted triple after memory_remember; got: {triples:?}"
2381 );
2382 assert!(
2386 auto.iter()
2387 .any(|t| t.subject == "tag:compiler" && t.predicate == "tags"),
2388 "expected tag:compiler edge in auto subset: {auto:?}"
2389 );
2390 assert!(
2391 auto.iter()
2392 .any(|t| t.subject == "tag:language" && t.predicate == "tags"),
2393 "expected tag:language edge in auto subset: {auto:?}"
2394 );
2395 assert!(
2396 auto.iter()
2397 .any(|t| t.subject == "room:Backend" && t.predicate == "contains"),
2398 "expected room:Backend edge in auto subset: {auto:?}"
2399 );
2400 assert!(
2401 auto.iter().any(|t| t.predicate == "mentioned-in"),
2402 "expected at least one #hashtag mention triple in auto subset: {auto:?}"
2403 );
2404 }
2405
2406 #[tokio::test]
2418 async fn auto_kg_extraction_no_op_does_not_fail_remember() {
2419 let (state, _tmp) = test_state();
2420 let _ = dispatch_tool(&state, "palace_create", json!({"name": "kgnoop"}))
2421 .await
2422 .expect("palace_create");
2423
2424 let res = dispatch_tool(
2425 &state,
2426 "memory_remember",
2427 json!({
2428 "palace": "kgnoop",
2429 "text": "The quick brown fox jumped over the lazy dog repeatedly",
2432 }),
2433 )
2434 .await
2435 .expect("memory_remember should succeed even when extraction yields nothing");
2436 assert!(res["drawer_id"].as_str().is_some());
2437 }
2438
2439 #[tokio::test]
2442 async fn dispatch_kg_assert_then_query() {
2443 let (state, _tmp) = test_state();
2444 let _ = dispatch_tool(&state, "palace_create", json!({"name": "gamma"}))
2445 .await
2446 .expect("palace_create");
2447
2448 let _ = dispatch_tool(
2449 &state,
2450 "kg_assert",
2451 json!({
2452 "palace": "gamma",
2453 "subject": "alice",
2454 "predicate": "works_at",
2455 "object": "Acme",
2456 "confidence": 0.9,
2457 "provenance": "test",
2458 }),
2459 )
2460 .await
2461 .expect("kg_assert");
2462
2463 let queried = dispatch_tool(
2464 &state,
2465 "kg_query",
2466 json!({"palace": "gamma", "subject": "alice"}),
2467 )
2468 .await
2469 .expect("kg_query");
2470 let triples = queried["triples"].as_array().expect("triples array");
2471 assert_eq!(triples.len(), 1);
2472 assert_eq!(triples[0]["object"], "Acme");
2473 assert_eq!(triples[0]["predicate"], "works_at");
2474 }
2475
2476 #[tokio::test]
2484 async fn dispatch_kg_gaps_returns_cached() {
2485 use trusty_common::memory_core::community::KnowledgeGap;
2486
2487 let (state, _tmp) = test_state();
2488 let _ = dispatch_tool(&state, "palace_create", json!({"name": "delta"}))
2489 .await
2490 .expect("palace_create");
2491
2492 let initial = dispatch_tool(&state, "kg_gaps", json!({"palace": "delta"}))
2494 .await
2495 .expect("kg_gaps empty");
2496 let gaps = initial["gaps"].as_array().expect("gaps array");
2497 assert_eq!(gaps.len(), 0);
2498
2499 state.registry.set_gaps(
2501 PalaceId::new("delta"),
2502 vec![KnowledgeGap {
2503 entities: vec!["x".to_string(), "y".to_string()],
2504 internal_density: 0.05,
2505 external_bridges: 0,
2506 suggested_exploration: "Explore connections between x and y".to_string(),
2507 }],
2508 );
2509 let seeded = dispatch_tool(&state, "kg_gaps", json!({"palace": "delta"}))
2510 .await
2511 .expect("kg_gaps seeded");
2512 let gaps = seeded["gaps"].as_array().expect("gaps array");
2513 assert_eq!(gaps.len(), 1);
2514 assert_eq!(gaps[0]["entities"][0], "x");
2515 assert_eq!(gaps[0]["external_bridges"], 0);
2516 assert!(gaps[0]["suggested_exploration"]
2517 .as_str()
2518 .unwrap()
2519 .contains("x"));
2520 }
2521
2522 #[tokio::test]
2527 async fn add_alias_round_trip_through_prompt_cache() {
2528 let _tmp = tempfile::tempdir().expect("tempdir");
2531 let root = _tmp.path().to_path_buf();
2532 let state = AppState::new(root).with_default_palace(Some("ctx".to_string()));
2533
2534 let _ = dispatch_tool(&state, "palace_create", json!({"name": "ctx"}))
2536 .await
2537 .expect("palace_create");
2538
2539 let added = dispatch_tool(
2541 &state,
2542 "add_alias",
2543 json!({"short": "tga", "full": "trusty-git-analytics"}),
2544 )
2545 .await
2546 .expect("add_alias");
2547 assert_eq!(added["asserted"], true);
2548 assert_eq!(added["short"], "tga");
2549
2550 let listed = dispatch_tool(&state, "list_prompt_facts", json!({}))
2552 .await
2553 .expect("list_prompt_facts");
2554 let facts = listed["facts"].as_array().expect("facts array");
2555 assert!(
2556 facts.iter().any(|f| f["subject"] == "tga"
2557 && f["predicate"] == "is_alias_for"
2558 && f["object"] == "trusty-git-analytics"),
2559 "expected tga alias in facts; got {facts:?}"
2560 );
2561
2562 {
2564 let guard = state.prompt_context_cache.read().await;
2565 assert!(
2566 guard.formatted.contains("tga → trusty-git-analytics"),
2567 "prompt cache should contain alias; got: {}",
2568 guard.formatted
2569 );
2570 }
2571
2572 let _ = dispatch_tool(
2574 &state,
2575 "add_alias",
2576 json!({"short": "tm", "full": "trusty-memory", "extra": "the MCP frontend"}),
2577 )
2578 .await
2579 .expect("add_alias with extra");
2580 {
2581 let guard = state.prompt_context_cache.read().await;
2582 assert!(
2583 guard
2584 .formatted
2585 .contains("tm → trusty-memory (the MCP frontend)"),
2586 "alias with extra not formatted; got: {}",
2587 guard.formatted
2588 );
2589 }
2590
2591 let removed = dispatch_tool(
2593 &state,
2594 "remove_prompt_fact",
2595 json!({"subject": "tga", "predicate": "is_alias_for"}),
2596 )
2597 .await
2598 .expect("remove_prompt_fact");
2599 assert_eq!(removed["removed"], true);
2600 {
2601 let guard = state.prompt_context_cache.read().await;
2602 assert!(
2603 !guard.formatted.contains("tga → trusty-git-analytics"),
2604 "retracted alias still in cache: {}",
2605 guard.formatted
2606 );
2607 assert!(
2608 guard.formatted.contains("tm → trusty-memory"),
2609 "non-retracted alias missing from cache: {}",
2610 guard.formatted
2611 );
2612 }
2613
2614 let missing = dispatch_tool(
2616 &state,
2617 "remove_prompt_fact",
2618 json!({"subject": "nope", "predicate": "is_alias_for"}),
2619 )
2620 .await
2621 .expect("remove_prompt_fact missing");
2622 assert_eq!(missing["removed"], false);
2623 }
2624
2625 #[tokio::test]
2630 async fn get_prompt_context_serves_cache_and_filters() {
2631 let (state, _tmp) = test_state();
2632
2633 let resp = dispatch_tool(&state, "get_prompt_context", json!({}))
2635 .await
2636 .expect("get_prompt_context empty");
2637 assert_eq!(resp.as_str().unwrap(), "No prompt facts stored yet.");
2638
2639 {
2641 let mut guard = state.prompt_context_cache.write().await;
2642 let triples = vec![
2643 (
2644 "tga".to_string(),
2645 "is_alias_for".to_string(),
2646 "trusty-git-analytics".to_string(),
2647 ),
2648 (
2649 "tm".to_string(),
2650 "is_alias_for".to_string(),
2651 "trusty-memory".to_string(),
2652 ),
2653 (
2654 "fact-1".to_string(),
2655 "is_fact".to_string(),
2656 "MSRV is 1.88".to_string(),
2657 ),
2658 ];
2659 let formatted = crate::prompt_facts::build_prompt_context(&triples);
2660 *guard = crate::prompt_facts::PromptFactsCache { triples, formatted };
2661 }
2662
2663 let resp = dispatch_tool(&state, "get_prompt_context", json!({}))
2665 .await
2666 .expect("get_prompt_context populated");
2667 let text = resp.as_str().expect("string body");
2668 assert!(text.contains("tga → trusty-git-analytics"));
2669 assert!(text.contains("tm → trusty-memory"));
2670 assert!(text.contains("MSRV is 1.88"));
2671
2672 let resp = dispatch_tool(&state, "get_prompt_context", json!({"query": "tga"}))
2674 .await
2675 .expect("get_prompt_context filtered");
2676 let text = resp.as_str().expect("string body");
2677 assert!(text.contains("tga → trusty-git-analytics"));
2678 assert!(!text.contains("tm → trusty-memory"));
2679 assert!(!text.contains("MSRV is 1.88"));
2680
2681 let resp = dispatch_tool(&state, "get_prompt_context", json!({"query": "MEMORY"}))
2683 .await
2684 .expect("get_prompt_context case-insensitive");
2685 let text = resp.as_str().expect("string body");
2686 assert!(text.contains("tm → trusty-memory"));
2687 assert!(!text.contains("tga → trusty-git-analytics"));
2688
2689 let resp = dispatch_tool(
2691 &state,
2692 "get_prompt_context",
2693 json!({"query": "zzz-nonexistent"}),
2694 )
2695 .await
2696 .expect("get_prompt_context no-match");
2697 assert_eq!(
2698 resp.as_str().unwrap(),
2699 "No project context found matching your query."
2700 );
2701
2702 let resp = dispatch_tool(&state, "get_prompt_context", json!({"query": " "}))
2704 .await
2705 .expect("get_prompt_context whitespace");
2706 let text = resp.as_str().expect("string body");
2707 assert!(text.contains("tga → trusty-git-analytics"));
2708 assert!(text.contains("tm → trusty-memory"));
2709 }
2710
2711 #[tokio::test]
2718 async fn dispatch_discover_aliases_inserts_new_and_dedupes() {
2719 let _tmp = tempfile::tempdir().expect("tempdir");
2722 let root = _tmp.path().to_path_buf();
2723 let state = AppState::new(root).with_default_palace(Some("disc".to_string()));
2724 let _ = dispatch_tool(&state, "palace_create", json!({"name": "disc"}))
2725 .await
2726 .expect("palace_create");
2727
2728 let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2732 .parent()
2733 .and_then(|p| p.parent())
2734 .expect("workspace root")
2735 .to_path_buf();
2736
2737 let first = dispatch_tool(
2738 &state,
2739 "discover_aliases",
2740 json!({"project_root": workspace_root.to_string_lossy()}),
2741 )
2742 .await
2743 .expect("discover_aliases first");
2744
2745 let new_count = first["new"].as_u64().expect("new is u64");
2746 assert!(new_count > 0, "expected new discoveries on first call");
2747 let discovered = first["discovered"].as_array().expect("discovered array");
2748 assert!(
2749 discovered
2750 .iter()
2751 .any(|d| d["short"] == "tga" && d["full"] == "trusty-git-analytics"),
2752 "expected tga alias in discoveries; got {discovered:?}"
2753 );
2754
2755 {
2757 let guard = state.prompt_context_cache.read().await;
2758 assert!(
2759 guard.formatted.contains("tga → trusty-git-analytics"),
2760 "prompt cache missing tga alias after discover_aliases; got: {}",
2761 guard.formatted
2762 );
2763 }
2764
2765 let second = dispatch_tool(
2768 &state,
2769 "discover_aliases",
2770 json!({"project_root": workspace_root.to_string_lossy()}),
2771 )
2772 .await
2773 .expect("discover_aliases second");
2774 assert_eq!(second["new"].as_u64(), Some(0), "expected 0 new on rerun");
2775 let already_known = second["already_known"].as_u64().expect("already_known");
2776 assert!(
2777 already_known >= new_count,
2778 "expected already_known >= {new_count}, got {already_known}"
2779 );
2780 }
2781
2782 #[tokio::test]
2789 async fn palace_create_auto_seeds_temporal_metadata() {
2790 let (state, _tmp) = test_state();
2791 let created = dispatch_tool(&state, "palace_create", json!({"name": "auto"}))
2792 .await
2793 .expect("palace_create");
2794 assert_eq!(created["palace_id"], "auto");
2795 let summary = &created["bootstrap"];
2797 assert!(summary.is_object(), "expected bootstrap summary object");
2798 assert!(summary["triples_asserted"].as_u64().unwrap_or(0) >= 2);
2799
2800 let queried = dispatch_tool(
2801 &state,
2802 "kg_query",
2803 json!({"palace": "auto", "subject": "auto"}),
2804 )
2805 .await
2806 .expect("kg_query");
2807 let triples = queried["triples"].as_array().expect("triples");
2808 let predicates: Vec<&str> = triples
2809 .iter()
2810 .filter_map(|t| t["predicate"].as_str())
2811 .collect();
2812 assert!(
2813 predicates.contains(&"created_at"),
2814 "expected created_at after palace_create; got {predicates:?}",
2815 );
2816 assert!(
2817 predicates.contains(&"bootstrapped_at"),
2818 "expected bootstrapped_at after palace_create; got {predicates:?}",
2819 );
2820 assert!(
2822 queried.get("hint").is_none(),
2823 "hint should be absent when triples exist"
2824 );
2825 }
2826
2827 #[tokio::test]
2832 async fn kg_query_emits_hint_when_palace_empty() {
2833 let (state, _tmp) = test_state();
2834 let _ = dispatch_tool(&state, "palace_create", json!({"name": "hinted"}))
2835 .await
2836 .expect("palace_create");
2837 let queried = dispatch_tool(
2839 &state,
2840 "kg_query",
2841 json!({"palace": "hinted", "subject": "unrelated-subject"}),
2842 )
2843 .await
2844 .expect("kg_query");
2845 assert_eq!(queried["triples"].as_array().unwrap().len(), 0);
2846 let hint = queried["hint"].as_str().expect("hint field present");
2847 assert!(hint.contains("kg_bootstrap"));
2848 assert!(hint.contains("kg_assert"));
2849 }
2850
2851 #[tokio::test]
2855 async fn kg_bootstrap_seeds_workspace_facts() {
2856 let (state, _tmp) = test_state();
2857 let _ = dispatch_tool(&state, "palace_create", json!({"name": "ws"}))
2858 .await
2859 .expect("palace_create");
2860
2861 let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2862 .parent()
2863 .and_then(|p| p.parent())
2864 .expect("workspace root")
2865 .to_path_buf();
2866
2867 let result = dispatch_tool(
2868 &state,
2869 "kg_bootstrap",
2870 json!({"palace": "ws", "project_path": workspace_root.to_string_lossy()}),
2871 )
2872 .await
2873 .expect("kg_bootstrap");
2874 assert!(result["triples_asserted"].as_u64().unwrap() > 0);
2875 let subject = result["project_subject"]
2876 .as_str()
2877 .expect("project_subject")
2878 .to_string();
2879
2880 let queried = dispatch_tool(
2882 &state,
2883 "kg_query",
2884 json!({"palace": "ws", "subject": subject}),
2885 )
2886 .await
2887 .expect("kg_query");
2888 let triples = queried["triples"].as_array().expect("triples");
2889 let predicates: Vec<&str> = triples
2890 .iter()
2891 .filter_map(|t| t["predicate"].as_str())
2892 .collect();
2893 assert!(
2897 predicates.contains(&"has_workspace_member") || predicates.contains(&"has_language"),
2898 "expected workspace/language fact; got {predicates:?}",
2899 );
2900 assert!(
2902 predicates.contains(&"source_repo"),
2903 "expected source_repo from .git/config; got {predicates:?}",
2904 );
2905 assert!(predicates.contains(&"bootstrapped_at"));
2907 }
2908
2909 #[test]
2918 fn content_gate_blocks_short_no_context() {
2919 assert_eq!(content_gate("yes", None), None);
2920 assert_eq!(content_gate("ok", None), None);
2921 assert_eq!(
2922 content_gate(" no thanks ", None),
2923 None,
2924 "2 words still < 4"
2925 );
2926 assert_eq!(
2927 content_gate("one two three", None),
2928 None,
2929 "3 words still < 4"
2930 );
2931 }
2932
2933 #[test]
2939 fn content_gate_wraps_short_with_context() {
2940 let combined = content_gate(
2941 "yes",
2942 Some("Do you want to enable auto-bootstrap on new palaces?"),
2943 )
2944 .expect("context should unlock the gate");
2945 assert_eq!(
2946 combined,
2947 "Do you want to enable auto-bootstrap on new palaces?\n\n---\n\nyes",
2948 );
2949 let combined = content_gate(
2952 "the quick brown fox jumps over the lazy dog",
2953 Some("Famous typing pangram"),
2954 )
2955 .expect("long content + context still combines");
2956 assert!(combined.starts_with("Famous typing pangram"));
2957 assert!(combined.contains("\n\n---\n\n"));
2958 assert!(combined.ends_with("the quick brown fox jumps over the lazy dog"));
2959 }
2960
2961 #[test]
2968 fn content_gate_keeps_long() {
2969 let body = "User prefers snake_case for python";
2970 let kept = content_gate(body, None).expect(">= 4 words passes");
2971 assert_eq!(kept, body, "passing content must round-trip verbatim");
2972 let boundary = "one two three four";
2974 assert_eq!(content_gate(boundary, None).as_deref(), Some(boundary));
2975 }
2976
2977 #[test]
2984 fn content_gate_blank_context_treated_as_none() {
2985 assert_eq!(content_gate("yes", Some("")), None);
2986 assert_eq!(content_gate("yes", Some(" ")), None);
2987 assert_eq!(content_gate("yes", Some("\n\t")), None);
2988 }
2989
2990 #[tokio::test]
2996 async fn dispatch_remember_skips_short_no_context() {
2997 let (state, _tmp) = test_state();
2998 let _ = dispatch_tool(&state, "palace_create", json!({"name": "gate"}))
2999 .await
3000 .expect("palace_create");
3001
3002 let res = dispatch_tool(
3003 &state,
3004 "memory_remember",
3005 json!({"palace": "gate", "text": "yes"}),
3006 )
3007 .await
3008 .expect("memory_remember (short)");
3009 assert_eq!(res["status"], "skipped");
3010 assert!(res["reason"]
3011 .as_str()
3012 .unwrap_or("")
3013 .contains("content gate"));
3014 let listed = dispatch_tool(
3016 &state,
3017 "memory_list",
3018 json!({"palace": "gate", "limit": 10}),
3019 )
3020 .await
3021 .expect("memory_list");
3022 let drawers = listed["drawers"].as_array().expect("drawers array");
3023 assert!(
3024 drawers.is_empty(),
3025 "no drawer should be written; got {drawers:?}"
3026 );
3027 }
3028
3029 #[tokio::test]
3037 async fn dispatch_remember_with_context_writes_combined() {
3038 let (state, _tmp) = test_state();
3039 let _ = dispatch_tool(&state, "palace_create", json!({"name": "ctxgate"}))
3040 .await
3041 .expect("palace_create");
3042
3043 let res = dispatch_tool(
3044 &state,
3045 "memory_remember",
3046 json!({
3047 "palace": "ctxgate",
3048 "text": "yes",
3049 "context": "Do you want to enable auto-bootstrap on new palaces?",
3050 "force": true,
3051 }),
3052 )
3053 .await
3054 .expect("memory_remember (with context)");
3055 assert_eq!(res["status"], "stored");
3056
3057 let listed = dispatch_tool(
3058 &state,
3059 "memory_list",
3060 json!({"palace": "ctxgate", "limit": 10}),
3061 )
3062 .await
3063 .expect("memory_list");
3064 let drawers = listed["drawers"].as_array().expect("drawers array");
3065 assert_eq!(drawers.len(), 1);
3066 let body = drawers[0]["content"].as_str().expect("content");
3067 assert!(body.starts_with("Do you want to enable auto-bootstrap"));
3068 assert!(body.contains("\n\n---\n\n"));
3069 assert!(body.ends_with("yes"));
3070 }
3071
3072 #[tokio::test]
3079 async fn dispatch_note_skips_short_no_context() {
3080 let (state, _tmp) = test_state();
3081 let _ = dispatch_tool(&state, "palace_create", json!({"name": "noteg"}))
3082 .await
3083 .expect("palace_create");
3084
3085 let res = dispatch_tool(
3086 &state,
3087 "memory_note",
3088 json!({"palace": "noteg", "content": "ok"}),
3089 )
3090 .await
3091 .expect("memory_note (short)");
3092 assert_eq!(res["status"], "skipped");
3093 let listed = dispatch_tool(
3094 &state,
3095 "memory_list",
3096 json!({"palace": "noteg", "limit": 10}),
3097 )
3098 .await
3099 .expect("memory_list");
3100 assert!(listed["drawers"].as_array().unwrap().is_empty());
3101 }
3102
3103 #[tokio::test]
3104 async fn dispatch_unknown_tool_errors() {
3105 let (state, _tmp) = test_state();
3106 let err = dispatch_tool(&state, "does_not_exist", json!({}))
3107 .await
3108 .expect_err("should error");
3109 assert!(err.to_string().contains("unknown tool"));
3110 }
3111
3112 #[test]
3123 fn blocklist_gate_blocks_tool_use() {
3124 assert!(blocklist_gate("Tool use: Bash"));
3125 assert!(blocklist_gate(
3126 "Tool use: Edit File: /Users/me/Projects/foo/bar.rs"
3127 ));
3128 assert!(blocklist_gate(" Tool use: Read"));
3130 }
3131
3132 #[test]
3137 fn blocklist_gate_blocks_session_ended() {
3138 assert!(blocklist_gate(
3139 "Claude Code session ended: 1d2c3b4a-0000-0000-0000-000000000000"
3140 ));
3141 assert!(blocklist_gate("Claude Code session started"));
3142 }
3143
3144 #[test]
3150 fn blocklist_gate_passes_normal_content() {
3151 assert!(!blocklist_gate("User prefers snake_case for python"));
3152 assert!(!blocklist_gate(
3153 "Quokkas are the happiest marsupials in Australia"
3154 ));
3155 assert!(!blocklist_gate("Note: refactor the dispatcher next sprint"));
3156 assert!(blocklist_gate("I used Tool use: Bash here"));
3161 }
3162
3163 #[tokio::test]
3173 async fn dedup_skips_near_duplicate() {
3174 let (state, _tmp) = test_state();
3175 let _ = dispatch_tool(&state, "palace_create", json!({"name": "dedup1"}))
3176 .await
3177 .expect("palace_create");
3178
3179 let _ = dispatch_tool(
3182 &state,
3183 "memory_remember",
3184 json!({
3185 "palace": "dedup1",
3186 "text": "The quick brown fox jumped over the lazy dog repeatedly today",
3187 }),
3188 )
3189 .await
3190 .expect("memory_remember seed");
3191
3192 let handle = open_palace_handle(&state, "dedup1").expect("open handle");
3193 assert!(
3197 dedup_gate(
3198 &handle,
3199 "The quick brown fox jumped over the lazy dog repeatedly yesterday"
3200 ),
3201 "near-duplicate should be detected"
3202 );
3203 assert!(
3205 dedup_gate(
3206 &handle,
3207 "The quick brown fox jumped over the lazy dog repeatedly today"
3208 ),
3209 "exact match should be detected"
3210 );
3211 }
3212
3213 #[tokio::test]
3219 async fn dedup_allows_different_content() {
3220 let (state, _tmp) = test_state();
3221 let _ = dispatch_tool(&state, "palace_create", json!({"name": "dedup2"}))
3222 .await
3223 .expect("palace_create");
3224
3225 let _ = dispatch_tool(
3226 &state,
3227 "memory_remember",
3228 json!({
3229 "palace": "dedup2",
3230 "text": "Quokkas are the happiest marsupials in Australia by general consensus",
3231 }),
3232 )
3233 .await
3234 .expect("memory_remember seed");
3235
3236 let handle = open_palace_handle(&state, "dedup2").expect("open handle");
3237 assert!(
3239 !dedup_gate(
3240 &handle,
3241 "Rust is a systems programming language focused on safety and concurrency"
3242 ),
3243 "unrelated content should pass the dedup gate"
3244 );
3245 assert!(!dedup_gate(&handle, " "));
3248 }
3249
3250 #[tokio::test]
3265 async fn dedup_gate_blocks_concurrent_duplicate_writes() {
3266 let (state, _tmp) = test_state();
3267 let state = std::sync::Arc::new(state);
3268 let _ = dispatch_tool(&state, "palace_create", json!({"name": "dedup_race"}))
3269 .await
3270 .expect("palace_create");
3271
3272 let text =
3276 "Concurrent identical writes must collapse to a single drawer under the dedup gate";
3277
3278 let s1 = state.clone();
3279 let t1 = tokio::spawn(async move {
3280 dispatch_tool(
3281 &s1,
3282 "memory_remember",
3283 json!({"palace": "dedup_race", "text": text}),
3284 )
3285 .await
3286 });
3287 let s2 = state.clone();
3288 let t2 = tokio::spawn(async move {
3289 dispatch_tool(
3290 &s2,
3291 "memory_remember",
3292 json!({"palace": "dedup_race", "text": text}),
3293 )
3294 .await
3295 });
3296 let r1 = t1.await.expect("join t1").expect("dispatch t1");
3297 let r2 = t2.await.expect("join t2").expect("dispatch t2");
3298
3299 let statuses = [
3302 r1["status"].as_str().unwrap_or(""),
3303 r2["status"].as_str().unwrap_or(""),
3304 ];
3305 let stored = statuses.iter().filter(|s| **s == "stored").count();
3306 let skipped = statuses.iter().filter(|s| **s == "skipped").count();
3307 assert_eq!(
3308 stored, 1,
3309 "exactly one concurrent write should be stored; got responses {r1:?} {r2:?}"
3310 );
3311 assert_eq!(
3312 skipped, 1,
3313 "exactly one concurrent write should be skipped; got responses {r1:?} {r2:?}"
3314 );
3315 let skipped_reason = if r1["status"] == "skipped" {
3316 r1["reason"].as_str().unwrap_or("")
3317 } else {
3318 r2["reason"].as_str().unwrap_or("")
3319 };
3320 assert!(
3321 skipped_reason.contains("duplicate within window"),
3322 "skipped envelope should cite dedup reason; got {skipped_reason:?}"
3323 );
3324
3325 let listed = dispatch_tool(
3327 &state,
3328 "memory_list",
3329 json!({"palace": "dedup_race", "limit": 10}),
3330 )
3331 .await
3332 .expect("memory_list");
3333 let drawers = listed["drawers"].as_array().expect("drawers array");
3334 assert_eq!(
3335 drawers.len(),
3336 1,
3337 "only one drawer should be persisted after concurrent identical writes; got {drawers:?}"
3338 );
3339 }
3340
3341 #[tokio::test]
3349 async fn dispatch_remember_blocks_blocklist_pattern() {
3350 let (state, _tmp) = test_state();
3351 let _ = dispatch_tool(&state, "palace_create", json!({"name": "blk"}))
3352 .await
3353 .expect("palace_create");
3354
3355 let res = dispatch_tool(
3356 &state,
3357 "memory_remember",
3358 json!({"palace": "blk", "text": "Tool use: Bash"}),
3359 )
3360 .await
3361 .expect("memory_remember (blocked)");
3362 assert_eq!(res["status"], "skipped");
3363 assert!(
3364 res["reason"]
3365 .as_str()
3366 .unwrap_or("")
3367 .contains("blocked pattern"),
3368 "reason should mention blocked pattern; got {res:?}"
3369 );
3370
3371 let listed = dispatch_tool(&state, "memory_list", json!({"palace": "blk", "limit": 10}))
3372 .await
3373 .expect("memory_list");
3374 let drawers = listed["drawers"].as_array().expect("drawers array");
3375 assert!(drawers.is_empty(), "no drawer should be written");
3376 }
3377
3378 #[tokio::test]
3392 async fn bm25_index_queue_drops_when_full() {
3393 let (mut state, _tmp) = test_state();
3397 let (tx, _rx_held) =
3398 tokio::sync::mpsc::channel::<Bm25IndexRequest>(BM25_INDEX_QUEUE_CAPACITY);
3399 state.bm25_index_tx = tx;
3400
3401 for i in 0..BM25_INDEX_QUEUE_CAPACITY {
3403 bm25_index_enqueue(
3404 &state,
3405 "default",
3406 Uuid::new_v4(),
3407 &format!("filler content {i}"),
3408 );
3409 }
3410 assert_eq!(
3412 state.bm25_index_tx.capacity(),
3413 0,
3414 "after filling, sender capacity must be 0"
3415 );
3416
3417 for i in 0..16 {
3420 bm25_index_enqueue(
3421 &state,
3422 "default",
3423 Uuid::new_v4(),
3424 &format!("overflow content {i}"),
3425 );
3426 }
3427
3428 let probe_req = Bm25IndexRequest {
3432 palace: "default".to_string(),
3433 drawer_id: Uuid::new_v4().to_string(),
3434 content: "probe".to_string(),
3435 data_dir: state.data_root.join("default").join("bm25"),
3436 };
3437 let probe = state.bm25_index_tx.try_send(probe_req);
3438 match probe {
3439 Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {}
3440 other => panic!("expected Full overflow, got {other:?}"),
3441 }
3442 }
3443}