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

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>