From 34f5e8d52a92636ff231eac9ff92583bf30eaf1b Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Thu, 12 Feb 2026 04:33:05 -0600 Subject: [PATCH] =?UTF-8?q?feat:=20Vestige=20v1.2.0=20=E2=80=94=20dashboar?= =?UTF-8?q?d,=20temporal=20tools,=20maintenance=20tools,=20detail=20levels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add web dashboard (axum) on port 3927 with memory browser, search, and system stats. New MCP tools: memory_timeline, memory_changelog, health_check, consolidate, stats, backup, export, gc. Search now supports detail_level (brief/summary/full) to control token usage. Add backup_to() and get_recent_state_transitions() to storage layer. Bump to v1.2.0. --- Cargo.lock | 118 ++- crates/vestige-core/src/lib.rs | 4 +- crates/vestige-core/src/storage/mod.rs | 4 +- crates/vestige-core/src/storage/sqlite.rs | 36 + crates/vestige-mcp/Cargo.toml | 8 +- crates/vestige-mcp/src/bin/cli.rs | 41 + crates/vestige-mcp/src/dashboard.html | 955 ++++++++++++++++++ crates/vestige-mcp/src/dashboard/handlers.rs | 316 ++++++ crates/vestige-mcp/src/dashboard/mod.rs | 106 ++ crates/vestige-mcp/src/dashboard/state.rs | 11 + crates/vestige-mcp/src/lib.rs | 5 + crates/vestige-mcp/src/main.rs | 14 + crates/vestige-mcp/src/server.rs | 78 +- crates/vestige-mcp/src/tools/changelog.rs | 191 ++++ crates/vestige-mcp/src/tools/maintenance.rs | 550 ++++++++++ crates/vestige-mcp/src/tools/mod.rs | 7 + .../vestige-mcp/src/tools/search_unified.rs | 247 ++++- crates/vestige-mcp/src/tools/timeline.rs | 184 ++++ 18 files changed, 2850 insertions(+), 25 deletions(-) create mode 100644 crates/vestige-mcp/src/dashboard.html create mode 100644 crates/vestige-mcp/src/dashboard/handlers.rs create mode 100644 crates/vestige-mcp/src/dashboard/mod.rs create mode 100644 crates/vestige-mcp/src/dashboard/state.rs create mode 100644 crates/vestige-mcp/src/lib.rs create mode 100644 crates/vestige-mcp/src/tools/changelog.rs create mode 100644 crates/vestige-mcp/src/tools/maintenance.rs create mode 100644 crates/vestige-mcp/src/tools/timeline.rs diff --git a/Cargo.lock b/Cargo.lock index 8fc55bc..5aeb4d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -218,6 +218,56 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + [[package]] name = "base64" version = "0.13.1" @@ -1225,6 +1275,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.8.1" @@ -1239,6 +1295,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -1554,6 +1611,25 @@ dependencies = [ "serde", ] +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1795,6 +1871,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "matrixmultiply" version = "0.3.10" @@ -2085,6 +2167,17 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + [[package]] name = "openssl" version = "0.10.75" @@ -2210,6 +2303,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2886,6 +2985,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3251,8 +3361,10 @@ dependencies = [ "pin-project-lite", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -3577,13 +3689,15 @@ dependencies = [ [[package]] name = "vestige-mcp" -version = "1.1.3" +version = "1.2.0" dependencies = [ "anyhow", + "axum", "chrono", "clap", "colored", "directories", + "open", "rmcp", "rusqlite", "serde", @@ -3591,6 +3705,8 @@ dependencies = [ "tempfile", "thiserror", "tokio", + "tower", + "tower-http", "tracing", "tracing-subscriber", "uuid", diff --git a/crates/vestige-core/src/lib.rs b/crates/vestige-core/src/lib.rs index 7706ab5..ad8d569 100644 --- a/crates/vestige-core/src/lib.rs +++ b/crates/vestige-core/src/lib.rs @@ -138,8 +138,8 @@ pub use fsrs::{ // Storage layer pub use storage::{ - ConsolidationHistoryRecord, InsightRecord, IntentionRecord, Result, SmartIngestResult, Storage, - StorageError, + ConsolidationHistoryRecord, InsightRecord, IntentionRecord, Result, SmartIngestResult, + StateTransitionRecord, Storage, StorageError, }; // Consolidation (sleep-inspired memory processing) diff --git a/crates/vestige-core/src/storage/mod.rs b/crates/vestige-core/src/storage/mod.rs index b399496..3b3ac12 100644 --- a/crates/vestige-core/src/storage/mod.rs +++ b/crates/vestige-core/src/storage/mod.rs @@ -11,6 +11,6 @@ mod sqlite; pub use migrations::MIGRATIONS; pub use sqlite::{ - ConsolidationHistoryRecord, InsightRecord, IntentionRecord, Result, SmartIngestResult, Storage, - StorageError, + ConsolidationHistoryRecord, InsightRecord, IntentionRecord, Result, SmartIngestResult, + StateTransitionRecord, Storage, StorageError, }; diff --git a/crates/vestige-core/src/storage/sqlite.rs b/crates/vestige-core/src/storage/sqlite.rs index 97b5087..b2f789a 100644 --- a/crates/vestige-core/src/storage/sqlite.rs +++ b/crates/vestige-core/src/storage/sqlite.rs @@ -2278,6 +2278,42 @@ impl Storage { } Ok(result) } + + /// Create a consistent backup using VACUUM INTO + pub fn backup_to(&self, path: &std::path::Path) -> Result<()> { + let path_str = path.to_str().ok_or_else(|| { + StorageError::Init("Invalid backup path encoding".to_string()) + })?; + self.conn.execute_batch(&format!("VACUUM INTO '{}'", path_str.replace('\'', "''")))?; + Ok(()) + } + + /// Get recent state transitions across all memories (system-wide changelog) + pub fn get_recent_state_transitions(&self, limit: i32) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT * FROM state_transitions ORDER BY timestamp DESC LIMIT ?1" + )?; + + let rows = stmt.query_map(params![limit], |row| { + Ok(StateTransitionRecord { + id: row.get("id")?, + memory_id: row.get("memory_id")?, + from_state: row.get("from_state")?, + to_state: row.get("to_state")?, + reason_type: row.get("reason_type")?, + reason_data: row.get("reason_data").ok().flatten(), + timestamp: DateTime::parse_from_rfc3339(&row.get::<_, String>("timestamp")?) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(|_| Utc::now()), + }) + })?; + + let mut result = Vec::new(); + for row in rows { + result.push(row?); + } + Ok(result) + } } // ============================================================================ diff --git a/crates/vestige-mcp/Cargo.toml b/crates/vestige-mcp/Cargo.toml index 61d819a..9b22a4f 100644 --- a/crates/vestige-mcp/Cargo.toml +++ b/crates/vestige-mcp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vestige-mcp" -version = "1.1.3" +version = "1.2.0" edition = "2024" description = "Cognitive memory MCP server for Claude - FSRS-6, spreading activation, synaptic tagging, and 130 years of memory research" authors = ["samvallad33"] @@ -71,5 +71,11 @@ colored = "3" # SQLite (for backup WAL checkpoint) rusqlite = { version = "0.38", features = ["bundled"] } +# Dashboard (v1.2) - hyper/tower already in Cargo.lock via rmcp/reqwest +axum = { version = "0.8", default-features = false, features = ["json", "query", "tokio", "http1"] } +tower = { version = "0.5", features = ["limit"] } +tower-http = { version = "0.6", features = ["cors", "set-header"] } +open = "5" + [dev-dependencies] tempfile = "3" diff --git a/crates/vestige-mcp/src/bin/cli.rs b/crates/vestige-mcp/src/bin/cli.rs index a32cad6..1929917 100644 --- a/crates/vestige-mcp/src/bin/cli.rs +++ b/crates/vestige-mcp/src/bin/cli.rs @@ -84,6 +84,16 @@ enum Commands { #[arg(long)] yes: bool, }, + + /// Launch the memory web dashboard + Dashboard { + /// Port to bind the dashboard server to + #[arg(long, default_value = "3927")] + port: u16, + /// Don't automatically open the browser + #[arg(long)] + no_open: bool, + }, } fn main() -> anyhow::Result<()> { @@ -107,6 +117,7 @@ fn main() -> anyhow::Result<()> { dry_run, yes, } => run_gc(min_retention, max_age_days, dry_run, yes), + Commands::Dashboard { port, no_open } => run_dashboard(port, !no_open), } } @@ -831,6 +842,36 @@ fn run_gc( Ok(()) } +/// Run the dashboard web server +fn run_dashboard(port: u16, open_browser: bool) -> anyhow::Result<()> { + println!("{}", "=== Vestige Dashboard ===".cyan().bold()); + println!(); + println!("Starting dashboard at {}...", format!("http://127.0.0.1:{}", port).cyan()); + + let mut storage = Storage::new(None)?; + + // Try to initialize embeddings for search support + #[cfg(feature = "embeddings")] + { + if let Err(e) = storage.init_embeddings() { + println!( + " {} Embeddings unavailable: {} (search will use keyword-only)", + "!".yellow(), + e + ); + } + } + + let storage = std::sync::Arc::new(tokio::sync::Mutex::new(storage)); + + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(async move { + vestige_mcp::dashboard::start_dashboard(storage, port, open_browser) + .await + .map_err(|e| anyhow::anyhow!("Dashboard error: {}", e)) + }) +} + /// Truncate a string for display (UTF-8 safe) fn truncate(s: &str, max_chars: usize) -> String { let s = s.replace('\n', " "); diff --git a/crates/vestige-mcp/src/dashboard.html b/crates/vestige-mcp/src/dashboard.html new file mode 100644 index 0000000..73b3080 --- /dev/null +++ b/crates/vestige-mcp/src/dashboard.html @@ -0,0 +1,955 @@ + + + + + +Vestige Memory Dashboard + + + + +
+ +
+
+ + v-- + + + Connecting... + +
+
+ + +
+
+ + +
+
+
--
+
Total Memories
+
+
+
--%
+
Avg Retention
+
+
+
--%
+
Embedding Coverage
+
+
+
--
+
Due for Review
+
+
+ + +
+
+ + +
+
+ + + +
+
+ + +
+
+
+
Select a memory to view details
+ +
+
+ + + +
+ + + + + +
+ + + + diff --git a/crates/vestige-mcp/src/dashboard/handlers.rs b/crates/vestige-mcp/src/dashboard/handlers.rs new file mode 100644 index 0000000..17b3f93 --- /dev/null +++ b/crates/vestige-mcp/src/dashboard/handlers.rs @@ -0,0 +1,316 @@ +//! Dashboard API endpoint handlers + +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::response::{Html, Json}; +use chrono::{Duration, Utc}; +use serde::Deserialize; +use serde_json::Value; + +use super::state::AppState; + +/// Serve the dashboard HTML +pub async fn serve_dashboard() -> Html<&'static str> { + Html(include_str!("../dashboard.html")) +} + +#[derive(Debug, Deserialize)] +pub struct MemoryListParams { + pub q: Option, + pub node_type: Option, + pub tag: Option, + pub min_retention: Option, + pub sort: Option, + pub limit: Option, + pub offset: Option, +} + +/// List memories with optional search +pub async fn list_memories( + State(state): State, + Query(params): Query, +) -> Result, StatusCode> { + let storage = state.storage.lock().await; + let limit = params.limit.unwrap_or(50).clamp(1, 200); + let offset = params.offset.unwrap_or(0).max(0); + + if let Some(query) = params.q.as_ref().filter(|q| !q.trim().is_empty()) { + { + // Use hybrid search + let results = storage + .hybrid_search(query, limit, 0.5, 0.5) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let formatted: Vec = results + .into_iter() + .filter(|r| { + if let Some(min_ret) = params.min_retention { + r.node.retention_strength >= min_ret + } else { + true + } + }) + .map(|r| { + serde_json::json!({ + "id": r.node.id, + "content": r.node.content, + "nodeType": r.node.node_type, + "tags": r.node.tags, + "retentionStrength": r.node.retention_strength, + "storageStrength": r.node.storage_strength, + "retrievalStrength": r.node.retrieval_strength, + "createdAt": r.node.created_at.to_rfc3339(), + "updatedAt": r.node.updated_at.to_rfc3339(), + "combinedScore": r.combined_score, + "source": r.node.source, + "reviewCount": r.node.reps, + }) + }) + .collect(); + + return Ok(Json(serde_json::json!({ + "total": formatted.len(), + "memories": formatted, + }))); + } + } + + // No search query — list all memories + let mut nodes = storage + .get_all_nodes(limit, offset) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Apply filters + if let Some(ref node_type) = params.node_type { + nodes.retain(|n| n.node_type == *node_type); + } + if let Some(ref tag) = params.tag { + nodes.retain(|n| n.tags.iter().any(|t| t == tag)); + } + if let Some(min_ret) = params.min_retention { + nodes.retain(|n| n.retention_strength >= min_ret); + } + + let formatted: Vec = nodes + .iter() + .map(|n| { + serde_json::json!({ + "id": n.id, + "content": n.content, + "nodeType": n.node_type, + "tags": n.tags, + "retentionStrength": n.retention_strength, + "storageStrength": n.storage_strength, + "retrievalStrength": n.retrieval_strength, + "createdAt": n.created_at.to_rfc3339(), + "updatedAt": n.updated_at.to_rfc3339(), + "source": n.source, + "reviewCount": n.reps, + }) + }) + .collect(); + + Ok(Json(serde_json::json!({ + "total": formatted.len(), + "memories": formatted, + }))) +} + +/// Get a single memory by ID +pub async fn get_memory( + State(state): State, + Path(id): Path, +) -> Result, StatusCode> { + let storage = state.storage.lock().await; + let node = storage + .get_node(&id) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + Ok(Json(serde_json::json!({ + "id": node.id, + "content": node.content, + "nodeType": node.node_type, + "tags": node.tags, + "retentionStrength": node.retention_strength, + "storageStrength": node.storage_strength, + "retrievalStrength": node.retrieval_strength, + "sentimentScore": node.sentiment_score, + "sentimentMagnitude": node.sentiment_magnitude, + "source": node.source, + "createdAt": node.created_at.to_rfc3339(), + "updatedAt": node.updated_at.to_rfc3339(), + "lastAccessedAt": node.last_accessed.to_rfc3339(), + "nextReviewAt": node.next_review.map(|dt| dt.to_rfc3339()), + "reviewCount": node.reps, + "validFrom": node.valid_from.map(|dt| dt.to_rfc3339()), + "validUntil": node.valid_until.map(|dt| dt.to_rfc3339()), + }))) +} + +/// Delete a memory by ID +pub async fn delete_memory( + State(state): State, + Path(id): Path, +) -> Result, StatusCode> { + let mut storage = state.storage.lock().await; + let deleted = storage + .delete_node(&id) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if deleted { + Ok(Json(serde_json::json!({ "deleted": true, "id": id }))) + } else { + Err(StatusCode::NOT_FOUND) + } +} + +/// Promote a memory +pub async fn promote_memory( + State(state): State, + Path(id): Path, +) -> Result, StatusCode> { + let storage = state.storage.lock().await; + let node = storage + .promote_memory(&id) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(serde_json::json!({ + "promoted": true, + "id": node.id, + "retentionStrength": node.retention_strength, + }))) +} + +/// Demote a memory +pub async fn demote_memory( + State(state): State, + Path(id): Path, +) -> Result, StatusCode> { + let storage = state.storage.lock().await; + let node = storage + .demote_memory(&id) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(serde_json::json!({ + "demoted": true, + "id": node.id, + "retentionStrength": node.retention_strength, + }))) +} + +/// Get system stats +pub async fn get_stats( + State(state): State, +) -> Result, StatusCode> { + let storage = state.storage.lock().await; + let stats = storage + .get_stats() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let embedding_coverage = if stats.total_nodes > 0 { + (stats.nodes_with_embeddings as f64 / stats.total_nodes as f64) * 100.0 + } else { + 0.0 + }; + + Ok(Json(serde_json::json!({ + "totalMemories": stats.total_nodes, + "dueForReview": stats.nodes_due_for_review, + "averageRetention": stats.average_retention, + "averageStorageStrength": stats.average_storage_strength, + "averageRetrievalStrength": stats.average_retrieval_strength, + "withEmbeddings": stats.nodes_with_embeddings, + "embeddingCoverage": embedding_coverage, + "embeddingModel": stats.embedding_model, + "oldestMemory": stats.oldest_memory.map(|dt| dt.to_rfc3339()), + "newestMemory": stats.newest_memory.map(|dt| dt.to_rfc3339()), + }))) +} + +#[derive(Debug, Deserialize)] +pub struct TimelineParams { + pub days: Option, + pub limit: Option, +} + +/// Get timeline data +pub async fn get_timeline( + State(state): State, + Query(params): Query, +) -> Result, StatusCode> { + let storage = state.storage.lock().await; + let days = params.days.unwrap_or(7).clamp(1, 90); + let limit = params.limit.unwrap_or(200).clamp(1, 500); + + let start = Utc::now() - Duration::days(days); + let nodes = storage + .query_time_range(Some(start), Some(Utc::now()), limit) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Group by day + let mut by_day: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + for node in &nodes { + let date = node.created_at.format("%Y-%m-%d").to_string(); + let content_preview: String = { + let preview: String = node.content.chars().take(100).collect(); + if preview.len() < node.content.len() { + format!("{}...", preview) + } else { + preview + } + }; + by_day.entry(date).or_default().push(serde_json::json!({ + "id": node.id, + "content": content_preview, + "nodeType": node.node_type, + "retentionStrength": node.retention_strength, + "createdAt": node.created_at.to_rfc3339(), + })); + } + + let timeline: Vec = by_day + .into_iter() + .rev() + .map(|(date, memories)| { + serde_json::json!({ + "date": date, + "count": memories.len(), + "memories": memories, + }) + }) + .collect(); + + Ok(Json(serde_json::json!({ + "days": days, + "totalMemories": nodes.len(), + "timeline": timeline, + }))) +} + +/// Health check +pub async fn health_check( + State(state): State, +) -> Result, StatusCode> { + let storage = state.storage.lock().await; + let stats = storage + .get_stats() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let status = if stats.total_nodes == 0 { + "empty" + } else if stats.average_retention < 0.3 { + "critical" + } else if stats.average_retention < 0.5 { + "degraded" + } else { + "healthy" + }; + + Ok(Json(serde_json::json!({ + "status": status, + "totalMemories": stats.total_nodes, + "averageRetention": stats.average_retention, + "version": env!("CARGO_PKG_VERSION"), + }))) +} diff --git a/crates/vestige-mcp/src/dashboard/mod.rs b/crates/vestige-mcp/src/dashboard/mod.rs new file mode 100644 index 0000000..c7f343a --- /dev/null +++ b/crates/vestige-mcp/src/dashboard/mod.rs @@ -0,0 +1,106 @@ +//! Memory Web Dashboard +//! +//! Self-contained web UI at localhost:3927 for browsing, searching, +//! and managing Vestige memories. Auto-starts inside the MCP server process. + +pub mod handlers; +pub mod state; + +use axum::routing::{delete, get, post}; +use axum::Router; +use std::net::SocketAddr; +use std::sync::Arc; +use tokio::sync::Mutex; +use tower::ServiceBuilder; +use tower_http::cors::CorsLayer; +use tower_http::set_header::SetResponseHeaderLayer; +use tracing::{info, warn}; + +use state::AppState; +use vestige_core::Storage; + +/// Build the axum router with all dashboard routes +pub fn build_router(storage: Arc>, port: u16) -> Router { + let state = AppState { storage }; + + let origin = format!("http://127.0.0.1:{}", port) + .parse::() + .expect("valid origin"); + let cors = CorsLayer::new() + .allow_origin(origin) + .allow_methods([axum::http::Method::GET, axum::http::Method::POST, axum::http::Method::DELETE]) + .allow_headers([axum::http::header::CONTENT_TYPE]); + + let csp = SetResponseHeaderLayer::overriding( + axum::http::header::CONTENT_SECURITY_POLICY, + axum::http::HeaderValue::from_static("default-src 'self' 'unsafe-inline'"), + ); + + Router::new() + // Dashboard UI + .route("/", get(handlers::serve_dashboard)) + // API endpoints + .route("/api/memories", get(handlers::list_memories)) + .route("/api/memories/{id}", get(handlers::get_memory)) + .route("/api/memories/{id}", delete(handlers::delete_memory)) + .route("/api/memories/{id}/promote", post(handlers::promote_memory)) + .route("/api/memories/{id}/demote", post(handlers::demote_memory)) + .route("/api/stats", get(handlers::get_stats)) + .route("/api/timeline", get(handlers::get_timeline)) + .route("/api/health", get(handlers::health_check)) + .layer( + ServiceBuilder::new() + .concurrency_limit(10) + .layer(cors) + .layer(csp) + ) + .with_state(state) +} + +/// Start the dashboard HTTP server (blocking — use in CLI mode) +pub async fn start_dashboard( + storage: Arc>, + port: u16, + open_browser: bool, +) -> Result<(), Box> { + let app = build_router(storage, port); + let addr = SocketAddr::from(([127, 0, 0, 1], port)); + + info!("Dashboard starting at http://127.0.0.1:{}", port); + + if open_browser { + let url = format!("http://127.0.0.1:{}", port); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + let _ = open::that(&url); + }); + } + + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app).await?; + Ok(()) +} + +/// Start the dashboard as a background task (non-blocking — use in MCP server) +pub async fn start_background( + storage: Arc>, + port: u16, +) -> Result<(), Box> { + let app = build_router(storage, port); + let addr = SocketAddr::from(([127, 0, 0, 1], port)); + + let listener = match tokio::net::TcpListener::bind(addr).await { + Ok(l) => l, + Err(e) => { + warn!( + "Dashboard could not bind to port {}: {} (MCP server continues without dashboard)", + port, e + ); + return Err(Box::new(e)); + } + }; + + info!("Dashboard available at http://127.0.0.1:{}", port); + axum::serve(listener, app).await?; + Ok(()) +} diff --git a/crates/vestige-mcp/src/dashboard/state.rs b/crates/vestige-mcp/src/dashboard/state.rs new file mode 100644 index 0000000..c53d8c7 --- /dev/null +++ b/crates/vestige-mcp/src/dashboard/state.rs @@ -0,0 +1,11 @@ +//! Dashboard shared state + +use std::sync::Arc; +use tokio::sync::Mutex; +use vestige_core::Storage; + +/// Shared application state for the dashboard +#[derive(Clone)] +pub struct AppState { + pub storage: Arc>, +} diff --git a/crates/vestige-mcp/src/lib.rs b/crates/vestige-mcp/src/lib.rs new file mode 100644 index 0000000..4d04bf1 --- /dev/null +++ b/crates/vestige-mcp/src/lib.rs @@ -0,0 +1,5 @@ +//! Vestige MCP Server Library +//! +//! Shared modules accessible to all binaries in the crate. + +pub mod dashboard; diff --git a/crates/vestige-mcp/src/main.rs b/crates/vestige-mcp/src/main.rs index 4f5ebf4..084a075 100644 --- a/crates/vestige-mcp/src/main.rs +++ b/crates/vestige-mcp/src/main.rs @@ -208,6 +208,20 @@ async fn main() { }); } + // Spawn dashboard HTTP server alongside MCP server + { + let dashboard_port = std::env::var("VESTIGE_DASHBOARD_PORT") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(3927); + let dashboard_storage = Arc::clone(&storage); + tokio::spawn(async move { + if let Err(e) = vestige_mcp::dashboard::start_background(dashboard_storage, dashboard_port).await { + warn!("Dashboard failed to start: {}", e); + } + }); + } + // Create MCP server let server = McpServer::new(storage); diff --git a/crates/vestige-mcp/src/server.rs b/crates/vestige-mcp/src/server.rs index 76f48eb..8302391 100644 --- a/crates/vestige-mcp/src/server.rs +++ b/crates/vestige-mcp/src/server.rs @@ -176,6 +176,52 @@ impl McpServer { description: Some("Demote a memory (thumbs down). Use when a memory led to a bad outcome or was wrong. Decreases retrieval strength so better alternatives surface. Does NOT delete.".to_string()), input_schema: tools::feedback::demote_schema(), }, + // ================================================================ + // TEMPORAL TOOLS (v1.2+) + // ================================================================ + 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(), + }, + 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(), + }, + // ================================================================ + // MAINTENANCE TOOLS (v1.2+) + // ================================================================ + ToolDescription { + name: "health_check".to_string(), + description: Some("System health status with warnings and recommendations. Returns status (healthy/degraded/critical/empty), stats, and actionable advice.".to_string()), + input_schema: tools::maintenance::health_check_schema(), + }, + 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(), + }, + ToolDescription { + name: "stats".to_string(), + description: Some("Full memory system statistics including total count, retention distribution, embedding coverage, and cognitive state breakdown.".to_string()), + input_schema: tools::maintenance::stats_schema(), + }, + 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(), + }, + 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(), + }, + 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(), + }, ]; let result = ListToolsResult { tools }; @@ -423,6 +469,22 @@ impl McpServer { "demote_memory" => tools::feedback::execute_demote(&self.storage, request.arguments).await, "request_feedback" => tools::feedback::execute_request_feedback(&self.storage, request.arguments).await, + // ================================================================ + // TEMPORAL TOOLS (v1.2+) + // ================================================================ + "memory_timeline" => tools::timeline::execute(&self.storage, request.arguments).await, + "memory_changelog" => tools::changelog::execute(&self.storage, request.arguments).await, + + // ================================================================ + // MAINTENANCE TOOLS (v1.2+) + // ================================================================ + "health_check" => tools::maintenance::execute_health_check(&self.storage, request.arguments).await, + "consolidate" => tools::maintenance::execute_consolidate(&self.storage, request.arguments).await, + "stats" => tools::maintenance::execute_stats(&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, + name => { return Err(JsonRpcError::method_not_found_with_message(&format!( "Unknown tool: {}", @@ -726,8 +788,8 @@ mod tests { let result = response.result.unwrap(); let tools = result["tools"].as_array().unwrap(); - // v1.1+: Only 8 tools are exposed (deprecated tools work internally but aren't listed) - assert_eq!(tools.len(), 8, "Expected exactly 8 tools in v1.1+"); + // v1.2+: 16 tools (8 unified + 2 temporal + 6 maintenance) + assert_eq!(tools.len(), 16, "Expected exactly 16 tools in v1.2+"); let tool_names: Vec<&str> = tools .iter() @@ -747,6 +809,18 @@ mod tests { // Feedback tools assert!(tool_names.contains(&"promote_memory")); assert!(tool_names.contains(&"demote_memory")); + + // Temporal tools (v1.2) + assert!(tool_names.contains(&"memory_timeline")); + assert!(tool_names.contains(&"memory_changelog")); + + // Maintenance tools (v1.2) + assert!(tool_names.contains(&"health_check")); + assert!(tool_names.contains(&"consolidate")); + assert!(tool_names.contains(&"stats")); + assert!(tool_names.contains(&"backup")); + assert!(tool_names.contains(&"export")); + assert!(tool_names.contains(&"gc")); } #[tokio::test] diff --git a/crates/vestige-mcp/src/tools/changelog.rs b/crates/vestige-mcp/src/tools/changelog.rs new file mode 100644 index 0000000..9ccbfe0 --- /dev/null +++ b/crates/vestige-mcp/src/tools/changelog.rs @@ -0,0 +1,191 @@ +//! Memory Changelog Tool +//! +//! View audit trail of memory changes. +//! Per-memory mode: state transitions for a single memory. +//! System-wide mode: consolidations + recent state transitions. + +use chrono::{DateTime, Utc}; +use serde::Deserialize; +use serde_json::Value; +use std::sync::Arc; +use tokio::sync::Mutex; +use uuid::Uuid; + +use vestige_core::Storage; + +/// Input schema for memory_changelog tool +pub fn schema() -> Value { + serde_json::json!({ + "type": "object", + "properties": { + "memory_id": { + "type": "string", + "description": "Scope to a single memory's audit trail. If omitted, returns system-wide changelog." + }, + "start": { + "type": "string", + "description": "Start of time range (ISO 8601). Only used in system-wide mode." + }, + "end": { + "type": "string", + "description": "End of time range (ISO 8601). Only used in system-wide mode." + }, + "limit": { + "type": "integer", + "description": "Maximum number of entries (default: 20, max: 100)", + "default": 20, + "minimum": 1, + "maximum": 100 + } + } + }) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ChangelogArgs { + memory_id: Option, + #[allow(dead_code)] + start: Option, + #[allow(dead_code)] + end: Option, + limit: Option, +} + +/// Execute memory_changelog tool +pub async fn execute( + storage: &Arc>, + args: Option, +) -> Result { + let args: ChangelogArgs = match args { + Some(v) => serde_json::from_value(v).map_err(|e| format!("Invalid arguments: {}", e))?, + None => ChangelogArgs { + memory_id: None, + start: None, + end: None, + limit: None, + }, + }; + + let limit = args.limit.unwrap_or(20).clamp(1, 100); + let storage = storage.lock().await; + + if let Some(ref memory_id) = args.memory_id { + // Per-memory mode: state transitions for a specific memory + execute_per_memory(&storage, memory_id, limit) + } else { + // System-wide mode: consolidations + recent transitions + execute_system_wide(&storage, limit) + } +} + +/// Per-memory changelog: state transition audit trail +fn execute_per_memory( + storage: &Storage, + memory_id: &str, + limit: i32, +) -> Result { + // Validate UUID format + Uuid::parse_str(memory_id) + .map_err(|_| format!("Invalid memory_id '{}'. Must be a valid UUID.", memory_id))?; + + // Get the memory for context + let node = storage + .get_node(memory_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Memory '{}' not found.", memory_id))?; + + // Get state transitions + let transitions = storage + .get_state_transitions(memory_id, limit) + .map_err(|e| e.to_string())?; + + let formatted_transitions: Vec = transitions + .iter() + .map(|t| { + serde_json::json!({ + "fromState": t.from_state, + "toState": t.to_state, + "reasonType": t.reason_type, + "reasonData": t.reason_data, + "timestamp": t.timestamp.to_rfc3339(), + }) + }) + .collect(); + + Ok(serde_json::json!({ + "tool": "memory_changelog", + "mode": "per_memory", + "memoryId": memory_id, + "memoryContent": node.content, + "memoryType": node.node_type, + "currentRetention": node.retention_strength, + "totalTransitions": formatted_transitions.len(), + "transitions": formatted_transitions, + })) +} + +/// System-wide changelog: consolidations + recent state transitions +fn execute_system_wide( + storage: &Storage, + limit: i32, +) -> Result { + // Get consolidation history + let consolidations = storage + .get_consolidation_history(limit) + .map_err(|e| e.to_string())?; + + // Get recent state transitions across all memories + let transitions = storage + .get_recent_state_transitions(limit) + .map_err(|e| e.to_string())?; + + // Build unified event list + let mut events: Vec<(DateTime, Value)> = Vec::new(); + + for c in &consolidations { + events.push(( + c.completed_at, + serde_json::json!({ + "type": "consolidation", + "timestamp": c.completed_at.to_rfc3339(), + "durationMs": c.duration_ms, + "memoriesReplayed": c.memories_replayed, + "connectionFound": c.connections_found, + "connectionsStrengthened": c.connections_strengthened, + "connectionsPruned": c.connections_pruned, + "insightsGenerated": c.insights_generated, + }), + )); + } + + for t in &transitions { + events.push(( + t.timestamp, + serde_json::json!({ + "type": "state_transition", + "timestamp": t.timestamp.to_rfc3339(), + "memoryId": t.memory_id, + "fromState": t.from_state, + "toState": t.to_state, + "reasonType": t.reason_type, + "reasonData": t.reason_data, + }), + )); + } + + // Sort by timestamp descending + events.sort_by(|a, b| b.0.cmp(&a.0)); + + // Truncate to limit + events.truncate(limit as usize); + + let formatted_events: Vec = events.into_iter().map(|(_, v)| v).collect(); + + Ok(serde_json::json!({ + "tool": "memory_changelog", + "mode": "system_wide", + "totalEvents": formatted_events.len(), + "events": formatted_events, + })) +} diff --git a/crates/vestige-mcp/src/tools/maintenance.rs b/crates/vestige-mcp/src/tools/maintenance.rs new file mode 100644 index 0000000..59dbf46 --- /dev/null +++ b/crates/vestige-mcp/src/tools/maintenance.rs @@ -0,0 +1,550 @@ +//! Maintenance MCP Tools +//! +//! Exposes CLI-only operations as MCP tools so Claude can trigger them automatically: +//! health_check, consolidate, stats, backup, export, gc. + +use chrono::{NaiveDate, Utc}; +use serde::Deserialize; +use serde_json::Value; +use std::sync::Arc; +use tokio::sync::Mutex; + +use vestige_core::Storage; + +// ============================================================================ +// SCHEMAS +// ============================================================================ + +pub fn health_check_schema() -> Value { + serde_json::json!({ + "type": "object", + "properties": {} + }) +} + +pub fn consolidate_schema() -> Value { + serde_json::json!({ + "type": "object", + "properties": {} + }) +} + +pub fn stats_schema() -> Value { + serde_json::json!({ + "type": "object", + "properties": {} + }) +} + +pub fn backup_schema() -> Value { + serde_json::json!({ + "type": "object", + "properties": {} + }) +} + +pub fn export_schema() -> Value { + serde_json::json!({ + "type": "object", + "properties": { + "format": { + "type": "string", + "description": "Export format: 'json' (default) or 'jsonl'", + "enum": ["json", "jsonl"], + "default": "json" + }, + "tags": { + "type": "array", + "items": { "type": "string" }, + "description": "Filter by tags (ALL must match)" + }, + "since": { + "type": "string", + "description": "Only export memories created after this date (YYYY-MM-DD)" + }, + "path": { + "type": "string", + "description": "Custom filename (not path). File is saved in ~/.vestige/exports/. Default: memories-{timestamp}.{format}" + } + } + }) +} + +pub fn gc_schema() -> Value { + serde_json::json!({ + "type": "object", + "properties": { + "min_retention": { + "type": "number", + "description": "Delete memories with retention below this threshold (default: 0.1)", + "default": 0.1, + "minimum": 0.0, + "maximum": 1.0 + }, + "max_age_days": { + "type": "integer", + "description": "Only delete memories older than this many days (optional additional filter)", + "minimum": 1 + }, + "dry_run": { + "type": "boolean", + "description": "If true (default), only report what would be deleted without actually deleting", + "default": true + } + } + }) +} + +// ============================================================================ +// EXECUTE FUNCTIONS +// ============================================================================ + +/// Health check tool +pub async fn execute_health_check( + storage: &Arc>, + _args: Option, +) -> Result { + let storage = storage.lock().await; + let stats = storage.get_stats().map_err(|e| e.to_string())?; + + let status = if stats.total_nodes == 0 { + "empty" + } else if stats.average_retention < 0.3 { + "critical" + } else if stats.average_retention < 0.5 { + "degraded" + } else { + "healthy" + }; + + let embedding_coverage = if stats.total_nodes > 0 { + (stats.nodes_with_embeddings as f64 / stats.total_nodes as f64) * 100.0 + } else { + 0.0 + }; + + let embedding_ready = storage.is_embedding_ready(); + + let mut warnings = Vec::new(); + if stats.average_retention < 0.5 && stats.total_nodes > 0 { + warnings.push("Low average retention - consider running consolidation"); + } + if stats.nodes_due_for_review > 10 { + warnings.push("Many memories are due for review"); + } + if stats.total_nodes > 0 && stats.nodes_with_embeddings == 0 { + warnings.push("No embeddings generated - semantic search unavailable"); + } + if embedding_coverage < 50.0 && stats.total_nodes > 10 { + warnings.push("Low embedding coverage - run consolidate to improve semantic search"); + } + + let mut recommendations = Vec::new(); + if status == "critical" { + recommendations.push("CRITICAL: Many memories have very low retention. Review important memories."); + } + if stats.nodes_due_for_review > 5 { + recommendations.push("Review due memories to strengthen retention."); + } + if stats.nodes_with_embeddings < stats.total_nodes { + recommendations.push("Run 'consolidate' to generate missing embeddings."); + } + if stats.total_nodes > 100 && stats.average_retention < 0.7 { + recommendations.push("Consider running periodic consolidation."); + } + if status == "healthy" && recommendations.is_empty() { + recommendations.push("Memory system is healthy!"); + } + + Ok(serde_json::json!({ + "tool": "health_check", + "status": status, + "totalMemories": stats.total_nodes, + "dueForReview": stats.nodes_due_for_review, + "averageRetention": stats.average_retention, + "embeddingCoverage": format!("{:.1}%", embedding_coverage), + "embeddingReady": embedding_ready, + "warnings": warnings, + "recommendations": recommendations, + })) +} + +/// Consolidate tool +pub async fn execute_consolidate( + storage: &Arc>, + _args: Option, +) -> Result { + let mut storage = storage.lock().await; + let result = storage.run_consolidation().map_err(|e| e.to_string())?; + + Ok(serde_json::json!({ + "tool": "consolidate", + "nodesProcessed": result.nodes_processed, + "nodesPromoted": result.nodes_promoted, + "nodesPruned": result.nodes_pruned, + "decayApplied": result.decay_applied, + "embeddingsGenerated": result.embeddings_generated, + "durationMs": result.duration_ms, + })) +} + +/// Stats tool +pub async fn execute_stats( + storage: &Arc>, + _args: Option, +) -> Result { + let storage = storage.lock().await; + let stats = storage.get_stats().map_err(|e| e.to_string())?; + + // Compute state distribution from a sample of nodes + let nodes = storage.get_all_nodes(500, 0).map_err(|e| e.to_string())?; + let total = nodes.len(); + let (active, dormant, silent, unavailable) = if total > 0 { + let mut a = 0usize; + let mut d = 0usize; + let mut s = 0usize; + let mut u = 0usize; + for node in &nodes { + let accessibility = node.retention_strength * 0.5 + + node.retrieval_strength * 0.3 + + node.storage_strength * 0.2; + if accessibility >= 0.7 { + a += 1; + } else if accessibility >= 0.4 { + d += 1; + } else if accessibility >= 0.1 { + s += 1; + } else { + u += 1; + } + } + (a, d, s, u) + } else { + (0, 0, 0, 0) + }; + + let embedding_coverage = if stats.total_nodes > 0 { + (stats.nodes_with_embeddings as f64 / stats.total_nodes as f64) * 100.0 + } else { + 0.0 + }; + + Ok(serde_json::json!({ + "tool": "stats", + "totalMemories": stats.total_nodes, + "dueForReview": stats.nodes_due_for_review, + "averageRetention": stats.average_retention, + "averageStorageStrength": stats.average_storage_strength, + "averageRetrievalStrength": stats.average_retrieval_strength, + "withEmbeddings": stats.nodes_with_embeddings, + "embeddingCoverage": format!("{:.1}%", embedding_coverage), + "embeddingModel": stats.embedding_model, + "oldestMemory": stats.oldest_memory.map(|dt| dt.to_rfc3339()), + "newestMemory": stats.newest_memory.map(|dt| dt.to_rfc3339()), + "stateDistribution": { + "active": active, + "dormant": dormant, + "silent": silent, + "unavailable": unavailable, + "sampled": total, + }, + })) +} + +/// Backup tool +pub async fn execute_backup( + storage: &Arc>, + _args: Option, +) -> Result { + // Determine backup path + let vestige_dir = directories::ProjectDirs::from("com", "vestige", "core") + .ok_or("Could not determine data directory")?; + let backup_dir = vestige_dir.data_dir().parent() + .unwrap_or(vestige_dir.data_dir()) + .join("backups"); + + std::fs::create_dir_all(&backup_dir) + .map_err(|e| format!("Failed to create backup directory: {}", e))?; + + let timestamp = Utc::now().format("%Y%m%d-%H%M%S"); + let backup_path = backup_dir.join(format!("vestige-{}.db", timestamp)); + + // Use VACUUM INTO for a consistent backup (handles WAL properly) + { + let storage = storage.lock().await; + storage.backup_to(&backup_path) + .map_err(|e| format!("Failed to create backup: {}", e))?; + } + + let file_size = std::fs::metadata(&backup_path) + .map(|m| m.len()) + .unwrap_or(0); + + Ok(serde_json::json!({ + "tool": "backup", + "path": backup_path.display().to_string(), + "sizeBytes": file_size, + "timestamp": Utc::now().to_rfc3339(), + })) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ExportArgs { + format: Option, + tags: Option>, + since: Option, + path: Option, +} + +/// Export tool +pub async fn execute_export( + storage: &Arc>, + args: Option, +) -> Result { + let args: ExportArgs = match args { + Some(v) => serde_json::from_value(v).map_err(|e| format!("Invalid arguments: {}", e))?, + None => ExportArgs { + format: None, + tags: None, + since: None, + path: None, + }, + }; + + let format = args.format.unwrap_or_else(|| "json".to_string()); + if format != "json" && format != "jsonl" { + return Err(format!("Invalid format '{}'. Must be 'json' or 'jsonl'.", format)); + } + + // Parse since date + let since_date = match &args.since { + Some(date_str) => { + let naive = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") + .map_err(|e| format!("Invalid date '{}': {}. Use YYYY-MM-DD.", date_str, e))?; + Some(naive.and_hms_opt(0, 0, 0).unwrap().and_utc()) + } + None => None, + }; + + let tag_filter: Vec = args.tags.unwrap_or_default(); + + // Fetch all nodes (capped at 100K to prevent OOM) + let storage = storage.lock().await; + let mut all_nodes = Vec::new(); + let page_size = 500; + let max_nodes = 100_000; + let mut offset = 0; + loop { + let batch = storage.get_all_nodes(page_size, offset).map_err(|e| e.to_string())?; + let batch_len = batch.len(); + all_nodes.extend(batch); + if batch_len < page_size as usize || all_nodes.len() >= max_nodes { + break; + } + offset += page_size; + } + + // Apply filters + let filtered: Vec<&vestige_core::KnowledgeNode> = all_nodes + .iter() + .filter(|node| { + if since_date.as_ref().is_some_and(|since_dt| node.created_at < *since_dt) { + return false; + } + if !tag_filter.is_empty() { + for tag in &tag_filter { + if !node.tags.iter().any(|t| t == tag) { + return false; + } + } + } + true + }) + .collect(); + + // Determine export path — always constrained to vestige exports directory + let vestige_dir = directories::ProjectDirs::from("com", "vestige", "core") + .ok_or("Could not determine data directory")?; + let export_dir = vestige_dir.data_dir().parent() + .unwrap_or(vestige_dir.data_dir()) + .join("exports"); + std::fs::create_dir_all(&export_dir) + .map_err(|e| format!("Failed to create export directory: {}", e))?; + + let export_path = match args.path { + Some(ref p) => { + // Only allow a filename, not a path — prevent path traversal + let filename = std::path::Path::new(p) + .file_name() + .ok_or("Invalid export filename: must be a simple filename, not a path")?; + let name_str = filename.to_str().ok_or("Invalid filename encoding")?; + if name_str.contains("..") { + return Err("Invalid export filename: '..' not allowed".to_string()); + } + export_dir.join(filename) + } + None => { + let timestamp = Utc::now().format("%Y%m%d-%H%M%S"); + export_dir.join(format!("memories-{}.{}", timestamp, format)) + } + }; + + // Write export + let file = std::fs::File::create(&export_path) + .map_err(|e| format!("Failed to create export file: {}", e))?; + let mut writer = std::io::BufWriter::new(file); + + use std::io::Write; + match format.as_str() { + "json" => { + serde_json::to_writer_pretty(&mut writer, &filtered) + .map_err(|e| format!("Failed to write JSON: {}", e))?; + writer.write_all(b"\n").map_err(|e| e.to_string())?; + } + "jsonl" => { + for node in &filtered { + serde_json::to_writer(&mut writer, node) + .map_err(|e| format!("Failed to write JSONL: {}", e))?; + writer.write_all(b"\n").map_err(|e| e.to_string())?; + } + } + _ => unreachable!(), + } + writer.flush().map_err(|e| e.to_string())?; + + let file_size = std::fs::metadata(&export_path).map(|m| m.len()).unwrap_or(0); + + Ok(serde_json::json!({ + "tool": "export", + "path": export_path.display().to_string(), + "format": format, + "memoriesExported": filtered.len(), + "totalMemories": all_nodes.len(), + "sizeBytes": file_size, + })) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct GcArgs { + min_retention: Option, + max_age_days: Option, + dry_run: Option, +} + +/// Garbage collection tool +pub async fn execute_gc( + storage: &Arc>, + args: Option, +) -> Result { + let args: GcArgs = match args { + Some(v) => serde_json::from_value(v).map_err(|e| format!("Invalid arguments: {}", e))?, + None => GcArgs { + min_retention: None, + max_age_days: None, + dry_run: None, + }, + }; + + let min_retention = args.min_retention.unwrap_or(0.1).clamp(0.0, 1.0); + let max_age_days = args.max_age_days; + let dry_run = args.dry_run.unwrap_or(true); // Default to dry_run for safety + + let mut storage = storage.lock().await; + let now = Utc::now(); + + // Fetch all nodes (capped at 100K to prevent OOM) + let mut all_nodes = Vec::new(); + let page_size = 500; + let max_nodes = 100_000; + let mut offset = 0; + loop { + let batch = storage.get_all_nodes(page_size, offset).map_err(|e| e.to_string())?; + let batch_len = batch.len(); + all_nodes.extend(batch); + if batch_len < page_size as usize || all_nodes.len() >= max_nodes { + break; + } + offset += page_size; + } + + // Find candidates + let candidates: Vec<&vestige_core::KnowledgeNode> = all_nodes + .iter() + .filter(|node| { + if node.retention_strength >= min_retention { + return false; + } + if let Some(max_days) = max_age_days { + let age_days = (now - node.created_at).num_days(); + if age_days < 0 || (age_days as u64) < max_days { + return false; + } + } + true + }) + .collect(); + + let candidate_count = candidates.len(); + + // Build sample for display + let sample: Vec = candidates + .iter() + .take(10) + .map(|node| { + let age_days = (now - node.created_at).num_days(); + let content_preview: String = { + let preview: String = node.content.chars().take(60).collect(); + if preview.len() < node.content.len() { + format!("{}...", preview) + } else { + preview + } + }; + serde_json::json!({ + "id": &node.id[..8.min(node.id.len())], + "retention": node.retention_strength, + "ageDays": age_days, + "contentPreview": content_preview, + }) + }) + .collect(); + + if dry_run { + return Ok(serde_json::json!({ + "tool": "gc", + "dryRun": true, + "minRetention": min_retention, + "maxAgeDays": max_age_days, + "candidateCount": candidate_count, + "totalMemories": all_nodes.len(), + "sample": sample, + "message": format!("{} memories would be deleted. Set dry_run=false to delete.", candidate_count), + })); + } + + // Perform actual deletion + let mut deleted = 0usize; + let mut errors = 0usize; + let ids: Vec = candidates.iter().map(|n| n.id.clone()).collect(); + + for id in &ids { + match storage.delete_node(id) { + Ok(true) => deleted += 1, + Ok(false) => errors += 1, + Err(_) => errors += 1, + } + } + + Ok(serde_json::json!({ + "tool": "gc", + "dryRun": false, + "minRetention": min_retention, + "maxAgeDays": max_age_days, + "deleted": deleted, + "errors": errors, + "totalBefore": all_nodes.len(), + "totalAfter": all_nodes.len() - deleted, + })) +} diff --git a/crates/vestige-mcp/src/tools/mod.rs b/crates/vestige-mcp/src/tools/mod.rs index d25e49d..6ba7968 100644 --- a/crates/vestige-mcp/src/tools/mod.rs +++ b/crates/vestige-mcp/src/tools/mod.rs @@ -14,6 +14,13 @@ pub mod memory_unified; pub mod search_unified; pub mod smart_ingest; +// v1.2: Temporal query tools +pub mod changelog; +pub mod timeline; + +// v1.2: Maintenance tools +pub mod maintenance; + // Deprecated tools - kept for internal backwards compatibility // These modules are intentionally unused in the public API #[allow(dead_code)] diff --git a/crates/vestige-mcp/src/tools/search_unified.rs b/crates/vestige-mcp/src/tools/search_unified.rs index b2a13ee..f08d59c 100644 --- a/crates/vestige-mcp/src/tools/search_unified.rs +++ b/crates/vestige-mcp/src/tools/search_unified.rs @@ -40,6 +40,12 @@ pub fn schema() -> Value { "default": 0.5, "minimum": 0.0, "maximum": 1.0 + }, + "detail_level": { + "type": "string", + "description": "Level of detail in results. 'brief' = id/type/tags/score only (saves tokens). 'summary' = default 8-field response. 'full' = all fields including FSRS state and timestamps.", + "enum": ["brief", "summary", "full"], + "default": "summary" } }, "required": ["query"] @@ -53,6 +59,8 @@ struct SearchArgs { limit: Option, min_retention: Option, min_similarity: Option, + #[serde(alias = "detail_level")] + detail_level: Option, } /// Execute unified search @@ -72,6 +80,19 @@ pub async fn execute( return Err("Query cannot be empty".to_string()); } + // Validate detail_level + let detail_level = match args.detail_level.as_deref() { + Some("brief") => "brief", + Some("full") => "full", + Some("summary") | None => "summary", + Some(invalid) => { + return Err(format!( + "Invalid detail_level '{}'. Must be 'brief', 'summary', or 'full'.", + invalid + )); + } + }; + // Clamp all parameters to valid ranges let limit = args.limit.unwrap_or(10).clamp(1, 100); let min_retention = args.min_retention.unwrap_or(0.0).clamp(0.0, 1.0); @@ -97,10 +118,10 @@ pub async fn execute( return false; } // Check similarity if semantic score is available - if let Some(sem_score) = r.semantic_score { - if sem_score < min_similarity { - return false; - } + if let Some(sem_score) = r.semantic_score + && sem_score < min_similarity + { + return false; } true }) @@ -111,31 +132,114 @@ pub async fn execute( let ids: Vec<&str> = filtered_results.iter().map(|r| r.node.id.as_str()).collect(); let _ = storage.strengthen_batch_on_access(&ids); // Ignore errors, don't fail search - // Format results + // Format results based on detail_level let formatted: Vec = filtered_results .iter() - .map(|r| { - serde_json::json!({ - "id": r.node.id, - "content": r.node.content, - "combinedScore": r.combined_score, - "keywordScore": r.keyword_score, - "semanticScore": r.semantic_score, - "nodeType": r.node.node_type, - "tags": r.node.tags, - "retentionStrength": r.node.retention_strength, - }) - }) + .map(|r| format_search_result(r, detail_level)) .collect(); Ok(serde_json::json!({ "query": args.query, "method": "hybrid", + "detailLevel": detail_level, "total": formatted.len(), "results": formatted, })) } +/// Format a search result based on the requested detail level. +fn format_search_result(r: &vestige_core::SearchResult, detail_level: &str) -> Value { + match detail_level { + "brief" => serde_json::json!({ + "id": r.node.id, + "nodeType": r.node.node_type, + "tags": r.node.tags, + "retentionStrength": r.node.retention_strength, + "combinedScore": r.combined_score, + }), + "full" => serde_json::json!({ + "id": r.node.id, + "content": r.node.content, + "combinedScore": r.combined_score, + "keywordScore": r.keyword_score, + "semanticScore": r.semantic_score, + "nodeType": r.node.node_type, + "tags": r.node.tags, + "retentionStrength": r.node.retention_strength, + "storageStrength": r.node.storage_strength, + "retrievalStrength": r.node.retrieval_strength, + "source": r.node.source, + "sentimentScore": r.node.sentiment_score, + "sentimentMagnitude": r.node.sentiment_magnitude, + "createdAt": r.node.created_at.to_rfc3339(), + "updatedAt": r.node.updated_at.to_rfc3339(), + "lastAccessed": r.node.last_accessed.to_rfc3339(), + "nextReview": r.node.next_review.map(|dt| dt.to_rfc3339()), + "stability": r.node.stability, + "difficulty": r.node.difficulty, + "reps": r.node.reps, + "lapses": r.node.lapses, + "validFrom": r.node.valid_from.map(|dt| dt.to_rfc3339()), + "validUntil": r.node.valid_until.map(|dt| dt.to_rfc3339()), + "matchType": format!("{:?}", r.match_type), + }), + // "summary" (default) — backwards compatible + _ => serde_json::json!({ + "id": r.node.id, + "content": r.node.content, + "combinedScore": r.combined_score, + "keywordScore": r.keyword_score, + "semanticScore": r.semantic_score, + "nodeType": r.node.node_type, + "tags": r.node.tags, + "retentionStrength": r.node.retention_strength, + }), + } +} + +/// Format a KnowledgeNode based on the requested detail level. +/// Reusable across search, timeline, and other tools. +pub fn format_node(node: &vestige_core::KnowledgeNode, detail_level: &str) -> Value { + match detail_level { + "brief" => serde_json::json!({ + "id": node.id, + "nodeType": node.node_type, + "tags": node.tags, + "retentionStrength": node.retention_strength, + }), + "full" => serde_json::json!({ + "id": node.id, + "content": node.content, + "nodeType": node.node_type, + "tags": node.tags, + "retentionStrength": node.retention_strength, + "storageStrength": node.storage_strength, + "retrievalStrength": node.retrieval_strength, + "source": node.source, + "sentimentScore": node.sentiment_score, + "sentimentMagnitude": node.sentiment_magnitude, + "createdAt": node.created_at.to_rfc3339(), + "updatedAt": node.updated_at.to_rfc3339(), + "lastAccessed": node.last_accessed.to_rfc3339(), + "nextReview": node.next_review.map(|dt| dt.to_rfc3339()), + "stability": node.stability, + "difficulty": node.difficulty, + "reps": node.reps, + "lapses": node.lapses, + "validFrom": node.valid_from.map(|dt| dt.to_rfc3339()), + "validUntil": node.valid_until.map(|dt| dt.to_rfc3339()), + }), + // "summary" (default) + _ => serde_json::json!({ + "id": node.id, + "content": node.content, + "nodeType": node.node_type, + "tags": node.tags, + "retentionStrength": node.retention_strength, + }), + } +} + // ============================================================================ // TESTS // ============================================================================ @@ -489,4 +593,113 @@ mod tests { assert_eq!(similarity_schema["maximum"], 1.0); assert_eq!(similarity_schema["default"], 0.5); } + + // ======================================================================== + // DETAIL LEVEL TESTS + // ======================================================================== + + #[test] + fn test_schema_has_detail_level() { + let schema_value = schema(); + let dl = &schema_value["properties"]["detail_level"]; + assert!(dl.is_object()); + assert_eq!(dl["default"], "summary"); + let enum_values = dl["enum"].as_array().unwrap(); + assert!(enum_values.contains(&serde_json::json!("brief"))); + assert!(enum_values.contains(&serde_json::json!("summary"))); + assert!(enum_values.contains(&serde_json::json!("full"))); + } + + #[tokio::test] + async fn test_search_detail_level_brief_excludes_content() { + let (storage, _dir) = test_storage().await; + ingest_test_content(&storage, "Brief mode test content for search.").await; + + let args = serde_json::json!({ + "query": "brief", + "detail_level": "brief", + "min_similarity": 0.0 + }); + let result = execute(&storage, Some(args)).await; + assert!(result.is_ok()); + + let value = result.unwrap(); + assert_eq!(value["detailLevel"], "brief"); + let results = value["results"].as_array().unwrap(); + if !results.is_empty() { + let first = &results[0]; + // Brief should NOT have content + assert!(first.get("content").is_none() || first["content"].is_null()); + // Brief should have these fields + assert!(first["id"].is_string()); + assert!(first["nodeType"].is_string()); + assert!(first["tags"].is_array()); + assert!(first["retentionStrength"].is_number()); + assert!(first["combinedScore"].is_number()); + } + } + + #[tokio::test] + async fn test_search_detail_level_full_includes_timestamps() { + let (storage, _dir) = test_storage().await; + ingest_test_content(&storage, "Full mode test content for search.").await; + + let args = serde_json::json!({ + "query": "full", + "detail_level": "full", + "min_similarity": 0.0 + }); + let result = execute(&storage, Some(args)).await; + assert!(result.is_ok()); + + let value = result.unwrap(); + assert_eq!(value["detailLevel"], "full"); + let results = value["results"].as_array().unwrap(); + if !results.is_empty() { + let first = &results[0]; + // Full should have timestamps + assert!(first["createdAt"].is_string()); + assert!(first["updatedAt"].is_string()); + assert!(first["content"].is_string()); + assert!(first["storageStrength"].is_number()); + assert!(first["retrievalStrength"].is_number()); + assert!(first["matchType"].is_string()); + } + } + + #[tokio::test] + async fn test_search_detail_level_default_is_summary() { + let (storage, _dir) = test_storage().await; + ingest_test_content(&storage, "Default detail level test content.").await; + + let args = serde_json::json!({ + "query": "default", + "min_similarity": 0.0 + }); + let result = execute(&storage, Some(args)).await; + assert!(result.is_ok()); + + let value = result.unwrap(); + assert_eq!(value["detailLevel"], "summary"); + let results = value["results"].as_array().unwrap(); + if !results.is_empty() { + let first = &results[0]; + // Summary should have content but not timestamps + assert!(first["content"].is_string()); + assert!(first["id"].is_string()); + assert!(first.get("createdAt").is_none() || first["createdAt"].is_null()); + } + } + + #[tokio::test] + async fn test_search_detail_level_invalid_fails() { + let (storage, _dir) = test_storage().await; + let args = serde_json::json!({ + "query": "test", + "detail_level": "invalid_level" + }); + let result = execute(&storage, Some(args)).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Invalid detail_level")); + } } diff --git a/crates/vestige-mcp/src/tools/timeline.rs b/crates/vestige-mcp/src/tools/timeline.rs new file mode 100644 index 0000000..79783a6 --- /dev/null +++ b/crates/vestige-mcp/src/tools/timeline.rs @@ -0,0 +1,184 @@ +//! Memory Timeline Tool +//! +//! Browse memories chronologically. Returns memories in a time range, +//! grouped by day. Defaults to last 7 days. + +use chrono::{DateTime, NaiveDate, Utc}; +use serde::Deserialize; +use serde_json::Value; +use std::collections::BTreeMap; +use std::sync::Arc; +use tokio::sync::Mutex; + +use vestige_core::Storage; + +use super::search_unified::format_node; + +/// Input schema for memory_timeline tool +pub fn schema() -> Value { + serde_json::json!({ + "type": "object", + "properties": { + "start": { + "type": "string", + "description": "Start of time range (ISO 8601 date or datetime). Default: 7 days ago." + }, + "end": { + "type": "string", + "description": "End of time range (ISO 8601 date or datetime). Default: now." + }, + "node_type": { + "type": "string", + "description": "Filter by node type (e.g. 'fact', 'concept', 'decision')" + }, + "tags": { + "type": "array", + "items": { "type": "string" }, + "description": "Filter by tags (ANY match)" + }, + "limit": { + "type": "integer", + "description": "Maximum number of memories to return (default: 50, max: 200)", + "default": 50, + "minimum": 1, + "maximum": 200 + }, + "detail_level": { + "type": "string", + "description": "Level of detail: 'brief', 'summary' (default), or 'full'", + "enum": ["brief", "summary", "full"], + "default": "summary" + } + } + }) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct TimelineArgs { + start: Option, + end: Option, + node_type: Option, + tags: Option>, + limit: Option, + #[serde(alias = "detail_level")] + detail_level: Option, +} + +/// Parse an ISO 8601 date or datetime string into a DateTime. +/// Supports both `2026-02-01` and `2026-02-01T00:00:00Z` formats. +fn parse_datetime(s: &str) -> Result, String> { + // Try full datetime first + if let Ok(dt) = DateTime::parse_from_rfc3339(s) { + return Ok(dt.with_timezone(&Utc)); + } + // Try date-only (YYYY-MM-DD) + if let Ok(date) = NaiveDate::parse_from_str(s, "%Y-%m-%d") { + let dt = date + .and_hms_opt(0, 0, 0) + .ok_or_else(|| format!("Invalid date: {}", s))? + .and_utc(); + return Ok(dt); + } + Err(format!( + "Invalid date/datetime '{}'. Use ISO 8601 format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ", + s + )) +} + +/// Execute memory_timeline tool +pub async fn execute( + storage: &Arc>, + args: Option, +) -> Result { + let args: TimelineArgs = match args { + Some(v) => serde_json::from_value(v).map_err(|e| format!("Invalid arguments: {}", e))?, + None => TimelineArgs { + start: None, + end: None, + node_type: None, + tags: None, + limit: None, + detail_level: None, + }, + }; + + // Validate detail_level + let detail_level = match args.detail_level.as_deref() { + Some("brief") => "brief", + Some("full") => "full", + Some("summary") | None => "summary", + Some(invalid) => { + return Err(format!( + "Invalid detail_level '{}'. Must be 'brief', 'summary', or 'full'.", + invalid + )); + } + }; + + // Parse time range + let now = Utc::now(); + let start = match &args.start { + Some(s) => Some(parse_datetime(s)?), + None => Some(now - chrono::Duration::days(7)), + }; + let end = match &args.end { + Some(e) => Some(parse_datetime(e)?), + None => Some(now), + }; + + let limit = args.limit.unwrap_or(50).clamp(1, 200); + + let storage = storage.lock().await; + + // Query memories in time range + let mut results = storage + .query_time_range(start, end, limit) + .map_err(|e| e.to_string())?; + + // Post-query filters + if let Some(ref node_type) = args.node_type { + results.retain(|n| n.node_type == *node_type); + } + if let Some(tags) = args.tags.as_ref().filter(|t| !t.is_empty()) { + results.retain(|n| tags.iter().any(|t| n.tags.contains(t))); + } + + // Group by day + let mut by_day: BTreeMap> = BTreeMap::new(); + for node in &results { + let date = node.created_at.date_naive(); + by_day + .entry(date) + .or_default() + .push(format_node(node, detail_level)); + } + + // Build timeline (newest first) + let timeline: Vec = by_day + .into_iter() + .rev() + .map(|(date, memories)| { + serde_json::json!({ + "date": date.to_string(), + "count": memories.len(), + "memories": memories, + }) + }) + .collect(); + + let total = results.len(); + let days = timeline.len(); + + Ok(serde_json::json!({ + "tool": "memory_timeline", + "range": { + "start": start.map(|dt| dt.to_rfc3339()), + "end": end.map(|dt| dt.to_rfc3339()), + }, + "detailLevel": detail_level, + "totalMemories": total, + "days": days, + "timeline": timeline, + })) +}