mod import;
mod lifecycle;
mod memory;
mod preferences;
mod query;
mod serialization;
mod status;
mod validation;
mod visualization;
use std::sync::atomic::AtomicU32;
use std::sync::Mutex;
use crate::Alaya;
use rmcp::{model::ServerInfo, schemars, tool, ServerHandler};
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct RememberParams {
#[schemars(description = "The message content to remember")]
pub content: String,
#[schemars(description = "Who said it: user, assistant, or system")]
pub role: String,
#[schemars(description = "Session ID to group related messages")]
pub session_id: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct RecallParams {
#[schemars(description = "What to search for in memory")]
pub query: String,
#[schemars(description = "Maximum results to return (default: 5)")]
pub max_results: Option<usize>,
#[schemars(
description = "Category ID to boost in ranking (memories in this category score higher)"
)]
pub boost_category: Option<i64>,
#[schemars(
description = "Only return semantic nodes belonging to this category ID (strict filter)"
)]
pub category_id: Option<i64>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct PreferencesParams {
#[schemars(description = "Optional domain filter (e.g. style, tone, format)")]
pub domain: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct KnowledgeParams {
#[schemars(description = "Filter by type: fact, relationship, event, concept")]
pub node_type: Option<String>,
#[schemars(description = "Minimum confidence threshold (0.0 to 1.0)")]
pub min_confidence: Option<f32>,
#[schemars(description = "Maximum results to return (default: 20)")]
pub limit: Option<usize>,
#[schemars(description = "Filter by category label (exact match)")]
pub category: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct PurgeParams {
#[schemars(description = "Purge scope: session, older_than, or all")]
pub scope: String,
#[schemars(description = "Session ID (required when scope is session)")]
pub session_id: Option<String>,
#[schemars(description = "Unix timestamp (required when scope is older_than)")]
pub before_timestamp: Option<i64>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct CategoriesParams {
#[schemars(
description = "Minimum stability threshold (0.0 to 1.0). Categories below this are filtered out."
)]
pub min_stability: Option<f32>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct NeighborsParams {
#[schemars(description = "Node type: episode, semantic, preference, or category")]
pub node_type: String,
#[schemars(description = "The numeric ID of the node")]
pub node_id: i64,
#[schemars(description = "How many hops to traverse (default: 1)")]
pub depth: Option<u32>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct NodeCategoryParams {
#[schemars(description = "The numeric ID of the semantic node")]
pub node_id: i64,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct LearnFactEntry {
#[schemars(description = "The knowledge content")]
pub content: String,
#[schemars(description = "Type: fact, relationship, event, or concept")]
pub node_type: String,
#[schemars(description = "Confidence level 0.0-1.0 (default: 0.8)")]
pub confidence: Option<f32>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct LearnParams {
#[schemars(description = "Facts to learn: [{content, node_type, confidence?}]")]
pub facts: Vec<LearnFactEntry>,
#[schemars(
description = "Session ID to link facts to (episodes in this session become source episodes)"
)]
pub session_id: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct ImportClaudeMemParams {
#[schemars(description = "Path to claude-mem.db (default: ~/.claude-mem/claude-mem.db)")]
pub path: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct ImportClaudeCodeParams {
#[schemars(
description = "Path to Claude Code JSONL conversation file (e.g., ~/.claude/projects/-Users-me-myproject/{uuid}.jsonl)"
)]
pub path: String,
}
pub struct AlayaMcp {
store: Mutex<Alaya>,
pub(crate) episode_count: AtomicU32,
pub(crate) unconsolidated_count: AtomicU32,
}
#[cfg(not(tarpaulin_include))]
impl Clone for AlayaMcp {
fn clone(&self) -> Self {
panic!("AlayaMcp should not be cloned \u{2014} single-instance server")
}
}
impl AlayaMcp {
pub fn new(store: Alaya) -> Self {
Self {
store: Mutex::new(store),
episode_count: AtomicU32::new(0),
unconsolidated_count: AtomicU32::new(0),
}
}
pub(crate) fn with_store<F, T>(&self, f: F) -> Result<T, String>
where
F: FnOnce(&Alaya) -> crate::Result<T>,
{
let store = self.store.lock().map_err(|e| format!("lock error: {e}"))?;
f(&store).map_err(|e| format!("{e}"))
}
}
#[tool(tool_box)]
impl AlayaMcp {
#[tool(
description = "Store a conversation message in Alaya's episodic memory. Call this for each message in the conversation that should be remembered."
)]
fn remember(&self, #[tool(aggr)] params: RememberParams) -> String {
memory::handle_remember(self, params)
}
#[tool(
description = "Search Alaya's memory using hybrid retrieval (BM25 + vector + graph + RRF fusion). Returns the most relevant memories matching the query."
)]
fn recall(&self, #[tool(aggr)] params: RecallParams) -> String {
memory::handle_recall(self, params)
}
#[tool(
description = "Get Alaya memory statistics: episode counts, knowledge breakdown by type, categories, preferences, graph links with strongest connection, and embedding coverage."
)]
fn status(&self) -> String {
status::handle_status(self)
}
#[tool(
description = "Get crystallized user preferences learned from past interactions. Optionally filter by domain (e.g. 'style', 'tone', 'format')."
)]
fn preferences(&self, #[tool(aggr)] params: PreferencesParams) -> String {
preferences::handle_preferences(self, params)
}
#[tool(
description = "Get distilled semantic knowledge (facts, relationships, events, concepts) extracted from past conversations."
)]
fn knowledge(&self, #[tool(aggr)] params: KnowledgeParams) -> String {
query::handle_knowledge(self, params)
}
#[tool(
description = "Run memory maintenance: deduplicates nodes, prunes weak links, decays stale preferences. Call periodically to keep memory healthy."
)]
fn maintain(&self) -> String {
lifecycle::handle_maintain(self)
}
#[tool(
description = "List emergent categories discovered from semantic knowledge clusters. Categories form automatically and evolve through use."
)]
fn categories(&self, #[tool(aggr)] params: CategoriesParams) -> String {
query::handle_categories(self, params)
}
#[tool(
description = "Get graph neighbors of a memory node via spreading activation. Shows connected memories with link weights."
)]
fn neighbors(&self, #[tool(aggr)] params: NeighborsParams) -> String {
query::handle_neighbors(self, params)
}
#[tool(
description = "Get which category a semantic knowledge node belongs to. Returns the category or 'uncategorized'."
)]
fn node_category(&self, #[tool(aggr)] params: NodeCategoryParams) -> String {
query::handle_node_category(self, params)
}
#[tool(
description = "Teach Alaya extracted knowledge directly. The agent extracts facts from conversation and calls this tool. Each fact becomes a semantic node with full lifecycle wiring (strength, categories, graph links)."
)]
fn learn(&self, #[tool(aggr)] params: LearnParams) -> String {
preferences::handle_learn(self, params)
}
#[tool(
description = "Import memories from claude-mem (claude-mem.db SQLite database). Reads observations and converts facts/concepts into Alaya semantic nodes."
)]
fn import_claude_mem(&self, #[tool(aggr)] params: ImportClaudeMemParams) -> String {
import::handle_import_claude_mem(self, params)
}
#[tool(
description = "Import conversation history from Claude Code JSONL files. Reads messages and stores them as episodes."
)]
fn import_claude_code(&self, #[tool(aggr)] params: ImportClaudeCodeParams) -> String {
import::handle_import_claude_code(self, params)
}
#[tool(
description = "Purge memories. Scope: 'session' (requires session_id), 'older_than' (requires before_timestamp), or 'all' (deletes everything)."
)]
fn purge(&self, #[tool(aggr)] params: PurgeParams) -> String {
lifecycle::handle_purge(self, params)
}
#[tool(
description = "Run conflict detection and resolution on semantic knowledge. Finds contradictory facts via embedding similarity, resolves using the configured strategy (recency by default), and archives superseded nodes."
)]
fn reconcile_memories(&self) -> String {
lifecycle::handle_reconcile(self)
}
#[tool(
description = "List unresolved conflicts between semantic knowledge nodes. Use after reconcile with manual strategy, or to review detected contradictions."
)]
fn list_conflicts(&self) -> String {
lifecycle::handle_conflicts(self)
}
#[tool(
description = "Generate a Mermaid diagram of the memory graph showing episodes, knowledge, categories, and their connections. Returns a Mermaid graph definition that can be rendered visually."
)]
fn visualize(&self, #[tool(aggr)] params: visualization::VisualizeParams) -> String {
visualization::handle_visualize(self, params)
}
}
#[tool(tool_box)]
impl ServerHandler for AlayaMcp {
fn get_info(&self) -> ServerInfo {
ServerInfo {
instructions: Some(
"Alaya is a memory engine for AI agents. Use 'remember' to store messages, \
'recall' to search memory, 'learn' to teach extracted knowledge directly, \
'status' to check stats, 'preferences' for user preferences, 'knowledge' for \
semantic facts, 'categories' for emergent clusters, 'neighbors' for graph \
traversal, 'node_category' to check a node's category, 'maintain' for cleanup, \
'import_claude_mem' to import from claude-mem.db, \
'import_claude_code' to import from Claude Code JSONL files, \
'purge' to delete data, 'reconcile_memories' to detect and resolve \
contradictions, 'list_conflicts' to review unresolved conflicts, and \
'visualize' to generate a Mermaid diagram of the memory graph."
.into(),
),
..Default::default()
}
}
}
#[cfg(all(test, feature = "mcp"))]
mod tests {
use super::*;
fn make_server() -> AlayaMcp {
let store = Alaya::open_in_memory().unwrap();
AlayaMcp::new(store)
}
#[test]
fn get_info_returns_instructions() {
use rmcp::ServerHandler;
let srv = make_server();
let info = srv.get_info();
let instructions = info.instructions.expect("should have instructions");
assert!(instructions.contains("Alaya is a memory engine"));
assert!(instructions.contains("remember"));
assert!(instructions.contains("recall"));
assert!(instructions.contains("learn"));
}
#[test]
fn full_lifecycle_remember_learn_recall() {
let srv = make_server();
srv.remember(RememberParams {
content: "The capital of France is Paris".into(),
role: "user".into(),
session_id: "geo".into(),
});
srv.remember(RememberParams {
content: "Paris has the Eiffel Tower".into(),
role: "assistant".into(),
session_id: "geo".into(),
});
let learn_result = srv.learn(LearnParams {
facts: vec![
LearnFactEntry {
content: "France capital is Paris".into(),
node_type: "fact".into(),
confidence: Some(0.95),
},
LearnFactEntry {
content: "Paris has Eiffel Tower".into(),
node_type: "fact".into(),
confidence: Some(0.9),
},
],
session_id: Some("geo".into()),
});
assert!(learn_result.contains("Learned 2 facts:"));
let recall_result = srv.recall(RecallParams {
query: "Paris France".into(),
max_results: Some(5),
boost_category: None,
});
assert!(recall_result.contains("Found"));
let status = srv.status();
assert!(status.contains("Episodes: 2"));
}
#[test]
fn visualize_tool_returns_mermaid() {
let srv = make_server();
srv.remember(RememberParams {
content: "User likes Rust".into(),
role: "user".into(),
session_id: "s1".into(),
});
let result = srv.visualize(visualization::VisualizeParams {
max_nodes: Some(10),
min_weight: Some(0.0),
});
assert!(result.contains("graph TD"), "should return a Mermaid graph");
}
}