From 9e1c386d3911fdb6dcff4e346c0693dd0945317f Mon Sep 17 00:00:00 2001 From: Sam Valladares <143034159+samvallad33@users.noreply.github.com> Date: Mon, 22 Jun 2026 01:19:40 -0500 Subject: [PATCH 1/9] fix(core): re-enable usearch fp16lib to unbreak Windows MSVC build (#94) The v2.1.26 pin set `default-features = false` on usearch to drop SimSIMD's AVX2/Haswell dispatch (the illegal-instruction crash on older x86_64 CPUs, #71). But usearch's defaults are ["simsimd", "fp16lib"], so disabling all defaults also dropped the unrelated `fp16lib` feature. With both off, usearch's build.rs sets USEARCH_USE_FP16LIB=0 and USEARCH_USE_SIMSIMD=0, selecting the bare `#else` half-precision branch in include/usearch/index_plugins.hpp, which carries a `#warning` directive. MSVC's cl.exe treats `#warning` as fatal error C1021, so the Windows build aborts: index_plugins.hpp(404): fatal error C1021: invalid preprocessor command 'warning' GCC/Clang only warn, so Linux/macOS never caught it. `/Zc:preprocessor` does not change MSVC's behavior here. Fix: re-enable `fp16lib` only, keeping SimSIMD disabled. fp16lib is a scalar, self-contained fp16<->fp32 conversion library with no SIMD intrinsics, so this sets USEARCH_USE_FP16LIB=1 (taking the non-warning branch) without reintroducing the #71 illegal-instruction risk. Restores v2.1.25 behavior on the half-precision path while keeping the #71 portability fix. usearch stays pinned at =2.23.0; Cargo.lock is unchanged. Verified on macOS (aarch64): cargo build -p vestige-core and -p vestige-mcp green, 9/9 vector-search tests pass, clippy clean. cargo tree confirms only the `fp16lib` usearch feature is active and `simsimd` is not. The MSVC repro is Windows-only and cannot be exercised on this host. Co-authored-by: Claude Opus 4.8 (1M context) --- crates/vestige-core/Cargo.toml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/vestige-core/Cargo.toml b/crates/vestige-core/Cargo.toml index 36936b6..d95fc2d 100644 --- a/crates/vestige-core/Cargo.toml +++ b/crates/vestige-core/Cargo.toml @@ -130,8 +130,19 @@ candle-core = { version = "0.10.2", optional = true } # # Disable default features so release binaries do not include SimSIMD's # Haswell+/AVX2/FMA dispatch targets. Those kernels can trigger illegal -# instructions on older x86_64 CPUs that Vestige otherwise supports. -usearch = { version = "=2.23.0", default-features = false, optional = true } +# instructions on older x86_64 CPUs that Vestige otherwise supports (#71). +# +# But re-enable `fp16lib` explicitly. usearch's defaults are +# ["simsimd", "fp16lib"]; with BOTH off, build.rs sets USEARCH_USE_FP16LIB=0 +# and USEARCH_USE_SIMSIMD=0, which selects the bare half-precision `#else` +# branch in include/usearch/index_plugins.hpp. That branch carries a +# `#warning` directive, which MSVC's cl.exe treats as fatal error C1021, +# breaking the Windows build (GCC/Clang only warn). `fp16lib` is a scalar, +# self-contained fp16<->fp32 conversion library with NO SIMD intrinsics, so +# re-enabling it sets USEARCH_USE_FP16LIB=1 (taking the non-warning branch) +# WITHOUT reintroducing the SimSIMD illegal-instruction risk from #71. Do not +# drop this feature. +usearch = { version = "=2.23.0", default-features = false, features = ["fp16lib"], optional = true } # LRU cache for query embeddings lru = "0.16" From ef4cefaa4081a4e3ff5863ff48f3e522197117b1 Mon Sep 17 00:00:00 2001 From: Sam Valladares <143034159+samvallad33@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:14:51 -0500 Subject: [PATCH 2/9] Add Dockerfile for MCP registry introspection (Glama) (#95) Lets registries such as Glama start the server in an isolated sandbox and run the standard MCP stdio introspection (tools/list, resources/list, prompts/list) so the listing passes its checks and scores. - node:20-slim (glibc) base, required because the npm postinstall downloads the prebuilt x86_64-unknown-linux-gnu binary (a -gnu binary will not run on musl/Alpine). - Installs vestige-mcp-server@latest globally; postinstall fetches the binary. - VESTIGE_DATA_DIR=/data keeps memory state inside the container. - ENTRYPOINT vestige-mcp speaks MCP over stdio. Verified locally: npm install + postinstall fetch the binary, the server boots, runs its migrations, and answers initialize + tools/list over stdio. --- Dockerfile | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e93e93c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# Dockerfile for running the Vestige MCP server in an isolated sandbox. +# +# Used by registries such as Glama to start the server and run the standard +# MCP stdio introspection exchange (tools/list, resources/list, prompts/list). +# The server speaks MCP over stdio, which is exactly what these tools expect. +# +# Base must be glibc (Debian), not musl/Alpine: the npm postinstall downloads +# the prebuilt x86_64-unknown-linux-gnu Rust binary from the GitHub release, and +# a -gnu binary will not run on an Alpine/musl image. + +FROM node:20-slim + +# ca-certificates lets the postinstall fetch the release asset over HTTPS. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Install the published package globally. Its postinstall downloads the matching +# prebuilt vestige-mcp binary for linux/x64 from the GitHub release. +RUN npm install -g vestige-mcp-server@latest + +# Keep all memory data inside the container under a writable path. +ENV VESTIGE_DATA_DIR=/data +RUN mkdir -p /data + +# Start the MCP server on stdio. The `vestige-mcp` bin execs the native binary +# and inherits stdio, so the MCP client talks to it directly. +ENTRYPOINT ["vestige-mcp"] From ba85a06eacc8e94a354de5d611268e253ae973ec Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Sun, 28 Jun 2026 17:28:04 -0500 Subject: [PATCH 3/9] =?UTF-8?q?feat(mcp):=20consolidate=20dedup+merge=20in?= =?UTF-8?q?to=20one=20`dedup`=20action-tool=20(8=E2=86=921)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool Consolidation v2.2.0, Layer 1 commit 1/6. Advertised tools 34 → 27. Folds find_duplicates + the 7 Phase-3 merge/supersede tools (merge_candidates, plan_merge, plan_supersede, apply_plan, merge_undo, protect, merge_policy) into a single action-dispatched `dedup` tool: action = scan (default) | plan_merge | plan_supersede | apply | undo | protect | policy - `scan` combines cosine-similarity duplicate clusters with Fellegi-Sunter merge candidates in one read-only payload. - The mutate/preview/reverse actions delegate to merge::execute verbatim, preserving plan_id → apply → undo, confirm-gating, and bitemporal-never-delete byte-for-byte. - All 8 old names remain dispatchable as hidden warn!+redirect aliases (≥1 minor release, removed v2.3.0) but drop off the advertised tools/list. - Size annotation: dedup=150_000 (scan payload). expected_max_result_size + both annotation tests kept in sync. - Tests: count assert 34→27, 8 negative asserts, new test_deprecated_dedup_aliases_redirect verifying old names still dispatch. Gates: cargo test --workspace (all green), cargo clippy -D warnings (clean). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/vestige-mcp/src/server.rs | 163 +++++++++++++++----------- crates/vestige-mcp/src/tools/dedup.rs | 112 ++++++++++++++++++ 2 files changed, 209 insertions(+), 66 deletions(-) diff --git a/crates/vestige-mcp/src/server.rs b/crates/vestige-mcp/src/server.rs index 6b919fa..e77b3ef 100644 --- a/crates/vestige-mcp/src/server.rs +++ b/crates/vestige-mcp/src/server.rs @@ -346,56 +346,16 @@ impl McpServer { input_schema: tools::importance::schema(), ..Default::default() }, - ToolDescription { - name: "find_duplicates".to_string(), - description: Some("Find duplicate and near-duplicate memory clusters using cosine similarity on embeddings. Returns clusters with suggested actions (merge/review). Use to clean up redundant memories.".to_string()), - input_schema: tools::dedup::schema(), - ..Default::default() - }, // ================================================================ - // MERGE / SUPERSEDE CONTROLS (v2.1.25 — Phase 3) - // Diff-previewed, confidence-gated, reversible, never silent. + // DEDUP / MERGE / SUPERSEDE — unified `dedup` tool (v2.2) + // Folds find_duplicates + the 7 Phase-3 merge tools into one + // action-dispatched surface. Diff-previewed, confidence-gated, + // reversible, never silent; bitemporal-never-delete preserved. // ================================================================ ToolDescription { - name: "merge_candidates".to_string(), - description: Some("Surface likely duplicate/overlapping memory clusters with confidence scores and the signals behind each (Fellegi-Sunter match/possible/non-match). Read-only — nothing is changed.".to_string()), - input_schema: tools::merge::merge_candidates_schema(), - ..Default::default() - }, - ToolDescription { - name: "plan_merge".to_string(), - description: Some("Produce a previewable MERGE plan (a diff: combined content/tags/provenance) for 2+ memories WITHOUT applying it. Returns a plan_id for apply_plan. Protected members block the merge.".to_string()), - input_schema: tools::merge::plan_merge_schema(), - ..Default::default() - }, - ToolDescription { - name: "plan_supersede".to_string(), - description: Some("Preview superseding memory A with B — bitemporal invalidation (stamps valid_until, keeps A queryable for audit) WITHOUT applying. Returns a plan_id for apply_plan.".to_string()), - input_schema: tools::merge::plan_supersede_schema(), - ..Default::default() - }, - ToolDescription { - name: "apply_plan".to_string(), - description: Some("Execute a previously-generated merge/supersede plan by id. Recorded as a reversible operation. Old memories are invalidated (never deleted). 'possible'/'non_match' plans require confirm=true.".to_string()), - input_schema: tools::merge::apply_plan_schema(), - ..Default::default() - }, - ToolDescription { - name: "merge_undo".to_string(), - description: Some("Reverse a prior merge/supersede operation (the 'git reflog for your agent's memory'). With no operation_id, lists the reversible operation log so you can pick one.".to_string()), - input_schema: tools::merge::merge_undo_schema(), - ..Default::default() - }, - ToolDescription { - name: "protect".to_string(), - description: Some("Pin a memory so it can never be auto-merged, superseded, or garbage-collected. Pass protected=false to unpin.".to_string()), - input_schema: tools::merge::protect_schema(), - ..Default::default() - }, - ToolDescription { - name: "merge_policy".to_string(), - description: Some("Get or set the per-project merge policy: the two Fellegi-Sunter thresholds (match_threshold, possible_threshold) and auto_apply. No args returns the current policy.".to_string()), - input_schema: tools::merge::merge_policy_schema(), + name: "dedup".to_string(), + description: Some("Deduplication & merge/supersede. Actions: 'scan' (default — surface duplicate clusters via cosine + merge candidates via Fellegi-Sunter, read-only), 'plan_merge' (preview a reversible merge plan for 2+ member_ids → plan_id), 'plan_supersede' (preview superseding old_id with new_id → plan_id), 'apply' (execute a plan_id; 'possible'/'non_match' need confirm=true), 'undo' (reverse an operation_id, or omit to list the reflog), 'protect' (pin a memory against auto-merge/supersede/forget), 'policy' (get/set Fellegi-Sunter thresholds). Old memories are invalidated, never deleted.".to_string()), + input_schema: tools::dedup::unified_schema(), ..Default::default() }, // ================================================================ @@ -516,6 +476,9 @@ impl McpServer { "memory_timeline" => Some(200_000), "memory" => Some(100_000), "codebase" => Some(100_000), + // v2.2: dedup action='scan' returns duplicate clusters + + // merge candidates + policy in one payload. + "dedup" => Some(150_000), _ => None, }; if let Some(n) = max_chars { @@ -1003,13 +966,31 @@ impl McpServer { "importance_score" => { tools::importance::execute(&self.storage, &self.cognitive, request.arguments).await } - "find_duplicates" => tools::dedup::execute(&self.storage, request.arguments).await, + // ================================================================ + // DEDUP / MERGE / SUPERSEDE — unified `dedup` tool (v2.2) + // ================================================================ + "dedup" => tools::dedup::execute_unified(&self.storage, request.arguments).await, - // ================================================================ - // MERGE / SUPERSEDE CONTROLS (v2.1.25 — Phase 3) - // ================================================================ + // DEPRECATED (v2.2): folded into `dedup`. Kept as hidden back-compat + // aliases (≥1 minor release) — they call the same underlying handlers + // verbatim, so envelopes/plan_id/confirm-gating/bitemporal are intact. + "find_duplicates" => { + warn!("Tool 'find_duplicates' is deprecated in v2.2. Use 'dedup' with action='scan'."); + tools::dedup::execute(&self.storage, request.arguments).await + } "merge_candidates" | "plan_merge" | "plan_supersede" | "apply_plan" | "merge_undo" | "protect" | "merge_policy" => { + warn!( + "Tool '{}' is deprecated in v2.2. Use 'dedup' (action={}).", + request.name, + match request.name.as_str() { + "merge_candidates" => "scan", + "apply_plan" => "apply", + "merge_undo" => "undo", + "merge_policy" => "policy", + other => other, + } + ); tools::merge::execute(&self.storage, request.name.as_str(), request.arguments).await } @@ -1820,10 +1801,10 @@ mod tests { let result = response.result.unwrap(); let tools = result["tools"].as_array().unwrap(); - // 34 tools: 25 from v2.1.21 + 7 Phase 3 merge/supersede tools - // (merge_candidates, plan_merge, plan_supersede, apply_plan, merge_undo, - // protect, merge_policy, composed_graph) + 1 connector tool (source_sync, #57). - assert_eq!(tools.len(), 34, "Expected exactly 34 tools"); + // v2.2 Tool Consolidation (Layer 1): 34 → 27 after `dedup` folds + // find_duplicates + the 7 Phase-3 merge tools (8 → 1). Old names remain + // dispatchable as hidden back-compat aliases but drop off the advertised list. + assert_eq!(tools.len(), 27, "Expected exactly 27 tools after dedup consolidation"); let tool_names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); @@ -1876,18 +1857,28 @@ mod tests { assert!(tool_names.contains(&"export")); assert!(tool_names.contains(&"gc")); - // Auto-save & dedup tools (v1.3) + // Auto-save tool (v1.3) assert!(tool_names.contains(&"importance_score")); - assert!(tool_names.contains(&"find_duplicates")); - // Merge / Supersede controls (v2.1.25 — Phase 3) - assert!(tool_names.contains(&"merge_candidates")); - assert!(tool_names.contains(&"plan_merge")); - assert!(tool_names.contains(&"plan_supersede")); - assert!(tool_names.contains(&"apply_plan")); - assert!(tool_names.contains(&"merge_undo")); - assert!(tool_names.contains(&"protect")); - assert!(tool_names.contains(&"merge_policy")); + // Dedup / merge / supersede — unified `dedup` tool (v2.2). + // find_duplicates + the 7 Phase-3 merge tools folded in; still + // dispatchable as hidden back-compat aliases, but off the advertised list. + assert!(tool_names.contains(&"dedup")); + for old in [ + "find_duplicates", + "merge_candidates", + "plan_merge", + "plan_supersede", + "apply_plan", + "merge_undo", + "protect", + "merge_policy", + ] { + assert!( + !tool_names.contains(&old), + "{old} should be folded into 'dedup' in v2.2" + ); + } // Cognitive tools (v1.5) assert!(tool_names.contains(&"dream")); @@ -1912,6 +1903,44 @@ mod tests { assert!(tool_names.contains(&"suppress")); } + /// v2.2: the 8 tools folded into `dedup` must still dispatch (hidden + /// back-compat aliases), i.e. they must NOT return the "Unknown tool" + /// InvalidParams (-32602) error. Read-only/list-style actions are used so + /// the call resolves without mutating or requiring extra setup. + #[tokio::test] + async fn test_deprecated_dedup_aliases_redirect() { + let (mut server, _dir) = test_server().await; + let init_request = make_request("initialize", Some(init_params())); + server.handle_request(init_request).await; + + // (tool name, args) — all read-only / list-style so they resolve cleanly. + let calls: Vec<(&str, serde_json::Value)> = vec![ + ("find_duplicates", serde_json::json!({})), + ("merge_candidates", serde_json::json!({})), + ("merge_undo", serde_json::json!({})), // no operation_id => lists the reflog + ("merge_policy", serde_json::json!({})), // no args => returns current policy + ("dedup", serde_json::json!({"action": "policy"})), + ("dedup", serde_json::json!({})), // default action = scan + ]; + + for (name, args) in calls { + let request = make_request( + "tools/call", + Some(serde_json::json!({ "name": name, "arguments": args })), + ); + let response = server.handle_request(request).await.unwrap(); + // The call may succeed (result) or fail for a domain reason, but it + // must NOT be the unknown-tool InvalidParams error. + if let Some(err) = response.error { + assert_ne!( + err.code, -32602, + "'{name}' should still dispatch (hidden alias), got unknown-tool error: {}", + err.message + ); + } + } + } + #[tokio::test] async fn test_tools_have_descriptions_and_schemas() { let (mut server, _dir) = test_server().await; @@ -2130,6 +2159,8 @@ mod tests { "memory_timeline" => Some(200_000), "memory" => Some(100_000), "codebase" => Some(100_000), + // v2.2: dedup action='scan' returns clusters + candidates + policy. + "dedup" => Some(150_000), _ => None, } } @@ -2145,7 +2176,7 @@ mod tests { let result = response.result.unwrap(); let tools = result["tools"].as_array().unwrap(); - for name in ["search", "memory_timeline", "memory", "codebase"] { + for name in ["search", "memory_timeline", "memory", "codebase", "dedup"] { let tool = tools .iter() .find(|t| t["name"].as_str() == Some(name)) diff --git a/crates/vestige-mcp/src/tools/dedup.rs b/crates/vestige-mcp/src/tools/dedup.rs index ea3da25..45b7988 100644 --- a/crates/vestige-mcp/src/tools/dedup.rs +++ b/crates/vestige-mcp/src/tools/dedup.rs @@ -276,6 +276,99 @@ pub async fn execute(storage: &Arc, args: Option) -> Result Value { + serde_json::json!({ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["scan", "plan_merge", "plan_supersede", "apply", "undo", "protect", "policy"], + "default": "scan", + "description": "What to do. 'scan' (default): surface duplicate clusters (cosine) AND merge candidates (Fellegi-Sunter), read-only. 'plan_merge'/'plan_supersede': preview a reversible plan without applying (returns plan_id). 'apply': execute a plan_id. 'undo': reverse a prior operation (omit operation_id to list the reflog). 'protect': pin a memory against auto-merge/supersede/forget. 'policy': get/set Fellegi-Sunter thresholds." + }, + "similarity_threshold": { + "type": "number", + "description": "[scan] Minimum cosine similarity for duplicate clusters (0.5-1.0, default 0.80).", + "minimum": 0.5, "maximum": 1.0 + }, + "limit": { + "type": "integer", + "description": "[scan] Max clusters/candidates to return (default 20).", + "minimum": 1, "maximum": 100 + }, + "tags": { + "type": "array", "items": { "type": "string" }, + "description": "[scan] Optional: only consider memories with these tags (ANY match)." + }, + "member_ids": { + "type": "array", "items": { "type": "string" }, + "description": "[plan_merge] IDs of memories to merge (>= 2). Survivor kept; rest bitemporally invalidated." + }, + "survivor_id": { "type": "string", "description": "[plan_merge] Optional: which member to keep (defaults to highest-retention)." }, + "old_id": { "type": "string", "description": "[plan_supersede] Memory being superseded (kept, marked invalid)." }, + "new_id": { "type": "string", "description": "[plan_supersede] Memory that supersedes the old one." }, + "plan_id": { "type": "string", "description": "[apply] ID of a plan produced by plan_merge/plan_supersede." }, + "confirm": { "type": "boolean", "default": false, "description": "[apply] Required true for 'possible'/'non_match' plans." }, + "operation_id": { "type": "string", "description": "[undo] Operation to reverse. Omit to list the reflog." }, + "id": { "type": "string", "description": "[protect] Memory id to protect/unprotect." }, + "protected": { "type": "boolean", "default": true, "description": "[protect] true to pin, false to unpin." }, + "match_threshold": { "type": "number", "minimum": 0.0, "maximum": 1.0, "description": "[policy] Score >= this => 'match'." }, + "possible_threshold": { "type": "number", "minimum": 0.0, "maximum": 1.0, "description": "[policy] Score in [possible, match) => review." }, + "auto_apply": { "type": "boolean", "description": "[policy] Allow 'match' plans to apply without confirm. Default false." } + } + }) +} + +/// Unified dispatcher for the `dedup` tool. Routes on `action` (default `scan`). +pub async fn execute_unified(storage: &Arc, args: Option) -> Result { + let action = args + .as_ref() + .and_then(|a| a.get("action")) + .and_then(|v| v.as_str()) + .unwrap_or("scan") + .to_string(); + + match action.as_str() { + "scan" => { + // Cosine-similarity duplicate clusters (this module). + let clusters = execute(storage, args.clone()).await?; + // Fellegi-Sunter merge candidates (merge module, name-dispatched). + let candidates = + super::merge::execute(storage, "merge_candidates", args.clone()).await?; + Ok(serde_json::json!({ + "action": "scan", + "duplicateClusters": clusters, + "mergeCandidates": candidates, + "nextStep": "Use action='plan_merge' (member_ids) or action='plan_supersede' (old_id,new_id) to preview a reversible plan, then action='apply' (plan_id)." + })) + } + "plan_merge" => super::merge::execute(storage, "plan_merge", args).await, + "plan_supersede" => super::merge::execute(storage, "plan_supersede", args).await, + "apply" => super::merge::execute(storage, "apply_plan", args).await, + "undo" => super::merge::execute(storage, "merge_undo", args).await, + "protect" => super::merge::execute(storage, "protect", args).await, + "policy" => super::merge::execute(storage, "merge_policy", args).await, + other => Err(format!( + "Unknown dedup action '{other}'. Use scan|plan_merge|plan_supersede|apply|undo|protect|policy." + )), + } +} + #[cfg(test)] mod tests { use super::*; @@ -287,6 +380,25 @@ mod tests { assert!(schema["properties"]["similarity_threshold"].is_object()); } + #[test] + fn test_unified_schema() { + let schema = unified_schema(); + assert_eq!(schema["type"], "object"); + let actions = schema["properties"]["action"]["enum"].as_array().unwrap(); + assert_eq!(actions.len(), 7); + assert_eq!(schema["properties"]["action"]["default"], "scan"); + } + + #[tokio::test] + async fn test_unified_scan_empty_storage() { + let dir = tempfile::TempDir::new().unwrap(); + let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap(); + let storage = Arc::new(storage); + // Default action (scan) on empty storage must not error. + let result = execute_unified(&storage, None).await; + assert!(result.is_ok()); + } + #[test] #[cfg(all(feature = "embeddings", feature = "vector-search"))] fn test_union_find() { From 8888634740fb8571ca038ffe6650687b423e4cd8 Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Sun, 28 Jun 2026 17:31:07 -0500 Subject: [PATCH 4/9] =?UTF-8?q?feat(mcp):=20rename=20`session=5Fcontext`?= =?UTF-8?q?=20=E2=86=92=20`session=5Fstart`=20(v2.2=20consolidation=202/6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool Consolidation v2.2.0, Layer 1 commit 2/6. Advertised count unchanged at 27 (pure rename). `session_start` is the imperative-outcome name for the one-call session initializer. `session_context` remains a hidden warn!+redirect alias (≥1 minor release, removed v2.3.0), calling the same handler unchanged. Tests: positive assert swapped to session_start + negative assert for the old name. Gates: cargo test --workspace, cargo clippy -D warnings — clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/vestige-mcp/src/server.rs | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/vestige-mcp/src/server.rs b/crates/vestige-mcp/src/server.rs index e77b3ef..2fb0d54 100644 --- a/crates/vestige-mcp/src/server.rs +++ b/crates/vestige-mcp/src/server.rs @@ -392,8 +392,8 @@ impl McpServer { // CONTEXT PACKETS (v1.8+) // ================================================================ ToolDescription { - name: "session_context".to_string(), - description: Some("One-call session initialization. Combines search, intentions, status, predictions, and codebase context into a single token-budgeted response. Replaces 5 separate calls at session start.".to_string()), + name: "session_start".to_string(), + description: Some("One-call session initialization. Combines search, intentions, status, predictions, and codebase context into a single token-budgeted response. Call this once at the start of a session instead of 5 separate calls. (Renamed from 'session_context' in v2.2.)".to_string()), input_schema: tools::session_context::schema(), ..Default::default() }, @@ -1017,9 +1017,20 @@ impl McpServer { "restore" => tools::restore::execute(&self.storage, request.arguments).await, // ================================================================ - // CONTEXT PACKETS (v1.8+) + // CONTEXT PACKETS (v1.8+) — `session_start` (renamed v2.2) // ================================================================ + "session_start" => { + tools::session_context::execute( + &self.storage, + &self.cognitive, + &self.output_config, + request.arguments, + ) + .await + } + // DEPRECATED (v2.2): renamed to `session_start`. Hidden alias. "session_context" => { + warn!("Tool 'session_context' is deprecated in v2.2. Use 'session_start'."); tools::session_context::execute( &self.storage, &self.cognitive, @@ -1886,8 +1897,12 @@ mod tests { assert!(tool_names.contains(&"predict")); assert!(tool_names.contains(&"restore")); - // Context packets (v1.8) - assert!(tool_names.contains(&"session_context")); + // Context packets (v1.8) — renamed session_context → session_start (v2.2) + assert!(tool_names.contains(&"session_start")); + assert!( + !tool_names.contains(&"session_context"), + "session_context renamed to 'session_start' in v2.2" + ); // Autonomic tools (v1.9) assert!(tool_names.contains(&"memory_health")); From 32e6a6cd8d75361fe2ec9e1dae4c22530e47ef16 Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Sun, 28 Jun 2026 17:37:57 -0500 Subject: [PATCH 5/9] =?UTF-8?q?feat(mcp):=20consolidate=20status/temporal?= =?UTF-8?q?=20into=20`memory=5Fstatus`=20(4=E2=86=921)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool Consolidation v2.2.0, Layer 1 commit 3/6 (3a). Advertised tools 27 → 24. Folds system_status + memory_health + memory_timeline + memory_changelog into one view-dispatched tool: view = health (default) | retention | timeline | changelog - Thin facade: each view forwards the same args envelope to the existing handler. No underlying arg struct uses deny_unknown_fields, so the `view` discriminator is ignored by each handler and per-view fields validate as before. The cognitive lock is never held across a forwarded call. - view='health' returns the byte-for-byte system_status shape (audit scripts parse it), incl. schema_introspection passthrough — verified by test_default_view_is_health asserting equality with execute_system_status. - All 4 old names remain hidden warn!+redirect aliases (removed v2.3.0). - Size annotation moved: memory_timeline (200k) → memory_status, kept in sync across the real loop, expected_max_result_size(), and both annotation tests. - Tests: count 27→24, 4 negative asserts, test_memory_status_views_and_aliases exercising all 4 views + 4 aliases. Gates: cargo test --workspace, cargo clippy -D warnings — clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/vestige-mcp/src/server.rs | 133 ++++++++++++----- crates/vestige-mcp/src/tools/memory_status.rs | 141 ++++++++++++++++++ crates/vestige-mcp/src/tools/mod.rs | 4 + 3 files changed, 240 insertions(+), 38 deletions(-) create mode 100644 crates/vestige-mcp/src/tools/memory_status.rs diff --git a/crates/vestige-mcp/src/server.rs b/crates/vestige-mcp/src/server.rs index 2fb0d54..979f8d9 100644 --- a/crates/vestige-mcp/src/server.rs +++ b/crates/vestige-mcp/src/server.rs @@ -290,29 +290,19 @@ impl McpServer { ..Default::default() }, // ================================================================ - // TEMPORAL TOOLS (v1.2+) + // STATUS / TEMPORAL — unified `memory_status` tool (v2.2) + // Folds system_status + memory_health + memory_timeline + + // memory_changelog into one view-dispatched surface. // ================================================================ ToolDescription { - name: "memory_timeline".to_string(), - description: Some("Browse memories chronologically. Returns memories in a time range, grouped by day. Defaults to last 7 days.".to_string()), - input_schema: tools::timeline::schema(), - ..Default::default() - }, - ToolDescription { - name: "memory_changelog".to_string(), - description: Some("View audit trail of memory changes. Per-memory: state transitions. System-wide: consolidations + recent state changes.".to_string()), - input_schema: tools::changelog::schema(), + name: "memory_status".to_string(), + description: Some("Memory status & history. Views: 'health' (default — full system health + stats + FSRS preview + cognitive-module health + warnings + recommendations), 'retention' (lightweight retention dashboard: avg, distribution, trend), 'timeline' (browse memories chronologically, grouped by day), 'changelog' (audit trail of memory state changes — per-memory transitions or system-wide).".to_string()), + input_schema: tools::memory_status::schema(), ..Default::default() }, // ================================================================ // MAINTENANCE TOOLS (v1.7: system_status replaces health_check + stats) // ================================================================ - ToolDescription { - name: "system_status".to_string(), - description: Some("Combined system health and statistics. Returns status (healthy/degraded/critical/empty), full stats, FSRS preview, cognitive module health, state distribution, warnings, and recommendations.".to_string()), - input_schema: tools::maintenance::system_status_schema(), - ..Default::default() - }, ToolDescription { name: "consolidate".to_string(), description: Some("Run FSRS-6 memory consolidation cycle. Applies decay, generates embeddings, and performs maintenance. Use when memories seem stale.".to_string()), @@ -399,13 +389,8 @@ impl McpServer { }, // ================================================================ // AUTONOMIC TOOLS (v1.9+) + // (memory_health folded into `memory_status` view='retention' in v2.2) // ================================================================ - ToolDescription { - name: "memory_health".to_string(), - description: Some("Retention dashboard. Returns avg retention, retention distribution (buckets: 0-20%, 20-40%, etc.), trend (improving/declining/stable), and recommendation. Lightweight alternative to full system_status focused on memory quality.".to_string()), - input_schema: tools::health::schema(), - ..Default::default() - }, ToolDescription { name: "memory_graph".to_string(), description: Some("Subgraph export for visualization. Input: center_id or query, depth (1-3), max_nodes. Returns nodes with force-directed layout positions and edges with weights. Powers memory graph visualization.".to_string()), @@ -473,7 +458,7 @@ impl McpServer { for tool in tools.iter_mut() { let max_chars: Option = match tool.name.as_str() { "search" => Some(300_000), - "memory_timeline" => Some(200_000), + "memory_status" => Some(200_000), "memory" => Some(100_000), "codebase" => Some(100_000), // v2.2: dedup action='scan' returns duplicate clusters + @@ -649,9 +634,23 @@ impl McpServer { } // ================================================================ - // SYSTEM STATUS (v1.7: replaces health_check + stats) + // MEMORY STATUS — unified status/temporal tool (v2.2) + // view = health (default) | retention | timeline | changelog // ================================================================ + "memory_status" => { + tools::memory_status::execute( + &self.storage, + &self.cognitive, + &self.output_config, + request.arguments, + ) + .await + } + + // DEPRECATED (v2.2): folded into `memory_status`. Hidden aliases — + // each calls the same underlying handler verbatim. "system_status" => { + warn!("Tool 'system_status' is deprecated in v2.2. Use 'memory_status' (view='health')."); tools::maintenance::execute_system_status( &self.storage, &self.cognitive, @@ -939,13 +938,18 @@ impl McpServer { } // ================================================================ - // TEMPORAL TOOLS (v1.2+) + // TEMPORAL TOOLS (v1.2+) — DEPRECATED (v2.2): folded into + // `memory_status` (view='timeline' / view='changelog'). Hidden aliases. // ================================================================ "memory_timeline" => { + warn!("Tool 'memory_timeline' is deprecated in v2.2. Use 'memory_status' (view='timeline')."); tools::timeline::execute(&self.storage, &self.output_config, request.arguments) .await } - "memory_changelog" => tools::changelog::execute(&self.storage, request.arguments).await, + "memory_changelog" => { + warn!("Tool 'memory_changelog' is deprecated in v2.2. Use 'memory_status' (view='changelog')."); + tools::changelog::execute(&self.storage, request.arguments).await + } // ================================================================ // MAINTENANCE TOOLS (v1.2+, non-deprecated) @@ -1043,7 +1047,11 @@ impl McpServer { // ================================================================ // AUTONOMIC TOOLS (v1.9+) // ================================================================ - "memory_health" => tools::health::execute(&self.storage, request.arguments).await, + // DEPRECATED (v2.2): folded into `memory_status` (view='retention'). + "memory_health" => { + warn!("Tool 'memory_health' is deprecated in v2.2. Use 'memory_status' (view='retention')."); + tools::health::execute(&self.storage, request.arguments).await + } "memory_graph" => tools::graph::execute(&self.storage, request.arguments).await, "composed_graph" => { tools::composed_graph::execute(&self.storage, request.arguments).await @@ -1815,7 +1823,11 @@ mod tests { // v2.2 Tool Consolidation (Layer 1): 34 → 27 after `dedup` folds // find_duplicates + the 7 Phase-3 merge tools (8 → 1). Old names remain // dispatchable as hidden back-compat aliases but drop off the advertised list. - assert_eq!(tools.len(), 27, "Expected exactly 27 tools after dedup consolidation"); + assert_eq!( + tools.len(), + 24, + "Expected exactly 24 tools after dedup + memory_status consolidation" + ); let tool_names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); @@ -1849,12 +1861,21 @@ mod tests { "demote_memory should be removed in v1.7" ); - // Temporal tools (v1.2) - assert!(tool_names.contains(&"memory_timeline")); - assert!(tool_names.contains(&"memory_changelog")); - - // Maintenance tools (v1.7: system_status replaces health_check + stats) - assert!(tool_names.contains(&"system_status")); + // Status / temporal — unified `memory_status` tool (v2.2). + // system_status + memory_health + memory_timeline + memory_changelog + // folded in; old names dispatch as hidden aliases but are off the list. + assert!(tool_names.contains(&"memory_status")); + for old in [ + "system_status", + "memory_health", + "memory_timeline", + "memory_changelog", + ] { + assert!( + !tool_names.contains(&old), + "{old} should be folded into 'memory_status' in v2.2" + ); + } assert!( !tool_names.contains(&"health_check"), "health_check should be removed in v1.7" @@ -1904,8 +1925,7 @@ mod tests { "session_context renamed to 'session_start' in v2.2" ); - // Autonomic tools (v1.9) - assert!(tool_names.contains(&"memory_health")); + // Autonomic tools (v1.9) — memory_health folded into memory_status (v2.2) assert!(tool_names.contains(&"memory_graph")); assert!(tool_names.contains(&"composed_graph")); @@ -1956,6 +1976,41 @@ mod tests { } } + /// v2.2: the 4 tools folded into `memory_status` must still dispatch, and + /// each `view` of the new tool must resolve. + #[tokio::test] + async fn test_memory_status_views_and_aliases() { + let (mut server, _dir) = test_server().await; + let init_request = make_request("initialize", Some(init_params())); + server.handle_request(init_request).await; + + let calls: Vec<(&str, serde_json::Value)> = vec![ + // Deprecated aliases must still dispatch. + ("system_status", serde_json::json!({})), + ("memory_health", serde_json::json!({})), + ("memory_timeline", serde_json::json!({})), + ("memory_changelog", serde_json::json!({})), + // New unified views. + ("memory_status", serde_json::json!({})), // default view = health + ("memory_status", serde_json::json!({"view": "retention"})), + ("memory_status", serde_json::json!({"view": "timeline"})), + ("memory_status", serde_json::json!({"view": "changelog"})), + ]; + + for (name, args) in calls { + let request = make_request( + "tools/call", + Some(serde_json::json!({ "name": name, "arguments": args })), + ); + let response = server.handle_request(request).await.unwrap(); + assert!( + response.error.is_none(), + "'{name}' {args} should resolve, got error: {:?}", + response.error + ); + } + } + #[tokio::test] async fn test_tools_have_descriptions_and_schemas() { let (mut server, _dir) = test_server().await; @@ -2171,7 +2226,9 @@ mod tests { fn expected_max_result_size(name: &str) -> Option { match name { "search" => Some(300_000), - "memory_timeline" => Some(200_000), + // v2.2: memory_timeline folded into memory_status (view='timeline'); + // the high-payload annotation moved with it. + "memory_status" => Some(200_000), "memory" => Some(100_000), "codebase" => Some(100_000), // v2.2: dedup action='scan' returns clusters + candidates + policy. @@ -2191,7 +2248,7 @@ mod tests { let result = response.result.unwrap(); let tools = result["tools"].as_array().unwrap(); - for name in ["search", "memory_timeline", "memory", "codebase", "dedup"] { + for name in ["search", "memory_status", "memory", "codebase", "dedup"] { let tool = tools .iter() .find(|t| t["name"].as_str() == Some(name)) diff --git a/crates/vestige-mcp/src/tools/memory_status.rs b/crates/vestige-mcp/src/tools/memory_status.rs new file mode 100644 index 0000000..61a6c58 --- /dev/null +++ b/crates/vestige-mcp/src/tools/memory_status.rs @@ -0,0 +1,141 @@ +//! Unified `memory_status` Tool (v2.2 — Tool Consolidation) +//! +//! Folds four read-only status/health/temporal tools into one +//! view-dispatched surface: +//! +//! view = health (default) | retention | timeline | changelog +//! +//! - `health` → full system health + statistics (the former `system_status`). +//! Returns the byte-for-byte `system_status` shape (audit scripts parse it), +//! including `schema_introspection` passthrough. +//! - `retention` → the lightweight retention dashboard (former `memory_health`). +//! - `timeline` → chronological browse (former `memory_timeline`). +//! - `changelog` → audit trail of memory changes (former `memory_changelog`). +//! +//! This is a thin facade: each view forwards the *same* args envelope to the +//! existing handler. None of the underlying arg structs use +//! `deny_unknown_fields`, so the discriminator `view` is simply ignored by each +//! handler — no lossy re-scoping required, and per-view fields validate as +//! before. The `cognitive` lock is never held across a forwarded call. + +use serde_json::Value; +use std::sync::Arc; +use tokio::sync::Mutex; + +use vestige_core::{OutputConfig, Storage}; + +use crate::cognitive::CognitiveEngine; + +/// Discriminated-union schema for the unified `memory_status` tool. +pub fn schema() -> Value { + serde_json::json!({ + "type": "object", + "properties": { + "view": { + "type": "string", + "enum": ["health", "retention", "timeline", "changelog"], + "default": "health", + "description": "Which status view. 'health' (default): full system health + stats + FSRS preview + warnings + recommendations. 'retention': lightweight retention dashboard (avg/distribution/trend). 'timeline': browse memories chronologically. 'changelog': audit trail of memory state changes." + }, + // --- [health view] --- + "schema_introspection": { + "type": "boolean", + "description": "[health view] Include the response-schema description in the output." + }, + // --- [timeline view] --- + "start": { "type": "string", "description": "[timeline/changelog view] Start of range (ISO 8601 date or datetime)." }, + "end": { "type": "string", "description": "[timeline/changelog view] End of range (ISO 8601 date or datetime)." }, + "node_type": { "type": "string", "description": "[timeline view] Filter by node type (e.g. 'fact', 'decision')." }, + "tags": { "type": "array", "items": { "type": "string" }, "description": "[timeline view] Filter by tags (ANY match)." }, + "detail_level": { + "type": "string", "enum": ["brief", "summary", "full"], + "description": "[timeline view] Level of detail (default 'summary')." + }, + // --- [changelog view] --- + "memory_id": { "type": "string", "description": "[changelog view] Per-memory mode: state transitions for this memory id." }, + // --- shared: limit (per-view ranges differ; clamped internally) --- + "limit": { + "type": "integer", + "description": "Max results. [timeline] default 50, max 200. [changelog] default 20, clamped to 100. Ignored by health/retention.", + "minimum": 1, "maximum": 200 + } + } + }) +} + +/// Unified dispatcher for `memory_status`. Routes on `view` (default `health`). +pub async fn execute( + storage: &Arc, + cognitive: &Arc>, + output_config: &OutputConfig, + args: Option, +) -> Result { + let view = args + .as_ref() + .and_then(|a| a.get("view")) + .and_then(|v| v.as_str()) + .unwrap_or("health") + .to_string(); + + match view.as_str() { + // Byte-for-byte system_status shape (incl. schema_introspection passthrough). + "health" => super::maintenance::execute_system_status(storage, cognitive, args).await, + "retention" => super::health::execute(storage, args).await, + "timeline" => super::timeline::execute(storage, output_config, args).await, + "changelog" => super::changelog::execute(storage, args).await, + other => Err(format!( + "Unknown memory_status view '{other}'. Use health|retention|timeline|changelog." + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cognitive::CognitiveEngine; + + fn test_storage() -> Arc { + let dir = tempfile::TempDir::new().unwrap(); + let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap(); + // Keep the tempdir alive for the duration of the process by leaking it; + // these are short-lived unit tests. + std::mem::forget(dir); + Arc::new(storage) + } + + #[test] + fn test_schema_views() { + let s = schema(); + let views = s["properties"]["view"]["enum"].as_array().unwrap(); + assert_eq!(views.len(), 4); + assert_eq!(s["properties"]["view"]["default"], "health"); + } + + #[tokio::test] + async fn test_default_view_is_health() { + let storage = test_storage(); + let cognitive = Arc::new(Mutex::new(CognitiveEngine::new())); + let oc = OutputConfig::default(); + // No args → health view → must match system_status output exactly. + let unified = execute(&storage, &cognitive, &oc, None).await.unwrap(); + let direct = super::super::maintenance::execute_system_status(&storage, &cognitive, None) + .await + .unwrap(); + assert_eq!( + unified, direct, + "memory_status view=health must equal system_status byte-for-byte" + ); + } + + #[tokio::test] + async fn test_all_views_resolve() { + let storage = test_storage(); + let cognitive = Arc::new(Mutex::new(CognitiveEngine::new())); + let oc = OutputConfig::default(); + for view in ["health", "retention", "timeline", "changelog"] { + let args = Some(serde_json::json!({ "view": view })); + let r = execute(&storage, &cognitive, &oc, args).await; + assert!(r.is_ok(), "view={view} should resolve, got {r:?}"); + } + } +} diff --git a/crates/vestige-mcp/src/tools/mod.rs b/crates/vestige-mcp/src/tools/mod.rs index f145caf..4f2043d 100644 --- a/crates/vestige-mcp/src/tools/mod.rs +++ b/crates/vestige-mcp/src/tools/mod.rs @@ -22,6 +22,10 @@ pub mod timeline; // v1.2: Maintenance tools pub mod maintenance; +// v2.2: Unified status surface — folds system_status + memory_health + +// memory_timeline + memory_changelog into one view-dispatched tool. +pub mod memory_status; + // v1.3: Auto-save and dedup tools pub mod dedup; pub mod importance; From e3378316edd02a74c9e9d253e539fccd291b6975 Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Sun, 28 Jun 2026 17:45:33 -0500 Subject: [PATCH 6/9] =?UTF-8?q?feat(mcp):=20consolidate=20graph/assoc/pred?= =?UTF-8?q?ict=20into=20`graph`=20(4=E2=86=921)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool Consolidation v2.2.0, Layer 1 commit 4/6 (3b). Advertised tools 24 → 21. Folds explore_connections + predict + memory_graph + composed_graph into one action-dispatched tool: action ∈ {chain, associations, bridges, predict, memory_graph, recent, get, memory, neighbors, never_composed, bounty_mode, label} - Transparent facade: each action forwards the same args envelope to the existing handler, which re-reads its own discriminator/params. No underlying arg struct uses deny_unknown_fields, so cross-fields are ignored. - All actions read-only except `label` (the one mutator), which is logged for audit via tracing::info!(event_id, outcome_type). - outcome_type enum sourced from composed_graph::OUTCOME_TYPES (now pub(crate)) rather than re-listed, so the vocabulary stays single-sourced. - All 4 old names remain hidden warn!+redirect aliases (removed v2.3.0). - Size annotation: graph=250_000 (memory_graph layout + bounty_mode pagination), kept in sync across loop, helper, and both annotation tests. - Tests: count 24→21, 4 negatives, test_graph_actions_and_aliases exercising read-only actions + aliases (incl. the memory_graph facade branch). Note: the design draft mislabeled graph::execute as sync; it is `pub async fn`. The per-commit build gate caught it — the facade awaits it correctly. Gates: cargo test --workspace, cargo clippy -D warnings — clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/vestige-mcp/src/server.rs | 117 +++++++++++---- .../vestige-mcp/src/tools/composed_graph.rs | 2 +- crates/vestige-mcp/src/tools/graph_unified.rs | 140 ++++++++++++++++++ crates/vestige-mcp/src/tools/mod.rs | 4 + 4 files changed, 231 insertions(+), 32 deletions(-) create mode 100644 crates/vestige-mcp/src/tools/graph_unified.rs diff --git a/crates/vestige-mcp/src/server.rs b/crates/vestige-mcp/src/server.rs index 979f8d9..65c78f0 100644 --- a/crates/vestige-mcp/src/server.rs +++ b/crates/vestige-mcp/src/server.rs @@ -357,16 +357,14 @@ impl McpServer { input_schema: tools::dream::schema(), ..Default::default() }, + // ================================================================ + // GRAPH — unified graph/association/prediction tool (v2.2) + // Folds explore_connections + predict + memory_graph + composed_graph. + // ================================================================ ToolDescription { - name: "explore_connections".to_string(), - description: Some("Graph exploration tool for memory connections. Actions: 'chain' (build reasoning path between memories), 'associations' (find related memories via spreading activation + hippocampal index), 'bridges' (find connecting memories between two nodes).".to_string()), - input_schema: tools::explore::schema(), - ..Default::default() - }, - ToolDescription { - name: "predict".to_string(), - description: Some("Proactive memory prediction — predicts what memories you'll need next based on context, recent activity, and learned patterns. Returns predictions, suggestions, and speculative retrievals.".to_string()), - input_schema: tools::predict::schema(), + name: "graph".to_string(), + description: Some("Memory graph & associations. Actions: 'chain' (reasoning path from→to), 'associations' (related memories via spreading activation, needs 'from'), 'bridges' (connectors between from/to), 'predict' (what memories you'll need next, from 'context'), 'memory_graph' (force-directed subgraph for viz, from center_id or query), 'recent'/'get'/'memory'/'neighbors'/'never_composed'/'bounty_mode' (composition topology), 'label' (record a composition outcome — the only write).".to_string()), + input_schema: tools::graph_unified::schema(), ..Default::default() }, // ================================================================ @@ -389,20 +387,9 @@ impl McpServer { }, // ================================================================ // AUTONOMIC TOOLS (v1.9+) - // (memory_health folded into `memory_status` view='retention' in v2.2) + // (memory_health → `memory_status` view='retention'; + // memory_graph + composed_graph → `graph`, all in v2.2) // ================================================================ - ToolDescription { - name: "memory_graph".to_string(), - description: Some("Subgraph export for visualization. Input: center_id or query, depth (1-3), max_nodes. Returns nodes with force-directed layout positions and edges with weights. Powers memory graph visualization.".to_string()), - input_schema: tools::graph::schema(), - ..Default::default() - }, - ToolDescription { - name: "composed_graph".to_string(), - description: Some("ComposedGraph memory topology. Reads durable composition events, members, and outcome labels; returns recent/already-composed lanes, neighbors, never-composed pairs, bounty-mode lanes, and lets users label outcomes such as helpful, submitted, accepted, rejected, duplicate_risk, needs_poc, or dead_end.".to_string()), - input_schema: tools::composed_graph::schema(), - ..Default::default() - }, // ================================================================ // DEEP REFERENCE (v2.0.4+) — replaces cross_reference // ================================================================ @@ -464,6 +451,9 @@ impl McpServer { // v2.2: dedup action='scan' returns duplicate clusters + // merge candidates + policy in one payload. "dedup" => Some(150_000), + // v2.2: graph action='memory_graph' (force-directed layout) and + // 'bounty_mode' pagination can both produce large payloads. + "graph" => Some(250_000), _ => None, }; if let Some(n) = max_chars { @@ -1012,10 +1002,20 @@ impl McpServer { }); tools::dream::execute(&self.storage, &self.cognitive, request.arguments).await } + // ================================================================ + // GRAPH — unified graph/association/prediction tool (v2.2) + // ================================================================ + "graph" => { + tools::graph_unified::execute(&self.storage, &self.cognitive, request.arguments) + .await + } + // DEPRECATED (v2.2): folded into `graph`. Hidden aliases. "explore_connections" => { + warn!("Tool 'explore_connections' is deprecated in v2.2. Use 'graph' (action='chain'|'associations'|'bridges')."); tools::explore::execute(&self.storage, &self.cognitive, request.arguments).await } "predict" => { + warn!("Tool 'predict' is deprecated in v2.2. Use 'graph' (action='predict')."); tools::predict::execute(&self.storage, &self.cognitive, request.arguments).await } "restore" => tools::restore::execute(&self.storage, request.arguments).await, @@ -1052,8 +1052,13 @@ impl McpServer { warn!("Tool 'memory_health' is deprecated in v2.2. Use 'memory_status' (view='retention')."); tools::health::execute(&self.storage, request.arguments).await } - "memory_graph" => tools::graph::execute(&self.storage, request.arguments).await, + // DEPRECATED (v2.2): folded into `graph`. Hidden aliases. + "memory_graph" => { + warn!("Tool 'memory_graph' is deprecated in v2.2. Use 'graph' (action='memory_graph')."); + tools::graph::execute(&self.storage, request.arguments).await + } "composed_graph" => { + warn!("Tool 'composed_graph' is deprecated in v2.2. Use 'graph' (action='recent'|'get'|'memory'|'neighbors'|'never_composed'|'bounty_mode'|'label')."); tools::composed_graph::execute(&self.storage, request.arguments).await } "deep_reference" | "cross_reference" => { @@ -1825,8 +1830,8 @@ mod tests { // dispatchable as hidden back-compat aliases but drop off the advertised list. assert_eq!( tools.len(), - 24, - "Expected exactly 24 tools after dedup + memory_status consolidation" + 21, + "Expected exactly 21 tools after dedup + memory_status + graph consolidation" ); let tool_names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); @@ -1913,9 +1918,8 @@ mod tests { } // Cognitive tools (v1.5) + // (explore_connections + predict folded into `graph` in v2.2) assert!(tool_names.contains(&"dream")); - assert!(tool_names.contains(&"explore_connections")); - assert!(tool_names.contains(&"predict")); assert!(tool_names.contains(&"restore")); // Context packets (v1.8) — renamed session_context → session_start (v2.2) @@ -1925,9 +1929,21 @@ mod tests { "session_context renamed to 'session_start' in v2.2" ); - // Autonomic tools (v1.9) — memory_health folded into memory_status (v2.2) - assert!(tool_names.contains(&"memory_graph")); - assert!(tool_names.contains(&"composed_graph")); + // Graph — unified `graph` tool (v2.2). explore_connections + predict + + // memory_graph + composed_graph folded in; old names dispatch as hidden + // aliases but are off the advertised list. (memory_health → memory_status.) + assert!(tool_names.contains(&"graph")); + for old in [ + "explore_connections", + "predict", + "memory_graph", + "composed_graph", + ] { + assert!( + !tool_names.contains(&old), + "{old} should be folded into 'graph' in v2.2" + ); + } // Deep reference + cross_reference alias (v2.0.4) assert!(tool_names.contains(&"deep_reference")); @@ -2011,6 +2027,43 @@ mod tests { } } + /// v2.2: the 4 tools folded into `graph` must still dispatch, and the + /// read-only `graph` actions must resolve. (memory_graph is sync — this also + /// guards the no-`.await` facade branch.) + #[tokio::test] + async fn test_graph_actions_and_aliases() { + let (mut server, _dir) = test_server().await; + let init_request = make_request("initialize", Some(init_params())); + server.handle_request(init_request).await; + + let calls: Vec<(&str, serde_json::Value)> = vec![ + // Deprecated aliases must still dispatch (not unknown-tool). + ("predict", serde_json::json!({})), + ("memory_graph", serde_json::json!({})), + ("composed_graph", serde_json::json!({"action": "recent"})), + // New unified actions (read-only). + ("graph", serde_json::json!({"action": "predict"})), + ("graph", serde_json::json!({"action": "memory_graph"})), + ("graph", serde_json::json!({"action": "recent"})), + ("graph", serde_json::json!({"action": "never_composed"})), + ]; + + for (name, args) in calls { + let request = make_request( + "tools/call", + Some(serde_json::json!({ "name": name, "arguments": args })), + ); + let response = server.handle_request(request).await.unwrap(); + if let Some(err) = response.error { + assert_ne!( + err.code, -32602, + "'{name}' {args} should dispatch (not unknown-tool): {}", + err.message + ); + } + } + } + #[tokio::test] async fn test_tools_have_descriptions_and_schemas() { let (mut server, _dir) = test_server().await; @@ -2233,6 +2286,8 @@ mod tests { "codebase" => Some(100_000), // v2.2: dedup action='scan' returns clusters + candidates + policy. "dedup" => Some(150_000), + // v2.2: graph memory_graph layout + bounty_mode pagination. + "graph" => Some(250_000), _ => None, } } @@ -2248,7 +2303,7 @@ mod tests { let result = response.result.unwrap(); let tools = result["tools"].as_array().unwrap(); - for name in ["search", "memory_status", "memory", "codebase", "dedup"] { + for name in ["search", "memory_status", "memory", "codebase", "dedup", "graph"] { let tool = tools .iter() .find(|t| t["name"].as_str() == Some(name)) diff --git a/crates/vestige-mcp/src/tools/composed_graph.rs b/crates/vestige-mcp/src/tools/composed_graph.rs index 957f8e8..58c18dd 100644 --- a/crates/vestige-mcp/src/tools/composed_graph.rs +++ b/crates/vestige-mcp/src/tools/composed_graph.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use uuid::Uuid; use vestige_core::{CompositionOutcomeRecord, Storage}; -const OUTCOME_TYPES: &[&str] = &[ +pub(crate) const OUTCOME_TYPES: &[&str] = &[ "helpful", "dead_end", "submitted", diff --git a/crates/vestige-mcp/src/tools/graph_unified.rs b/crates/vestige-mcp/src/tools/graph_unified.rs new file mode 100644 index 0000000..ab8a4dc --- /dev/null +++ b/crates/vestige-mcp/src/tools/graph_unified.rs @@ -0,0 +1,140 @@ +//! Unified `graph` Tool (v2.2 — Tool Consolidation) +//! +//! Folds four graph/association/prediction tools into one action-dispatched +//! surface: +//! +//! action ∈ { +//! chain, associations, bridges, // former explore_connections +//! predict, // former predict +//! memory_graph, // former memory_graph (viz subgraph) +//! recent, get, memory, neighbors, // former composed_graph +//! never_composed, bounty_mode, label, // " +//! } +//! +//! This is a transparent facade: each action forwards the *same* args envelope +//! to the existing handler, which re-reads its own discriminator/params. None of +//! the underlying arg structs use `deny_unknown_fields`, so unrelated fields are +//! ignored. All actions are read-only EXCEPT `label`, which writes a composition +//! outcome (the one mutator) and is logged for audit. + +use serde_json::Value; +use std::sync::Arc; +use tokio::sync::Mutex; + +use vestige_core::Storage; + +use crate::cognitive::CognitiveEngine; +// Reuse composed_graph's canonical outcome-label vocabulary (do not re-list). +use super::composed_graph::OUTCOME_TYPES; + +/// Discriminated-union schema for the unified `graph` tool. +pub fn schema() -> Value { + serde_json::json!({ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "chain", "associations", "bridges", + "predict", "memory_graph", + "recent", "get", "memory", "neighbors", + "never_composed", "bounty_mode", "label" + ], + "description": "Graph operation. Reasoning paths: 'chain' (from→to), 'associations' (related via spreading activation, needs 'from'), 'bridges' (connectors between from/to). 'predict' (what memories you'll need next, from 'context'). 'memory_graph' (force-directed subgraph for viz, from 'center_id' or 'query'). Composition topology: 'recent', 'get' (event_id), 'memory' (memory_id), 'neighbors' (memory_id), 'never_composed', 'bounty_mode', 'label' (record an outcome — the only write)." + }, + // --- explore (chain/associations/bridges) --- + "from": { "type": "string", "description": "[chain/associations/bridges] Source memory ID." }, + "to": { "type": "string", "description": "[chain/bridges] Target memory ID." }, + // --- predict --- + "context": { "type": "object", "description": "[predict] Current context (current_file, current_topics, codebase)." }, + // --- memory_graph (viz subgraph) --- + "center_id": { "type": "string", "description": "[memory_graph] Center node id (or use 'query')." }, + "query": { "type": "string", "description": "[memory_graph] Pick a center node by search query." }, + "depth": { "type": "integer", "minimum": 1, "maximum": 3, "description": "[memory_graph] Traversal depth (1-3, default 2)." }, + "max_nodes": { "type": "integer", "description": "[memory_graph] Max nodes (default 50, capped 200)." }, + // --- composed_graph --- + "event_id": { "type": "string", "description": "[get/label] Composition event id." }, + "memory_id": { "type": "string", "description": "[memory/neighbors] Memory id." }, + "tags": { "type": "array", "items": { "type": "string" }, "description": "[never_composed/bounty_mode] Optional tag filter." }, + "outcome_type": { + "type": "string", + "enum": OUTCOME_TYPES, + "description": "[label] Outcome to record for the composition (the only mutating action)." + }, + // --- shared --- + "limit": { "type": "integer", "description": "Max results (per-action defaults; clamped internally).", "minimum": 1, "maximum": 100 } + }, + "required": ["action"] + }) +} + +/// Unified dispatcher for `graph`. Routes on `action`. +pub async fn execute( + storage: &Arc, + cognitive: &Arc>, + args: Option, +) -> Result { + let action = args + .as_ref() + .and_then(|a| a.get("action")) + .and_then(|v| v.as_str()) + .ok_or("Missing 'action'. Use chain|associations|bridges|predict|memory_graph|recent|get|memory|neighbors|never_composed|bounty_mode|label.")? + .to_string(); + + match action.as_str() { + // explore_connections — re-reads its own `action` (chain/associations/bridges). + "chain" | "associations" | "bridges" => { + super::explore::execute(storage, cognitive, args).await + } + // predict — reads `context`, ignores `action`. + "predict" => super::predict::execute(storage, cognitive, args).await, + // memory_graph — reads center_id/query/depth, ignores `action`. + "memory_graph" => super::graph::execute(storage, args).await, + // composed_graph — re-reads its own `action`. `label` is the only write. + "recent" | "get" | "memory" | "neighbors" | "never_composed" | "bounty_mode" | "label" => { + if action == "label" { + let event_id = args + .as_ref() + .and_then(|a| a.get("event_id")) + .and_then(|v| v.as_str()) + .unwrap_or("?"); + let outcome = args + .as_ref() + .and_then(|a| a.get("outcome_type")) + .and_then(|v| v.as_str()) + .unwrap_or("?"); + tracing::info!( + event_id = %event_id, + outcome_type = %outcome, + "graph: composition outcome labeled" + ); + } + super::composed_graph::execute(storage, args).await + } + other => Err(format!( + "Unknown graph action '{other}'. Use chain|associations|bridges|predict|memory_graph|recent|get|memory|neighbors|never_composed|bounty_mode|label." + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_schema_action_count() { + let s = schema(); + let actions = s["properties"]["action"]["enum"].as_array().unwrap(); + assert_eq!(actions.len(), 12); + // outcome_type enum is sourced from the canonical const. + let outcomes = s["properties"]["outcome_type"]["enum"].as_array().unwrap(); + assert_eq!(outcomes.len(), OUTCOME_TYPES.len()); + } + + #[test] + fn test_missing_action_errors() { + // Pure arg-shape check; no storage needed for the early return path. + let s = schema(); + assert_eq!(s["required"][0], "action"); + } +} diff --git a/crates/vestige-mcp/src/tools/mod.rs b/crates/vestige-mcp/src/tools/mod.rs index 4f2043d..f8ee696 100644 --- a/crates/vestige-mcp/src/tools/mod.rs +++ b/crates/vestige-mcp/src/tools/mod.rs @@ -46,6 +46,10 @@ pub mod session_context; pub mod graph; pub mod health; +// v2.2: Unified graph surface — folds explore_connections + predict + +// memory_graph + composed_graph into one action-dispatched tool. +pub mod graph_unified; + // v2.1: Cross-reference (connect the dots) pub mod composed_graph; pub mod contradictions; From fa8718672466f3685e22e38408d3cc2ae57a6d75 Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Sun, 28 Jun 2026 17:57:25 -0500 Subject: [PATCH 7/9] =?UTF-8?q?feat(mcp):=20consolidate=20maintenance/life?= =?UTF-8?q?cycle=20into=20`maintain`=20(7=E2=86=921)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool Consolidation v2.2.0, Layer 1 commit 5/6. Advertised tools 21 → 15. Folds consolidate + dream + gc + importance_score + backup + export + restore into one action-dispatched tool: action = consolidate | dream | gc | importance_score | backup | export | restore - Thin facade forwards the same args envelope to each existing handler; the `action` discriminator is cloned out before the envelope is moved into a callee. No handler uses deny_unknown_fields, so per-action params validate. - Safety defaults preserved (all handler-internal): gc dry_run=true by default, restore path-confinement, export traversal guard. Verified by test_maintain_actions_and_safety (gc dry-run + restore missing-path error). - Events preserved end-to-end: - Pre-dispatch Started events (ConsolidationStarted/DreamStarted) re-emitted in the `maintain` arm keyed on action. - emit_tool_event normalizes the `maintain` name to its effective action, so the existing ConsolidationCompleted/DreamCompleted/ImportanceScored arms fire unchanged — no duplicated emit logic. - All 7 old names remain hidden warn!+redirect aliases (removed v2.3.0), keeping their own pre-emits. - Tests: count 21→15, 7 negatives, new dispatch/safety test. Gates: cargo test --workspace, cargo clippy -D warnings — clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/vestige-mcp/src/server.rs | 228 +++++++++++++++++------ crates/vestige-mcp/src/tools/maintain.rs | 134 +++++++++++++ crates/vestige-mcp/src/tools/mod.rs | 4 + 3 files changed, 304 insertions(+), 62 deletions(-) create mode 100644 crates/vestige-mcp/src/tools/maintain.rs diff --git a/crates/vestige-mcp/src/server.rs b/crates/vestige-mcp/src/server.rs index 65c78f0..66a5ef6 100644 --- a/crates/vestige-mcp/src/server.rs +++ b/crates/vestige-mcp/src/server.rs @@ -301,39 +301,14 @@ impl McpServer { ..Default::default() }, // ================================================================ - // MAINTENANCE TOOLS (v1.7: system_status replaces health_check + stats) + // MAINTAIN — unified maintenance/lifecycle tool (v2.2) + // Folds consolidate + dream + gc + importance_score + backup + + // export + restore into one action-dispatched surface. // ================================================================ ToolDescription { - name: "consolidate".to_string(), - description: Some("Run FSRS-6 memory consolidation cycle. Applies decay, generates embeddings, and performs maintenance. Use when memories seem stale.".to_string()), - input_schema: tools::maintenance::consolidate_schema(), - ..Default::default() - }, - ToolDescription { - name: "backup".to_string(), - description: Some("Create a SQLite database backup. Returns the backup file path.".to_string()), - input_schema: tools::maintenance::backup_schema(), - ..Default::default() - }, - ToolDescription { - name: "export".to_string(), - description: Some("Export memories as JSON or JSONL. Supports tag and date filters.".to_string()), - input_schema: tools::maintenance::export_schema(), - ..Default::default() - }, - ToolDescription { - name: "gc".to_string(), - description: Some("Garbage collect stale memories below retention threshold. Defaults to dry_run=true for safety.".to_string()), - input_schema: tools::maintenance::gc_schema(), - ..Default::default() - }, - // ================================================================ - // AUTO-SAVE & DEDUP TOOLS (v1.3+) - // ================================================================ - ToolDescription { - name: "importance_score".to_string(), - description: Some("Score content importance using 4-channel neuroscience model (novelty/arousal/reward/attention). Returns composite score, channel breakdown, encoding boost, and explanations.".to_string()), - input_schema: tools::importance::schema(), + name: "maintain".to_string(), + description: Some("Memory maintenance & lifecycle. Actions: 'consolidate' (run FSRS-6 decay/embedding cycle), 'dream' (replay memories → insights/connections + strengthen patterns), 'gc' (garbage-collect stale memories; dry_run=true by default for safety), 'importance_score' (4-channel neuroscience score for 'content'), 'backup' (SQLite DB backup), 'export' (memories as JSON/JSONL with tag/date filters), 'restore' (restore from a JSON backup at 'path').".to_string()), + input_schema: tools::maintain::schema(), ..Default::default() }, // ================================================================ @@ -350,13 +325,8 @@ impl McpServer { }, // ================================================================ // COGNITIVE TOOLS (v1.5+) + // (dream folded into `maintain` action='dream' in v2.2) // ================================================================ - ToolDescription { - name: "dream".to_string(), - description: Some("Trigger memory dreaming — replays recent memories to discover hidden connections, synthesize insights, and strengthen important patterns. Returns insights, connections, and dream stats.".to_string()), - input_schema: tools::dream::schema(), - ..Default::default() - }, // ================================================================ // GRAPH — unified graph/association/prediction tool (v2.2) // Folds explore_connections + predict + memory_graph + composed_graph. @@ -369,13 +339,8 @@ impl McpServer { }, // ================================================================ // RESTORE TOOL (v1.5+) + // (folded into `maintain` action='restore' in v2.2) // ================================================================ - ToolDescription { - name: "restore".to_string(), - description: Some("Restore memories from a JSON backup file. Supports MCP wrapper format, RecallResult format, and direct memory array format.".to_string()), - input_schema: tools::restore::schema(), - ..Default::default() - }, // ================================================================ // CONTEXT PACKETS (v1.8+) // ================================================================ @@ -942,22 +907,65 @@ impl McpServer { } // ================================================================ - // MAINTENANCE TOOLS (v1.2+, non-deprecated) + // MAINTAIN — unified maintenance/lifecycle tool (v2.2) + // action = consolidate | dream | gc | importance_score | backup + // | export | restore + // ================================================================ + "maintain" => { + // Mirror the pre-dispatch *Started* events that the standalone + // consolidate/dream arms emit, keyed off the action. + match request + .arguments + .as_ref() + .and_then(|a| a.get("action")) + .and_then(|v| v.as_str()) + { + Some("consolidate") => self.emit(VestigeEvent::ConsolidationStarted { + timestamp: chrono::Utc::now(), + }), + Some("dream") => self.emit(VestigeEvent::DreamStarted { + memory_count: self + .storage + .get_stats() + .map(|s| s.total_nodes as usize) + .unwrap_or(0), + timestamp: chrono::Utc::now(), + }), + _ => {} + } + tools::maintain::execute(&self.storage, &self.cognitive, request.arguments).await + } + + // ================================================================ + // MAINTENANCE TOOLS (v1.2+) — DEPRECATED (v2.2): folded into + // `maintain`. Hidden aliases; pre-emit Started events preserved. // ================================================================ "consolidate" => { + warn!("Tool 'consolidate' is deprecated in v2.2. Use 'maintain' (action='consolidate')."); self.emit(VestigeEvent::ConsolidationStarted { timestamp: chrono::Utc::now(), }); tools::maintenance::execute_consolidate(&self.storage, request.arguments).await } - "backup" => tools::maintenance::execute_backup(&self.storage, request.arguments).await, - "export" => tools::maintenance::execute_export(&self.storage, request.arguments).await, - "gc" => tools::maintenance::execute_gc(&self.storage, request.arguments).await, + "backup" => { + warn!("Tool 'backup' is deprecated in v2.2. Use 'maintain' (action='backup')."); + tools::maintenance::execute_backup(&self.storage, request.arguments).await + } + "export" => { + warn!("Tool 'export' is deprecated in v2.2. Use 'maintain' (action='export')."); + tools::maintenance::execute_export(&self.storage, request.arguments).await + } + "gc" => { + warn!("Tool 'gc' is deprecated in v2.2. Use 'maintain' (action='gc')."); + tools::maintenance::execute_gc(&self.storage, request.arguments).await + } // ================================================================ // AUTO-SAVE & DEDUP TOOLS (v1.3+) // ================================================================ + // DEPRECATED (v2.2): folded into `maintain` (action='importance_score'). "importance_score" => { + warn!("Tool 'importance_score' is deprecated in v2.2. Use 'maintain' (action='importance_score')."); tools::importance::execute(&self.storage, &self.cognitive, request.arguments).await } // ================================================================ @@ -989,9 +997,11 @@ impl McpServer { } // ================================================================ - // COGNITIVE TOOLS (v1.5+) + // COGNITIVE TOOLS (v1.5+) — DEPRECATED (v2.2): dream folded into + // `maintain` (action='dream'). Hidden alias; DreamStarted preserved. // ================================================================ "dream" => { + warn!("Tool 'dream' is deprecated in v2.2. Use 'maintain' (action='dream')."); self.emit(VestigeEvent::DreamStarted { memory_count: self .storage @@ -1018,7 +1028,11 @@ impl McpServer { warn!("Tool 'predict' is deprecated in v2.2. Use 'graph' (action='predict')."); tools::predict::execute(&self.storage, &self.cognitive, request.arguments).await } - "restore" => tools::restore::execute(&self.storage, request.arguments).await, + // DEPRECATED (v2.2): folded into `maintain` (action='restore'). + "restore" => { + warn!("Tool 'restore' is deprecated in v2.2. Use 'maintain' (action='restore')."); + tools::restore::execute(&self.storage, request.arguments).await + } // ================================================================ // CONTEXT PACKETS (v1.8+) — `session_start` (renamed v2.2) @@ -1300,6 +1314,19 @@ impl McpServer { } let now = Utc::now(); + // v2.2: the unified `maintain` tool folds consolidate/dream/importance_score + // (the three maintenance actions that emit). Normalize its name to the + // effective action so the existing emit arms below fire unchanged. Old + // standalone names still arrive verbatim and match directly. + let tool_name = if tool_name == "maintain" { + args.as_ref() + .and_then(|a| a.get("action")) + .and_then(|v| v.as_str()) + .unwrap_or("maintain") + } else { + tool_name + }; + match tool_name { // -- smart_ingest: memory created/updated -- "smart_ingest" | "ingest" | "session_checkpoint" => { @@ -1830,8 +1857,8 @@ mod tests { // dispatchable as hidden back-compat aliases but drop off the advertised list. assert_eq!( tools.len(), - 21, - "Expected exactly 21 tools after dedup + memory_status + graph consolidation" + 15, + "Expected exactly 15 tools after dedup + memory_status + graph + maintain consolidation" ); let tool_names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); @@ -1889,13 +1916,24 @@ mod tests { !tool_names.contains(&"stats"), "stats should be removed in v1.7" ); - assert!(tool_names.contains(&"consolidate")); - assert!(tool_names.contains(&"backup")); - assert!(tool_names.contains(&"export")); - assert!(tool_names.contains(&"gc")); - - // Auto-save tool (v1.3) - assert!(tool_names.contains(&"importance_score")); + // Maintenance / lifecycle — unified `maintain` tool (v2.2). + // consolidate + dream + gc + importance_score + backup + export + restore + // folded in; old names dispatch as hidden aliases but are off the list. + assert!(tool_names.contains(&"maintain")); + for old in [ + "consolidate", + "dream", + "gc", + "importance_score", + "backup", + "export", + "restore", + ] { + assert!( + !tool_names.contains(&old), + "{old} should be folded into 'maintain' in v2.2" + ); + } // Dedup / merge / supersede — unified `dedup` tool (v2.2). // find_duplicates + the 7 Phase-3 merge tools folded in; still @@ -1917,10 +1955,8 @@ mod tests { ); } - // Cognitive tools (v1.5) - // (explore_connections + predict folded into `graph` in v2.2) - assert!(tool_names.contains(&"dream")); - assert!(tool_names.contains(&"restore")); + // Cognitive tools (v1.5): explore_connections + predict → `graph`; + // dream + restore → `maintain` (all v2.2). Nothing left advertised here. // Context packets (v1.8) — renamed session_context → session_start (v2.2) assert!(tool_names.contains(&"session_start")); @@ -2064,6 +2100,74 @@ mod tests { } } + /// v2.2: the 7 tools folded into `maintain` must still dispatch, the new + /// actions must resolve, gc must default to dry_run, and restore must keep + /// path validation (a nonexistent path errors rather than silently no-op). + #[tokio::test] + async fn test_maintain_actions_and_safety() { + let (mut server, _dir) = test_server().await; + let init_request = make_request("initialize", Some(init_params())); + server.handle_request(init_request).await; + + // Aliases + safe new actions must dispatch (not unknown-tool). + let dispatch_ok: Vec<(&str, serde_json::Value)> = vec![ + ("consolidate", serde_json::json!({})), + ("backup", serde_json::json!({})), + ("dream", serde_json::json!({})), + ("maintain", serde_json::json!({"action": "consolidate"})), + ("maintain", serde_json::json!({"action": "gc"})), + ("maintain", serde_json::json!({"action": "backup"})), + ]; + for (name, args) in dispatch_ok { + let request = make_request( + "tools/call", + Some(serde_json::json!({ "name": name, "arguments": args })), + ); + let response = server.handle_request(request).await.unwrap(); + if let Some(err) = response.error { + assert_ne!(err.code, -32602, "'{name}' {args} should dispatch: {}", err.message); + } + } + + // gc via maintain defaults to dry_run=true (no deletion). + let gc_req = make_request( + "tools/call", + Some(serde_json::json!({ "name": "maintain", "arguments": {"action": "gc"} })), + ); + let gc_resp = server.handle_request(gc_req).await.unwrap(); + let text = gc_resp.result.unwrap()["content"][0]["text"] + .as_str() + .unwrap() + .to_string(); + assert!( + text.contains("\"dryRun\": true") || text.contains("\"dryRun\":true"), + "maintain action=gc must default to dry_run=true; got: {text}" + ); + + // restore keeps path validation: a missing file must error, not no-op. + let restore_req = make_request( + "tools/call", + Some(serde_json::json!({ + "name": "maintain", + "arguments": {"action": "restore", "path": "/nonexistent/vestige-backup-xyz.json"} + })), + ); + let restore_resp = server.handle_request(restore_req).await.unwrap(); + // Either a JSON-RPC error or an error envelope is acceptable; a silent + // success is NOT (that would mean confinement/validation was bypassed). + let validated = restore_resp.error.is_some() + || restore_resp + .result + .map(|r| { + r["content"][0]["text"] + .as_str() + .map(|t| t.to_lowercase().contains("not found") || t.to_lowercase().contains("error")) + .unwrap_or(false) + }) + .unwrap_or(false); + assert!(validated, "maintain action=restore must validate a missing path"); + } + #[tokio::test] async fn test_tools_have_descriptions_and_schemas() { let (mut server, _dir) = test_server().await; diff --git a/crates/vestige-mcp/src/tools/maintain.rs b/crates/vestige-mcp/src/tools/maintain.rs new file mode 100644 index 0000000..4231603 --- /dev/null +++ b/crates/vestige-mcp/src/tools/maintain.rs @@ -0,0 +1,134 @@ +//! Unified `maintain` Tool (v2.2 — Tool Consolidation) +//! +//! Folds the seven maintenance/lifecycle tools into one action-dispatched +//! surface: +//! +//! action = consolidate | dream | gc | importance_score | backup | export | restore +//! +//! This is a thin facade: each action forwards the *same* args envelope to the +//! existing handler. None of the underlying arg structs use +//! `deny_unknown_fields`, so the `action` discriminator is ignored by each +//! handler and per-action params validate as before. Safety defaults are +//! preserved because they live inside the callees: +//! - `gc` defaults `dry_run=true` (handler-internal), +//! - `restore` keeps path-confinement (handler-internal), +//! - `export` keeps its traversal guard (handler-internal). +//! +//! The `consolidate`/`dream` *Started* events and the +//! `consolidate`/`dream`/`importance_score` *Completed* events are emitted by +//! the server dispatch + `emit_tool_event` (which normalizes the `maintain` +//! name to its effective action) — not here. + +use serde_json::Value; +use std::sync::Arc; +use tokio::sync::Mutex; + +use vestige_core::Storage; + +use crate::cognitive::CognitiveEngine; + +/// Discriminated-union schema for the unified `maintain` tool. +pub fn schema() -> Value { + serde_json::json!({ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["consolidate", "dream", "gc", "importance_score", "backup", "export", "restore"], + "description": "Maintenance op. 'consolidate' (run FSRS-6 decay/embedding cycle), 'dream' (replay memories → insights/connections), 'gc' (garbage-collect stale memories; dry_run=true by default), 'importance_score' (4-channel neuroscience score for 'content'), 'backup' (SQLite DB backup), 'export' (memories as JSON/JSONL with filters), 'restore' (restore from a JSON backup at 'path')." + }, + // --- gc --- + "min_retention": { "type": "number", "minimum": 0.0, "maximum": 1.0, "description": "[gc] Collect memories below this retention (default 0.1)." }, + "dry_run": { "type": "boolean", "description": "[gc] Preview only. Defaults to TRUE for safety." }, + // --- importance_score --- + "content": { "type": "string", "description": "[importance_score] Content to score." }, + // --- export --- + "format": { "type": "string", "enum": ["json", "jsonl"], "description": "[export] Output format." }, + "tags": { "type": "array", "items": { "type": "string" }, "description": "[export] Tag filter." }, + "start": { "type": "string", "description": "[export] Start date filter (ISO 8601)." }, + "end": { "type": "string", "description": "[export] End date filter (ISO 8601)." }, + // --- backup / restore --- + "path": { "type": "string", "description": "[restore] Path to a JSON backup file (path-confined)." } + }, + "required": ["action"] + }) +} + +/// Unified dispatcher for `maintain`. Routes on `action` (required). +pub async fn execute( + storage: &Arc, + cognitive: &Arc>, + args: Option, +) -> Result { + // Clone the discriminator out before the args envelope is moved into a callee. + let action = args + .as_ref() + .and_then(|a| a.get("action")) + .and_then(|v| v.as_str()) + .ok_or("Missing 'action'. Use consolidate|dream|gc|importance_score|backup|export|restore.")? + .to_string(); + + match action.as_str() { + "consolidate" => super::maintenance::execute_consolidate(storage, args).await, + "dream" => super::dream::execute(storage, cognitive, args).await, + "gc" => super::maintenance::execute_gc(storage, args).await, + "importance_score" => super::importance::execute(storage, cognitive, args).await, + "backup" => super::maintenance::execute_backup(storage, args).await, + "export" => super::maintenance::execute_export(storage, args).await, + "restore" => super::restore::execute(storage, args).await, + other => Err(format!( + "Unknown maintain action '{other}'. Use consolidate|dream|gc|importance_score|backup|export|restore." + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_storage() -> Arc { + let dir = tempfile::TempDir::new().unwrap(); + let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap(); + std::mem::forget(dir); + Arc::new(storage) + } + + #[test] + fn test_schema_actions() { + let s = schema(); + let actions = s["properties"]["action"]["enum"].as_array().unwrap(); + assert_eq!(actions.len(), 7); + assert_eq!(s["required"][0], "action"); + } + + #[tokio::test] + async fn test_missing_action_errors() { + let storage = test_storage(); + let cognitive = Arc::new(Mutex::new(CognitiveEngine::new())); + let r = execute(&storage, &cognitive, None).await; + assert!(r.is_err(), "missing action must error"); + } + + #[tokio::test] + async fn test_gc_defaults_dry_run() { + let storage = test_storage(); + let cognitive = Arc::new(Mutex::new(CognitiveEngine::new())); + // No dry_run passed → handler default true → nothing is actually deleted. + let args = Some(serde_json::json!({ "action": "gc" })); + let r = execute(&storage, &cognitive, args).await.unwrap(); + // gc's envelope reports dry_run; assert it stayed true. + let dry = r + .get("dryRun") + .or(r.get("dry_run")) + .and_then(|v| v.as_bool()); + assert_eq!(dry, Some(true), "gc must default to dry_run=true via maintain"); + } + + #[tokio::test] + async fn test_consolidate_resolves() { + let storage = test_storage(); + let cognitive = Arc::new(Mutex::new(CognitiveEngine::new())); + let args = Some(serde_json::json!({ "action": "consolidate" })); + assert!(execute(&storage, &cognitive, args).await.is_ok()); + } +} diff --git a/crates/vestige-mcp/src/tools/mod.rs b/crates/vestige-mcp/src/tools/mod.rs index f8ee696..82972a7 100644 --- a/crates/vestige-mcp/src/tools/mod.rs +++ b/crates/vestige-mcp/src/tools/mod.rs @@ -22,6 +22,10 @@ pub mod timeline; // v1.2: Maintenance tools pub mod maintenance; +// v2.2: Unified maintenance surface — folds consolidate + dream + gc + +// importance_score + backup + export + restore into one action-dispatched tool. +pub mod maintain; + // v2.2: Unified status surface — folds system_status + memory_health + // memory_timeline + memory_changelog into one view-dispatched tool. pub mod memory_status; From 7398f0c1b32cff1be02cbc252b41a71248c2ec87 Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Sun, 28 Jun 2026 18:08:39 -0500 Subject: [PATCH 8/9] =?UTF-8?q?feat(mcp):=20consolidate=20retrieval=20into?= =?UTF-8?q?=20`recall`=20(4=E2=86=921,=20HOT=20PATH)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool Consolidation v2.2.0, Layer 1 commit 6/6. Advertised tools 15 → 12 (target). Folds search + deep_reference + cross_reference + contradictions into one mode-dispatched tool: mode = lookup (DEFAULT) | reason | contradictions HOT-PATH INVARIANT: with no `mode` set, `recall` is a zero-overhead pass-through to search_unified::execute — plain recall never pays the 5–10× reasoning cost. Only mode='reason' runs spreading activation + FSRS trust scoring. Verified by test_recall_lookup_matches_search_shape (recall default == search byte-for-byte). - Schema derived from search_unified::schema() so every lookup param carries through; the global `required: ["query"]` is dropped (contradictions uses `topic`) and validated per-mode at runtime; mode/depth/topic/since/min_trust added. - `recall` becomes the primary retrieval verb and first advertised tool. The old `recall`/`semantic_search`/`hybrid_search` search aliases now redirect to it; search/deep_reference/cross_reference/contradictions are hidden warn!+redirect aliases (removed v2.3.0). - Size annotation moved search (300k) → recall, synced across loop, helper, and both annotation tests. test_meta_wire_shape updated to probe `recall`. - emit_tool_event normalizes `recall` → SearchPerformed only on lookup; reason/contradictions do not emit. - Tests: count 15→12, 4 negatives, recall modes/aliases + lookup-shape tests. Final advertised surface (12): recall, memory, codebase, intention, smart_ingest, source_sync, memory_status, dedup, graph, maintain, session_start, suppress. Gates: cargo test --workspace (435 mcp tests green), cargo clippy -D warnings. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/vestige-mcp/src/server.rs | 184 +++++++++++++++++++------ crates/vestige-mcp/src/tools/mod.rs | 5 + crates/vestige-mcp/src/tools/recall.rs | 163 ++++++++++++++++++++++ 3 files changed, 309 insertions(+), 43 deletions(-) create mode 100644 crates/vestige-mcp/src/tools/recall.rs diff --git a/crates/vestige-mcp/src/server.rs b/crates/vestige-mcp/src/server.rs index 66a5ef6..f63a781 100644 --- a/crates/vestige-mcp/src/server.rs +++ b/crates/vestige-mcp/src/server.rs @@ -245,14 +245,19 @@ impl McpServer { // Deprecated tools still work via redirects in handle_tools_call. let mut tools = vec![ // ================================================================ - // UNIFIED TOOLS (v1.1+) + // RECALL — unified retrieval tool (v2.2). HOT PATH. + // Folds search + deep_reference + cross_reference + contradictions. + // mode='lookup' (default) is a zero-overhead pass-through to search. // ================================================================ ToolDescription { - name: "search".to_string(), - description: Some("Unified search tool. Uses hybrid search (keyword + semantic + convex combination fusion) internally. Auto-strengthens memories on access (Testing Effect).".to_string()), - input_schema: tools::search_unified::schema(), + name: "recall".to_string(), + description: Some("Retrieve from memory. Modes: 'lookup' (default — fast hybrid search: keyword + semantic + convex fusion, auto-strengthens on access; use for plain recall), 'reason' (deep cognitive reasoning across memories with FSRS-6 trust scoring, spreading activation, supersession, and contradiction analysis; use when accuracy matters, needs 'query'), 'contradictions' (surface trust-weighted disagreement pairs for a 'topic'). Default mode is fast — only 'reason' pays the deep-analysis cost.".to_string()), + input_schema: tools::recall::schema(), ..Default::default() }, + // ================================================================ + // UNIFIED TOOLS (v1.1+) + // ================================================================ ToolDescription { name: "memory".to_string(), description: Some("Unified memory management tool. Actions: 'get' (retrieve full node), 'purge' (irreversibly remove content/embeddings with confirm=true), 'delete' (legacy alias for purge), 'state' (get accessibility state), 'promote' (thumbs up — increases retrieval strength), 'demote' (thumbs down — decreases retrieval strength, does NOT delete), 'edit' (update content in-place, preserves FSRS state).".to_string()), @@ -356,26 +361,10 @@ impl McpServer { // memory_graph + composed_graph → `graph`, all in v2.2) // ================================================================ // ================================================================ - // DEEP REFERENCE (v2.0.4+) — replaces cross_reference + // DEEP REFERENCE (v2.0.4+) — folded into `recall` (mode='reason' / + // 'contradictions') in v2.2. deep_reference/cross_reference/ + // contradictions remain hidden dispatch aliases. // ================================================================ - ToolDescription { - name: "deep_reference".to_string(), - description: Some("Deep cognitive reasoning across memories. Combines FSRS-6 trust scoring, spreading activation, temporal supersession, dream insights, and contradiction analysis to build a complete understanding of a topic. Returns trust-scored evidence, fact evolution timeline, and a recommended answer. Use this when accuracy matters.".to_string()), - input_schema: tools::cross_reference::schema(), - ..Default::default() - }, - ToolDescription { - name: "cross_reference".to_string(), - description: Some("Alias for deep_reference. Connect the dots across memories with cognitive reasoning.".to_string()), - input_schema: tools::cross_reference::schema(), - ..Default::default() - }, - ToolDescription { - name: "contradictions".to_string(), - description: Some("Inspect memory disagreements directly. Scans a topic or recent memories for trust-weighted contradiction pairs using the same local logic as deep_reference.".to_string()), - input_schema: tools::contradictions::schema(), - ..Default::default() - }, // ================================================================ // ACTIVE FORGETTING (v2.0.5) — top-down suppression // Anderson et al. 2025 Nat Rev Neurosci + Davis Rac1 @@ -409,7 +398,8 @@ impl McpServer { // empirical measurement shows truncation under realistic use. for tool in tools.iter_mut() { let max_chars: Option = match tool.name.as_str() { - "search" => Some(300_000), + // v2.2: search folded into recall (mode='lookup'); annotation moved. + "recall" => Some(300_000), "memory_status" => Some(200_000), "memory" => Some(100_000), "codebase" => Some(100_000), @@ -470,7 +460,20 @@ impl McpServer { // ================================================================ // UNIFIED TOOLS (v1.1+) - Preferred API // ================================================================ + // RECALL — unified retrieval tool (v2.2). HOT PATH. + // mode = lookup (default, zero-overhead) | reason | contradictions + "recall" => { + tools::recall::execute( + &self.storage, + &self.cognitive, + &self.output_config, + request.arguments, + ) + .await + } + // DEPRECATED (v2.2): folded into `recall` (mode='lookup'). Hidden alias. "search" => { + warn!("Tool 'search' is deprecated in v2.2. Use 'recall' (mode='lookup', the default)."); tools::search_unified::execute( &self.storage, &self.cognitive, @@ -617,11 +620,12 @@ impl McpServer { "mark_reviewed" => tools::review::execute(&self.storage, request.arguments).await, // ================================================================ - // DEPRECATED: Search tools - redirect to unified 'search' + // DEPRECATED: legacy search aliases — redirect to `recall` lookup. + // ('recall' itself is now the unified retrieval tool, handled above.) // ================================================================ - "recall" | "semantic_search" | "hybrid_search" => { + "semantic_search" | "hybrid_search" => { warn!( - "Tool '{}' is deprecated. Use 'search' instead.", + "Tool '{}' is deprecated. Use 'recall' (mode='lookup') instead.", request.name ); tools::search_unified::execute( @@ -1075,11 +1079,14 @@ impl McpServer { warn!("Tool 'composed_graph' is deprecated in v2.2. Use 'graph' (action='recent'|'get'|'memory'|'neighbors'|'never_composed'|'bounty_mode'|'label')."); tools::composed_graph::execute(&self.storage, request.arguments).await } + // DEPRECATED (v2.2): folded into `recall`. Hidden aliases. "deep_reference" | "cross_reference" => { + warn!("Tool '{}' is deprecated in v2.2. Use 'recall' (mode='reason').", request.name); tools::cross_reference::execute(&self.storage, &self.cognitive, request.arguments) .await } "contradictions" => { + warn!("Tool 'contradictions' is deprecated in v2.2. Use 'recall' (mode='contradictions')."); tools::contradictions::execute(&self.storage, request.arguments).await } @@ -1323,6 +1330,18 @@ impl McpServer { .and_then(|a| a.get("action")) .and_then(|v| v.as_str()) .unwrap_or("maintain") + } else if tool_name == "recall" { + // The unified `recall` tool fires SearchPerformed only for the lookup + // path (the former `search`). reason/contradictions do not emit, so + // map them to a non-emitting name. + match args + .as_ref() + .and_then(|a| a.get("mode")) + .and_then(|v| v.as_str()) + { + Some("reason") | Some("contradictions") => "recall_noemit", + _ => "search", // lookup (default) → SearchPerformed + } } else { tool_name }; @@ -1857,14 +1876,16 @@ mod tests { // dispatchable as hidden back-compat aliases but drop off the advertised list. assert_eq!( tools.len(), - 15, - "Expected exactly 15 tools after dedup + memory_status + graph + maintain consolidation" + 12, + "Expected exactly 12 tools after v2.2 Layer-1 consolidation \ + (dedup + memory_status + graph + maintain + recall; session_context renamed)" ); let tool_names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); // Unified tools - assert!(tool_names.contains(&"search")); + // (search folded into `recall` mode='lookup' in v2.2) + assert!(tool_names.contains(&"recall")); assert!(tool_names.contains(&"memory")); assert!(tool_names.contains(&"codebase")); assert!(tool_names.contains(&"intention")); @@ -1981,10 +2002,20 @@ mod tests { ); } - // Deep reference + cross_reference alias (v2.0.4) - assert!(tool_names.contains(&"deep_reference")); - assert!(tool_names.contains(&"cross_reference")); - assert!(tool_names.contains(&"contradictions")); + // Retrieval — unified `recall` tool (v2.2). search + deep_reference + + // cross_reference + contradictions folded in; old names dispatch as + // hidden aliases but are off the advertised list. + for old in [ + "search", + "deep_reference", + "cross_reference", + "contradictions", + ] { + assert!( + !tool_names.contains(&old), + "{old} should be folded into 'recall' in v2.2" + ); + } // Active forgetting (v2.0.5) — Anderson 2025 + Davis Rac1 assert!(tool_names.contains(&"suppress")); @@ -2168,6 +2199,71 @@ mod tests { assert!(validated, "maintain action=restore must validate a missing path"); } + /// v2.2 HOT PATH: `recall` defaults to mode='lookup' (search), the folded + /// names still dispatch, and the reason/contradictions modes resolve. + #[tokio::test] + async fn test_recall_modes_and_aliases() { + let (mut server, _dir) = test_server().await; + let init_request = make_request("initialize", Some(init_params())); + server.handle_request(init_request).await; + + let calls: Vec<(&str, serde_json::Value)> = vec![ + // Deprecated aliases must still dispatch. + ("search", serde_json::json!({"query": "x"})), + ("deep_reference", serde_json::json!({"query": "x"})), + ("cross_reference", serde_json::json!({"query": "x"})), + ("contradictions", serde_json::json!({})), + ("semantic_search", serde_json::json!({"query": "x"})), + // New unified modes. + ("recall", serde_json::json!({"query": "x"})), // default mode = lookup + ("recall", serde_json::json!({"mode": "lookup", "query": "x"})), + ("recall", serde_json::json!({"mode": "reason", "query": "x"})), + ("recall", serde_json::json!({"mode": "contradictions"})), + ]; + + for (name, args) in calls { + let request = make_request( + "tools/call", + Some(serde_json::json!({ "name": name, "arguments": args })), + ); + let response = server.handle_request(request).await.unwrap(); + assert!( + response.error.is_none(), + "'{name}' {args} should resolve, got error: {:?}", + response.error + ); + } + } + + /// v2.2: `recall` mode='lookup' (the default) must produce the same result + /// shape as the former standalone `search` — i.e. the no-mode default is a + /// faithful pass-through, not a reasoning call. + #[tokio::test] + async fn test_recall_lookup_matches_search_shape() { + let (mut server, _dir) = test_server().await; + let init_request = make_request("initialize", Some(init_params())); + server.handle_request(init_request).await; + + let args = serde_json::json!({ "query": "anything" }); + let via_recall = make_request( + "tools/call", + Some(serde_json::json!({ "name": "recall", "arguments": args })), + ); + let via_search = make_request( + "tools/call", + Some(serde_json::json!({ "name": "search", "arguments": args })), + ); + let r1 = server.handle_request(via_recall).await.unwrap(); + let r2 = server.handle_request(via_search).await.unwrap(); + assert!(r1.error.is_none() && r2.error.is_none()); + // The unified-tool wrapper text (the search payload) must match. + assert_eq!( + r1.result.unwrap()["content"][0]["text"], + r2.result.unwrap()["content"][0]["text"], + "recall(mode=lookup) must equal search byte-for-byte" + ); + } + #[tokio::test] async fn test_tools_have_descriptions_and_schemas() { let (mut server, _dir) = test_server().await; @@ -2382,7 +2478,8 @@ mod tests { /// (cargo-cult prevention). fn expected_max_result_size(name: &str) -> Option { match name { - "search" => Some(300_000), + // v2.2: search folded into recall (mode='lookup'); annotation moved. + "recall" => Some(300_000), // v2.2: memory_timeline folded into memory_status (view='timeline'); // the high-payload annotation moved with it. "memory_status" => Some(200_000), @@ -2407,7 +2504,7 @@ mod tests { let result = response.result.unwrap(); let tools = result["tools"].as_array().unwrap(); - for name in ["search", "memory_status", "memory", "codebase", "dedup", "graph"] { + for name in ["recall", "memory_status", "memory", "codebase", "dedup", "graph"] { let tool = tools .iter() .find(|t| t["name"].as_str() == Some(name)) @@ -2495,19 +2592,20 @@ mod tests { let result = response.result.unwrap(); let tools = result["tools"].as_array().unwrap(); - let search_tool = tools + // v2.2: `recall` is the annotated retrieval tool (search folded in). + let recall_tool = tools .iter() - .find(|t| t["name"].as_str() == Some("search")) - .expect("'search' tool present"); + .find(|t| t["name"].as_str() == Some("recall")) + .expect("'recall' tool present"); // Wire-form: `_meta` must exist; `meta` (un-renamed) must NOT exist. assert!( - search_tool.get("_meta").is_some(), - "search tool missing `_meta` key (serde rename to _meta did not apply)" + recall_tool.get("_meta").is_some(), + "recall tool missing `_meta` key (serde rename to _meta did not apply)" ); assert!( - search_tool.get("meta").is_none(), - "search tool has un-renamed `meta` key (regression — serde rename broke)" + recall_tool.get("meta").is_none(), + "recall tool has un-renamed `meta` key (regression — serde rename broke)" ); } } diff --git a/crates/vestige-mcp/src/tools/mod.rs b/crates/vestige-mcp/src/tools/mod.rs index 82972a7..4e356e0 100644 --- a/crates/vestige-mcp/src/tools/mod.rs +++ b/crates/vestige-mcp/src/tools/mod.rs @@ -11,6 +11,11 @@ pub mod codebase_unified; pub mod intention_unified; pub mod memory_unified; pub mod search_unified; + +// v2.2: Unified retrieval surface — folds search + deep_reference + +// cross_reference + contradictions into one mode-dispatched tool. +// mode=lookup (default) is a zero-overhead pass-through to search_unified. +pub mod recall; pub mod smart_ingest; // #57: external-source connectors (GitHub Issues / Redmine retrieval layer) pub mod source_sync; diff --git a/crates/vestige-mcp/src/tools/recall.rs b/crates/vestige-mcp/src/tools/recall.rs new file mode 100644 index 0000000..c899fd6 --- /dev/null +++ b/crates/vestige-mcp/src/tools/recall.rs @@ -0,0 +1,163 @@ +//! Unified `recall` Tool (v2.2 — Tool Consolidation, HOT PATH) +//! +//! Folds the four retrieval/reasoning tools into one mode-dispatched surface: +//! +//! mode = lookup (DEFAULT) | reason | contradictions +//! +//! - `lookup` (default) → hybrid search (the former `search`). This is the hot +//! path: with no `mode` set, `recall` is a ZERO-overhead pass-through to +//! `search_unified::execute` — it must never pay the cost of the reasoning +//! path. (`deep_reference`/`reason` runs spreading activation + FSRS trust +//! scoring + contradiction analysis and is 5–10× slower.) +//! - `reason` → deep cognitive reasoning across memories (former +//! `deep_reference` / `cross_reference`). +//! - `contradictions` → trust-weighted disagreement pairs (former +//! `contradictions`). +//! +//! The schema is derived from `search_unified::schema()` (so every lookup +//! parameter stays available and documented) plus the `mode` discriminator and +//! the reason/contradictions fields. `query` is NOT globally required because +//! the contradictions mode is scoped by `topic`; per-mode requirements are +//! validated at runtime. + +use serde_json::Value; +use std::sync::Arc; +use tokio::sync::Mutex; + +use vestige_core::{OutputConfig, Storage}; + +use crate::cognitive::CognitiveEngine; + +/// Discriminated-union schema for the unified `recall` tool. +/// +/// Built on top of `search_unified::schema()` so all lookup parameters carry +/// through verbatim; the `required: ["query"]` constraint is dropped (validated +/// per-mode at runtime) and the mode/reason/contradictions fields are added. +pub fn schema() -> Value { + let mut schema = super::search_unified::schema(); + + if let Some(obj) = schema.as_object_mut() { + // Drop the global `query` requirement — contradictions uses `topic`. + obj.remove("required"); + + if let Some(props) = obj.get_mut("properties").and_then(|p| p.as_object_mut()) { + props.insert( + "mode".to_string(), + serde_json::json!({ + "type": "string", + "enum": ["lookup", "reason", "contradictions"], + "default": "lookup", + "description": "Retrieval mode. 'lookup' (default): fast hybrid search — use for plain recall. 'reason': deep cognitive reasoning across memories (FSRS-6 trust scoring, spreading activation, supersession, contradiction analysis) — use when accuracy matters; needs 'query'. 'contradictions': surface trust-weighted disagreement pairs for a 'topic' (or recent memories)." + }), + ); + // reason (deep_reference) extra field. + props.insert( + "depth".to_string(), + serde_json::json!({ + "type": "integer", + "description": "[reason mode] How many memories to analyze (default 20, max 50).", + "minimum": 5, "maximum": 50 + }), + ); + // contradictions extra fields. + props.insert( + "topic".to_string(), + serde_json::json!({ + "type": "string", + "description": "[contradictions mode] Topic to scope contradiction detection. If omitted, scans recent memories." + }), + ); + props.insert( + "since".to_string(), + serde_json::json!({ + "type": "string", + "description": "[contradictions mode] RFC3339 timestamp; only memories updated after this are considered." + }), + ); + props.insert( + "min_trust".to_string(), + serde_json::json!({ + "type": "number", + "minimum": 0.0, "maximum": 1.0, + "description": "[contradictions mode] Minimum trust for both sides of a contradiction (default 0.3)." + }), + ); + } + } + + schema +} + +/// Unified dispatcher for `recall`. Routes on `mode` (default `lookup`). +/// +/// HOT-PATH INVARIANT: `mode` absent ⇒ `lookup` ⇒ direct pass-through to +/// `search_unified::execute`, no extra work. +pub async fn execute( + storage: &Arc, + cognitive: &Arc>, + output_config: &OutputConfig, + args: Option, +) -> Result { + let mode = args + .as_ref() + .and_then(|a| a.get("mode")) + .and_then(|v| v.as_str()) + .unwrap_or("lookup"); + + match mode { + // Zero-overhead default: straight to hybrid search. + "lookup" => super::search_unified::execute(storage, cognitive, output_config, args).await, + // Deep reasoning (deep_reference / cross_reference share this handler). + "reason" => super::cross_reference::execute(storage, cognitive, args).await, + // Trust-weighted contradiction pairs (storage-only). + "contradictions" => super::contradictions::execute(storage, args).await, + other => Err(format!( + "Unknown recall mode '{other}'. Use lookup|reason|contradictions." + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_schema_has_mode_and_no_required() { + let s = schema(); + let modes = s["properties"]["mode"]["enum"].as_array().unwrap(); + assert_eq!(modes.len(), 3); + assert_eq!(s["properties"]["mode"]["default"], "lookup"); + // query must NOT be globally required (contradictions uses topic). + assert!( + s.get("required").is_none(), + "recall must not globally require 'query'" + ); + // lookup params carried over from search schema. + assert!(s["properties"]["limit"].is_object()); + assert!(s["properties"]["detail_level"].is_object()); + } + + #[tokio::test] + async fn test_lookup_is_default_and_resolves() { + let dir = tempfile::TempDir::new().unwrap(); + let storage = Arc::new(Storage::new(Some(dir.path().join("test.db"))).unwrap()); + let cognitive = Arc::new(Mutex::new(CognitiveEngine::new())); + let oc = OutputConfig::default(); + // No mode → lookup → behaves like search (query required by search). + let args = Some(serde_json::json!({ "query": "anything" })); + let r = execute(&storage, &cognitive, &oc, args).await; + assert!(r.is_ok(), "default lookup should resolve: {r:?}"); + } + + #[tokio::test] + async fn test_contradictions_mode_resolves_without_query() { + let dir = tempfile::TempDir::new().unwrap(); + let storage = Arc::new(Storage::new(Some(dir.path().join("test.db"))).unwrap()); + let cognitive = Arc::new(Mutex::new(CognitiveEngine::new())); + let oc = OutputConfig::default(); + // contradictions uses topic, not query — must resolve with no query. + let args = Some(serde_json::json!({ "mode": "contradictions" })); + let r = execute(&storage, &cognitive, &oc, args).await; + assert!(r.is_ok(), "contradictions mode should resolve: {r:?}"); + } +} From ec8bf272553802ac9ede7c1d64a9f2e10a8e92d2 Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Sun, 28 Jun 2026 18:12:16 -0500 Subject: [PATCH 9/9] docs(mcp): add reconciled two-layer tool-consolidation plan; refresh stale comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds docs/launch/tool-consolidation-v2.2.0.md — the single sequenced plan that reconciles the two prior planning notes: - Layer 1 (this PR): 34 → 12 advertised tools, safe commit order, alias policy, preserved invariants, and the test that proves each. - Layer 2 (follow-up): tiny always-on default surface + SessionStart/Stop hooks. Also refreshes stale in-code comments to match the consolidated surface: - server.rs handle_tools_list header (was "v2.1.21: 25 tools") and the size-annotation rationale (now lists recall/memory_status/dedup/graph). - tools/mod.rs module doc (the facade vs. granular-handler relationship). No behavior change. Gates: cargo test --workspace, cargo clippy -D warnings, pnpm dashboard check + build — all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/vestige-mcp/src/server.rs | 16 +-- crates/vestige-mcp/src/tools/mod.rs | 11 +- docs/launch/tool-consolidation-v2.2.0.md | 135 +++++++++++++++++++++++ 3 files changed, 152 insertions(+), 10 deletions(-) create mode 100644 docs/launch/tool-consolidation-v2.2.0.md diff --git a/crates/vestige-mcp/src/server.rs b/crates/vestige-mcp/src/server.rs index f63a781..671f179 100644 --- a/crates/vestige-mcp/src/server.rs +++ b/crates/vestige-mcp/src/server.rs @@ -240,9 +240,10 @@ impl McpServer { /// Handle tools/list request async fn handle_tools_list(&self) -> Result { - // v2.1.21: 25 tools (verified by the `tools.len() == 25` assertion in the - // handle_tools_list test below — the `suppress` tool landed in v2.0.5). - // Deprecated tools still work via redirects in handle_tools_call. + // v2.2: 12 advertised tools after Layer-1 Tool Consolidation + // (verified by `tools.len() == 12` in test_tools_list_returns_all_tools). + // 22 deprecated/folded names still work as hidden redirects in + // handle_tools_call. See docs/launch/tool-consolidation-v2.2.0.md. let mut tools = vec![ // ================================================================ // RECALL — unified retrieval tool (v2.2). HOT PATH. @@ -387,10 +388,11 @@ impl McpServer { // chunk-read them. // // Per-tool caps below are sized at ~2× observed peak with growth - // headroom; max permitted by Anthropic is 500_000. Only the four - // empirically-measured high-payload tools carry the annotation today; - // the remaining 21 tools deliberately do NOT (cargo-cult prevention — - // annotating a small-payload tool dilutes the signal). + // headroom; max permitted by Anthropic is 500_000. Only the + // high-payload tools carry the annotation (recall, memory_status, + // memory, codebase, dedup, graph); the remaining advertised tools + // deliberately do NOT (cargo-cult prevention — annotating a + // small-payload tool dilutes the signal). // // Other tools that COULD plausibly grow into the annotated set with // future workload (`deep_reference`, `cross_reference`, `memory_graph`, diff --git a/crates/vestige-mcp/src/tools/mod.rs b/crates/vestige-mcp/src/tools/mod.rs index 4e356e0..e69d59a 100644 --- a/crates/vestige-mcp/src/tools/mod.rs +++ b/crates/vestige-mcp/src/tools/mod.rs @@ -2,9 +2,14 @@ //! //! Tool implementations for the Vestige MCP server. //! -//! The unified tools (codebase_unified, intention_unified, memory_unified, search_unified) -//! are the primary API. The granular tools below are kept for backwards compatibility -//! but are not exposed in the MCP tool list. +//! v2.2 Tool Consolidation (Layer 1): the advertised surface is 12 tools — +//! recall, memory, codebase, intention, smart_ingest, source_sync, +//! memory_status, dedup, graph, maintain, session_start, suppress. The unified +//! facade modules (recall, dedup, memory_status, graph_unified, maintain, plus +//! the earlier *_unified) dispatch on an action/mode/view discriminator and +//! delegate to the granular handler modules below, which stay in the crate as +//! the implementation layer and as hidden back-compat aliases (see the redirect +//! arms in server.rs). See docs/launch/tool-consolidation-v2.2.0.md. // Active unified tools pub mod codebase_unified; diff --git a/docs/launch/tool-consolidation-v2.2.0.md b/docs/launch/tool-consolidation-v2.2.0.md new file mode 100644 index 0000000..3e7190c --- /dev/null +++ b/docs/launch/tool-consolidation-v2.2.0.md @@ -0,0 +1,135 @@ +# Tool Consolidation v2.2.0 + +> Reduce the Vestige MCP tool surface so an agent can reliably pick the right +> tool, then make the few always-on tools deterministic. Two layers: Layer 1 +> (this release) collapses 34 advertised tools to 12; Layer 2 (follow-up) shrinks +> the *default* surface and enforces the memory loop with hooks. + +## Why (frontier evidence) + +More advertised tools actively degrade tool selection — the 30 tools an agent +ignores make the 5 it uses harder to choose: + +- **RAG-MCP** (arXiv 2505.03275): selection accuracy collapses 43% → 14% when the + full tool catalog is dumped into context; stays >90% under ~30 tools. +- **Anthropic tool-deferral**: deferring tool schemas moved Opus 4 from 49% → 74% + on a tool-heavy benchmark. +- **GitHub Copilot**: 40 → 13 tools gave +2–5pp accuracy and −400ms latency. +- **OpenAI** guidance: aim for <20 functions visible at the start of a turn. +- **RoTBench** (2401.08326): tool *names* are load-bearing — renaming drops GPT-4 + 80 → 58. So renames are deliberate and every old name keeps working. + +Vestige had **34** advertised tools. This is the correction. + +## Layer 1 — Count reduction (THIS RELEASE): 34 → 12 advertised + +Principle: **one consolidation per commit, one change per submission.** Each +consolidation is its own commit, landed in a safe order with the hot retrieval +path touched last. Every old tool name remains a hidden `warn!` + redirect alias +for at least one minor release (so existing `.mcp.json` configs, hooks, and agent +habits keep working) and is removed in **v2.3.0**. + +### Safe order (as committed) + +| # | Commit | Folds | Into | Count | +|---|--------|-------|------|------:| +| 1 | `dedup` | find_duplicates + merge_candidates + plan_merge + plan_supersede + apply_plan + merge_undo + protect + merge_policy (8) | `dedup` | 34 → 27 | +| 2 | `session_start` | session_context (rename) | `session_start` | 27 | +| 3a | `memory_status` | system_status + memory_health + memory_timeline + memory_changelog (4) | `memory_status` | 27 → 24 | +| 3b | `graph` | explore_connections + predict + memory_graph + composed_graph (4) | `graph` | 24 → 21 | +| 4 | `maintain` | consolidate + dream + gc + importance_score + backup + export + restore (7) | `maintain` | 21 → 15 | +| 5 | `recall` | search + deep_reference + cross_reference + contradictions (4) | `recall` | 15 → 12 | + +`recall` is committed **last** because it is the hot path. + +### Final advertised surface (12) + +| Standalone (6) | Consolidated (6) | +|---|---| +| `smart_ingest` | `recall` | +| `memory` | `dedup` | +| `codebase` | `memory_status` | +| `intention` | `graph` | +| `source_sync` | `maintain` | +| `suppress` | `session_start` | + +### Action / mode / view maps + +- **`recall`** — `mode`: `lookup` (default) · `reason` · `contradictions` +- **`dedup`** — `action`: `scan` (default) · `plan_merge` · `plan_supersede` · `apply` · `undo` · `protect` · `policy` +- **`memory_status`** — `view`: `health` (default) · `retention` · `timeline` · `changelog` +- **`graph`** — `action`: `chain` · `associations` · `bridges` · `predict` · `memory_graph` · `recent` · `get` · `memory` · `neighbors` · `never_composed` · `bounty_mode` · `label` +- **`maintain`** — `action`: `consolidate` · `dream` · `gc` · `importance_score` · `backup` · `export` · `restore` + +### Resolved design decisions + +- **`search` is folded, not kept standalone.** `recall` with no `mode` (the + default) *is* search — a zero-overhead pass-through to `search_unified`. Keeping + both `search` and `recall` advertised would be the exact RAG-MCP anti-pattern. + Final count is a clean **12**, leaving 2 slots of headroom toward a future + always-on `save` surface rather than spending them on a redundant verb. +- **`graph` actions are flat peers, not nested.** `explore`'s `chain` / + `associations` / `bridges` sit alongside `predict` / `memory_graph` / + `composed_graph` actions in a single `action` enum — matching the existing + `memory` / `codebase` flat-action convention and avoiding a translation layer. + +### Invariants preserved (with the test that proves each) + +- **bitemporal-never-delete** (`dedup`): plan → apply → undo, confirm-gating, and + invalidation-not-deletion delegate to `merge::execute` verbatim. +- **`system_status` response shape** (`memory_status` view=`health`): byte-for-byte + — `test_default_view_is_health`. +- **`gc` dry-run default** + **`restore` path-confinement** (`maintain`): + `test_maintain_actions_and_safety`. +- **`recall` lookup = search, no reasoning cost** (hot path): + `test_recall_lookup_matches_search_shape`. +- **Dashboard events** (consolidate/dream/importance_score Started + Completed, + SearchPerformed): preserved by re-emitting in the new dispatch arms and by + `emit_tool_event` normalizing the unified tool name to its effective sub-action. + +### Result-size annotations (moved with their tools) + +`memory_timeline` (200k) → `memory_status`; `search` (300k) → `recall`; new +`dedup` 150k and `graph` 250k. Kept in sync across the annotation loop, the +`expected_max_result_size` helper, and both annotation guard tests. + +### Deprecation timeline + +Aliases `warn!` in v2.2.x and are hard-removed in **v2.3.0**. Full alias list (31 +names) lives in the dispatch redirects in `crates/vestige-mcp/src/server.rs`. + +## Layer 2 — Default-surface + hooks (FOLLOW-UP, NOT in v2.2.0) + +Count reduction is necessary but not sufficient: what matters most is how few +tools are visible *at the start of a turn*, plus making the memory loop fire +deterministically instead of hoping the model remembers. + +- **Tiny always-on surface (~3)**: `recall` @ session start, `save` (=`smart_ingest`) + @ session end, `recall` on-demand for facts. Everything else (`dedup`, `graph`, + `maintain`, `memory_status`, …) deferred off the default surface, loaded on + demand. +- **Deterministic hooks**: a `SessionStart` hook fires `recall`; a `Stop` hook + fires `save` (async, fire-and-forget — synchronous heavy work in `Stop` causes + loops + per-turn lag). "If the model fails to save, it's gone" — move save out + of the model hot loop. +- This is what turns 12-advertised into ~3-default. Status: **design guidance + only; no code in v2.2.0.** + +## Verification + +Per-commit gates (all green for every commit): + +```sh +cargo test --workspace --no-fail-fast +cargo clippy --workspace -- -D warnings +``` + +Release gates before tagging v2.2.0: + +```sh +pnpm --filter @vestige/dashboard check +pnpm --filter @vestige/dashboard build +``` + +Plus a `tools/list` smoke check asserting exactly **12** advertised names +(`test_tools_list_returns_all_tools`).