1use serde::{Deserialize, Serialize};
8use serde_json::{Value, json};
9use std::io::{self, BufRead, Write};
10use std::path::Path;
11use std::sync::Arc;
12use std::time::Instant;
13
14use crate::config::{AppConfig, FeatureTier, RerankerMode, TierConfig};
15use crate::db;
16use crate::embeddings::Embedder;
17use crate::hnsw::VectorIndex;
18use crate::llm::OllamaClient;
19use crate::models::{CandidateCounts, GovernancePolicy, Memory, RecallMeta, RecallTelemetry, Tier};
20use crate::reranker::CrossEncoder;
21use crate::validate;
22
23#[derive(Deserialize)]
26struct RpcRequest {
27 jsonrpc: String,
28 id: Option<Value>,
29 method: String,
30 #[serde(default)]
31 params: Value,
32}
33
34#[derive(Debug, Serialize)]
35struct RpcResponse {
36 jsonrpc: String,
37 id: Value,
38 #[serde(skip_serializing_if = "Option::is_none")]
39 result: Option<Value>,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 error: Option<RpcError>,
42}
43
44#[derive(Debug, Serialize)]
45struct RpcError {
46 code: i64,
47 message: String,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 data: Option<Value>,
50}
51
52fn ok_response(id: Value, result: Value) -> RpcResponse {
53 RpcResponse {
54 jsonrpc: "2.0".into(),
55 id,
56 result: Some(result),
57 error: None,
58 }
59}
60
61fn err_response(id: Value, code: i64, message: String) -> RpcResponse {
62 RpcResponse {
63 jsonrpc: "2.0".into(),
64 id,
65 result: None,
66 error: Some(RpcError {
67 code,
68 message,
69 data: None,
70 }),
71 }
72}
73
74fn audit_emit_for_mcp_dispatch(
80 tool_name: &str,
81 arguments: &Value,
82 result: &Result<Value, String>,
83 mcp_client: Option<&str>,
84) {
85 if !crate::audit::is_enabled() {
86 return;
87 }
88 let action = match tool_name {
89 "memory_store" | "memory_delete" => return,
91 "memory_recall"
92 | "memory_search"
93 | "memory_get"
94 | "memory_list"
95 | "memory_session_start" => crate::audit::AuditAction::Recall,
96 "memory_update" => crate::audit::AuditAction::Update,
97 "memory_promote" => crate::audit::AuditAction::Promote,
98 "memory_forget" => crate::audit::AuditAction::Forget,
99 "memory_link" => crate::audit::AuditAction::Link,
100 "memory_consolidate" => crate::audit::AuditAction::Consolidate,
101 "memory_pending_approve" => crate::audit::AuditAction::Approve,
102 "memory_pending_reject" => crate::audit::AuditAction::Reject,
103 _ => return,
105 };
106 let agent_id = arguments
107 .get("agent_id")
108 .and_then(Value::as_str)
109 .map(str::to_string)
110 .unwrap_or_else(|| {
111 mcp_client
112 .map(|c| format!("ai:{c}"))
113 .unwrap_or_else(|| "anonymous".into())
114 });
115 let namespace = arguments
116 .get("namespace")
117 .and_then(Value::as_str)
118 .unwrap_or("global")
119 .to_string();
120 let memory_id = arguments
121 .get("id")
122 .or_else(|| arguments.get("memory_id"))
123 .and_then(Value::as_str)
124 .unwrap_or("*")
125 .to_string();
126 let mut builder = crate::audit::EventBuilder::new(
127 action,
128 crate::audit::actor(
129 agent_id,
130 mcp_client.map_or("host_fallback", |_| "mcp_client_info"),
131 None,
132 ),
133 crate::audit::AuditTarget {
134 memory_id,
135 namespace,
136 title: None,
137 tier: None,
138 scope: None,
139 },
140 );
141 if let Err(e) = result {
142 builder = builder.error(e.clone());
143 }
144 crate::audit::emit(builder);
145}
146
147const TOOLS_VERSION: &str = "2026-04-26";
154
155pub(crate) fn families_overview(profile: &crate::profile::Profile) -> Value {
168 use crate::profile::Family;
169 let defs = tool_definitions();
170 let all_tools = defs
171 .get("tools")
172 .and_then(Value::as_array)
173 .cloned()
174 .unwrap_or_default();
175 let entries: Vec<Value> = Family::all()
176 .iter()
177 .map(|fam| {
178 let tools_in_family: Vec<&str> = all_tools
179 .iter()
180 .filter_map(|t| t.get("name").and_then(Value::as_str))
181 .filter(|n| Family::for_tool(n) == Some(*fam))
182 .collect();
183 json!({
184 "name": fam.name(),
185 "tool_count": tools_in_family.len(),
186 "loaded": profile.includes(*fam),
187 "tools": tools_in_family,
188 })
189 })
190 .collect();
191 json!({
192 "schema_version": "v0.6.4-families-1",
193 "always_on": crate::profile::ALWAYS_ON_TOOLS,
194 "families": entries,
195 })
196}
197
198pub(crate) fn handle_capabilities_family(
216 family_name: &str,
217 include_schema: bool,
218 profile: &crate::profile::Profile,
219 allowlist_cfg: Option<&crate::config::McpConfig>,
220 agent_id: Option<&str>,
221 audit_conn: Option<&rusqlite::Connection>,
222) -> Result<Value, String> {
223 use crate::profile::Family;
224 if family_name.is_empty() {
225 return Err("memory_capabilities: 'family' must not be empty".to_string());
226 }
227 let family = Family::all()
228 .iter()
229 .find(|f| f.name() == family_name)
230 .copied()
231 .ok_or_else(|| {
232 let valid: Vec<&str> = Family::all().iter().map(|f| f.name()).collect();
233 format!(
234 "unknown family '{family_name}'. Valid families: {}.",
235 valid.join(", ")
236 )
237 })?;
238
239 if include_schema && let Some(mcp_cfg) = allowlist_cfg {
241 use crate::config::AllowlistDecision;
242 match mcp_cfg.allowlist_decision(agent_id, family.name()) {
243 AllowlistDecision::Disabled | AllowlistDecision::Allow => {}
244 AllowlistDecision::Deny => {
245 if let Some(conn) = audit_conn {
248 crate::db::record_capability_expansion(
249 conn,
250 agent_id,
251 family.name(),
252 false,
253 None,
254 );
255 }
256 return Err(format!(
257 "agent '{}' is not permitted to expand family '{}' under \
258 [mcp.allowlist]. Ask an operator to add a matching rule \
259 to config.toml or pass an allowed agent_id.",
260 agent_id.unwrap_or("<anonymous>"),
261 family.name()
262 ));
263 }
264 }
265 }
266
267 if include_schema && let Some(conn) = audit_conn {
271 crate::db::record_capability_expansion(conn, agent_id, family.name(), true, None);
272 }
273
274 let defs = tool_definitions();
275 let all_tools = defs
276 .get("tools")
277 .and_then(Value::as_array)
278 .cloned()
279 .unwrap_or_default();
280 let in_family: Vec<Value> = all_tools
281 .into_iter()
282 .filter(|t| {
283 t.get("name")
284 .and_then(Value::as_str)
285 .and_then(Family::for_tool)
286 == Some(family)
287 })
288 .collect();
289
290 if include_schema {
291 Ok(json!({
292 "schema_version": "v0.6.4-family-schemas-1",
293 "family": family.name(),
294 "loaded_under_active_profile": profile.includes(family),
295 "tools": in_family,
296 }))
297 } else {
298 let names: Vec<&str> = in_family
299 .iter()
300 .filter_map(|t| t.get("name").and_then(Value::as_str))
301 .collect();
302 Ok(json!({
303 "schema_version": "v0.6.4-family-list-1",
304 "family": family.name(),
305 "loaded_under_active_profile": profile.includes(family),
306 "tools": names,
307 }))
308 }
309}
310
311pub(crate) fn tool_definitions_for_profile(profile: &crate::profile::Profile) -> Value {
318 let mut defs = tool_definitions();
319 if let Some(arr) = defs.get_mut("tools").and_then(|t| t.as_array_mut()) {
320 arr.retain(|tool| {
321 tool.get("name")
322 .and_then(Value::as_str)
323 .is_some_and(|name| profile.loads(name))
324 });
325 }
326 defs
327}
328
329#[allow(clippy::too_many_lines)]
330pub(crate) fn tool_definitions() -> Value {
331 json!({
332 "toolsVersion": TOOLS_VERSION,
333 "tools": [
334 {
335 "name": "memory_store",
336 "description": "Store a new memory. Deduplicates by title+namespace.",
337 "inputSchema": {
338 "type": "object",
339 "properties": {
340 "title": {"type": "string", "description": "Short descriptive title"},
341 "content": {"type": "string", "description": "Full memory content"},
342 "tier": {"type": "string", "enum": ["short", "mid", "long"], "default": "mid"},
343 "namespace": {"type": "string", "description": "Project/topic namespace"},
344 "tags": {"type": "array", "items": {"type": "string"}, "default": []},
345 "priority": {"type": "integer", "minimum": 1, "maximum": 10, "default": 5},
346 "confidence": {"type": "number", "minimum": 0.0, "maximum": 1.0, "default": 1.0},
347 "source": {"type": "string", "enum": ["user", "claude", "hook", "api", "cli", "import", "consolidation", "system", "chaos"], "default": "claude"},
348 "metadata": {"type": "object", "description": "Arbitrary JSON metadata", "default": {}},
349 "agent_id": {"type": "string", "description": "Agent identifier. If omitted, the server synthesizes an NHI-hardened default (ai:<client>@<host>:pid-<pid>, host:<host>:pid-<pid>-<uuid8>, or anonymous:pid-<pid>-<uuid8>)."},
350 "scope": {"type": "string", "enum": ["private", "team", "unit", "org", "collective"], "description": "Task 1.5 visibility scope. Defaults to private when unset. Stored as metadata.scope."},
351 "on_conflict": {"type": "string", "enum": ["error", "merge", "version"], "description": "v0.6.3.1 P2 (G6) — collision policy when (title, namespace) already exists. 'error' returns CONFLICT (default for v2-capable clients). 'merge' updates the existing row in place (legacy v0.6.3 behaviour, default for v1 clients). 'version' appends a monotonic suffix to the title — 'My Memory (2)', '(3)', ..."}
352 },
353 "required": ["title", "content"]
354 }
355 },
356 {
357 "name": "memory_recall",
358 "description": "Recall memories relevant to a context. Uses fuzzy OR matching, ranks by relevance + priority + access frequency + tier.",
359 "inputSchema": {
360 "type": "object",
361 "properties": {
362 "context": {"type": "string", "description": "What you're trying to remember"},
363 "namespace": {"type": "string", "description": "Filter by namespace"},
364 "limit": {"type": "integer", "default": 10, "maximum": 50},
365 "tags": {"type": "string", "description": "Filter by tag"},
366 "since": {"type": "string", "description": "Only memories created after this RFC3339 timestamp"},
367 "until": {"type": "string", "description": "Only memories created before this RFC3339 timestamp"},
368 "as_agent": {"type": "string", "description": "Querying agent's namespace position (Task 1.5). Enables scope-based visibility filtering — results include private memories at this namespace, team/unit/org memories at ancestor subtrees, and collective memories globally."},
369 "budget_tokens": {"type": "integer", "minimum": 0, "description": "Phase P6 (R1) — context-budget-aware recall. Return the highest-ranked memories whose cumulative content tokens (deterministic cl100k_base BPE; matches Claude/GPT context accounting) fit in N. If the top-ranked memory alone exceeds the budget, it is returned anyway with meta.budget_overflow=true (R1 always-return-at-least-one guarantee). budget_tokens=0 returns zero memories with overflow=false. Response meta block: budget_tokens_used, budget_tokens_remaining, memories_dropped, budget_overflow."},
370 "context_tokens": {"type": "array", "items": {"type": "string"}, "description": "v0.6.0.0 contextual recall — recent conversation tokens used to bias the query embedding at 70/30 (primary/context). Pulls results toward memories that match both the explicit query and nearby conversation topics."},
371 "format": {"type": "string", "enum": ["json", "toon", "toon_compact"], "default": "toon_compact", "description": "Response format. Default 'toon_compact' saves 79% tokens vs JSON. 'toon' includes timestamps. 'json' for structured parsing."}
372 },
373 "required": ["context"]
374 }
375 },
376 {
377 "name": "memory_search",
378 "description": "Search memories by exact keyword match (AND semantics).",
379 "inputSchema": {
380 "type": "object",
381 "properties": {
382 "query": {"type": "string"},
383 "namespace": {"type": "string"},
384 "tier": {"type": "string", "enum": ["short", "mid", "long"]},
385 "limit": {"type": "integer", "default": 20, "maximum": 200},
386 "agent_id": {"type": "string", "description": "Filter by metadata.agent_id (exact match)."},
387 "as_agent": {"type": "string", "description": "Querying agent's namespace position (Task 1.5) for scope-based visibility filtering."},
388 "format": {"type": "string", "enum": ["json", "toon", "toon_compact"], "default": "toon_compact", "description": "Response format. Default 'toon_compact' saves 79% tokens. 'json' for structured parsing."}
389 },
390 "required": ["query"]
391 }
392 },
393 {
394 "name": "memory_list",
395 "description": "List memories, optionally filtered by namespace or tier.",
396 "inputSchema": {
397 "type": "object",
398 "properties": {
399 "namespace": {"type": "string"},
400 "tier": {"type": "string", "enum": ["short", "mid", "long"]},
401 "limit": {"type": "integer", "default": 20, "maximum": 200},
402 "agent_id": {"type": "string", "description": "Filter by metadata.agent_id (exact match)."},
403 "format": {"type": "string", "enum": ["json", "toon", "toon_compact"], "default": "toon_compact", "description": "Response format. Default 'toon_compact' saves 79% tokens. 'json' for structured parsing."}
404 }
405 }
406 },
407 {
408 "name": "memory_get_taxonomy",
409 "description": "Pillar 1 / Stream A — return a hierarchical tree of namespaces with memory counts. Walks the `/`-delimited namespace paths grouped from live memories (expired rows excluded). Each node carries `count` (memories at exactly that namespace) and `subtree_count` (count plus all descendants visible within `depth`); the response also exposes `total_count` for the prefix and a `truncated` flag set when `limit` forced rows to be dropped from the tree.",
410 "inputSchema": {
411 "type": "object",
412 "properties": {
413 "namespace_prefix": {"type": "string", "description": "Restrict the tree to memories at this namespace OR any descendant. Omit to walk the full tree. Trailing '/' is tolerated."},
414 "depth": {"type": "integer", "minimum": 0, "maximum": 8, "default": 8, "description": "Max levels to descend below the prefix. Memories deeper than this still contribute to `subtree_count` of the boundary ancestor."},
415 "limit": {"type": "integer", "minimum": 1, "maximum": 10000, "default": 1000, "description": "Cap on `(namespace, count)` rows walked when assembling the tree. Densest namespaces win when truncated."}
416 }
417 }
418 },
419 {
420 "name": "memory_check_duplicate",
421 "description": "Pillar 2 / Stream D — pre-write near-duplicate check. Embeds `title + content`, scans live memories with stored embeddings (optionally restricted to `namespace`), and returns the highest-cosine match. `is_duplicate` is `nearest.similarity >= threshold`; the response also surfaces `suggested_merge` (the nearest memory's id) when the threshold is met. Threshold is clamped to a hard floor of 0.5 so permissive callers can't dress unrelated content as a merge candidate. Requires the embedder to be loaded (semantic tier or above).",
422 "inputSchema": {
423 "type": "object",
424 "properties": {
425 "title": {"type": "string", "description": "Title of the candidate memory. Combined with `content` to form the embedding input, matching memory_store's encoding."},
426 "content": {"type": "string", "description": "Content of the candidate memory."},
427 "namespace": {"type": "string", "description": "Restrict the duplicate scan to this namespace. Omit to scan all namespaces."},
428 "threshold": {"type": "number", "minimum": 0.5, "maximum": 1.0, "default": 0.85, "description": "Cosine similarity threshold for declaring a duplicate. Clamped to >= 0.5. Default 0.85 is tuned for MiniLM-L6-v2 — near-paraphrases land at 0.88+."}
429 },
430 "required": ["title", "content"]
431 }
432 },
433 {
434 "name": "memory_entity_register",
435 "description": "Pillar 2 / Stream B — register an entity (canonical name + aliases) under a namespace. Entities are stored as long-tier memories tagged 'entity' with metadata.kind='entity', so the (title, namespace) coordinate is shared with regular memories without ambiguity. Idempotent: re-registering the same canonical_name+namespace reuses the existing entity_id and merges any new aliases. Errors when the namespace+canonical_name already names a non-entity memory.",
436 "inputSchema": {
437 "type": "object",
438 "properties": {
439 "canonical_name": {"type": "string", "description": "Display name for the entity. Stored as the entity memory's title."},
440 "namespace": {"type": "string", "description": "Namespace under which the entity lives. Hierarchy paths (e.g. 'projects/alpha') are accepted."},
441 "aliases": {"type": "array", "items": {"type": "string"}, "description": "Aliases that should resolve to this entity. Blank entries are skipped; duplicates are de-duped via the entity_aliases primary key."},
442 "metadata": {"type": "object", "description": "Arbitrary metadata to attach to the entity memory. Caller-supplied 'kind' is overwritten with 'entity'; agent_id is stamped from the NHI caller when not specified."},
443 "agent_id": {"type": "string", "description": "Override the caller's resolved NHI for the entity memory's metadata.agent_id."}
444 },
445 "required": ["canonical_name", "namespace"]
446 }
447 },
448 {
449 "name": "memory_entity_get_by_alias",
450 "description": "Pillar 2 / Stream B — resolve an alias to its registered entity. When 'namespace' is provided, only entities in that namespace are returned. When omitted, the most recently created matching entity wins. Returns null when no entity claims the alias under the given filter.",
451 "inputSchema": {
452 "type": "object",
453 "properties": {
454 "alias": {"type": "string", "description": "Alias string to resolve. Whitespace is trimmed."},
455 "namespace": {"type": "string", "description": "Restrict the resolution to this namespace. Omit to search all namespaces."}
456 },
457 "required": ["alias"]
458 }
459 },
460 {
461 "name": "memory_kg_timeline",
462 "description": "Pillar 2 / Stream C — ordered fact timeline for an entity. Returns outbound links from `source_id` (e.g. an entity registered via memory_entity_register) with their temporal-validity columns (valid_from, valid_until, observed_by) and the target memory's title/namespace. Events are ordered by valid_from ASC; rows with NULL valid_from are excluded. Cross-namespace by design — callers can post-filter by target_namespace if needed.",
463 "inputSchema": {
464 "type": "object",
465 "properties": {
466 "source_id": {"type": "string", "description": "Memory ID whose outbound assertions form the timeline. Typically an entity_id from memory_entity_register, but any memory works."},
467 "since": {"type": "string", "description": "RFC3339 timestamp; events with valid_from earlier than this are excluded (inclusive boundary)."},
468 "until": {"type": "string", "description": "RFC3339 timestamp; events with valid_from later than this are excluded (inclusive boundary)."},
469 "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 200, "description": "Max events returned. Clamped to [1, 1000]."}
470 },
471 "required": ["source_id"]
472 }
473 },
474 {
475 "name": "memory_kg_invalidate",
476 "description": "Pillar 2 / Stream C — mark a KG link as superseded by setting its `valid_until` column. The link is identified by the (source_id, target_id, relation) triple (memory_links has no separate id column). When `valid_until` is omitted, the current wall-clock time is used. Idempotent: repeated calls overwrite the prior value and the response reports `previous_valid_until` so callers can detect the overwrite. Returns `found: false` when no link matches the triple.",
477 "inputSchema": {
478 "type": "object",
479 "properties": {
480 "source_id": {"type": "string", "description": "Source memory ID of the link to invalidate."},
481 "target_id": {"type": "string", "description": "Target memory ID of the link to invalidate."},
482 "relation": {"type": "string", "description": "Relation label of the link (e.g. 'related_to', 'supersedes', 'derived_from'). Must be a recognized relation."},
483 "valid_until": {"type": "string", "description": "RFC3339 timestamp marking when the assertion stops being valid. Defaults to the current time when omitted."}
484 },
485 "required": ["source_id", "target_id", "relation"]
486 }
487 },
488 {
489 "name": "memory_kg_query",
490 "description": "Pillar 2 / Stream C — outbound KG traversal from a source memory. Returns one node per link reachable from `source_id` within `max_depth` hops, with the link's temporal-validity columns (valid_from, valid_until, observed_by) and the target memory's title/namespace. Multi-hop traversal uses a recursive CTE with cycle detection — chains only extend through links that pass every filter on every hop. Filters: `valid_at` keeps only links valid at that instant; `allowed_agents` keeps only links observed by an agent in the set (empty list returns zero rows by design — empty allowlist means 'no agents are trusted'). Ordered by depth ASC, then COALESCE(valid_from, created_at) ASC, for stable shallow-first display. `max_depth` ceiling is 5 (matches the published performance budget); larger values return an explicit error.",
491 "inputSchema": {
492 "type": "object",
493 "properties": {
494 "source_id": {"type": "string", "description": "Memory ID whose outbound links form the traversal frontier. Typically an entity_id from memory_entity_register, but any memory works."},
495 "max_depth": {"type": "integer", "minimum": 1, "maximum": 5, "default": 1, "description": "Hops from the source. Supported range: 1..=5 (matches the published performance budget for `memory_kg_query`). Larger values return an explicit error."},
496 "valid_at": {"type": "string", "description": "RFC3339 timestamp; only links valid at this instant (valid_from <= valid_at AND (valid_until IS NULL OR valid_until > valid_at)) are returned. Omit to skip the temporal filter (NULL valid_from rows are then included)."},
497 "allowed_agents": {"type": "array", "items": {"type": "string"}, "description": "If provided, only links whose observed_by is in this set are returned. An empty array returns zero rows. Omit to skip the agent filter."},
498 "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 200, "description": "Max nodes returned across all depths. Clamped to [1, 1000]."}
499 },
500 "required": ["source_id"]
501 }
502 },
503 {
504 "name": "memory_delete",
505 "description": "Delete a memory by ID.",
506 "inputSchema": {
507 "type": "object",
508 "properties": {
509 "id": {"type": "string"}
510 },
511 "required": ["id"]
512 }
513 },
514 {
515 "name": "memory_promote",
516 "description": "Promote a memory. Default: bump tier to long-term (permanent, clears expiry). Task 1.7: when 'to_namespace' is supplied, clone the memory to a hierarchical-ancestor namespace and link clone → source with 'derived_from'. Original is untouched.",
517 "inputSchema": {
518 "type": "object",
519 "properties": {
520 "id": {"type": "string"},
521 "to_namespace": {"type": "string", "description": "Task 1.7: hierarchical-ancestor namespace to clone this memory into. Must be a proper ancestor (per namespace_ancestors()). Original memory stays put; a new memory with derived_from link is created at the target namespace."}
522 },
523 "required": ["id"]
524 }
525 },
526 {
527 "name": "memory_forget",
528 "description": "Bulk delete memories matching a pattern, namespace, or tier. Archives before deletion. Use dry_run to preview.",
529 "inputSchema": {
530 "type": "object",
531 "properties": {
532 "namespace": {"type": "string"},
533 "pattern": {"type": "string"},
534 "tier": {"type": "string", "enum": ["short", "mid", "long"]},
535 "dry_run": {"type": "boolean", "default": false, "description": "If true, report what would be deleted without deleting"}
536 }
537 }
538 },
539 {
540 "name": "memory_stats",
541 "description": "Get memory store statistics.",
542 "inputSchema": { "type": "object", "properties": {} }
543 },
544 {
545 "name": "memory_update",
546 "description": "Update an existing memory by ID. Only provided fields are changed.",
547 "inputSchema": {
548 "type": "object",
549 "properties": {
550 "id": {"type": "string", "description": "Memory ID to update"},
551 "title": {"type": "string"},
552 "content": {"type": "string"},
553 "tier": {"type": "string", "enum": ["short", "mid", "long"]},
554 "namespace": {"type": "string"},
555 "tags": {"type": "array", "items": {"type": "string"}},
556 "priority": {"type": "integer", "minimum": 1, "maximum": 10},
557 "confidence": {"type": "number", "minimum": 0.0, "maximum": 1.0},
558 "expires_at": {"type": "string", "description": "Expiry timestamp (RFC3339), or null to clear"},
559 "metadata": {"type": "object", "description": "Arbitrary JSON metadata"}
560 },
561 "required": ["id"]
562 }
563 },
564 {
565 "name": "memory_get",
566 "description": "Get a specific memory by ID, including its links.",
567 "inputSchema": {
568 "type": "object",
569 "properties": {
570 "id": {"type": "string", "description": "Memory ID to retrieve"}
571 },
572 "required": ["id"]
573 }
574 },
575 {
576 "name": "memory_link",
577 "description": "Create a link between two memories.",
578 "inputSchema": {
579 "type": "object",
580 "properties": {
581 "source_id": {"type": "string", "description": "Source memory ID"},
582 "target_id": {"type": "string", "description": "Target memory ID"},
583 "relation": {"type": "string", "enum": ["related_to", "supersedes", "contradicts", "derived_from"], "default": "related_to"}
584 },
585 "required": ["source_id", "target_id"]
586 }
587 },
588 {
589 "name": "memory_get_links",
590 "description": "Get all links for a memory (both directions).",
591 "inputSchema": {
592 "type": "object",
593 "properties": {
594 "id": {"type": "string", "description": "Memory ID to get links for"}
595 },
596 "required": ["id"]
597 }
598 },
599 {
600 "name": "memory_consolidate",
601 "description": "Consolidate multiple memories into one long-term summary. Deletes source memories and creates derived_from links. If summary is omitted and LLM is available (smart/autonomous tier), auto-generates a summary.",
602 "inputSchema": {
603 "type": "object",
604 "properties": {
605 "ids": {"type": "array", "items": {"type": "string"}, "minItems": 2, "maxItems": 100, "description": "Memory IDs to consolidate (minimum 2, maximum 100)"},
606 "title": {"type": "string", "description": "Title for the consolidated memory"},
607 "summary": {"type": "string", "description": "Summary content (optional — auto-generated via LLM if omitted at smart/autonomous tier)"},
608 "namespace": {"type": "string", "default": "global"}
609 },
610 "required": ["ids", "title"]
611 }
612 },
613 {
614 "name": "memory_capabilities",
615 "description": "Report the active feature tier, loaded models, and available capabilities of the memory system. Returns capabilities schema v2 by default (recommended). Pass accept=\"v1\" for the legacy shape used before v0.6.3.1. v0.6.4 — pass family=<name> to enumerate that family's tools; add include_schema=true to retrieve full schemas inline (NHI runtime-expansion path).",
616 "inputSchema": {
617 "type": "object",
618 "properties": {
619 "accept": {
620 "type": "string",
621 "enum": ["v1", "v2"],
622 "default": "v2",
623 "description": "Capabilities-schema version. v2 is the honest, runtime-overlaid shape (default). v1 returns the legacy pre-v0.6.3.1 shape for backward compat."
624 },
625 "family": {
626 "type": "string",
627 "enum": ["core", "lifecycle", "graph", "governance", "power", "meta", "archive", "other"],
628 "description": "v0.6.4 — when set, returns the tool list (or full schemas with include_schema=true) for that family instead of the global capabilities document. Used by NHI agents to opt into a tool family at runtime without restarting the MCP server."
629 },
630 "include_schema": {
631 "type": "boolean",
632 "default": false,
633 "description": "v0.6.4 — when true, return full MCP-style tool definitions for each tool in the requested family. Requires family=<name>."
634 }
635 }
636 }
637 },
638 {
639 "name": "memory_expand_query",
640 "description": "Use LLM to expand a search query into additional semantically related terms. Requires smart or autonomous tier.",
641 "inputSchema": {
642 "type": "object",
643 "properties": {
644 "query": {"type": "string", "description": "The search query to expand"}
645 },
646 "required": ["query"]
647 }
648 },
649 {
650 "name": "memory_auto_tag",
651 "description": "Use LLM to auto-generate tags for a memory. Requires smart or autonomous tier.",
652 "inputSchema": {
653 "type": "object",
654 "properties": {
655 "id": {"type": "string", "description": "Memory ID to auto-tag"}
656 },
657 "required": ["id"]
658 }
659 },
660 {
661 "name": "memory_detect_contradiction",
662 "description": "Use LLM to check if two memories contradict each other. Requires smart or autonomous tier.",
663 "inputSchema": {
664 "type": "object",
665 "properties": {
666 "id_a": {"type": "string", "description": "First memory ID"},
667 "id_b": {"type": "string", "description": "Second memory ID"}
668 },
669 "required": ["id_a", "id_b"]
670 }
671 },
672 {
673 "name": "memory_archive_list",
674 "description": "List archived (expired) memories. Archived memories are preserved before GC deletion.",
675 "inputSchema": {
676 "type": "object",
677 "properties": {
678 "namespace": {"type": "string", "description": "Filter by namespace"},
679 "limit": {"type": "integer", "description": "Max results (default 50, max 1000)"},
680 "offset": {"type": "integer", "description": "Pagination offset"}
681 }
682 }
683 },
684 {
685 "name": "memory_archive_restore",
686 "description": "Restore an archived memory back to the active memory store (expires_at cleared).",
687 "inputSchema": {
688 "type": "object",
689 "properties": {
690 "id": {"type": "string", "description": "ID of the archived memory to restore"}
691 },
692 "required": ["id"]
693 }
694 },
695 {
696 "name": "memory_archive_purge",
697 "description": "Permanently delete archived memories. Optionally only those older than N days.",
698 "inputSchema": {
699 "type": "object",
700 "properties": {
701 "older_than_days": {"type": "integer", "description": "Only purge entries archived more than N days ago. Omit to purge all."}
702 }
703 }
704 },
705 {
706 "name": "memory_archive_stats",
707 "description": "Show archive statistics: total count and breakdown by namespace.",
708 "inputSchema": {
709 "type": "object",
710 "properties": {}
711 }
712 },
713 {
714 "name": "memory_gc",
715 "description": "Trigger garbage collection on expired memories. Archives them before deletion. Supports dry_run mode.",
716 "inputSchema": {
717 "type": "object",
718 "properties": {
719 "dry_run": {"type": "boolean", "default": false, "description": "If true, report what would be collected without deleting"}
720 }
721 }
722 },
723 {
724 "name": "memory_session_start",
725 "description": "Auto-recall recent memories on session start. Returns the most recently accessed/updated memories. If LLM is available (smart/autonomous tier), returns an AI-generated summary.",
726 "inputSchema": {
727 "type": "object",
728 "properties": {
729 "namespace": {"type": "string", "description": "Optional namespace to scope recall"},
730 "limit": {"type": "integer", "default": 10, "maximum": 50},
731 "format": {"type": "string", "enum": ["json", "toon", "toon_compact"], "default": "toon_compact"}
732 }
733 }
734 },
735 {
736 "name": "memory_namespace_set_standard",
737 "description": "Set a memory as the standard/policy for a namespace. Auto-prepended to recall and session_start. Supports rule layering (global '*' + parent chain + namespace). Task 1.8: accepts optional `governance` policy object merged into the standard memory's metadata.",
738 "inputSchema": {
739 "type": "object",
740 "properties": {
741 "namespace": {"type": "string", "description": "Namespace to set the standard for"},
742 "id": {"type": "string", "description": "Memory ID to use as the standard"},
743 "parent": {"type": "string", "description": "Optional parent namespace to inherit standards from (rule layering)"},
744 "governance": {
745 "type": "object",
746 "description": "Task 1.8 governance policy. Stored in metadata.governance on the standard memory. Consumed by Task 1.9 enforcement + 1.10 approver types. v0.6.3.1 (P4, G1): adds `inherit` flag controlling parent-namespace policy bubbling.",
747 "properties": {
748 "write": {"type": "string", "enum": ["any", "registered", "owner", "approve"]},
749 "promote": {"type": "string", "enum": ["any", "registered", "owner", "approve"]},
750 "delete": {"type": "string", "enum": ["any", "registered", "owner", "approve"]},
751 "approver": {"description": "ApproverType: \"human\" | {\"agent\": \"<id>\"} | {\"consensus\": <n>}"},
752 "inherit": {"type": "boolean", "default": true, "description": "v0.6.3.1 (P4, G1): when true (default), missing policy at this namespace falls through to parent in the chain. Set false to opt this subtree out of parent inheritance."}
753 }
754 }
755 },
756 "required": ["namespace", "id"]
757 }
758 },
759 {
760 "name": "memory_namespace_get_standard",
761 "description": "Get the standard/policy memory for a namespace, if one is set. With inherit=true returns the full N-level resolved chain (Task 1.6).",
762 "inputSchema": {
763 "type": "object",
764 "properties": {
765 "namespace": {"type": "string", "description": "Namespace to get the standard for"},
766 "inherit": {"type": "boolean", "default": false, "description": "Task 1.6: when true, return the full inheritance chain (global * → ancestors → namespace) as a list instead of the single namespace's standard."}
767 },
768 "required": ["namespace"]
769 }
770 },
771 {
772 "name": "memory_namespace_clear_standard",
773 "description": "Clear the standard/policy for a namespace.",
774 "inputSchema": {
775 "type": "object",
776 "properties": {
777 "namespace": {"type": "string", "description": "Namespace to clear the standard for"}
778 },
779 "required": ["namespace"]
780 }
781 },
782 {
783 "name": "memory_pending_list",
784 "description": "List pending governance-queued actions (Task 1.9). Filter by status: pending (default) / approved / rejected.",
785 "inputSchema": {
786 "type": "object",
787 "properties": {
788 "status": {"type": "string", "enum": ["pending", "approved", "rejected"]},
789 "limit": {"type": "integer", "default": 100, "maximum": 1000}
790 }
791 }
792 },
793 {
794 "name": "memory_pending_approve",
795 "description": "Approve a pending action by id (Task 1.9). Caller identity is stamped as decided_by.",
796 "inputSchema": {
797 "type": "object",
798 "properties": {
799 "id": {"type": "string", "description": "Pending action id"}
800 },
801 "required": ["id"]
802 }
803 },
804 {
805 "name": "memory_pending_reject",
806 "description": "Reject a pending action by id (Task 1.9). Caller identity is stamped as decided_by.",
807 "inputSchema": {
808 "type": "object",
809 "properties": {
810 "id": {"type": "string", "description": "Pending action id"}
811 },
812 "required": ["id"]
813 }
814 },
815 {
816 "name": "memory_agent_register",
817 "description": "Register an agent in the reserved _agents namespace. Stores agent_type and capabilities, refreshes last_seen_at on re-registration while preserving registered_at. agent_id is claimed, not attested.",
818 "inputSchema": {
819 "type": "object",
820 "properties": {
821 "agent_id": {"type": "string", "description": "Agent identifier (same validation as metadata.agent_id)"},
822 "agent_type": {"type": "string", "enum": ["ai:claude-opus-4.6", "ai:claude-opus-4.7", "ai:codex-5.4", "ai:grok-4.2", "human", "system"]},
823 "capabilities": {"type": "array", "items": {"type": "string"}, "default": [], "description": "Optional capability tags"}
824 },
825 "required": ["agent_id", "agent_type"]
826 }
827 },
828 {
829 "name": "memory_agent_list",
830 "description": "List every registered agent.",
831 "inputSchema": {
832 "type": "object",
833 "properties": {}
834 }
835 },
836 {
837 "name": "memory_notify",
838 "description": "v0.6.0.0 — send a message from the caller to another agent. Stored as a memory in the reserved `_messages/<target>` namespace with sender metadata. The sender is the caller's resolved agent_id. Target agent reads via `memory_inbox`. Payload is a free-form string.",
839 "inputSchema": {
840 "type": "object",
841 "properties": {
842 "target_agent_id": {"type": "string", "description": "Recipient agent_id (same validation as metadata.agent_id)"},
843 "title": {"type": "string", "description": "Short subject (≤ 200 chars, required)"},
844 "payload": {"type": "string", "description": "Message body (required)"},
845 "priority": {"type": "integer", "minimum": 1, "maximum": 10, "default": 5},
846 "tier": {"type": "string", "enum": ["short", "mid", "long"], "default": "mid", "description": "short TTL default = 6h, mid = 7d, long = no expiry"}
847 },
848 "required": ["target_agent_id", "title", "payload"]
849 }
850 },
851 {
852 "name": "memory_inbox",
853 "description": "v0.6.0.0 — list messages sent to an agent via memory_notify. Reads the reserved `_messages/<agent_id>` namespace. `access_count == 0` is the conventional unread marker; recalling/reading a memory increments access_count via the normal touch path.",
854 "inputSchema": {
855 "type": "object",
856 "properties": {
857 "agent_id": {"type": "string", "description": "Recipient agent_id. Defaults to the caller's resolved agent_id."},
858 "unread_only": {"type": "boolean", "default": false, "description": "When true, return only messages with access_count == 0."},
859 "limit": {"type": "integer", "default": 50, "maximum": 500}
860 }
861 }
862 },
863 {
864 "name": "memory_subscribe",
865 "description": "v0.6.0.0 — register a webhook subscription. Events fire on memory_store today and additional events in v0.6.1+. Payload is a JSON body signed with HMAC-SHA256 when a secret is supplied (header: X-Ai-Memory-Signature: sha256=<hex>). URL must be https unless the host is a loopback address. The shared secret is stored hashed only; the plaintext the operator supplies is what they verify signatures with.",
866 "inputSchema": {
867 "type": "object",
868 "properties": {
869 "url": {"type": "string", "description": "https:// endpoint (or http:// for loopback). SSRF guard rejects private-range IPs."},
870 "events": {"type": "string", "default": "*", "description": "Comma-separated event whitelist or `*` for all. Known events: memory_store, memory_delete, memory_promote."},
871 "secret": {"type": "string", "description": "Optional shared secret for HMAC signing. If omitted, payload is unsigned."},
872 "namespace_filter": {"type": "string", "description": "Optional exact namespace match."},
873 "agent_filter": {"type": "string", "description": "Optional agent_id filter — only events whose stored agent_id matches this value will fire."}
874 },
875 "required": ["url"]
876 }
877 },
878 {
879 "name": "memory_unsubscribe",
880 "description": "v0.6.0.0 — delete a subscription by id.",
881 "inputSchema": {
882 "type": "object",
883 "properties": {
884 "id": {"type": "string"}
885 },
886 "required": ["id"]
887 }
888 },
889 {
890 "name": "memory_list_subscriptions",
891 "description": "v0.6.0.0 — list active webhook subscriptions. Secrets are not exposed; only `secret_hash` is stored and even that is not returned.",
892 "inputSchema": {
893 "type": "object",
894 "properties": {}
895 }
896 }
897 ]
898 })
899}
900
901fn prompt_definitions() -> Value {
905 json!({
906 "prompts": [
907 {
908 "name": "recall-first",
909 "description": "System prompt for AI clients: proactive memory recall, TOON format, tier strategy.",
910 "arguments": [
911 {
912 "name": "namespace",
913 "description": "Optional namespace to scope recall (e.g. project name)",
914 "required": false
915 }
916 ]
917 },
918 {
919 "name": "memory-workflow",
920 "description": "Quick reference card for memory tool usage patterns."
921 }
922 ]
923 })
924}
925
926fn prompt_content(name: &str, params: &Value) -> Result<Value, String> {
928 match name {
929 "recall-first" => {
930 let ns_hint = params
931 .get("arguments")
932 .and_then(|a| a.get("namespace"))
933 .and_then(|v| v.as_str())
934 .map(|ns| format!(" Scope recall to namespace \"{ns}\" when relevant."))
935 .unwrap_or_default();
936
937 Ok(json!({
938 "messages": [{
939 "role": "user",
940 "content": {
941 "type": "text",
942 "text": format!(
943 "You have access to a persistent memory system (ai-memory). Follow these rules:\n\
944 1. RECALL FIRST: At conversation start, call memory_recall with the user's apparent topic. Before answering any question about prior work, recall first.\n\
945 2. STORE LEARNINGS: When the user corrects you or teaches something, call memory_store with tier:long, priority:9.\n\
946 3. TOON FORMAT: All recall/list/search responses default to TOON compact (79% smaller than JSON). Pass format:\"json\" only if you need structured parsing.\n\
947 4. TIERS: short=6h ephemeral, mid=7d working knowledge, long=permanent. Mid auto-promotes to long at 5 accesses.\n\
948 5. DEDUP: Storing with an existing title+namespace updates the existing memory, not a duplicate.\n\
949 6. NAMESPACES: Organize by project/topic. Always pass namespace when storing and recalling.\n\
950 7. CAPABILITIES: Call memory_capabilities once per session to discover available features (tier-dependent).\n\
951 8. TAGS: Use tags for cross-cutting concerns. memory_auto_tag can generate them if available.{ns_hint}")
952 }
953 }]
954 }))
955 }
956 "memory-workflow" => Ok(json!({
957 "messages": [{
958 "role": "user",
959 "content": {
960 "type": "text",
961 "text": "\
962 STORE: memory_store(title, content, tier, namespace, tags, priority) — dedup by title+ns\n\
963 RECALL: memory_recall(context, namespace) → ranked results (TOON compact default)\n\
964 SEARCH: memory_search(query, namespace) → exact AND match (TOON compact default)\n\
965 LIST: memory_list(namespace, tier) → browse with filters (TOON compact default)\n\
966 GET: memory_get(id) → single memory with links\n\
967 PROMOTE: memory_promote(id) — mid→long, clears expiry\n\
968 CONSOLIDATE: memory_consolidate(ids, title) — merge N→1, LLM summary if available\n\
969 LINK: memory_link(source_id, target_id, relation) — related_to|supersedes|contradicts|derived_from\n\
970 TAG: memory_auto_tag(id) — LLM generates tags (smart+ tier)\n\
971 EXPAND: memory_expand_query(query) — LLM broadens search terms (smart+ tier)\n\
972 CONTRADICT: memory_detect_contradiction(id_a, id_b) — LLM checks conflict (smart+ tier)"
973 }
974 }]
975 })),
976 _ => Err(format!("unknown prompt: {name}")),
977 }
978}
979
980const AUTONOMY_MIN_CONTENT_LEN: usize = 50;
986
987#[derive(Debug, Clone, Copy, PartialEq, Eq)]
996enum OnConflict {
997 Error,
998 Merge,
999 Version,
1000}
1001
1002impl OnConflict {
1003 fn parse(s: &str) -> Result<Self, String> {
1004 match s {
1005 "error" => Ok(Self::Error),
1006 "merge" => Ok(Self::Merge),
1007 "version" => Ok(Self::Version),
1008 other => Err(format!(
1009 "invalid on_conflict '{other}' (expected error|merge|version)"
1010 )),
1011 }
1012 }
1013}
1014
1015fn default_on_conflict_for_client(mcp_client: Option<&str>) -> OnConflict {
1023 let Some(client) = mcp_client else {
1024 return OnConflict::Merge;
1025 };
1026 let head = client.split('@').next().unwrap_or(client);
1028 let normalized = head.to_ascii_lowercase();
1029 const V2_CLIENT_PREFIXES: &[&str] = &["ai:claude-code", "ai:ai-memory-cli/v2"];
1031 for prefix in V2_CLIENT_PREFIXES {
1032 if normalized.starts_with(prefix) {
1033 return OnConflict::Error;
1034 }
1035 }
1036 OnConflict::Merge
1037}
1038
1039#[allow(clippy::too_many_lines)]
1040#[allow(clippy::too_many_arguments)]
1041fn handle_store(
1042 conn: &rusqlite::Connection,
1043 db_path: &Path,
1044 params: &Value,
1045 embedder: Option<&Embedder>,
1046 llm: Option<&OllamaClient>,
1047 vector_index: Option<&VectorIndex>,
1048 resolved_ttl: &crate::config::ResolvedTtl,
1049 autonomous_hooks: bool,
1050 mcp_client: Option<&str>,
1051) -> Result<Value, String> {
1052 let title = params["title"].as_str().ok_or("title is required")?;
1053 let content = params["content"].as_str().ok_or("content is required")?;
1054 let tier_str = params["tier"].as_str().unwrap_or("mid");
1055 let tier = Tier::from_str(tier_str).ok_or(format!("invalid tier: {tier_str}"))?;
1056 let namespace = params["namespace"].as_str().unwrap_or("global").to_string();
1057 let source = params["source"].as_str().unwrap_or("claude").to_string();
1058 let on_conflict = if let Some(s) = params["on_conflict"].as_str() {
1060 OnConflict::parse(s)?
1061 } else {
1062 default_on_conflict_for_client(mcp_client)
1063 };
1064 let priority = i32::try_from(params["priority"].as_i64().unwrap_or(5)).expect("i64 as i32");
1065 let confidence = params["confidence"].as_f64().unwrap_or(1.0);
1066 let tags: Vec<String> = params["tags"]
1067 .as_array()
1068 .map(|a| {
1069 a.iter()
1070 .filter_map(|v| v.as_str().map(String::from))
1071 .collect()
1072 })
1073 .unwrap_or_default();
1074
1075 validate::validate_title(title).map_err(|e| e.to_string())?;
1076 validate::validate_content(content).map_err(|e| e.to_string())?;
1077 validate::validate_namespace(&namespace).map_err(|e| e.to_string())?;
1078 validate::validate_source(&source).map_err(|e| e.to_string())?;
1079 validate::validate_tags(&tags).map_err(|e| e.to_string())?;
1080 validate::validate_priority(priority).map_err(|e| e.to_string())?;
1081 validate::validate_confidence(confidence).map_err(|e| e.to_string())?;
1082
1083 let mut metadata = if params["metadata"].is_object() {
1084 params["metadata"].clone()
1085 } else {
1086 serde_json::json!({})
1087 };
1088 let explicit_agent_id = params["agent_id"]
1095 .as_str()
1096 .or_else(|| metadata.get("agent_id").and_then(serde_json::Value::as_str));
1097 let agent_id = crate::identity::resolve_agent_id(explicit_agent_id, mcp_client)
1098 .map_err(|e| e.to_string())?;
1099 if let Some(obj) = metadata.as_object_mut() {
1100 obj.insert(
1101 "agent_id".to_string(),
1102 serde_json::Value::String(agent_id.clone()),
1103 );
1104 }
1105 let explicit_scope = params["scope"]
1107 .as_str()
1108 .or_else(|| metadata.get("scope").and_then(serde_json::Value::as_str))
1109 .map(str::to_string);
1110 if let Some(ref s) = explicit_scope {
1111 validate::validate_scope(s).map_err(|e| e.to_string())?;
1112 if let Some(obj) = metadata.as_object_mut() {
1113 obj.insert("scope".to_string(), serde_json::Value::String(s.clone()));
1114 }
1115 }
1116 validate::validate_metadata(&metadata).map_err(|e| e.to_string())?;
1117
1118 let now = chrono::Utc::now();
1119 let expires_at = resolved_ttl
1120 .ttl_for_tier(&tier)
1121 .map(|s| (now + chrono::Duration::seconds(s)).to_rfc3339());
1122
1123 let resolved_title = match on_conflict {
1128 OnConflict::Error => {
1129 if let Some(existing_id) =
1130 db::find_by_title_namespace(conn, title, &namespace).map_err(|e| e.to_string())?
1131 {
1132 return Err(format!(
1133 "CONFLICT: memory with title '{title}' already exists in namespace \
1134 '{namespace}' (existing id: {existing_id}). Pass \
1135 on_conflict='merge' to update in place or 'version' to suffix the title."
1136 ));
1137 }
1138 title.to_string()
1139 }
1140 OnConflict::Version => {
1141 db::next_versioned_title(conn, title, &namespace).map_err(|e| e.to_string())?
1142 }
1143 OnConflict::Merge => title.to_string(),
1144 };
1145
1146 let mem = Memory {
1147 id: uuid::Uuid::new_v4().to_string(),
1148 tier,
1149 namespace,
1150 title: resolved_title,
1151 content: content.to_string(),
1152 tags,
1153 priority: priority.clamp(1, 10),
1154 confidence: confidence.clamp(0.0, 1.0),
1155 source,
1156 access_count: 0,
1157 created_at: now.to_rfc3339(),
1158 updated_at: now.to_rfc3339(),
1159 last_accessed_at: None,
1160 expires_at,
1161 metadata,
1162 };
1163
1164 {
1166 use crate::models::{GovernanceDecision, GovernedAction};
1167 let payload = serde_json::to_value(&mem).unwrap_or_default();
1168 match db::enforce_governance(
1169 conn,
1170 GovernedAction::Store,
1171 &mem.namespace,
1172 &agent_id,
1173 None,
1174 None,
1175 &payload,
1176 )
1177 .map_err(|e| e.to_string())?
1178 {
1179 GovernanceDecision::Allow => {}
1180 GovernanceDecision::Deny(reason) => {
1181 return Err(format!("store denied by governance: {reason}"));
1182 }
1183 GovernanceDecision::Pending(pending_id) => {
1184 return Ok(json!({
1185 "status": "pending",
1186 "pending_id": pending_id,
1187 "reason": "governance requires approval",
1188 "action": "store",
1189 "namespace": mem.namespace,
1190 }));
1191 }
1192 }
1193 }
1194
1195 let existing = db::find_contradictions(conn, &mem.title, &mem.namespace).unwrap_or_default();
1203 let exact_dup = if matches!(on_conflict, OnConflict::Merge) {
1204 existing
1205 .iter()
1206 .find(|c| c.title == mem.title && c.namespace == mem.namespace)
1207 } else {
1208 None
1209 };
1210 if let Some(dup) = exact_dup {
1211 let preserved_metadata = crate::identity::preserve_agent_id(&dup.metadata, &mem.metadata);
1216 let (_found, content_changed) = db::update(
1217 conn,
1218 &dup.id,
1219 None, Some(mem.content.as_str()), Some(&mem.tier), None, Some(&mem.tags), Some(mem.priority), Some(mem.confidence), None, Some(&preserved_metadata), )
1229 .map_err(|e| e.to_string())?;
1230 if content_changed && let Some(emb) = embedder {
1232 let text = format!("{} {}", mem.title, mem.content);
1233 if let Ok(embedding) = emb.embed(&text) {
1234 let _ = db::set_embedding(conn, &dup.id, &embedding);
1235 if let Some(idx) = vector_index {
1236 idx.remove(&dup.id);
1237 idx.insert(dup.id.clone(), embedding);
1238 }
1239 }
1240 }
1241 let echoed_agent_id = preserved_metadata
1243 .get("agent_id")
1244 .and_then(|v| v.as_str())
1245 .map(str::to_string);
1246 return Ok(json!({
1247 "id": dup.id,
1248 "tier": mem.tier,
1249 "title": mem.title,
1250 "namespace": mem.namespace,
1251 "agent_id": echoed_agent_id,
1252 "duplicate": true,
1253 "action": "updated existing memory"
1254 }));
1255 }
1256
1257 let actual_id = db::insert(conn, &mem).map_err(|e| e.to_string())?;
1258
1259 crate::audit::emit(crate::audit::EventBuilder::new(
1261 crate::audit::AuditAction::Store,
1262 crate::audit::actor(
1263 agent_id.clone(),
1264 mcp_client.map_or("host_fallback", |_| "mcp_client_info"),
1265 explicit_scope.clone(),
1266 ),
1267 crate::audit::target_memory(
1268 actual_id.clone(),
1269 mem.namespace.clone(),
1270 Some(mem.title.clone()),
1271 Some(mem.tier.to_string()),
1272 explicit_scope.clone(),
1273 ),
1274 ));
1275
1276 let contradiction_ids: Vec<String> = existing
1278 .iter()
1279 .filter(|c| c.id != mem.id && c.id != actual_id)
1280 .map(|c| c.id.clone())
1281 .collect();
1282
1283 if let Some(emb) = embedder {
1285 let text = format!("{} {}", mem.title, mem.content);
1286 match emb.embed(&text) {
1287 Ok(embedding) => {
1288 if let Err(e) = db::set_embedding(conn, &actual_id, &embedding) {
1289 tracing::warn!("failed to store embedding for {}: {}", &actual_id, e);
1290 }
1291 if let Some(idx) = vector_index {
1293 idx.insert(actual_id.clone(), embedding);
1294 }
1295 }
1296 Err(e) => {
1297 tracing::warn!("failed to generate embedding for {}: {}", &actual_id, e);
1298 }
1299 }
1300 }
1301
1302 let mut auto_tags: Vec<String> = Vec::new();
1310 let mut confirmed_contradictions: Vec<String> = Vec::new();
1311 let hooks_skipped_reason: Option<&'static str> = if !autonomous_hooks {
1312 Some("disabled")
1313 } else if llm.is_none() {
1314 Some("no_llm")
1315 } else if mem.content.len() < AUTONOMY_MIN_CONTENT_LEN {
1316 Some("content_too_short")
1317 } else if mem.namespace.starts_with('_') {
1318 Some("internal_namespace")
1319 } else {
1320 None
1321 };
1322 if hooks_skipped_reason.is_none()
1323 && let Some(llm_client) = llm
1324 {
1325 match llm_client.auto_tag(&mem.title, &mem.content) {
1326 Ok(tags) => {
1327 auto_tags = tags.into_iter().take(8).collect();
1328 }
1329 Err(e) => {
1330 tracing::warn!("auto_tag hook failed for {}: {}", &actual_id, e);
1331 }
1332 }
1333 for cand in &existing {
1334 if cand.id == actual_id || cand.id == mem.id {
1335 continue;
1336 }
1337 match llm_client.detect_contradiction(&mem.content, &cand.content) {
1338 Ok(true) => confirmed_contradictions.push(cand.id.clone()),
1339 Ok(false) => {}
1340 Err(e) => {
1341 tracing::warn!(
1342 "detect_contradiction hook failed ({actual_id} vs {}): {e}",
1343 cand.id
1344 );
1345 }
1346 }
1347 }
1348 if !auto_tags.is_empty() || !confirmed_contradictions.is_empty() {
1351 let mut updated_metadata = mem.metadata.clone();
1352 if let Some(obj) = updated_metadata.as_object_mut() {
1353 if !auto_tags.is_empty() {
1354 obj.insert("auto_tags".to_string(), json!(auto_tags));
1355 }
1356 if !confirmed_contradictions.is_empty() {
1357 obj.insert(
1358 "confirmed_contradictions".to_string(),
1359 json!(confirmed_contradictions),
1360 );
1361 }
1362 }
1363 if let Err(e) = db::update(
1364 conn,
1365 &actual_id,
1366 None,
1367 None,
1368 None,
1369 None,
1370 None,
1371 None,
1372 None,
1373 None,
1374 Some(&updated_metadata),
1375 ) {
1376 tracing::warn!(
1377 "autonomy-hook metadata update failed for {}: {}",
1378 &actual_id,
1379 e
1380 );
1381 }
1382 }
1383 }
1384
1385 crate::subscriptions::dispatch_event(
1389 conn,
1390 "memory_store",
1391 &actual_id,
1392 &mem.namespace,
1393 Some(&agent_id),
1394 db_path,
1395 );
1396
1397 let mut response = json!({
1399 "id": actual_id,
1400 "tier": mem.tier,
1401 "title": mem.title,
1402 "namespace": mem.namespace,
1403 "agent_id": agent_id,
1404 });
1405 if !contradiction_ids.is_empty() {
1406 response["potential_contradictions"] = json!(contradiction_ids);
1407 }
1408 if !auto_tags.is_empty() {
1409 response["auto_tags"] = json!(auto_tags);
1410 }
1411 if !confirmed_contradictions.is_empty() {
1412 response["confirmed_contradictions"] = json!(confirmed_contradictions);
1413 }
1414 if let Some(reason) = hooks_skipped_reason
1415 && autonomous_hooks
1416 {
1417 response["autonomy_hook_skipped"] = json!(reason);
1418 }
1419 Ok(response)
1420}
1421
1422fn build_namespace_chain(conn: &rusqlite::Connection, namespace: &str) -> Vec<String> {
1439 db::build_namespace_chain(conn, namespace)
1440}
1441
1442fn inject_namespace_standard(
1446 conn: &rusqlite::Connection,
1447 namespace: Option<&str>,
1448 response: &mut Value,
1449) {
1450 let mut standards: Vec<Value> = Vec::new();
1451 let mut standard_ids: Vec<String> = Vec::new();
1452
1453 let add_standard = |std: Value, ids: &mut Vec<String>, stds: &mut Vec<Value>| {
1455 let id = std["id"].as_str().unwrap_or_default().to_string();
1456 if !ids.contains(&id) {
1457 ids.push(id);
1458 stds.push(std);
1459 }
1460 };
1461
1462 let chain = if let Some(ns) = namespace {
1463 build_namespace_chain(conn, ns)
1464 } else {
1465 vec!["*".to_string()]
1467 };
1468
1469 for link in chain {
1470 if let Some(std) = lookup_namespace_standard(conn, &link) {
1471 add_standard(std, &mut standard_ids, &mut standards);
1472 }
1473 }
1474
1475 if standards.is_empty() {
1476 return;
1477 }
1478
1479 if let Some(memories) = response["memories"].as_array_mut() {
1481 memories.retain(|m| {
1482 let mid = m["id"].as_str().unwrap_or_default();
1483 !standard_ids.iter().any(|sid| sid == mid)
1484 });
1485 response["count"] = json!(memories.len());
1486 }
1487
1488 if standards.len() == 1 {
1490 response["standard"] = standards.into_iter().next().unwrap();
1491 } else {
1492 response["standards"] = json!(standards);
1493 }
1494}
1495
1496#[allow(clippy::too_many_arguments)]
1497#[allow(clippy::too_many_lines)]
1498pub fn handle_recall(
1499 conn: &rusqlite::Connection,
1500 params: &Value,
1501 embedder: Option<&Embedder>,
1502 vector_index: Option<&VectorIndex>,
1503 reranker: Option<&CrossEncoder>,
1504 archive_on_gc: bool,
1505 resolved_ttl: &crate::config::ResolvedTtl,
1506 resolved_scoring: &crate::config::ResolvedScoring,
1507) -> Result<Value, String> {
1508 fn scored_memories(results: Vec<(Memory, f64)>) -> Vec<Value> {
1510 results
1511 .into_iter()
1512 .map(|(mem, score)| {
1513 let mut val = serde_json::to_value(&mem).unwrap_or_default();
1514 if let Some(obj) = val.as_object_mut() {
1515 obj.insert(
1516 "score".to_string(),
1517 json!((score * 1000.0).round() / 1000.0),
1518 );
1519 }
1520 val
1521 })
1522 .collect()
1523 }
1524
1525 let _ = db::gc_if_needed(conn, archive_on_gc);
1526 let context = params["context"].as_str().ok_or("context is required")?;
1527 let namespace = params["namespace"].as_str();
1528 let limit = usize::try_from(params["limit"].as_u64().unwrap_or(10)).unwrap_or(usize::MAX);
1529 let tags = params["tags"].as_str();
1530 let since = params["since"].as_str();
1531 let until = params["until"].as_str();
1532 let as_agent = params["as_agent"].as_str();
1534 if let Some(a) = as_agent {
1535 validate::validate_namespace(a).map_err(|e| e.to_string())?;
1536 }
1537 let budget_tokens = params["budget_tokens"]
1545 .as_u64()
1546 .and_then(|n| usize::try_from(n).ok());
1547
1548 let context_tokens: Vec<String> = params["context_tokens"]
1550 .as_array()
1551 .map(|arr| {
1552 arr.iter()
1553 .filter_map(|v| v.as_str().map(String::from))
1554 .collect()
1555 })
1556 .unwrap_or_default();
1557
1558 let decorate_budget = |resp: &mut Value, outcome: &db::BudgetOutcome| {
1571 resp["tokens_used"] = json!(outcome.tokens_used);
1572 if let Some(b) = budget_tokens {
1573 resp["budget_tokens"] = json!(b);
1574 let meta = resp
1579 .as_object_mut()
1580 .expect("recall response is always a JSON object")
1581 .entry("meta".to_string())
1582 .or_insert_with(|| json!({}));
1583 meta["budget_tokens_used"] = json!(outcome.tokens_used);
1584 meta["budget_tokens_remaining"] = json!(outcome.tokens_remaining.unwrap_or(0));
1585 meta["memories_dropped"] = json!(outcome.memories_dropped);
1586 meta["budget_overflow"] = json!(outcome.budget_overflow);
1587 }
1588 };
1589
1590 let reranker_used = match reranker {
1596 Some(ce) if ce.is_neural() => "neural",
1597 Some(_) => "lexical",
1598 None => "none",
1599 };
1600 let attach_meta = |resp: &mut Value, recall_mode: &str, telemetry: &RecallTelemetry| {
1601 let blend_weight = (telemetry.blend_weight_avg * 1000.0).round() / 1000.0;
1605 let meta = RecallMeta {
1606 recall_mode: recall_mode.to_string(),
1607 reranker_used: reranker_used.to_string(),
1608 candidate_counts: CandidateCounts {
1609 fts: telemetry.fts_candidates,
1610 hnsw: telemetry.hnsw_candidates,
1611 },
1612 blend_weight,
1613 };
1614 if let Ok(Value::Object(p3_fields)) = serde_json::to_value(&meta) {
1617 let meta_obj = resp
1618 .as_object_mut()
1619 .expect("recall response is always a JSON object")
1620 .entry("meta".to_string())
1621 .or_insert_with(|| json!({}));
1622 if let Some(existing) = meta_obj.as_object_mut() {
1623 for (k, v) in p3_fields {
1624 existing.insert(k, v);
1625 }
1626 }
1627 }
1628 };
1629
1630 if let Some(emb) = embedder {
1632 match emb.embed(context) {
1633 Ok(primary_emb) => {
1634 let query_emb = if context_tokens.is_empty() {
1637 primary_emb
1638 } else {
1639 let joined = context_tokens.join(" ");
1640 match emb.embed(&joined) {
1641 Ok(ctx_emb) => {
1642 crate::embeddings::Embedder::fuse(&primary_emb, &ctx_emb, 0.7)
1643 }
1644 Err(e) => {
1645 tracing::warn!("context_tokens embed failed, using primary only: {e}");
1646 primary_emb
1647 }
1648 }
1649 };
1650 let (results, outcome, telemetry) = db::recall_hybrid_with_telemetry(
1651 conn,
1652 context,
1653 &query_emb,
1654 namespace,
1655 limit.min(50),
1656 tags,
1657 since,
1658 until,
1659 vector_index,
1660 resolved_ttl.short_extend_secs,
1661 resolved_ttl.mid_extend_secs,
1662 as_agent,
1663 budget_tokens,
1664 resolved_scoring,
1665 )
1666 .map_err(|e| e.to_string())?;
1667
1668 if let Some(ce) = reranker {
1670 let ce_reranked = ce.rerank(context, results);
1671 let memories = scored_memories(ce_reranked);
1672 let mut resp = json!({"memories": memories, "count": memories.len(), "mode": "hybrid+rerank"});
1673 decorate_budget(&mut resp, &outcome);
1674 attach_meta(&mut resp, "hybrid", &telemetry);
1675 inject_namespace_standard(conn, namespace, &mut resp);
1676 return Ok(resp);
1677 }
1678
1679 let memories = scored_memories(results);
1680 let mut resp =
1681 json!({"memories": memories, "count": memories.len(), "mode": "hybrid"});
1682 decorate_budget(&mut resp, &outcome);
1683 attach_meta(&mut resp, "hybrid", &telemetry);
1684 inject_namespace_standard(conn, namespace, &mut resp);
1685 return Ok(resp);
1686 }
1687 Err(e) => {
1688 tracing::warn!("embedding failed, falling back to FTS: {}", e);
1695 }
1696 }
1697 }
1698
1699 let (results, outcome, telemetry) = db::recall_with_telemetry(
1701 conn,
1702 context,
1703 namespace,
1704 limit.min(50),
1705 tags,
1706 since,
1707 until,
1708 resolved_ttl.short_extend_secs,
1709 resolved_ttl.mid_extend_secs,
1710 as_agent,
1711 budget_tokens,
1712 )
1713 .map_err(|e| e.to_string())?;
1714 let memories = scored_memories(results);
1715 let mut resp = json!({"memories": memories, "count": memories.len(), "mode": "keyword"});
1716 decorate_budget(&mut resp, &outcome);
1717 attach_meta(&mut resp, "keyword_only", &telemetry);
1718 inject_namespace_standard(conn, namespace, &mut resp);
1719 Ok(resp)
1720}
1721
1722#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1728pub enum CapabilitiesAccept {
1729 V1,
1730 V2,
1731}
1732
1733impl CapabilitiesAccept {
1734 #[must_use]
1737 pub fn parse(s: &str) -> Self {
1738 match s.trim().to_ascii_lowercase().as_str() {
1739 "v1" | "1" => Self::V1,
1740 _ => Self::V2,
1741 }
1742 }
1743}
1744
1745pub fn handle_capabilities_with_conn(
1771 tier_config: &TierConfig,
1772 reranker: Option<&CrossEncoder>,
1773 embedder_loaded: bool,
1774 conn: Option<&rusqlite::Connection>,
1775 accept: CapabilitiesAccept,
1776) -> Result<Value, String> {
1777 let mut caps = tier_config.capabilities();
1778
1779 caps.features.reranker_active = match reranker {
1784 Some(ce) if ce.is_neural() => RerankerMode::Neural,
1785 Some(_) => {
1786 caps.features.cross_encoder_reranking = false;
1788 caps.models.cross_encoder = "lexical-fallback (neural download failed)".to_string();
1789 RerankerMode::LexicalFallback
1790 }
1791 None => RerankerMode::Off,
1792 };
1793
1794 caps.features.embedder_loaded = embedder_loaded;
1796 caps.features.recall_mode_active = compute_recall_mode(tier_config, embedder_loaded);
1797
1798 caps.hnsw.evictions_total = crate::hnsw::index_evictions_total();
1804 caps.hnsw.evicted_recently = crate::hnsw::evicted_recently(60);
1805
1806 if let Some(c) = conn {
1808 if let Ok(n) = db::count_active_governance_rules(c) {
1809 caps.permissions.active_rules = n;
1810 }
1811 if let Ok(n) = db::count_subscriptions(c) {
1812 caps.hooks.registered_count = n;
1813 }
1814 if let Ok(n) = db::count_pending_actions_by_status(c, "pending") {
1815 caps.approval.pending_requests = n;
1816 }
1817 }
1818
1819 match accept {
1821 CapabilitiesAccept::V2 => serde_json::to_value(caps).map_err(|e| e.to_string()),
1822 CapabilitiesAccept::V1 => serde_json::to_value(caps.to_v1()).map_err(|e| e.to_string()),
1823 }
1824}
1825
1826fn compute_recall_mode(
1836 tier_config: &TierConfig,
1837 embedder_loaded: bool,
1838) -> crate::config::RecallMode {
1839 use crate::config::RecallMode;
1840 if tier_config.embedding_model.is_none() {
1841 RecallMode::Disabled
1842 } else if embedder_loaded {
1843 RecallMode::Hybrid
1844 } else {
1845 RecallMode::Degraded
1846 }
1847}
1848
1849fn handle_expand_query(llm: Option<&OllamaClient>, params: &Value) -> Result<Value, String> {
1850 let llm = llm.ok_or("query expansion requires smart or autonomous tier (Ollama LLM)")?;
1851 let query = params["query"].as_str().ok_or("query is required")?;
1852 let terms = llm.expand_query(query).map_err(|e| e.to_string())?;
1853 Ok(json!({"original": query, "expanded_terms": terms}))
1854}
1855
1856fn handle_auto_tag(
1857 conn: &rusqlite::Connection,
1858 llm: Option<&OllamaClient>,
1859 params: &Value,
1860) -> Result<Value, String> {
1861 let llm = llm.ok_or("auto-tagging requires smart or autonomous tier (Ollama LLM)")?;
1862 let id = params["id"].as_str().ok_or("id is required")?;
1863 validate::validate_id(id).map_err(|e| e.to_string())?;
1864 let mem = db::get(conn, id)
1865 .map_err(|e| e.to_string())?
1866 .ok_or("memory not found")?;
1867 let tags = llm
1868 .auto_tag(&mem.title, &mem.content)
1869 .map_err(|e| e.to_string())?;
1870 let mut all_tags = mem.tags.clone();
1872 for t in &tags {
1873 if !all_tags.contains(t) {
1874 all_tags.push(t.clone());
1875 }
1876 }
1877 db::update(
1878 conn,
1879 id,
1880 None,
1881 None,
1882 None,
1883 None,
1884 Some(&all_tags),
1885 None,
1886 None,
1887 None,
1888 None,
1889 )
1890 .map_err(|e| e.to_string())?;
1891 Ok(json!({"id": id, "new_tags": tags, "all_tags": all_tags}))
1892}
1893
1894fn handle_detect_contradiction(
1895 conn: &rusqlite::Connection,
1896 llm: Option<&OllamaClient>,
1897 params: &Value,
1898) -> Result<Value, String> {
1899 let llm =
1900 llm.ok_or("contradiction detection requires smart or autonomous tier (Ollama LLM)")?;
1901 let id_a = params["id_a"].as_str().ok_or("id_a is required")?;
1902 let id_b = params["id_b"].as_str().ok_or("id_b is required")?;
1903 validate::validate_id(id_a).map_err(|e| e.to_string())?;
1904 validate::validate_id(id_b).map_err(|e| e.to_string())?;
1905 let mem_a = db::get(conn, id_a)
1906 .map_err(|e| e.to_string())?
1907 .ok_or("memory A not found")?;
1908 let mem_b = db::get(conn, id_b)
1909 .map_err(|e| e.to_string())?
1910 .ok_or("memory B not found")?;
1911 let contradicts = llm
1912 .detect_contradiction(&mem_a.content, &mem_b.content)
1913 .map_err(|e| e.to_string())?;
1914 Ok(json!({
1915 "contradicts": contradicts,
1916 "memory_a": {"id": id_a, "title": mem_a.title},
1917 "memory_b": {"id": id_b, "title": mem_b.title}
1918 }))
1919}
1920
1921fn handle_search(conn: &rusqlite::Connection, params: &Value) -> Result<Value, String> {
1922 let query = params["query"].as_str().ok_or("query is required")?;
1923 let namespace = params["namespace"].as_str();
1924 let tier = params["tier"].as_str().and_then(Tier::from_str);
1925 let limit = usize::try_from(params["limit"].as_u64().unwrap_or(20)).unwrap_or(usize::MAX);
1929
1930 let agent_id = params["agent_id"].as_str();
1931 if let Some(aid) = agent_id {
1932 validate::validate_agent_id(aid).map_err(|e| e.to_string())?;
1933 }
1934 let as_agent = params["as_agent"].as_str();
1935 if let Some(a) = as_agent {
1936 validate::validate_namespace(a).map_err(|e| e.to_string())?;
1937 }
1938 let results = db::search(
1939 conn,
1940 query,
1941 namespace,
1942 tier.as_ref(),
1943 limit.min(200),
1944 None,
1945 None,
1946 None,
1947 None,
1948 agent_id,
1949 as_agent,
1950 )
1951 .map_err(|e| e.to_string())?;
1952 Ok(json!({"results": results, "count": results.len()}))
1953}
1954
1955fn handle_get_taxonomy(conn: &rusqlite::Connection, params: &Value) -> Result<Value, String> {
1956 let prefix_raw = params
1961 .get("namespace_prefix")
1962 .and_then(|v| v.as_str())
1963 .map(str::trim)
1964 .filter(|s| !s.is_empty());
1965 let prefix_owned: Option<String> = prefix_raw.map(|s| s.trim_end_matches('/').to_string());
1966 if let Some(p) = prefix_owned.as_deref() {
1967 validate::validate_namespace(p).map_err(|e| e.to_string())?;
1968 }
1969 let depth = usize::try_from(params.get("depth").and_then(Value::as_u64).unwrap_or(8))
1970 .unwrap_or(usize::MAX)
1971 .min(crate::models::MAX_NAMESPACE_DEPTH);
1972 let limit = usize::try_from(params.get("limit").and_then(Value::as_u64).unwrap_or(1000))
1973 .unwrap_or(usize::MAX)
1974 .clamp(1, 10_000);
1975
1976 let tax =
1977 db::get_taxonomy(conn, prefix_owned.as_deref(), depth, limit).map_err(|e| e.to_string())?;
1978 Ok(json!({
1979 "tree": tax.tree,
1980 "total_count": tax.total_count,
1981 "truncated": tax.truncated,
1982 }))
1983}
1984
1985fn handle_check_duplicate(
1986 conn: &rusqlite::Connection,
1987 params: &Value,
1988 embedder: Option<&Embedder>,
1989) -> Result<Value, String> {
1990 let title = params["title"].as_str().ok_or("title is required")?;
1991 let content = params["content"].as_str().ok_or("content is required")?;
1992 let namespace = params["namespace"]
1993 .as_str()
1994 .map(str::trim)
1995 .filter(|s| !s.is_empty());
1996 #[allow(clippy::cast_possible_truncation)]
2000 let threshold = params["threshold"]
2001 .as_f64()
2002 .map_or(db::DUPLICATE_THRESHOLD_DEFAULT, |t| t as f32);
2003
2004 validate::validate_title(title).map_err(|e| e.to_string())?;
2005 validate::validate_content(content).map_err(|e| e.to_string())?;
2006 if let Some(ns) = namespace {
2007 validate::validate_namespace(ns).map_err(|e| e.to_string())?;
2008 }
2009
2010 let emb = embedder
2011 .ok_or("memory_check_duplicate requires the embedder; enable semantic tier or above")?;
2012 let text = format!("{title} {content}");
2013 let query_embedding = emb.embed(&text).map_err(|e| e.to_string())?;
2014
2015 let check = db::check_duplicate(conn, &query_embedding, namespace, threshold)
2016 .map_err(|e| e.to_string())?;
2017
2018 let nearest_json = check.nearest.as_ref().map(|m| {
2021 json!({
2022 "id": m.id,
2023 "title": m.title,
2024 "namespace": m.namespace,
2025 "similarity": (m.similarity * 1000.0).round() / 1000.0,
2026 })
2027 });
2028 let suggested_merge = if check.is_duplicate {
2029 check.nearest.as_ref().map(|m| m.id.clone())
2030 } else {
2031 None
2032 };
2033
2034 Ok(json!({
2035 "is_duplicate": check.is_duplicate,
2036 "threshold": check.threshold,
2037 "nearest": nearest_json,
2038 "suggested_merge": suggested_merge,
2039 "candidates_scanned": check.candidates_scanned,
2040 }))
2041}
2042
2043fn handle_entity_register(
2044 conn: &rusqlite::Connection,
2045 params: &Value,
2046 mcp_client: Option<&str>,
2047) -> Result<Value, String> {
2048 let canonical_name = params["canonical_name"]
2049 .as_str()
2050 .ok_or("canonical_name is required")?;
2051 let namespace = params["namespace"]
2052 .as_str()
2053 .ok_or("namespace is required")?;
2054 let aliases: Vec<String> = params["aliases"]
2055 .as_array()
2056 .map(|arr| {
2057 arr.iter()
2058 .filter_map(|v| v.as_str().map(String::from))
2059 .collect()
2060 })
2061 .unwrap_or_default();
2062 let extra_metadata = if params["metadata"].is_object() {
2063 params["metadata"].clone()
2064 } else {
2065 json!({})
2066 };
2067 let explicit_agent_id = params["agent_id"].as_str();
2068
2069 validate::validate_title(canonical_name).map_err(|e| e.to_string())?;
2070 validate::validate_namespace(namespace).map_err(|e| e.to_string())?;
2071 if let Some(aid) = explicit_agent_id {
2072 validate::validate_agent_id(aid).map_err(|e| e.to_string())?;
2073 }
2074
2075 let agent_id = crate::identity::resolve_agent_id(explicit_agent_id, mcp_client)
2076 .map_err(|e| e.to_string())?;
2077
2078 let reg = db::entity_register(
2079 conn,
2080 canonical_name,
2081 namespace,
2082 &aliases,
2083 &extra_metadata,
2084 Some(&agent_id),
2085 )
2086 .map_err(|e| e.to_string())?;
2087
2088 Ok(json!({
2089 "entity_id": reg.entity_id,
2090 "canonical_name": reg.canonical_name,
2091 "namespace": reg.namespace,
2092 "aliases": reg.aliases,
2093 "created": reg.created,
2094 }))
2095}
2096
2097fn handle_entity_get_by_alias(
2098 conn: &rusqlite::Connection,
2099 params: &Value,
2100) -> Result<Value, String> {
2101 let alias = params["alias"].as_str().ok_or("alias is required")?;
2102 let namespace = params["namespace"]
2103 .as_str()
2104 .map(str::trim)
2105 .filter(|s| !s.is_empty());
2106 if let Some(ns) = namespace {
2107 validate::validate_namespace(ns).map_err(|e| e.to_string())?;
2108 }
2109
2110 match db::entity_get_by_alias(conn, alias, namespace).map_err(|e| e.to_string())? {
2111 Some(rec) => Ok(json!({
2112 "found": true,
2113 "entity_id": rec.entity_id,
2114 "canonical_name": rec.canonical_name,
2115 "namespace": rec.namespace,
2116 "aliases": rec.aliases,
2117 })),
2118 None => Ok(json!({
2119 "found": false,
2120 "entity_id": null,
2121 "canonical_name": null,
2122 "namespace": null,
2123 "aliases": [],
2124 })),
2125 }
2126}
2127
2128fn handle_kg_timeline(conn: &rusqlite::Connection, params: &Value) -> Result<Value, String> {
2129 let source_id = params["source_id"]
2130 .as_str()
2131 .ok_or("source_id is required")?;
2132 validate::validate_id(source_id).map_err(|e| e.to_string())?;
2133 let since = params["since"]
2134 .as_str()
2135 .map(str::trim)
2136 .filter(|s| !s.is_empty());
2137 let until = params["until"]
2138 .as_str()
2139 .map(str::trim)
2140 .filter(|s| !s.is_empty());
2141 if let Some(s) = since {
2142 validate::validate_expires_at_format(s).map_err(|e| e.to_string())?;
2143 }
2144 if let Some(u) = until {
2145 validate::validate_expires_at_format(u).map_err(|e| e.to_string())?;
2146 }
2147 let limit = params["limit"]
2148 .as_u64()
2149 .and_then(|n| usize::try_from(n).ok());
2150
2151 let events =
2152 db::kg_timeline(conn, source_id, since, until, limit).map_err(|e| e.to_string())?;
2153
2154 let events_json: Vec<Value> = events
2155 .iter()
2156 .map(|e| {
2157 json!({
2158 "target_id": e.target_id,
2159 "relation": e.relation,
2160 "valid_from": e.valid_from,
2161 "valid_until": e.valid_until,
2162 "observed_by": e.observed_by,
2163 "title": e.title,
2164 "target_namespace": e.target_namespace,
2165 })
2166 })
2167 .collect();
2168
2169 Ok(json!({
2170 "source_id": source_id,
2171 "events": events_json,
2172 "count": events.len(),
2173 }))
2174}
2175
2176fn handle_kg_invalidate(conn: &rusqlite::Connection, params: &Value) -> Result<Value, String> {
2177 let source_id = params["source_id"]
2178 .as_str()
2179 .ok_or("source_id is required")?;
2180 let target_id = params["target_id"]
2181 .as_str()
2182 .ok_or("target_id is required")?;
2183 let relation = params["relation"].as_str().ok_or("relation is required")?;
2184 validate::validate_link(source_id, target_id, relation).map_err(|e| e.to_string())?;
2185 let valid_until = params["valid_until"]
2186 .as_str()
2187 .map(str::trim)
2188 .filter(|s| !s.is_empty());
2189 if let Some(ts) = valid_until {
2190 validate::validate_expires_at_format(ts).map_err(|e| e.to_string())?;
2191 }
2192
2193 match db::invalidate_link(conn, source_id, target_id, relation, valid_until)
2194 .map_err(|e| e.to_string())?
2195 {
2196 Some(res) => Ok(json!({
2197 "found": true,
2198 "source_id": source_id,
2199 "target_id": target_id,
2200 "relation": relation,
2201 "valid_until": res.valid_until,
2202 "previous_valid_until": res.previous_valid_until,
2203 })),
2204 None => Ok(json!({
2205 "found": false,
2206 "source_id": source_id,
2207 "target_id": target_id,
2208 "relation": relation,
2209 })),
2210 }
2211}
2212
2213fn handle_kg_query(conn: &rusqlite::Connection, params: &Value) -> Result<Value, String> {
2214 let source_id = params["source_id"]
2215 .as_str()
2216 .ok_or("source_id is required")?;
2217 validate::validate_id(source_id).map_err(|e| e.to_string())?;
2218
2219 let max_depth = params["max_depth"]
2220 .as_u64()
2221 .and_then(|n| usize::try_from(n).ok())
2222 .unwrap_or(1);
2223
2224 let valid_at = params["valid_at"]
2225 .as_str()
2226 .map(str::trim)
2227 .filter(|s| !s.is_empty());
2228 if let Some(t) = valid_at {
2229 validate::validate_expires_at_format(t).map_err(|e| e.to_string())?;
2230 }
2231
2232 let allowed_agents: Option<Vec<String>> = params["allowed_agents"].as_array().map(|arr| {
2233 arr.iter()
2234 .filter_map(|v| v.as_str().map(str::trim).filter(|s| !s.is_empty()))
2235 .map(str::to_string)
2236 .collect()
2237 });
2238 if let Some(agents) = allowed_agents.as_ref() {
2239 for a in agents {
2240 validate::validate_agent_id(a).map_err(|e| e.to_string())?;
2241 }
2242 }
2243
2244 let limit = params["limit"]
2245 .as_u64()
2246 .and_then(|n| usize::try_from(n).ok());
2247
2248 let nodes = db::kg_query(
2249 conn,
2250 source_id,
2251 max_depth,
2252 valid_at,
2253 allowed_agents.as_deref(),
2254 limit,
2255 )
2256 .map_err(|e| e.to_string())?;
2257
2258 let memories_json: Vec<Value> = nodes
2259 .iter()
2260 .map(|n| {
2261 json!({
2262 "target_id": n.target_id,
2263 "relation": n.relation,
2264 "valid_from": n.valid_from,
2265 "valid_until": n.valid_until,
2266 "observed_by": n.observed_by,
2267 "title": n.title,
2268 "target_namespace": n.target_namespace,
2269 "depth": n.depth,
2270 "path": n.path,
2271 })
2272 })
2273 .collect();
2274 let paths_json: Vec<&str> = nodes.iter().map(|n| n.path.as_str()).collect();
2275
2276 Ok(json!({
2277 "source_id": source_id,
2278 "max_depth": max_depth,
2279 "memories": memories_json,
2280 "paths": paths_json,
2281 "count": nodes.len(),
2282 }))
2283}
2284
2285fn handle_list(conn: &rusqlite::Connection, params: &Value) -> Result<Value, String> {
2286 let namespace = params["namespace"].as_str();
2287 let tier = params["tier"].as_str().and_then(Tier::from_str);
2288 let limit = usize::try_from(params["limit"].as_u64().unwrap_or(20)).unwrap_or(usize::MAX);
2290 let agent_id = params["agent_id"].as_str();
2291 if let Some(aid) = agent_id {
2292 validate::validate_agent_id(aid).map_err(|e| e.to_string())?;
2293 }
2294
2295 let results = db::list(
2296 conn,
2297 namespace,
2298 tier.as_ref(),
2299 limit.min(200),
2300 0,
2301 None,
2302 None,
2303 None,
2304 None,
2305 agent_id,
2306 )
2307 .map_err(|e| e.to_string())?;
2308 Ok(json!({"memories": results, "count": results.len()}))
2309}
2310
2311fn handle_delete(
2312 conn: &rusqlite::Connection,
2313 db_path: &Path,
2314 params: &Value,
2315 vector_index: Option<&VectorIndex>,
2316 mcp_client: Option<&str>,
2317) -> Result<Value, String> {
2318 let id = params["id"].as_str().ok_or("id is required")?;
2319 validate::validate_id(id).map_err(|e| e.to_string())?;
2320
2321 let target = if let Some(m) = db::get(conn, id).map_err(|e| e.to_string())? {
2323 Some(m)
2324 } else {
2325 db::get_by_prefix(conn, id).map_err(|e| e.to_string())?
2326 };
2327 let Some(target) = target else {
2328 return Err("memory not found".into());
2329 };
2330
2331 let snapshot_namespace = target.namespace.clone();
2335 let snapshot_title = target.title.clone();
2336 let snapshot_tier = target.tier.as_str().to_string();
2337 let snapshot_owner: Option<String> = target
2338 .metadata
2339 .get("agent_id")
2340 .and_then(|v| v.as_str())
2341 .map(str::to_string);
2342
2343 {
2345 use crate::models::{GovernanceDecision, GovernedAction};
2346 let agent_id = crate::identity::resolve_agent_id(params["agent_id"].as_str(), mcp_client)
2347 .map_err(|e| e.to_string())?;
2348 let mem_owner = target
2349 .metadata
2350 .get("agent_id")
2351 .and_then(|v| v.as_str())
2352 .map(str::to_string);
2353 let payload = json!({"id": target.id, "title": target.title});
2354 match db::enforce_governance(
2355 conn,
2356 GovernedAction::Delete,
2357 &target.namespace,
2358 &agent_id,
2359 Some(&target.id),
2360 mem_owner.as_deref(),
2361 &payload,
2362 )
2363 .map_err(|e| e.to_string())?
2364 {
2365 GovernanceDecision::Allow => {}
2366 GovernanceDecision::Deny(reason) => {
2367 return Err(format!("delete denied by governance: {reason}"));
2368 }
2369 GovernanceDecision::Pending(pending_id) => {
2370 return Ok(json!({
2371 "status": "pending",
2372 "pending_id": pending_id,
2373 "reason": "governance requires approval",
2374 "action": "delete",
2375 "memory_id": target.id,
2376 }));
2377 }
2378 }
2379 }
2380
2381 let deleted = db::delete(conn, &target.id).map_err(|e| e.to_string())?;
2382 if deleted {
2383 if let Some(idx) = vector_index {
2384 idx.remove(&target.id);
2385 }
2386 crate::audit::emit(crate::audit::EventBuilder::new(
2388 crate::audit::AuditAction::Delete,
2389 crate::audit::actor(
2390 snapshot_owner
2391 .clone()
2392 .unwrap_or_else(|| "unknown".to_string()),
2393 mcp_client.map_or("host_fallback", |_| "mcp_client_info"),
2394 None,
2395 ),
2396 crate::audit::target_memory(
2397 target.id.clone(),
2398 snapshot_namespace.clone(),
2399 Some(snapshot_title.clone()),
2400 Some(snapshot_tier.clone()),
2401 None,
2402 ),
2403 ));
2404 let details = serde_json::to_value(crate::subscriptions::DeleteEventDetails {
2407 title: snapshot_title,
2408 tier: snapshot_tier,
2409 })
2410 .ok();
2411 crate::subscriptions::dispatch_event_with_details(
2412 conn,
2413 "memory_delete",
2414 &target.id,
2415 &snapshot_namespace,
2416 snapshot_owner.as_deref(),
2417 db_path,
2418 details,
2419 );
2420 Ok(json!({"deleted": true}))
2421 } else {
2422 Err("memory not found".into())
2423 }
2424}
2425
2426fn handle_promote(
2427 conn: &rusqlite::Connection,
2428 db_path: &Path,
2429 params: &Value,
2430 mcp_client: Option<&str>,
2431) -> Result<Value, String> {
2432 let id = params["id"].as_str().ok_or("id is required")?;
2433 validate::validate_id(id).map_err(|e| e.to_string())?;
2434 let target = if let Some(m) = db::get(conn, id).map_err(|e| e.to_string())? {
2437 m
2438 } else if let Some(m) = db::get_by_prefix(conn, id).map_err(|e| e.to_string())? {
2439 m
2440 } else {
2441 return Err("memory not found".into());
2442 };
2443 let resolved_id = target.id.clone();
2444 let snapshot_namespace = target.namespace.clone();
2446 let snapshot_owner: Option<String> = target
2447 .metadata
2448 .get("agent_id")
2449 .and_then(|v| v.as_str())
2450 .map(str::to_string);
2451
2452 {
2454 use crate::models::{GovernanceDecision, GovernedAction};
2455 let agent_id = crate::identity::resolve_agent_id(params["agent_id"].as_str(), mcp_client)
2456 .map_err(|e| e.to_string())?;
2457 let mem_owner = target
2458 .metadata
2459 .get("agent_id")
2460 .and_then(|v| v.as_str())
2461 .map(str::to_string);
2462 let payload = json!({
2463 "id": resolved_id,
2464 "to_namespace": params["to_namespace"].as_str(),
2465 });
2466 match db::enforce_governance(
2467 conn,
2468 GovernedAction::Promote,
2469 &target.namespace,
2470 &agent_id,
2471 Some(&resolved_id),
2472 mem_owner.as_deref(),
2473 &payload,
2474 )
2475 .map_err(|e| e.to_string())?
2476 {
2477 GovernanceDecision::Allow => {}
2478 GovernanceDecision::Deny(reason) => {
2479 return Err(format!("promote denied by governance: {reason}"));
2480 }
2481 GovernanceDecision::Pending(pending_id) => {
2482 return Ok(json!({
2483 "status": "pending",
2484 "pending_id": pending_id,
2485 "reason": "governance requires approval",
2486 "action": "promote",
2487 "memory_id": resolved_id,
2488 }));
2489 }
2490 }
2491 }
2492
2493 if let Some(to_ns) = params["to_namespace"].as_str() {
2498 validate::validate_namespace(to_ns).map_err(|e| e.to_string())?;
2499 let clone_id =
2500 db::promote_to_namespace(conn, &resolved_id, to_ns).map_err(|e| e.to_string())?;
2501 let details = serde_json::to_value(crate::subscriptions::PromoteEventDetails {
2505 mode: "vertical".to_string(),
2506 tier: None,
2507 to_namespace: Some(to_ns.to_string()),
2508 clone_id: Some(clone_id.clone()),
2509 })
2510 .ok();
2511 crate::subscriptions::dispatch_event_with_details(
2512 conn,
2513 "memory_promote",
2514 &resolved_id,
2515 &snapshot_namespace,
2516 snapshot_owner.as_deref(),
2517 db_path,
2518 details,
2519 );
2520 return Ok(json!({
2521 "promoted": true,
2522 "mode": "vertical",
2523 "source_id": resolved_id,
2524 "clone_id": clone_id,
2525 "to_namespace": to_ns,
2526 }));
2527 }
2528
2529 let (found, _) = db::update(
2531 conn,
2532 &resolved_id,
2533 None,
2534 None,
2535 Some(&Tier::Long),
2536 None,
2537 None,
2538 None,
2539 None,
2540 Some(""), None,
2542 )
2543 .map_err(|e| e.to_string())?;
2544 if !found {
2545 return Err("memory not found".into());
2546 }
2547 let details = serde_json::to_value(crate::subscriptions::PromoteEventDetails {
2550 mode: "tier".to_string(),
2551 tier: Some("long".to_string()),
2552 to_namespace: None,
2553 clone_id: None,
2554 })
2555 .ok();
2556 crate::subscriptions::dispatch_event_with_details(
2557 conn,
2558 "memory_promote",
2559 &resolved_id,
2560 &snapshot_namespace,
2561 snapshot_owner.as_deref(),
2562 db_path,
2563 details,
2564 );
2565 Ok(json!({"promoted": true, "mode": "tier", "id": resolved_id, "tier": "long"}))
2566}
2567
2568fn handle_forget(
2569 conn: &rusqlite::Connection,
2570 params: &Value,
2571 archive: bool,
2572) -> Result<Value, String> {
2573 let namespace = params["namespace"].as_str();
2574 let pattern = params["pattern"].as_str();
2575 let tier = params["tier"].as_str().and_then(Tier::from_str);
2576 let dry_run = params["dry_run"].as_bool().unwrap_or(false);
2577
2578 if dry_run {
2579 let count =
2580 db::forget_count(conn, namespace, pattern, tier.as_ref()).map_err(|e| e.to_string())?;
2581 return Ok(json!({"would_delete": count, "dry_run": true}));
2582 }
2583
2584 let deleted =
2585 db::forget(conn, namespace, pattern, tier.as_ref(), archive).map_err(|e| e.to_string())?;
2586 Ok(json!({"deleted": deleted, "archived": archive}))
2587}
2588
2589fn handle_stats(conn: &rusqlite::Connection, db_path: &Path) -> Result<Value, String> {
2590 let stats = db::stats(conn, db_path).map_err(|e| e.to_string())?;
2591 serde_json::to_value(stats).map_err(|e| e.to_string())
2592}
2593
2594fn handle_update(
2595 conn: &rusqlite::Connection,
2596 params: &Value,
2597 embedder: Option<&Embedder>,
2598 vector_index: Option<&VectorIndex>,
2599) -> Result<Value, String> {
2600 let id = params["id"].as_str().ok_or("id is required")?;
2601 validate::validate_id(id).map_err(|e| e.to_string())?;
2602 let resolved_id = if db::get(conn, id).map_err(|e| e.to_string())?.is_some() {
2604 id.to_string()
2605 } else if let Some(mem) = db::get_by_prefix(conn, id).map_err(|e| e.to_string())? {
2606 mem.id
2607 } else {
2608 return Err("memory not found".into());
2609 };
2610 let title = params["title"].as_str();
2611 let content = params["content"].as_str();
2612 let tier = params["tier"].as_str().and_then(Tier::from_str);
2613 let namespace = params["namespace"].as_str();
2614 let tags: Option<Vec<String>> = params["tags"].as_array().map(|a| {
2615 a.iter()
2616 .filter_map(|v| v.as_str().map(String::from))
2617 .collect()
2618 });
2619 let priority = params["priority"]
2620 .as_i64()
2621 .map(|p| i32::try_from(p).expect("i64 as i32"));
2622 let confidence = params["confidence"].as_f64();
2623 let expires_at = params["expires_at"].as_str();
2624
2625 if let Some(t) = title {
2626 validate::validate_title(t).map_err(|e| e.to_string())?;
2627 }
2628 if let Some(c) = content {
2629 validate::validate_content(c).map_err(|e| e.to_string())?;
2630 }
2631 if let Some(ns) = &namespace {
2632 validate::validate_namespace(ns).map_err(|e| e.to_string())?;
2633 }
2634 if let Some(ref t) = tags {
2635 validate::validate_tags(t).map_err(|e| e.to_string())?;
2636 }
2637 if let Some(p) = priority {
2638 validate::validate_priority(p).map_err(|e| e.to_string())?;
2639 }
2640 if let Some(c) = confidence {
2641 validate::validate_confidence(c).map_err(|e| e.to_string())?;
2642 }
2643 if let Some(ts) = expires_at {
2644 validate::validate_expires_at_format(ts).map_err(|e| e.to_string())?;
2646 }
2647
2648 let metadata = if params["metadata"].is_object() {
2649 let m = params["metadata"].clone();
2650 validate::validate_metadata(&m).map_err(|e| e.to_string())?;
2651 let existing = db::get(conn, &resolved_id)
2654 .map_err(|e| e.to_string())?
2655 .map_or_else(|| serde_json::json!({}), |m| m.metadata);
2656 Some(crate::identity::preserve_agent_id(&existing, &m))
2657 } else {
2658 None
2659 };
2660
2661 let (found, content_changed) = db::update(
2662 conn,
2663 &resolved_id,
2664 title,
2665 content,
2666 tier.as_ref(),
2667 namespace,
2668 tags.as_ref(),
2669 priority,
2670 confidence,
2671 expires_at,
2672 metadata.as_ref(),
2673 )
2674 .map_err(|e| e.to_string())?;
2675
2676 if !found {
2677 return Err("memory not found".into());
2678 }
2679
2680 if content_changed && let Some(emb) = embedder {
2682 let mem = db::get(conn, &resolved_id).map_err(|e| e.to_string())?;
2683 if let Some(ref m) = mem {
2684 let text = format!("{} {}", m.title, m.content);
2685 if let Ok(embedding) = emb.embed(&text) {
2686 let _ = db::set_embedding(conn, &resolved_id, &embedding);
2687 if let Some(idx) = vector_index {
2688 idx.remove(&resolved_id);
2689 idx.insert(resolved_id.clone(), embedding);
2690 }
2691 }
2692 }
2693 }
2694
2695 let mem = db::get(conn, &resolved_id).map_err(|e| e.to_string())?;
2696 Ok(json!({"updated": true, "memory": mem}))
2697}
2698
2699fn handle_get(conn: &rusqlite::Connection, params: &Value) -> Result<Value, String> {
2700 let id = params["id"].as_str().ok_or("id is required")?;
2701 validate::validate_id(id).map_err(|e| e.to_string())?;
2702 match db::resolve_id(conn, id).map_err(|e| e.to_string())? {
2703 Some(mem) => {
2704 let links = db::get_links(conn, &mem.id).unwrap_or_default();
2705 let mut val = serde_json::to_value(&mem).map_err(|e| e.to_string())?;
2707 if let Some(obj) = val.as_object_mut() {
2708 obj.insert("links".to_string(), json!(links));
2709 }
2710 Ok(val)
2711 }
2712 None => Err("memory not found".into()),
2713 }
2714}
2715
2716fn handle_link(
2717 conn: &rusqlite::Connection,
2718 db_path: &Path,
2719 params: &Value,
2720) -> Result<Value, String> {
2721 let source_id = params["source_id"]
2722 .as_str()
2723 .ok_or("source_id is required")?;
2724 let target_id = params["target_id"]
2725 .as_str()
2726 .ok_or("target_id is required")?;
2727 let relation = params["relation"].as_str().unwrap_or("related_to");
2728
2729 validate::validate_link(source_id, target_id, relation).map_err(|e| e.to_string())?;
2730 db::create_link(conn, source_id, target_id, relation).map_err(|e| e.to_string())?;
2731
2732 let (event_namespace, event_agent_id) = match db::get(conn, source_id) {
2738 Ok(Some(mem)) => {
2739 let owner = mem
2740 .metadata
2741 .get("agent_id")
2742 .and_then(|v| v.as_str())
2743 .map(str::to_string);
2744 (mem.namespace, owner)
2745 }
2746 _ => ("global".to_string(), None),
2747 };
2748 let details = serde_json::to_value(crate::subscriptions::LinkCreatedEventDetails {
2749 target_id: target_id.to_string(),
2750 relation: relation.to_string(),
2751 })
2752 .ok();
2753 crate::subscriptions::dispatch_event_with_details(
2754 conn,
2755 "memory_link_created",
2756 source_id,
2757 &event_namespace,
2758 event_agent_id.as_deref(),
2759 db_path,
2760 details,
2761 );
2762
2763 Ok(
2764 json!({"linked": true, "source_id": source_id, "target_id": target_id, "relation": relation}),
2765 )
2766}
2767
2768fn handle_get_links(conn: &rusqlite::Connection, params: &Value) -> Result<Value, String> {
2769 let id = params["id"].as_str().ok_or("id is required")?;
2770 validate::validate_id(id).map_err(|e| e.to_string())?;
2771 let links = db::get_links(conn, id).map_err(|e| e.to_string())?;
2772 Ok(json!({"links": links, "count": links.len()}))
2773}
2774
2775fn handle_consolidate(
2776 conn: &rusqlite::Connection,
2777 db_path: &Path,
2778 params: &Value,
2779 llm: Option<&OllamaClient>,
2780 embedder: Option<&Embedder>,
2781 vector_index: Option<&VectorIndex>,
2782 mcp_client: Option<&str>,
2783) -> Result<Value, String> {
2784 let ids_arr = params["ids"]
2785 .as_array()
2786 .ok_or("ids is required (array of memory IDs)")?;
2787 let mut ids = Vec::with_capacity(ids_arr.len());
2788 for (i, v) in ids_arr.iter().enumerate() {
2789 match v.as_str() {
2790 Some(s) => {
2791 validate::validate_id(s).map_err(|e| e.to_string())?;
2792 ids.push(s.to_string());
2793 }
2794 None => return Err(format!("ids[{i}] must be a string")),
2795 }
2796 }
2797 let title = params["title"].as_str().ok_or("title is required")?;
2798 let namespace = params["namespace"].as_str().unwrap_or("global");
2799
2800 let summary: String = if let Some(s) = params["summary"].as_str() {
2802 s.to_string()
2803 } else if let Some(llm_client) = llm {
2804 let mut memory_pairs: Vec<(String, String)> = Vec::new();
2806 for id in &ids {
2807 match db::get(conn, id) {
2808 Ok(Some(mem)) => memory_pairs.push((mem.title, mem.content)),
2809 Ok(None) => return Err(format!("memory not found: {id}")),
2810 Err(e) => return Err(e.to_string()),
2811 }
2812 }
2813 llm_client
2814 .summarize_memories(&memory_pairs)
2815 .map_err(|e| format!("LLM summarization failed: {e}"))?
2816 } else {
2817 return Err(
2818 "summary is required (or use smart/autonomous tier for auto-summarization)".into(),
2819 );
2820 };
2821
2822 validate::validate_consolidate(&ids, title, &summary, namespace).map_err(|e| e.to_string())?;
2823
2824 let auto_generated = params["summary"].as_str().is_none();
2825
2826 if let Some(idx) = vector_index {
2828 for id in &ids {
2829 idx.remove(id);
2830 }
2831 }
2832
2833 let explicit_agent_id = params["agent_id"].as_str();
2836 let consolidator_agent_id = crate::identity::resolve_agent_id(explicit_agent_id, mcp_client)
2837 .map_err(|e| e.to_string())?;
2838 let new_id = db::consolidate(
2839 conn,
2840 &ids,
2841 title,
2842 &summary,
2843 namespace,
2844 &Tier::Long,
2845 "consolidation",
2846 &consolidator_agent_id,
2847 )
2848 .map_err(|e| e.to_string())?;
2849
2850 if let Some(emb) = embedder {
2852 let text = format!("{title} {summary}");
2853 match emb.embed(&text) {
2854 Ok(embedding) => {
2855 if let Err(e) = db::set_embedding(conn, &new_id, &embedding) {
2856 tracing::warn!(
2857 "failed to store embedding for consolidated {}: {}",
2858 &new_id,
2859 e
2860 );
2861 }
2862 if let Some(idx) = vector_index {
2863 for id in &ids {
2865 idx.remove(id);
2866 }
2867 idx.insert(new_id.clone(), embedding);
2868 }
2869 }
2870 Err(e) => {
2871 tracing::warn!(
2872 "failed to generate embedding for consolidated {}: {}",
2873 &new_id,
2874 e
2875 );
2876 }
2877 }
2878 }
2879
2880 let mut result = json!({"id": new_id, "consolidated": ids.len()});
2881 if auto_generated {
2882 result["auto_summary"] = json!(true);
2883 result["summary_preview"] = json!(summary.chars().take(200).collect::<String>());
2884 }
2885 let standard_ids: Vec<&str> = ids
2887 .iter()
2888 .filter(|id| db::is_namespace_standard(conn, id))
2889 .map(std::string::String::as_str)
2890 .collect();
2891 if !standard_ids.is_empty() {
2892 result["warning"] = json!(format!(
2893 "consolidated memories included namespace standard(s): {}. Re-set the standard to the new memory ID: {}",
2894 standard_ids.join(", "),
2895 new_id
2896 ));
2897 }
2898
2899 let details = serde_json::to_value(crate::subscriptions::ConsolidatedEventDetails {
2903 source_ids: ids.clone(),
2904 source_count: ids.len(),
2905 })
2906 .ok();
2907 crate::subscriptions::dispatch_event_with_details(
2908 conn,
2909 "memory_consolidated",
2910 &new_id,
2911 namespace,
2912 Some(&consolidator_agent_id),
2913 db_path,
2914 details,
2915 );
2916
2917 Ok(result)
2918}
2919
2920pub(crate) fn handle_namespace_set_standard(
2925 conn: &rusqlite::Connection,
2926 params: &Value,
2927) -> Result<Value, String> {
2928 let namespace = params["namespace"]
2929 .as_str()
2930 .ok_or("namespace is required")?;
2931 validate::validate_namespace(namespace).map_err(|e| e.to_string())?;
2932 let id = params["id"].as_str().ok_or("id is required")?;
2933 validate::validate_id(id).map_err(|e| e.to_string())?;
2934 let parent = params["parent"].as_str();
2935 if let Some(p) = parent {
2936 validate::validate_namespace(p).map_err(|e| e.to_string())?;
2937 }
2938
2939 let governance_val = params.get("governance").filter(|v| !v.is_null());
2942 if let Some(g) = governance_val {
2943 let policy: crate::models::GovernancePolicy =
2944 serde_json::from_value(g.clone()).map_err(|e| format!("invalid governance: {e}"))?;
2945 validate::validate_governance_policy(&policy).map_err(|e| e.to_string())?;
2946
2947 let mut mem = db::get(conn, id)
2949 .map_err(|e| e.to_string())?
2950 .ok_or_else(|| format!("memory not found: {id}"))?;
2951 let mut metadata = if mem.metadata.is_object() {
2952 mem.metadata.clone()
2953 } else {
2954 serde_json::json!({})
2955 };
2956 if let Some(obj) = metadata.as_object_mut() {
2957 obj.insert(
2958 "governance".to_string(),
2959 serde_json::to_value(&policy).map_err(|e| e.to_string())?,
2960 );
2961 }
2962 let (found, _) = db::update(
2963 conn,
2964 &mem.id,
2965 None,
2966 None,
2967 None,
2968 None,
2969 None,
2970 None,
2971 None,
2972 None,
2973 Some(&metadata),
2974 )
2975 .map_err(|e| e.to_string())?;
2976 if !found {
2977 return Err(format!("memory not found during governance merge: {id}"));
2978 }
2979 mem.metadata = metadata;
2980 }
2981
2982 db::set_namespace_standard(conn, namespace, id, parent).map_err(|e| e.to_string())?;
2983 let mut resp = json!({"set": true, "namespace": namespace, "standard_id": id});
2984 if let Some(p) = parent {
2985 resp["parent"] = json!(p);
2986 }
2987 if let Some(g) = governance_val {
2988 resp["governance"] = g.clone();
2989 }
2990 Ok(resp)
2991}
2992
2993pub(crate) fn handle_namespace_get_standard(
2994 conn: &rusqlite::Connection,
2995 params: &Value,
2996) -> Result<Value, String> {
2997 let namespace = params["namespace"]
2998 .as_str()
2999 .ok_or("namespace is required")?;
3000 validate::validate_namespace(namespace).map_err(|e| e.to_string())?;
3001
3002 let inherit = params["inherit"].as_bool().unwrap_or(false);
3004 if inherit {
3005 let chain = build_namespace_chain(conn, namespace);
3006 let mut standards: Vec<Value> = Vec::new();
3007 for link in &chain {
3008 if let Some(std) = lookup_namespace_standard(conn, link) {
3009 let gov = extract_governance(&std);
3010 let entry = json!({
3011 "namespace": link,
3012 "standard_id": std["id"].clone(),
3013 "title": std["title"].clone(),
3014 "content": std["content"].clone(),
3015 "priority": std["priority"].clone(),
3016 "governance": gov,
3017 });
3018 standards.push(entry);
3019 }
3020 }
3021 return Ok(json!({
3022 "namespace": namespace,
3023 "chain": chain,
3024 "standards": standards,
3025 "count": standards.len(),
3026 }));
3027 }
3028
3029 let standard_id = db::get_namespace_standard(conn, namespace).map_err(|e| e.to_string())?;
3030 match standard_id {
3031 Some(id) => {
3032 let mem = db::get(conn, &id).map_err(|e| e.to_string())?;
3033 match mem {
3034 Some(m) => {
3035 let gov = GovernancePolicy::from_metadata(&m.metadata)
3037 .map(Result::unwrap_or_default)
3038 .unwrap_or_default();
3039 Ok(json!({
3040 "namespace": namespace,
3041 "standard_id": id,
3042 "title": m.title,
3043 "content": m.content,
3044 "priority": m.priority,
3045 "governance": gov,
3046 }))
3047 }
3048 None => Ok(
3049 json!({"namespace": namespace, "standard_id": id, "warning": "standard memory not found — may have been deleted"}),
3050 ),
3051 }
3052 }
3053 None => Ok(json!({"namespace": namespace, "standard_id": null})),
3054 }
3055}
3056
3057fn extract_governance(mem_val: &Value) -> Value {
3061 let default = serde_json::to_value(GovernancePolicy::default()).unwrap_or(Value::Null);
3062 let Some(meta) = mem_val.get("metadata") else {
3063 return default;
3064 };
3065 match GovernancePolicy::from_metadata(meta) {
3066 Some(Ok(p)) => serde_json::to_value(&p).unwrap_or(default),
3067 _ => default,
3068 }
3069}
3070
3071pub(crate) fn handle_namespace_clear_standard(
3072 conn: &rusqlite::Connection,
3073 params: &Value,
3074) -> Result<Value, String> {
3075 let namespace = params["namespace"]
3076 .as_str()
3077 .ok_or("namespace is required")?;
3078 validate::validate_namespace(namespace).map_err(|e| e.to_string())?;
3079 let cleared = db::clear_namespace_standard(conn, namespace).map_err(|e| e.to_string())?;
3080 Ok(json!({"cleared": cleared, "namespace": namespace}))
3081}
3082
3083fn lookup_namespace_standard(conn: &rusqlite::Connection, namespace: &str) -> Option<Value> {
3085 let standard_id = db::get_namespace_standard(conn, namespace).ok()??;
3086 let mem = db::get(conn, &standard_id).ok()??;
3087 serde_json::to_value(&mem).ok()
3088}
3089
3090#[allow(dead_code)]
3098fn auto_register_path_hierarchy(conn: &rusqlite::Connection, namespace: &str) {
3099 if db::get_namespace_parent(conn, namespace).is_some() {
3101 return;
3102 }
3103 let Ok(cwd) = std::env::current_dir() else {
3104 return;
3105 };
3106 let home = dirs::home_dir().unwrap_or_default();
3107 let mut current = cwd.parent().map(std::path::Path::to_path_buf);
3109 while let Some(dir) = current {
3110 if dir == home || !dir.starts_with(&home) {
3112 break;
3113 }
3114 if let Some(dir_name) = dir.file_name().and_then(|n| n.to_str()) {
3115 if db::get_namespace_standard(conn, dir_name)
3117 .ok()
3118 .flatten()
3119 .is_some()
3120 {
3121 let now = chrono::Utc::now().to_rfc3339();
3123 let _ = conn.execute(
3124 "UPDATE namespace_meta SET parent_namespace = ?1, updated_at = ?2 WHERE namespace = ?3 AND parent_namespace IS NULL",
3125 rusqlite::params![dir_name, now, namespace],
3126 );
3127 tracing::info!(
3128 "auto-registered parent namespace: {} -> {}",
3129 namespace,
3130 dir_name
3131 );
3132 break;
3133 }
3134 }
3135 current = dir.parent().map(std::path::Path::to_path_buf);
3136 }
3137}
3138
3139fn handle_agent_register(conn: &rusqlite::Connection, params: &Value) -> Result<Value, String> {
3144 let agent_id = params["agent_id"].as_str().ok_or("agent_id is required")?;
3145 let agent_type = params["agent_type"]
3146 .as_str()
3147 .ok_or("agent_type is required")?;
3148 let capabilities: Vec<String> = params["capabilities"]
3149 .as_array()
3150 .map(|arr| {
3151 arr.iter()
3152 .filter_map(|v| v.as_str().map(String::from))
3153 .collect()
3154 })
3155 .unwrap_or_default();
3156
3157 validate::validate_agent_id(agent_id).map_err(|e| e.to_string())?;
3158 validate::validate_agent_type(agent_type).map_err(|e| e.to_string())?;
3159 validate::validate_capabilities(&capabilities).map_err(|e| e.to_string())?;
3160
3161 let id =
3162 db::register_agent(conn, agent_id, agent_type, &capabilities).map_err(|e| e.to_string())?;
3163
3164 Ok(json!({
3165 "registered": true,
3166 "id": id,
3167 "agent_id": agent_id,
3168 "agent_type": agent_type,
3169 "capabilities": capabilities,
3170 }))
3171}
3172
3173fn handle_agent_list(conn: &rusqlite::Connection) -> Result<Value, String> {
3174 let agents = db::list_agents(conn).map_err(|e| e.to_string())?;
3175 Ok(json!({
3176 "count": agents.len(),
3177 "agents": agents,
3178 }))
3179}
3180
3181fn messages_namespace_for(agent_id: &str) -> String {
3189 format!("_messages/{agent_id}")
3190}
3191
3192pub(crate) fn handle_notify(
3193 conn: &rusqlite::Connection,
3194 params: &Value,
3195 resolved_ttl: &crate::config::ResolvedTtl,
3196 mcp_client: Option<&str>,
3197) -> Result<Value, String> {
3198 let target = params["target_agent_id"]
3199 .as_str()
3200 .ok_or("target_agent_id is required")?;
3201 let title = params["title"].as_str().ok_or("title is required")?;
3202 let payload = params["payload"].as_str().ok_or("payload is required")?;
3203 let priority = i32::try_from(params["priority"].as_i64().unwrap_or(5))
3204 .expect("i64 as i32")
3205 .clamp(1, 10);
3206 let tier_str = params["tier"].as_str().unwrap_or("mid");
3207 let tier = Tier::from_str(tier_str).ok_or(format!("invalid tier: {tier_str}"))?;
3208
3209 validate::validate_agent_id(target).map_err(|e| e.to_string())?;
3210 validate::validate_title(title).map_err(|e| e.to_string())?;
3211 validate::validate_content(payload).map_err(|e| e.to_string())?;
3212
3213 let sender = crate::identity::resolve_agent_id(None, mcp_client).map_err(|e| e.to_string())?;
3214 let namespace = messages_namespace_for(target);
3215
3216 let now = chrono::Utc::now();
3217 let expires_at = resolved_ttl
3218 .ttl_for_tier(&tier)
3219 .map(|s| (now + chrono::Duration::seconds(s)).to_rfc3339());
3220
3221 let metadata = json!({
3222 "agent_id": sender.clone(),
3223 "recipient_agent_id": target,
3224 "message_kind": "notify",
3225 });
3226
3227 let mem = Memory {
3228 id: uuid::Uuid::new_v4().to_string(),
3229 tier,
3230 namespace: namespace.clone(),
3231 title: title.to_string(),
3232 content: payload.to_string(),
3233 tags: vec!["_message".to_string()],
3234 priority,
3235 confidence: 1.0,
3236 source: "notify".to_string(),
3237 access_count: 0,
3238 created_at: now.to_rfc3339(),
3239 updated_at: now.to_rfc3339(),
3240 last_accessed_at: None,
3241 expires_at,
3242 metadata,
3243 };
3244 let actual_id = db::insert(conn, &mem).map_err(|e| e.to_string())?;
3245
3246 Ok(json!({
3247 "id": actual_id,
3248 "from": sender,
3249 "to": target,
3250 "namespace": namespace,
3251 "tier": mem.tier,
3252 "delivered_at": mem.created_at,
3253 }))
3254}
3255
3256pub(crate) fn handle_inbox(
3257 conn: &rusqlite::Connection,
3258 params: &Value,
3259 mcp_client: Option<&str>,
3260) -> Result<Value, String> {
3261 let explicit = params["agent_id"].as_str();
3264 let owner =
3265 crate::identity::resolve_agent_id(explicit, mcp_client).map_err(|e| e.to_string())?;
3266 let unread_only = params["unread_only"].as_bool().unwrap_or(false);
3267 let limit = usize::try_from(params["limit"].as_u64().unwrap_or(50))
3268 .unwrap_or(usize::MAX)
3269 .min(500);
3270 let namespace = messages_namespace_for(&owner);
3271 let items = db::list(
3272 conn,
3273 Some(&namespace),
3274 None,
3275 limit,
3276 0,
3277 None,
3278 None,
3279 None,
3280 None,
3281 None,
3282 )
3283 .map_err(|e| e.to_string())?;
3284 let filtered: Vec<&Memory> = items
3285 .iter()
3286 .filter(|m| !unread_only || m.access_count == 0)
3287 .collect();
3288 let messages: Vec<Value> = filtered
3289 .iter()
3290 .map(|m| {
3291 let sender = m
3292 .metadata
3293 .get("agent_id")
3294 .and_then(|v| v.as_str())
3295 .unwrap_or("");
3296 json!({
3297 "id": m.id,
3298 "from": sender,
3299 "title": m.title,
3300 "payload": m.content,
3301 "priority": m.priority,
3302 "tier": m.tier,
3303 "created_at": m.created_at,
3304 "read": m.access_count > 0,
3305 "access_count": m.access_count,
3306 })
3307 })
3308 .collect();
3309 Ok(json!({
3310 "agent_id": owner,
3311 "namespace": namespace,
3312 "count": messages.len(),
3313 "unread_only": unread_only,
3314 "messages": messages,
3315 }))
3316}
3317
3318pub(crate) fn handle_subscribe(
3321 conn: &rusqlite::Connection,
3322 params: &Value,
3323 mcp_client: Option<&str>,
3324) -> Result<Value, String> {
3325 let url = params["url"].as_str().ok_or("url is required")?;
3326 let events = params["events"].as_str().unwrap_or("*");
3327 let secret = params["secret"].as_str();
3328 let namespace_filter = params["namespace_filter"].as_str();
3329 let agent_filter = params["agent_filter"].as_str();
3330 let created_by =
3331 crate::identity::resolve_agent_id(None, mcp_client).map_err(|e| e.to_string())?;
3332
3333 let event_types: Option<Vec<String>> = params["event_types"].as_array().map(|arr| {
3339 arr.iter()
3340 .filter_map(|v| v.as_str().map(str::to_string))
3341 .collect()
3342 });
3343
3344 let registered = crate::db::list_agents(conn)
3352 .map_err(|e| e.to_string())?
3353 .into_iter()
3354 .any(|a| a.agent_id == created_by);
3355 if !registered {
3356 return Err(format!(
3357 "agent {created_by:?} is not registered; call memory_agent_register before memory_subscribe"
3358 ));
3359 }
3360
3361 crate::subscriptions::validate_url(url).map_err(|e| e.to_string())?;
3362
3363 let id = crate::subscriptions::insert(
3364 conn,
3365 &crate::subscriptions::NewSubscription {
3366 url,
3367 events,
3368 secret,
3369 namespace_filter,
3370 agent_filter,
3371 created_by: Some(&created_by),
3372 event_types: event_types.as_deref(),
3373 },
3374 )
3375 .map_err(|e| e.to_string())?;
3376
3377 let mut response = json!({
3378 "id": id,
3379 "url": url,
3380 "events": events,
3381 "namespace_filter": namespace_filter,
3382 "agent_filter": agent_filter,
3383 "created_by": created_by,
3384 });
3385 if let Some(et) = &event_types {
3386 response["event_types"] = json!(et);
3387 }
3388 Ok(response)
3389}
3390
3391pub(crate) fn handle_unsubscribe(
3392 conn: &rusqlite::Connection,
3393 params: &Value,
3394) -> Result<Value, String> {
3395 let id = params["id"].as_str().ok_or("id is required")?;
3396 let removed = crate::subscriptions::delete(conn, id).map_err(|e| e.to_string())?;
3397 Ok(json!({"id": id, "removed": removed}))
3398}
3399
3400pub(crate) fn handle_list_subscriptions(conn: &rusqlite::Connection) -> Result<Value, String> {
3401 let subs = crate::subscriptions::list(conn).map_err(|e| e.to_string())?;
3402 Ok(json!({"count": subs.len(), "subscriptions": subs}))
3403}
3404
3405fn handle_pending_list(conn: &rusqlite::Connection, params: &Value) -> Result<Value, String> {
3406 let status = params["status"].as_str();
3407 let limit = usize::try_from(params["limit"].as_u64().unwrap_or(100))
3408 .unwrap_or(usize::MAX)
3409 .min(1000);
3410 let items = db::list_pending_actions(conn, status, limit).map_err(|e| e.to_string())?;
3411 Ok(json!({"count": items.len(), "pending": items}))
3412}
3413
3414fn handle_pending_approve(
3415 conn: &rusqlite::Connection,
3416 params: &Value,
3417 mcp_client: Option<&str>,
3418) -> Result<Value, String> {
3419 use crate::db::ApproveOutcome;
3420 let id = params["id"].as_str().ok_or("id is required")?;
3421 validate::validate_id(id).map_err(|e| e.to_string())?;
3422 let agent_id = crate::identity::resolve_agent_id(params["agent_id"].as_str(), mcp_client)
3423 .map_err(|e| e.to_string())?;
3424 match db::approve_with_approver_type(conn, id, &agent_id).map_err(|e| e.to_string())? {
3425 ApproveOutcome::Approved => {
3426 let executed = db::execute_pending_action(conn, id).map_err(|e| e.to_string())?;
3428 Ok(json!({
3429 "approved": true,
3430 "id": id,
3431 "decided_by": agent_id,
3432 "executed": true,
3433 "memory_id": executed,
3434 }))
3435 }
3436 ApproveOutcome::Pending { votes, quorum } => Ok(json!({
3437 "approved": false,
3438 "status": "pending",
3439 "id": id,
3440 "votes": votes,
3441 "quorum": quorum,
3442 "reason": "consensus threshold not yet reached",
3443 })),
3444 ApproveOutcome::Rejected(reason) => Err(format!("approve rejected: {reason}")),
3445 }
3446}
3447
3448fn handle_pending_reject(
3449 conn: &rusqlite::Connection,
3450 params: &Value,
3451 mcp_client: Option<&str>,
3452) -> Result<Value, String> {
3453 let id = params["id"].as_str().ok_or("id is required")?;
3454 validate::validate_id(id).map_err(|e| e.to_string())?;
3455 let agent_id = crate::identity::resolve_agent_id(params["agent_id"].as_str(), mcp_client)
3456 .map_err(|e| e.to_string())?;
3457 let transitioned =
3458 db::decide_pending_action(conn, id, false, &agent_id).map_err(|e| e.to_string())?;
3459 if !transitioned {
3460 return Err(format!("pending action not found or already decided: {id}"));
3461 }
3462 Ok(json!({"rejected": true, "id": id, "decided_by": agent_id}))
3463}
3464
3465fn handle_archive_list(conn: &rusqlite::Connection, params: &Value) -> Result<Value, String> {
3466 let namespace = params["namespace"].as_str();
3467 let limit = usize::try_from(params["limit"].as_u64().unwrap_or(50)).unwrap_or(usize::MAX);
3468 let offset = usize::try_from(params["offset"].as_u64().unwrap_or(0)).unwrap_or(usize::MAX);
3469 let items =
3470 db::list_archived(conn, namespace, limit.min(1000), offset).map_err(|e| e.to_string())?;
3471 Ok(json!({"archived": items, "count": items.len()}))
3472}
3473
3474fn handle_archive_restore(conn: &rusqlite::Connection, params: &Value) -> Result<Value, String> {
3475 let id = params["id"].as_str().ok_or("id is required")?;
3476 crate::validate::validate_id(id).map_err(|e| e.to_string())?;
3477 let restored = db::restore_archived(conn, id).map_err(|e| e.to_string())?;
3478 if !restored {
3479 return Err("not found in archive".into());
3480 }
3481 Ok(json!({"restored": true, "id": id}))
3482}
3483
3484fn handle_archive_purge(conn: &rusqlite::Connection, params: &Value) -> Result<Value, String> {
3485 let older_than_days = params["older_than_days"].as_i64();
3486 let purged = db::purge_archive(conn, older_than_days).map_err(|e| e.to_string())?;
3487 Ok(json!({"purged": purged}))
3488}
3489
3490fn handle_archive_stats(conn: &rusqlite::Connection) -> Result<Value, String> {
3491 db::archive_stats(conn).map_err(|e| e.to_string())
3492}
3493
3494fn handle_gc(conn: &rusqlite::Connection, params: &Value, archive: bool) -> Result<Value, String> {
3495 let dry_run = params["dry_run"].as_bool().unwrap_or(false);
3496 if dry_run {
3497 let now = chrono::Utc::now().to_rfc3339();
3499 let count: usize = conn
3500 .query_row(
3501 "SELECT COUNT(*) FROM memories WHERE expires_at IS NOT NULL AND expires_at < ?1",
3502 rusqlite::params![now],
3503 |r| r.get(0),
3504 )
3505 .unwrap_or(0);
3506 return Ok(json!({"collected": count, "dry_run": true}));
3507 }
3508 let count = db::gc(conn, archive).map_err(|e| e.to_string())?;
3509 Ok(json!({"collected": count, "dry_run": false}))
3510}
3511
3512pub(crate) fn handle_session_start(
3513 conn: &rusqlite::Connection,
3514 params: &Value,
3515 llm: Option<&OllamaClient>,
3516) -> Result<Value, String> {
3517 let namespace = params["namespace"].as_str();
3518 let limit = usize::try_from(params["limit"].as_u64().unwrap_or(10)).unwrap_or(usize::MAX);
3519
3520 let results = db::list(
3521 conn,
3522 namespace,
3523 None,
3524 limit.min(50),
3525 0,
3526 None,
3527 None,
3528 None,
3529 None,
3530 None,
3531 )
3532 .map_err(|e| e.to_string())?;
3533
3534 let memories: Vec<Value> = results
3535 .iter()
3536 .map(|mem| {
3537 let mut val = serde_json::to_value(mem).unwrap_or_default();
3538 if let Some(obj) = val.as_object_mut() {
3539 obj.insert("score".to_string(), json!(0.0));
3540 }
3541 val
3542 })
3543 .collect();
3544
3545 let mut response = json!({
3546 "memories": memories,
3547 "count": memories.len(),
3548 "mode": "session_start",
3549 });
3550
3551 if let Some(llm_client) = llm
3552 && !results.is_empty()
3553 {
3554 let pairs: Vec<(String, String)> = results
3555 .iter()
3556 .map(|m| (m.title.clone(), m.content.clone()))
3557 .collect();
3558 match llm_client.summarize_memories(&pairs) {
3559 Ok(summary) => {
3560 response["summary"] = json!(summary);
3561 }
3562 Err(e) => {
3563 tracing::warn!("session_start LLM summary failed: {}", e);
3564 }
3565 }
3566 }
3567
3568 inject_namespace_standard(conn, namespace, &mut response);
3574
3575 Ok(response)
3576}
3577
3578#[allow(clippy::too_many_arguments)]
3579#[allow(clippy::too_many_lines)]
3580#[allow(clippy::too_many_arguments)]
3581fn handle_request(
3582 conn: &rusqlite::Connection,
3583 db_path: &Path,
3584 req: &RpcRequest,
3585 embedder: Option<&Embedder>,
3586 llm: Option<&OllamaClient>,
3587 reranker: Option<&CrossEncoder>,
3588 tier_config: &TierConfig,
3589 vector_index: Option<&VectorIndex>,
3590 resolved_ttl: &crate::config::ResolvedTtl,
3591 resolved_scoring: &crate::config::ResolvedScoring,
3592 archive_on_gc: bool,
3593 autonomous_hooks: bool,
3594 mcp_client: Option<&str>,
3595 profile: &crate::profile::Profile,
3596 mcp_config: Option<&crate::config::McpConfig>,
3597) -> RpcResponse {
3598 let id = req.id.clone().unwrap_or(Value::Null);
3599
3600 if req.jsonrpc != "2.0" {
3602 return err_response(
3603 id,
3604 -32600,
3605 "invalid JSON-RPC version (must be \"2.0\")".into(),
3606 );
3607 }
3608
3609 match req.method.as_str() {
3610 "initialize" => ok_response(
3611 id,
3612 json!({
3613 "protocolVersion": "2024-11-05",
3614 "capabilities": { "tools": {}, "prompts": {} },
3615 "serverInfo": {
3616 "name": "ai-memory",
3617 "version": env!("CARGO_PKG_VERSION")
3618 }
3619 }),
3620 ),
3621 "notifications/initialized" | "ping" => ok_response(id, json!({})),
3622 "tools/list" => ok_response(id, tool_definitions_for_profile(profile)),
3623 "prompts/list" => ok_response(id, prompt_definitions()),
3624 "prompts/get" => {
3625 let prompt_name = match req.params["name"].as_str() {
3626 Some(name) if !name.is_empty() => name,
3627 _ => return err_response(id, -32602, "missing or empty prompt name".into()),
3628 };
3629 match prompt_content(prompt_name, &req.params) {
3630 Ok(val) => ok_response(id, val),
3631 Err(e) => err_response(id, -32602, e),
3632 }
3633 }
3634 "tools/call" => {
3635 let tool_name = match req.params["name"].as_str() {
3636 Some(name) if !name.is_empty() => name,
3637 _ => return err_response(id, -32602, "missing or empty tool name".into()),
3638 };
3639
3640 if !profile.loads(tool_name) {
3647 let owning_family = crate::profile::Family::for_tool(tool_name);
3648 let hint = match owning_family {
3649 Some(f) => format!(
3650 "tool '{tool_name}' is in family '{}' which is not loaded under \
3651 the active profile. Restart with `--profile <name>` or \
3652 `--profile core,{}` to load it, or call `memory_capabilities \
3653 --include-schema family={}` to expand at runtime.",
3654 f.name(),
3655 f.name(),
3656 f.name()
3657 ),
3658 None => format!(
3659 "tool '{tool_name}' is not registered in this build. Call \
3660 `memory_capabilities` to see available tools."
3661 ),
3662 };
3663 return err_response(id, -32601, hint);
3664 }
3665
3666 let span = tracing::info_span!(
3672 "mcp_tool_call",
3673 tool = tool_name,
3674 rpc_id = ?id,
3675 );
3676 let _enter = span.enter();
3677 let started = Instant::now();
3678
3679 let empty_obj = json!({});
3680 let arguments = if req.params["arguments"].is_object() {
3681 &req.params["arguments"]
3682 } else {
3683 &empty_obj
3684 };
3685
3686 let result = match tool_name {
3687 "memory_store" => handle_store(
3688 conn,
3689 db_path,
3690 arguments,
3691 embedder,
3692 llm,
3693 vector_index,
3694 resolved_ttl,
3695 autonomous_hooks,
3696 mcp_client,
3697 ),
3698 "memory_recall" => handle_recall(
3699 conn,
3700 arguments,
3701 embedder,
3702 vector_index,
3703 reranker,
3704 archive_on_gc,
3705 resolved_ttl,
3706 resolved_scoring,
3707 ),
3708 "memory_search" => handle_search(conn, arguments),
3709 "memory_list" => handle_list(conn, arguments),
3710 "memory_get_taxonomy" => handle_get_taxonomy(conn, arguments),
3711 "memory_check_duplicate" => handle_check_duplicate(conn, arguments, embedder),
3712 "memory_entity_register" => handle_entity_register(conn, arguments, mcp_client),
3713 "memory_entity_get_by_alias" => handle_entity_get_by_alias(conn, arguments),
3714 "memory_kg_timeline" => handle_kg_timeline(conn, arguments),
3715 "memory_kg_invalidate" => handle_kg_invalidate(conn, arguments),
3716 "memory_kg_query" => handle_kg_query(conn, arguments),
3717 "memory_delete" => {
3718 handle_delete(conn, db_path, arguments, vector_index, mcp_client)
3719 }
3720 "memory_promote" => handle_promote(conn, db_path, arguments, mcp_client),
3721 "memory_pending_list" => handle_pending_list(conn, arguments),
3722 "memory_pending_approve" => handle_pending_approve(conn, arguments, mcp_client),
3723 "memory_pending_reject" => handle_pending_reject(conn, arguments, mcp_client),
3724 "memory_forget" => handle_forget(conn, arguments, archive_on_gc),
3725 "memory_stats" => handle_stats(conn, db_path),
3726 "memory_update" => handle_update(conn, arguments, embedder, vector_index),
3727 "memory_get" => handle_get(conn, arguments),
3728 "memory_link" => handle_link(conn, db_path, arguments),
3729 "memory_get_links" => handle_get_links(conn, arguments),
3730 "memory_consolidate" => handle_consolidate(
3731 conn,
3732 db_path,
3733 arguments,
3734 llm,
3735 embedder,
3736 vector_index,
3737 mcp_client,
3738 ),
3739 "memory_capabilities" => {
3740 if let Some(fam_name) = arguments.get("family").and_then(Value::as_str) {
3744 let include_schema = arguments
3745 .get("include_schema")
3746 .and_then(Value::as_bool)
3747 .unwrap_or(false);
3748 let aid = arguments
3754 .get("agent_id")
3755 .and_then(Value::as_str)
3756 .or(mcp_client);
3757 handle_capabilities_family(
3758 fam_name,
3759 include_schema,
3760 profile,
3761 mcp_config,
3762 aid,
3763 Some(conn),
3764 )
3765 } else {
3766 let accept = arguments
3770 .get("accept")
3771 .and_then(Value::as_str)
3772 .map_or(CapabilitiesAccept::V2, CapabilitiesAccept::parse);
3773 let result = handle_capabilities_with_conn(
3779 tier_config,
3780 reranker,
3781 embedder.is_some(),
3782 Some(conn),
3783 accept,
3784 );
3785 result.map(|mut value| {
3786 if matches!(accept, CapabilitiesAccept::V2) {
3787 if let Some(obj) = value.as_object_mut() {
3788 obj.insert("families".to_string(), families_overview(profile));
3789 }
3790 }
3791 value
3792 })
3793 }
3794 }
3795 "memory_expand_query" => handle_expand_query(llm, arguments),
3796 "memory_auto_tag" => handle_auto_tag(conn, llm, arguments),
3797 "memory_detect_contradiction" => handle_detect_contradiction(conn, llm, arguments),
3798 "memory_archive_list" => handle_archive_list(conn, arguments),
3799 "memory_archive_restore" => handle_archive_restore(conn, arguments),
3800 "memory_archive_purge" => handle_archive_purge(conn, arguments),
3801 "memory_archive_stats" => handle_archive_stats(conn),
3802 "memory_gc" => handle_gc(conn, arguments, archive_on_gc),
3803 "memory_session_start" => handle_session_start(conn, arguments, llm),
3804 "memory_namespace_set_standard" => handle_namespace_set_standard(conn, arguments),
3805 "memory_namespace_get_standard" => handle_namespace_get_standard(conn, arguments),
3806 "memory_namespace_clear_standard" => {
3807 handle_namespace_clear_standard(conn, arguments)
3808 }
3809 "memory_agent_register" => handle_agent_register(conn, arguments),
3810 "memory_agent_list" => handle_agent_list(conn),
3811 "memory_notify" => handle_notify(conn, arguments, resolved_ttl, mcp_client),
3812 "memory_inbox" => handle_inbox(conn, arguments, mcp_client),
3813 "memory_subscribe" => handle_subscribe(conn, arguments, mcp_client),
3814 "memory_unsubscribe" => handle_unsubscribe(conn, arguments),
3815 "memory_list_subscriptions" => handle_list_subscriptions(conn),
3816 unknown => {
3823 return err_response(id, -32601, format!("unknown tool: {unknown}"));
3824 }
3825 };
3826
3827 let elapsed_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
3831 match &result {
3832 Ok(_) => tracing::info!(elapsed_ms, "ok"),
3833 Err(err) => tracing::warn!(elapsed_ms, error = %err, "err"),
3834 }
3835
3836 audit_emit_for_mcp_dispatch(tool_name, arguments, &result, mcp_client);
3842
3843 match result {
3844 Ok(val) => {
3845 let format_str = arguments
3847 .get("format")
3848 .and_then(|v| v.as_str())
3849 .unwrap_or("toon_compact");
3850 let text = match format_str {
3851 "toon"
3852 if matches!(
3853 tool_name,
3854 "memory_recall" | "memory_list" | "memory_session_start"
3855 ) =>
3856 {
3857 crate::toon::memories_to_toon(&val, false)
3858 }
3859 "toon_compact"
3860 if matches!(
3861 tool_name,
3862 "memory_recall" | "memory_list" | "memory_session_start"
3863 ) =>
3864 {
3865 crate::toon::memories_to_toon(&val, true)
3866 }
3867 "toon" if tool_name == "memory_search" => {
3868 crate::toon::search_to_toon(&val, false)
3869 }
3870 "toon_compact" if tool_name == "memory_search" => {
3871 crate::toon::search_to_toon(&val, true)
3872 }
3873 _ => serde_json::to_string_pretty(&val).unwrap_or_default(),
3874 };
3875 ok_response(
3876 id,
3877 json!({
3878 "content": [{
3879 "type": "text",
3880 "text": text
3881 }]
3882 }),
3883 )
3884 }
3885 Err(e) => ok_response(
3886 id,
3887 json!({
3888 "content": [{"type": "text", "text": e}],
3889 "isError": true
3890 }),
3891 ),
3892 }
3893 }
3894 _ => err_response(id, -32601, format!("method not found: {}", req.method)),
3895 }
3896}
3897
3898#[allow(clippy::too_many_lines)]
3909pub fn run_mcp_server(
3910 db_path: &Path,
3911 tier: FeatureTier,
3912 app_config: &AppConfig,
3913 profile: &crate::profile::Profile,
3914) -> anyhow::Result<()> {
3915 let _ = tracing_subscriber::fmt()
3922 .with_env_filter(
3923 tracing_subscriber::EnvFilter::try_from_default_env()
3924 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("ai_memory=info")),
3925 )
3926 .with_writer(std::io::stderr)
3927 .try_init();
3928
3929 let conn = db::open(db_path)?;
3930 let stdin = io::stdin();
3931 let mut stdout = io::stdout();
3932
3933 let mut tier_config = tier.config();
3934 eprintln!("ai-memory: requested tier = {}", tier.as_str());
3935 let family_names: Vec<&'static str> = profile.families().iter().map(|f| f.name()).collect();
3939 eprintln!(
3940 "ai-memory: profile = {} families ({}); expected tool count = {}",
3941 profile.families().len(),
3942 family_names.join(", "),
3943 profile.expected_tool_count()
3944 );
3945
3946 if tier_config.llm_model.is_some()
3949 && let Some(ref llm_override) = app_config.llm_model
3950 {
3951 match llm_override.as_str() {
3952 "gemma4:e2b" => {
3953 tier_config.llm_model = Some(crate::config::LlmModel::Gemma4E2B);
3954 eprintln!("ai-memory: llm_model override from config: gemma4:e2b");
3955 }
3956 "gemma4:e4b" => {
3957 tier_config.llm_model = Some(crate::config::LlmModel::Gemma4E4B);
3958 eprintln!("ai-memory: llm_model override from config: gemma4:e4b");
3959 }
3960 other => eprintln!("ai-memory: unknown llm_model '{other}', using tier default"),
3961 }
3962 }
3963
3964 if tier_config.embedding_model.is_some()
3966 && let Some(ref emb_override) = app_config.embedding_model
3967 {
3968 match emb_override.as_str() {
3969 "mini_lm_l6_v2" => {
3970 tier_config.embedding_model = Some(crate::config::EmbeddingModel::MiniLmL6V2);
3971 eprintln!("ai-memory: embedding_model override from config: mini_lm_l6_v2 (local)");
3972 }
3973 "nomic_embed_v15" => {
3974 tier_config.embedding_model = Some(crate::config::EmbeddingModel::NomicEmbedV15);
3975 eprintln!(
3976 "ai-memory: embedding_model override from config: nomic_embed_v15 (Ollama)"
3977 );
3978 }
3979 other => {
3980 eprintln!("ai-memory: unknown embedding_model '{other}', using tier default");
3981 }
3982 }
3983 }
3984
3985 let llm: Option<Arc<OllamaClient>> = if let Some(ref llm_model) = tier_config.llm_model {
3988 let model_id = llm_model.ollama_model_id();
3989 eprintln!(
3990 "ai-memory: connecting to Ollama for {} ...",
3991 llm_model.display_name()
3992 );
3993 let ollama_url = app_config.effective_ollama_url();
3994 match OllamaClient::new_with_url(ollama_url, model_id) {
3995 Ok(client) => {
3996 eprintln!("ai-memory: Ollama connected, ensuring model {model_id} is available...");
3997 if let Err(e) = client.ensure_model() {
3998 eprintln!("ai-memory: model pull failed: {e} (LLM features disabled)");
3999 None
4000 } else {
4001 eprintln!("ai-memory: LLM ready ({})", llm_model.display_name());
4002 Some(Arc::new(client))
4003 }
4004 }
4005 Err(e) => {
4006 eprintln!("ai-memory: Ollama not available: {e} (LLM features disabled)");
4007 None
4008 }
4009 }
4010 } else {
4011 None
4012 };
4013
4014 let embed_client: Option<Arc<OllamaClient>> = {
4017 let embed_url = app_config.effective_embed_url();
4018 let ollama_url = app_config.effective_ollama_url();
4019 if embed_url == ollama_url {
4020 llm.clone()
4021 } else {
4022 eprintln!("ai-memory: using separate embed URL: {embed_url}");
4024 match OllamaClient::new_with_url(embed_url, "nomic-embed-text") {
4025 Ok(client) => Some(Arc::new(client)),
4026 Err(e) => {
4027 eprintln!("ai-memory: embed client failed: {e}, falling back to LLM client");
4028 llm.clone()
4029 }
4030 }
4031 }
4032 };
4033 let embedder = if let Some(ref emb_model) = tier_config.embedding_model {
4034 match Embedder::for_model(*emb_model, embed_client) {
4035 Ok(emb) => {
4036 eprintln!("ai-memory: embedder loaded ({})", emb.model_description());
4037 match db::get_unembedded_ids(&conn) {
4039 Ok(unembedded) if !unembedded.is_empty() => {
4040 eprintln!("ai-memory: backfilling {} memories...", unembedded.len());
4041 let mut ok = 0usize;
4042 for (id, title, content) in &unembedded {
4043 let text = format!("{title} {content}");
4044 match emb.embed(&text) {
4045 Ok(embedding) => {
4046 if db::set_embedding(&conn, id, &embedding).is_ok() {
4047 ok += 1;
4048 }
4049 }
4050 Err(e) => {
4051 eprintln!(
4052 "ai-memory: embed failed for {}: {}",
4053 &id[..8.min(id.len())],
4054 e
4055 );
4056 }
4057 }
4058 }
4059 eprintln!("ai-memory: backfilled {}/{}", ok, unembedded.len());
4060 }
4061 _ => {}
4062 }
4063 Some(emb)
4064 }
4065 Err(e) => {
4066 eprintln!("ai-memory: embedder failed: {e}");
4067 None
4068 }
4069 }
4070 } else {
4071 None
4072 };
4073
4074 let vector_index = if embedder.is_some() {
4076 match db::get_all_embeddings(&conn) {
4077 Ok(entries) if !entries.is_empty() => {
4078 eprintln!(
4079 "ai-memory: building HNSW index ({} vectors)...",
4080 entries.len()
4081 );
4082 let idx = VectorIndex::build(entries);
4083 eprintln!("ai-memory: HNSW index ready ({} entries)", idx.len());
4084 Some(idx)
4085 }
4086 _ => {
4087 eprintln!("ai-memory: no embeddings for HNSW index, using linear scan");
4088 Some(VectorIndex::empty())
4089 }
4090 }
4091 } else {
4092 None
4093 };
4094
4095 let reranker = if tier_config.cross_encoder {
4097 eprintln!("ai-memory: loading neural cross-encoder (ms-marco-MiniLM-L-6-v2)...");
4098 let ce = CrossEncoder::new_neural();
4099 if ce.is_neural() {
4100 eprintln!("ai-memory: neural cross-encoder ready");
4101 } else {
4102 eprintln!("ai-memory: using lexical cross-encoder fallback");
4103 }
4104 Some(ce)
4105 } else {
4106 None
4107 };
4108
4109 let effective_tier = if llm.is_some() && embedder.is_some() && reranker.is_some() {
4111 "autonomous"
4112 } else if llm.is_some() && embedder.is_some() {
4113 "smart"
4114 } else if embedder.is_some() {
4115 "semantic"
4116 } else {
4117 "keyword"
4118 };
4119 eprintln!("ai-memory MCP server started (stdio, tier={effective_tier})");
4120
4121 let mut mcp_client_name: Option<String> = None;
4125
4126 for line in stdin.lock().lines() {
4127 let line = line?;
4128 if line.trim().is_empty() {
4129 continue;
4130 }
4131
4132 let req: RpcRequest = match serde_json::from_str(&line) {
4133 Ok(r) => r,
4134 Err(e) => {
4135 let resp = err_response(Value::Null, -32700, format!("parse error: {e}"));
4136 let out = serde_json::to_string(&resp)?;
4137 writeln!(stdout, "{out}")?;
4138 stdout.flush()?;
4139 continue;
4140 }
4141 };
4142
4143 if req.method == "initialize"
4145 && let Some(name) = req.params["clientInfo"]["name"].as_str()
4146 && !name.is_empty()
4147 {
4148 mcp_client_name = Some(name.to_string());
4149 }
4150
4151 if req.id.is_none() || req.id == Some(Value::Null) {
4153 continue;
4154 }
4155
4156 let resolved_ttl = app_config.effective_ttl();
4157 let resolved_scoring = app_config.effective_scoring();
4158 let archive_on_gc = app_config.effective_archive_on_gc();
4159 let autonomous_hooks = app_config.effective_autonomous_hooks();
4160 let resp = handle_request(
4161 &conn,
4162 db_path,
4163 &req,
4164 embedder.as_ref(),
4165 llm.as_deref(),
4166 reranker.as_ref(),
4167 &tier_config,
4168 vector_index.as_ref(),
4169 &resolved_ttl,
4170 &resolved_scoring,
4171 archive_on_gc,
4172 autonomous_hooks,
4173 mcp_client_name.as_deref(),
4174 profile,
4175 app_config.mcp.as_ref(),
4176 );
4177 let out = serde_json::to_string(&resp)?;
4178 writeln!(stdout, "{out}")?;
4179 stdout.flush()?;
4180 }
4181
4182 let _ = db::checkpoint(&conn);
4183 eprintln!("ai-memory MCP server stopped");
4184 Ok(())
4185}
4186
4187#[cfg(test)]
4188mod tests {
4189 use super::*;
4190 use serde_json::json;
4191
4192 #[test]
4193 fn tool_definitions_returns_43_tools() {
4194 let defs = tool_definitions();
4201 let tools = defs["tools"].as_array().unwrap();
4202 assert_eq!(tools.len(), 43);
4203 }
4204
4205 #[test]
4210 fn tool_definitions_for_profile_core_registers_5_plus_capabilities() {
4211 let defs = tool_definitions_for_profile(&crate::profile::Profile::core());
4212 let tools = defs["tools"].as_array().unwrap();
4213 let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
4214 assert_eq!(
4216 tools.len(),
4217 6,
4218 "core profile should register 5 core tools + memory_capabilities; got {names:?}"
4219 );
4220 for required in [
4221 "memory_store",
4222 "memory_recall",
4223 "memory_list",
4224 "memory_get",
4225 "memory_search",
4226 "memory_capabilities",
4227 ] {
4228 assert!(
4229 names.contains(&required),
4230 "core profile missing {required}; got {names:?}"
4231 );
4232 }
4233 for excluded in [
4235 "memory_kg_query",
4236 "memory_consolidate",
4237 "memory_archive_list",
4238 "memory_subscribe",
4239 "memory_promote",
4240 ] {
4241 assert!(
4242 !names.contains(&excluded),
4243 "core profile leaked {excluded}; got {names:?}"
4244 );
4245 }
4246 }
4247
4248 #[test]
4249 fn tool_definitions_for_profile_full_registers_43() {
4250 let defs = tool_definitions_for_profile(&crate::profile::Profile::full());
4251 let tools = defs["tools"].as_array().unwrap();
4252 assert_eq!(
4253 tools.len(),
4254 43,
4255 "full profile must reproduce v0.6.3 surface 1:1"
4256 );
4257 }
4258
4259 #[test]
4260 fn tool_definitions_for_profile_graph_registers_thirteen_plus_capabilities() {
4261 let defs = tool_definitions_for_profile(&crate::profile::Profile::graph());
4262 let tools = defs["tools"].as_array().unwrap();
4263 assert_eq!(
4266 tools.len(),
4267 14,
4268 "graph profile = core(5) + graph(8) + capabilities-bootstrap(1)"
4269 );
4270 }
4271
4272 #[test]
4274 fn tool_definitions_for_profile_custom_core_comma_graph_registers_union() {
4275 let p = crate::profile::Profile::parse("core,graph").unwrap();
4276 let defs = tool_definitions_for_profile(&p);
4277 let tools = defs["tools"].as_array().unwrap();
4278 assert_eq!(tools.len(), 14, "core,graph = 5 + 8 + capabilities");
4279 }
4280
4281 #[test]
4284 fn families_overview_lists_all_eight_with_correct_loaded_flags() {
4285 let p = crate::profile::Profile::core();
4286 let v = families_overview(&p);
4287 let families = v["families"].as_array().unwrap();
4288 assert_eq!(families.len(), 8, "all eight families must appear");
4289
4290 let core_row = families.iter().find(|r| r["name"] == "core").unwrap();
4291 assert_eq!(core_row["loaded"], true);
4292 assert_eq!(core_row["tool_count"], 5);
4293 let graph_row = families.iter().find(|r| r["name"] == "graph").unwrap();
4294 assert_eq!(graph_row["loaded"], false);
4295 assert_eq!(graph_row["tool_count"], 8);
4296
4297 let always_on = v["always_on"].as_array().unwrap();
4298 assert_eq!(always_on.len(), 1);
4299 assert_eq!(always_on[0], "memory_capabilities");
4300 }
4301
4302 #[test]
4303 fn handle_capabilities_family_lists_tool_names() {
4304 let p = crate::profile::Profile::core();
4305 let v = handle_capabilities_family("graph", false, &p, None, None, None).unwrap();
4306 assert_eq!(v["family"], "graph");
4307 assert_eq!(v["loaded_under_active_profile"], false);
4308 let tools = v["tools"].as_array().unwrap();
4309 assert_eq!(tools.len(), 8);
4310 assert!(tools.iter().any(|t| t == "memory_kg_query"));
4312 }
4313
4314 #[test]
4315 fn handle_capabilities_family_include_schema_returns_full_definitions() {
4316 let p = crate::profile::Profile::core();
4317 let v = handle_capabilities_family("graph", true, &p, None, None, None).unwrap();
4318 assert_eq!(v["family"], "graph");
4319 let tools = v["tools"].as_array().unwrap();
4320 assert_eq!(tools.len(), 8);
4321 for tool in tools {
4323 assert!(tool.get("name").is_some(), "missing name");
4324 assert!(tool.get("description").is_some(), "missing description");
4325 assert!(tool.get("inputSchema").is_some(), "missing inputSchema");
4326 }
4327 }
4328
4329 #[test]
4330 fn handle_capabilities_family_unknown_returns_diagnostic_err() {
4331 let p = crate::profile::Profile::core();
4332 let err = handle_capabilities_family("xyz", false, &p, None, None, None).unwrap_err();
4333 assert!(err.contains("xyz"));
4334 assert!(err.contains("Valid families"));
4335 assert!(err.contains("core"));
4336 assert!(err.contains("graph"));
4337 }
4338
4339 #[test]
4340 fn handle_capabilities_family_empty_name_errors() {
4341 let p = crate::profile::Profile::core();
4342 let err = handle_capabilities_family("", false, &p, None, None, None).unwrap_err();
4343 assert!(err.contains("must not be empty"));
4344 }
4345
4346 #[test]
4347 fn tool_definitions_include_check_duplicate() {
4348 let defs = tool_definitions();
4349 let tools = defs["tools"].as_array().unwrap();
4350 let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
4351 assert!(names.contains(&"memory_check_duplicate"));
4352 }
4353
4354 #[test]
4355 fn tool_definitions_include_entity_registry_tools() {
4356 let defs = tool_definitions();
4357 let tools = defs["tools"].as_array().unwrap();
4358 let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
4359 assert!(names.contains(&"memory_entity_register"));
4360 assert!(names.contains(&"memory_entity_get_by_alias"));
4361 }
4362
4363 #[test]
4364 fn tool_definitions_include_kg_timeline() {
4365 let defs = tool_definitions();
4366 let tools = defs["tools"].as_array().unwrap();
4367 let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
4368 assert!(names.contains(&"memory_kg_timeline"));
4369 }
4370
4371 #[test]
4372 fn tool_definitions_include_kg_invalidate() {
4373 let defs = tool_definitions();
4374 let tools = defs["tools"].as_array().unwrap();
4375 let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
4376 assert!(names.contains(&"memory_kg_invalidate"));
4377 }
4378
4379 #[test]
4380 fn tool_definitions_include_kg_query() {
4381 let defs = tool_definitions();
4382 let tools = defs["tools"].as_array().unwrap();
4383 let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
4384 assert!(names.contains(&"memory_kg_query"));
4385 }
4386
4387 #[test]
4388 fn tool_definitions_include_agent_register_and_list() {
4389 let defs = tool_definitions();
4390 let names: Vec<&str> = defs["tools"]
4391 .as_array()
4392 .unwrap()
4393 .iter()
4394 .filter_map(|t| t["name"].as_str())
4395 .collect();
4396 assert!(names.contains(&"memory_agent_register"));
4397 assert!(names.contains(&"memory_agent_list"));
4398 }
4399
4400 #[test]
4401 fn tool_definitions_include_notify_and_inbox() {
4402 let defs = tool_definitions();
4404 let names: Vec<&str> = defs["tools"]
4405 .as_array()
4406 .unwrap()
4407 .iter()
4408 .filter_map(|t| t["name"].as_str())
4409 .collect();
4410 assert!(names.contains(&"memory_notify"));
4411 assert!(names.contains(&"memory_inbox"));
4412 }
4413
4414 #[test]
4415 fn messages_namespace_is_prefixed() {
4416 assert_eq!(super::messages_namespace_for("alice"), "_messages/alice");
4417 assert_eq!(
4418 super::messages_namespace_for("ai:claude-opus-4.7"),
4419 "_messages/ai:claude-opus-4.7"
4420 );
4421 }
4422
4423 #[test]
4424 fn tool_definitions_all_have_names() {
4425 let defs = tool_definitions();
4426 let tools = defs["tools"].as_array().unwrap();
4427 for tool in tools {
4428 assert!(tool["name"].as_str().unwrap().starts_with("memory_"));
4429 }
4430 }
4431
4432 #[test]
4433 fn tool_definitions_recall_has_toon_default() {
4434 let defs = tool_definitions();
4435 let tools = defs["tools"].as_array().unwrap();
4436 let recall = tools.iter().find(|t| t["name"] == "memory_recall").unwrap();
4437 let format_schema = &recall["inputSchema"]["properties"]["format"];
4438 assert_eq!(format_schema["default"], "toon_compact");
4439 }
4440
4441 #[test]
4442 fn prompt_definitions_returns_2() {
4443 let defs = prompt_definitions();
4444 let prompts = defs["prompts"].as_array().unwrap();
4445 assert_eq!(prompts.len(), 2);
4446 assert_eq!(prompts[0]["name"], "recall-first");
4447 assert_eq!(prompts[1]["name"], "memory-workflow");
4448 }
4449
4450 #[test]
4451 fn prompt_definitions_recall_first_has_arguments() {
4452 let defs = prompt_definitions();
4453 let prompts = defs["prompts"].as_array().unwrap();
4454 let recall_first = &prompts[0];
4455 let args = recall_first["arguments"].as_array().unwrap();
4456 assert_eq!(args.len(), 1);
4457 assert_eq!(args[0]["name"], "namespace");
4458 }
4459
4460 #[test]
4461 fn prompt_content_recall_first() {
4462 let params = json!({});
4463 let result = prompt_content("recall-first", ¶ms).unwrap();
4464 let msgs = result["messages"].as_array().unwrap();
4465 assert_eq!(msgs.len(), 1);
4466 let text = msgs[0]["content"]["text"].as_str().unwrap();
4467 assert!(text.contains("RECALL FIRST"));
4468 assert!(text.contains("TOON"));
4469 assert!(text.contains("memory_recall"));
4470 assert!(text.contains("memory_store"));
4471 assert!(text.contains("DEDUP"));
4472 }
4473
4474 #[test]
4475 fn prompt_content_recall_first_with_namespace() {
4476 let params = json!({"arguments": {"namespace": "my-project"}});
4477 let result = prompt_content("recall-first", ¶ms).unwrap();
4478 let text = result["messages"][0]["content"]["text"].as_str().unwrap();
4479 assert!(text.contains("my-project"));
4480 }
4481
4482 #[test]
4483 fn prompt_content_memory_workflow() {
4484 let params = json!({});
4485 let result = prompt_content("memory-workflow", ¶ms).unwrap();
4486 let text = result["messages"][0]["content"]["text"].as_str().unwrap();
4487 assert!(text.contains("STORE"));
4488 assert!(text.contains("RECALL"));
4489 assert!(text.contains("SEARCH"));
4490 assert!(text.contains("CONSOLIDATE"));
4491 assert!(text.contains("TOON"));
4492 }
4493
4494 #[test]
4495 fn prompt_content_unknown() {
4496 let params = json!({});
4497 let result = prompt_content("nonexistent", ¶ms);
4498 assert!(result.is_err());
4499 assert!(result.unwrap_err().contains("unknown prompt"));
4500 }
4501
4502 #[test]
4503 fn prompt_content_role_is_user() {
4504 let params = json!({});
4505 let result = prompt_content("recall-first", ¶ms).unwrap();
4506 assert_eq!(result["messages"][0]["role"], "user");
4507 }
4508
4509 #[test]
4510 fn ok_response_structure() {
4511 let resp = ok_response(json!(1), json!({"test": true}));
4512 assert_eq!(resp.jsonrpc, "2.0");
4513 assert_eq!(resp.id, json!(1));
4514 assert!(resp.result.is_some());
4515 assert!(resp.error.is_none());
4516 }
4517
4518 #[test]
4519 fn err_response_structure() {
4520 let resp = err_response(json!(1), -32600, "test error".to_string());
4521 assert_eq!(resp.jsonrpc, "2.0");
4522 assert!(resp.error.is_some());
4523 let err = resp.error.unwrap();
4524 assert_eq!(err.code, -32600);
4525 assert_eq!(err.message, "test error");
4526 }
4527
4528 #[derive(Clone)]
4532 struct VecWriter(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
4533
4534 impl std::io::Write for VecWriter {
4535 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
4536 self.0.lock().unwrap().extend_from_slice(buf);
4537 Ok(buf.len())
4538 }
4539 fn flush(&mut self) -> std::io::Result<()> {
4540 Ok(())
4541 }
4542 }
4543
4544 impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for VecWriter {
4545 type Writer = VecWriter;
4546 fn make_writer(&'a self) -> Self::Writer {
4547 self.clone()
4548 }
4549 }
4550
4551 fn run_with_capture<F: FnOnce()>(f: F) -> String {
4552 let buf = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
4553 let writer = VecWriter(buf.clone());
4554 let subscriber = tracing_subscriber::fmt()
4555 .with_writer(writer)
4556 .with_max_level(tracing::Level::INFO)
4557 .with_ansi(false)
4558 .finish();
4559 tracing::subscriber::with_default(subscriber, f);
4560 String::from_utf8(buf.lock().unwrap().clone()).unwrap_or_default()
4561 }
4562
4563 fn make_tools_call(tool: &str, args: Value) -> RpcRequest {
4564 RpcRequest {
4565 jsonrpc: "2.0".into(),
4566 id: Some(json!(1)),
4567 method: "tools/call".into(),
4568 params: json!({ "name": tool, "arguments": args }),
4569 }
4570 }
4571
4572 #[test]
4577 fn tools_call_emits_span_with_tool_name_and_elapsed_ms() {
4578 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
4579 let tier_config = FeatureTier::Keyword.config();
4580 let resolved_ttl = crate::config::ResolvedTtl::default();
4581 let resolved_scoring = crate::config::ResolvedScoring::default();
4582 let req = make_tools_call("memory_list", json!({"limit": 1}));
4583
4584 let captured = run_with_capture(|| {
4585 let resp = handle_request(
4586 &conn,
4587 std::path::Path::new(":memory:"),
4588 &req,
4589 None,
4590 None,
4591 None,
4592 &tier_config,
4593 None,
4594 &resolved_ttl,
4595 &resolved_scoring,
4596 true,
4597 false,
4598 None,
4599 &crate::profile::Profile::full(),
4600 None,
4601 );
4602 assert!(resp.error.is_none(), "expected ok rpc response");
4603 });
4604
4605 assert!(
4606 captured.contains("mcp_tool_call"),
4607 "missing span name in: {captured}"
4608 );
4609 assert!(
4610 captured.contains("memory_list"),
4611 "missing tool field in: {captured}"
4612 );
4613 assert!(
4614 captured.contains("elapsed_ms"),
4615 "missing elapsed_ms field in: {captured}"
4616 );
4617 assert!(
4618 captured.contains(" ok"),
4619 "missing ok outcome event in: {captured}"
4620 );
4621 }
4622
4623 #[test]
4627 fn tools_call_emits_warn_event_on_handler_error() {
4628 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
4629 let tier_config = FeatureTier::Keyword.config();
4630 let resolved_ttl = crate::config::ResolvedTtl::default();
4631 let resolved_scoring = crate::config::ResolvedScoring::default();
4632 let req = make_tools_call("memory_get", json!({"id": ""}));
4635
4636 let captured = run_with_capture(|| {
4637 let resp = handle_request(
4638 &conn,
4639 std::path::Path::new(":memory:"),
4640 &req,
4641 None,
4642 None,
4643 None,
4644 &tier_config,
4645 None,
4646 &resolved_ttl,
4647 &resolved_scoring,
4648 true,
4649 false,
4650 None,
4651 &crate::profile::Profile::full(),
4652 None,
4653 );
4654 assert!(resp.error.is_none());
4658 });
4659
4660 assert!(
4661 captured.contains("mcp_tool_call"),
4662 "missing span in err path: {captured}"
4663 );
4664 assert!(
4665 captured.contains("memory_get"),
4666 "missing tool field in err path: {captured}"
4667 );
4668 assert!(
4669 captured.contains("WARN"),
4670 "missing WARN level on err path: {captured}"
4671 );
4672 assert!(
4673 captured.contains("err"),
4674 "missing err outcome in: {captured}"
4675 );
4676 }
4677 #[test]
4681 #[allow(clippy::too_many_lines)]
4682 fn mcp_tools_smoke_matrix() {
4683 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
4684 let tier_config = FeatureTier::Keyword.config();
4685 let resolved_ttl = crate::config::ResolvedTtl::default();
4686 let resolved_scoring = crate::config::ResolvedScoring::default();
4687
4688 struct ToolCase {
4689 name: &'static str,
4690 valid_args: Value,
4691 required_arg: Option<&'static str>, }
4693
4694 let cases: &[ToolCase] = &[
4695 ToolCase {
4696 name: "memory_store",
4697 valid_args: json!({"title": "test", "content": "test content"}),
4698 required_arg: Some("title"),
4699 },
4700 ToolCase {
4701 name: "memory_recall",
4702 valid_args: json!({"context": "test"}),
4703 required_arg: Some("context"),
4704 },
4705 ToolCase {
4706 name: "memory_search",
4707 valid_args: json!({"query": "test"}),
4708 required_arg: Some("query"),
4709 },
4710 ToolCase {
4711 name: "memory_list",
4712 valid_args: json!({}),
4713 required_arg: None,
4714 },
4715 ToolCase {
4716 name: "memory_get_taxonomy",
4717 valid_args: json!({}),
4718 required_arg: None,
4719 },
4720 ToolCase {
4721 name: "memory_check_duplicate",
4722 valid_args: json!({"title": "test", "content": "test content"}),
4723 required_arg: Some("title"),
4724 },
4725 ToolCase {
4726 name: "memory_entity_register",
4727 valid_args: json!({"canonical_name": "Entity", "namespace": "test"}),
4728 required_arg: Some("canonical_name"),
4729 },
4730 ToolCase {
4731 name: "memory_entity_get_by_alias",
4732 valid_args: json!({"alias": "test"}),
4733 required_arg: Some("alias"),
4734 },
4735 ToolCase {
4736 name: "memory_kg_timeline",
4737 valid_args: json!({"source_id": "fake-id-for-test"}),
4738 required_arg: Some("source_id"),
4739 },
4740 ToolCase {
4741 name: "memory_kg_invalidate",
4742 valid_args: json!({"source_id": "s", "target_id": "t", "relation": "related_to"}),
4743 required_arg: Some("source_id"),
4744 },
4745 ToolCase {
4746 name: "memory_kg_query",
4747 valid_args: json!({"source_id": "fake-id-for-test"}),
4748 required_arg: Some("source_id"),
4749 },
4750 ToolCase {
4751 name: "memory_delete",
4752 valid_args: json!({"id": "fake-id-for-test"}),
4753 required_arg: Some("id"),
4754 },
4755 ToolCase {
4756 name: "memory_promote",
4757 valid_args: json!({"id": "fake-id-for-test"}),
4758 required_arg: Some("id"),
4759 },
4760 ToolCase {
4761 name: "memory_forget",
4762 valid_args: json!({}),
4763 required_arg: None,
4764 },
4765 ToolCase {
4766 name: "memory_stats",
4767 valid_args: json!({}),
4768 required_arg: None,
4769 },
4770 ToolCase {
4771 name: "memory_update",
4772 valid_args: json!({"id": "fake-id-for-test"}),
4773 required_arg: Some("id"),
4774 },
4775 ToolCase {
4776 name: "memory_get",
4777 valid_args: json!({"id": "fake-id-for-test"}),
4778 required_arg: Some("id"),
4779 },
4780 ToolCase {
4781 name: "memory_link",
4782 valid_args: json!({"source_id": "s", "target_id": "t"}),
4783 required_arg: Some("source_id"),
4784 },
4785 ToolCase {
4786 name: "memory_get_links",
4787 valid_args: json!({"id": "fake-id-for-test"}),
4788 required_arg: Some("id"),
4789 },
4790 ToolCase {
4791 name: "memory_consolidate",
4792 valid_args: json!({"ids": ["id1", "id2"], "title": "consolidated"}),
4793 required_arg: Some("ids"),
4794 },
4795 ToolCase {
4796 name: "memory_capabilities",
4797 valid_args: json!({}),
4798 required_arg: None,
4799 },
4800 ToolCase {
4801 name: "memory_expand_query",
4802 valid_args: json!({"query": "test"}),
4803 required_arg: Some("query"),
4804 },
4805 ToolCase {
4806 name: "memory_auto_tag",
4807 valid_args: json!({"id": "fake-id-for-test"}),
4808 required_arg: Some("id"),
4809 },
4810 ToolCase {
4811 name: "memory_detect_contradiction",
4812 valid_args: json!({"id_a": "a", "id_b": "b"}),
4813 required_arg: Some("id_a"),
4814 },
4815 ToolCase {
4816 name: "memory_archive_list",
4817 valid_args: json!({}),
4818 required_arg: None,
4819 },
4820 ToolCase {
4821 name: "memory_archive_restore",
4822 valid_args: json!({"id": "fake-id-for-test"}),
4823 required_arg: Some("id"),
4824 },
4825 ToolCase {
4826 name: "memory_archive_purge",
4827 valid_args: json!({}),
4828 required_arg: None,
4829 },
4830 ToolCase {
4831 name: "memory_archive_stats",
4832 valid_args: json!({}),
4833 required_arg: None,
4834 },
4835 ToolCase {
4836 name: "memory_gc",
4837 valid_args: json!({}),
4838 required_arg: None,
4839 },
4840 ToolCase {
4841 name: "memory_session_start",
4842 valid_args: json!({}),
4843 required_arg: None,
4844 },
4845 ToolCase {
4846 name: "memory_namespace_set_standard",
4847 valid_args: json!({"namespace": "test", "id": "fake-id-for-test"}),
4848 required_arg: Some("namespace"),
4849 },
4850 ToolCase {
4851 name: "memory_namespace_get_standard",
4852 valid_args: json!({"namespace": "test"}),
4853 required_arg: Some("namespace"),
4854 },
4855 ToolCase {
4856 name: "memory_namespace_clear_standard",
4857 valid_args: json!({"namespace": "test"}),
4858 required_arg: Some("namespace"),
4859 },
4860 ToolCase {
4861 name: "memory_pending_list",
4862 valid_args: json!({}),
4863 required_arg: None,
4864 },
4865 ToolCase {
4866 name: "memory_pending_approve",
4867 valid_args: json!({"id": "fake-id-for-test"}),
4868 required_arg: Some("id"),
4869 },
4870 ToolCase {
4871 name: "memory_pending_reject",
4872 valid_args: json!({"id": "fake-id-for-test"}),
4873 required_arg: Some("id"),
4874 },
4875 ToolCase {
4876 name: "memory_agent_register",
4877 valid_args: json!({"agent_id": "test-agent", "agent_type": "human"}),
4878 required_arg: Some("agent_id"),
4879 },
4880 ToolCase {
4881 name: "memory_agent_list",
4882 valid_args: json!({}),
4883 required_arg: None,
4884 },
4885 ToolCase {
4886 name: "memory_notify",
4887 valid_args: json!({"target_agent_id": "agent", "title": "msg", "payload": "body"}),
4888 required_arg: Some("target_agent_id"),
4889 },
4890 ToolCase {
4891 name: "memory_inbox",
4892 valid_args: json!({}),
4893 required_arg: None,
4894 },
4895 ToolCase {
4896 name: "memory_subscribe",
4897 valid_args: json!({"url": "https://example.com/webhook"}),
4898 required_arg: Some("url"),
4899 },
4900 ToolCase {
4901 name: "memory_unsubscribe",
4902 valid_args: json!({"id": "fake-id-for-test"}),
4903 required_arg: Some("id"),
4904 },
4905 ToolCase {
4906 name: "memory_list_subscriptions",
4907 valid_args: json!({}),
4908 required_arg: None,
4909 },
4910 ];
4911
4912 for case in cases {
4914 let req = make_tools_call(case.name, case.valid_args.clone());
4915 let resp = handle_request(
4916 &conn,
4917 std::path::Path::new(":memory:"),
4918 &req,
4919 None,
4920 None,
4921 None,
4922 &tier_config,
4923 None,
4924 &resolved_ttl,
4925 &resolved_scoring,
4926 true,
4927 false,
4928 None,
4929 &crate::profile::Profile::full(),
4930 None,
4931 );
4932 assert!(
4933 resp.error.is_none(),
4934 "happy path failed for {}: {:?}",
4935 case.name,
4936 resp.error
4937 );
4938 assert!(
4939 resp.result.is_some(),
4940 "missing result for happy path {}: {:?}",
4941 case.name,
4942 resp
4943 );
4944 }
4945
4946 for case in cases {
4948 if let Some(required_arg) = case.required_arg {
4949 let mut bad_args = case.valid_args.clone();
4950 bad_args.as_object_mut().unwrap().remove(required_arg);
4951
4952 let req = make_tools_call(case.name, bad_args);
4953 let resp = handle_request(
4954 &conn,
4955 std::path::Path::new(":memory:"),
4956 &req,
4957 None,
4958 None,
4959 None,
4960 &tier_config,
4961 None,
4962 &resolved_ttl,
4963 &resolved_scoring,
4964 true,
4965 false,
4966 None,
4967 &crate::profile::Profile::full(),
4968 None,
4969 );
4970
4971 assert!(
4974 resp.error.is_none() || resp.result.is_some(),
4975 "unexpected RPC-layer error for {} (missing {}) should be handler-level Err",
4976 case.name,
4977 required_arg
4978 );
4979 }
4980 }
4981 }
4982
4983 fn invoke_handle_request(conn: &rusqlite::Connection, req: &RpcRequest) -> RpcResponse {
5005 let tier_config = FeatureTier::Keyword.config();
5006 let resolved_ttl = crate::config::ResolvedTtl::default();
5007 let resolved_scoring = crate::config::ResolvedScoring::default();
5008 handle_request(
5009 conn,
5010 std::path::Path::new(":memory:"),
5011 req,
5012 None,
5013 None,
5014 None,
5015 &tier_config,
5016 None,
5017 &resolved_ttl,
5018 &resolved_scoring,
5019 true,
5020 false,
5021 None,
5022 &crate::profile::Profile::full(),
5023 None,
5024 )
5025 }
5026
5027 fn dispatch_line(conn: &rusqlite::Connection, line: &str) -> Option<RpcResponse> {
5038 if line.trim().is_empty() {
5039 return None;
5040 }
5041 let req: RpcRequest = match serde_json::from_str(line) {
5042 Ok(r) => r,
5043 Err(e) => {
5044 return Some(err_response(
5045 Value::Null,
5046 -32700,
5047 format!("parse error: {e}"),
5048 ));
5049 }
5050 };
5051 if req.id.is_none() || req.id == Some(Value::Null) {
5052 return None;
5053 }
5054 Some(invoke_handle_request(conn, &req))
5055 }
5056
5057 #[test]
5065 fn handle_store_happy_returns_id_and_tier() {
5066 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5067 let req = make_tools_call(
5068 "memory_store",
5069 json!({"title": "t", "content": "c", "namespace": "m9-store", "tier": "short"}),
5070 );
5071 let resp = invoke_handle_request(&conn, &req);
5072 assert!(resp.error.is_none());
5073 let text = resp.result.unwrap()["content"][0]["text"]
5074 .as_str()
5075 .unwrap()
5076 .to_string();
5077 let val: Value = serde_json::from_str(&text).unwrap();
5078 assert!(val["id"].is_string());
5079 assert_eq!(val["tier"], "short");
5080 }
5081
5082 #[test]
5083 fn handle_store_error_missing_title() {
5084 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5085 let req = make_tools_call("memory_store", json!({"content": "c"}));
5086 let resp = invoke_handle_request(&conn, &req);
5087 let result = resp.result.unwrap();
5089 assert_eq!(result["isError"], true);
5090 }
5091
5092 #[test]
5093 fn handle_recall_happy_returns_memories_array() {
5094 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5095 let req = make_tools_call(
5096 "memory_recall",
5097 json!({"context": "anything", "format": "json"}),
5098 );
5099 let resp = invoke_handle_request(&conn, &req);
5100 assert!(resp.error.is_none());
5101 let text = resp.result.unwrap()["content"][0]["text"]
5102 .as_str()
5103 .unwrap()
5104 .to_string();
5105 let val: Value = serde_json::from_str(&text).unwrap();
5106 assert!(val["memories"].is_array());
5107 assert!(val["count"].is_u64());
5108 }
5109
5110 #[test]
5111 fn handle_recall_budget_tokens_zero_returns_empty() {
5112 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5118 let req = make_tools_call(
5119 "memory_recall",
5120 json!({"context": "x", "budget_tokens": 0, "format": "json"}),
5121 );
5122 let resp = invoke_handle_request(&conn, &req);
5123 assert!(resp.error.is_none(), "budget_tokens=0 must not error");
5124 let text = resp.result.unwrap()["content"][0]["text"]
5125 .as_str()
5126 .unwrap()
5127 .to_string();
5128 let val: Value = serde_json::from_str(&text).unwrap();
5129 assert_eq!(val["count"], 0, "budget_tokens=0 returns zero memories");
5130 assert_eq!(val["budget_tokens"], 0);
5131 assert_eq!(val["tokens_used"], 0);
5132 assert_eq!(val["meta"]["budget_overflow"], false);
5133 assert_eq!(val["meta"]["budget_tokens_used"], 0);
5134 assert_eq!(val["meta"]["budget_tokens_remaining"], 0);
5135 }
5136
5137 #[test]
5138 fn handle_search_happy_returns_results_array() {
5139 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5140 let req = make_tools_call(
5141 "memory_search",
5142 json!({"query": "needle", "format": "json"}),
5143 );
5144 let resp = invoke_handle_request(&conn, &req);
5145 assert!(resp.error.is_none());
5146 let text = resp.result.unwrap()["content"][0]["text"]
5147 .as_str()
5148 .unwrap()
5149 .to_string();
5150 let val: Value = serde_json::from_str(&text).unwrap();
5151 assert!(val["results"].is_array());
5152 assert!(val["count"].is_u64());
5153 }
5154
5155 #[test]
5156 fn handle_search_error_missing_query() {
5157 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5158 let req = make_tools_call("memory_search", json!({}));
5159 let resp = invoke_handle_request(&conn, &req);
5160 let result = resp.result.unwrap();
5161 assert_eq!(result["isError"], true);
5162 }
5163
5164 #[test]
5165 fn handle_get_happy_returns_memory() {
5166 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5167 let mem = Memory {
5169 id: uuid::Uuid::new_v4().to_string(),
5170 tier: Tier::Mid,
5171 namespace: "m9-get".into(),
5172 title: "t".into(),
5173 content: "c".into(),
5174 tags: vec![],
5175 priority: 5,
5176 confidence: 1.0,
5177 source: "test".into(),
5178 access_count: 0,
5179 created_at: chrono::Utc::now().to_rfc3339(),
5180 updated_at: chrono::Utc::now().to_rfc3339(),
5181 last_accessed_at: None,
5182 expires_at: None,
5183 metadata: json!({}),
5184 };
5185 let id = db::insert(&conn, &mem).unwrap();
5186 let req = make_tools_call("memory_get", json!({"id": id}));
5187 let resp = invoke_handle_request(&conn, &req);
5188 assert!(resp.error.is_none());
5189 let text = resp.result.unwrap()["content"][0]["text"]
5190 .as_str()
5191 .unwrap()
5192 .to_string();
5193 let val: Value = serde_json::from_str(&text).unwrap();
5194 assert_eq!(val["title"], "t");
5195 assert_eq!(val["namespace"], "m9-get");
5196 assert!(val["links"].is_array());
5197 }
5198
5199 #[test]
5200 fn handle_get_error_unknown_id() {
5201 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5202 let req = make_tools_call(
5203 "memory_get",
5204 json!({"id": "00000000-0000-0000-0000-000000000000"}),
5205 );
5206 let resp = invoke_handle_request(&conn, &req);
5207 let result = resp.result.unwrap();
5208 assert_eq!(result["isError"], true);
5209 assert!(
5210 result["content"][0]["text"]
5211 .as_str()
5212 .unwrap()
5213 .contains("not found")
5214 );
5215 }
5216
5217 #[test]
5218 fn handle_list_happy_returns_memories_array() {
5219 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5220 let req = make_tools_call("memory_list", json!({"format": "json"}));
5221 let resp = invoke_handle_request(&conn, &req);
5222 assert!(resp.error.is_none());
5223 let text = resp.result.unwrap()["content"][0]["text"]
5224 .as_str()
5225 .unwrap()
5226 .to_string();
5227 let val: Value = serde_json::from_str(&text).unwrap();
5228 assert!(val["memories"].is_array());
5229 }
5230
5231 #[test]
5232 fn handle_list_error_invalid_agent_id() {
5233 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5235 let req = make_tools_call("memory_list", json!({"agent_id": "has space"}));
5236 let resp = invoke_handle_request(&conn, &req);
5237 let result = resp.result.unwrap();
5238 assert_eq!(result["isError"], true);
5239 }
5240
5241 #[test]
5242 fn handle_delete_happy_removes_existing_memory() {
5243 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5244 let mem = Memory {
5245 id: uuid::Uuid::new_v4().to_string(),
5246 tier: Tier::Mid,
5247 namespace: "m9-del".into(),
5248 title: "t".into(),
5249 content: "c".into(),
5250 tags: vec![],
5251 priority: 5,
5252 confidence: 1.0,
5253 source: "test".into(),
5254 access_count: 0,
5255 created_at: chrono::Utc::now().to_rfc3339(),
5256 updated_at: chrono::Utc::now().to_rfc3339(),
5257 last_accessed_at: None,
5258 expires_at: None,
5259 metadata: json!({}),
5260 };
5261 let id = db::insert(&conn, &mem).unwrap();
5262 let req = make_tools_call("memory_delete", json!({"id": id}));
5263 let resp = invoke_handle_request(&conn, &req);
5264 assert!(resp.error.is_none());
5265 let text = resp.result.unwrap()["content"][0]["text"]
5266 .as_str()
5267 .unwrap()
5268 .to_string();
5269 let val: Value = serde_json::from_str(&text).unwrap();
5270 assert_eq!(val["deleted"], true);
5271 }
5272
5273 #[test]
5274 fn handle_delete_error_empty_id() {
5275 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5276 let req = make_tools_call("memory_delete", json!({"id": ""}));
5277 let resp = invoke_handle_request(&conn, &req);
5278 let result = resp.result.unwrap();
5279 assert_eq!(result["isError"], true);
5280 }
5281
5282 #[test]
5283 fn handle_link_happy_returns_linked_true() {
5284 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5285 let mut ids = Vec::new();
5286 for tag in ["a", "b"] {
5287 let mem = Memory {
5288 id: uuid::Uuid::new_v4().to_string(),
5289 tier: Tier::Mid,
5290 namespace: "m9-link".into(),
5291 title: tag.into(),
5292 content: "c".into(),
5293 tags: vec![],
5294 priority: 5,
5295 confidence: 1.0,
5296 source: "test".into(),
5297 access_count: 0,
5298 created_at: chrono::Utc::now().to_rfc3339(),
5299 updated_at: chrono::Utc::now().to_rfc3339(),
5300 last_accessed_at: None,
5301 expires_at: None,
5302 metadata: json!({}),
5303 };
5304 ids.push(db::insert(&conn, &mem).unwrap());
5305 }
5306 let req = make_tools_call(
5307 "memory_link",
5308 json!({"source_id": ids[0], "target_id": ids[1], "relation": "related_to"}),
5309 );
5310 let resp = invoke_handle_request(&conn, &req);
5311 assert!(resp.error.is_none());
5312 let text = resp.result.unwrap()["content"][0]["text"]
5313 .as_str()
5314 .unwrap()
5315 .to_string();
5316 let val: Value = serde_json::from_str(&text).unwrap();
5317 assert_eq!(val["linked"], true);
5318 }
5319
5320 #[test]
5321 fn handle_link_error_missing_target_id() {
5322 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5323 let req = make_tools_call("memory_link", json!({"source_id": "x"}));
5324 let resp = invoke_handle_request(&conn, &req);
5325 let result = resp.result.unwrap();
5326 assert_eq!(result["isError"], true);
5327 }
5328
5329 #[test]
5330 fn handle_promote_error_unknown_id() {
5331 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5332 let req = make_tools_call(
5333 "memory_promote",
5334 json!({"id": "00000000-0000-0000-0000-000000000000"}),
5335 );
5336 let resp = invoke_handle_request(&conn, &req);
5337 let result = resp.result.unwrap();
5338 assert_eq!(result["isError"], true);
5339 }
5340
5341 #[test]
5342 fn handle_consolidate_error_missing_summary_keyword_tier() {
5343 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5345 let req = make_tools_call(
5346 "memory_consolidate",
5347 json!({"ids": ["a", "b"], "title": "t"}),
5348 );
5349 let resp = invoke_handle_request(&conn, &req);
5350 let result = resp.result.unwrap();
5351 assert_eq!(result["isError"], true);
5352 let msg = result["content"][0]["text"].as_str().unwrap();
5353 assert!(msg.contains("summary"));
5354 }
5355
5356 #[test]
5357 fn handle_capabilities_happy_returns_tier_struct() {
5358 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5359 let req = make_tools_call("memory_capabilities", json!({}));
5360 let resp = invoke_handle_request(&conn, &req);
5361 assert!(resp.error.is_none());
5362 let text = resp.result.unwrap()["content"][0]["text"]
5363 .as_str()
5364 .unwrap()
5365 .to_string();
5366 let val: Value = serde_json::from_str(&text).unwrap();
5367 assert!(val["tier"].is_string());
5368 assert!(val["features"].is_object());
5369 }
5370
5371 #[test]
5376 fn mcp_capabilities_v2_schema_includes_all_blocks() {
5377 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5378 let req = make_tools_call("memory_capabilities", json!({}));
5379 let resp = invoke_handle_request(&conn, &req);
5380 let text = resp.result.unwrap()["content"][0]["text"]
5381 .as_str()
5382 .unwrap()
5383 .to_string();
5384 let val: Value = serde_json::from_str(&text).unwrap();
5385
5386 assert_eq!(val["schema_version"], "2", "schema_version bumped to 2");
5387
5388 assert!(val["permissions"].is_object(), "permissions block present");
5391 assert_eq!(val["permissions"]["mode"], "advisory");
5392 assert!(val["permissions"]["active_rules"].is_number());
5393 assert!(
5394 val["permissions"].get("rule_summary").is_none(),
5395 "v2 drops rule_summary (no per-rule serializer)"
5396 );
5397 assert_eq!(val["permissions"]["inheritance"], "enforced");
5401
5402 assert!(val["hooks"].is_object(), "hooks block present");
5404 assert!(val["hooks"]["registered_count"].is_number());
5405 assert!(
5406 val["hooks"].get("by_event").is_none(),
5407 "v2 drops hooks.by_event (no event registry)"
5408 );
5409
5410 assert!(val["compaction"].is_object(), "compaction block present");
5412 assert_eq!(val["compaction"]["planned"], true);
5413 assert_eq!(val["compaction"]["enabled"], false);
5414 assert_eq!(val["compaction"]["version"], "v0.8+");
5415 assert!(val["compaction"].get("interval_minutes").is_none());
5416 assert!(val["compaction"].get("last_run_at").is_none());
5417 assert!(val["compaction"].get("last_run_stats").is_none());
5418
5419 assert!(val["approval"].is_object(), "approval block present");
5422 assert!(val["approval"]["pending_requests"].is_number());
5423 assert!(
5424 val["approval"].get("subscribers").is_none(),
5425 "v2 drops approval.subscribers (no subscription API)"
5426 );
5427 assert!(
5428 val["approval"].get("default_timeout_seconds").is_none(),
5429 "v2 drops approval.default_timeout_seconds (no sweeper)"
5430 );
5431
5432 assert!(val["transcripts"].is_object(), "transcripts block present");
5434 assert_eq!(val["transcripts"]["planned"], true);
5435 assert_eq!(val["transcripts"]["enabled"], false);
5436 assert_eq!(val["transcripts"]["version"], "v0.7+");
5437
5438 assert_eq!(val["features"]["memory_reflection"]["planned"], true);
5440 assert_eq!(val["features"]["memory_reflection"]["enabled"], false);
5441
5442 assert_eq!(val["features"]["recall_mode_active"], "disabled");
5445 assert_eq!(val["features"]["reranker_active"], "off");
5446 }
5447
5448 #[test]
5453 fn mcp_capabilities_v2_backwards_compatible() {
5454 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5455 let req = make_tools_call("memory_capabilities", json!({}));
5456 let resp = invoke_handle_request(&conn, &req);
5457 let text = resp.result.unwrap()["content"][0]["text"]
5458 .as_str()
5459 .unwrap()
5460 .to_string();
5461 let val: Value = serde_json::from_str(&text).unwrap();
5462
5463 assert!(val["tier"].is_string(), "v1: tier preserved");
5465 assert!(val["version"].is_string(), "v1: version preserved");
5466 assert!(val["features"].is_object(), "v1: features preserved");
5467 assert!(val["models"].is_object(), "v1: models preserved");
5468
5469 assert!(val["features"]["keyword_search"].is_boolean());
5471 assert!(val["features"]["semantic_search"].is_boolean());
5472 assert!(val["features"]["embedder_loaded"].is_boolean());
5473 assert!(val["models"]["embedding"].is_string());
5474 assert!(val["models"]["llm"].is_string());
5475 assert!(val["models"]["cross_encoder"].is_string());
5476 }
5477
5478 #[test]
5482 fn mcp_capabilities_accept_v1_returns_legacy_shape() {
5483 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5484 let req = make_tools_call("memory_capabilities", json!({"accept": "v1"}));
5485 let resp = invoke_handle_request(&conn, &req);
5486 let text = resp.result.unwrap()["content"][0]["text"]
5487 .as_str()
5488 .unwrap()
5489 .to_string();
5490 let val: Value = serde_json::from_str(&text).unwrap();
5491
5492 assert!(val.get("schema_version").is_none());
5494 assert!(val.get("permissions").is_none());
5496 assert!(val.get("hooks").is_none());
5497 assert!(val.get("compaction").is_none());
5498 assert!(val.get("approval").is_none());
5499 assert!(val.get("transcripts").is_none());
5500 assert!(val["features"]["memory_reflection"].is_boolean());
5502 assert!(val["features"].get("recall_mode_active").is_none());
5504 assert!(val["features"].get("reranker_active").is_none());
5505 }
5506
5507 #[test]
5511 fn mcp_capabilities_pending_requests_reflects_db() {
5512 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5513 let payload = serde_json::json!({"foo": "bar"}).to_string();
5516 conn.execute(
5517 "INSERT INTO pending_actions (id, action_type, memory_id, namespace,
5518 payload, requested_by, requested_at, status)
5519 VALUES ('p-1', 'store', NULL, 'global', ?1, 'agent-1',
5520 '2026-04-27T00:00:00Z', 'pending')",
5521 rusqlite::params![payload],
5522 )
5523 .unwrap();
5524
5525 let req = make_tools_call("memory_capabilities", json!({}));
5526 let resp = invoke_handle_request(&conn, &req);
5527 let text = resp.result.unwrap()["content"][0]["text"]
5528 .as_str()
5529 .unwrap()
5530 .to_string();
5531 let val: Value = serde_json::from_str(&text).unwrap();
5532
5533 assert_eq!(
5534 val["approval"]["pending_requests"], 1,
5535 "pending_actions(status=pending) count surfaces live"
5536 );
5537 }
5538
5539 #[test]
5540 fn handle_subscribe_error_unregistered_agent() {
5541 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5543 let req = make_tools_call(
5544 "memory_subscribe",
5545 json!({"url": "https://example.com/hook"}),
5546 );
5547 let resp = invoke_handle_request(&conn, &req);
5548 let result = resp.result.unwrap();
5549 assert_eq!(result["isError"], true);
5550 let msg = result["content"][0]["text"].as_str().unwrap();
5551 assert!(msg.contains("not registered"));
5552 }
5553
5554 #[test]
5559 fn test_jsonrpc_handles_well_formed_request() {
5560 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5561 let line = r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#;
5562 let resp = dispatch_line(&conn, line).expect("expected response");
5563 assert!(resp.error.is_none());
5564 let result = resp.result.unwrap();
5565 assert!(result["tools"].is_array());
5566 }
5567
5568 #[test]
5569 fn test_jsonrpc_handles_malformed_json() {
5570 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5571 let resp = dispatch_line(&conn, "this is not json at all").expect("expected response");
5573 let err = resp.error.unwrap();
5574 assert_eq!(err.code, -32700);
5575 assert!(err.message.contains("parse error"));
5576 assert_eq!(resp.id, Value::Null);
5578 }
5579
5580 #[test]
5581 fn test_jsonrpc_handles_truncated_request() {
5582 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5583 let resp = dispatch_line(&conn, r#"{"jsonrpc":"2.0","id":1,"method":"#)
5585 .expect("expected response");
5586 let err = resp.error.unwrap();
5587 assert_eq!(err.code, -32700);
5588 }
5589
5590 #[test]
5591 fn test_jsonrpc_handles_two_requests_per_line() {
5592 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5597 let line = r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"} {"jsonrpc":"2.0","id":2,"method":"tools/list"}"#;
5598 let resp = dispatch_line(&conn, line).expect("expected response");
5599 let err = resp.error.unwrap();
5600 assert_eq!(err.code, -32700);
5601 }
5602
5603 #[test]
5604 fn test_jsonrpc_handles_blank_line() {
5605 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5607 assert!(dispatch_line(&conn, "").is_none());
5608 assert!(dispatch_line(&conn, " \t ").is_none());
5609 }
5610
5611 #[test]
5612 fn test_jsonrpc_handles_notification_no_response() {
5613 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5616 let line = r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#;
5617 assert!(dispatch_line(&conn, line).is_none());
5618 let line_null = r#"{"jsonrpc":"2.0","id":null,"method":"notifications/initialized"}"#;
5620 assert!(dispatch_line(&conn, line_null).is_none());
5621 }
5622
5623 #[test]
5624 fn test_jsonrpc_handles_method_not_found() {
5625 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5627 let req = RpcRequest {
5628 jsonrpc: "2.0".into(),
5629 id: Some(json!(7)),
5630 method: "no/such/method".into(),
5631 params: json!({}),
5632 };
5633 let resp = invoke_handle_request(&conn, &req);
5634 let err = resp.error.unwrap();
5635 assert_eq!(err.code, -32601);
5636 assert!(err.message.contains("method not found"));
5637 }
5638
5639 #[test]
5640 fn test_jsonrpc_handles_invalid_params() {
5641 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5643 let req = RpcRequest {
5644 jsonrpc: "2.0".into(),
5645 id: Some(json!(8)),
5646 method: "tools/call".into(),
5647 params: json!({"arguments": {}}),
5648 };
5649 let resp = invoke_handle_request(&conn, &req);
5650 let err = resp.error.unwrap();
5651 assert_eq!(err.code, -32602);
5652 }
5653
5654 #[test]
5655 fn test_jsonrpc_handles_unknown_tool_returns_minus_32601() {
5656 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5658 let req = make_tools_call("memory_does_not_exist", json!({}));
5659 let resp = invoke_handle_request(&conn, &req);
5660 let err = resp.error.unwrap();
5661 assert_eq!(err.code, -32601);
5662 assert!(err.message.contains("memory_does_not_exist"));
5663 }
5664
5665 #[test]
5666 fn test_jsonrpc_rejects_wrong_version() {
5667 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5669 let req = RpcRequest {
5670 jsonrpc: "1.0".into(),
5671 id: Some(json!(1)),
5672 method: "tools/list".into(),
5673 params: json!({}),
5674 };
5675 let resp = invoke_handle_request(&conn, &req);
5676 let err = resp.error.unwrap();
5677 assert_eq!(err.code, -32600);
5678 }
5679
5680 #[test]
5681 fn test_jsonrpc_handles_initialize() {
5682 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5684 let req = RpcRequest {
5685 jsonrpc: "2.0".into(),
5686 id: Some(json!(1)),
5687 method: "initialize".into(),
5688 params: json!({"clientInfo": {"name": "test-client"}}),
5689 };
5690 let resp = invoke_handle_request(&conn, &req);
5691 assert!(resp.error.is_none());
5692 let result = resp.result.unwrap();
5693 assert_eq!(result["protocolVersion"], "2024-11-05");
5694 assert_eq!(result["serverInfo"]["name"], "ai-memory");
5695 }
5696
5697 #[test]
5711 fn test_auto_register_creates_top_level_namespace() {
5712 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5715 super::auto_register_path_hierarchy(&conn, "m9-top");
5716 let count: i64 = conn
5717 .query_row("SELECT COUNT(*) FROM namespace_meta", [], |r| r.get(0))
5718 .unwrap();
5719 assert_eq!(count, 0);
5720 }
5721
5722 #[test]
5723 fn test_auto_register_creates_nested_hierarchy() {
5724 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5732 let mem = Memory {
5736 id: uuid::Uuid::new_v4().to_string(),
5737 tier: Tier::Long,
5738 namespace: "m9-parent".into(),
5739 title: "parent standard".into(),
5740 content: "...".into(),
5741 tags: vec![],
5742 priority: 5,
5743 confidence: 1.0,
5744 source: "test".into(),
5745 access_count: 0,
5746 created_at: chrono::Utc::now().to_rfc3339(),
5747 updated_at: chrono::Utc::now().to_rfc3339(),
5748 last_accessed_at: None,
5749 expires_at: None,
5750 metadata: json!({}),
5751 };
5752 let std_id = db::insert(&conn, &mem).unwrap();
5753 db::set_namespace_standard(&conn, "m9-parent", &std_id, None).unwrap();
5754 let child_mem = Memory {
5756 id: uuid::Uuid::new_v4().to_string(),
5757 tier: Tier::Long,
5758 namespace: "repo/team/sub".into(),
5759 title: "child".into(),
5760 content: "...".into(),
5761 tags: vec![],
5762 priority: 5,
5763 confidence: 1.0,
5764 source: "test".into(),
5765 access_count: 0,
5766 created_at: chrono::Utc::now().to_rfc3339(),
5767 updated_at: chrono::Utc::now().to_rfc3339(),
5768 last_accessed_at: None,
5769 expires_at: None,
5770 metadata: json!({}),
5771 };
5772 let child_id = db::insert(&conn, &child_mem).unwrap();
5773 db::set_namespace_standard(&conn, "repo/team/sub", &child_id, None).unwrap();
5774 super::auto_register_path_hierarchy(&conn, "repo/team/sub");
5776 let id = db::get_namespace_standard(&conn, "repo/team/sub")
5778 .unwrap()
5779 .unwrap();
5780 assert_eq!(id, child_id);
5781 }
5782
5783 #[test]
5784 fn test_auto_register_idempotent() {
5785 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5788 super::auto_register_path_hierarchy(&conn, "m9-idem");
5789 super::auto_register_path_hierarchy(&conn, "m9-idem");
5790 let count: i64 = conn
5791 .query_row("SELECT COUNT(*) FROM namespace_meta", [], |r| r.get(0))
5792 .unwrap();
5793 assert_eq!(count, 0);
5794 }
5795
5796 #[test]
5797 fn test_auto_register_handles_empty_string_or_root() {
5798 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5801 super::auto_register_path_hierarchy(&conn, "");
5802 super::auto_register_path_hierarchy(&conn, "/");
5803 super::auto_register_path_hierarchy(&conn, "*");
5804 let count: i64 = conn
5806 .query_row("SELECT COUNT(*) FROM namespace_meta", [], |r| r.get(0))
5807 .unwrap();
5808 assert_eq!(count, 0);
5809 }
5810
5811 #[test]
5812 fn test_auto_register_skips_when_explicit_parent_set() {
5813 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5817 let parent_mem = Memory {
5819 id: uuid::Uuid::new_v4().to_string(),
5820 tier: Tier::Long,
5821 namespace: "m9-explicit-parent".into(),
5822 title: "p".into(),
5823 content: "c".into(),
5824 tags: vec![],
5825 priority: 5,
5826 confidence: 1.0,
5827 source: "test".into(),
5828 access_count: 0,
5829 created_at: chrono::Utc::now().to_rfc3339(),
5830 updated_at: chrono::Utc::now().to_rfc3339(),
5831 last_accessed_at: None,
5832 expires_at: None,
5833 metadata: json!({}),
5834 };
5835 let parent_id = db::insert(&conn, &parent_mem).unwrap();
5836 db::set_namespace_standard(&conn, "m9-explicit-parent", &parent_id, None).unwrap();
5837
5838 let child_mem = Memory {
5839 id: uuid::Uuid::new_v4().to_string(),
5840 tier: Tier::Long,
5841 namespace: "m9-explicit-child".into(),
5842 title: "c".into(),
5843 content: "c".into(),
5844 tags: vec![],
5845 priority: 5,
5846 confidence: 1.0,
5847 source: "test".into(),
5848 access_count: 0,
5849 created_at: chrono::Utc::now().to_rfc3339(),
5850 updated_at: chrono::Utc::now().to_rfc3339(),
5851 last_accessed_at: None,
5852 expires_at: None,
5853 metadata: json!({}),
5854 };
5855 let child_id = db::insert(&conn, &child_mem).unwrap();
5856 db::set_namespace_standard(
5857 &conn,
5858 "m9-explicit-child",
5859 &child_id,
5860 Some("m9-explicit-parent"),
5861 )
5862 .unwrap();
5863
5864 assert_eq!(
5866 db::get_namespace_parent(&conn, "m9-explicit-child"),
5867 Some("m9-explicit-parent".to_string())
5868 );
5869 super::auto_register_path_hierarchy(&conn, "m9-explicit-child");
5870 assert_eq!(
5872 db::get_namespace_parent(&conn, "m9-explicit-child"),
5873 Some("m9-explicit-parent".to_string())
5874 );
5875 }
5876
5877 fn make_recall_response(memories: Vec<Value>) -> Value {
5882 let count = memories.len();
5883 json!({
5884 "memories": memories,
5885 "count": count,
5886 "mode": "keyword",
5887 })
5888 }
5889
5890 fn seed_namespace_standard(
5891 conn: &rusqlite::Connection,
5892 namespace: &str,
5893 title: &str,
5894 ) -> String {
5895 let mem = Memory {
5896 id: uuid::Uuid::new_v4().to_string(),
5897 tier: Tier::Long,
5898 namespace: namespace.into(),
5899 title: title.into(),
5900 content: "policy text".into(),
5901 tags: vec!["_standard".into()],
5902 priority: 5,
5903 confidence: 1.0,
5904 source: "test".into(),
5905 access_count: 0,
5906 created_at: chrono::Utc::now().to_rfc3339(),
5907 updated_at: chrono::Utc::now().to_rfc3339(),
5908 last_accessed_at: None,
5909 expires_at: None,
5910 metadata: json!({}),
5911 };
5912 let id = db::insert(conn, &mem).unwrap();
5913 db::set_namespace_standard(conn, namespace, &id, None).unwrap();
5914 id
5915 }
5916
5917 #[test]
5918 fn test_inject_namespace_standard_attaches_when_present() {
5919 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5920 let std_id = seed_namespace_standard(&conn, "m9-inject-attach", "S");
5921 let mut resp = make_recall_response(vec![]);
5922 super::inject_namespace_standard(&conn, Some("m9-inject-attach"), &mut resp);
5923 assert!(resp["standard"].is_object(), "expected attached standard");
5924 assert_eq!(resp["standard"]["id"].as_str().unwrap(), std_id);
5925 }
5926
5927 #[test]
5928 fn test_inject_namespace_standard_skips_when_absent() {
5929 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5932 let mut resp = make_recall_response(vec![]);
5933 let before = resp.clone();
5934 super::inject_namespace_standard(&conn, Some("m9-inject-empty"), &mut resp);
5935 assert_eq!(resp, before);
5936 assert!(resp.get("standard").is_none());
5937 assert!(resp.get("standards").is_none());
5938 }
5939
5940 #[test]
5941 fn test_inject_namespace_standard_top_of_recall_response() {
5942 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5946 let std_id = seed_namespace_standard(&conn, "m9-inject-dedup", "S");
5947 let dup = json!({"id": std_id, "title": "S", "content": "policy text"});
5949 let other = json!({"id": "other-id", "title": "noise", "content": "x"});
5950 let mut resp = make_recall_response(vec![dup.clone(), other.clone()]);
5951 super::inject_namespace_standard(&conn, Some("m9-inject-dedup"), &mut resp);
5952 assert_eq!(resp["standard"]["id"].as_str().unwrap(), std_id);
5953 let memories = resp["memories"].as_array().unwrap();
5954 assert_eq!(memories.len(), 1);
5955 assert_eq!(memories[0]["id"], "other-id");
5956 assert_eq!(resp["count"], 1);
5957 }
5958
5959 #[test]
5960 fn test_inject_namespace_standard_preserves_other_response_fields() {
5961 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5963 seed_namespace_standard(&conn, "m9-inject-preserve", "S");
5964 let mut resp = json!({
5965 "memories": [],
5966 "count": 0,
5967 "mode": "hybrid",
5968 "diagnostics": {"latency_ms": 42},
5969 });
5970 super::inject_namespace_standard(&conn, Some("m9-inject-preserve"), &mut resp);
5971 assert_eq!(resp["mode"], "hybrid");
5972 assert_eq!(resp["diagnostics"]["latency_ms"], 42);
5973 assert!(resp["standard"].is_object());
5974 }
5975
5976 #[test]
5977 fn test_inject_namespace_standard_no_namespace_uses_global() {
5978 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5981 seed_namespace_standard(&conn, "*", "global standard");
5982 let mut resp = make_recall_response(vec![]);
5983 super::inject_namespace_standard(&conn, None, &mut resp);
5984 assert_eq!(resp["standard"]["title"], "global standard");
5985 }
5986
5987 #[test]
5988 fn test_inject_namespace_standard_multiple_levels_emits_array() {
5989 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5993 seed_namespace_standard(&conn, "*", "GLOBAL");
5994 seed_namespace_standard(&conn, "m9-multi", "LOCAL");
5995 let mut resp = make_recall_response(vec![]);
5996 super::inject_namespace_standard(&conn, Some("m9-multi"), &mut resp);
5997 assert!(resp["standards"].is_array());
5998 let arr = resp["standards"].as_array().unwrap();
5999 assert_eq!(arr.len(), 2);
6000 assert_eq!(arr[0]["title"], "GLOBAL");
6002 assert_eq!(arr[1]["title"], "LOCAL");
6003 assert!(resp.get("standard").is_none());
6004 }
6005
6006 #[test]
6031 fn handle_archive_list_returns_empty_when_no_archived() {
6032 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6033 let req = make_tools_call("memory_archive_list", json!({}));
6034 let resp = invoke_handle_request(&conn, &req);
6035 assert!(resp.error.is_none());
6036 let text = resp.result.unwrap()["content"][0]["text"]
6037 .as_str()
6038 .unwrap()
6039 .to_string();
6040 let val: Value = serde_json::from_str(&text).unwrap();
6041 assert_eq!(val["count"], 0);
6042 assert!(val["archived"].is_array());
6043 }
6044
6045 #[test]
6046 fn handle_archive_list_with_namespace_filter() {
6047 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6048 let req = make_tools_call(
6049 "memory_archive_list",
6050 json!({"namespace": "w12-archive", "limit": 5, "offset": 0}),
6051 );
6052 let resp = invoke_handle_request(&conn, &req);
6053 assert!(resp.error.is_none());
6054 }
6055
6056 #[test]
6057 fn handle_archive_restore_unknown_id_returns_error() {
6058 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6059 let req = make_tools_call(
6060 "memory_archive_restore",
6061 json!({"id": "00000000-0000-0000-0000-000000000000"}),
6062 );
6063 let resp = invoke_handle_request(&conn, &req);
6064 let result = resp.result.unwrap();
6065 assert_eq!(result["isError"], true);
6066 let msg = result["content"][0]["text"].as_str().unwrap();
6067 assert!(msg.contains("archive") || msg.contains("not found"));
6068 }
6069
6070 #[test]
6071 fn handle_archive_purge_with_older_than_zero() {
6072 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6075 let req = make_tools_call("memory_archive_purge", json!({"older_than_days": 0}));
6076 let resp = invoke_handle_request(&conn, &req);
6077 assert!(resp.error.is_none());
6078 let text = resp.result.unwrap()["content"][0]["text"]
6079 .as_str()
6080 .unwrap()
6081 .to_string();
6082 let val: Value = serde_json::from_str(&text).unwrap();
6083 assert!(val["purged"].is_u64() || val["purged"].is_i64());
6084 }
6085
6086 #[test]
6087 fn handle_archive_stats_returns_struct() {
6088 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6089 let req = make_tools_call("memory_archive_stats", json!({}));
6090 let resp = invoke_handle_request(&conn, &req);
6091 assert!(resp.error.is_none());
6092 let text = resp.result.unwrap()["content"][0]["text"]
6093 .as_str()
6094 .unwrap()
6095 .to_string();
6096 let val: Value = serde_json::from_str(&text).unwrap();
6097 assert!(val.is_object() || val.is_number() || val.is_array());
6099 }
6100
6101 #[test]
6102 fn handle_kg_timeline_unknown_source_returns_empty_events() {
6103 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6104 let req = make_tools_call(
6105 "memory_kg_timeline",
6106 json!({"source_id": "00000000-0000-0000-0000-000000000000"}),
6107 );
6108 let resp = invoke_handle_request(&conn, &req);
6109 assert!(resp.error.is_none());
6110 let text = resp.result.unwrap()["content"][0]["text"]
6111 .as_str()
6112 .unwrap()
6113 .to_string();
6114 let val: Value = serde_json::from_str(&text).unwrap();
6115 assert!(val["events"].is_array());
6116 assert_eq!(val["count"], 0);
6117 }
6118
6119 #[test]
6120 fn handle_kg_timeline_with_since_until_filters() {
6121 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6122 let req = make_tools_call(
6123 "memory_kg_timeline",
6124 json!({
6125 "source_id": "00000000-0000-0000-0000-000000000000",
6126 "since": "2024-01-01T00:00:00Z",
6127 "until": "2025-01-01T00:00:00Z",
6128 "limit": 50,
6129 }),
6130 );
6131 let resp = invoke_handle_request(&conn, &req);
6132 assert!(resp.error.is_none());
6133 }
6134
6135 #[test]
6136 fn handle_kg_timeline_invalid_since_returns_error() {
6137 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6138 let req = make_tools_call(
6139 "memory_kg_timeline",
6140 json!({
6141 "source_id": "00000000-0000-0000-0000-000000000000",
6142 "since": "this-is-not-a-timestamp",
6143 }),
6144 );
6145 let resp = invoke_handle_request(&conn, &req);
6146 let result = resp.result.unwrap();
6147 assert_eq!(result["isError"], true);
6148 }
6149
6150 #[test]
6151 fn handle_kg_invalidate_no_match_returns_found_false() {
6152 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6153 let req = make_tools_call(
6154 "memory_kg_invalidate",
6155 json!({
6156 "source_id": "00000000-0000-0000-0000-000000000000",
6157 "target_id": "11111111-1111-1111-1111-111111111111",
6158 "relation": "related_to",
6159 }),
6160 );
6161 let resp = invoke_handle_request(&conn, &req);
6162 assert!(resp.error.is_none());
6163 let text = resp.result.unwrap()["content"][0]["text"]
6164 .as_str()
6165 .unwrap()
6166 .to_string();
6167 let val: Value = serde_json::from_str(&text).unwrap();
6168 assert_eq!(val["found"], false);
6169 }
6170
6171 #[test]
6172 fn handle_kg_invalidate_with_explicit_valid_until() {
6173 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6177 let src = Memory {
6178 id: uuid::Uuid::new_v4().to_string(),
6179 tier: Tier::Long,
6180 namespace: "w12-kg".into(),
6181 title: "src".into(),
6182 content: "c".into(),
6183 tags: vec![],
6184 priority: 5,
6185 confidence: 1.0,
6186 source: "test".into(),
6187 access_count: 0,
6188 created_at: chrono::Utc::now().to_rfc3339(),
6189 updated_at: chrono::Utc::now().to_rfc3339(),
6190 last_accessed_at: None,
6191 expires_at: None,
6192 metadata: json!({}),
6193 };
6194 let mut tgt = src.clone();
6195 tgt.id = uuid::Uuid::new_v4().to_string();
6196 tgt.title = "tgt".into();
6197 let src_id = db::insert(&conn, &src).unwrap();
6198 let tgt_id = db::insert(&conn, &tgt).unwrap();
6199 db::create_link(&conn, &src_id, &tgt_id, "related_to").unwrap();
6200
6201 let req = make_tools_call(
6202 "memory_kg_invalidate",
6203 json!({
6204 "source_id": src_id,
6205 "target_id": tgt_id,
6206 "relation": "related_to",
6207 "valid_until": "2025-01-01T00:00:00Z",
6208 }),
6209 );
6210 let resp = invoke_handle_request(&conn, &req);
6211 assert!(resp.error.is_none());
6212 let text = resp.result.unwrap()["content"][0]["text"]
6213 .as_str()
6214 .unwrap()
6215 .to_string();
6216 let val: Value = serde_json::from_str(&text).unwrap();
6217 assert_eq!(val["found"], true);
6218 assert_eq!(val["valid_until"], "2025-01-01T00:00:00Z");
6219 }
6220
6221 #[test]
6222 fn handle_kg_invalidate_invalid_valid_until_format() {
6223 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6224 let req = make_tools_call(
6225 "memory_kg_invalidate",
6226 json!({
6227 "source_id": "00000000-0000-0000-0000-000000000000",
6228 "target_id": "11111111-1111-1111-1111-111111111111",
6229 "relation": "related_to",
6230 "valid_until": "not-a-date",
6231 }),
6232 );
6233 let resp = invoke_handle_request(&conn, &req);
6234 let result = resp.result.unwrap();
6235 assert_eq!(result["isError"], true);
6236 }
6237
6238 #[test]
6239 fn handle_kg_query_with_max_depth_and_filters() {
6240 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6241 let req = make_tools_call(
6242 "memory_kg_query",
6243 json!({
6244 "source_id": "00000000-0000-0000-0000-000000000000",
6245 "max_depth": 2,
6246 "valid_at": "2025-01-01T00:00:00Z",
6247 "allowed_agents": ["agent-a", "agent-b"],
6248 "limit": 10,
6249 }),
6250 );
6251 let resp = invoke_handle_request(&conn, &req);
6252 assert!(resp.error.is_none());
6253 let text = resp.result.unwrap()["content"][0]["text"]
6254 .as_str()
6255 .unwrap()
6256 .to_string();
6257 let val: Value = serde_json::from_str(&text).unwrap();
6258 assert_eq!(val["max_depth"], 2);
6259 assert!(val["memories"].is_array());
6260 }
6261
6262 #[test]
6263 fn handle_kg_query_invalid_valid_at() {
6264 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6265 let req = make_tools_call(
6266 "memory_kg_query",
6267 json!({
6268 "source_id": "00000000-0000-0000-0000-000000000000",
6269 "valid_at": "garbage",
6270 }),
6271 );
6272 let resp = invoke_handle_request(&conn, &req);
6273 let result = resp.result.unwrap();
6274 assert_eq!(result["isError"], true);
6275 }
6276
6277 #[test]
6278 fn handle_kg_query_rejects_invalid_agent_id() {
6279 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6280 let req = make_tools_call(
6281 "memory_kg_query",
6282 json!({
6283 "source_id": "00000000-0000-0000-0000-000000000000",
6284 "allowed_agents": ["bad agent with spaces!!"],
6285 }),
6286 );
6287 let resp = invoke_handle_request(&conn, &req);
6288 let result = resp.result.unwrap();
6289 assert_eq!(result["isError"], true);
6290 }
6291
6292 #[test]
6293 fn handle_session_start_happy_returns_memories() {
6294 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6295 let mem = Memory {
6297 id: uuid::Uuid::new_v4().to_string(),
6298 tier: Tier::Long,
6299 namespace: "w12-session".into(),
6300 title: "seed".into(),
6301 content: "c".into(),
6302 tags: vec![],
6303 priority: 5,
6304 confidence: 1.0,
6305 source: "test".into(),
6306 access_count: 0,
6307 created_at: chrono::Utc::now().to_rfc3339(),
6308 updated_at: chrono::Utc::now().to_rfc3339(),
6309 last_accessed_at: None,
6310 expires_at: None,
6311 metadata: json!({}),
6312 };
6313 db::insert(&conn, &mem).unwrap();
6314 let req = make_tools_call(
6315 "memory_session_start",
6316 json!({"namespace": "w12-session", "limit": 5, "format": "json"}),
6317 );
6318 let resp = invoke_handle_request(&conn, &req);
6319 assert!(resp.error.is_none());
6320 let text = resp.result.unwrap()["content"][0]["text"]
6321 .as_str()
6322 .unwrap()
6323 .to_string();
6324 let val: Value = serde_json::from_str(&text).unwrap();
6325 assert_eq!(val["mode"], "session_start");
6326 assert!(val["memories"].is_array());
6327 }
6328
6329 #[test]
6330 fn handle_session_start_empty_namespace_returns_zero() {
6331 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6332 let req = make_tools_call(
6333 "memory_session_start",
6334 json!({"namespace": "w12-empty-ns", "format": "json"}),
6335 );
6336 let resp = invoke_handle_request(&conn, &req);
6337 assert!(resp.error.is_none());
6338 let text = resp.result.unwrap()["content"][0]["text"]
6339 .as_str()
6340 .unwrap()
6341 .to_string();
6342 let val: Value = serde_json::from_str(&text).unwrap();
6343 assert_eq!(val["count"], 0);
6344 }
6345
6346 #[test]
6347 fn handle_inbox_returns_empty_for_unregistered_caller() {
6348 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6349 let req = make_tools_call("memory_inbox", json!({"agent_id": "test-bot"}));
6350 let resp = invoke_handle_request(&conn, &req);
6351 assert!(resp.error.is_none());
6352 let text = resp.result.unwrap()["content"][0]["text"]
6353 .as_str()
6354 .unwrap()
6355 .to_string();
6356 let val: Value = serde_json::from_str(&text).unwrap();
6357 assert_eq!(val["agent_id"], "test-bot");
6358 assert!(val["namespace"].as_str().unwrap().starts_with("_messages/"));
6359 assert_eq!(val["count"], 0);
6360 }
6361
6362 #[test]
6363 fn handle_inbox_with_unread_only_filter() {
6364 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6365 let req = make_tools_call(
6366 "memory_inbox",
6367 json!({"agent_id": "test-bot", "unread_only": true, "limit": 10}),
6368 );
6369 let resp = invoke_handle_request(&conn, &req);
6370 assert!(resp.error.is_none());
6371 let text = resp.result.unwrap()["content"][0]["text"]
6372 .as_str()
6373 .unwrap()
6374 .to_string();
6375 let val: Value = serde_json::from_str(&text).unwrap();
6376 assert_eq!(val["unread_only"], true);
6377 }
6378
6379 #[test]
6380 fn handle_notify_happy_returns_message_id() {
6381 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6382 let req = make_tools_call(
6383 "memory_notify",
6384 json!({
6385 "target_agent_id": "alice",
6386 "title": "hello",
6387 "payload": "world",
6388 "tier": "mid",
6389 "priority": 5,
6390 }),
6391 );
6392 let resp = invoke_handle_request(&conn, &req);
6393 assert!(resp.error.is_none());
6394 let text = resp.result.unwrap()["content"][0]["text"]
6395 .as_str()
6396 .unwrap()
6397 .to_string();
6398 let val: Value = serde_json::from_str(&text).unwrap();
6399 assert!(val["id"].is_string());
6400 assert_eq!(val["to"], "alice");
6401 assert_eq!(val["namespace"], "_messages/alice");
6402 }
6403
6404 #[test]
6405 fn handle_notify_invalid_tier_returns_error() {
6406 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6407 let req = make_tools_call(
6408 "memory_notify",
6409 json!({
6410 "target_agent_id": "bob",
6411 "title": "hi",
6412 "payload": "p",
6413 "tier": "bogus-tier",
6414 }),
6415 );
6416 let resp = invoke_handle_request(&conn, &req);
6417 let result = resp.result.unwrap();
6418 assert_eq!(result["isError"], true);
6419 let msg = result["content"][0]["text"].as_str().unwrap();
6420 assert!(msg.contains("invalid tier"));
6421 }
6422
6423 #[test]
6424 fn handle_agent_register_then_list() {
6425 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6426 let req = make_tools_call(
6428 "memory_agent_register",
6429 json!({
6430 "agent_id": "w12-bot",
6431 "agent_type": "ai:w12-bot",
6432 "capabilities": ["read", "write"],
6433 }),
6434 );
6435 let resp = invoke_handle_request(&conn, &req);
6436 assert!(resp.error.is_none());
6437 let text = resp.result.unwrap()["content"][0]["text"]
6438 .as_str()
6439 .unwrap()
6440 .to_string();
6441 let val: Value = serde_json::from_str(&text).unwrap();
6442 assert_eq!(val["registered"], true);
6443 let req2 = make_tools_call("memory_agent_list", json!({}));
6445 let resp2 = invoke_handle_request(&conn, &req2);
6446 assert!(resp2.error.is_none());
6447 let text2 = resp2.result.unwrap()["content"][0]["text"]
6448 .as_str()
6449 .unwrap()
6450 .to_string();
6451 let val2: Value = serde_json::from_str(&text2).unwrap();
6452 assert!(val2["count"].as_u64().unwrap() >= 1);
6453 }
6454
6455 #[test]
6456 fn handle_agent_register_invalid_type_rejects() {
6457 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6458 let req = make_tools_call(
6459 "memory_agent_register",
6460 json!({"agent_id": "w12-bot2", "agent_type": " not-allowed-type with spaces "}),
6461 );
6462 let resp = invoke_handle_request(&conn, &req);
6463 let result = resp.result.unwrap();
6464 assert_eq!(result["isError"], true);
6465 }
6466
6467 #[test]
6468 fn handle_namespace_set_get_clear_round_trip() {
6469 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6470 let mem = Memory {
6472 id: uuid::Uuid::new_v4().to_string(),
6473 tier: Tier::Long,
6474 namespace: "w12-ns".into(),
6475 title: "policy".into(),
6476 content: "be excellent".into(),
6477 tags: vec![],
6478 priority: 5,
6479 confidence: 1.0,
6480 source: "test".into(),
6481 access_count: 0,
6482 created_at: chrono::Utc::now().to_rfc3339(),
6483 updated_at: chrono::Utc::now().to_rfc3339(),
6484 last_accessed_at: None,
6485 expires_at: None,
6486 metadata: json!({}),
6487 };
6488 let std_id = db::insert(&conn, &mem).unwrap();
6489
6490 let set_req = make_tools_call(
6492 "memory_namespace_set_standard",
6493 json!({"namespace": "w12-ns", "id": std_id.clone()}),
6494 );
6495 let set_resp = invoke_handle_request(&conn, &set_req);
6496 assert!(set_resp.error.is_none());
6497 let set_text = set_resp.result.unwrap()["content"][0]["text"]
6498 .as_str()
6499 .unwrap()
6500 .to_string();
6501 let set_val: Value = serde_json::from_str(&set_text).unwrap();
6502 assert_eq!(set_val["set"], true);
6503
6504 let get_req = make_tools_call(
6506 "memory_namespace_get_standard",
6507 json!({"namespace": "w12-ns"}),
6508 );
6509 let get_resp = invoke_handle_request(&conn, &get_req);
6510 assert!(get_resp.error.is_none());
6511 let get_text = get_resp.result.unwrap()["content"][0]["text"]
6512 .as_str()
6513 .unwrap()
6514 .to_string();
6515 let get_val: Value = serde_json::from_str(&get_text).unwrap();
6516 assert_eq!(get_val["standard_id"], std_id);
6517
6518 let clr_req = make_tools_call(
6520 "memory_namespace_clear_standard",
6521 json!({"namespace": "w12-ns"}),
6522 );
6523 let clr_resp = invoke_handle_request(&conn, &clr_req);
6524 assert!(clr_resp.error.is_none());
6525 let clr_text = clr_resp.result.unwrap()["content"][0]["text"]
6526 .as_str()
6527 .unwrap()
6528 .to_string();
6529 let clr_val: Value = serde_json::from_str(&clr_text).unwrap();
6530 assert_eq!(clr_val["cleared"], true);
6531 }
6532
6533 #[test]
6534 fn handle_namespace_get_standard_missing_returns_null() {
6535 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6536 let req = make_tools_call(
6537 "memory_namespace_get_standard",
6538 json!({"namespace": "w12-no-standard-here"}),
6539 );
6540 let resp = invoke_handle_request(&conn, &req);
6541 assert!(resp.error.is_none());
6542 let text = resp.result.unwrap()["content"][0]["text"]
6543 .as_str()
6544 .unwrap()
6545 .to_string();
6546 let val: Value = serde_json::from_str(&text).unwrap();
6547 assert!(val["standard_id"].is_null());
6548 }
6549
6550 #[test]
6551 fn handle_namespace_get_standard_inherit_returns_chain() {
6552 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6555 seed_namespace_standard(&conn, "*", "global rule");
6556 seed_namespace_standard(&conn, "w12-inh", "specific rule");
6557 let req = make_tools_call(
6558 "memory_namespace_get_standard",
6559 json!({"namespace": "w12-inh", "inherit": true}),
6560 );
6561 let resp = invoke_handle_request(&conn, &req);
6562 assert!(resp.error.is_none());
6563 let text = resp.result.unwrap()["content"][0]["text"]
6564 .as_str()
6565 .unwrap()
6566 .to_string();
6567 let val: Value = serde_json::from_str(&text).unwrap();
6568 assert!(val["chain"].is_array());
6569 assert!(val["standards"].is_array());
6570 assert!(val["count"].as_u64().unwrap() >= 1);
6571 }
6572
6573 #[test]
6574 fn handle_namespace_set_standard_with_invalid_governance_rejected() {
6575 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6576 let mem = Memory {
6577 id: uuid::Uuid::new_v4().to_string(),
6578 tier: Tier::Long,
6579 namespace: "w12-gov".into(),
6580 title: "p".into(),
6581 content: "c".into(),
6582 tags: vec![],
6583 priority: 5,
6584 confidence: 1.0,
6585 source: "test".into(),
6586 access_count: 0,
6587 created_at: chrono::Utc::now().to_rfc3339(),
6588 updated_at: chrono::Utc::now().to_rfc3339(),
6589 last_accessed_at: None,
6590 expires_at: None,
6591 metadata: json!({}),
6592 };
6593 let id = db::insert(&conn, &mem).unwrap();
6594 let req = make_tools_call(
6595 "memory_namespace_set_standard",
6596 json!({
6597 "namespace": "w12-gov",
6598 "id": id,
6599 "governance": {"this": "is not a valid policy"},
6600 }),
6601 );
6602 let resp = invoke_handle_request(&conn, &req);
6603 let result = resp.result.unwrap();
6604 assert_eq!(result["isError"], true);
6605 let msg = result["content"][0]["text"].as_str().unwrap();
6606 assert!(msg.contains("invalid governance") || msg.contains("governance"));
6607 }
6608
6609 #[test]
6610 fn handle_namespace_set_standard_invalid_namespace_rejected() {
6611 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6612 let req = make_tools_call(
6613 "memory_namespace_set_standard",
6614 json!({"namespace": "bad ns with spaces!!", "id": "any"}),
6615 );
6616 let resp = invoke_handle_request(&conn, &req);
6617 let result = resp.result.unwrap();
6618 assert_eq!(result["isError"], true);
6619 }
6620
6621 #[test]
6622 fn handle_pending_list_happy_returns_array() {
6623 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6624 let req = make_tools_call(
6625 "memory_pending_list",
6626 json!({"status": "pending", "limit": 100}),
6627 );
6628 let resp = invoke_handle_request(&conn, &req);
6629 assert!(resp.error.is_none());
6630 let text = resp.result.unwrap()["content"][0]["text"]
6631 .as_str()
6632 .unwrap()
6633 .to_string();
6634 let val: Value = serde_json::from_str(&text).unwrap();
6635 assert!(val["pending"].is_array());
6636 assert!(val["count"].is_u64());
6637 }
6638
6639 #[test]
6640 fn handle_pending_approve_unknown_id_returns_error() {
6641 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6642 let req = make_tools_call(
6643 "memory_pending_approve",
6644 json!({"id": "00000000-0000-0000-0000-000000000000", "agent_id": "human:approver"}),
6645 );
6646 let resp = invoke_handle_request(&conn, &req);
6647 let result = resp.result.unwrap();
6648 assert!(result.is_object());
6651 }
6652
6653 #[test]
6654 fn handle_pending_reject_unknown_id_returns_not_found() {
6655 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6656 let req = make_tools_call(
6657 "memory_pending_reject",
6658 json!({"id": "00000000-0000-0000-0000-000000000000", "agent_id": "human:rejector"}),
6659 );
6660 let resp = invoke_handle_request(&conn, &req);
6661 let result = resp.result.unwrap();
6662 assert_eq!(result["isError"], true);
6663 let msg = result["content"][0]["text"].as_str().unwrap();
6664 assert!(msg.contains("not found") || msg.contains("already decided"));
6665 }
6666
6667 #[test]
6668 fn handle_gc_dry_run_returns_count_without_deleting() {
6669 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6670 let req = make_tools_call("memory_gc", json!({"dry_run": true}));
6671 let resp = invoke_handle_request(&conn, &req);
6672 assert!(resp.error.is_none());
6673 let text = resp.result.unwrap()["content"][0]["text"]
6674 .as_str()
6675 .unwrap()
6676 .to_string();
6677 let val: Value = serde_json::from_str(&text).unwrap();
6678 assert_eq!(val["dry_run"], true);
6679 assert!(val["collected"].is_u64() || val["collected"].is_i64());
6680 }
6681
6682 #[test]
6683 fn handle_gc_actual_run_returns_zero_on_empty_db() {
6684 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6685 let req = make_tools_call("memory_gc", json!({}));
6686 let resp = invoke_handle_request(&conn, &req);
6687 assert!(resp.error.is_none());
6688 let text = resp.result.unwrap()["content"][0]["text"]
6689 .as_str()
6690 .unwrap()
6691 .to_string();
6692 let val: Value = serde_json::from_str(&text).unwrap();
6693 assert_eq!(val["dry_run"], false);
6694 }
6695
6696 #[test]
6697 fn handle_forget_dry_run_with_filters() {
6698 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6699 let req = make_tools_call(
6700 "memory_forget",
6701 json!({"namespace": "w12-forget", "tier": "short", "dry_run": true}),
6702 );
6703 let resp = invoke_handle_request(&conn, &req);
6704 assert!(resp.error.is_none());
6705 let text = resp.result.unwrap()["content"][0]["text"]
6706 .as_str()
6707 .unwrap()
6708 .to_string();
6709 let val: Value = serde_json::from_str(&text).unwrap();
6710 assert_eq!(val["dry_run"], true);
6711 }
6712
6713 #[test]
6714 fn handle_forget_actual_with_namespace() {
6715 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6716 let req = make_tools_call(
6717 "memory_forget",
6718 json!({"namespace": "w12-forget-actual", "dry_run": false}),
6719 );
6720 let resp = invoke_handle_request(&conn, &req);
6721 assert!(resp.error.is_none());
6722 }
6723
6724 #[test]
6725 fn handle_unsubscribe_unknown_returns_false() {
6726 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6729 let req = make_tools_call(
6730 "memory_unsubscribe",
6731 json!({"id": "00000000-0000-0000-0000-000000000000"}),
6732 );
6733 let resp = invoke_handle_request(&conn, &req);
6734 assert!(resp.error.is_none());
6735 let text = resp.result.unwrap()["content"][0]["text"]
6736 .as_str()
6737 .unwrap()
6738 .to_string();
6739 let val: Value = serde_json::from_str(&text).unwrap();
6740 assert!(
6742 val["removed"] == json!(false) || val["removed"] == json!(0),
6743 "unexpected removed value: {:?}",
6744 val["removed"]
6745 );
6746 }
6747
6748 #[test]
6749 fn handle_list_subscriptions_returns_array() {
6750 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6751 let req = make_tools_call("memory_list_subscriptions", json!({}));
6752 let resp = invoke_handle_request(&conn, &req);
6753 assert!(resp.error.is_none());
6754 }
6755
6756 #[test]
6757 fn handle_entity_register_happy() {
6758 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6759 let req = make_tools_call(
6760 "memory_entity_register",
6761 json!({
6762 "canonical_name": "Hugo Boss",
6763 "namespace": "w12-people",
6764 "aliases": ["HB", "Hugo"],
6765 }),
6766 );
6767 let resp = invoke_handle_request(&conn, &req);
6768 assert!(resp.error.is_none());
6769 let text = resp.result.unwrap()["content"][0]["text"]
6770 .as_str()
6771 .unwrap()
6772 .to_string();
6773 let val: Value = serde_json::from_str(&text).unwrap();
6774 assert!(val["entity_id"].is_string());
6775 assert_eq!(val["canonical_name"], "Hugo Boss");
6776 }
6777
6778 #[test]
6779 fn handle_entity_register_invalid_namespace() {
6780 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6781 let req = make_tools_call(
6782 "memory_entity_register",
6783 json!({"canonical_name": "X", "namespace": "INVALID NS!"}),
6784 );
6785 let resp = invoke_handle_request(&conn, &req);
6786 let result = resp.result.unwrap();
6787 assert_eq!(result["isError"], true);
6788 }
6789
6790 #[test]
6791 fn handle_entity_get_by_alias_not_found_returns_null() {
6792 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6793 let req = make_tools_call(
6794 "memory_entity_get_by_alias",
6795 json!({"alias": "no-such-alias", "namespace": "w12-people"}),
6796 );
6797 let resp = invoke_handle_request(&conn, &req);
6798 assert!(resp.error.is_none());
6799 let text = resp.result.unwrap()["content"][0]["text"]
6800 .as_str()
6801 .unwrap()
6802 .to_string();
6803 let val: Value = serde_json::from_str(&text).unwrap();
6804 assert_eq!(val["found"], false);
6805 }
6806
6807 #[test]
6808 fn handle_get_taxonomy_with_prefix_and_depth() {
6809 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6810 let req = make_tools_call(
6811 "memory_get_taxonomy",
6812 json!({"namespace_prefix": "w12-tax", "depth": 4, "limit": 100}),
6813 );
6814 let resp = invoke_handle_request(&conn, &req);
6815 assert!(resp.error.is_none());
6816 let text = resp.result.unwrap()["content"][0]["text"]
6817 .as_str()
6818 .unwrap()
6819 .to_string();
6820 let val: Value = serde_json::from_str(&text).unwrap();
6821 assert!(val["tree"].is_object() || val["tree"].is_array());
6822 }
6823
6824 #[test]
6825 fn handle_get_taxonomy_strips_trailing_slash() {
6826 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6827 let req = make_tools_call(
6828 "memory_get_taxonomy",
6829 json!({"namespace_prefix": "w12-tax/", "depth": 2}),
6830 );
6831 let resp = invoke_handle_request(&conn, &req);
6832 assert!(resp.error.is_none());
6834 }
6835
6836 #[test]
6837 fn handle_get_taxonomy_invalid_prefix_after_strip() {
6838 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6839 let req = make_tools_call(
6840 "memory_get_taxonomy",
6841 json!({"namespace_prefix": "BAD NS!"}),
6842 );
6843 let resp = invoke_handle_request(&conn, &req);
6844 let result = resp.result.unwrap();
6845 assert_eq!(result["isError"], true);
6846 }
6847
6848 #[test]
6849 fn handle_check_duplicate_no_embedder_errors() {
6850 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6853 let req = make_tools_call(
6854 "memory_check_duplicate",
6855 json!({"title": "T", "content": "C"}),
6856 );
6857 let resp = invoke_handle_request(&conn, &req);
6858 let result = resp.result.unwrap();
6859 assert_eq!(result["isError"], true);
6860 let msg = result["content"][0]["text"].as_str().unwrap();
6861 assert!(msg.contains("embedder") || msg.contains("semantic"));
6862 }
6863
6864 #[test]
6865 fn handle_expand_query_no_llm_errors() {
6866 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6867 let req = make_tools_call("memory_expand_query", json!({"query": "test"}));
6868 let resp = invoke_handle_request(&conn, &req);
6869 let result = resp.result.unwrap();
6870 assert_eq!(result["isError"], true);
6871 let msg = result["content"][0]["text"].as_str().unwrap();
6872 assert!(msg.contains("smart") || msg.contains("LLM") || msg.contains("Ollama"));
6873 }
6874
6875 #[test]
6876 fn handle_auto_tag_no_llm_errors() {
6877 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6878 let req = make_tools_call(
6879 "memory_auto_tag",
6880 json!({"id": "00000000-0000-0000-0000-000000000000"}),
6881 );
6882 let resp = invoke_handle_request(&conn, &req);
6883 let result = resp.result.unwrap();
6884 assert_eq!(result["isError"], true);
6885 }
6886
6887 #[test]
6888 fn handle_detect_contradiction_no_llm_errors() {
6889 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6890 let req = make_tools_call(
6891 "memory_detect_contradiction",
6892 json!({"id_a": "00000000-0000-0000-0000-000000000000", "id_b": "11111111-1111-1111-1111-111111111111"}),
6893 );
6894 let resp = invoke_handle_request(&conn, &req);
6895 let result = resp.result.unwrap();
6896 assert_eq!(result["isError"], true);
6897 }
6898
6899 #[test]
6900 fn handle_update_unknown_id_returns_not_found() {
6901 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6902 let req = make_tools_call(
6903 "memory_update",
6904 json!({
6905 "id": "00000000-0000-0000-0000-000000000000",
6906 "title": "new title",
6907 }),
6908 );
6909 let resp = invoke_handle_request(&conn, &req);
6910 let result = resp.result.unwrap();
6911 assert_eq!(result["isError"], true);
6912 let msg = result["content"][0]["text"].as_str().unwrap();
6913 assert!(msg.contains("not found"));
6914 }
6915
6916 #[test]
6917 fn handle_update_invalid_priority_rejected() {
6918 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6919 let mem = Memory {
6921 id: uuid::Uuid::new_v4().to_string(),
6922 tier: Tier::Long,
6923 namespace: "w12-update".into(),
6924 title: "t".into(),
6925 content: "c".into(),
6926 tags: vec![],
6927 priority: 5,
6928 confidence: 1.0,
6929 source: "test".into(),
6930 access_count: 0,
6931 created_at: chrono::Utc::now().to_rfc3339(),
6932 updated_at: chrono::Utc::now().to_rfc3339(),
6933 last_accessed_at: None,
6934 expires_at: None,
6935 metadata: json!({}),
6936 };
6937 let id = db::insert(&conn, &mem).unwrap();
6938 let req = make_tools_call(
6939 "memory_update",
6940 json!({"id": id, "priority": 99_i64}), );
6942 let resp = invoke_handle_request(&conn, &req);
6943 let result = resp.result.unwrap();
6944 assert_eq!(result["isError"], true);
6945 }
6946
6947 #[test]
6948 fn handle_update_with_metadata_object_accepted() {
6949 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6952 let mem = Memory {
6953 id: uuid::Uuid::new_v4().to_string(),
6954 tier: Tier::Mid,
6955 namespace: "w12-meta".into(),
6956 title: "t".into(),
6957 content: "c".into(),
6958 tags: vec![],
6959 priority: 5,
6960 confidence: 1.0,
6961 source: "test".into(),
6962 access_count: 0,
6963 created_at: chrono::Utc::now().to_rfc3339(),
6964 updated_at: chrono::Utc::now().to_rfc3339(),
6965 last_accessed_at: None,
6966 expires_at: None,
6967 metadata: json!({}),
6968 };
6969 let id = db::insert(&conn, &mem).unwrap();
6970 let req = make_tools_call(
6971 "memory_update",
6972 json!({
6973 "id": id,
6974 "metadata": {"custom": "field", "numbers": [1, 2, 3]},
6975 }),
6976 );
6977 let resp = invoke_handle_request(&conn, &req);
6978 assert!(resp.error.is_none());
6979 }
6980
6981 #[test]
6982 fn handle_get_links_unknown_id_returns_empty() {
6983 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6984 let req = make_tools_call(
6985 "memory_get_links",
6986 json!({"id": "00000000-0000-0000-0000-000000000000"}),
6987 );
6988 let resp = invoke_handle_request(&conn, &req);
6989 assert!(resp.error.is_none());
6990 let text = resp.result.unwrap()["content"][0]["text"]
6991 .as_str()
6992 .unwrap()
6993 .to_string();
6994 let val: Value = serde_json::from_str(&text).unwrap();
6995 assert!(val["links"].is_array());
6996 assert_eq!(val["count"], 0);
6997 }
6998
6999 #[test]
7000 fn handle_link_invalid_relation_rejected() {
7001 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7002 let req = make_tools_call(
7003 "memory_link",
7004 json!({
7005 "source_id": "00000000-0000-0000-0000-000000000000",
7006 "target_id": "11111111-1111-1111-1111-111111111111",
7007 "relation": "BADRELATIONNOTALLOWED",
7008 }),
7009 );
7010 let resp = invoke_handle_request(&conn, &req);
7011 let result = resp.result.unwrap();
7012 assert_eq!(result["isError"], true);
7013 }
7014
7015 #[test]
7016 fn handle_promote_to_namespace_with_explicit_target() {
7017 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7023 let mem = Memory {
7024 id: uuid::Uuid::new_v4().to_string(),
7025 tier: Tier::Mid,
7026 namespace: "w12-parent/w12-child".into(),
7027 title: "t".into(),
7028 content: "c".into(),
7029 tags: vec![],
7030 priority: 5,
7031 confidence: 1.0,
7032 source: "test".into(),
7033 access_count: 0,
7034 created_at: chrono::Utc::now().to_rfc3339(),
7035 updated_at: chrono::Utc::now().to_rfc3339(),
7036 last_accessed_at: None,
7037 expires_at: None,
7038 metadata: json!({}),
7039 };
7040 let id = db::insert(&conn, &mem).unwrap();
7041 let req = make_tools_call(
7042 "memory_promote",
7043 json!({"id": id, "to_namespace": "w12-parent"}),
7044 );
7045 let resp = invoke_handle_request(&conn, &req);
7046 assert!(resp.error.is_none());
7047 let text = resp.result.unwrap()["content"][0]["text"]
7048 .as_str()
7049 .unwrap()
7050 .to_string();
7051 let val: Value = serde_json::from_str(&text).unwrap();
7052 assert_eq!(val["mode"], "vertical");
7053 assert!(val["clone_id"].is_string());
7054 }
7055
7056 #[test]
7057 fn handle_promote_invalid_to_namespace_rejected() {
7058 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7059 let mem = Memory {
7060 id: uuid::Uuid::new_v4().to_string(),
7061 tier: Tier::Mid,
7062 namespace: "w12-pm".into(),
7063 title: "t".into(),
7064 content: "c".into(),
7065 tags: vec![],
7066 priority: 5,
7067 confidence: 1.0,
7068 source: "test".into(),
7069 access_count: 0,
7070 created_at: chrono::Utc::now().to_rfc3339(),
7071 updated_at: chrono::Utc::now().to_rfc3339(),
7072 last_accessed_at: None,
7073 expires_at: None,
7074 metadata: json!({}),
7075 };
7076 let id = db::insert(&conn, &mem).unwrap();
7077 let req = make_tools_call(
7078 "memory_promote",
7079 json!({"id": id, "to_namespace": "BAD NS WITH SPACES"}),
7080 );
7081 let resp = invoke_handle_request(&conn, &req);
7082 let result = resp.result.unwrap();
7083 assert_eq!(result["isError"], true);
7084 }
7085
7086 #[test]
7087 fn handle_consolidate_with_explicit_summary_no_llm() {
7088 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7090 let mem_a = Memory {
7091 id: uuid::Uuid::new_v4().to_string(),
7092 tier: Tier::Mid,
7093 namespace: "w12-cons".into(),
7094 title: "a".into(),
7095 content: "alpha".into(),
7096 tags: vec![],
7097 priority: 5,
7098 confidence: 1.0,
7099 source: "test".into(),
7100 access_count: 0,
7101 created_at: chrono::Utc::now().to_rfc3339(),
7102 updated_at: chrono::Utc::now().to_rfc3339(),
7103 last_accessed_at: None,
7104 expires_at: None,
7105 metadata: json!({}),
7106 };
7107 let mut mem_b = mem_a.clone();
7108 mem_b.id = uuid::Uuid::new_v4().to_string();
7109 mem_b.title = "b".into();
7110 mem_b.content = "beta".into();
7111 let id_a = db::insert(&conn, &mem_a).unwrap();
7112 let id_b = db::insert(&conn, &mem_b).unwrap();
7113
7114 let req = make_tools_call(
7115 "memory_consolidate",
7116 json!({
7117 "ids": [id_a, id_b],
7118 "title": "merged",
7119 "summary": "merged summary",
7120 "namespace": "w12-cons",
7121 }),
7122 );
7123 let resp = invoke_handle_request(&conn, &req);
7124 assert!(resp.error.is_none());
7125 let text = resp.result.unwrap()["content"][0]["text"]
7126 .as_str()
7127 .unwrap()
7128 .to_string();
7129 let val: Value = serde_json::from_str(&text).unwrap();
7130 assert!(val["id"].is_string());
7131 assert_eq!(val["consolidated"], 2);
7132 }
7133
7134 #[test]
7135 fn handle_consolidate_non_string_id_rejected() {
7136 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7137 let req = make_tools_call(
7138 "memory_consolidate",
7139 json!({"ids": [42, "valid-id"], "title": "t", "summary": "s"}),
7140 );
7141 let resp = invoke_handle_request(&conn, &req);
7142 let result = resp.result.unwrap();
7143 assert_eq!(result["isError"], true);
7144 let msg = result["content"][0]["text"].as_str().unwrap();
7145 assert!(msg.contains("must be a string"));
7146 }
7147
7148 #[test]
7153 fn test_jsonrpc_handles_ping() {
7154 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7155 let req = RpcRequest {
7156 jsonrpc: "2.0".into(),
7157 id: Some(json!(1)),
7158 method: "ping".into(),
7159 params: json!({}),
7160 };
7161 let resp = invoke_handle_request(&conn, &req);
7162 assert!(resp.error.is_none());
7163 }
7164
7165 #[test]
7166 fn test_jsonrpc_handles_notifications_initialized() {
7167 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7170 let req = RpcRequest {
7171 jsonrpc: "2.0".into(),
7172 id: Some(json!(2)),
7173 method: "notifications/initialized".into(),
7174 params: json!({}),
7175 };
7176 let resp = invoke_handle_request(&conn, &req);
7177 assert!(resp.error.is_none());
7178 }
7179
7180 #[test]
7181 fn test_jsonrpc_prompts_list_returns_array() {
7182 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7183 let req = RpcRequest {
7184 jsonrpc: "2.0".into(),
7185 id: Some(json!(3)),
7186 method: "prompts/list".into(),
7187 params: json!({}),
7188 };
7189 let resp = invoke_handle_request(&conn, &req);
7190 assert!(resp.error.is_none());
7191 let result = resp.result.unwrap();
7192 assert!(result["prompts"].is_array());
7193 }
7194
7195 #[test]
7196 fn test_jsonrpc_prompts_get_known_name_returns_messages() {
7197 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7198 let req = RpcRequest {
7199 jsonrpc: "2.0".into(),
7200 id: Some(json!(4)),
7201 method: "prompts/get".into(),
7202 params: json!({"name": "recall-first"}),
7203 };
7204 let resp = invoke_handle_request(&conn, &req);
7205 assert!(resp.error.is_none());
7206 let result = resp.result.unwrap();
7207 assert!(result["messages"].is_array());
7208 }
7209
7210 #[test]
7211 fn test_jsonrpc_prompts_get_with_namespace_arg_includes_hint() {
7212 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7213 let req = RpcRequest {
7214 jsonrpc: "2.0".into(),
7215 id: Some(json!(5)),
7216 method: "prompts/get".into(),
7217 params: json!({"name": "recall-first", "arguments": {"namespace": "w12-test"}}),
7218 };
7219 let resp = invoke_handle_request(&conn, &req);
7220 assert!(resp.error.is_none());
7221 let result = resp.result.unwrap();
7222 let text = result["messages"][0]["content"]["text"].as_str().unwrap();
7223 assert!(text.contains("w12-test"));
7224 }
7225
7226 #[test]
7227 fn test_jsonrpc_prompts_get_unknown_name_returns_error() {
7228 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7229 let req = RpcRequest {
7230 jsonrpc: "2.0".into(),
7231 id: Some(json!(6)),
7232 method: "prompts/get".into(),
7233 params: json!({"name": "no-such-prompt"}),
7234 };
7235 let resp = invoke_handle_request(&conn, &req);
7236 let err = resp.error.unwrap();
7237 assert_eq!(err.code, -32602);
7238 }
7239
7240 #[test]
7241 fn test_jsonrpc_prompts_get_missing_name_returns_error() {
7242 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7243 let req = RpcRequest {
7244 jsonrpc: "2.0".into(),
7245 id: Some(json!(7)),
7246 method: "prompts/get".into(),
7247 params: json!({}),
7248 };
7249 let resp = invoke_handle_request(&conn, &req);
7250 let err = resp.error.unwrap();
7251 assert_eq!(err.code, -32602);
7252 }
7253
7254 #[test]
7255 fn test_jsonrpc_prompts_get_memory_workflow() {
7256 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7257 let req = RpcRequest {
7258 jsonrpc: "2.0".into(),
7259 id: Some(json!(8)),
7260 method: "prompts/get".into(),
7261 params: json!({"name": "memory-workflow"}),
7262 };
7263 let resp = invoke_handle_request(&conn, &req);
7264 assert!(resp.error.is_none());
7265 let result = resp.result.unwrap();
7266 assert!(result["messages"].is_array());
7267 }
7268
7269 #[test]
7270 fn test_jsonrpc_tools_call_empty_tool_name_rejected() {
7271 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7272 let req = RpcRequest {
7273 jsonrpc: "2.0".into(),
7274 id: Some(json!(9)),
7275 method: "tools/call".into(),
7276 params: json!({"name": ""}),
7277 };
7278 let resp = invoke_handle_request(&conn, &req);
7279 let err = resp.error.unwrap();
7280 assert_eq!(err.code, -32602);
7281 }
7282
7283 #[test]
7284 fn test_jsonrpc_tools_call_arguments_not_object_uses_empty() {
7285 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7290 let req = RpcRequest {
7291 jsonrpc: "2.0".into(),
7292 id: Some(json!(10)),
7293 method: "tools/call".into(),
7294 params: json!({"name": "memory_capabilities", "arguments": null}),
7295 };
7296 let resp = invoke_handle_request(&conn, &req);
7297 assert!(resp.error.is_none());
7299 }
7300
7301 #[test]
7302 fn test_jsonrpc_tools_call_unicode_in_args() {
7303 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7306 let req = make_tools_call(
7307 "memory_store",
7308 json!({"title": "тест", "content": "日本語 ✨", "namespace": "w12-unicode"}),
7309 );
7310 let resp = invoke_handle_request(&conn, &req);
7311 assert!(resp.error.is_none());
7312 }
7313
7314 #[test]
7315 fn test_jsonrpc_dispatch_line_with_id_zero_treated_as_request() {
7316 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7319 let line = r#"{"jsonrpc":"2.0","id":0,"method":"tools/list"}"#;
7320 let resp = dispatch_line(&conn, line);
7321 assert!(resp.is_some());
7322 }
7323
7324 #[test]
7325 fn test_jsonrpc_dispatch_line_string_id_passes_through() {
7326 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7327 let line = r#"{"jsonrpc":"2.0","id":"call-abc","method":"tools/list"}"#;
7328 let resp = dispatch_line(&conn, line).expect("expected response");
7329 assert_eq!(resp.id, json!("call-abc"));
7330 }
7331
7332 #[test]
7337 fn test_build_namespace_chain_global_only() {
7338 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7339 let chain = super::build_namespace_chain(&conn, "*");
7340 assert_eq!(chain, vec!["*".to_string()]);
7341 }
7342
7343 #[test]
7344 fn test_build_namespace_chain_simple_namespace() {
7345 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7347 let chain = super::build_namespace_chain(&conn, "w12-flat");
7348 assert!(chain.contains(&"*".to_string()));
7349 assert!(chain.contains(&"w12-flat".to_string()));
7350 }
7351
7352 #[test]
7353 fn test_build_namespace_chain_nested_yields_ancestors() {
7354 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7355 let chain = super::build_namespace_chain(&conn, "a/b/c");
7356 assert_eq!(chain.first().unwrap(), "*");
7358 assert!(chain.contains(&"a/b/c".to_string()));
7359 let pos_a = chain.iter().position(|s| s == "a").unwrap();
7361 let pos_ab = chain.iter().position(|s| s == "a/b").unwrap();
7362 let pos_abc = chain.iter().position(|s| s == "a/b/c").unwrap();
7363 assert!(pos_a < pos_ab && pos_ab < pos_abc);
7364 }
7365
7366 #[test]
7367 fn test_build_namespace_chain_with_explicit_parent() {
7368 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7371 let parent_mem = Memory {
7374 id: uuid::Uuid::new_v4().to_string(),
7375 tier: Tier::Long,
7376 namespace: "w12-explicit-grand".into(),
7377 title: "g".into(),
7378 content: "c".into(),
7379 tags: vec![],
7380 priority: 5,
7381 confidence: 1.0,
7382 source: "test".into(),
7383 access_count: 0,
7384 created_at: chrono::Utc::now().to_rfc3339(),
7385 updated_at: chrono::Utc::now().to_rfc3339(),
7386 last_accessed_at: None,
7387 expires_at: None,
7388 metadata: json!({}),
7389 };
7390 let pid = db::insert(&conn, &parent_mem).unwrap();
7391 db::set_namespace_standard(&conn, "w12-explicit-grand", &pid, None).unwrap();
7392
7393 let mut child_mem = parent_mem.clone();
7394 child_mem.id = uuid::Uuid::new_v4().to_string();
7395 child_mem.namespace = "w12-explicit-leaf".into();
7396 let cid = db::insert(&conn, &child_mem).unwrap();
7397 db::set_namespace_standard(&conn, "w12-explicit-leaf", &cid, Some("w12-explicit-grand"))
7398 .unwrap();
7399
7400 let chain = super::build_namespace_chain(&conn, "w12-explicit-leaf");
7401 assert!(chain.contains(&"w12-explicit-grand".to_string()));
7403 assert!(chain.contains(&"w12-explicit-leaf".to_string()));
7404 }
7405
7406 #[test]
7411 fn test_extract_governance_default_when_metadata_absent() {
7412 let mem_val = json!({"id": "x"});
7413 let gov = super::extract_governance(&mem_val);
7414 assert!(gov.is_object() || gov.is_null());
7416 }
7417
7418 #[test]
7419 fn test_extract_governance_default_when_metadata_invalid() {
7420 let mem_val = json!({"metadata": {"governance": {"unknown": "policy"}}});
7422 let gov = super::extract_governance(&mem_val);
7423 assert!(gov.is_object());
7425 }
7426
7427 #[test]
7432 fn test_messages_namespace_for_plain_id() {
7433 assert_eq!(super::messages_namespace_for("alice"), "_messages/alice");
7434 }
7435
7436 #[test]
7437 fn test_messages_namespace_for_ai_prefixed_id() {
7438 let ns = super::messages_namespace_for("ai:claude@host:pid-1");
7439 assert!(ns.starts_with("_messages/"));
7440 assert!(ns.contains("ai:"));
7441 }
7442
7443 #[test]
7449 fn test_inject_namespace_standard_no_namespace_no_global() {
7450 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7452 let mut resp = make_recall_response(vec![]);
7453 let before = resp.clone();
7454 super::inject_namespace_standard(&conn, None, &mut resp);
7455 assert_eq!(resp, before);
7456 }
7457
7458 #[test]
7466 fn handle_promote_default_tier_to_long() {
7467 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7470 let mem = Memory {
7471 id: uuid::Uuid::new_v4().to_string(),
7472 tier: Tier::Mid,
7473 namespace: "w12-tier-promote".into(),
7474 title: "t".into(),
7475 content: "c".into(),
7476 tags: vec![],
7477 priority: 5,
7478 confidence: 1.0,
7479 source: "test".into(),
7480 access_count: 0,
7481 created_at: chrono::Utc::now().to_rfc3339(),
7482 updated_at: chrono::Utc::now().to_rfc3339(),
7483 last_accessed_at: None,
7484 expires_at: None,
7485 metadata: json!({}),
7486 };
7487 let id = db::insert(&conn, &mem).unwrap();
7488 let req = make_tools_call("memory_promote", json!({"id": id}));
7489 let resp = invoke_handle_request(&conn, &req);
7490 assert!(resp.error.is_none());
7491 let text = resp.result.unwrap()["content"][0]["text"]
7492 .as_str()
7493 .unwrap()
7494 .to_string();
7495 let val: Value = serde_json::from_str(&text).unwrap();
7496 assert_eq!(val["promoted"], true);
7497 assert_eq!(val["mode"], "tier");
7498 assert_eq!(val["tier"], "long");
7499 }
7500
7501 #[test]
7502 fn handle_store_dedup_updates_existing() {
7503 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7506 let req1 = make_tools_call(
7507 "memory_store",
7508 json!({
7509 "title": "dup-title",
7510 "content": "first",
7511 "namespace": "w12-dedup",
7512 "tier": "mid",
7513 }),
7514 );
7515 let resp1 = invoke_handle_request(&conn, &req1);
7516 assert!(resp1.error.is_none());
7517 let text1 = resp1.result.unwrap()["content"][0]["text"]
7518 .as_str()
7519 .unwrap()
7520 .to_string();
7521 let val1: Value = serde_json::from_str(&text1).unwrap();
7522 let id1 = val1["id"].as_str().unwrap().to_string();
7523
7524 let req2 = make_tools_call(
7525 "memory_store",
7526 json!({
7527 "title": "dup-title",
7528 "content": "second-update",
7529 "namespace": "w12-dedup",
7530 "tier": "long",
7531 }),
7532 );
7533 let resp2 = invoke_handle_request(&conn, &req2);
7534 assert!(resp2.error.is_none());
7535 let text2 = resp2.result.unwrap()["content"][0]["text"]
7536 .as_str()
7537 .unwrap()
7538 .to_string();
7539 let val2: Value = serde_json::from_str(&text2).unwrap();
7540 assert_eq!(val2["id"], id1);
7541 assert_eq!(val2["duplicate"], true);
7542 assert_eq!(val2["action"], "updated existing memory");
7543 }
7544
7545 #[test]
7546 fn handle_subscribe_with_registered_agent_succeeds() {
7547 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7550 let resolved = crate::identity::resolve_agent_id(None, None).unwrap();
7554 db::register_agent(&conn, &resolved, "human", &[]).unwrap();
7555 let req = make_tools_call(
7556 "memory_subscribe",
7557 json!({
7558 "url": "https://example.com/hook",
7559 "events": "memory_store,memory_delete",
7560 "namespace_filter": "w12-sub",
7561 }),
7562 );
7563 let resp = invoke_handle_request(&conn, &req);
7564 assert!(resp.error.is_none());
7565 let text = resp.result.unwrap()["content"][0]["text"]
7566 .as_str()
7567 .unwrap()
7568 .to_string();
7569 let val: Value = serde_json::from_str(&text).unwrap();
7570 assert!(val["id"].is_string());
7571 assert_eq!(val["url"], "https://example.com/hook");
7572 }
7573
7574 #[test]
7575 fn handle_subscribe_invalid_url_after_registered() {
7576 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7579 let resolved = crate::identity::resolve_agent_id(None, None).unwrap();
7580 db::register_agent(&conn, &resolved, "human", &[]).unwrap();
7581 let req = make_tools_call("memory_subscribe", json!({"url": "not-a-url-at-all"}));
7582 let resp = invoke_handle_request(&conn, &req);
7583 let result = resp.result.unwrap();
7584 assert_eq!(result["isError"], true);
7585 }
7586
7587 #[test]
7588 fn handle_namespace_set_standard_with_valid_governance() {
7589 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7593 let mem = Memory {
7594 id: uuid::Uuid::new_v4().to_string(),
7595 tier: Tier::Long,
7596 namespace: "w12-gov-ok".into(),
7597 title: "p".into(),
7598 content: "c".into(),
7599 tags: vec![],
7600 priority: 5,
7601 confidence: 1.0,
7602 source: "test".into(),
7603 access_count: 0,
7604 created_at: chrono::Utc::now().to_rfc3339(),
7605 updated_at: chrono::Utc::now().to_rfc3339(),
7606 last_accessed_at: None,
7607 expires_at: None,
7608 metadata: json!({}),
7609 };
7610 let id = db::insert(&conn, &mem).unwrap();
7611 let req = make_tools_call(
7612 "memory_namespace_set_standard",
7613 json!({
7614 "namespace": "w12-gov-ok",
7615 "id": id,
7616 "governance": {
7617 "write": "any",
7618 "promote": "any",
7619 "delete": "owner",
7620 "approver": "human",
7621 },
7622 }),
7623 );
7624 let resp = invoke_handle_request(&conn, &req);
7625 assert!(resp.error.is_none());
7626 let text = resp.result.unwrap()["content"][0]["text"]
7627 .as_str()
7628 .unwrap()
7629 .to_string();
7630 let val: Value = serde_json::from_str(&text).unwrap();
7631 assert_eq!(val["set"], true);
7632 assert!(val["governance"].is_object());
7633 }
7634
7635 #[test]
7636 fn handle_namespace_set_standard_with_parent() {
7637 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7638 let mem = Memory {
7639 id: uuid::Uuid::new_v4().to_string(),
7640 tier: Tier::Long,
7641 namespace: "w12-parent-ns".into(),
7642 title: "p".into(),
7643 content: "c".into(),
7644 tags: vec![],
7645 priority: 5,
7646 confidence: 1.0,
7647 source: "test".into(),
7648 access_count: 0,
7649 created_at: chrono::Utc::now().to_rfc3339(),
7650 updated_at: chrono::Utc::now().to_rfc3339(),
7651 last_accessed_at: None,
7652 expires_at: None,
7653 metadata: json!({}),
7654 };
7655 let id = db::insert(&conn, &mem).unwrap();
7656 let req = make_tools_call(
7657 "memory_namespace_set_standard",
7658 json!({
7659 "namespace": "w12-parent-ns",
7660 "id": id,
7661 "parent": "w12-grand-ns",
7662 }),
7663 );
7664 let resp = invoke_handle_request(&conn, &req);
7665 assert!(resp.error.is_none());
7666 let text = resp.result.unwrap()["content"][0]["text"]
7667 .as_str()
7668 .unwrap()
7669 .to_string();
7670 let val: Value = serde_json::from_str(&text).unwrap();
7671 assert_eq!(val["parent"], "w12-grand-ns");
7672 }
7673
7674 #[test]
7675 fn handle_get_resolves_by_prefix_and_includes_links() {
7676 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7680 let mem = Memory {
7681 id: uuid::Uuid::new_v4().to_string(),
7682 tier: Tier::Long,
7683 namespace: "w12-prefix".into(),
7684 title: "T".into(),
7685 content: "C".into(),
7686 tags: vec![],
7687 priority: 5,
7688 confidence: 1.0,
7689 source: "test".into(),
7690 access_count: 0,
7691 created_at: chrono::Utc::now().to_rfc3339(),
7692 updated_at: chrono::Utc::now().to_rfc3339(),
7693 last_accessed_at: None,
7694 expires_at: None,
7695 metadata: json!({}),
7696 };
7697 let id = db::insert(&conn, &mem).unwrap();
7698 let req = make_tools_call("memory_get", json!({"id": id}));
7699 let resp = invoke_handle_request(&conn, &req);
7700 assert!(resp.error.is_none());
7701 let text = resp.result.unwrap()["content"][0]["text"]
7702 .as_str()
7703 .unwrap()
7704 .to_string();
7705 let val: Value = serde_json::from_str(&text).unwrap();
7706 assert!(val["links"].is_array());
7707 assert_eq!(val["id"], id);
7708 }
7709
7710 #[test]
7711 fn handle_link_creates_link_between_existing_memories() {
7712 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7715 let src = Memory {
7716 id: uuid::Uuid::new_v4().to_string(),
7717 tier: Tier::Long,
7718 namespace: "w12-link".into(),
7719 title: "src".into(),
7720 content: "c".into(),
7721 tags: vec![],
7722 priority: 5,
7723 confidence: 1.0,
7724 source: "test".into(),
7725 access_count: 0,
7726 created_at: chrono::Utc::now().to_rfc3339(),
7727 updated_at: chrono::Utc::now().to_rfc3339(),
7728 last_accessed_at: None,
7729 expires_at: None,
7730 metadata: json!({}),
7731 };
7732 let mut tgt = src.clone();
7733 tgt.id = uuid::Uuid::new_v4().to_string();
7734 tgt.title = "tgt".into();
7735 let src_id = db::insert(&conn, &src).unwrap();
7736 let tgt_id = db::insert(&conn, &tgt).unwrap();
7737 let req = make_tools_call(
7738 "memory_link",
7739 json!({
7740 "source_id": src_id,
7741 "target_id": tgt_id,
7742 "relation": "related_to",
7743 }),
7744 );
7745 let resp = invoke_handle_request(&conn, &req);
7746 assert!(resp.error.is_none());
7747 let text = resp.result.unwrap()["content"][0]["text"]
7748 .as_str()
7749 .unwrap()
7750 .to_string();
7751 let val: Value = serde_json::from_str(&text).unwrap();
7752 assert_eq!(val["linked"], true);
7753 }
7754
7755 #[test]
7756 fn handle_get_links_returns_outbound_and_inbound() {
7757 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7759 let src = Memory {
7760 id: uuid::Uuid::new_v4().to_string(),
7761 tier: Tier::Long,
7762 namespace: "w12-getlinks".into(),
7763 title: "src".into(),
7764 content: "c".into(),
7765 tags: vec![],
7766 priority: 5,
7767 confidence: 1.0,
7768 source: "test".into(),
7769 access_count: 0,
7770 created_at: chrono::Utc::now().to_rfc3339(),
7771 updated_at: chrono::Utc::now().to_rfc3339(),
7772 last_accessed_at: None,
7773 expires_at: None,
7774 metadata: json!({}),
7775 };
7776 let mut tgt = src.clone();
7777 tgt.id = uuid::Uuid::new_v4().to_string();
7778 let src_id = db::insert(&conn, &src).unwrap();
7779 let tgt_id = db::insert(&conn, &tgt).unwrap();
7780 db::create_link(&conn, &src_id, &tgt_id, "supersedes").unwrap();
7781
7782 let req = make_tools_call("memory_get_links", json!({"id": src_id}));
7783 let resp = invoke_handle_request(&conn, &req);
7784 assert!(resp.error.is_none());
7785 let text = resp.result.unwrap()["content"][0]["text"]
7786 .as_str()
7787 .unwrap()
7788 .to_string();
7789 let val: Value = serde_json::from_str(&text).unwrap();
7790 assert!(val["count"].as_u64().unwrap() >= 1);
7791 }
7792
7793 #[test]
7794 fn handle_kg_timeline_with_seeded_link_returns_event() {
7795 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7796 let src = Memory {
7797 id: uuid::Uuid::new_v4().to_string(),
7798 tier: Tier::Long,
7799 namespace: "w12-tl".into(),
7800 title: "src".into(),
7801 content: "c".into(),
7802 tags: vec![],
7803 priority: 5,
7804 confidence: 1.0,
7805 source: "test".into(),
7806 access_count: 0,
7807 created_at: chrono::Utc::now().to_rfc3339(),
7808 updated_at: chrono::Utc::now().to_rfc3339(),
7809 last_accessed_at: None,
7810 expires_at: None,
7811 metadata: json!({}),
7812 };
7813 let mut tgt = src.clone();
7814 tgt.id = uuid::Uuid::new_v4().to_string();
7815 tgt.title = "tgt".into();
7816 let src_id = db::insert(&conn, &src).unwrap();
7817 let tgt_id = db::insert(&conn, &tgt).unwrap();
7818 db::create_link(&conn, &src_id, &tgt_id, "related_to").unwrap();
7819
7820 let req = make_tools_call(
7821 "memory_kg_timeline",
7822 json!({"source_id": src_id, "limit": 10}),
7823 );
7824 let resp = invoke_handle_request(&conn, &req);
7825 assert!(resp.error.is_none());
7826 let text = resp.result.unwrap()["content"][0]["text"]
7827 .as_str()
7828 .unwrap()
7829 .to_string();
7830 let val: Value = serde_json::from_str(&text).unwrap();
7831 assert_eq!(val["count"], 1);
7832 let events = val["events"].as_array().unwrap();
7833 assert_eq!(events[0]["target_id"], tgt_id);
7834 }
7835
7836 #[test]
7837 fn handle_kg_query_with_seeded_link_returns_node() {
7838 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7839 let src = Memory {
7840 id: uuid::Uuid::new_v4().to_string(),
7841 tier: Tier::Long,
7842 namespace: "w12-kgq".into(),
7843 title: "src".into(),
7844 content: "c".into(),
7845 tags: vec![],
7846 priority: 5,
7847 confidence: 1.0,
7848 source: "test".into(),
7849 access_count: 0,
7850 created_at: chrono::Utc::now().to_rfc3339(),
7851 updated_at: chrono::Utc::now().to_rfc3339(),
7852 last_accessed_at: None,
7853 expires_at: None,
7854 metadata: json!({}),
7855 };
7856 let mut tgt = src.clone();
7857 tgt.id = uuid::Uuid::new_v4().to_string();
7858 let src_id = db::insert(&conn, &src).unwrap();
7859 let tgt_id = db::insert(&conn, &tgt).unwrap();
7860 db::create_link(&conn, &src_id, &tgt_id, "related_to").unwrap();
7861
7862 let req = make_tools_call(
7863 "memory_kg_query",
7864 json!({"source_id": src_id, "max_depth": 1, "limit": 10}),
7865 );
7866 let resp = invoke_handle_request(&conn, &req);
7867 assert!(resp.error.is_none());
7868 let text = resp.result.unwrap()["content"][0]["text"]
7869 .as_str()
7870 .unwrap()
7871 .to_string();
7872 let val: Value = serde_json::from_str(&text).unwrap();
7873 assert!(val["count"].as_u64().unwrap() >= 1);
7874 assert!(val["paths"].is_array());
7875 }
7876
7877 #[test]
7878 fn handle_archive_list_with_pagination() {
7879 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7880 let req = make_tools_call("memory_archive_list", json!({"limit": 100, "offset": 50}));
7881 let resp = invoke_handle_request(&conn, &req);
7882 assert!(resp.error.is_none());
7883 }
7884
7885 #[test]
7886 fn handle_pending_list_with_status_filter() {
7887 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7888 for status in &["pending", "approved", "rejected"] {
7889 let req = make_tools_call(
7890 "memory_pending_list",
7891 json!({"status": status, "limit": 50}),
7892 );
7893 let resp = invoke_handle_request(&conn, &req);
7894 assert!(resp.error.is_none(), "failed for status={status}");
7895 }
7896 }
7897
7898 #[test]
7899 fn handle_pending_approve_with_seeded_pending_action() {
7900 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7902 let pending_id = db::queue_pending_action(
7903 &conn,
7904 crate::models::GovernedAction::Promote,
7905 "w12-approve",
7906 None,
7907 "human:requestor",
7908 &json!({"id": "00000000-0000-0000-0000-000000000000"}),
7909 )
7910 .unwrap();
7911 let req = make_tools_call(
7912 "memory_pending_approve",
7913 json!({"id": pending_id, "agent_id": "human:approver"}),
7914 );
7915 let resp = invoke_handle_request(&conn, &req);
7916 let result = resp.result.unwrap();
7919 assert!(result.is_object());
7920 }
7921
7922 #[test]
7923 fn handle_pending_reject_with_seeded_pending_action() {
7924 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7925 let pending_id = db::queue_pending_action(
7926 &conn,
7927 crate::models::GovernedAction::Promote,
7928 "w12-reject",
7929 None,
7930 "human:requestor",
7931 &json!({"id": "00000000-0000-0000-0000-000000000000"}),
7932 )
7933 .unwrap();
7934 let req = make_tools_call(
7935 "memory_pending_reject",
7936 json!({"id": pending_id, "agent_id": "human:rejector"}),
7937 );
7938 let resp = invoke_handle_request(&conn, &req);
7939 assert!(resp.error.is_none());
7940 let text = resp.result.unwrap()["content"][0]["text"]
7941 .as_str()
7942 .unwrap()
7943 .to_string();
7944 let val: Value = serde_json::from_str(&text).unwrap();
7945 assert_eq!(val["rejected"], true);
7946 }
7947
7948 #[test]
7949 fn handle_session_start_toon_format_default() {
7950 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7953 let req = make_tools_call("memory_session_start", json!({"namespace": "w12-toon"}));
7954 let resp = invoke_handle_request(&conn, &req);
7955 assert!(resp.error.is_none());
7956 let result = resp.result.unwrap();
7958 assert!(result["content"][0]["text"].is_string());
7959 }
7960
7961 #[test]
7962 fn handle_search_explicit_toon_format() {
7963 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7964 let req = make_tools_call(
7965 "memory_search",
7966 json!({"query": "anything", "format": "toon"}),
7967 );
7968 let resp = invoke_handle_request(&conn, &req);
7969 assert!(resp.error.is_none());
7970 }
7971
7972 #[test]
7973 fn handle_recall_explicit_toon_format() {
7974 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7975 let req = make_tools_call("memory_recall", json!({"context": "ctx", "format": "toon"}));
7976 let resp = invoke_handle_request(&conn, &req);
7977 assert!(resp.error.is_none());
7978 }
7979
7980 #[test]
7981 fn handle_list_explicit_toon_compact_format() {
7982 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7983 let req = make_tools_call(
7984 "memory_list",
7985 json!({"namespace": "w12-toon-list", "format": "toon_compact"}),
7986 );
7987 let resp = invoke_handle_request(&conn, &req);
7988 assert!(resp.error.is_none());
7989 }
7990
7991 #[test]
7992 fn handle_search_with_namespace_and_tier_filters() {
7993 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7994 let req = make_tools_call(
7995 "memory_search",
7996 json!({
7997 "query": "test query",
7998 "namespace": "w12-search",
7999 "tier": "long",
8000 "limit": 10,
8001 "agent_id": "ai:bot",
8002 "format": "json",
8003 }),
8004 );
8005 let resp = invoke_handle_request(&conn, &req);
8006 assert!(resp.error.is_none());
8007 }
8008
8009 #[test]
8010 fn handle_search_invalid_agent_id_rejected() {
8011 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8012 let req = make_tools_call(
8013 "memory_search",
8014 json!({"query": "x", "agent_id": "bad agent !!"}),
8015 );
8016 let resp = invoke_handle_request(&conn, &req);
8017 let result = resp.result.unwrap();
8018 assert_eq!(result["isError"], true);
8019 }
8020
8021 #[test]
8022 fn handle_search_invalid_as_agent_rejected() {
8023 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8024 let req = make_tools_call(
8025 "memory_search",
8026 json!({"query": "x", "as_agent": "BAD AS AGENT"}),
8027 );
8028 let resp = invoke_handle_request(&conn, &req);
8029 let result = resp.result.unwrap();
8030 assert_eq!(result["isError"], true);
8031 }
8032
8033 #[test]
8034 fn handle_recall_invalid_as_agent_rejected() {
8035 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8036 let req = make_tools_call(
8037 "memory_recall",
8038 json!({"context": "x", "as_agent": "INVALID NS"}),
8039 );
8040 let resp = invoke_handle_request(&conn, &req);
8041 let result = resp.result.unwrap();
8042 assert_eq!(result["isError"], true);
8043 }
8044
8045 #[test]
8046 fn handle_recall_with_context_tokens() {
8047 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8050 let req = make_tools_call(
8051 "memory_recall",
8052 json!({
8053 "context": "main",
8054 "context_tokens": ["recent", "tokens", "from", "convo"],
8055 "format": "json",
8056 }),
8057 );
8058 let resp = invoke_handle_request(&conn, &req);
8059 assert!(resp.error.is_none());
8060 }
8061
8062 #[test]
8063 fn handle_recall_with_budget_tokens_positive() {
8064 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8065 let req = make_tools_call(
8066 "memory_recall",
8067 json!({"context": "x", "budget_tokens": 1000, "format": "json"}),
8068 );
8069 let resp = invoke_handle_request(&conn, &req);
8070 assert!(resp.error.is_none());
8071 let text = resp.result.unwrap()["content"][0]["text"]
8072 .as_str()
8073 .unwrap()
8074 .to_string();
8075 let val: Value = serde_json::from_str(&text).unwrap();
8076 assert!(val["tokens_used"].is_u64() || val["tokens_used"].is_i64());
8077 assert_eq!(val["budget_tokens"], 1000);
8078 }
8079
8080 #[test]
8081 fn handle_recall_invalid_namespace_filter_passes_through() {
8082 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8085 let req = make_tools_call(
8086 "memory_recall",
8087 json!({
8088 "context": "x",
8089 "namespace": "w12-no-such-namespace",
8090 "format": "json",
8091 }),
8092 );
8093 let resp = invoke_handle_request(&conn, &req);
8094 assert!(resp.error.is_none());
8095 }
8096
8097 #[test]
8098 fn handle_list_with_tier_filter() {
8099 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8100 let req = make_tools_call(
8101 "memory_list",
8102 json!({
8103 "namespace": "w12-list-tier",
8104 "tier": "long",
8105 "agent_id": "ai:bot",
8106 "limit": 25,
8107 "format": "json",
8108 }),
8109 );
8110 let resp = invoke_handle_request(&conn, &req);
8111 assert!(resp.error.is_none());
8112 }
8113
8114 #[test]
8115 fn handle_list_invalid_tier_treated_as_none() {
8116 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8120 let req = make_tools_call(
8121 "memory_list",
8122 json!({"namespace": "w12-list-bad-tier", "tier": "ULTRAMID", "format": "json"}),
8123 );
8124 let resp = invoke_handle_request(&conn, &req);
8125 assert!(resp.error.is_none());
8126 }
8127
8128 #[test]
8129 fn handle_get_taxonomy_invalid_depth_clamps_to_max() {
8130 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8133 let req = make_tools_call(
8134 "memory_get_taxonomy",
8135 json!({"depth": 100_000_u64, "limit": 50_000_u64}),
8136 );
8137 let resp = invoke_handle_request(&conn, &req);
8138 assert!(resp.error.is_none());
8139 }
8140
8141 #[test]
8142 fn handle_archive_purge_no_filter_purges_all() {
8143 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8144 let req = make_tools_call("memory_archive_purge", json!({}));
8145 let resp = invoke_handle_request(&conn, &req);
8146 assert!(resp.error.is_none());
8147 }
8148
8149 #[test]
8150 fn handle_check_duplicate_invalid_title_rejected() {
8151 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8154 let req = make_tools_call(
8155 "memory_check_duplicate",
8156 json!({"title": "", "content": "anything"}),
8157 );
8158 let resp = invoke_handle_request(&conn, &req);
8159 let result = resp.result.unwrap();
8160 assert_eq!(result["isError"], true);
8161 }
8162
8163 #[test]
8164 fn handle_check_duplicate_invalid_namespace_rejected() {
8165 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8166 let req = make_tools_call(
8167 "memory_check_duplicate",
8168 json!({"title": "T", "content": "C", "namespace": "BAD NS"}),
8169 );
8170 let resp = invoke_handle_request(&conn, &req);
8171 let result = resp.result.unwrap();
8172 assert_eq!(result["isError"], true);
8173 }
8174
8175 #[test]
8176 fn handle_entity_register_with_explicit_agent_id() {
8177 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8180 let req = make_tools_call(
8181 "memory_entity_register",
8182 json!({
8183 "canonical_name": "Org Alpha",
8184 "namespace": "w12-orgs",
8185 "aliases": ["alpha", "α"],
8186 "agent_id": "ai:bot",
8187 }),
8188 );
8189 let resp = invoke_handle_request(&conn, &req);
8190 assert!(resp.error.is_none());
8191 }
8192
8193 #[test]
8194 fn handle_entity_register_invalid_explicit_agent_id() {
8195 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8196 let req = make_tools_call(
8197 "memory_entity_register",
8198 json!({
8199 "canonical_name": "Org Beta",
8200 "namespace": "w12-orgs",
8201 "agent_id": "BAD AGENT !!",
8202 }),
8203 );
8204 let resp = invoke_handle_request(&conn, &req);
8205 let result = resp.result.unwrap();
8206 assert_eq!(result["isError"], true);
8207 }
8208
8209 #[test]
8210 fn handle_entity_get_by_alias_no_namespace() {
8211 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8213 let req = make_tools_call("memory_entity_get_by_alias", json!({"alias": "any-alias"}));
8214 let resp = invoke_handle_request(&conn, &req);
8215 assert!(resp.error.is_none());
8216 }
8217
8218 #[test]
8219 fn handle_inbox_with_message_seeded() {
8220 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8222 let notify = make_tools_call(
8223 "memory_notify",
8224 json!({
8225 "target_agent_id": "alice-w12",
8226 "title": "ping",
8227 "payload": "are you there?",
8228 "tier": "short",
8229 }),
8230 );
8231 let _ = invoke_handle_request(&conn, ¬ify);
8232 let inbox = make_tools_call(
8233 "memory_inbox",
8234 json!({"agent_id": "alice-w12", "limit": 10}),
8235 );
8236 let resp = invoke_handle_request(&conn, &inbox);
8237 assert!(resp.error.is_none());
8238 let text = resp.result.unwrap()["content"][0]["text"]
8239 .as_str()
8240 .unwrap()
8241 .to_string();
8242 let val: Value = serde_json::from_str(&text).unwrap();
8243 assert!(val["count"].as_u64().unwrap() >= 1);
8244 assert_eq!(val["agent_id"], "alice-w12");
8245 }
8246
8247 #[test]
8248 fn handle_consolidate_succeeds_when_source_was_standard() {
8249 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8255 let mem_a = Memory {
8256 id: uuid::Uuid::new_v4().to_string(),
8257 tier: Tier::Long,
8258 namespace: "w12-cons-warn".into(),
8259 title: "a".into(),
8260 content: "alpha".into(),
8261 tags: vec![],
8262 priority: 5,
8263 confidence: 1.0,
8264 source: "test".into(),
8265 access_count: 0,
8266 created_at: chrono::Utc::now().to_rfc3339(),
8267 updated_at: chrono::Utc::now().to_rfc3339(),
8268 last_accessed_at: None,
8269 expires_at: None,
8270 metadata: json!({}),
8271 };
8272 let mut mem_b = mem_a.clone();
8273 mem_b.id = uuid::Uuid::new_v4().to_string();
8274 mem_b.title = "b".into();
8275 mem_b.content = "beta".into();
8276 let id_a = db::insert(&conn, &mem_a).unwrap();
8277 let id_b = db::insert(&conn, &mem_b).unwrap();
8278 db::set_namespace_standard(&conn, "w12-cons-warn", &id_a, None).unwrap();
8280
8281 let req = make_tools_call(
8282 "memory_consolidate",
8283 json!({
8284 "ids": [id_a, id_b],
8285 "title": "merged-warn",
8286 "summary": "merged summary",
8287 "namespace": "w12-cons-warn",
8288 }),
8289 );
8290 let resp = invoke_handle_request(&conn, &req);
8291 assert!(resp.error.is_none());
8292 let text = resp.result.unwrap()["content"][0]["text"]
8293 .as_str()
8294 .unwrap()
8295 .to_string();
8296 let val: Value = serde_json::from_str(&text).unwrap();
8297 assert!(val["id"].is_string());
8298 assert_eq!(val["consolidated"], 2);
8299 }
8300
8301 #[test]
8302 fn handle_update_clears_expires_with_empty_string() {
8303 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8306 let mem = Memory {
8307 id: uuid::Uuid::new_v4().to_string(),
8308 tier: Tier::Short,
8309 namespace: "w12-clear-exp".into(),
8310 title: "t".into(),
8311 content: "c".into(),
8312 tags: vec![],
8313 priority: 5,
8314 confidence: 1.0,
8315 source: "test".into(),
8316 access_count: 0,
8317 created_at: chrono::Utc::now().to_rfc3339(),
8318 updated_at: chrono::Utc::now().to_rfc3339(),
8319 last_accessed_at: None,
8320 expires_at: Some(chrono::Utc::now().to_rfc3339()),
8321 metadata: json!({}),
8322 };
8323 let id = db::insert(&conn, &mem).unwrap();
8324 let req = make_tools_call("memory_update", json!({"id": id, "expires_at": ""}));
8325 let resp = invoke_handle_request(&conn, &req);
8326 let result = resp.result.unwrap();
8329 assert!(result.is_object());
8332 }
8333
8334 #[test]
8335 fn handle_update_change_namespace() {
8336 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8337 let mem = Memory {
8338 id: uuid::Uuid::new_v4().to_string(),
8339 tier: Tier::Mid,
8340 namespace: "w12-update-ns".into(),
8341 title: "t".into(),
8342 content: "c".into(),
8343 tags: vec![],
8344 priority: 5,
8345 confidence: 1.0,
8346 source: "test".into(),
8347 access_count: 0,
8348 created_at: chrono::Utc::now().to_rfc3339(),
8349 updated_at: chrono::Utc::now().to_rfc3339(),
8350 last_accessed_at: None,
8351 expires_at: None,
8352 metadata: json!({}),
8353 };
8354 let id = db::insert(&conn, &mem).unwrap();
8355 let req = make_tools_call(
8356 "memory_update",
8357 json!({
8358 "id": id,
8359 "namespace": "w12-update-ns-new",
8360 "tags": ["a", "b"],
8361 "title": "new-title",
8362 "content": "new-content",
8363 "tier": "long",
8364 "priority": 8_i64,
8365 "confidence": 0.9_f64,
8366 }),
8367 );
8368 let resp = invoke_handle_request(&conn, &req);
8369 assert!(resp.error.is_none());
8370 }
8371
8372 #[test]
8373 fn handle_delete_with_prefix_id_lookup() {
8374 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8376 let mem = Memory {
8377 id: uuid::Uuid::new_v4().to_string(),
8378 tier: Tier::Mid,
8379 namespace: "w12-delete-prefix".into(),
8380 title: "t".into(),
8381 content: "c".into(),
8382 tags: vec![],
8383 priority: 5,
8384 confidence: 1.0,
8385 source: "test".into(),
8386 access_count: 0,
8387 created_at: chrono::Utc::now().to_rfc3339(),
8388 updated_at: chrono::Utc::now().to_rfc3339(),
8389 last_accessed_at: None,
8390 expires_at: None,
8391 metadata: json!({}),
8392 };
8393 let id = db::insert(&conn, &mem).unwrap();
8394 let req = make_tools_call("memory_delete", json!({"id": id}));
8395 let resp = invoke_handle_request(&conn, &req);
8396 assert!(resp.error.is_none());
8397 let text = resp.result.unwrap()["content"][0]["text"]
8398 .as_str()
8399 .unwrap()
8400 .to_string();
8401 let val: Value = serde_json::from_str(&text).unwrap();
8402 assert_eq!(val["deleted"], true);
8403 }
8404
8405 #[test]
8406 fn handle_unsubscribe_after_subscribe_removes_row() {
8407 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8409 let resolved = crate::identity::resolve_agent_id(None, None).unwrap();
8410 db::register_agent(&conn, &resolved, "human", &[]).unwrap();
8411 let sub = make_tools_call(
8412 "memory_subscribe",
8413 json!({"url": "https://example.com/hook2"}),
8414 );
8415 let sub_resp = invoke_handle_request(&conn, &sub);
8416 let sub_text = sub_resp.result.unwrap()["content"][0]["text"]
8417 .as_str()
8418 .unwrap()
8419 .to_string();
8420 let sub_val: Value = serde_json::from_str(&sub_text).unwrap();
8421 let id = sub_val["id"].as_str().unwrap().to_string();
8422
8423 let unsub = make_tools_call("memory_unsubscribe", json!({"id": id}));
8424 let unsub_resp = invoke_handle_request(&conn, &unsub);
8425 assert!(unsub_resp.error.is_none());
8426 let unsub_text = unsub_resp.result.unwrap()["content"][0]["text"]
8427 .as_str()
8428 .unwrap()
8429 .to_string();
8430 let unsub_val: Value = serde_json::from_str(&unsub_text).unwrap();
8431 assert!(
8432 unsub_val["removed"] == json!(true) || unsub_val["removed"] == json!(1),
8433 "unexpected removed value: {:?}",
8434 unsub_val["removed"]
8435 );
8436 }
8437
8438 #[test]
8439 fn handle_list_subscriptions_after_subscribe_returns_one() {
8440 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8441 let resolved = crate::identity::resolve_agent_id(None, None).unwrap();
8442 db::register_agent(&conn, &resolved, "human", &[]).unwrap();
8443 let sub = make_tools_call(
8444 "memory_subscribe",
8445 json!({"url": "https://example.com/listed"}),
8446 );
8447 let _ = invoke_handle_request(&conn, &sub);
8448 let req = make_tools_call("memory_list_subscriptions", json!({}));
8449 let resp = invoke_handle_request(&conn, &req);
8450 assert!(resp.error.is_none());
8451 let text = resp.result.unwrap()["content"][0]["text"]
8452 .as_str()
8453 .unwrap()
8454 .to_string();
8455 let val: Value = serde_json::from_str(&text).unwrap();
8456 assert!(val.get("subscriptions").is_some() || val.get("count").is_some() || val.is_array());
8459 }
8460
8461 #[test]
8462 fn test_inject_namespace_standard_dedup_keeps_originals_order() {
8463 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8466 let std_id = seed_namespace_standard(&conn, "w12-order", "S");
8467 let mems = vec![
8468 json!({"id": "first", "title": "f"}),
8469 json!({"id": std_id, "title": "S"}),
8470 json!({"id": "third", "title": "t"}),
8471 ];
8472 let mut resp = make_recall_response(mems);
8473 super::inject_namespace_standard(&conn, Some("w12-order"), &mut resp);
8474 let memories = resp["memories"].as_array().unwrap();
8475 assert_eq!(memories.len(), 2);
8476 assert_eq!(memories[0]["id"], "first");
8477 assert_eq!(memories[1]["id"], "third");
8478 }
8479}