use std::sync::{Mutex, RwLock};
use plugmem_host::{
Database, DbEntry, DbName, Embedder, FactId, HostError, IfMissing, LinkInput, MaintenanceMode,
MaintenanceOptions, ReadOnlyDatabase, RecallQuery, RememberInput, UnlinkInput, Workspace,
};
use serde::Serialize;
use serde_json::{Value, json};
use crate::{messages, rpc};
pub struct ReaderShared {
db: RwLock<ReadOnlyDatabase>,
embedder: Mutex<Option<Box<dyn Embedder>>>,
}
impl ReaderShared {
pub fn new(db: ReadOnlyDatabase, embedder: Option<Box<dyn Embedder>>) -> Self {
Self {
db: RwLock::new(db),
embedder: Mutex::new(embedder),
}
}
}
const GENERATION: &str = "plugmem_generation";
const REFRESH: &str = "plugmem_refresh";
const REMEMBER: &str = "plugmem_remember";
const RECALL: &str = "plugmem_recall";
const REVISE: &str = "plugmem_revise";
const FORGET: &str = "plugmem_forget";
const LINK: &str = "plugmem_link";
const UNLINK: &str = "plugmem_unlink";
const SHOW: &str = "plugmem_show";
const STATS: &str = "plugmem_stats";
const EXPORT: &str = "plugmem_export";
const MAINTAIN: &str = "plugmem_maintain";
const CHECKPOINT: &str = "plugmem_checkpoint";
const VERIFY: &str = "plugmem_verify";
const VERSION: &str = "plugmem_version";
const ABOUT: &str = "plugmem_about";
const SETTINGS_HELP: &str = "plugmem_settings_help";
pub fn definitions() -> Vec<Value> {
vec![
remember_def(),
recall_def(),
revise_def(),
forget_def(),
link_def(),
unlink_def(),
show_def(),
stats_def(),
export_def(),
maintain_def(),
checkpoint_def(),
verify_def(),
version_def(),
about_def(),
settings_help_def(),
]
}
const STATELESS_TOOLS: &[&str] = &[VERSION, ABOUT, SETTINGS_HELP];
const DATABASE_TOOLS: &[&str] = &[
REMEMBER, RECALL, REVISE, FORGET, LINK, UNLINK, SHOW, STATS, EXPORT, MAINTAIN, CHECKPOINT,
VERIFY,
];
fn stateless(name: &str, id: &Value, args: Option<&Value>) -> Option<Value> {
let id = id.clone();
match name {
VERSION => Some(rpc::tool_result(
id,
format!("plugmem {}", env!("CARGO_PKG_VERSION")),
false,
)),
ABOUT => Some(rpc::tool_result(
id,
messages::ABOUT_TOOL.to_string(),
false,
)),
SETTINGS_HELP => Some(rpc::tool_result(
id,
render(&settings_help_value(), format_arg(args)),
false,
)),
_ => None,
}
}
pub fn call(db: &Database, id: Value, params: Option<&Value>) -> Value {
let Some(params) = params else {
return rpc::error(id, -32602, "missing params");
};
let name = params.get("name").and_then(Value::as_str).unwrap_or("");
let args = params.get("arguments");
if let Some(reply) = stateless(name, &id, args) {
return reply;
}
match name {
REMEMBER => remember(db, id, args, None),
RECALL => recall(db, id, args),
REVISE => revise(db, id, args),
FORGET => forget(db, id, args),
LINK => link(db, id, args),
UNLINK => unlink(db, id, args),
SHOW => show(db, id, args),
STATS => rpc::tool_result(id, render(&db.stats(), format_arg(args)), false),
EXPORT => rpc::tool_result(id, render(&db.export(), format_arg(args)), false),
MAINTAIN => match maintenance_options(args) {
Ok(options) => match db.maintain_with_options(now_ms(), options) {
Ok(report) => rpc::tool_result(id, render(&report, format_arg(args)), false),
Err(e) => tool_error(id, &e),
},
Err(message) => rpc::tool_result(id, message, true),
},
CHECKPOINT => match db.checkpoint(now_ms()) {
Ok(()) => rpc::tool_result(id, render(&json!({ "ok": true }), format_arg(args)), false),
Err(e) => tool_error(id, &e),
},
VERIFY => match db.verify() {
Ok(()) => rpc::tool_result(id, render(&json!({ "ok": true }), format_arg(args)), false),
Err(e) => tool_error(id, &e),
},
other => rpc::tool_result(id, format!("unknown tool: {other}"), true),
}
}
pub fn definitions_ro() -> Vec<Value> {
vec![
recall_def(),
show_def(),
stats_def(),
export_def(),
verify_def(),
format_only_def(GENERATION, messages::GENERATION_TOOL),
format_only_def(REFRESH, messages::REFRESH_TOOL),
version_def(),
about_def(),
settings_help_def(),
]
}
pub fn call_ro(reader: &ReaderShared, id: Value, params: Option<&Value>) -> Value {
let Some(params) = params else {
return rpc::error(id, -32602, "missing params");
};
let name = params.get("name").and_then(Value::as_str).unwrap_or("");
let args = params.get("arguments");
if let Some(reply) = stateless(name, &id, args) {
return reply;
}
match name {
RECALL => recall_ro(reader, id, args),
SHOW => {
let db = reader.db.read().expect("snapshot lock");
match db.get(FactId(id_arg(args))) {
Some(snap) => rpc::tool_result(id, render(&snap, format_arg(args)), false),
None => rpc::tool_result(id, format!("fact {} does not exist", id_arg(args)), true),
}
}
STATS => {
let stats = reader.db.read().expect("snapshot lock").stats();
rpc::tool_result(id, render(&stats, format_arg(args)), false)
}
EXPORT => {
let facts = reader.db.read().expect("snapshot lock").export();
rpc::tool_result(id, render(&facts, format_arg(args)), false)
}
VERIFY => match reader.db.read().expect("snapshot lock").verify() {
Ok(()) => rpc::tool_result(id, render(&json!({ "ok": true }), format_arg(args)), false),
Err(e) => tool_error(id, &e),
},
GENERATION => {
let generation = reader.db.read().expect("snapshot lock").generation();
rpc::tool_result(
id,
render(&json!({ "generation": generation }), format_arg(args)),
false,
)
}
REFRESH => {
let mut db = reader.db.write().expect("snapshot lock");
match db.refresh() {
Ok(moved) => {
let generation = db.generation();
rpc::tool_result(
id,
render(
&json!({ "refreshed": moved, "generation": generation }),
format_arg(args),
),
false,
)
}
Err(e) => tool_error(id, &e),
}
}
REMEMBER | REVISE | FORGET | LINK | MAINTAIN | CHECKPOINT => {
rpc::tool_result(id, messages::READ_ONLY_REFUSAL.into(), true)
}
SETTINGS_HELP => {
rpc::tool_result(id, render(&settings_help_value(), format_arg(args)), false)
}
other => rpc::tool_result(id, format!("unknown tool: {other}"), true),
}
}
const DB_ARG: &str = "db";
const WORKSPACE_LIST: &str = "plugmem_workspace_list";
const WORKSPACE_FIND: &str = "plugmem_workspace_find";
const CREATING_TOOLS: &[&str] = &[REMEMBER, REVISE, FORGET, LINK, UNLINK, MAINTAIN, CHECKPOINT];
pub struct WorkspaceShared {
workspace: Workspace,
default: Option<DbName>,
allowed: Vec<DbName>,
create: bool,
}
impl WorkspaceShared {
pub fn new(
workspace: Workspace,
default: Option<DbName>,
allowed: Vec<DbName>,
create: bool,
) -> Self {
Self {
workspace,
default,
allowed,
create,
}
}
pub fn workspace(&self) -> &Workspace {
&self.workspace
}
pub fn default_db(&self) -> Option<&DbName> {
self.default.as_ref()
}
fn resolve(&self, tool: &str, args: Option<&Value>) -> Result<Database, String> {
let name = match arg_str(args, DB_ARG) {
Some(given) => DbName::parse(given).map_err(|e| e.to_string())?,
None => self
.default
.clone()
.ok_or_else(|| messages::WORKSPACE_DB_REQUIRED.to_string())?,
};
if !self.allowed.is_empty() && !self.allowed.contains(&name) {
return Err(format!(
"{name} is not one of the memories this server was started with"
));
}
let missing = if self.create && CREATING_TOOLS.contains(&tool) {
IfMissing::Create
} else {
IfMissing::Fail
};
self.workspace
.get(&name, now_ms(), missing)
.map_err(|e| e.to_string())
}
}
pub fn definitions_ws(default: Option<&DbName>) -> Vec<Value> {
let mut out = definitions();
for tool in &mut out {
if tool
.get("name")
.and_then(Value::as_str)
.is_some_and(|n| STATELESS_TOOLS.contains(&n))
{
continue;
}
let Some(schema) = tool.get_mut("inputSchema") else {
continue;
};
schema["properties"][DB_ARG] = match default {
Some(name) => json!({
"type": "string",
"default": name.as_str(),
"description": messages::ARG_DB_OPTIONAL,
}),
None => json!({ "type": "string", "description": messages::ARG_DB }),
};
if default.is_none() {
let required = schema
.get_mut("required")
.and_then(Value::as_array_mut)
.map(std::mem::take)
.unwrap_or_default();
let mut with_db = vec![json!(DB_ARG)];
with_db.extend(required);
schema["required"] = Value::Array(with_db);
}
}
out.push(workspace_list_def());
out.push(workspace_find_def());
out
}
pub fn call_ws(shared: &WorkspaceShared, id: Value, params: Option<&Value>) -> Value {
let Some(params) = params else {
return rpc::error(id, -32602, "missing params");
};
let name = params.get("name").and_then(Value::as_str).unwrap_or("");
let args = params.get("arguments");
if let Some(reply) = stateless(name, &id, args) {
return reply;
}
match name {
WORKSPACE_LIST => match shared.workspace.entries() {
Ok(entries) => {
rpc::tool_result(id, render(&entries_json(&entries), format_arg(args)), false)
}
Err(e) => rpc::tool_result(id, e.to_string(), true),
},
WORKSPACE_FIND => {
let query = arg_str(args, "query").unwrap_or_default();
let k = arg_u64(args, "k").unwrap_or(8).min(64) as usize;
match shared.workspace.find(query, k, now_ms()) {
Ok(entries) => {
rpc::tool_result(id, render(&entries_json(&entries), format_arg(args)), false)
}
Err(e) => rpc::tool_result(id, e.to_string(), true),
}
}
name if DATABASE_TOOLS.contains(&name) => match shared.resolve(name, args) {
Ok(db) => call(&db, id, Some(params)),
Err(message) => rpc::tool_result(id, message, true),
},
other => rpc::tool_result(id, format!("unknown tool: {other}"), true),
}
}
fn entries_json(entries: &[DbEntry]) -> Value {
Value::Array(
entries
.iter()
.map(|e| {
json!({
"db": e.name.as_str(),
"description": e.description,
"tags": e.tags,
"owner": e.owner,
"archived": e.is_archived(),
})
})
.collect(),
)
}
fn workspace_list_def() -> Value {
simple_def(WORKSPACE_LIST, messages::WORKSPACE_LIST_TOOL)
}
fn workspace_find_def() -> Value {
json!({
"name": WORKSPACE_FIND,
"description": messages::WORKSPACE_FIND_TOOL,
"inputSchema": {
"type": "object",
"properties": {
"query": { "type": "string", "description": messages::ARG_WORKSPACE_QUERY },
"k": { "type": "integer", "minimum": 0, "description": messages::ARG_K },
"format": format_prop()
},
"required": ["query"]
}
})
}
fn recall_ro(reader: &ReaderShared, id: Value, args: Option<&Value>) -> Value {
let format = format_arg(args);
let query = arg_str(args, "query").map(String::from);
let vector = match query.as_deref() {
Some(text) => {
let mut embedder = reader.embedder.lock().expect("embedder lock");
match embedder.as_mut() {
Some(e) => match e.embed(&[text]) {
Ok(mut v) => v.pop(),
Err(e) => return tool_error(id, &e),
},
None => None,
}
}
None => None,
};
let tags = arg_str_vec(args, "tags");
let tag_refs: Vec<&str> = tags.iter().map(String::as_str).collect();
let entities = arg_str_vec(args, "entities");
let ent_refs: Vec<&str> = entities.iter().map(String::as_str).collect();
let q = RecallQuery {
now: now_ms(),
text: query.as_deref(),
vector: vector.as_deref(),
tags: &tag_refs,
entities: &ent_refs,
as_of: arg_u64(args, "as_of"),
range: arg_range(args),
k: arg_u64(args, "k").unwrap_or(0) as usize,
token_budget: None,
include_closed: arg_bool(args, "closed"),
ef: None,
};
let db = reader.db.read().expect("snapshot lock");
match db.recall(q) {
Ok(res) if format == "human" => rpc::tool_result(id, res.rendered, false),
Ok(res) => rpc::tool_result(id, render(&res, "json"), false),
Err(e) => tool_error(id, &e),
}
}
fn id_arg(args: Option<&Value>) -> u32 {
arg_u64(args, "id").unwrap_or(0) as u32
}
fn remember(db: &Database, id: Value, args: Option<&Value>, revise: Option<FactId>) -> Value {
let format = format_arg(args);
let Some(text) = arg_str(args, "text") else {
return rpc::tool_result(id, "missing required `text`".into(), true);
};
let tags = arg_str_vec(args, "tags");
let tag_refs: Vec<&str> = tags.iter().map(String::as_str).collect();
let links = arg_links(args);
let link_refs: Vec<(&str, &str)> = links
.iter()
.map(|(r, e)| (r.as_str(), e.as_str()))
.collect();
let meta = arg_meta(args);
let meta_refs: Vec<(&str, &str)> = meta.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let input = RememberInput {
entity: arg_str(args, "entity"),
tags: &tag_refs,
links: &link_refs,
metadata: (!meta_refs.is_empty()).then_some(meta_refs.as_slice()),
valid_from: arg_u64(args, "valid_from"),
..RememberInput::text(now_ms(), text)
};
let res = match revise {
Some(target) => db.revise(target, input),
None => db.remember(input),
};
match res {
Ok(outcome) => rpc::tool_result(id, render(&outcome, format), false),
Err(e) => tool_error(id, &e),
}
}
fn revise(db: &Database, id: Value, args: Option<&Value>) -> Value {
let Some(target) = arg_u64(args, "id") else {
return rpc::tool_result(id, "missing required `id`".into(), true);
};
remember(db, id, args, Some(FactId(target as u32)))
}
fn forget(db: &Database, id: Value, args: Option<&Value>) -> Value {
let Some(fid) = arg_u64(args, "id") else {
return rpc::tool_result(id, "missing required `id`".into(), true);
};
match db.forget(now_ms(), FactId(fid as u32)) {
Ok(fresh) => rpc::tool_result(
id,
render(&json!({ "id": fid, "forgotten": fresh }), format_arg(args)),
false,
),
Err(e) => tool_error(id, &e),
}
}
fn link(db: &Database, id: Value, args: Option<&Value>) -> Value {
let (Some(src), Some(rel), Some(dst)) = (
arg_str(args, "src"),
arg_str(args, "rel"),
arg_str(args, "dst"),
) else {
return rpc::tool_result(id, "link needs `src`, `rel` and `dst`".into(), true);
};
match db.link(LinkInput {
now: now_ms(),
src,
rel,
dst,
provenance: None,
}) {
Ok(()) => rpc::tool_result(
id,
render(
&json!({ "src": src, "rel": rel, "dst": dst }),
format_arg(args),
),
false,
),
Err(e) => tool_error(id, &e),
}
}
fn unlink(db: &Database, id: Value, args: Option<&Value>) -> Value {
let (Some(src), Some(rel), Some(dst)) = (
arg_str(args, "src"),
arg_str(args, "rel"),
arg_str(args, "dst"),
) else {
return rpc::tool_result(id, "unlink needs `src`, `rel` and `dst`".into(), true);
};
match db.unlink(UnlinkInput {
now: now_ms(),
src,
rel,
dst,
}) {
Ok(unlinked) => rpc::tool_result(
id,
render(
&json!({ "src": src, "rel": rel, "dst": dst, "unlinked": unlinked }),
format_arg(args),
),
false,
),
Err(e) => tool_error(id, &e),
}
}
fn recall(db: &Database, id: Value, args: Option<&Value>) -> Value {
let format = format_arg(args);
let tags = arg_str_vec(args, "tags");
let tag_refs: Vec<&str> = tags.iter().map(String::as_str).collect();
let entities = arg_str_vec(args, "entities");
let ent_refs: Vec<&str> = entities.iter().map(String::as_str).collect();
let q = RecallQuery {
now: now_ms(),
text: arg_str(args, "query"),
vector: None,
tags: &tag_refs,
entities: &ent_refs,
as_of: arg_u64(args, "as_of"),
range: arg_range(args),
k: arg_u64(args, "k").unwrap_or(0) as usize,
token_budget: None,
include_closed: arg_bool(args, "closed"),
ef: None,
};
match db.recall(q) {
Ok(res) if format == "human" => rpc::tool_result(id, res.rendered, false),
Ok(res) => rpc::tool_result(id, render(&res, "json"), false),
Err(e) => tool_error(id, &e),
}
}
fn show(db: &Database, id: Value, args: Option<&Value>) -> Value {
let Some(fid) = arg_u64(args, "id") else {
return rpc::tool_result(id, "missing required `id`".into(), true);
};
match db.get(FactId(fid as u32)) {
Some(snap) => rpc::tool_result(id, render(&snap, format_arg(args)), false),
None => rpc::tool_result(id, format!("fact {fid} does not exist"), true),
}
}
fn remember_def() -> Value {
remember_like(REMEMBER, messages::REMEMBER_TOOL, false)
}
fn revise_def() -> Value {
remember_like(REVISE, messages::REVISE_TOOL, true)
}
fn remember_like(name: &str, description: &str, with_id: bool) -> Value {
let mut props = json!({
"text": { "type": "string", "description": messages::ARG_TEXT },
"entity": { "type": "string", "description": messages::ARG_ENTITY },
"tags": { "type": "array", "items": { "type": "string" }, "description": messages::ARG_TAGS },
"links": {
"type": "array",
"items": {
"type": "object",
"properties": {
"rel": { "type": "string" },
"entity": { "type": "string" }
},
"required": ["rel", "entity"]
},
"description": messages::ARG_LINKS
},
"metadata": {
"type": "object",
"additionalProperties": { "type": "string" },
"description": messages::ARG_METADATA
},
"valid_from": { "type": "integer", "minimum": 0, "description": messages::ARG_VALID_FROM },
"format": format_prop()
});
let mut required = vec![json!("text")];
if with_id {
props["id"] = json!({ "type": "integer", "minimum": 0, "description": messages::ARG_ID });
required.push(json!("id"));
}
json!({
"name": name,
"description": description,
"inputSchema": { "type": "object", "properties": props, "required": required }
})
}
fn recall_def() -> Value {
json!({
"name": RECALL,
"description": messages::RECALL_TOOL,
"inputSchema": {
"type": "object",
"properties": {
"query": { "type": "string", "description": messages::ARG_QUERY },
"tags": { "type": "array", "items": { "type": "string" }, "description": messages::ARG_TAGS },
"entities": { "type": "array", "items": { "type": "string" }, "description": messages::ARG_ENTITIES },
"as_of": { "type": "integer", "minimum": 0, "description": messages::ARG_AS_OF },
"range": {
"type": "array",
"items": { "type": "integer", "minimum": 0 },
"minItems": 2,
"maxItems": 2,
"description": messages::ARG_RANGE
},
"k": { "type": "integer", "minimum": 0, "description": messages::ARG_K },
"closed": { "type": "boolean", "description": messages::ARG_CLOSED },
"format": format_prop()
}
}
})
}
fn forget_def() -> Value {
id_only_def(FORGET, messages::FORGET_TOOL)
}
fn show_def() -> Value {
id_only_def(SHOW, messages::SHOW_TOOL)
}
fn id_only_def(name: &str, description: &str) -> Value {
json!({
"name": name,
"description": description,
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "integer", "minimum": 0, "description": messages::ARG_ID },
"format": format_prop()
},
"required": ["id"]
}
})
}
fn link_def() -> Value {
edge_def(LINK, messages::LINK_TOOL)
}
fn unlink_def() -> Value {
edge_def(UNLINK, messages::UNLINK_TOOL)
}
fn edge_def(name: &str, description: &str) -> Value {
json!({
"name": name,
"description": description,
"inputSchema": {
"type": "object",
"properties": {
"src": { "type": "string", "description": messages::ARG_SRC },
"rel": { "type": "string", "description": messages::ARG_REL },
"dst": { "type": "string", "description": messages::ARG_DST },
"format": format_prop()
},
"required": ["src", "rel", "dst"]
}
})
}
fn stats_def() -> Value {
format_only_def(STATS, messages::STATS_TOOL)
}
fn export_def() -> Value {
format_only_def(EXPORT, messages::EXPORT_TOOL)
}
fn maintain_def() -> Value {
json!({
"name": MAINTAIN,
"description": messages::MAINTAIN_TOOL,
"inputSchema": {
"type": "object",
"properties": {
"mode": {
"type": "string",
"enum": MAINTAIN_MODES,
"description": messages::ARG_MAINTAIN_MODE
},
"format": format_prop()
}
}
})
}
const MAINTAIN_MODES: [&str; 5] = [
"auto",
"compact",
"reindex-text",
"optimize-vectors",
"full",
];
fn maintenance_options(args: Option<&Value>) -> Result<MaintenanceOptions, String> {
let Some(mode) = arg_str(args, "mode") else {
return Ok(MaintenanceOptions::auto());
};
match mode {
"auto" => Ok(MaintenanceOptions::auto()),
"full" => Ok(MaintenanceOptions::full()),
"compact" => Ok(explicit(MaintenanceMode::Compact)),
"reindex-text" => Ok(explicit(MaintenanceMode::ReindexText)),
"optimize-vectors" => Ok(explicit(MaintenanceMode::OptimizeVectors)),
other => Err(format!(
"unknown maintenance mode `{other}`; expected one of {}",
MAINTAIN_MODES.join(", ")
)),
}
}
fn explicit(mode: MaintenanceMode) -> MaintenanceOptions {
MaintenanceOptions {
mode,
..MaintenanceOptions::auto()
}
}
fn checkpoint_def() -> Value {
format_only_def(CHECKPOINT, messages::CHECKPOINT_TOOL)
}
fn verify_def() -> Value {
format_only_def(VERIFY, messages::VERIFY_TOOL)
}
fn version_def() -> Value {
simple_def(VERSION, messages::VERSION_TOOL)
}
fn about_def() -> Value {
simple_def(ABOUT, messages::ABOUT_TOOL)
}
fn settings_help_def() -> Value {
format_only_def(SETTINGS_HELP, messages::SETTINGS_HELP_TOOL)
}
fn settings_help_value() -> Value {
let help = plugmem_host::settings_help();
let settings: Vec<_> = help
.docs()
.iter()
.map(|doc| {
json!({
"section": doc.section,
"key": doc.key,
"type": doc.value_type,
"default": doc.default,
"description": doc.description,
"scope": doc.scope.as_str(),
})
})
.collect();
json!({
"topic": "settings",
"config_path_precedence": help.config_path_precedence(),
"default_config_path": plugmem_host::default_config_path()
.map(|path| path.display().to_string()),
"settings": settings,
})
}
fn simple_def(name: &str, description: &str) -> Value {
json!({
"name": name,
"description": description,
"inputSchema": { "type": "object", "properties": {} }
})
}
fn format_only_def(name: &str, description: &str) -> Value {
json!({
"name": name,
"description": description,
"inputSchema": { "type": "object", "properties": { "format": format_prop() } }
})
}
fn format_prop() -> Value {
json!({
"type": "string",
"enum": ["human", "json"],
"description": messages::ARG_FORMAT
})
}
fn arg_str<'a>(args: Option<&'a Value>, key: &str) -> Option<&'a str> {
args.and_then(|a| a.get(key)).and_then(Value::as_str)
}
fn arg_u64(args: Option<&Value>, key: &str) -> Option<u64> {
args.and_then(|a| a.get(key)).and_then(Value::as_u64)
}
fn arg_bool(args: Option<&Value>, key: &str) -> bool {
args.and_then(|a| a.get(key))
.and_then(Value::as_bool)
.unwrap_or(false)
}
fn arg_str_vec(args: Option<&Value>, key: &str) -> Vec<String> {
args.and_then(|a| a.get(key))
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
}
fn arg_links(args: Option<&Value>) -> Vec<(String, String)> {
args.and_then(|a| a.get("links"))
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|v| {
let rel = v.get("rel").and_then(Value::as_str)?;
let entity = v.get("entity").and_then(Value::as_str)?;
Some((rel.to_string(), entity.to_string()))
})
.collect()
})
.unwrap_or_default()
}
fn arg_meta(args: Option<&Value>) -> Vec<(String, String)> {
args.and_then(|a| a.get("metadata"))
.and_then(Value::as_object)
.map(|obj| {
obj.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect::<std::collections::BTreeMap<_, _>>()
.into_iter()
.collect()
})
.unwrap_or_default()
}
fn arg_range(args: Option<&Value>) -> Option<(u64, u64)> {
let arr = args
.and_then(|a| a.get("range"))
.and_then(Value::as_array)?;
Some((arr.first()?.as_u64()?, arr.get(1)?.as_u64()?))
}
fn format_arg(args: Option<&Value>) -> &str {
args.and_then(|a| a.get("format"))
.and_then(Value::as_str)
.unwrap_or("json")
}
fn render<T: Serialize>(value: &T, format: &str) -> String {
let out = if format == "human" {
serde_json::to_string_pretty(value)
} else {
serde_json::to_string(value)
};
out.unwrap_or_else(|e| format!("serialization error: {e}"))
}
fn tool_error(id: Value, e: &HostError) -> Value {
let text = match e.capacity_hint() {
Some(hint) => format!("{e}\n{hint}"),
None => e.to_string(),
};
rpc::tool_result(id, text, true)
}
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use plugmem_host::{Config, Database};
struct TempDir(std::path::PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
let dir = std::env::temp_dir().join(format!(
"plugmem-mcp-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
TempDir(dir)
}
fn db(&self) -> std::path::PathBuf {
self.0.join("m.plugmem")
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
pub(super) fn params(name: &str, args: Value) -> Value {
json!({ "name": name, "arguments": args })
}
pub(super) fn text(v: &Value) -> String {
v["result"]["content"][0]["text"]
.as_str()
.unwrap()
.to_string()
}
pub(super) fn is_error(v: &Value) -> bool {
v["result"]["isError"].as_bool().unwrap()
}
#[test]
fn arg_extractors_read_their_shapes() {
let a = json!({
"s": "hi", "n": 7, "b": true,
"list": ["x", 1, "y"],
"links": [{"rel": "r", "entity": "e"}, {"rel": "only"}],
"metadata": {"uri": "s3://b/x", "n2": 5, "mime": "pdf"},
"range": [10, 20]
});
let a = Some(&a);
assert_eq!(arg_str(a, "s"), Some("hi"));
assert_eq!(arg_str(a, "missing"), None);
assert_eq!(arg_u64(a, "n"), Some(7));
assert!(arg_bool(a, "b"));
assert!(!arg_bool(a, "missing"));
assert_eq!(arg_str_vec(a, "list"), vec!["x", "y"]); assert_eq!(arg_links(a), vec![("r".to_string(), "e".to_string())]); assert_eq!(
arg_meta(a),
vec![
("mime".to_string(), "pdf".to_string()),
("uri".to_string(), "s3://b/x".to_string()),
]
);
assert_eq!(arg_range(a), Some((10, 20)));
assert_eq!(arg_range(None), None);
assert_eq!(format_arg(a), "json");
assert_eq!(format_arg(Some(&json!({"format": "human"}))), "human");
assert_eq!(id_arg(Some(&json!({"id": 3}))), 3);
assert_eq!(id_arg(None), 0);
}
#[test]
fn render_json_vs_human() {
let v = json!({ "a": 1 });
assert_eq!(render(&v, "json"), "{\"a\":1}");
assert!(render(&v, "human").contains('\n'));
}
#[test]
fn definitions_cover_both_modes() {
let names = |defs: Vec<Value>| -> Vec<String> {
defs.iter()
.map(|d| d["name"].as_str().unwrap().to_owned())
.collect()
};
let w = names(definitions());
assert_eq!(w[0], REMEMBER);
assert!(w.iter().any(|n| n == UNLINK));
assert!(w.iter().any(|n| n == MAINTAIN) && w.iter().any(|n| n == CHECKPOINT));
let ro = names(definitions_ro());
assert!(ro.iter().any(|n| n == REFRESH) && ro.iter().any(|n| n == GENERATION));
assert!(!ro.iter().any(|n| n == REMEMBER)); }
#[test]
fn settings_help_is_explicit_and_contains_shared_database_path() {
let tmp = TempDir::new("settings-help");
let (db, _) = Database::open(tmp.db(), Config::default()).unwrap();
let response = call(
&db,
json!(1),
Some(¶ms(SETTINGS_HELP, json!({ "format": "json" }))),
);
let value: Value = serde_json::from_str(&text(&response)).unwrap();
assert_eq!(value["topic"], "settings");
assert!(
value["settings"]
.as_array()
.unwrap()
.iter()
.any(|setting| { setting["section"] == "database" && setting["key"] == "path" })
);
}
#[test]
fn writer_call_dispatches_every_verb() {
let tmp = TempDir::new("call");
let (db, _) = Database::open(tmp.db(), Config::default()).unwrap();
assert_eq!(call(&db, json!(1), None)["error"]["code"], -32602);
assert!(is_error(&call(
&db,
json!(1),
Some(¶ms("plugmem_nope", json!({})))
)));
let r = call(
&db,
json!(1),
Some(¶ms(
"plugmem_remember",
json!({"text": "prefers tokio", "entity": "user", "tags": ["pref"], "links": [{"rel":"at","entity":"acme"}]}),
)),
);
assert!(!is_error(&r));
let outcome: Value = serde_json::from_str(&text(&r)).unwrap();
assert_eq!(outcome["id"], 0);
assert!(is_error(&call(
&db,
json!(1),
Some(¶ms("plugmem_remember", json!({})))
)));
let rj = call(
&db,
json!(2),
Some(¶ms("plugmem_recall", json!({"query": "tokio"}))),
);
assert!(serde_json::from_str::<Value>(&text(&rj)).unwrap()["facts"].is_array());
let rh = call(
&db,
json!(2),
Some(¶ms(
"plugmem_recall",
json!({"query": "tokio", "format": "human"}),
)),
);
assert!(text(&rh).contains("[f0]"));
assert!(
text(&call(
&db,
json!(3),
Some(¶ms("plugmem_show", json!({"id": 0})))
))
.contains("prefers tokio")
);
assert!(is_error(&call(
&db,
json!(3),
Some(¶ms("plugmem_show", json!({"id": 999})))
)));
assert!(is_error(&call(
&db,
json!(3),
Some(¶ms("plugmem_show", json!({})))
)));
let rv = call(
&db,
json!(4),
Some(¶ms(
"plugmem_revise",
json!({"id": 0, "text": "prefers async-std"}),
)),
);
assert_eq!(serde_json::from_str::<Value>(&text(&rv)).unwrap()["id"], 1);
assert!(is_error(&call(
&db,
json!(4),
Some(¶ms("plugmem_revise", json!({"text": "x"})))
)));
assert!(!is_error(&call(
&db,
json!(5),
Some(¶ms(
"plugmem_link",
json!({"src": "user", "rel": "works_at", "dst": "acme"})
))
)));
assert!(is_error(&call(
&db,
json!(5),
Some(¶ms("plugmem_link", json!({"src": "user"})))
)));
assert!(!is_error(&call(
&db,
json!(5),
Some(¶ms(
"plugmem_unlink",
json!({"src": "user", "rel": "works_at", "dst": "acme"})
))
)));
assert!(is_error(&call(
&db,
json!(5),
Some(¶ms("plugmem_unlink", json!({"src": "user"})))
)));
assert!(
serde_json::from_str::<Value>(&text(&call(
&db,
json!(6),
Some(¶ms("plugmem_stats", json!({})))
)))
.unwrap()["facts"]
.is_number()
);
assert!(
serde_json::from_str::<Value>(&text(&call(
&db,
json!(7),
Some(¶ms("plugmem_export", json!({})))
)))
.unwrap()
.is_array()
);
assert!(!is_error(&call(
&db,
json!(8),
Some(¶ms("plugmem_maintain", json!({})))
)));
assert!(!is_error(&call(
&db,
json!(9),
Some(¶ms("plugmem_checkpoint", json!({})))
)));
assert!(!is_error(&call(
&db,
json!(10),
Some(¶ms("plugmem_verify", json!({})))
)));
let f = call(
&db,
json!(11),
Some(¶ms("plugmem_forget", json!({"id": 1}))),
);
assert_eq!(
serde_json::from_str::<Value>(&text(&f)).unwrap()["forgotten"],
true
);
assert!(is_error(&call(
&db,
json!(11),
Some(¶ms("plugmem_forget", json!({})))
)));
assert!(
text(&call(
&db,
json!(12),
Some(¶ms("plugmem_version", json!({})))
))
.contains("plugmem")
);
assert!(
text(&call(
&db,
json!(13),
Some(¶ms("plugmem_about", json!({})))
))
.contains("skill")
);
}
#[test]
fn remember_accepts_metadata_and_show_returns_it_sorted() {
let tmp = TempDir::new("meta");
let (db, _) = Database::open(tmp.db(), Config::default()).unwrap();
let schema = &remember_def()["inputSchema"]["properties"]["metadata"];
assert_eq!(schema["type"], "object");
assert_eq!(schema["additionalProperties"]["type"], "string");
let r = call(
&db,
json!(1),
Some(¶ms(
"plugmem_remember",
json!({"text": "a scan", "metadata": {"uri": "s3://b/x", "mime": "pdf"}}),
)),
);
assert!(!is_error(&r));
let card: Value = serde_json::from_str(&text(&call(
&db,
json!(2),
Some(¶ms("plugmem_show", json!({"id": 0}))),
)))
.unwrap();
assert_eq!(card["metadata"]["uri"], "s3://b/x");
assert_eq!(card["metadata"]["mime"], "pdf");
}
#[test]
fn read_only_call_dispatches_and_refuses_writes() {
let tmp = TempDir::new("call-ro");
{
let (db, _) = Database::open(tmp.db(), Config::default()).unwrap();
call(
&db,
json!(1),
Some(¶ms(
"plugmem_remember",
json!({"text": "the sky is blue", "entity": "sky"}),
)),
);
db.checkpoint(now_ms()).unwrap();
}
let ro = Database::open_readonly(tmp.db(), Config::default()).unwrap();
let reader = ReaderShared::new(ro, None);
assert_eq!(call_ro(&reader, json!(1), None)["error"]["code"], -32602);
assert!(is_error(&call_ro(
&reader,
json!(1),
Some(¶ms("plugmem_nope", json!({})))
)));
assert!(
serde_json::from_str::<Value>(&text(&call_ro(
&reader,
json!(2),
Some(¶ms("plugmem_recall", json!({"query": "sky"})))
)))
.unwrap()["facts"]
.is_array()
);
assert!(
text(&call_ro(
&reader,
json!(3),
Some(¶ms("plugmem_show", json!({"id": 0})))
))
.contains("sky")
);
assert!(is_error(&call_ro(
&reader,
json!(3),
Some(¶ms("plugmem_show", json!({"id": 999})))
))); assert_eq!(
serde_json::from_str::<Value>(&text(&call_ro(
&reader,
json!(4),
Some(¶ms("plugmem_stats", json!({})))
)))
.unwrap()["facts"],
1
);
assert!(
serde_json::from_str::<Value>(&text(&call_ro(
&reader,
json!(5),
Some(¶ms("plugmem_export", json!({})))
)))
.unwrap()
.is_array()
);
assert!(!is_error(&call_ro(
&reader,
json!(6),
Some(¶ms("plugmem_verify", json!({})))
)));
assert!(
serde_json::from_str::<Value>(&text(&call_ro(
&reader,
json!(7),
Some(¶ms("plugmem_generation", json!({})))
)))
.unwrap()["generation"]
.is_number()
);
let rf = call_ro(
&reader,
json!(8),
Some(¶ms("plugmem_refresh", json!({}))),
);
assert_eq!(
serde_json::from_str::<Value>(&text(&rf)).unwrap()["refreshed"],
false
);
assert!(
text(&call_ro(
&reader,
json!(9),
Some(¶ms("plugmem_version", json!({})))
))
.contains("plugmem")
);
assert!(
text(&call_ro(
&reader,
json!(10),
Some(¶ms("plugmem_about", json!({})))
))
.contains("skill")
);
for verb in [
"plugmem_remember",
"plugmem_revise",
"plugmem_forget",
"plugmem_link",
"plugmem_unlink",
"plugmem_maintain",
"plugmem_checkpoint",
] {
assert!(
is_error(&call_ro(&reader, json!(11), Some(¶ms(verb, json!({}))))),
"{verb} must be refused"
);
}
}
}
#[cfg(test)]
mod workspace_tests {
use super::tests::{is_error, params, text};
use super::*;
use plugmem_host::{Config, Settings};
struct TempDir(std::path::PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
let dir = std::env::temp_dir().join(format!(
"plugmem-mcp-ws-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
TempDir(dir)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn name(s: &str) -> DbName {
DbName::parse(s).unwrap()
}
fn shared(
tmp: &TempDir,
default: Option<&str>,
allow: &[&str],
create: bool,
) -> WorkspaceShared {
let workspace = Settings::from_table(None)
.unwrap()
.open_workspace(&tmp.0)
.unwrap();
WorkspaceShared::new(
workspace,
default.map(name),
allow.iter().copied().map(name).collect(),
create,
)
}
fn db_prop(defs: &[Value], tool: &str) -> Option<Value> {
defs.iter()
.find(|d| d["name"] == tool)
.and_then(|d| d["inputSchema"]["properties"].get(DB_ARG).cloned())
}
fn required(defs: &[Value], tool: &str) -> Vec<String> {
defs.iter()
.find(|d| d["name"] == tool)
.and_then(|d| d["inputSchema"]["required"].as_array().cloned())
.unwrap_or_default()
.iter()
.map(|v| v.as_str().unwrap_or_default().to_string())
.collect()
}
#[test]
fn the_db_argument_appears_only_where_it_is_a_real_question() {
let plain = definitions();
assert_eq!(db_prop(&plain, REMEMBER), None);
assert_eq!(required(&plain, REMEMBER), ["text"]);
assert!(!plain.iter().any(|d| d["name"] == WORKSPACE_FIND));
let defaulted = definitions_ws(Some(&name("chat-42")));
let prop = db_prop(&defaulted, REMEMBER).unwrap();
assert_eq!(prop["default"], "chat-42");
assert_eq!(required(&defaulted, REMEMBER), ["text"]);
let bare = definitions_ws(None);
assert!(db_prop(&bare, REMEMBER).unwrap().get("default").is_none());
assert_eq!(required(&bare, REMEMBER), [DB_ARG, "text"]);
assert_eq!(required(&bare, STATS), [DB_ARG]);
for defs in [&defaulted, &bare] {
assert!(defs.iter().any(|d| d["name"] == WORKSPACE_LIST));
assert!(defs.iter().any(|d| d["name"] == WORKSPACE_FIND));
}
}
#[test]
fn every_tool_is_either_database_scoped_or_deliberately_not() {
let mut advertised: Vec<String> = definitions()
.iter()
.map(|d| d["name"].as_str().unwrap().to_string())
.collect();
advertised.sort();
let mut known: Vec<String> = DATABASE_TOOLS
.iter()
.chain(STATELESS_TOOLS)
.map(|s| (*s).to_string())
.collect();
known.sort();
assert_eq!(advertised, known);
let bare = definitions_ws(None);
for tool in DATABASE_TOOLS {
assert!(db_prop(&bare, tool).is_some(), "{tool} has no {DB_ARG}");
}
for tool in STATELESS_TOOLS {
assert_eq!(
db_prop(&bare, tool),
None,
"{tool} should not take {DB_ARG}"
);
}
}
#[test]
fn asking_what_version_is_running_needs_no_memory() {
let tmp = TempDir::new("stateless");
let ws = shared(&tmp, None, &[], true);
for tool in STATELESS_TOOLS {
let out = call_ws(&ws, json!(1), Some(¶ms(tool, json!({}))));
assert!(!is_error(&out), "{tool}: {}", text(&out));
}
}
#[test]
fn a_call_reaches_the_named_database_and_only_that_one() {
let tmp = TempDir::new("route");
let ws = shared(&tmp, None, &[], true);
for (db, fact) in [
("chat-42", "the sky is blue"),
("chat-43", "the sky is red"),
] {
let out = call_ws(
&ws,
json!(1),
Some(¶ms(REMEMBER, json!({ "db": db, "text": fact }))),
);
assert!(!is_error(&out), "{}", text(&out));
}
let out = call_ws(
&ws,
json!(2),
Some(¶ms(RECALL, json!({ "db": "chat-42", "query": "sky" }))),
);
assert!(text(&out).contains("the sky is blue"), "{}", text(&out));
assert!(!text(&out).contains("the sky is red"), "{}", text(&out));
}
#[test]
fn without_a_default_the_db_argument_is_required_at_call_time_too() {
let tmp = TempDir::new("required");
let ws = shared(&tmp, None, &[], true);
let out = call_ws(&ws, json!(1), Some(¶ms(STATS, json!({}))));
assert!(is_error(&out));
assert!(
text(&out).contains("plugmem_workspace_find"),
"{}",
text(&out)
);
let ws = shared(&tmp, Some("chat-42"), &[], true);
call_ws(
&ws,
json!(2),
Some(¶ms(REMEMBER, json!({ "text": "a fact" }))),
);
let out = call_ws(&ws, json!(3), Some(¶ms(STATS, json!({}))));
assert!(!is_error(&out));
assert!(text(&out).contains("\"facts\":1"), "{}", text(&out));
}
#[test]
fn creation_follows_the_verb_not_the_name() {
let tmp = TempDir::new("create");
let ws = shared(&tmp, None, &[], true);
let out = call_ws(
&ws,
json!(1),
Some(¶ms(RECALL, json!({ "db": "typo", "query": "x" }))),
);
assert!(is_error(&out));
assert!(text(&out).contains("typo"));
assert!(!ws.workspace().layout().exists(&name("typo")));
let out = call_ws(
&ws,
json!(2),
Some(¶ms(
REMEMBER,
json!({ "db": "chat-99", "text": "hello" }),
)),
);
assert!(!is_error(&out), "{}", text(&out));
assert!(ws.workspace().layout().exists(&name("chat-99")));
let strict = shared(&tmp, None, &[], false);
let out = call_ws(
&strict,
json!(3),
Some(¶ms(
REMEMBER,
json!({ "db": "chat-100", "text": "hello" }),
)),
);
assert!(is_error(&out));
assert!(!ws.workspace().layout().exists(&name("chat-100")));
}
#[test]
fn a_name_outside_the_alphabet_or_the_allow_set_is_refused() {
let tmp = TempDir::new("refuse");
let ws = shared(&tmp, None, &["chat-42"], true);
let out = call_ws(
&ws,
json!(1),
Some(¶ms(STATS, json!({ "db": "../etc/passwd" }))),
);
assert!(is_error(&out));
assert!(text(&out).contains("not a usable database name"));
let out = call_ws(
&ws,
json!(2),
Some(¶ms(STATS, json!({ "db": "other" }))),
);
assert!(is_error(&out));
assert!(text(&out).contains("not one of the memories"));
call_ws(
&ws,
json!(3),
Some(¶ms(REMEMBER, json!({ "db": "chat-42", "text": "x" }))),
);
let out = call_ws(
&ws,
json!(4),
Some(¶ms(STATS, json!({ "db": "chat-42" }))),
);
assert!(!is_error(&out));
}
#[test]
fn the_workspace_verbs_return_names_to_use_as_db() {
let tmp = TempDir::new("find");
let ws = shared(&tmp, None, &[], true);
ws.workspace()
.describe(
&name("chat-42"),
1_000,
plugmem_host::Description {
text: "release planning and performance work",
tags: &["kind:chat"],
owner: Some("ann"),
},
)
.unwrap();
let listed = call_ws(&ws, json!(1), Some(¶ms(WORKSPACE_LIST, json!({}))));
assert!(
text(&listed).contains("\"db\":\"chat-42\""),
"{}",
text(&listed)
);
assert!(text(&listed).contains("\"owner\":\"ann\""));
assert!(text(&listed).contains("\"archived\":false"));
let found = call_ws(
&ws,
json!(2),
Some(¶ms(
WORKSPACE_FIND,
json!({ "query": "release planning" }),
)),
);
assert!(
text(&found).contains("\"db\":\"chat-42\""),
"{}",
text(&found)
);
let out = call_ws(
&ws,
json!(3),
Some(¶ms(STATS, json!({ "db": "chat-42" }))),
);
assert!(!is_error(&out));
}
#[test]
fn concurrent_calls_to_different_databases_do_not_cross() {
let tmp = TempDir::new("concurrent");
let ws = std::sync::Arc::new(shared(&tmp, None, &[], true));
let dbs = ["a", "b", "c", "d"];
for db in dbs {
call_ws(
&ws,
json!(0),
Some(¶ms(
REMEMBER,
json!({ "db": db, "text": format!("i am {db}") }),
)),
);
}
let mut handles = Vec::new();
for db in dbs {
for _ in 0..8 {
let ws = std::sync::Arc::clone(&ws);
handles.push(std::thread::spawn(move || {
let out = call_ws(
&ws,
json!(1),
Some(¶ms(RECALL, json!({ "db": db, "query": "i am" }))),
);
assert!(
text(&out).contains(&format!("i am {db}")),
"{db} got {}",
text(&out)
);
for other in dbs.iter().filter(|o| **o != db) {
assert!(!text(&out).contains(&format!("i am {other}")));
}
}));
}
}
for h in handles {
h.join().unwrap();
}
}
#[test]
fn an_unknown_tool_and_missing_params_behave_as_they_do_elsewhere() {
let tmp = TempDir::new("misc");
let ws = shared(&tmp, Some("chat-42"), &[], true);
assert_eq!(call_ws(&ws, json!(1), None)["error"]["code"], -32602);
let out = call_ws(&ws, json!(2), Some(¶ms("plugmem_nope", json!({}))));
assert!(is_error(&out));
assert!(text(&out).contains("unknown tool"));
}
#[test]
fn a_registry_failure_is_a_tool_error_not_a_panic() {
let tmp = TempDir::new("registry-busy");
let ws = shared(&tmp, None, &[], true);
std::fs::create_dir_all(&tmp.0).unwrap();
let _held = Database::open(ws.workspace().layout().registry_path(), Config::default())
.unwrap()
.0;
for tool in [WORKSPACE_LIST, WORKSPACE_FIND] {
let out = call_ws(&ws, json!(1), Some(¶ms(tool, json!({ "query": "x" }))));
assert!(is_error(&out), "{tool}");
}
}
}