From 32e6a6cd8d75361fe2ec9e1dae4c22530e47ef16 Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Sun, 28 Jun 2026 17:37:57 -0500 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20consolidate=20status/temporal=20in?= =?UTF-8?q?to=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;