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:
Sam Valladares 2026-02-12 04:33:05 -06:00
parent a92fb2b6ed
commit 34f5e8d52a
18 changed files with 2850 additions and 25 deletions

118
Cargo.lock generated
View file

@ -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",

View file

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

View file

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

View file

@ -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)
}
}
// ============================================================================

View file

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

View file

@ -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', " ");

View 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 &ge;
<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,"&amp;").replace(/"/g,"&quot;").replace(/'/g,"&#39;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
}
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>

View 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"),
})))
}

View 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(())
}

View 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>>,
}

View file

@ -0,0 +1,5 @@
//! Vestige MCP Server Library
//!
//! Shared modules accessible to all binaries in the crate.
pub mod dashboard;

View file

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

View file

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

View 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,
}))
}

View 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,
}))
}

View file

@ -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)]

View file

@ -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"));
}
}

View 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,
}))
}