mirror of
https://github.com/samvallad33/vestige.git
synced 2026-04-25 00:36:22 +02:00
feat: Vestige v1.2.0 — dashboard, temporal tools, maintenance tools, detail levels
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.
This commit is contained in:
parent
a92fb2b6ed
commit
34f5e8d52a
18 changed files with 2850 additions and 25 deletions
118
Cargo.lock
generated
118
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<Vec<StateTransitionRecord>> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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', " ");
|
||||
|
|
|
|||
955
crates/vestige-mcp/src/dashboard.html
Normal file
955
crates/vestige-mcp/src/dashboard.html
Normal file
|
|
@ -0,0 +1,955 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vestige Memory Dashboard</title>
|
||||
<style>
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{
|
||||
--bg:#0d1117;--bg-secondary:#161b22;--bg-tertiary:#21262d;
|
||||
--text:#e6edf3;--text-secondary:#8b949e;
|
||||
--border:#30363d;
|
||||
--accent:#58a6ff;--accent-hover:#79c0ff;
|
||||
--green:#3fb950;--yellow:#d29922;--red:#f85149;--purple:#bc8cff;
|
||||
--radius:8px;
|
||||
--font:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;
|
||||
--transition:0.15s ease;
|
||||
}
|
||||
html,body{height:100%;font-family:var(--font);background:var(--bg);color:var(--text);font-size:14px;line-height:1.5}
|
||||
a{color:var(--accent);text-decoration:none}
|
||||
a:hover{color:var(--accent-hover)}
|
||||
button{font-family:var(--font);cursor:pointer;border:none;background:none;color:var(--text);font-size:inherit}
|
||||
button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||
input,select{font-family:var(--font);font-size:inherit;color:var(--text);background:var(--bg-tertiary);border:1px solid var(--border);border-radius:var(--radius);padding:8px 12px;outline:none;transition:border-color var(--transition)}
|
||||
input:focus,select:focus{border-color:var(--accent)}
|
||||
::-webkit-scrollbar{width:8px;height:8px}
|
||||
::-webkit-scrollbar-track{background:var(--bg-secondary)}
|
||||
::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px}
|
||||
::-webkit-scrollbar-thumb:hover{background:var(--text-secondary)}
|
||||
|
||||
/* ── Layout ── */
|
||||
#app{display:flex;flex-direction:column;height:100vh;overflow:hidden}
|
||||
|
||||
/* ── Header ── */
|
||||
header{display:flex;align-items:center;justify-content:space-between;padding:12px 24px;background:var(--bg-secondary);border-bottom:1px solid var(--border);flex-shrink:0}
|
||||
.header-left{display:flex;align-items:center;gap:16px}
|
||||
.logo{font-size:22px;font-weight:700;letter-spacing:-0.5px;background:linear-gradient(135deg,var(--accent),var(--purple));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
|
||||
.version-badge{font-size:11px;color:var(--text-secondary);background:var(--bg-tertiary);padding:2px 8px;border-radius:10px;border:1px solid var(--border)}
|
||||
.health-indicator{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-secondary)}
|
||||
.health-dot{width:8px;height:8px;border-radius:50%;background:var(--text-secondary);transition:background 0.3s ease}
|
||||
.health-dot.ok{background:var(--green);box-shadow:0 0 6px rgba(63,185,80,0.4)}
|
||||
.health-dot.error{background:var(--red);box-shadow:0 0 6px rgba(248,81,73,0.4)}
|
||||
.header-right{display:flex;align-items:center;gap:6px}
|
||||
.view-toggle{padding:6px 14px;border-radius:var(--radius);background:var(--bg-tertiary);border:1px solid var(--border);font-size:13px;transition:all var(--transition)}
|
||||
.view-toggle:hover{border-color:var(--text-secondary)}
|
||||
.view-toggle.active{background:var(--accent);color:#fff;border-color:var(--accent)}
|
||||
|
||||
/* ── Stats Row ── */
|
||||
.stats-row{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;padding:16px 24px;flex-shrink:0}
|
||||
.stat-card{background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:16px 20px;transition:border-color var(--transition)}
|
||||
.stat-card:hover{border-color:var(--text-secondary)}
|
||||
.stat-value{font-size:28px;font-weight:700;line-height:1.2}
|
||||
.stat-label{font-size:12px;color:var(--text-secondary);margin-top:2px;text-transform:uppercase;letter-spacing:0.5px}
|
||||
.stat-value.green{color:var(--green)}
|
||||
.stat-value.accent{color:var(--accent)}
|
||||
.stat-value.yellow{color:var(--yellow)}
|
||||
.stat-value.purple{color:var(--purple)}
|
||||
|
||||
/* ── Search & Filter Toolbar ── */
|
||||
.toolbar{display:flex;flex-direction:column;gap:10px;padding:0 24px 12px;flex-shrink:0}
|
||||
.search-wrap{position:relative}
|
||||
.search-wrap input{width:100%;padding:10px 16px 10px 38px;font-size:15px}
|
||||
.search-icon{position:absolute;left:12px;top:50%;transform:translateY(-50%);color:var(--text-secondary);pointer-events:none}
|
||||
.search-icon svg{width:16px;height:16px;display:block}
|
||||
.filter-bar{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
|
||||
.filter-bar select{height:34px}
|
||||
.filter-bar label{font-size:12px;color:var(--text-secondary);display:flex;align-items:center;gap:6px;white-space:nowrap}
|
||||
.retention-val{font-size:12px;color:var(--text-secondary);min-width:32px;text-align:right}
|
||||
input[type=range]{-webkit-appearance:none;appearance:none;background:var(--bg-tertiary);height:4px;border-radius:2px;border:none;padding:0;width:100px;cursor:pointer}
|
||||
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:14px;height:14px;border-radius:50%;background:var(--accent);cursor:pointer;border:none}
|
||||
input[type=range]::-moz-range-thumb{width:14px;height:14px;border-radius:50%;background:var(--accent);cursor:pointer;border:none}
|
||||
|
||||
/* ── Main Content Area ── */
|
||||
.main{flex:1;display:grid;grid-template-columns:65% 35%;overflow:hidden;border-top:1px solid var(--border)}
|
||||
.main.timeline-active{grid-template-columns:1fr}
|
||||
|
||||
/* ── Memory List ── */
|
||||
.memory-list{overflow-y:auto;border-right:1px solid var(--border)}
|
||||
.memory-item{padding:14px 24px;border-bottom:1px solid var(--border);cursor:pointer;transition:background var(--transition);outline:none}
|
||||
.memory-item:hover,.memory-item:focus-visible{background:var(--bg-secondary)}
|
||||
.memory-item.selected{background:var(--bg-tertiary);border-left:3px solid var(--accent);padding-left:21px}
|
||||
.mi-header{display:flex;align-items:center;gap:8px;margin-bottom:6px}
|
||||
.type-badge{font-size:11px;padding:2px 8px;border-radius:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.3px;flex-shrink:0;line-height:1.5}
|
||||
.type-badge.fact{background:rgba(88,166,255,0.15);color:var(--accent)}
|
||||
.type-badge.concept{background:rgba(188,140,255,0.15);color:var(--purple)}
|
||||
.type-badge.event{background:rgba(63,185,80,0.15);color:var(--green)}
|
||||
.type-badge.person{background:rgba(210,153,34,0.15);color:var(--yellow)}
|
||||
.type-badge.place{background:rgba(248,81,73,0.15);color:var(--red)}
|
||||
.type-badge.note{background:rgba(139,148,158,0.15);color:var(--text-secondary)}
|
||||
.type-badge.pattern{background:rgba(63,185,80,0.15);color:var(--green)}
|
||||
.type-badge.decision{background:rgba(210,153,34,0.15);color:var(--yellow)}
|
||||
.mi-date{font-size:11px;color:var(--text-secondary);margin-left:auto;flex-shrink:0}
|
||||
.mi-content{font-size:13px;color:var(--text);line-height:1.5;margin-bottom:8px;word-break:break-word}
|
||||
.mi-footer{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.retention-bar{flex:0 0 80px;height:4px;background:var(--bg-tertiary);border-radius:2px;overflow:hidden}
|
||||
.retention-bar-fill{height:100%;border-radius:2px;transition:width 0.3s ease}
|
||||
.retention-label{font-size:11px;color:var(--text-secondary)}
|
||||
.tag-chip{font-size:10px;padding:1px 6px;border-radius:8px;background:var(--bg-tertiary);color:var(--text-secondary);border:1px solid var(--border)}
|
||||
.empty-state{padding:60px 24px;text-align:center;color:var(--text-secondary)}
|
||||
.empty-state .empty-title{font-size:16px;margin-bottom:8px;color:var(--text)}
|
||||
.load-more-btn{display:block;width:100%;padding:12px;text-align:center;color:var(--accent);background:none;border:none;border-bottom:1px solid var(--border);cursor:pointer;font-size:13px;transition:background var(--transition)}
|
||||
.load-more-btn:hover{background:var(--bg-secondary)}
|
||||
|
||||
/* ── Detail Panel ── */
|
||||
.detail-panel{overflow-y:auto;padding:24px}
|
||||
.detail-empty{display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-secondary);font-size:14px}
|
||||
.detail-header{display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:16px}
|
||||
.detail-id{font-size:11px;color:var(--text-secondary);font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,monospace;word-break:break-all;margin-top:4px}
|
||||
.detail-content-box{background:var(--bg-tertiary);border:1px solid var(--border);border-radius:var(--radius);padding:16px;margin-bottom:20px;white-space:pre-wrap;word-break:break-word;line-height:1.6;font-size:13px;max-height:280px;overflow-y:auto}
|
||||
.detail-section{margin-bottom:20px}
|
||||
.detail-section-title{font-size:11px;text-transform:uppercase;letter-spacing:0.8px;color:var(--text-secondary);margin-bottom:10px;font-weight:600}
|
||||
.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
|
||||
.detail-field{background:var(--bg-tertiary);border-radius:var(--radius);padding:10px 12px}
|
||||
.detail-field-label{font-size:10px;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-secondary);margin-bottom:2px}
|
||||
.detail-field-value{font-size:13px;font-weight:500}
|
||||
.detail-tags{display:flex;flex-wrap:wrap;gap:6px}
|
||||
.detail-tag{font-size:12px;padding:3px 10px;border-radius:12px;background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-secondary)}
|
||||
.detail-source{background:var(--bg-tertiary);border-radius:var(--radius);padding:10px 12px;font-size:13px;word-break:break-all}
|
||||
.detail-actions{display:flex;gap:8px;margin-top:20px}
|
||||
.btn{padding:8px 16px;border-radius:var(--radius);font-size:13px;font-weight:500;transition:all var(--transition);border:1px solid transparent}
|
||||
.btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||
.btn-promote{background:rgba(63,185,80,0.15);color:var(--green);border-color:rgba(63,185,80,0.3)}
|
||||
.btn-promote:hover{background:rgba(63,185,80,0.25)}
|
||||
.btn-demote{background:rgba(210,153,34,0.15);color:var(--yellow);border-color:rgba(210,153,34,0.3)}
|
||||
.btn-demote:hover{background:rgba(210,153,34,0.25)}
|
||||
.btn-delete{background:rgba(248,81,73,0.1);color:var(--red);border-color:rgba(248,81,73,0.25)}
|
||||
.btn-delete:hover{background:rgba(248,81,73,0.2)}
|
||||
|
||||
/* ── Retention Curve ── */
|
||||
.curve-container{margin-top:8px}
|
||||
.curve-container svg{width:100%;height:auto;display:block}
|
||||
|
||||
/* ── Timeline View ── */
|
||||
.timeline-view{overflow-y:auto;padding:0 24px}
|
||||
.timeline-day{margin-bottom:24px}
|
||||
.timeline-date-header{font-size:13px;font-weight:600;color:var(--text-secondary);padding:10px 0;border-bottom:1px solid var(--border);position:sticky;top:0;background:var(--bg);z-index:1;display:flex;align-items:center;justify-content:space-between}
|
||||
.timeline-count{font-size:11px;font-weight:400;color:var(--text-secondary);background:var(--bg-tertiary);padding:2px 8px;border-radius:10px}
|
||||
|
||||
/* ── Modal ── */
|
||||
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:1000;opacity:0;pointer-events:none;transition:opacity var(--transition)}
|
||||
.modal-overlay.open{opacity:1;pointer-events:auto}
|
||||
.modal{background:var(--bg-secondary);border:1px solid var(--border);border-radius:12px;padding:24px;max-width:420px;width:90%;transform:scale(0.95);transition:transform var(--transition)}
|
||||
.modal-overlay.open .modal{transform:scale(1)}
|
||||
.modal-title{font-size:16px;font-weight:600;margin-bottom:8px}
|
||||
.modal-body{color:var(--text-secondary);font-size:14px;margin-bottom:20px;line-height:1.5}
|
||||
.modal-actions{display:flex;gap:8px;justify-content:flex-end}
|
||||
.btn-cancel{background:var(--bg-tertiary);border:1px solid var(--border);padding:8px 18px;border-radius:var(--radius);font-size:13px;color:var(--text)}
|
||||
.btn-cancel:hover{border-color:var(--text-secondary)}
|
||||
.btn-confirm-delete{background:var(--red);color:#fff;padding:8px 18px;border-radius:var(--radius);font-size:13px;font-weight:500}
|
||||
.btn-confirm-delete:hover{background:#da3633}
|
||||
|
||||
/* ── Toast ── */
|
||||
.toast-container{position:fixed;bottom:24px;right:24px;z-index:1001}
|
||||
.toast{padding:10px 20px;border-radius:var(--radius);font-size:13px;transform:translateY(80px);opacity:0;transition:all 0.3s ease;pointer-events:none;margin-top:8px}
|
||||
.toast.visible{transform:translateY(0);opacity:1}
|
||||
.toast.success{background:var(--green);color:#fff}
|
||||
.toast.error{background:var(--red);color:#fff}
|
||||
|
||||
/* ── Spinner / Loading ── */
|
||||
.spinner{display:inline-block;width:16px;height:16px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin 0.6s linear infinite;vertical-align:middle}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.loading-center{display:flex;align-items:center;justify-content:center;padding:40px;gap:10px;color:var(--text-secondary)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="app">
|
||||
<!-- ── Header ── -->
|
||||
<header>
|
||||
<div class="header-left">
|
||||
<span class="logo">Vestige</span>
|
||||
<span class="version-badge" id="js-version">v--</span>
|
||||
<span class="health-indicator">
|
||||
<span class="health-dot" id="js-health-dot"></span>
|
||||
<span id="js-health-text">Connecting...</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button class="view-toggle active" id="js-btn-browser" aria-pressed="true">Browser</button>
|
||||
<button class="view-toggle" id="js-btn-timeline" aria-pressed="false">Timeline</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ── Stats Cards ── -->
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value accent" id="js-stat-total">--</div>
|
||||
<div class="stat-label">Total Memories</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value green" id="js-stat-retention">--%</div>
|
||||
<div class="stat-label">Avg Retention</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value purple" id="js-stat-embeddings">--%</div>
|
||||
<div class="stat-label">Embedding Coverage</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value yellow" id="js-stat-due">--</div>
|
||||
<div class="stat-label">Due for Review</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Search & Filter Toolbar ── -->
|
||||
<div class="toolbar">
|
||||
<div class="search-wrap">
|
||||
<span class="search-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clip-rule="evenodd"/></svg></span>
|
||||
<input type="text" id="js-search" placeholder="Search memories... (press / to focus)" autocomplete="off" aria-label="Search memories">
|
||||
</div>
|
||||
<div class="filter-bar">
|
||||
<select id="js-filter-type" aria-label="Filter by node type">
|
||||
<option value="">All types</option>
|
||||
<option value="fact">Fact</option>
|
||||
<option value="concept">Concept</option>
|
||||
<option value="event">Event</option>
|
||||
<option value="person">Person</option>
|
||||
<option value="place">Place</option>
|
||||
<option value="note">Note</option>
|
||||
<option value="pattern">Pattern</option>
|
||||
<option value="decision">Decision</option>
|
||||
</select>
|
||||
<label>
|
||||
Retention ≥
|
||||
<input type="range" id="js-filter-retention" min="0" max="100" value="0" aria-label="Minimum retention percentage">
|
||||
<span class="retention-val" id="js-retention-val">0%</span>
|
||||
</label>
|
||||
<select id="js-filter-sort" aria-label="Sort order">
|
||||
<option value="newest">Newest</option>
|
||||
<option value="oldest">Oldest</option>
|
||||
<option value="strongest">Strongest</option>
|
||||
<option value="weakest">Weakest</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Browser View ── -->
|
||||
<div class="main" id="js-main-browser">
|
||||
<div class="memory-list" id="js-memory-list" role="listbox" aria-label="Memory list"></div>
|
||||
<div class="detail-panel" id="js-detail-panel">
|
||||
<div class="detail-empty" id="js-detail-empty">Select a memory to view details</div>
|
||||
<div id="js-detail-body" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Timeline View ── -->
|
||||
<div class="main timeline-active" id="js-main-timeline" style="display:none">
|
||||
<div class="timeline-view" id="js-timeline-view"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Delete Confirmation Modal ── -->
|
||||
<div class="modal-overlay" id="js-delete-modal" role="dialog" aria-modal="true" aria-labelledby="js-modal-title">
|
||||
<div class="modal">
|
||||
<div class="modal-title" id="js-modal-title">Delete Memory</div>
|
||||
<div class="modal-body" id="js-modal-body">Are you sure you want to permanently delete this memory? This action cannot be undone.</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-cancel" id="js-modal-cancel">Cancel</button>
|
||||
<button class="btn btn-confirm-delete" id="js-modal-confirm">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Toast Container ── -->
|
||||
<div class="toast-container" id="js-toast-container"></div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
"use strict";
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// State
|
||||
// ────────────────────────────────────────────
|
||||
var state = {
|
||||
memories: [],
|
||||
selectedId: null,
|
||||
total: 0,
|
||||
offset: 0,
|
||||
pageSize: 50,
|
||||
view: "browser", // "browser" | "timeline"
|
||||
deleteTargetId: null,
|
||||
searchTimer: null,
|
||||
retentionTimer: null
|
||||
};
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// DOM references
|
||||
// ────────────────────────────────────────────
|
||||
var $version = document.getElementById("js-version");
|
||||
var $healthDot = document.getElementById("js-health-dot");
|
||||
var $healthText = document.getElementById("js-health-text");
|
||||
var $btnBrowser = document.getElementById("js-btn-browser");
|
||||
var $btnTimeline = document.getElementById("js-btn-timeline");
|
||||
var $statTotal = document.getElementById("js-stat-total");
|
||||
var $statRetention = document.getElementById("js-stat-retention");
|
||||
var $statEmbed = document.getElementById("js-stat-embeddings");
|
||||
var $statDue = document.getElementById("js-stat-due");
|
||||
var $search = document.getElementById("js-search");
|
||||
var $filterType = document.getElementById("js-filter-type");
|
||||
var $filterRet = document.getElementById("js-filter-retention");
|
||||
var $retVal = document.getElementById("js-retention-val");
|
||||
var $filterSort = document.getElementById("js-filter-sort");
|
||||
var $mainBrowser = document.getElementById("js-main-browser");
|
||||
var $mainTimeline = document.getElementById("js-main-timeline");
|
||||
var $memList = document.getElementById("js-memory-list");
|
||||
var $detailPanel = document.getElementById("js-detail-panel");
|
||||
var $detailEmpty = document.getElementById("js-detail-empty");
|
||||
var $detailBody = document.getElementById("js-detail-body");
|
||||
var $timelineView = document.getElementById("js-timeline-view");
|
||||
var $deleteModal = document.getElementById("js-delete-modal");
|
||||
var $modalCancel = document.getElementById("js-modal-cancel");
|
||||
var $modalConfirm = document.getElementById("js-modal-confirm");
|
||||
var $toastContainer = document.getElementById("js-toast-container");
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// API Helper
|
||||
// ────────────────────────────────────────────
|
||||
function apiFetch(path, opts) {
|
||||
return fetch(path, opts).then(function(res) {
|
||||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||
return res.json();
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Health Check
|
||||
// ────────────────────────────────────────────
|
||||
function checkHealth() {
|
||||
apiFetch("/api/health").then(function(data) {
|
||||
$healthDot.className = "health-dot ok";
|
||||
$healthText.textContent = "Connected";
|
||||
if (data.version) $version.textContent = "v" + data.version;
|
||||
}).catch(function() {
|
||||
$healthDot.className = "health-dot error";
|
||||
$healthText.textContent = "Disconnected";
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Stats
|
||||
// ────────────────────────────────────────────
|
||||
function loadStats() {
|
||||
apiFetch("/api/stats").then(function(s) {
|
||||
$statTotal.textContent = fmtNum(s.totalMemories);
|
||||
$statRetention.textContent = Math.round((s.averageRetention || 0) * 100) + "%";
|
||||
$statEmbed.textContent = Math.round(s.embeddingCoverage || 0) + "%";
|
||||
$statDue.textContent = fmtNum(s.dueForReview || 0);
|
||||
}).catch(function() { /* silent */ });
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Fetch Memory List
|
||||
// ────────────────────────────────────────────
|
||||
function fetchMemories(append) {
|
||||
if (!append) {
|
||||
state.offset = 0;
|
||||
state.memories = [];
|
||||
}
|
||||
|
||||
var params = new URLSearchParams();
|
||||
var q = $search.value.trim();
|
||||
if (q) params.set("q", q);
|
||||
var nt = $filterType.value;
|
||||
if (nt) params.set("node_type", nt);
|
||||
var minRet = parseInt($filterRet.value, 10);
|
||||
if (minRet > 0) params.set("min_retention", (minRet / 100).toFixed(2));
|
||||
params.set("limit", String(state.pageSize));
|
||||
params.set("offset", String(state.offset));
|
||||
|
||||
if (!append) {
|
||||
$memList.innerHTML = '<div class="loading-center"><span class="spinner"></span> Loading...</div>';
|
||||
}
|
||||
|
||||
apiFetch("/api/memories?" + params.toString()).then(function(data) {
|
||||
state.total = data.total || 0;
|
||||
var items = data.memories || [];
|
||||
state.memories = append ? state.memories.concat(items) : items;
|
||||
state.offset = state.memories.length;
|
||||
renderMemoryList();
|
||||
}).catch(function(e) {
|
||||
$memList.innerHTML = '<div class="empty-state"><div class="empty-title">Failed to load memories</div><div>' + esc(e.message) + '</div></div>';
|
||||
});
|
||||
}
|
||||
|
||||
function resetAndFetch() {
|
||||
state.selectedId = null;
|
||||
hideDetail();
|
||||
fetchMemories(false);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Render Memory List
|
||||
// ────────────────────────────────────────────
|
||||
function renderMemoryList() {
|
||||
var sortKey = $filterSort.value;
|
||||
var sorted = state.memories.slice();
|
||||
|
||||
sorted.sort(function(a, b) {
|
||||
if (sortKey === "oldest") return cmpDate(a.createdAt, b.createdAt);
|
||||
if (sortKey === "newest") return cmpDate(b.createdAt, a.createdAt);
|
||||
if (sortKey === "strongest") return (b.retentionStrength || 0) - (a.retentionStrength || 0);
|
||||
if (sortKey === "weakest") return (a.retentionStrength || 0) - (b.retentionStrength || 0);
|
||||
return 0;
|
||||
});
|
||||
|
||||
if (sorted.length === 0) {
|
||||
$memList.innerHTML = '<div class="empty-state"><div class="empty-title">No memories found</div><div>Try adjusting your search or filters.</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
var html = "";
|
||||
for (var i = 0; i < sorted.length; i++) {
|
||||
html += memoryItemHTML(sorted[i]);
|
||||
}
|
||||
|
||||
if (state.offset < state.total) {
|
||||
var remaining = state.total - state.offset;
|
||||
html += '<button class="load-more-btn" id="js-load-more">Load more (' + remaining + ' remaining)</button>';
|
||||
}
|
||||
|
||||
$memList.innerHTML = html;
|
||||
|
||||
// Re-attach load more listener
|
||||
var loadBtn = document.getElementById("js-load-more");
|
||||
if (loadBtn) {
|
||||
loadBtn.addEventListener("click", function() { fetchMemories(true); });
|
||||
}
|
||||
|
||||
// Attach click/keyboard listeners to items
|
||||
var items = $memList.querySelectorAll(".memory-item");
|
||||
for (var j = 0; j < items.length; j++) {
|
||||
(function(el) {
|
||||
var id = el.getAttribute("data-id");
|
||||
el.addEventListener("click", function() { selectMemory(id); });
|
||||
el.addEventListener("keydown", function(e) {
|
||||
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); selectMemory(id); }
|
||||
});
|
||||
})(items[j]);
|
||||
}
|
||||
}
|
||||
|
||||
function memoryItemHTML(m) {
|
||||
var sel = m.id === state.selectedId ? " selected" : "";
|
||||
var ret = Math.round((m.retentionStrength || 0) * 100);
|
||||
var retColor = retentionColor(ret);
|
||||
var content = truncate(m.content || "", 150);
|
||||
var date = fmtRelative(m.createdAt);
|
||||
var nodeType = m.nodeType || "note";
|
||||
var tags = (m.tags || []).slice(0, 3);
|
||||
|
||||
var tagsHTML = "";
|
||||
for (var i = 0; i < tags.length; i++) {
|
||||
tagsHTML += '<span class="tag-chip">' + esc(tags[i]) + '</span>';
|
||||
}
|
||||
|
||||
return '<div class="memory-item' + sel + '" data-id="' + escAttr(m.id) + '" tabindex="0" role="option" aria-selected="' + (sel ? "true" : "false") + '">'
|
||||
+ '<div class="mi-header">'
|
||||
+ '<span class="type-badge ' + escAttr(nodeType) + '">' + esc(nodeType) + '</span>'
|
||||
+ '<span class="mi-date">' + esc(date) + '</span>'
|
||||
+ '</div>'
|
||||
+ '<div class="mi-content">' + esc(content) + '</div>'
|
||||
+ '<div class="mi-footer">'
|
||||
+ '<div class="retention-bar"><div class="retention-bar-fill" style="width:' + ret + '%;background:' + retColor + '"></div></div>'
|
||||
+ '<span class="retention-label">' + ret + '%</span>'
|
||||
+ tagsHTML
|
||||
+ '</div></div>';
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Select & Show Detail
|
||||
// ────────────────────────────────────────────
|
||||
function selectMemory(id) {
|
||||
state.selectedId = id;
|
||||
|
||||
// Update selected highlight
|
||||
var items = $memList.querySelectorAll(".memory-item");
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var isSelected = items[i].getAttribute("data-id") === id;
|
||||
items[i].classList.toggle("selected", isSelected);
|
||||
items[i].setAttribute("aria-selected", isSelected ? "true" : "false");
|
||||
}
|
||||
|
||||
// Fetch full memory detail
|
||||
apiFetch("/api/memories/" + encodeURIComponent(id)).then(function(m) {
|
||||
renderDetail(m);
|
||||
}).catch(function() {
|
||||
showToast("Failed to load memory details", "error");
|
||||
});
|
||||
}
|
||||
|
||||
function renderDetail(m) {
|
||||
$detailEmpty.style.display = "none";
|
||||
$detailBody.style.display = "block";
|
||||
|
||||
var ret = Math.round((m.retentionStrength || 0) * 100);
|
||||
var stor = (m.storageStrength || 0);
|
||||
var retr = (m.retrievalStrength || 0);
|
||||
var nodeType = m.nodeType || "note";
|
||||
|
||||
var html = '';
|
||||
|
||||
// Header
|
||||
html += '<div class="detail-header"><div>'
|
||||
+ '<span class="type-badge ' + escAttr(nodeType) + '">' + esc(nodeType) + '</span>'
|
||||
+ '<div class="detail-id">' + esc(m.id) + '</div>'
|
||||
+ '</div></div>';
|
||||
|
||||
// Full content
|
||||
html += '<div class="detail-content-box">' + esc(m.content || "") + '</div>';
|
||||
|
||||
// FSRS Fields
|
||||
html += '<div class="detail-section"><div class="detail-section-title">FSRS Fields</div>'
|
||||
+ '<div class="detail-grid">'
|
||||
+ fieldHTML("Retention", ret + "%")
|
||||
+ fieldHTML("Storage Strength", stor.toFixed(2))
|
||||
+ fieldHTML("Retrieval Strength", retr.toFixed(2))
|
||||
+ fieldHTML("Review Count", m.reviewCount != null ? m.reviewCount : "--")
|
||||
+ fieldHTML("Access Count", m.accessCount != null ? m.accessCount : "--")
|
||||
+ fieldHTML("Sentiment", fmtSentiment(m.sentimentScore, m.sentimentMagnitude))
|
||||
+ '</div></div>';
|
||||
|
||||
// Timestamps
|
||||
html += '<div class="detail-section"><div class="detail-section-title">Timestamps</div>'
|
||||
+ '<div class="detail-grid">'
|
||||
+ fieldHTML("Created", fmtDateTime(m.createdAt))
|
||||
+ fieldHTML("Updated", fmtDateTime(m.updatedAt))
|
||||
+ fieldHTML("Last Accessed", fmtDateTime(m.lastAccessedAt))
|
||||
+ fieldHTML("Next Review", fmtDateTime(m.nextReviewAt))
|
||||
+ fieldHTML("Valid From", fmtDateTime(m.validFrom))
|
||||
+ fieldHTML("Valid Until", fmtDateTime(m.validUntil))
|
||||
+ '</div></div>';
|
||||
|
||||
// Source
|
||||
if (m.source) {
|
||||
html += '<div class="detail-section"><div class="detail-section-title">Source</div>'
|
||||
+ '<div class="detail-source">' + esc(m.source) + '</div></div>';
|
||||
}
|
||||
|
||||
// Tags
|
||||
if (m.tags && m.tags.length > 0) {
|
||||
var tagsHTML = '';
|
||||
for (var i = 0; i < m.tags.length; i++) {
|
||||
tagsHTML += '<span class="detail-tag">' + esc(m.tags[i]) + '</span>';
|
||||
}
|
||||
html += '<div class="detail-section"><div class="detail-section-title">Tags</div>'
|
||||
+ '<div class="detail-tags">' + tagsHTML + '</div></div>';
|
||||
}
|
||||
|
||||
// Forgetting Curve
|
||||
html += '<div class="detail-section"><div class="detail-section-title">Forgetting Curve</div>'
|
||||
+ '<div class="curve-container">' + renderForgettingCurve(stor) + '</div></div>';
|
||||
|
||||
// Action Buttons
|
||||
html += '<div class="detail-actions">'
|
||||
+ '<button class="btn btn-promote" id="js-act-promote">Promote</button>'
|
||||
+ '<button class="btn btn-demote" id="js-act-demote">Demote</button>'
|
||||
+ '<button class="btn btn-delete" id="js-act-delete">Delete</button>'
|
||||
+ '</div>';
|
||||
|
||||
$detailBody.innerHTML = html;
|
||||
|
||||
// Wire up action buttons
|
||||
var memId = m.id;
|
||||
document.getElementById("js-act-promote").addEventListener("click", function() { doPromote(memId); });
|
||||
document.getElementById("js-act-demote").addEventListener("click", function() { doDemote(memId); });
|
||||
document.getElementById("js-act-delete").addEventListener("click", function() { openDeleteModal(memId); });
|
||||
}
|
||||
|
||||
function fieldHTML(label, value) {
|
||||
return '<div class="detail-field">'
|
||||
+ '<div class="detail-field-label">' + esc(label) + '</div>'
|
||||
+ '<div class="detail-field-value">' + esc(String(value != null ? value : "--")) + '</div>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
function hideDetail() {
|
||||
$detailEmpty.style.display = "flex";
|
||||
$detailBody.style.display = "none";
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Forgetting Curve (FSRS-6)
|
||||
// ────────────────────────────────────────────
|
||||
function renderForgettingCurve(S) {
|
||||
if (!S || S <= 0) S = 1;
|
||||
|
||||
var DECAY = -0.1542;
|
||||
var FACTOR = Math.pow(0.9, 1.0 / DECAY) - 1; // ~0.0667
|
||||
|
||||
var W = 320, H = 140;
|
||||
var PL = 40, PR = 10, PT = 10, PB = 28;
|
||||
var plotW = W - PL - PR;
|
||||
var plotH = H - PT - PB;
|
||||
|
||||
// Time range: up to 4*S days, clamped [7, 365]
|
||||
var maxT = Math.min(Math.max(S * 4, 7), 365);
|
||||
|
||||
function R(t) { return Math.pow(1 + FACTOR * t / S, DECAY); }
|
||||
function tx(t) { return PL + (t / maxT) * plotW; }
|
||||
function ry(r) { return PT + (1 - r) * plotH; }
|
||||
|
||||
// Build curve polyline
|
||||
var steps = 80;
|
||||
var points = [];
|
||||
for (var i = 0; i <= steps; i++) {
|
||||
var t = (i / steps) * maxT;
|
||||
points.push(tx(t).toFixed(1) + "," + ry(R(t)).toFixed(1));
|
||||
}
|
||||
|
||||
// Fill area under curve
|
||||
var fillPoints = points.slice();
|
||||
fillPoints.push(tx(maxT).toFixed(1) + "," + ry(0).toFixed(1));
|
||||
fillPoints.push(tx(0).toFixed(1) + "," + ry(0).toFixed(1));
|
||||
|
||||
// Current position marker at t = 1 day (just reviewed)
|
||||
var nowT = Math.min(1, maxT * 0.05);
|
||||
var nowR = R(nowT);
|
||||
var cx = tx(nowT), cy = ry(nowR);
|
||||
|
||||
// 90% retention threshold
|
||||
var r90y = ry(0.9);
|
||||
|
||||
// X-axis labels
|
||||
var midLabel = Math.round(maxT / 2);
|
||||
var endLabel = Math.round(maxT);
|
||||
|
||||
var svg = '<svg viewBox="0 0 ' + W + ' ' + H + '" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Forgetting curve">'
|
||||
// Grid lines
|
||||
+ '<line x1="'+PL+'" y1="'+ry(1)+'" x2="'+(W-PR)+'" y2="'+ry(1)+'" stroke="'+escAttr("var(--border)")+'" stroke-width="0.5"/>'
|
||||
+ '<line x1="'+PL+'" y1="'+ry(0.5)+'" x2="'+(W-PR)+'" y2="'+ry(0.5)+'" stroke="'+escAttr("var(--border)")+'" stroke-width="0.5"/>'
|
||||
+ '<line x1="'+PL+'" y1="'+ry(0)+'" x2="'+(W-PR)+'" y2="'+ry(0)+'" stroke="'+escAttr("var(--border)")+'" stroke-width="0.5"/>'
|
||||
// 90% threshold dashed line
|
||||
+ '<line x1="'+PL+'" y1="'+r90y.toFixed(1)+'" x2="'+(W-PR)+'" y2="'+r90y.toFixed(1)+'" stroke="'+escAttr("var(--yellow)")+'" stroke-width="0.5" stroke-dasharray="4,3"/>'
|
||||
+ '<text x="'+(W-PR-2)+'" y="'+(r90y - 3).toFixed(1)+'" text-anchor="end" fill="'+escAttr("var(--yellow)")+'" font-size="9" font-family="'+escAttr("var(--font)")+'">90%</text>'
|
||||
// Fill under curve
|
||||
+ '<polygon points="' + fillPoints.join(" ") + '" fill="rgba(88,166,255,0.08)"/>'
|
||||
// Curve line
|
||||
+ '<polyline points="' + points.join(" ") + '" fill="none" stroke="'+escAttr("var(--accent)")+'" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>'
|
||||
// Current position
|
||||
+ '<circle cx="'+cx.toFixed(1)+'" cy="'+cy.toFixed(1)+'" r="4" fill="'+escAttr("var(--accent)")+'"/>'
|
||||
+ '<circle cx="'+cx.toFixed(1)+'" cy="'+cy.toFixed(1)+'" r="7" fill="none" stroke="'+escAttr("var(--accent)")+'" stroke-width="1" opacity="0.4">'
|
||||
+ '<animate attributeName="r" from="7" to="12" dur="2s" repeatCount="indefinite"/>'
|
||||
+ '<animate attributeName="opacity" from="0.4" to="0" dur="2s" repeatCount="indefinite"/>'
|
||||
+ '</circle>'
|
||||
// Y-axis labels
|
||||
+ '<text x="'+(PL-4)+'" y="'+(ry(1)+4)+'" text-anchor="end" fill="'+escAttr("var(--text-secondary)")+'" font-size="10" font-family="'+escAttr("var(--font)")+'">100%</text>'
|
||||
+ '<text x="'+(PL-4)+'" y="'+(ry(0.5)+4)+'" text-anchor="end" fill="'+escAttr("var(--text-secondary)")+'" font-size="10" font-family="'+escAttr("var(--font)")+'">50%</text>'
|
||||
+ '<text x="'+(PL-4)+'" y="'+(ry(0)+4)+'" text-anchor="end" fill="'+escAttr("var(--text-secondary)")+'" font-size="10" font-family="'+escAttr("var(--font)")+'">0%</text>'
|
||||
// X-axis labels
|
||||
+ '<text x="'+PL+'" y="'+(H-4)+'" text-anchor="start" fill="'+escAttr("var(--text-secondary)")+'" font-size="10" font-family="'+escAttr("var(--font)")+'">0d</text>'
|
||||
+ '<text x="'+tx(maxT/2).toFixed(1)+'" y="'+(H-4)+'" text-anchor="middle" fill="'+escAttr("var(--text-secondary)")+'" font-size="10" font-family="'+escAttr("var(--font)")+'">'+midLabel+'d</text>'
|
||||
+ '<text x="'+tx(maxT).toFixed(1)+'" y="'+(H-4)+'" text-anchor="end" fill="'+escAttr("var(--text-secondary)")+'" font-size="10" font-family="'+escAttr("var(--font)")+'">'+endLabel+'d</text>'
|
||||
// S value label
|
||||
+ '<text x="'+(W-PR)+'" y="'+(PT+12)+'" text-anchor="end" fill="'+escAttr("var(--text-secondary)")+'" font-size="10" font-family="'+escAttr("var(--font)")+'">S = '+S.toFixed(2)+'</text>'
|
||||
+ '</svg>';
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Actions: Promote / Demote / Delete
|
||||
// ────────────────────────────────────────────
|
||||
function doPromote(id) {
|
||||
apiFetch("/api/memories/" + encodeURIComponent(id) + "/promote", { method: "POST" })
|
||||
.then(function(data) {
|
||||
showToast("Promoted. Retention: " + Math.round((data.retentionStrength || 0) * 100) + "%", "success");
|
||||
updateLocalRetention(id, data.retentionStrength);
|
||||
selectMemory(id);
|
||||
loadStats();
|
||||
})
|
||||
.catch(function() { showToast("Failed to promote memory", "error"); });
|
||||
}
|
||||
|
||||
function doDemote(id) {
|
||||
apiFetch("/api/memories/" + encodeURIComponent(id) + "/demote", { method: "POST" })
|
||||
.then(function(data) {
|
||||
showToast("Demoted. Retention: " + Math.round((data.retentionStrength || 0) * 100) + "%", "success");
|
||||
updateLocalRetention(id, data.retentionStrength);
|
||||
selectMemory(id);
|
||||
loadStats();
|
||||
})
|
||||
.catch(function() { showToast("Failed to demote memory", "error"); });
|
||||
}
|
||||
|
||||
function updateLocalRetention(id, retention) {
|
||||
for (var i = 0; i < state.memories.length; i++) {
|
||||
if (state.memories[i].id === id) {
|
||||
state.memories[i].retentionStrength = retention;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteModal(id) {
|
||||
state.deleteTargetId = id;
|
||||
$deleteModal.classList.add("open");
|
||||
$modalConfirm.focus();
|
||||
}
|
||||
|
||||
function closeDeleteModal() {
|
||||
$deleteModal.classList.remove("open");
|
||||
state.deleteTargetId = null;
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
var id = state.deleteTargetId;
|
||||
if (!id) return;
|
||||
closeDeleteModal();
|
||||
|
||||
apiFetch("/api/memories/" + encodeURIComponent(id), { method: "DELETE" })
|
||||
.then(function() {
|
||||
showToast("Memory deleted", "success");
|
||||
state.memories = state.memories.filter(function(m) { return m.id !== id; });
|
||||
state.total = Math.max(0, state.total - 1);
|
||||
if (state.selectedId === id) {
|
||||
state.selectedId = null;
|
||||
hideDetail();
|
||||
}
|
||||
renderMemoryList();
|
||||
loadStats();
|
||||
})
|
||||
.catch(function() { showToast("Failed to delete memory", "error"); });
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Timeline View
|
||||
// ────────────────────────────────────────────
|
||||
function loadTimeline() {
|
||||
$timelineView.innerHTML = '<div class="loading-center"><span class="spinner"></span> Loading timeline...</div>';
|
||||
|
||||
apiFetch("/api/timeline?days=30&limit=500")
|
||||
.then(function(data) {
|
||||
var timeline = data.timeline || [];
|
||||
if (timeline.length === 0) {
|
||||
$timelineView.innerHTML = '<div class="empty-state"><div class="empty-title">No timeline data</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
var html = "";
|
||||
for (var d = 0; d < timeline.length; d++) {
|
||||
var day = timeline[d];
|
||||
html += '<div class="timeline-day">';
|
||||
html += '<div class="timeline-date-header"><span>' + esc(fmtDateFull(day.date)) + '</span><span class="timeline-count">' + day.count + ' memories</span></div>';
|
||||
var dayMemories = day.memories || [];
|
||||
for (var m = 0; m < dayMemories.length; m++) {
|
||||
html += memoryItemHTML(dayMemories[m]);
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
$timelineView.innerHTML = html;
|
||||
|
||||
// Attach click handlers for timeline items
|
||||
var items = $timelineView.querySelectorAll(".memory-item");
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
(function(el) {
|
||||
var id = el.getAttribute("data-id");
|
||||
el.addEventListener("click", function() {
|
||||
setView("browser");
|
||||
selectMemory(id);
|
||||
});
|
||||
})(items[i]);
|
||||
}
|
||||
})
|
||||
.catch(function(e) {
|
||||
$timelineView.innerHTML = '<div class="empty-state"><div class="empty-title">Failed to load timeline</div><div>' + esc(e.message) + '</div></div>';
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// View Toggle
|
||||
// ────────────────────────────────────────────
|
||||
function setView(v) {
|
||||
state.view = v;
|
||||
$btnBrowser.classList.toggle("active", v === "browser");
|
||||
$btnTimeline.classList.toggle("active", v === "timeline");
|
||||
$btnBrowser.setAttribute("aria-pressed", v === "browser" ? "true" : "false");
|
||||
$btnTimeline.setAttribute("aria-pressed", v === "timeline" ? "true" : "false");
|
||||
$mainBrowser.style.display = v === "browser" ? "grid" : "none";
|
||||
$mainTimeline.style.display = v === "timeline" ? "grid" : "none";
|
||||
if (v === "timeline") loadTimeline();
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Toast Notifications
|
||||
// ────────────────────────────────────────────
|
||||
function showToast(msg, type) {
|
||||
var el = document.createElement("div");
|
||||
el.className = "toast " + (type || "success");
|
||||
el.textContent = msg;
|
||||
$toastContainer.appendChild(el);
|
||||
|
||||
// Trigger reflow then animate in
|
||||
void el.offsetWidth;
|
||||
el.classList.add("visible");
|
||||
|
||||
setTimeout(function() {
|
||||
el.classList.remove("visible");
|
||||
setTimeout(function() { el.remove(); }, 300);
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Utility Functions
|
||||
// ────────────────────────────────────────────
|
||||
function esc(s) {
|
||||
if (s == null) return "";
|
||||
var d = document.createElement("div");
|
||||
d.textContent = String(s);
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function escAttr(s) {
|
||||
if (s == null) return "";
|
||||
return String(s).replace(/&/g,"&").replace(/"/g,""").replace(/'/g,"'").replace(/</g,"<").replace(/>/g,">");
|
||||
}
|
||||
|
||||
function truncate(s, n) {
|
||||
if (!s) return "";
|
||||
return s.length > n ? s.substring(0, n) + "..." : s;
|
||||
}
|
||||
|
||||
function retentionColor(pct) {
|
||||
if (pct >= 70) return "var(--green)";
|
||||
if (pct >= 40) return "var(--yellow)";
|
||||
return "var(--red)";
|
||||
}
|
||||
|
||||
function fmtNum(n) {
|
||||
if (n == null) return "--";
|
||||
return Number(n).toLocaleString();
|
||||
}
|
||||
|
||||
function fmtRelative(d) {
|
||||
if (!d) return "--";
|
||||
try {
|
||||
var dt = new Date(d);
|
||||
if (isNaN(dt.getTime())) return "--";
|
||||
var diff = Date.now() - dt.getTime();
|
||||
if (diff < 0) return fmtDateTime(d);
|
||||
if (diff < 60000) return "just now";
|
||||
if (diff < 3600000) return Math.floor(diff / 60000) + "m ago";
|
||||
if (diff < 86400000) return Math.floor(diff / 3600000) + "h ago";
|
||||
if (diff < 604800000) return Math.floor(diff / 86400000) + "d ago";
|
||||
return dt.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||
} catch(e) { return "--"; }
|
||||
}
|
||||
|
||||
function fmtDateTime(d) {
|
||||
if (!d) return "--";
|
||||
try {
|
||||
var dt = new Date(d);
|
||||
if (isNaN(dt.getTime())) return "--";
|
||||
return dt.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
|
||||
+ " " + dt.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
|
||||
} catch(e) { return "--"; }
|
||||
}
|
||||
|
||||
function fmtDateFull(d) {
|
||||
if (!d) return "--";
|
||||
try {
|
||||
var dt = new Date(d);
|
||||
if (isNaN(dt.getTime())) return "--";
|
||||
return dt.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" });
|
||||
} catch(e) { return String(d); }
|
||||
}
|
||||
|
||||
function fmtSentiment(score, mag) {
|
||||
if (score == null) return "--";
|
||||
var s = Number(score).toFixed(2);
|
||||
if (mag != null) s += " (" + Number(mag).toFixed(2) + ")";
|
||||
return s;
|
||||
}
|
||||
|
||||
function cmpDate(a, b) {
|
||||
var da = a ? new Date(a).getTime() : 0;
|
||||
var db = b ? new Date(b).getTime() : 0;
|
||||
return da - db;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Event Listeners
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
// Search with debounce
|
||||
$search.addEventListener("input", function() {
|
||||
clearTimeout(state.searchTimer);
|
||||
state.searchTimer = setTimeout(function() { resetAndFetch(); }, 300);
|
||||
});
|
||||
|
||||
// Filters
|
||||
$filterType.addEventListener("change", function() { resetAndFetch(); });
|
||||
$filterSort.addEventListener("change", function() { resetAndFetch(); });
|
||||
|
||||
// Retention slider with debounce
|
||||
$filterRet.addEventListener("input", function() {
|
||||
$retVal.textContent = $filterRet.value + "%";
|
||||
clearTimeout(state.retentionTimer);
|
||||
state.retentionTimer = setTimeout(function() { resetAndFetch(); }, 300);
|
||||
});
|
||||
|
||||
// View toggles
|
||||
$btnBrowser.addEventListener("click", function() { setView("browser"); });
|
||||
$btnTimeline.addEventListener("click", function() { setView("timeline"); });
|
||||
|
||||
// Modal
|
||||
$modalCancel.addEventListener("click", closeDeleteModal);
|
||||
$modalConfirm.addEventListener("click", confirmDelete);
|
||||
$deleteModal.addEventListener("click", function(e) {
|
||||
if (e.target === $deleteModal) closeDeleteModal();
|
||||
});
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener("keydown", function(e) {
|
||||
// Escape closes modal
|
||||
if (e.key === "Escape") {
|
||||
if ($deleteModal.classList.contains("open")) {
|
||||
closeDeleteModal();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// "/" focuses search when not in an input
|
||||
if (e.key === "/" && document.activeElement !== $search && document.activeElement.tagName !== "INPUT" && document.activeElement.tagName !== "SELECT") {
|
||||
$search.focus();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Arrow key navigation in memory list
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
var focused = document.activeElement;
|
||||
if (focused && focused.classList.contains("memory-item")) {
|
||||
var next = e.key === "ArrowDown" ? focused.nextElementSibling : focused.previousElementSibling;
|
||||
if (next && next.classList.contains("memory-item")) {
|
||||
next.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Initialize
|
||||
// ────────────────────────────────────────────
|
||||
checkHealth();
|
||||
loadStats();
|
||||
fetchMemories(false);
|
||||
|
||||
// Refresh health every 30 seconds
|
||||
setInterval(checkHealth, 30000);
|
||||
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
316
crates/vestige-mcp/src/dashboard/handlers.rs
Normal file
316
crates/vestige-mcp/src/dashboard/handlers.rs
Normal file
|
|
@ -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<String>,
|
||||
pub node_type: Option<String>,
|
||||
pub tag: Option<String>,
|
||||
pub min_retention: Option<f64>,
|
||||
pub sort: Option<String>,
|
||||
pub limit: Option<i32>,
|
||||
pub offset: Option<i32>,
|
||||
}
|
||||
|
||||
/// List memories with optional search
|
||||
pub async fn list_memories(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<MemoryListParams>,
|
||||
) -> Result<Json<Value>, 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<Value> = 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<Value> = 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<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Value>, 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<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Value>, 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<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Value>, 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<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Value>, 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<AppState>,
|
||||
) -> Result<Json<Value>, 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<i64>,
|
||||
pub limit: Option<i32>,
|
||||
}
|
||||
|
||||
/// Get timeline data
|
||||
pub async fn get_timeline(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<TimelineParams>,
|
||||
) -> Result<Json<Value>, 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<String, Vec<Value>> = 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<Value> = 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<AppState>,
|
||||
) -> Result<Json<Value>, 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"),
|
||||
})))
|
||||
}
|
||||
106
crates/vestige-mcp/src/dashboard/mod.rs
Normal file
106
crates/vestige-mcp/src/dashboard/mod.rs
Normal file
|
|
@ -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<Mutex<Storage>>, port: u16) -> Router {
|
||||
let state = AppState { storage };
|
||||
|
||||
let origin = format!("http://127.0.0.1:{}", port)
|
||||
.parse::<axum::http::HeaderValue>()
|
||||
.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<Mutex<Storage>>,
|
||||
port: u16,
|
||||
open_browser: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
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<Mutex<Storage>>,
|
||||
port: u16,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
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(())
|
||||
}
|
||||
11
crates/vestige-mcp/src/dashboard/state.rs
Normal file
11
crates/vestige-mcp/src/dashboard/state.rs
Normal file
|
|
@ -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<Mutex<Storage>>,
|
||||
}
|
||||
5
crates/vestige-mcp/src/lib.rs
Normal file
5
crates/vestige-mcp/src/lib.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
//! Vestige MCP Server Library
|
||||
//!
|
||||
//! Shared modules accessible to all binaries in the crate.
|
||||
|
||||
pub mod dashboard;
|
||||
|
|
@ -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::<u16>().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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
191
crates/vestige-mcp/src/tools/changelog.rs
Normal file
191
crates/vestige-mcp/src/tools/changelog.rs
Normal file
|
|
@ -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<String>,
|
||||
#[allow(dead_code)]
|
||||
start: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
end: Option<String>,
|
||||
limit: Option<i32>,
|
||||
}
|
||||
|
||||
/// Execute memory_changelog tool
|
||||
pub async fn execute(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
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<Value, String> {
|
||||
// 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<Value> = 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<Value, String> {
|
||||
// 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<Utc>, 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<Value> = events.into_iter().map(|(_, v)| v).collect();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"tool": "memory_changelog",
|
||||
"mode": "system_wide",
|
||||
"totalEvents": formatted_events.len(),
|
||||
"events": formatted_events,
|
||||
}))
|
||||
}
|
||||
550
crates/vestige-mcp/src/tools/maintenance.rs
Normal file
550
crates/vestige-mcp/src/tools/maintenance.rs
Normal file
|
|
@ -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<Mutex<Storage>>,
|
||||
_args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
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<Mutex<Storage>>,
|
||||
_args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
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<Mutex<Storage>>,
|
||||
_args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
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<Mutex<Storage>>,
|
||||
_args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
// 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<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
since: Option<String>,
|
||||
path: Option<String>,
|
||||
}
|
||||
|
||||
/// Export tool
|
||||
pub async fn execute_export(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
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<String> = 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<f64>,
|
||||
max_age_days: Option<u64>,
|
||||
dry_run: Option<bool>,
|
||||
}
|
||||
|
||||
/// Garbage collection tool
|
||||
pub async fn execute_gc(
|
||||
storage: &Arc<Mutex<Storage>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
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<Value> = 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<String> = 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,
|
||||
}))
|
||||
}
|
||||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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<i32>,
|
||||
min_retention: Option<f64>,
|
||||
min_similarity: Option<f32>,
|
||||
#[serde(alias = "detail_level")]
|
||||
detail_level: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<Value> = 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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
184
crates/vestige-mcp/src/tools/timeline.rs
Normal file
184
crates/vestige-mcp/src/tools/timeline.rs
Normal file
|
|
@ -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<String>,
|
||||
end: Option<String>,
|
||||
node_type: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
limit: Option<i32>,
|
||||
#[serde(alias = "detail_level")]
|
||||
detail_level: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse an ISO 8601 date or datetime string into a DateTime<Utc>.
|
||||
/// Supports both `2026-02-01` and `2026-02-01T00:00:00Z` formats.
|
||||
fn parse_datetime(s: &str) -> Result<DateTime<Utc>, 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<Mutex<Storage>>,
|
||||
args: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
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<NaiveDate, Vec<Value>> = 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<Value> = 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,
|
||||
}))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue