use crate::AppState;
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 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. 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}
},
"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.",
"inputSchema": {
"type": "object",
"properties": {
"palace": {"type": "string"},
"content": {"type": "string", "description": "Brief fact to remember"},
"tags": {"type": "array", "items": {"type": "string"}}
},
"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": "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"],
}
}
]
})
}
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}"))
}
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)"))
}
pub async fn dispatch_tool(state: &AppState, name: &str, args: Value) -> Result<Value> {
match name {
"memory_remember" => {
let palace = resolve_palace(state, &args, "memory_remember")?;
let palace = palace.as_str();
let text = args
.get("text")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("memory_remember: missing 'text'"))?
.to_string();
let room = parse_room(args.get("room").and_then(|v| v.as_str()));
let tags: 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();
let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
let handle = open_palace_handle(state, palace)?;
let opts = mcp_remember_opts(force);
let drawer_id = handle
.remember_with_options(text, room, tags, 0.5, opts)
.await
.context("PalaceHandle::remember_with_options")?;
Ok(json!({
"drawer_id": drawer_id.to_string(),
"palace": palace,
"status": "stored",
}))
}
"memory_note" => {
let palace = resolve_palace(state, &args, "memory_note")?;
let palace = palace.as_str();
let content = args
.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("memory_note: missing 'content'"))?
.to_string();
let tags: 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();
let handle = open_palace_handle(state, palace)?;
let drawer_id = handle
.remember_with_options(
content,
RoomType::General,
tags,
1.0,
RememberOptions::note(),
)
.await
.context("PalaceHandle::remember_with_options (note)")?;
Ok(json!({
"drawer_id": drawer_id.to_string(),
"palace": palace,
"status": "stored",
"drawer_type": "UserFact",
}))
}
"memory_recall" => {
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 results = recall(&handle, embedder.as_ref(), query, top_k)
.await
.context("recall")?;
Ok(serialize_recall(&palace, query, results))
}
"memory_recall_deep" => {
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))
}
"palace_create" => {
let palace_name = args
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("palace_create: missing 'name'"))?;
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")?;
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,
}))
}
"palace_list" => {
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}))
}
"kg_assert" => {
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"}))
}
"add_alias" => {
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}))
}
"list_prompt_facts" => {
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}))
}
"remove_prompt_fact" => {
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"}))
}
}
"kg_query" => {
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)
}
"memory_list" => {
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}))
}
"memory_forget" => {
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")?;
Ok(json!({"status": "deleted", "drawer_id": drawer_id_str, "palace": palace}))
}
"palace_info" => {
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,
}))
}
"palace_compact" => {
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,
}))
}
"kg_gaps" => {
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 }))
}
"memory_recall_all" => {
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 }))
}
"get_prompt_context" => {
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()
.map_err(|e| anyhow!("prompt cache lock poisoned: {e}"))?;
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))
}
"discover_aliases" => {
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,
}))
}
"kg_bootstrap" => {
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)
}
other => anyhow::bail!("unknown tool: {other}"),
}
}
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 {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().to_path_buf();
std::mem::forget(tmp);
AppState::new(root)
}
#[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(), 20);
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_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",
] {
assert!(names.contains(&expected), "missing tool: {expected}");
}
}
#[tokio::test]
async fn dispatch_palace_create_persists() {
let state = 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 = 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 dispatch_kg_assert_then_query() {
let state = 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 = 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();
std::mem::forget(tmp);
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().expect("read lock");
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().expect("read lock");
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().expect("read lock");
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 = 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().expect("write lock");
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();
std::mem::forget(tmp);
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().expect("read lock");
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 = 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 = 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 = 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"));
}
#[tokio::test]
async fn dispatch_unknown_tool_errors() {
let state = test_state();
let err = dispatch_tool(&state, "does_not_exist", json!({}))
.await
.expect_err("should error");
assert!(err.to_string().contains("unknown tool"));
}
}