feat(mcp): consolidate maintenance/lifecycle into maintain (7→1)

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) <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-06-28 17:57:25 -05:00
parent e3378316ed
commit fa87186724
3 changed files with 304 additions and 62 deletions

View file

@ -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;

View file

@ -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<Storage>,
cognitive: &Arc<Mutex<CognitiveEngine>>,
args: Option<Value>,
) -> Result<Value, String> {
// 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<Storage> {
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());
}
}

View file

@ -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;