use crate::attribution::{session_tag_from_tags, CreatorInfo, CreatorSource, MCP_CLIENT_NAME};
use crate::kg_extract::{extract_triples, ExtractInput};
use crate::{ActivitySource, AppState, DaemonEvent};
use anyhow::{anyhow, Context, Result};
use serde_json::{json, Value};
use trusty_common::memory_core::filter::{FilterConfig, MCP_MIN_TOKENS};
use trusty_common::memory_core::palace::{Palace, PalaceId, RoomType};
use trusty_common::memory_core::retrieval::{
recall, recall_across_palaces, recall_deep, RememberOptions,
};
use trusty_common::memory_core::store::kg::Triple;
use uuid::Uuid;
fn lookup_palace_name(state: &AppState, palace_id: &str) -> String {
state
.palace_names
.get(palace_id)
.map(|entry| entry.value().clone())
.unwrap_or_else(|| palace_id.to_string())
}
const CONTENT_GATE_MIN_WORDS: usize = 4;
fn content_gate(content: &str, context: Option<&str>) -> Option<String> {
let trimmed = content.trim();
let word_count = trimmed.split_whitespace().count();
let context_clean = context.map(str::trim).filter(|s| !s.is_empty());
if let Some(ctx) = context_clean {
return Some(format!("{ctx}\n\n---\n\n{content}"));
}
if word_count < CONTENT_GATE_MIN_WORDS {
return None;
}
Some(content.to_string())
}
const BLOCKLIST_PATTERNS: &[&str] = &[
"Tool use: ", "Claude Code session", ];
const DEDUP_WINDOW_MINUTES: i64 = 5;
const DEDUP_SCAN_LIMIT: usize = 50;
const DEDUP_SIMILARITY_THRESHOLD: f64 = 0.92;
fn blocklist_gate(content: &str) -> bool {
let trimmed = content.trim_start();
BLOCKLIST_PATTERNS.iter().any(|pat| trimmed.contains(pat))
}
fn dedup_gate(handle: &trusty_common::memory_core::PalaceHandle, content: &str) -> bool {
let trimmed = content.trim();
if trimmed.is_empty() {
return false;
}
let now = chrono::Utc::now();
let window_start = now - chrono::Duration::minutes(DEDUP_WINDOW_MINUTES);
let recent = handle.list_drawers(None, None, DEDUP_SCAN_LIMIT);
recent
.iter()
.filter(|d| d.created_at >= window_start)
.any(|d| strsim::jaro_winkler(trimmed, d.content.trim()) > DEDUP_SIMILARITY_THRESHOLD)
}
fn mcp_remember_opts(force: bool) -> RememberOptions {
let filter = FilterConfig {
min_tokens: MCP_MIN_TOKENS,
..FilterConfig::default()
};
RememberOptions {
filter,
force,
..RememberOptions::default()
}
}
pub struct MemoryMcpServer;
impl MemoryMcpServer {
pub fn new() -> Self {
Self
}
}
impl Default for MemoryMcpServer {
fn default() -> Self {
Self::new()
}
}
pub fn tool_definitions() -> Value {
tool_definitions_with(false)
}
pub fn tool_definitions_with(has_default: bool) -> Value {
let memory_remember_required: Vec<&str> = if has_default {
vec!["text"]
} else {
vec!["palace", "text"]
};
let memory_recall_required: Vec<&str> = if has_default {
vec!["query"]
} else {
vec!["palace", "query"]
};
let kg_assert_required: Vec<&str> = if has_default {
vec!["subject", "predicate", "object"]
} else {
vec!["palace", "subject", "predicate", "object"]
};
let kg_query_required: Vec<&str> = if has_default {
vec!["subject"]
} else {
vec!["palace", "subject"]
};
let memory_list_required: Vec<&str> = if has_default { vec![] } else { vec!["palace"] };
let memory_forget_required: Vec<&str> = if has_default {
vec!["drawer_id"]
} else {
vec!["palace", "drawer_id"]
};
let palace_info_required: Vec<&str> = if has_default { vec![] } else { vec!["palace"] };
let palace_compact_required: Vec<&str> = if has_default { vec![] } else { vec!["palace"] };
let memory_note_required: Vec<&str> = if has_default {
vec!["content"]
} else {
vec!["palace", "content"]
};
json!({
"tools": [
{
"name": "memory_remember",
"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.",
"inputSchema": {
"type": "object",
"properties": {
"palace": {"type": "string", "description": "Palace ID (optional if server started with --palace)"},
"text": {"type": "string", "description": "Memory content"},
"room": {"type": "string", "description": "Room type (optional)"},
"tags": {"type": "array", "items": {"type": "string"}},
"force": {"type": "boolean", "description": "Bypass the signal/noise filter. Use sparingly — intended for explicit operator overrides.", "default": false},
"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)."}
},
"required": memory_remember_required,
}
},
{
"name": "memory_note",
"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.",
"inputSchema": {
"type": "object",
"properties": {
"palace": {"type": "string"},
"content": {"type": "string", "description": "Brief fact to remember"},
"tags": {"type": "array", "items": {"type": "string"}},
"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)."}
},
"required": memory_note_required,
}
},
{
"name": "memory_recall",
"description": "Recall memories using L0+L1+L2 progressive retrieval.",
"inputSchema": {
"type": "object",
"properties": {
"palace": {"type": "string"},
"query": {"type": "string"},
"top_k": {"type": "integer", "default": 10}
},
"required": memory_recall_required,
}
},
{
"name": "memory_recall_deep",
"description": "Deep recall using L3 full HNSW search.",
"inputSchema": {
"type": "object",
"properties": {
"palace": {"type": "string"},
"query": {"type": "string"},
"top_k": {"type": "integer", "default": 10}
},
"required": memory_recall_required,
}
},
{
"name": "palace_create",
"description": "Create a new memory palace.",
"inputSchema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"description": {"type": "string"}
},
"required": ["name"]
}
},
{
"name": "palace_list",
"description": "List all palaces on this machine.",
"inputSchema": {"type": "object", "properties": {}}
},
{
"name": "palace_delete",
"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.",
"inputSchema": {
"type": "object",
"properties": {
"palace_id": {"type": "string", "description": "Id of the palace to delete."},
"force": {"type": "boolean", "description": "Required when the palace still has drawers; defaults to false.", "default": false}
},
"required": ["palace_id"]
}
},
{
"name": "palace_update",
"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.",
"inputSchema": {
"type": "object",
"properties": {
"palace_id": {"type": "string", "description": "Id of the palace to rename."},
"name": {"type": "string", "description": "New display name. Trimmed; must be non-empty."}
},
"required": ["palace_id", "name"]
}
},
{
"name": "kg_assert",
"description": "Assert a fact in the temporal knowledge graph.",
"inputSchema": {
"type": "object",
"properties": {
"palace": {"type": "string"},
"subject": {"type": "string"},
"predicate": {"type": "string"},
"object": {"type": "string"},
"confidence": {"type": "number", "default": 1.0},
"provenance": {"type": "string"}
},
"required": kg_assert_required,
}
},
{
"name": "kg_query",
"description": "Query active knowledge-graph triples for a subject.",
"inputSchema": {
"type": "object",
"properties": {
"palace": {"type": "string"},
"subject": {"type": "string"}
},
"required": kg_query_required,
}
},
{
"name": "memory_list",
"description": "List drawers in a palace, optionally filtered by room type or tag.",
"inputSchema": {
"type": "object",
"properties": {
"palace": {"type": "string"},
"room": {"type": "string", "description": "Filter by room type (Frontend, Backend, Testing, Planning, Documentation, Research, Configuration, Meetings, General, or custom)"},
"tag": {"type": "string", "description": "Filter by tag"},
"limit": {"type": "integer", "description": "Max results (default 50)"}
},
"required": memory_list_required,
}
},
{
"name": "memory_forget",
"description": "Delete a drawer from a palace by its UUID.",
"inputSchema": {
"type": "object",
"properties": {
"palace": {"type": "string"},
"drawer_id": {"type": "string", "description": "UUID of the drawer to delete"}
},
"required": memory_forget_required,
}
},
{
"name": "palace_info",
"description": "Get metadata and stats for a single palace.",
"inputSchema": {
"type": "object",
"properties": {
"palace": {"type": "string"}
},
"required": palace_info_required,
}
},
{
"name": "palace_compact",
"description": "Remove orphaned vector index entries (vectors with no matching drawer row). See issue #49.",
"inputSchema": {
"type": "object",
"properties": {
"palace": {"type": "string"}
},
"required": palace_compact_required,
}
},
{
"name": "add_alias",
"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.",
"inputSchema": {
"type": "object",
"properties": {
"short": {"type": "string", "description": "Short name / alias (subject)"},
"full": {"type": "string", "description": "Full / canonical name (object)"},
"extra": {"type": "string", "description": "Optional extra context appended to the full name"}
},
"required": ["short", "full"],
}
},
{
"name": "list_prompt_facts",
"description": "List every active prompt-fact triple (aliases, conventions, facts, shorthands) across all palaces.",
"inputSchema": {"type": "object", "properties": {}}
},
{
"name": "remove_prompt_fact",
"description": "Retract the active triple for a (subject, predicate) pair from the prompt-facts surface. Closes the interval without inserting a replacement.",
"inputSchema": {
"type": "object",
"properties": {
"subject": {"type": "string"},
"predicate": {"type": "string", "description": "One of is_alias_for, has_convention, is_fact, is_shorthand_for"}
},
"required": ["subject", "predicate"],
}
},
{
"name": "get_prompt_context",
"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).",
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Optional filter — only return facts whose subject or object contains this string (case-insensitive). Omit to return all hot facts."
}
}
}
},
{
"name": "discover_aliases",
"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.",
"inputSchema": {
"type": "object",
"properties": {
"project_root": {"type": "string", "description": "Optional filesystem path to scan. Defaults to the process cwd."}
}
}
},
{
"name": "kg_gaps",
"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.",
"inputSchema": {
"type": "object",
"properties": {
"palace": {"type": "string", "description": "Palace name (optional, defaults to the active palace)"}
}
}
},
{
"name": "kg_bootstrap",
"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.",
"inputSchema": {
"type": "object",
"properties": {
"palace": {"type": "string", "description": "Palace ID (optional if server started with --palace)"},
"project_path": {"type": "string", "description": "Filesystem path to scan. Omit to scan the palace's own data dir (temporal metadata only)."}
}
}
},
{
"name": "memory_recall_all",
"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.",
"inputSchema": {
"type": "object",
"properties": {
"q": {"type": "string", "description": "Free-text query"},
"top_k": {"type": "integer", "default": 10},
"deep": {"type": "boolean", "default": false}
},
"required": ["q"],
}
},
{
"name": "memory_send_message",
"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.",
"inputSchema": {
"type": "object",
"properties": {
"to_palace": {"type": "string", "description": "Recipient palace id (repo slug)."},
"purpose": {"type": "string", "description": "Free-text purpose / category (e.g. `task`, `notify`, `reply`)."},
"content": {"type": "string", "description": "Message body — plain text, no length limit. Rendered into the recipient session as a Markdown block."},
"from_palace": {"type": "string", "description": "Sender palace id (optional, defaults to cwd-derived slug)."}
},
"required": ["to_palace", "purpose", "content"],
}
}
]
})
}
pub(crate) fn room_label(room: &RoomType) -> Option<String> {
let label = match room {
RoomType::Frontend => "Frontend",
RoomType::Backend => "Backend",
RoomType::Testing => "Testing",
RoomType::Planning => "Planning",
RoomType::Documentation => "Documentation",
RoomType::Research => "Research",
RoomType::Configuration => "Configuration",
RoomType::Meetings => "Meetings",
RoomType::General => "General",
RoomType::Custom(s) => return Some(s.clone()),
};
Some(label.to_string())
}
fn parse_room(s: Option<&str>) -> RoomType {
match s.unwrap_or("General") {
"Frontend" => RoomType::Frontend,
"Backend" => RoomType::Backend,
"Testing" => RoomType::Testing,
"Planning" => RoomType::Planning,
"Documentation" => RoomType::Documentation,
"Research" => RoomType::Research,
"Configuration" => RoomType::Configuration,
"Meetings" => RoomType::Meetings,
"General" => RoomType::General,
other => RoomType::Custom(other.to_string()),
}
}
fn open_palace_handle(
state: &AppState,
palace_id: &str,
) -> Result<std::sync::Arc<trusty_common::memory_core::PalaceHandle>> {
let pid = PalaceId::new(palace_id);
state
.registry
.open_palace(&state.data_root, &pid)
.with_context(|| format!("open palace {palace_id}"))
}
pub(crate) async fn auto_extract_and_assert(
handle: &std::sync::Arc<trusty_common::memory_core::PalaceHandle>,
drawer_id: Uuid,
content: &str,
tags: &[String],
room: Option<&str>,
) {
let input = ExtractInput {
drawer_id,
content,
tags,
room,
};
let triples = extract_triples(&input);
if triples.is_empty() {
return;
}
for triple in triples {
let s = triple.subject.clone();
let p = triple.predicate.clone();
if let Err(e) = handle.kg.assert(triple).await {
tracing::warn!(
drawer_id = %drawer_id,
subject = %s,
predicate = %p,
"auto kg extraction: assert failed (non-fatal): {e:#}",
);
}
}
}
fn resolve_palace<'a>(state: &'a AppState, args: &'a Value, tool: &str) -> Result<String> {
if let Some(p) = args.get("palace").and_then(|v| v.as_str()) {
return Ok(p.to_string());
}
state
.default_palace
.clone()
.ok_or_else(|| anyhow!("{tool}: missing 'palace' (no --palace default configured)"))
}
struct WriteDrawerParams<'a> {
palace_id: &'a str,
content: String,
tags: Vec<String>,
room: RoomType,
importance: f32,
opts: RememberOptions,
room_label_for_kg: Option<String>,
}
async fn write_drawer(state: &AppState, params: WriteDrawerParams<'_>) -> Result<Uuid> {
let WriteDrawerParams {
palace_id,
content,
tags,
room,
importance,
opts,
room_label_for_kg,
} = params;
let handle = open_palace_handle(state, palace_id)?;
let preview = crate::service::drawer_content_preview(&content);
let content_for_kg = content.clone();
let tags_for_kg = tags.clone();
let drawer_id = handle
.remember_with_options(content, room, tags, importance, opts)
.await
.context("PalaceHandle::remember_with_options")?;
bm25_index_enqueue(state, palace_id, drawer_id, &content_for_kg);
let palace_name = lookup_palace_name(state, palace_id);
let drawer_count = handle.drawers.read().len();
state.emit(DaemonEvent::DrawerAdded {
palace_id: palace_id.to_string(),
palace_name,
drawer_count,
timestamp: chrono::Utc::now(),
content_preview: preview,
source: ActivitySource::Mcp,
});
auto_extract_and_assert(
&handle,
drawer_id,
&content_for_kg,
&tags_for_kg,
room_label_for_kg.as_deref(),
)
.await;
Ok(drawer_id)
}
fn skipped_envelope(palace_id: &str, reason: &str) -> Value {
json!({
"palace": palace_id,
"status": "skipped",
"reason": reason,
})
}
fn parse_tags(args: &Value) -> Vec<String> {
args.get("tags")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|t| t.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default()
}
fn attach_mcp_attribution(tags: &mut Vec<String>) {
if let Some(session_tag) = session_tag_from_tags(tags) {
tags.push(session_tag);
}
CreatorInfo::new_self(MCP_CLIENT_NAME, CreatorSource::Mcp).merge_into(tags);
}
async fn handle_memory_remember(state: &AppState, args: Value) -> Result<Value> {
let palace = resolve_palace(state, &args, "memory_remember")?;
let palace = palace.as_str();
let raw_text = args
.get("text")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("memory_remember: missing 'text'"))?
.to_string();
if blocklist_gate(&raw_text) {
tracing::debug!(
palace = %palace,
"content gate: skipped (blocked pattern)",
);
return Ok(skipped_envelope(
palace,
"content gate: skipped (blocked pattern)",
));
}
let ctx = args.get("context").and_then(|v| v.as_str());
let text = match content_gate(&raw_text, ctx) {
Some(t) => t,
None => {
return Ok(skipped_envelope(
palace,
"content gate: skipped (short prompt, no context)",
));
}
};
let room = parse_room(args.get("room").and_then(|v| v.as_str()));
let mut tags = parse_tags(&args);
attach_mcp_attribution(&mut tags);
let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
let write_lock = state.palace_write_lock(palace);
let _write_guard = write_lock.lock().await;
if !force {
let handle = open_palace_handle(state, palace)?;
if dedup_gate(&handle, &text) {
tracing::debug!(
palace = %palace,
"content gate: skipped (duplicate within window)",
);
return Ok(skipped_envelope(
palace,
"content gate: skipped (duplicate within window)",
));
}
}
let room_label_for_kg = room_label(&room);
let drawer_id = write_drawer(
state,
WriteDrawerParams {
palace_id: palace,
content: text,
tags,
room,
importance: 0.5,
opts: mcp_remember_opts(force),
room_label_for_kg,
},
)
.await?;
Ok(json!({
"drawer_id": drawer_id.to_string(),
"palace": palace,
"status": "stored",
}))
}
async fn handle_memory_note(state: &AppState, args: Value) -> Result<Value> {
let palace = resolve_palace(state, &args, "memory_note")?;
let palace = palace.as_str();
let raw_content = args
.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("memory_note: missing 'content'"))?
.to_string();
if blocklist_gate(&raw_content) {
tracing::debug!(
palace = %palace,
"content gate: skipped (blocked pattern)",
);
return Ok(skipped_envelope(
palace,
"content gate: skipped (blocked pattern)",
));
}
let ctx = args.get("context").and_then(|v| v.as_str());
let content = match content_gate(&raw_content, ctx) {
Some(c) => c,
None => {
return Ok(skipped_envelope(
palace,
"content gate: skipped (short prompt, no context)",
));
}
};
let mut tags = parse_tags(&args);
attach_mcp_attribution(&mut tags);
let write_lock = state.palace_write_lock(palace);
let _write_guard = write_lock.lock().await;
{
let handle = open_palace_handle(state, palace)?;
if dedup_gate(&handle, &content) {
tracing::debug!(
palace = %palace,
"content gate: skipped (duplicate within window)",
);
return Ok(skipped_envelope(
palace,
"content gate: skipped (duplicate within window)",
));
}
}
let drawer_id = write_drawer(
state,
WriteDrawerParams {
palace_id: palace,
content,
tags,
room: RoomType::General,
importance: 1.0,
opts: RememberOptions::note(),
room_label_for_kg: Some("General".to_string()),
},
)
.await
.context("PalaceHandle::remember_with_options (note)")?;
Ok(json!({
"drawer_id": drawer_id.to_string(),
"palace": palace,
"status": "stored",
"drawer_type": "UserFact",
}))
}
async fn handle_memory_recall(state: &AppState, args: Value) -> Result<Value> {
let palace = resolve_palace(state, &args, "memory_recall")?;
let query = args
.get("query")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("memory_recall: missing 'query'"))?;
let top_k = args.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
let handle = open_palace_handle(state, &palace)?;
let embedder = state.embedder().await?;
let vector_fut = recall(&handle, embedder.as_ref(), query, top_k);
let bm25_fut = bm25_search_optional(state, &palace, query, top_k);
let (vector_res, bm25_res) = tokio::join!(vector_fut, bm25_fut);
let mut results = vector_res.context("recall")?;
if let Some(bm25_hits) = bm25_res {
fuse_bm25_into_recall(&mut results, &bm25_hits, top_k);
}
Ok(serialize_recall(&palace, query, results))
}
async fn handle_memory_recall_deep(state: &AppState, args: Value) -> Result<Value> {
let palace = resolve_palace(state, &args, "memory_recall_deep")?;
let query = args
.get("query")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("memory_recall_deep: missing 'query'"))?;
let top_k = args.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
let handle = open_palace_handle(state, &palace)?;
let embedder = state.embedder().await?;
let results = recall_deep(&handle, embedder.as_ref(), query, top_k)
.await
.context("recall_deep")?;
Ok(serialize_recall(&palace, query, results))
}
async fn handle_palace_create(state: &AppState, args: Value) -> Result<Value> {
let palace_name = args
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("palace_create: missing 'name'"))?;
let skip_enforcement = std::env::var("TRUSTY_SKIP_PALACE_ENFORCEMENT").as_deref() == Ok("1");
if !skip_enforcement {
let cwd = std::env::current_dir().unwrap_or_else(|_| state.data_root.clone());
crate::project_root::validate_palace_name(palace_name, &cwd)?;
}
let description = args
.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let palace = Palace {
id: PalaceId::new(palace_name),
name: palace_name.to_string(),
description,
created_at: chrono::Utc::now(),
data_dir: state.data_root.join(palace_name),
};
let _handle = state
.registry
.create_palace(&state.data_root, palace)
.context("create_palace")?;
state
.palace_names
.insert(palace_name.to_string(), palace_name.to_string());
state.emit(DaemonEvent::PalaceCreated {
id: palace_name.to_string(),
name: palace_name.to_string(),
source: ActivitySource::Mcp,
});
let bootstrap_summary = match crate::bootstrap::bootstrap_palace(state, palace_name, None).await
{
Ok(r) => Some(serde_json::json!({
"triples_asserted": r.triples_asserted,
"project_subject": r.project_subject,
})),
Err(e) => {
tracing::warn!(
palace = %palace_name,
"auto-bootstrap on palace_create failed: {e:#}",
);
None
}
};
Ok(json!({
"palace_id": palace_name,
"status": "created",
"bootstrap": bootstrap_summary,
}))
}
async fn handle_palace_list(state: &AppState, _args: Value) -> Result<Value> {
let root = state.data_root.clone();
let palaces = tokio::task::spawn_blocking(move || {
trusty_common::memory_core::PalaceRegistry::list_palaces(&root)
})
.await
.context("join list_palaces")??;
let ids: Vec<String> = palaces.iter().map(|p| p.id.as_str().to_string()).collect();
Ok(json!({ "palaces": ids }))
}
async fn handle_palace_delete(state: &AppState, args: Value) -> Result<Value> {
let palace_id = args
.get("palace_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("palace_delete: missing 'palace_id'"))?
.to_string();
let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
use crate::service::{MemoryService, ServiceError};
let svc = MemoryService::new(state.clone());
match svc.delete_palace(&palace_id, force).await {
Ok(()) => Ok(json!({ "deleted": palace_id })),
Err(ServiceError::NotFound(_)) => Err(anyhow!("Palace not found: {palace_id}")),
Err(ServiceError::Conflict(msg)) => Err(anyhow!(msg)),
Err(e) => Err(anyhow!("palace_delete: {e}")),
}
}
async fn handle_palace_update(state: &AppState, args: Value) -> Result<Value> {
let palace_id = args
.get("palace_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("palace_update: missing 'palace_id'"))?
.to_string();
let name = args
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("palace_update: missing 'name'"))?
.to_string();
use crate::service::MemoryService;
let svc = MemoryService::new(state.clone());
match svc.update_palace_name(&palace_id, &name).await {
Ok(_info) => Ok(json!({ "updated": palace_id, "name": name.trim() })),
Err(e) => Err(anyhow!("palace_update: {e}")),
}
}
async fn handle_kg_assert(state: &AppState, args: Value) -> Result<Value> {
let palace = resolve_palace(state, &args, "kg_assert")?;
let palace = palace.as_str();
let subject = args
.get("subject")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("kg_assert: missing 'subject'"))?
.to_string();
let predicate = args
.get("predicate")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("kg_assert: missing 'predicate'"))?
.to_string();
let object = args
.get("object")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("kg_assert: missing 'object'"))?
.to_string();
let confidence = args
.get("confidence")
.and_then(|v| v.as_f64())
.map(|c| (c as f32).clamp(0.0, 1.0))
.unwrap_or(1.0);
let provenance = args
.get("provenance")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let handle = open_palace_handle(state, palace)?;
let triple = Triple {
subject,
predicate,
object,
valid_from: chrono::Utc::now(),
valid_to: None,
confidence,
provenance,
};
let is_hot = crate::prompt_facts::is_hot_predicate(&triple.predicate);
handle.kg.assert(triple).await.context("kg.assert")?;
if is_hot {
if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
tracing::warn!("rebuild_prompt_cache after kg_assert failed: {e:#}");
}
}
Ok(json!({ "status": "asserted" }))
}
async fn handle_add_alias(state: &AppState, args: Value) -> Result<Value> {
let short = args
.get("short")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("add_alias: missing 'short'"))?
.to_string();
let full = args
.get("full")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("add_alias: missing 'full'"))?
.to_string();
let extra = args
.get("extra")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let palace = resolve_palace(state, &args, "add_alias")?;
let handle = open_palace_handle(state, &palace)?;
let object = match extra.as_deref() {
Some(e) if !e.is_empty() => format!("{full} ({e})"),
_ => full.clone(),
};
let triple = Triple {
subject: short.clone(),
predicate: "is_alias_for".to_string(),
object,
valid_from: chrono::Utc::now(),
valid_to: None,
confidence: 1.0,
provenance: Some("add_alias".to_string()),
};
handle
.kg
.assert(triple)
.await
.context("kg.assert (alias)")?;
if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
tracing::warn!("rebuild_prompt_cache after add_alias failed: {e:#}");
}
Ok(json!({ "asserted": true, "short": short, "full": full }))
}
async fn handle_list_prompt_facts(state: &AppState, _args: Value) -> Result<Value> {
let triples = crate::prompt_facts::gather_hot_triples(state).await?;
let payload: Vec<Value> = triples
.into_iter()
.map(|(subject, predicate, object)| {
json!({ "subject": subject, "predicate": predicate, "object": object })
})
.collect();
Ok(json!({ "facts": payload }))
}
async fn handle_remove_prompt_fact(state: &AppState, args: Value) -> Result<Value> {
let subject = args
.get("subject")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("remove_prompt_fact: missing 'subject'"))?
.to_string();
let predicate = args
.get("predicate")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("remove_prompt_fact: missing 'predicate'"))?
.to_string();
let mut closed_total: usize = 0;
for palace_id in state.registry.list() {
if let Some(handle) = state.registry.get(&palace_id) {
match handle.kg.retract(&subject, &predicate).await {
Ok(n) => closed_total += n,
Err(e) => tracing::warn!(
palace = %palace_id.as_str(),
"retract failed: {e:#}",
),
}
}
}
if closed_total > 0 {
if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
tracing::warn!("rebuild_prompt_cache after remove_prompt_fact failed: {e:#}");
}
Ok(json!({ "removed": true, "closed": closed_total }))
} else {
Ok(json!({ "removed": false, "reason": "not found" }))
}
}
async fn handle_kg_query(state: &AppState, args: Value) -> Result<Value> {
let palace = resolve_palace(state, &args, "kg_query")?;
let subject = args
.get("subject")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("kg_query: missing 'subject'"))?;
let handle = open_palace_handle(state, &palace)?;
let triples = handle
.kg
.query_active(subject)
.await
.context("kg.query_active")?;
let payload: Vec<Value> = triples
.iter()
.map(|t| {
json!({
"subject": t.subject,
"predicate": t.predicate,
"object": t.object,
"valid_from": t.valid_from.to_rfc3339(),
"valid_to": t.valid_to.as_ref().map(|d| d.to_rfc3339()),
"confidence": t.confidence,
"provenance": t.provenance,
})
})
.collect();
let mut response = json!({ "subject": subject, "triples": payload });
if crate::bootstrap::is_kg_empty_for_subject(&triples) {
response["hint"] = Value::String(crate::bootstrap::KG_EMPTY_HINT.to_string());
}
Ok(response)
}
async fn handle_memory_list(state: &AppState, args: Value) -> Result<Value> {
let palace = resolve_palace(state, &args, "memory_list")?;
let handle = open_palace_handle(state, &palace)?;
let room = args
.get("room")
.and_then(|v| v.as_str())
.map(|s| parse_room(Some(s)));
let tag = args
.get("tag")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
let drawers = handle.list_drawers(room, tag, limit);
let payload: Vec<Value> = drawers
.iter()
.map(|d| {
json!({
"drawer_id": d.id.to_string(),
"content": d.content,
"importance": d.importance,
"tags": d.tags,
"created_at": d.created_at.to_rfc3339(),
"drawer_type": d.drawer_type.as_str(),
"expires_at": d.expires_at.map(|t| t.to_rfc3339()),
})
})
.collect();
Ok(json!({ "palace": palace, "drawers": payload }))
}
async fn handle_memory_forget(state: &AppState, args: Value) -> Result<Value> {
let palace = resolve_palace(state, &args, "memory_forget")?;
let drawer_id_str = args
.get("drawer_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("memory_forget: missing 'drawer_id'"))?;
let drawer_id = Uuid::parse_str(drawer_id_str)
.map_err(|e| anyhow!("memory_forget: invalid drawer_id UUID: {e}"))?;
let handle = open_palace_handle(state, &palace)?;
handle.forget(drawer_id).await.context("forget")?;
let drawer_count = handle.drawers.read().len();
state.emit(DaemonEvent::DrawerDeleted {
palace_id: palace.clone(),
drawer_count,
source: ActivitySource::Mcp,
});
Ok(json!({ "status": "deleted", "drawer_id": drawer_id_str, "palace": palace }))
}
async fn handle_palace_info(state: &AppState, args: Value) -> Result<Value> {
let palace = resolve_palace(state, &args, "palace_info")?;
let handle = open_palace_handle(state, &palace)?;
let drawer_count = handle.list_drawers(None, None, usize::MAX).len();
let data_dir = handle
.data_dir
.as_ref()
.map(|p| p.to_string_lossy().to_string());
Ok(json!({
"id": handle.id.as_str(),
"name": handle.id.as_str(),
"drawer_count": drawer_count,
"data_dir": data_dir,
}))
}
async fn handle_palace_compact(state: &AppState, args: Value) -> Result<Value> {
let palace = resolve_palace(state, &args, "palace_compact")?;
let handle = open_palace_handle(state, &palace)?;
let valid_ids: std::collections::HashSet<Uuid> =
handle.drawers.read().iter().map(|d| d.id).collect();
let vector_store = handle.vector_store.clone();
let res = tokio::task::spawn_blocking(move || vector_store.compact_orphans(&valid_ids))
.await
.context("join palace_compact")??;
Ok(json!({
"palace": palace,
"total_checked": res.total_checked,
"orphans_removed": res.orphans_removed,
"index_size_before": res.index_size_before,
"index_size_after": res.index_size_after,
}))
}
async fn handle_kg_gaps(state: &AppState, args: Value) -> Result<Value> {
let palace = resolve_palace(state, &args, "kg_gaps")?;
let _handle = open_palace_handle(state, &palace)?;
let pid = PalaceId::new(&palace);
let cached = state.registry.get_gaps(&pid).unwrap_or_default();
let payload: Vec<Value> = cached
.into_iter()
.map(|g| {
json!({
"entities": g.entities,
"internal_density": g.internal_density,
"external_bridges": g.external_bridges,
"suggested_exploration": g.suggested_exploration,
})
})
.collect();
Ok(json!({ "palace": palace, "gaps": payload }))
}
async fn handle_memory_recall_all(state: &AppState, args: Value) -> Result<Value> {
let query = args
.get("q")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("memory_recall_all: missing 'q'"))?;
let top_k = args.get("top_k").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
let deep = args.get("deep").and_then(|v| v.as_bool()).unwrap_or(false);
let root = state.data_root.clone();
let palaces = tokio::task::spawn_blocking(move || {
trusty_common::memory_core::PalaceRegistry::list_palaces(&root)
})
.await
.context("join list_palaces")??;
let mut handles = Vec::with_capacity(palaces.len());
for p in &palaces {
match state.registry.open_palace(&state.data_root, &p.id) {
Ok(h) => handles.push(h),
Err(e) => {
tracing::warn!(palace = %p.id, "memory_recall_all: open failed: {e:#}")
}
}
}
let embedder = state.embedder().await?;
let erased: std::sync::Arc<dyn trusty_common::memory_core::embed::Embedder + Send + Sync> =
embedder;
let results = recall_across_palaces(&handles, &erased, query, top_k, deep)
.await
.context("recall_across_palaces")?;
let payload: Vec<Value> = results
.iter()
.map(|r| {
json!({
"palace_id": r.palace_id,
"drawer_id": r.result.drawer.id.to_string(),
"content": r.result.drawer.content,
"importance": r.result.drawer.importance,
"tags": r.result.drawer.tags,
"score": r.result.score,
"layer": r.result.layer,
"drawer_type": r.result.drawer.drawer_type.as_str(),
})
})
.collect();
Ok(json!({ "query": query, "results": payload }))
}
async fn handle_get_prompt_context(state: &AppState, args: Value) -> Result<Value> {
let query = args
.get("query")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let cache_snapshot = {
let guard = state.prompt_context_cache.read().await;
guard.clone()
};
let body = if let Some(q) = query.as_deref() {
let needle = q.to_lowercase();
let filtered: Vec<(String, String, String)> = cache_snapshot
.triples
.into_iter()
.filter(|(subject, _predicate, object)| {
subject.to_lowercase().contains(&needle) || object.to_lowercase().contains(&needle)
})
.collect();
let formatted = crate::prompt_facts::build_prompt_context(&filtered);
if formatted.is_empty() {
"No project context found matching your query.".to_string()
} else {
formatted
}
} else if cache_snapshot.formatted.is_empty() {
"No prompt facts stored yet.".to_string()
} else {
cache_snapshot.formatted
};
Ok(Value::String(body))
}
async fn handle_discover_aliases(state: &AppState, args: Value) -> Result<Value> {
let palace = resolve_palace(state, &args, "discover_aliases")?;
let project_root = args
.get("project_root")
.and_then(|v| v.as_str())
.map(std::path::PathBuf::from)
.or_else(|| std::env::current_dir().ok())
.ok_or_else(|| anyhow!("discover_aliases: no project_root and cwd unavailable"))?;
let discoveries = crate::discovery::discover_project_aliases(&project_root).await?;
let handle = open_palace_handle(state, &palace)?;
let mut already_known = 0usize;
let mut newly_asserted = 0usize;
let mut reported: Vec<Value> = Vec::with_capacity(discoveries.len());
for d in &discoveries {
let active = handle
.kg
.query_active(&d.short)
.await
.context("kg.query_active")?;
let exists = active
.iter()
.any(|t| t.predicate == "is_alias_for" && t.object == d.full);
if exists {
already_known += 1;
continue;
}
let triple = Triple {
subject: d.short.clone(),
predicate: "is_alias_for".to_string(),
object: d.full.clone(),
valid_from: chrono::Utc::now(),
valid_to: None,
confidence: 1.0,
provenance: Some(format!("discover_aliases:{}", d.source.as_str())),
};
handle
.kg
.assert(triple)
.await
.context("kg.assert (discover)")?;
newly_asserted += 1;
reported.push(json!({
"short": d.short,
"full": d.full,
"source": d.source.as_str(),
}));
}
if newly_asserted > 0 {
if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
tracing::warn!("rebuild_prompt_cache after discover_aliases failed: {e:#}");
}
}
Ok(json!({
"discovered": reported,
"already_known": already_known,
"new": newly_asserted,
"palace": palace,
}))
}
async fn handle_kg_bootstrap(state: &AppState, args: Value) -> Result<Value> {
let palace = resolve_palace(state, &args, "kg_bootstrap")?;
let project_path = args
.get("project_path")
.and_then(|v| v.as_str())
.map(std::path::PathBuf::from);
let result = crate::bootstrap::bootstrap_palace(state, &palace, project_path.as_deref())
.await
.context("bootstrap_palace")?;
if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(state).await {
tracing::warn!("rebuild_prompt_cache after kg_bootstrap failed: {e:#}");
}
crate::bootstrap::result_to_json(&result)
}
async fn handle_memory_send_message(state: &AppState, args: Value) -> Result<Value> {
let to_palace = args
.get("to_palace")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("memory_send_message: missing 'to_palace'"))?
.to_string();
let purpose = args
.get("purpose")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("memory_send_message: missing 'purpose'"))?
.to_string();
let content = args
.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("memory_send_message: missing 'content'"))?
.to_string();
let from_palace = if let Some(s) = args.get("from_palace").and_then(|v| v.as_str()) {
s.to_string()
} else if let Some(d) = state.default_palace.clone() {
d
} else {
crate::messaging::cwd_palace_slug()
.context("memory_send_message: derive from_palace from cwd")?
};
let drawer_id = crate::messaging::send_message_to_palace(
&state.registry,
&state.data_root,
&from_palace,
&to_palace,
&purpose,
content,
CreatorInfo::new_self(MCP_CLIENT_NAME, CreatorSource::Mcp),
)
.await
.context("memory_send_message")?;
Ok(json!({
"drawer_id": drawer_id.to_string(),
"from_palace": from_palace,
"to_palace": to_palace,
"purpose": purpose,
"status": "sent",
}))
}
pub async fn dispatch_tool(state: &AppState, name: &str, args: Value) -> Result<Value> {
match name {
"memory_remember" => handle_memory_remember(state, args).await,
"memory_note" => handle_memory_note(state, args).await,
"memory_recall" => handle_memory_recall(state, args).await,
"memory_recall_deep" => handle_memory_recall_deep(state, args).await,
"palace_create" => handle_palace_create(state, args).await,
"palace_list" => handle_palace_list(state, args).await,
"palace_delete" => handle_palace_delete(state, args).await,
"palace_update" => handle_palace_update(state, args).await,
"kg_assert" => handle_kg_assert(state, args).await,
"add_alias" => handle_add_alias(state, args).await,
"list_prompt_facts" => handle_list_prompt_facts(state, args).await,
"remove_prompt_fact" => handle_remove_prompt_fact(state, args).await,
"kg_query" => handle_kg_query(state, args).await,
"memory_list" => handle_memory_list(state, args).await,
"memory_forget" => handle_memory_forget(state, args).await,
"palace_info" => handle_palace_info(state, args).await,
"palace_compact" => handle_palace_compact(state, args).await,
"kg_gaps" => handle_kg_gaps(state, args).await,
"memory_recall_all" => handle_memory_recall_all(state, args).await,
"get_prompt_context" => handle_get_prompt_context(state, args).await,
"discover_aliases" => handle_discover_aliases(state, args).await,
"kg_bootstrap" => handle_kg_bootstrap(state, args).await,
"memory_send_message" => handle_memory_send_message(state, args).await,
other => anyhow::bail!("unknown tool: {other}"),
}
}
fn bm25_data_dir_for_palace(state: &AppState, palace: &str) -> std::path::PathBuf {
state.data_root.join(palace).join("bm25")
}
async fn ensure_bm25_running_for_palace(state: &AppState, palace: &str) -> bool {
let Some(supervisor) = state.bm25_supervisor.as_ref() else {
return true;
};
let data_dir = bm25_data_dir_for_palace(state, palace);
match supervisor.ensure_running(palace, &data_dir).await {
Ok(_socket) => true,
Err(e) => {
tracing::warn!(
palace = %palace,
"bm25 supervisor could not start daemon (degrading to vector-only): {e:#}"
);
false
}
}
}
pub const BM25_INDEX_QUEUE_CAPACITY: usize = 256;
#[derive(Debug)]
pub struct Bm25IndexRequest {
pub palace: String,
pub drawer_id: String,
pub content: String,
pub data_dir: std::path::PathBuf,
}
pub fn spawn_bm25_index_worker(
mut rx: tokio::sync::mpsc::Receiver<Bm25IndexRequest>,
client: Option<std::sync::Arc<trusty_common::bm25_client::Bm25Client>>,
supervisor: Option<std::sync::Arc<crate::bm25_supervisor::Bm25Supervisor>>,
) {
tokio::spawn(async move {
while let Some(req) = rx.recv().await {
let Some(client) = client.as_ref() else {
continue;
};
if let Some(sup) = supervisor.as_ref() {
if let Err(e) = sup.ensure_running(&req.palace, &req.data_dir).await {
tracing::warn!(
palace = %req.palace,
"bm25 supervisor failed to start daemon for index (non-fatal): {e:#}"
);
continue;
}
}
if let Err(e) = client.index(&req.drawer_id, &req.content).await {
tracing::warn!(
palace = %req.palace,
drawer_id = %req.drawer_id,
"bm25 daemon index failed (non-fatal): {e:#}"
);
}
}
tracing::debug!("bm25 index worker exiting (channel closed)");
});
}
fn bm25_index_enqueue(state: &AppState, palace: &str, drawer_id: Uuid, content: &str) {
let req = Bm25IndexRequest {
palace: palace.to_string(),
drawer_id: drawer_id.to_string(),
content: content.to_string(),
data_dir: bm25_data_dir_for_palace(state, palace),
};
match state.bm25_index_tx.try_send(req) {
Ok(()) => {}
Err(tokio::sync::mpsc::error::TrySendError::Full(req)) => {
tracing::warn!(
palace = %req.palace,
drawer_id = %req.drawer_id,
"BM25 index queue full — skipping drawer {}",
req.drawer_id
);
}
Err(tokio::sync::mpsc::error::TrySendError::Closed(req)) => {
tracing::debug!(
palace = %req.palace,
drawer_id = %req.drawer_id,
"BM25 index queue closed — skipping drawer {}",
req.drawer_id
);
}
}
}
async fn bm25_search_optional(
state: &AppState,
palace: &str,
query: &str,
top_k: usize,
) -> Option<Vec<trusty_common::bm25_client::BM25Hit>> {
let client = state.bm25_client.as_ref()?;
if !ensure_bm25_running_for_palace(state, palace).await {
return None;
}
match client.search(query, top_k).await {
Ok(hits) => Some(hits),
Err(e) => {
tracing::warn!(
palace = %palace,
"bm25 daemon search failed (falling back to vector-only): {e:#}"
);
None
}
}
}
fn fuse_bm25_into_recall(
results: &mut Vec<trusty_common::memory_core::retrieval::RecallResult>,
bm25_hits: &[trusty_common::bm25_client::BM25Hit],
top_k: usize,
) {
const RRF_K: f32 = 60.0;
if bm25_hits.is_empty() {
return;
}
for (rank, hit) in bm25_hits.iter().enumerate() {
let bonus = 1.0 / (RRF_K + rank as f32 + 1.0);
if let Some(existing) = results
.iter_mut()
.find(|r| r.drawer.id.to_string() == hit.doc_id)
{
existing.score += bonus;
}
}
results.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.layer.cmp(&b.layer))
});
results.truncate(top_k);
}
fn serialize_recall(
palace: &str,
query: &str,
results: Vec<trusty_common::memory_core::retrieval::RecallResult>,
) -> Value {
let payload: Vec<Value> = results
.iter()
.map(|r| {
json!({
"drawer_id": r.drawer.id.to_string(),
"content": r.drawer.content,
"score": r.score,
"layer": r.layer,
"tags": r.drawer.tags,
"importance": r.drawer.importance,
"drawer_type": r.drawer.drawer_type.as_str(),
})
})
.collect();
json!({
"palace": palace,
"query": query,
"results": payload,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::AppState;
fn test_state() -> (AppState, tempfile::TempDir) {
unsafe {
std::env::set_var("TRUSTY_SKIP_PALACE_ENFORCEMENT", "1");
}
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().to_path_buf();
(AppState::new(root), tmp)
}
#[test]
fn tool_definitions_drops_palace_required_when_default_set() {
let with_default = tool_definitions_with(true);
let without_default = tool_definitions_with(false);
for (name, palace_required_when_no_default) in [
("memory_remember", true),
("memory_recall", true),
("memory_recall_deep", true),
("memory_list", true),
("memory_forget", true),
("palace_info", true),
("palace_compact", true),
("kg_assert", true),
("kg_query", true),
] {
for (defs, has_default) in [(&with_default, true), (&without_default, false)] {
let tools = defs["tools"].as_array().unwrap();
let tool = tools.iter().find(|t| t["name"] == name).unwrap();
let required: Vec<&str> = tool["inputSchema"]["required"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
let palace_required = required.contains(&"palace");
let expected = palace_required_when_no_default && !has_default;
assert_eq!(
palace_required, expected,
"tool={name} has_default={has_default} required={required:?}"
);
}
}
}
#[test]
fn tool_definitions_lists_all_tools() {
let defs = tool_definitions();
let tools = defs
.get("tools")
.and_then(|t| t.as_array())
.expect("tools array");
assert_eq!(tools.len(), 23);
let names: Vec<&str> = tools
.iter()
.filter_map(|t| t.get("name").and_then(|n| n.as_str()))
.collect();
for expected in [
"memory_remember",
"memory_note",
"memory_recall",
"memory_recall_deep",
"memory_list",
"memory_forget",
"palace_create",
"palace_delete",
"palace_update",
"palace_list",
"palace_info",
"palace_compact",
"kg_assert",
"kg_query",
"memory_recall_all",
"kg_gaps",
"add_alias",
"list_prompt_facts",
"remove_prompt_fact",
"get_prompt_context",
"discover_aliases",
"kg_bootstrap",
"memory_send_message",
] {
assert!(names.contains(&expected), "missing tool: {expected}");
}
}
#[tokio::test]
async fn dispatch_palace_create_persists() {
let (state, _tmp) = test_state();
let created = dispatch_tool(&state, "palace_create", json!({"name": "alpha"}))
.await
.expect("palace_create");
assert_eq!(created["palace_id"], "alpha");
let listed = dispatch_tool(&state, "palace_list", json!({}))
.await
.expect("palace_list");
let ids = listed["palaces"].as_array().expect("palaces array");
assert!(ids.iter().any(|v| v.as_str() == Some("alpha")));
}
#[tokio::test]
async fn dispatch_remember_then_recall() {
let (state, _tmp) = test_state();
let _ = dispatch_tool(&state, "palace_create", json!({"name": "beta"}))
.await
.expect("palace_create");
let remembered = dispatch_tool(
&state,
"memory_remember",
json!({
"palace": "beta",
"text": "Quokkas are the happiest marsupials in Australia by general consensus",
"room": "General",
"tags": ["wildlife"],
}),
)
.await
.expect("memory_remember");
assert!(remembered["drawer_id"].as_str().is_some());
let recalled = dispatch_tool(
&state,
"memory_recall",
json!({"palace": "beta", "query": "Quokkas marsupials Australia", "top_k": 5}),
)
.await
.expect("memory_recall");
let results = recalled["results"].as_array().expect("results");
assert!(
results
.iter()
.any(|r| r["content"].as_str().unwrap_or("").contains("Quokkas")),
"expected to recall the Quokkas drawer; got {results:?}"
);
}
#[tokio::test]
async fn auto_kg_extraction_hooks_into_memory_remember() {
let (state, _tmp) = test_state();
let _ = dispatch_tool(&state, "palace_create", json!({"name": "kgauto"}))
.await
.expect("palace_create");
let _ = dispatch_tool(
&state,
"memory_remember",
json!({
"palace": "kgauto",
"text": "Rustc is a compiler for the Rust language; tracks #performance",
"room": "Backend",
"tags": ["compiler", "language"],
}),
)
.await
.expect("memory_remember");
let handle = open_palace_handle(&state, "kgauto").expect("open palace");
let triples = handle.kg.list_active(1000, 0).await.expect("list_active");
let auto: Vec<_> = triples
.iter()
.filter(|t| t.provenance.as_deref() == Some(crate::kg_extract::AUTO_PROVENANCE))
.collect();
assert!(
!auto.is_empty(),
"expected at least one auto-extracted triple after memory_remember; got: {triples:?}"
);
assert!(
auto.iter()
.any(|t| t.subject == "tag:compiler" && t.predicate == "tags"),
"expected tag:compiler edge in auto subset: {auto:?}"
);
assert!(
auto.iter()
.any(|t| t.subject == "tag:language" && t.predicate == "tags"),
"expected tag:language edge in auto subset: {auto:?}"
);
assert!(
auto.iter()
.any(|t| t.subject == "room:Backend" && t.predicate == "contains"),
"expected room:Backend edge in auto subset: {auto:?}"
);
assert!(
auto.iter().any(|t| t.predicate == "mentioned-in"),
"expected at least one #hashtag mention triple in auto subset: {auto:?}"
);
}
#[tokio::test]
async fn auto_kg_extraction_no_op_does_not_fail_remember() {
let (state, _tmp) = test_state();
let _ = dispatch_tool(&state, "palace_create", json!({"name": "kgnoop"}))
.await
.expect("palace_create");
let res = dispatch_tool(
&state,
"memory_remember",
json!({
"palace": "kgnoop",
"text": "The quick brown fox jumped over the lazy dog repeatedly",
}),
)
.await
.expect("memory_remember should succeed even when extraction yields nothing");
assert!(res["drawer_id"].as_str().is_some());
}
#[tokio::test]
async fn dispatch_kg_assert_then_query() {
let (state, _tmp) = test_state();
let _ = dispatch_tool(&state, "palace_create", json!({"name": "gamma"}))
.await
.expect("palace_create");
let _ = dispatch_tool(
&state,
"kg_assert",
json!({
"palace": "gamma",
"subject": "alice",
"predicate": "works_at",
"object": "Acme",
"confidence": 0.9,
"provenance": "test",
}),
)
.await
.expect("kg_assert");
let queried = dispatch_tool(
&state,
"kg_query",
json!({"palace": "gamma", "subject": "alice"}),
)
.await
.expect("kg_query");
let triples = queried["triples"].as_array().expect("triples array");
assert_eq!(triples.len(), 1);
assert_eq!(triples[0]["object"], "Acme");
assert_eq!(triples[0]["predicate"], "works_at");
}
#[tokio::test]
async fn dispatch_kg_gaps_returns_cached() {
use trusty_common::memory_core::community::KnowledgeGap;
let (state, _tmp) = test_state();
let _ = dispatch_tool(&state, "palace_create", json!({"name": "delta"}))
.await
.expect("palace_create");
let initial = dispatch_tool(&state, "kg_gaps", json!({"palace": "delta"}))
.await
.expect("kg_gaps empty");
let gaps = initial["gaps"].as_array().expect("gaps array");
assert_eq!(gaps.len(), 0);
state.registry.set_gaps(
PalaceId::new("delta"),
vec![KnowledgeGap {
entities: vec!["x".to_string(), "y".to_string()],
internal_density: 0.05,
external_bridges: 0,
suggested_exploration: "Explore connections between x and y".to_string(),
}],
);
let seeded = dispatch_tool(&state, "kg_gaps", json!({"palace": "delta"}))
.await
.expect("kg_gaps seeded");
let gaps = seeded["gaps"].as_array().expect("gaps array");
assert_eq!(gaps.len(), 1);
assert_eq!(gaps[0]["entities"][0], "x");
assert_eq!(gaps[0]["external_bridges"], 0);
assert!(gaps[0]["suggested_exploration"]
.as_str()
.unwrap()
.contains("x"));
}
#[tokio::test]
async fn add_alias_round_trip_through_prompt_cache() {
let _tmp = tempfile::tempdir().expect("tempdir");
let root = _tmp.path().to_path_buf();
let state = AppState::new(root).with_default_palace(Some("ctx".to_string()));
let _ = dispatch_tool(&state, "palace_create", json!({"name": "ctx"}))
.await
.expect("palace_create");
let added = dispatch_tool(
&state,
"add_alias",
json!({"short": "tga", "full": "trusty-git-analytics"}),
)
.await
.expect("add_alias");
assert_eq!(added["asserted"], true);
assert_eq!(added["short"], "tga");
let listed = dispatch_tool(&state, "list_prompt_facts", json!({}))
.await
.expect("list_prompt_facts");
let facts = listed["facts"].as_array().expect("facts array");
assert!(
facts.iter().any(|f| f["subject"] == "tga"
&& f["predicate"] == "is_alias_for"
&& f["object"] == "trusty-git-analytics"),
"expected tga alias in facts; got {facts:?}"
);
{
let guard = state.prompt_context_cache.read().await;
assert!(
guard.formatted.contains("tga → trusty-git-analytics"),
"prompt cache should contain alias; got: {}",
guard.formatted
);
}
let _ = dispatch_tool(
&state,
"add_alias",
json!({"short": "tm", "full": "trusty-memory", "extra": "the MCP frontend"}),
)
.await
.expect("add_alias with extra");
{
let guard = state.prompt_context_cache.read().await;
assert!(
guard
.formatted
.contains("tm → trusty-memory (the MCP frontend)"),
"alias with extra not formatted; got: {}",
guard.formatted
);
}
let removed = dispatch_tool(
&state,
"remove_prompt_fact",
json!({"subject": "tga", "predicate": "is_alias_for"}),
)
.await
.expect("remove_prompt_fact");
assert_eq!(removed["removed"], true);
{
let guard = state.prompt_context_cache.read().await;
assert!(
!guard.formatted.contains("tga → trusty-git-analytics"),
"retracted alias still in cache: {}",
guard.formatted
);
assert!(
guard.formatted.contains("tm → trusty-memory"),
"non-retracted alias missing from cache: {}",
guard.formatted
);
}
let missing = dispatch_tool(
&state,
"remove_prompt_fact",
json!({"subject": "nope", "predicate": "is_alias_for"}),
)
.await
.expect("remove_prompt_fact missing");
assert_eq!(missing["removed"], false);
}
#[tokio::test]
async fn get_prompt_context_serves_cache_and_filters() {
let (state, _tmp) = test_state();
let resp = dispatch_tool(&state, "get_prompt_context", json!({}))
.await
.expect("get_prompt_context empty");
assert_eq!(resp.as_str().unwrap(), "No prompt facts stored yet.");
{
let mut guard = state.prompt_context_cache.write().await;
let triples = vec![
(
"tga".to_string(),
"is_alias_for".to_string(),
"trusty-git-analytics".to_string(),
),
(
"tm".to_string(),
"is_alias_for".to_string(),
"trusty-memory".to_string(),
),
(
"fact-1".to_string(),
"is_fact".to_string(),
"MSRV is 1.88".to_string(),
),
];
let formatted = crate::prompt_facts::build_prompt_context(&triples);
*guard = crate::prompt_facts::PromptFactsCache { triples, formatted };
}
let resp = dispatch_tool(&state, "get_prompt_context", json!({}))
.await
.expect("get_prompt_context populated");
let text = resp.as_str().expect("string body");
assert!(text.contains("tga → trusty-git-analytics"));
assert!(text.contains("tm → trusty-memory"));
assert!(text.contains("MSRV is 1.88"));
let resp = dispatch_tool(&state, "get_prompt_context", json!({"query": "tga"}))
.await
.expect("get_prompt_context filtered");
let text = resp.as_str().expect("string body");
assert!(text.contains("tga → trusty-git-analytics"));
assert!(!text.contains("tm → trusty-memory"));
assert!(!text.contains("MSRV is 1.88"));
let resp = dispatch_tool(&state, "get_prompt_context", json!({"query": "MEMORY"}))
.await
.expect("get_prompt_context case-insensitive");
let text = resp.as_str().expect("string body");
assert!(text.contains("tm → trusty-memory"));
assert!(!text.contains("tga → trusty-git-analytics"));
let resp = dispatch_tool(
&state,
"get_prompt_context",
json!({"query": "zzz-nonexistent"}),
)
.await
.expect("get_prompt_context no-match");
assert_eq!(
resp.as_str().unwrap(),
"No project context found matching your query."
);
let resp = dispatch_tool(&state, "get_prompt_context", json!({"query": " "}))
.await
.expect("get_prompt_context whitespace");
let text = resp.as_str().expect("string body");
assert!(text.contains("tga → trusty-git-analytics"));
assert!(text.contains("tm → trusty-memory"));
}
#[tokio::test]
async fn dispatch_discover_aliases_inserts_new_and_dedupes() {
let _tmp = tempfile::tempdir().expect("tempdir");
let root = _tmp.path().to_path_buf();
let state = AppState::new(root).with_default_palace(Some("disc".to_string()));
let _ = dispatch_tool(&state, "palace_create", json!({"name": "disc"}))
.await
.expect("palace_create");
let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.expect("workspace root")
.to_path_buf();
let first = dispatch_tool(
&state,
"discover_aliases",
json!({"project_root": workspace_root.to_string_lossy()}),
)
.await
.expect("discover_aliases first");
let new_count = first["new"].as_u64().expect("new is u64");
assert!(new_count > 0, "expected new discoveries on first call");
let discovered = first["discovered"].as_array().expect("discovered array");
assert!(
discovered
.iter()
.any(|d| d["short"] == "tga" && d["full"] == "trusty-git-analytics"),
"expected tga alias in discoveries; got {discovered:?}"
);
{
let guard = state.prompt_context_cache.read().await;
assert!(
guard.formatted.contains("tga → trusty-git-analytics"),
"prompt cache missing tga alias after discover_aliases; got: {}",
guard.formatted
);
}
let second = dispatch_tool(
&state,
"discover_aliases",
json!({"project_root": workspace_root.to_string_lossy()}),
)
.await
.expect("discover_aliases second");
assert_eq!(second["new"].as_u64(), Some(0), "expected 0 new on rerun");
let already_known = second["already_known"].as_u64().expect("already_known");
assert!(
already_known >= new_count,
"expected already_known >= {new_count}, got {already_known}"
);
}
#[tokio::test]
async fn palace_create_auto_seeds_temporal_metadata() {
let (state, _tmp) = test_state();
let created = dispatch_tool(&state, "palace_create", json!({"name": "auto"}))
.await
.expect("palace_create");
assert_eq!(created["palace_id"], "auto");
let summary = &created["bootstrap"];
assert!(summary.is_object(), "expected bootstrap summary object");
assert!(summary["triples_asserted"].as_u64().unwrap_or(0) >= 2);
let queried = dispatch_tool(
&state,
"kg_query",
json!({"palace": "auto", "subject": "auto"}),
)
.await
.expect("kg_query");
let triples = queried["triples"].as_array().expect("triples");
let predicates: Vec<&str> = triples
.iter()
.filter_map(|t| t["predicate"].as_str())
.collect();
assert!(
predicates.contains(&"created_at"),
"expected created_at after palace_create; got {predicates:?}",
);
assert!(
predicates.contains(&"bootstrapped_at"),
"expected bootstrapped_at after palace_create; got {predicates:?}",
);
assert!(
queried.get("hint").is_none(),
"hint should be absent when triples exist"
);
}
#[tokio::test]
async fn kg_query_emits_hint_when_palace_empty() {
let (state, _tmp) = test_state();
let _ = dispatch_tool(&state, "palace_create", json!({"name": "hinted"}))
.await
.expect("palace_create");
let queried = dispatch_tool(
&state,
"kg_query",
json!({"palace": "hinted", "subject": "unrelated-subject"}),
)
.await
.expect("kg_query");
assert_eq!(queried["triples"].as_array().unwrap().len(), 0);
let hint = queried["hint"].as_str().expect("hint field present");
assert!(hint.contains("kg_bootstrap"));
assert!(hint.contains("kg_assert"));
}
#[tokio::test]
async fn kg_bootstrap_seeds_workspace_facts() {
let (state, _tmp) = test_state();
let _ = dispatch_tool(&state, "palace_create", json!({"name": "ws"}))
.await
.expect("palace_create");
let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.expect("workspace root")
.to_path_buf();
let result = dispatch_tool(
&state,
"kg_bootstrap",
json!({"palace": "ws", "project_path": workspace_root.to_string_lossy()}),
)
.await
.expect("kg_bootstrap");
assert!(result["triples_asserted"].as_u64().unwrap() > 0);
let subject = result["project_subject"]
.as_str()
.expect("project_subject")
.to_string();
let queried = dispatch_tool(
&state,
"kg_query",
json!({"palace": "ws", "subject": subject}),
)
.await
.expect("kg_query");
let triples = queried["triples"].as_array().expect("triples");
let predicates: Vec<&str> = triples
.iter()
.filter_map(|t| t["predicate"].as_str())
.collect();
assert!(
predicates.contains(&"has_workspace_member") || predicates.contains(&"has_language"),
"expected workspace/language fact; got {predicates:?}",
);
assert!(
predicates.contains(&"source_repo"),
"expected source_repo from .git/config; got {predicates:?}",
);
assert!(predicates.contains(&"bootstrapped_at"));
}
#[test]
fn content_gate_blocks_short_no_context() {
assert_eq!(content_gate("yes", None), None);
assert_eq!(content_gate("ok", None), None);
assert_eq!(
content_gate(" no thanks ", None),
None,
"2 words still < 4"
);
assert_eq!(
content_gate("one two three", None),
None,
"3 words still < 4"
);
}
#[test]
fn content_gate_wraps_short_with_context() {
let combined = content_gate(
"yes",
Some("Do you want to enable auto-bootstrap on new palaces?"),
)
.expect("context should unlock the gate");
assert_eq!(
combined,
"Do you want to enable auto-bootstrap on new palaces?\n\n---\n\nyes",
);
let combined = content_gate(
"the quick brown fox jumps over the lazy dog",
Some("Famous typing pangram"),
)
.expect("long content + context still combines");
assert!(combined.starts_with("Famous typing pangram"));
assert!(combined.contains("\n\n---\n\n"));
assert!(combined.ends_with("the quick brown fox jumps over the lazy dog"));
}
#[test]
fn content_gate_keeps_long() {
let body = "User prefers snake_case for python";
let kept = content_gate(body, None).expect(">= 4 words passes");
assert_eq!(kept, body, "passing content must round-trip verbatim");
let boundary = "one two three four";
assert_eq!(content_gate(boundary, None).as_deref(), Some(boundary));
}
#[test]
fn content_gate_blank_context_treated_as_none() {
assert_eq!(content_gate("yes", Some("")), None);
assert_eq!(content_gate("yes", Some(" ")), None);
assert_eq!(content_gate("yes", Some("\n\t")), None);
}
#[tokio::test]
async fn dispatch_remember_skips_short_no_context() {
let (state, _tmp) = test_state();
let _ = dispatch_tool(&state, "palace_create", json!({"name": "gate"}))
.await
.expect("palace_create");
let res = dispatch_tool(
&state,
"memory_remember",
json!({"palace": "gate", "text": "yes"}),
)
.await
.expect("memory_remember (short)");
assert_eq!(res["status"], "skipped");
assert!(res["reason"]
.as_str()
.unwrap_or("")
.contains("content gate"));
let listed = dispatch_tool(
&state,
"memory_list",
json!({"palace": "gate", "limit": 10}),
)
.await
.expect("memory_list");
let drawers = listed["drawers"].as_array().expect("drawers array");
assert!(
drawers.is_empty(),
"no drawer should be written; got {drawers:?}"
);
}
#[tokio::test]
async fn dispatch_remember_with_context_writes_combined() {
let (state, _tmp) = test_state();
let _ = dispatch_tool(&state, "palace_create", json!({"name": "ctxgate"}))
.await
.expect("palace_create");
let res = dispatch_tool(
&state,
"memory_remember",
json!({
"palace": "ctxgate",
"text": "yes",
"context": "Do you want to enable auto-bootstrap on new palaces?",
"force": true,
}),
)
.await
.expect("memory_remember (with context)");
assert_eq!(res["status"], "stored");
let listed = dispatch_tool(
&state,
"memory_list",
json!({"palace": "ctxgate", "limit": 10}),
)
.await
.expect("memory_list");
let drawers = listed["drawers"].as_array().expect("drawers array");
assert_eq!(drawers.len(), 1);
let body = drawers[0]["content"].as_str().expect("content");
assert!(body.starts_with("Do you want to enable auto-bootstrap"));
assert!(body.contains("\n\n---\n\n"));
assert!(body.ends_with("yes"));
}
#[tokio::test]
async fn dispatch_note_skips_short_no_context() {
let (state, _tmp) = test_state();
let _ = dispatch_tool(&state, "palace_create", json!({"name": "noteg"}))
.await
.expect("palace_create");
let res = dispatch_tool(
&state,
"memory_note",
json!({"palace": "noteg", "content": "ok"}),
)
.await
.expect("memory_note (short)");
assert_eq!(res["status"], "skipped");
let listed = dispatch_tool(
&state,
"memory_list",
json!({"palace": "noteg", "limit": 10}),
)
.await
.expect("memory_list");
assert!(listed["drawers"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn dispatch_unknown_tool_errors() {
let (state, _tmp) = test_state();
let err = dispatch_tool(&state, "does_not_exist", json!({}))
.await
.expect_err("should error");
assert!(err.to_string().contains("unknown tool"));
}
#[test]
fn blocklist_gate_blocks_tool_use() {
assert!(blocklist_gate("Tool use: Bash"));
assert!(blocklist_gate(
"Tool use: Edit File: /Users/me/Projects/foo/bar.rs"
));
assert!(blocklist_gate(" Tool use: Read"));
}
#[test]
fn blocklist_gate_blocks_session_ended() {
assert!(blocklist_gate(
"Claude Code session ended: 1d2c3b4a-0000-0000-0000-000000000000"
));
assert!(blocklist_gate("Claude Code session started"));
}
#[test]
fn blocklist_gate_passes_normal_content() {
assert!(!blocklist_gate("User prefers snake_case for python"));
assert!(!blocklist_gate(
"Quokkas are the happiest marsupials in Australia"
));
assert!(!blocklist_gate("Note: refactor the dispatcher next sprint"));
assert!(blocklist_gate("I used Tool use: Bash here"));
}
#[tokio::test]
async fn dedup_skips_near_duplicate() {
let (state, _tmp) = test_state();
let _ = dispatch_tool(&state, "palace_create", json!({"name": "dedup1"}))
.await
.expect("palace_create");
let _ = dispatch_tool(
&state,
"memory_remember",
json!({
"palace": "dedup1",
"text": "The quick brown fox jumped over the lazy dog repeatedly today",
}),
)
.await
.expect("memory_remember seed");
let handle = open_palace_handle(&state, "dedup1").expect("open handle");
assert!(
dedup_gate(
&handle,
"The quick brown fox jumped over the lazy dog repeatedly yesterday"
),
"near-duplicate should be detected"
);
assert!(
dedup_gate(
&handle,
"The quick brown fox jumped over the lazy dog repeatedly today"
),
"exact match should be detected"
);
}
#[tokio::test]
async fn dedup_allows_different_content() {
let (state, _tmp) = test_state();
let _ = dispatch_tool(&state, "palace_create", json!({"name": "dedup2"}))
.await
.expect("palace_create");
let _ = dispatch_tool(
&state,
"memory_remember",
json!({
"palace": "dedup2",
"text": "Quokkas are the happiest marsupials in Australia by general consensus",
}),
)
.await
.expect("memory_remember seed");
let handle = open_palace_handle(&state, "dedup2").expect("open handle");
assert!(
!dedup_gate(
&handle,
"Rust is a systems programming language focused on safety and concurrency"
),
"unrelated content should pass the dedup gate"
);
assert!(!dedup_gate(&handle, " "));
}
#[tokio::test]
async fn dedup_gate_blocks_concurrent_duplicate_writes() {
let (state, _tmp) = test_state();
let state = std::sync::Arc::new(state);
let _ = dispatch_tool(&state, "palace_create", json!({"name": "dedup_race"}))
.await
.expect("palace_create");
let text =
"Concurrent identical writes must collapse to a single drawer under the dedup gate";
let s1 = state.clone();
let t1 = tokio::spawn(async move {
dispatch_tool(
&s1,
"memory_remember",
json!({"palace": "dedup_race", "text": text}),
)
.await
});
let s2 = state.clone();
let t2 = tokio::spawn(async move {
dispatch_tool(
&s2,
"memory_remember",
json!({"palace": "dedup_race", "text": text}),
)
.await
});
let r1 = t1.await.expect("join t1").expect("dispatch t1");
let r2 = t2.await.expect("join t2").expect("dispatch t2");
let statuses = [
r1["status"].as_str().unwrap_or(""),
r2["status"].as_str().unwrap_or(""),
];
let stored = statuses.iter().filter(|s| **s == "stored").count();
let skipped = statuses.iter().filter(|s| **s == "skipped").count();
assert_eq!(
stored, 1,
"exactly one concurrent write should be stored; got responses {r1:?} {r2:?}"
);
assert_eq!(
skipped, 1,
"exactly one concurrent write should be skipped; got responses {r1:?} {r2:?}"
);
let skipped_reason = if r1["status"] == "skipped" {
r1["reason"].as_str().unwrap_or("")
} else {
r2["reason"].as_str().unwrap_or("")
};
assert!(
skipped_reason.contains("duplicate within window"),
"skipped envelope should cite dedup reason; got {skipped_reason:?}"
);
let listed = dispatch_tool(
&state,
"memory_list",
json!({"palace": "dedup_race", "limit": 10}),
)
.await
.expect("memory_list");
let drawers = listed["drawers"].as_array().expect("drawers array");
assert_eq!(
drawers.len(),
1,
"only one drawer should be persisted after concurrent identical writes; got {drawers:?}"
);
}
#[tokio::test]
async fn dispatch_remember_blocks_blocklist_pattern() {
let (state, _tmp) = test_state();
let _ = dispatch_tool(&state, "palace_create", json!({"name": "blk"}))
.await
.expect("palace_create");
let res = dispatch_tool(
&state,
"memory_remember",
json!({"palace": "blk", "text": "Tool use: Bash"}),
)
.await
.expect("memory_remember (blocked)");
assert_eq!(res["status"], "skipped");
assert!(
res["reason"]
.as_str()
.unwrap_or("")
.contains("blocked pattern"),
"reason should mention blocked pattern; got {res:?}"
);
let listed = dispatch_tool(&state, "memory_list", json!({"palace": "blk", "limit": 10}))
.await
.expect("memory_list");
let drawers = listed["drawers"].as_array().expect("drawers array");
assert!(drawers.is_empty(), "no drawer should be written");
}
#[tokio::test]
async fn bm25_index_queue_drops_when_full() {
let (mut state, _tmp) = test_state();
let (tx, _rx_held) =
tokio::sync::mpsc::channel::<Bm25IndexRequest>(BM25_INDEX_QUEUE_CAPACITY);
state.bm25_index_tx = tx;
for i in 0..BM25_INDEX_QUEUE_CAPACITY {
bm25_index_enqueue(
&state,
"default",
Uuid::new_v4(),
&format!("filler content {i}"),
);
}
assert_eq!(
state.bm25_index_tx.capacity(),
0,
"after filling, sender capacity must be 0"
);
for i in 0..16 {
bm25_index_enqueue(
&state,
"default",
Uuid::new_v4(),
&format!("overflow content {i}"),
);
}
let probe_req = Bm25IndexRequest {
palace: "default".to_string(),
drawer_id: Uuid::new_v4().to_string(),
content: "probe".to_string(),
data_dir: state.data_root.join("default").join("bm25"),
};
let probe = state.bm25_index_tx.try_send(probe_req);
match probe {
Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {}
other => panic!("expected Full overflow, got {other:?}"),
}
}
}