agentmemory-python / src /viewer /index.html
Yash030's picture
feat: add Python plugin system, multi-agent connect CLI, and MCP tool expansions
26a284a
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>agentmemory viewer</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<!-- Removed Google Fonts <link> in #323: the viewer CSP is strict
(default-src 'none', style-src 'unsafe-inline', font-src 'self')
and external stylesheets from fonts.googleapis.com were blocked,
producing console CSP violations that some browsers (Windows
Edge in particular) escalate into broader script-execution
issues. System-font fallbacks are already declared on every
--font-* CSS variable below, so the viewer renders fine with
Georgia / system serif / system sans / system mono. -->
<style>
:root {
--bg: #F9F9F7;
--bg-alt: #F0F0EC;
--bg-subtle: #F4F4F0;
--bg-inset: #E8E8E3;
--border: #111111;
--border-light: #D4D4CF;
--border-heavy: #111111;
--ink: #111111;
--ink-secondary: #333333;
--ink-muted: #666666;
--ink-faint: #999999;
--accent: #CC0000;
--accent-light: #FF1A1A;
--cream: #F5F0E8;
--node-file: #2D6A4F;
--node-function: #1D4E89;
--node-concept: #B8860B;
--node-error: #CC0000;
--node-decision: #6B3FA0;
--node-pattern: #2563EB;
--node-library: #C2410C;
--node-person: #111111;
--green: #2D6A4F;
--blue: #1D4E89;
--yellow: #B8860B;
--red: #CC0000;
--purple: #6B3FA0;
--orange: #C2410C;
--cyan: #0E7490;
--font-display: 'Playfair Display', Georgia, 'Times New Roman', serif;
--font-body: 'Lora', Georgia, serif;
--font-ui: 'Inter', -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
}
html[data-theme="dark"] {
--bg: #1a1a1e;
--bg-alt: #232328;
--bg-subtle: #1f1f24;
--bg-inset: #2a2a30;
--border: #444;
--border-light: #3a3a42;
--border-heavy: #ccc;
--ink: #eee;
--ink-secondary: #ccc;
--ink-muted: #999;
--ink-faint: #777;
--cream: #2a2520;
}
html[data-theme="dark"] body {
background-image: radial-gradient(circle, #3a3a42 0.5px, transparent 0.5px);
}
html[data-theme="dark"] .graph-tooltip {
background: rgba(30,30,35,0.92);
border-color: rgba(255,255,255,0.1);
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
}
html[data-theme="dark"] .graph-controls button {
background: rgba(30,30,35,0.92);
border-color: rgba(255,255,255,0.1);
}
html[data-theme="dark"] .graph-controls button:hover {
background: var(--ink);
color: var(--bg);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--font-body);
background: var(--bg);
color: var(--ink-secondary);
line-height: 1.6;
overflow: hidden;
height: 100vh;
display: flex;
flex-direction: column;
background-image: radial-gradient(circle, #D4D4CF 0.5px, transparent 0.5px);
background-size: 16px 16px;
}
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 0; }
::-webkit-scrollbar-thumb:hover { background: var(--ink-muted); }
.app-header {
padding: 10px 24px;
border-bottom: 4px solid var(--border-heavy);
display: flex;
align-items: center;
justify-content: space-between;
background: var(--bg);
flex: 0 0 auto;
position: relative;
z-index: 3;
}
.app-header .brand {
display: flex;
align-items: baseline;
gap: 10px;
color: inherit;
text-decoration: none;
cursor: pointer;
}
.app-header .brand:hover h1,
.app-header .brand:focus-visible h1 { color: var(--accent); }
.app-header .brand:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
.app-header .brand h1 {
font-size: 22px;
color: var(--ink);
font-weight: 900;
font-family: var(--font-display);
letter-spacing: -0.02em;
text-transform: lowercase;
}
.app-header .brand .version {
font-size: 10px;
color: var(--ink-faint);
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.1em;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.ws-status {
font-size: 10px;
padding: 3px 10px;
display: flex;
align-items: center;
gap: 5px;
font-family: var(--font-ui);
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 600;
border: 1px solid var(--border-light);
}
.ws-status::before {
content: '';
width: 6px;
height: 6px;
display: inline-block;
}
.ws-status.connected { border-color: var(--green); color: var(--green); }
.ws-status.connected::before { background: var(--green); }
.ws-status.disconnected { border-color: var(--ink-faint); color: var(--ink-faint); }
.ws-status.disconnected::before { background: var(--ink-faint); }
.tab-bar {
display: flex;
height: 48px;
flex-shrink: 0;
border-bottom: 1px solid var(--border-light);
background: var(--bg);
overflow-x: auto;
flex: 0 0 auto;
position: relative;
z-index: 2;
}
.tab-bar button {
background: none;
border: none;
color: var(--ink-muted);
padding: 10px 20px;
font-size: 11px;
cursor: pointer;
border-bottom: 2px solid transparent;
white-space: nowrap;
font-family: var(--font-ui);
text-transform: uppercase;
letter-spacing: 0.12em;
font-weight: 600;
transition: color 0.15s, border-color 0.15s;
}
.tab-bar button:hover { color: var(--ink); }
.tab-bar button.active {
color: var(--ink);
border-bottom-color: var(--accent);
}
.view { display: none; flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 24px; }
.view.active { display: block; }
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0;
margin-bottom: 24px;
border: 1px solid var(--border);
}
.stat-card {
background: var(--bg);
padding: 16px 20px;
border-right: 1px solid var(--border-light);
border-bottom: 1px solid var(--border-light);
}
.stat-card:last-child { border-right: none; }
.stat-card .label {
font-size: 9px;
color: var(--ink-muted);
text-transform: uppercase;
letter-spacing: 0.15em;
margin-bottom: 4px;
font-family: var(--font-ui);
font-weight: 600;
}
.stat-card .value {
font-size: 32px;
font-weight: 900;
color: var(--ink);
font-family: var(--font-display);
line-height: 1.1;
}
.stat-card .sub {
font-size: 11px;
color: var(--ink-faint);
margin-top: 2px;
font-family: var(--font-ui);
}
.card {
background: var(--bg);
border: 1px solid var(--border);
padding: 20px;
margin-bottom: 16px;
transition: box-shadow 0.15s;
}
.card:hover {
box-shadow: 4px 4px 0px 0px var(--border);
}
.card-title {
font-size: 13px;
font-weight: 700;
color: var(--ink);
margin-bottom: 12px;
font-family: var(--font-display);
text-transform: uppercase;
letter-spacing: 0.06em;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-light);
}
.health-bar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.health-dot {
width: 10px;
height: 10px;
}
.health-dot.healthy { background: var(--green); }
.health-dot.degraded { background: var(--yellow); }
.health-dot.critical { background: var(--accent); }
.badge {
display: inline-block;
font-size: 9px;
padding: 2px 8px;
font-weight: 600;
font-family: var(--font-ui);
text-transform: uppercase;
letter-spacing: 0.08em;
border: 1px solid;
}
.badge-blue { border-color: var(--blue); color: var(--blue); background: transparent; }
.badge-green { border-color: var(--green); color: var(--green); background: transparent; }
.badge-yellow { border-color: var(--yellow); color: var(--yellow); background: transparent; }
.badge-red { border-color: var(--accent); color: var(--accent); background: transparent; }
.badge-purple { border-color: var(--purple); color: var(--purple); background: transparent; }
.badge-orange { border-color: var(--orange); color: var(--orange); background: transparent; }
.badge-cyan { border-color: var(--cyan); color: var(--cyan); background: transparent; }
.badge-muted { border-color: var(--border-light); color: var(--ink-muted); background: transparent; }
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
font-family: var(--font-body);
}
th {
text-align: left;
padding: 8px 12px;
border-bottom: 2px solid var(--border);
color: var(--ink);
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.12em;
font-weight: 600;
font-family: var(--font-ui);
}
td {
padding: 8px 12px;
border-bottom: 1px solid var(--border-light);
vertical-align: top;
}
tr:hover td { background: var(--bg-alt); }
.strength-bar {
width: 60px;
height: 4px;
background: var(--bg-inset);
overflow: hidden;
display: inline-block;
vertical-align: middle;
}
.strength-bar .fill {
height: 100%;
transition: width 0.3s;
}
.toolbar {
display: flex;
gap: 10px;
margin-bottom: 20px;
align-items: center;
flex-wrap: wrap;
}
.toolbar input, .toolbar select {
background: var(--bg);
border: 1px solid var(--border);
color: var(--ink);
padding: 7px 12px;
font-size: 13px;
outline: none;
font-family: var(--font-ui);
}
.toolbar input:focus, .toolbar select:focus {
border-color: var(--ink);
box-shadow: 2px 2px 0px 0px var(--border);
}
.toolbar input { flex: 1; min-width: 200px; }
.btn {
background: var(--bg);
border: 1px solid var(--border);
color: var(--ink);
padding: 7px 16px;
font-size: 11px;
cursor: pointer;
transition: box-shadow 0.1s, transform 0.1s;
font-family: var(--font-ui);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.btn:hover { box-shadow: 3px 3px 0px 0px var(--border); transform: translate(-1px, -1px); }
.btn:active { box-shadow: none; transform: translate(0, 0); }
.btn-danger { border-color: var(--accent); color: var(--accent); }
.btn-danger:hover { background: var(--accent); color: white; box-shadow: 3px 3px 0px 0px var(--border); }
.btn-primary { background: var(--ink); color: var(--bg); border-color: var(--ink); }
.btn-primary:hover { background: var(--ink-secondary); box-shadow: 3px 3px 0px 0px var(--ink-muted); }
.graph-container {
display: flex;
height: calc(100vh - 130px);
margin: -24px;
border-top: 1px solid var(--border-light);
}
.graph-canvas-wrap {
flex: 1;
position: relative;
overflow: hidden;
background: var(--bg);
}
.graph-canvas-wrap canvas {
display: block;
width: 100%;
height: 100%;
}
.graph-sidebar {
width: 260px;
border-left: 2px solid var(--border);
padding: 20px;
overflow-y: auto;
background: var(--bg);
}
.graph-sidebar h3 {
font-size: 9px;
color: var(--ink);
text-transform: uppercase;
letter-spacing: 0.15em;
margin-bottom: 12px;
font-family: var(--font-ui);
font-weight: 600;
padding-bottom: 6px;
border-bottom: 1px solid var(--border-light);
}
.filter-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 0;
font-size: 12px;
cursor: pointer;
font-family: var(--font-ui);
}
.filter-item input[type="checkbox"] {
accent-color: var(--ink);
}
.filter-dot {
width: 8px;
height: 8px;
display: inline-block;
}
.graph-info {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border-light);
}
.graph-info .info-row {
display: flex;
justify-content: space-between;
font-size: 12px;
padding: 3px 0;
font-family: var(--font-ui);
}
.graph-info .info-row .info-label { color: var(--ink-muted); }
.graph-info .info-row .info-value { color: var(--ink); font-weight: 600; font-family: var(--font-mono); }
.obs-card {
background: var(--bg);
border: 1px solid var(--border-light);
padding: 16px 20px;
margin-bottom: 12px;
border-left: 3px solid var(--border-light);
transition: box-shadow 0.15s;
min-width: 0;
max-width: 100%;
overflow: hidden;
}
.obs-card:hover { box-shadow: 3px 3px 0px 0px var(--border-light); }
.obs-card.imp-high { border-left-color: var(--accent); }
.obs-card.imp-med { border-left-color: var(--yellow); }
.obs-card.imp-low { border-left-color: var(--green); }
.obs-card .obs-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 12px;
margin-bottom: 6px;
}
.obs-card .obs-title-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.obs-card .obs-meta {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
white-space: nowrap;
}
.obs-card .obs-type-icon {
flex: 0 0 auto;
}
.obs-card .obs-title {
font-size: 14px;
font-weight: 700;
color: var(--ink);
font-family: var(--font-display);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.obs-card .obs-time {
font-size: 10px;
color: var(--ink-faint);
font-family: var(--font-mono);
letter-spacing: 0.04em;
}
.obs-card .obs-narrative {
font-size: 13px;
color: var(--ink-muted);
margin-bottom: 6px;
overflow-wrap: anywhere;
word-break: break-word;
}
.obs-card pre {
max-width: 100%;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
.obs-card .obs-facts {
margin: 6px 0 6px 16px;
font-size: 12px;
color: var(--ink-muted);
}
.obs-card .obs-facts li { margin-bottom: 2px; }
.tag-list { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 6px; }
mark { background: rgba(204, 0, 0, 0.12); color: var(--ink); padding: 0 2px; border-radius: 2px; }
.tag {
font-size: 10px;
padding: 1px 6px;
border: 1px solid var(--blue);
color: var(--blue);
font-family: var(--font-mono);
font-weight: 500;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.tag.file-tag { border-color: var(--green); color: var(--green); }
.session-list { display: flex; flex-direction: column; gap: 0; }
.session-item {
background: var(--bg);
border: 1px solid var(--border-light);
border-bottom: none;
padding: 14px 20px;
cursor: pointer;
transition: background 0.1s;
}
.session-item:last-child { border-bottom: 1px solid var(--border-light); }
.session-item:hover { background: var(--bg-alt); }
.session-item.selected { background: var(--bg-alt); border-left: 3px solid var(--accent); }
.session-item .session-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.session-item .session-project {
font-weight: 700;
color: var(--ink);
font-size: 14px;
font-family: var(--font-display);
}
.session-item .session-meta {
font-size: 11px;
color: var(--ink-muted);
font-family: var(--font-mono);
}
.detail-panel {
background: var(--bg);
border: 1px solid var(--border);
padding: 24px;
margin-top: 20px;
}
.detail-panel h3 {
font-size: 15px;
font-weight: 700;
color: var(--ink);
margin-bottom: 16px;
font-family: var(--font-display);
text-transform: uppercase;
letter-spacing: 0.04em;
padding-bottom: 8px;
border-bottom: 2px solid var(--border);
}
.detail-row {
display: flex;
padding: 6px 0;
font-size: 13px;
border-bottom: 1px solid var(--bg-inset);
}
.detail-row .dl { color: var(--ink-muted); width: 140px; flex-shrink: 0; font-family: var(--font-ui); font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; font-weight: 600; padding-top: 2px; }
.detail-row .dv { color: var(--ink); font-family: var(--font-body); }
.audit-entry {
padding: 12px 0;
border-bottom: 1px solid var(--border-light);
font-size: 13px;
}
.audit-entry:last-child { border-bottom: none; }
.audit-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.audit-detail {
font-size: 12px;
color: var(--ink-faint);
margin-top: 4px;
max-height: 0;
overflow: hidden;
transition: max-height 0.2s;
}
.audit-detail.open { max-height: 200px; }
.audit-detail pre {
font-family: var(--font-mono);
font-size: 11px;
background: var(--bg-alt);
padding: 10px;
border: 1px solid var(--border-light);
overflow-x: auto;
}
.bar-chart { margin-top: 8px; }
.bar-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
font-size: 12px;
}
.bar-label { width: 120px; color: var(--ink-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-mono); font-size: 11px; }
.bar-track {
flex: 1;
height: 6px;
background: var(--bg-inset);
overflow: hidden;
}
.bar-fill {
height: 100%;
transition: width 0.3s;
}
.bar-value { width: 30px; text-align: right; color: var(--ink-muted); font-size: 11px; font-family: var(--font-mono); font-weight: 500; }
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--ink-faint);
}
.empty-state .empty-icon { font-size: 36px; margin-bottom: 10px; opacity: 0.4; }
.empty-state p { font-size: 14px; font-family: var(--font-body); font-style: italic; }
.empty-state .empty-title { font-size: 16px; font-weight: 600; font-style: normal; color: var(--ink-muted); margin-bottom: 8px; }
.empty-state .empty-lead { font-style: normal; font-size: 14px; color: var(--ink-muted); max-width: 520px; margin: 0 auto 14px; line-height: 1.5; }
.empty-state pre.empty-cmd {
display: inline-block; margin: 10px auto 12px; padding: 10px 14px;
background: var(--bg-alt); border: 1px solid var(--border);
border-radius: 4px; font-family: var(--font-mono); font-size: 12px;
color: var(--ink); text-align: left; font-style: normal; white-space: pre;
}
.empty-state .empty-link { color: var(--accent); text-decoration: underline; font-size: 13px; font-style: normal; }
/* Feature flag banner system — compact collapsed by default */
.flag-banners {
padding: 0 12px 10px 12px;
background: var(--bg);
flex: 0 0 auto;
position: relative;
z-index: 1;
}
.flag-banners:empty { display: none; }
button.flag-summary {
display: flex; align-items: center; gap: 12px;
padding: 8px 14px; border-radius: 4px;
border: 1px solid var(--border);
background: var(--bg-subtle);
font-family: var(--font-ui); font-size: 12px;
color: var(--ink-muted);
cursor: pointer; user-select: none;
width: 100%; text-align: left;
appearance: none;
flex: 1 1 auto;
}
button.flag-summary:hover,
button.flag-summary:focus-visible { background: var(--bg-alt); outline: 2px solid var(--border); outline-offset: 1px; }
.flag-summary .flag-count { color: var(--ink); font-weight: 600; }
.flag-summary .flag-pill {
display: inline-block; padding: 1px 8px; border-radius: 10px;
background: #f59e0b20; color: #d97706; font-size: 11px; font-weight: 600;
margin-right: 6px;
}
.flag-summary .flag-pill.info { background: var(--border-light); color: var(--ink-muted); }
.flag-summary .flag-toggle { margin-left: auto; font-size: 11px; opacity: 0.7; }
.flag-list {
display: none; flex-direction: column; gap: 6px;
margin-top: 6px;
max-height: min(30vh, 260px);
overflow-y: auto;
}
.flag-list.open { display: flex; }
.flag-banner {
display: flex; align-items: flex-start; gap: 10px;
padding: 10px 14px; border-radius: 3px;
border: 1px solid var(--border);
background: var(--bg-subtle);
font-family: var(--font-ui); font-size: 12px;
}
.flag-banner.warn { border-left: 3px solid #f59e0b; }
.flag-banner.info { border-left: 3px solid var(--ink-muted); }
.flag-banner .flag-icon { flex-shrink: 0; font-size: 14px; line-height: 1.3; }
.flag-banner .flag-body { flex: 1; min-width: 0; }
.flag-banner .flag-title { font-weight: 600; color: var(--ink); margin-bottom: 2px; font-size: 12px; }
.flag-banner .flag-title code { font-family: var(--font-mono); font-size: 10px; color: var(--ink-muted); font-weight: 400; margin-left: 4px; }
.flag-banner .flag-desc { color: var(--ink-muted); margin-bottom: 4px; line-height: 1.4; font-size: 12px; }
.flag-banner .flag-enable {
display: block; margin-top: 2px; padding: 5px 8px;
background: var(--bg); border: 1px solid var(--border); border-radius: 3px;
font-family: var(--font-mono); font-size: 10px; color: var(--ink);
white-space: pre-wrap; word-break: break-all;
}
.flag-close {
background: none; border: none; color: var(--ink-faint); cursor: pointer;
font-size: 16px; line-height: 1; padding: 0 4px; font-family: inherit;
flex: 0 0 auto;
}
.flag-close:hover,
.flag-close:focus-visible { color: var(--ink); outline: 2px solid var(--border); outline-offset: 1px; }
.viewer-auth {
display: none;
padding: 0 24px 10px 24px;
background: var(--bg);
flex: 0 0 auto;
position: relative;
z-index: 1;
}
.viewer-auth.open { display: block; }
.viewer-auth-panel {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(180px, 320px) auto;
gap: 10px;
align-items: center;
width: min(960px, 100%);
max-width: 960px;
padding: 10px 14px;
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
background: var(--bg-subtle);
font-family: var(--font-ui);
font-size: 12px;
}
.viewer-auth-title { font-weight: 600; color: var(--ink); margin-bottom: 2px; }
.viewer-auth-desc { color: var(--ink-muted); line-height: 1.4; }
.viewer-auth-desc code { font-family: var(--font-mono); font-size: 10px; color: var(--ink); }
.viewer-auth input {
width: 100%;
min-width: 0;
padding: 7px 9px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--ink);
font-family: var(--font-mono);
font-size: 12px;
}
@media (max-width: 900px) {
.viewer-auth-panel { grid-template-columns: 1fr; }
}
/* Viewer footer */
.viewer-footer {
margin-top: 48px; padding: 16px 0 24px;
border-top: 1px solid var(--border-light);
display: flex; align-items: center; gap: 10px;
font-family: var(--font-ui); font-size: 11px;
color: var(--ink-faint); letter-spacing: 0.05em;
}
.viewer-footer a { color: var(--ink-muted); text-decoration: none; }
.viewer-footer a:hover { color: var(--ink); text-decoration: underline; }
.viewer-footer .footer-sep { color: var(--ink-faint); opacity: 0.5; }
.loading { color: var(--ink-faint); padding: 20px; text-align: center; font-style: italic; font-family: var(--font-body); }
.empty { color: var(--ink-muted); padding: 24px; text-align: center; font-family: var(--font-body); font-style: italic; border: 1px dashed var(--border); }
.replay-controls { display: flex; align-items: center; gap: 6px; padding: 10px 0; flex-wrap: wrap; font-family: var(--font-ui); font-size: 12px; }
.replay-controls button { padding: 4px 10px; border: 1px solid var(--border); background: var(--bg); color: var(--ink); cursor: pointer; font-family: var(--font-ui); font-size: 12px; }
.replay-controls button:hover { background: var(--bg-alt); }
.replay-controls button.active { background: var(--ink); color: var(--bg); }
.replay-controls .sep { width: 12px; }
.replay-progress { height: 3px; background: var(--border-light); margin: 4px 0 12px 0; }
.replay-progress-bar { height: 100%; background: var(--ink); transition: width 100ms linear; }
.replay-grid { display: grid; grid-template-columns: 340px 1fr; gap: 16px; align-items: start; }
.replay-list { max-height: 60vh; overflow-y: auto; border: 1px solid var(--border); }
.replay-event { display: grid; grid-template-columns: 90px 1fr 60px; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border-light); font-family: var(--font-ui); font-size: 11px; cursor: default; }
.replay-event:hover { background: var(--bg-alt); }
.replay-event-active { background: var(--bg-alt); border-left: 2px solid var(--ink); }
.replay-event-kind { font-size: 9px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-muted); align-self: center; }
.replay-event-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.replay-event-time { text-align: right; font-family: var(--font-mono); color: var(--ink-muted); }
.replay-event-prompt .replay-event-kind { color: var(--blue, #0366d6); }
.replay-event-response .replay-event-kind { color: var(--green, #2ea043); }
.replay-event-tool_call .replay-event-kind { color: var(--orange, #bf8700); }
.replay-event-tool_result .replay-event-kind { color: var(--ink-muted); }
.replay-event-tool_error .replay-event-kind { color: var(--red, #cf222e); }
.replay-detail { border: 1px solid var(--border); padding: 14px; max-height: 60vh; overflow-y: auto; font-family: var(--font-body); font-size: 13px; }
.replay-detail-header { margin-bottom: 6px; }
.replay-body { background: var(--bg-alt); padding: 10px; white-space: pre-wrap; word-break: break-word; font-family: var(--font-mono); font-size: 12px; }
.replay-tool { margin-top: 10px; font-family: var(--font-ui); font-size: 12px; }
.replay-tool-block { margin-top: 8px; }
.replay-tool-block pre { background: var(--bg-alt); padding: 10px; max-height: 240px; overflow: auto; font-family: var(--font-mono); font-size: 11px; white-space: pre-wrap; word-break: break-word; }
.muted { color: var(--ink-muted); font-size: 11px; }
.metric-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.metric-table th { padding: 6px 8px; font-size: 9px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-muted); border-bottom: 2px solid var(--border); text-align: left; font-family: var(--font-ui); font-weight: 600; }
.metric-table td { padding: 5px 8px; border-bottom: 1px solid var(--border-light); }
.metric-table tr:hover td { background: var(--bg-alt); }
.metric-fn { font-family: var(--font-mono); font-size: 11px; color: var(--blue); }
.metric-num { font-family: var(--font-mono); color: var(--ink); text-align: right; }
.cb-indicator { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; font-size: 10px; font-weight: 600; font-family: var(--font-ui); text-transform: uppercase; letter-spacing: 0.08em; border: 1px solid; }
.cb-closed { border-color: var(--green); color: var(--green); }
.cb-open { border-color: var(--accent); color: var(--accent); }
.cb-half-open { border-color: var(--yellow); color: var(--yellow); }
.worker-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 12px; font-family: var(--font-ui); }
.worker-dot { width: 8px; height: 8px; }
.worker-dot.running { background: var(--green); }
.worker-dot.stopped { background: var(--accent); }
.worker-dot.starting { background: var(--yellow); }
.gauge { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.gauge-bar { flex: 1; height: 6px; background: var(--bg-inset); overflow: hidden; }
.gauge-fill { height: 100%; transition: width 0.5s; }
.gauge-label { width: 90px; font-size: 10px; color: var(--ink-muted); font-family: var(--font-ui); text-transform: uppercase; letter-spacing: 0.08em; font-weight: 600; }
.gauge-value { width: 70px; font-size: 11px; color: var(--ink); text-align: right; font-family: var(--font-mono); }
.obs-type-icon { font-size: 16px; margin-right: 4px; }
.obs-subtitle { font-size: 12px; color: var(--ink-faint); margin-top: 2px; font-style: italic; }
.obs-importance { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; font-size: 11px; font-weight: 700; font-family: var(--font-mono); border: 1px solid; }
.imp-1, .imp-2, .imp-3 { border-color: var(--green); color: var(--green); }
.imp-4, .imp-5, .imp-6 { border-color: var(--yellow); color: var(--yellow); }
.imp-7, .imp-8, .imp-9, .imp-10 { border-color: var(--accent); color: var(--accent); }
.three-col { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; }
@media (max-width: 1100px) { .three-col { grid-template-columns: 1fr 1fr; } }
@media (max-width: 768px) { .three-col { grid-template-columns: 1fr; } }
.pagination {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 20px;
}
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.3);
z-index: 100;
align-items: center;
justify-content: center;
}
.modal-overlay.open { display: flex; }
.modal {
background: var(--bg);
border: 2px solid var(--border);
padding: 28px;
max-width: 460px;
width: 90%;
box-shadow: 6px 6px 0px 0px var(--border);
}
.modal h3 {
font-size: 18px;
font-weight: 700;
color: var(--ink);
margin-bottom: 12px;
font-family: var(--font-display);
}
.modal p { font-size: 13px; color: var(--ink-muted); margin-bottom: 16px; }
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.selected-node-info {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border-light);
}
.selected-node-info h4 {
font-size: 13px;
font-weight: 700;
color: var(--ink);
margin-bottom: 6px;
font-family: var(--font-display);
}
.selected-node-info .prop {
font-size: 12px;
color: var(--ink-muted);
padding: 2px 0;
font-family: var(--font-ui);
}
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 768px) {
.two-col { grid-template-columns: 1fr; }
.graph-sidebar { width: 200px; }
.stats-grid { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }
}
.section-rule {
border: none;
border-top: 1px solid var(--border-light);
margin: 20px 0;
}
.dateline {
font-family: var(--font-mono);
font-size: 10px;
color: var(--ink-faint);
text-transform: uppercase;
letter-spacing: 0.1em;
}
.timeline-container { position: relative; padding: 20px 0; }
.timeline-container::before { content: ''; position: absolute; left: 50%; top: 0; bottom: 0; width: 2px; background: var(--border-light); transform: translateX(-50%); }
.timeline-item { position: relative; width: 45%; margin-bottom: 20px; min-width: 0; }
.timeline-item.tl-left { margin-left: 0; margin-right: auto; text-align: right; padding-right: 30px; }
.timeline-item.tl-right { margin-left: auto; margin-right: 0; padding-left: 30px; }
.timeline-dot { position: absolute; width: 12px; height: 12px; border-radius: 50%; top: 16px; z-index: 1; border: 2px solid var(--bg); }
.timeline-item.tl-left .timeline-dot { right: -6px; transform: translateX(50%); }
.timeline-item.tl-right .timeline-dot { left: -6px; transform: translateX(-50%); }
.timeline-connector { position: absolute; top: 21px; height: 1px; background: var(--border-light); width: 24px; }
.timeline-item.tl-left .timeline-connector { right: 0; }
.timeline-item.tl-right .timeline-connector { left: 0; }
.timeline-date-marker { text-align: center; position: relative; margin: 24px 0 16px; z-index: 2; }
.timeline-date-marker span { background: var(--bg); padding: 4px 16px; font-size: 10px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-muted); border: 1px solid var(--border-light); }
.heatmap-wrap { overflow-x: auto; padding: 8px 0; }
.heatmap-grid { display: grid; grid-template-rows: repeat(7, 1fr); grid-auto-flow: column; grid-auto-columns: 12px; gap: 2px; }
.heatmap-cell { width: 10px; height: 10px; background: var(--bg-inset); cursor: default; }
.heatmap-cell[title] { cursor: pointer; }
.heatmap-cell.level-1 { background: rgba(45,106,79,0.2); }
.heatmap-cell.level-2 { background: rgba(45,106,79,0.4); }
.heatmap-cell.level-3 { background: rgba(45,106,79,0.65); }
.heatmap-cell.level-4 { background: var(--green); }
.heatmap-labels { display: flex; gap: 2px; font-size: 9px; color: var(--ink-faint); font-family: var(--font-mono); margin-bottom: 4px; }
.graph-search { width: 100%; background: var(--bg); border: 1px solid var(--border); padding: 7px 12px; font-size: 12px; font-family: var(--font-ui); margin-bottom: 12px; outline: none; }
.graph-search:focus { border-color: var(--ink); box-shadow: 2px 2px 0px 0px var(--border); }
.graph-legend { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border-light); }
.graph-legend-item { display: flex; align-items: center; gap: 6px; padding: 3px 0; font-size: 11px; font-family: var(--font-ui); color: var(--ink-muted); }
.graph-legend-shape { width: 16px; height: 16px; display: flex; align-items: center; justify-content: center; }
.graph-tooltip { position: absolute; background: rgba(255,255,255,0.88); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border: 1px solid rgba(17,17,17,0.08); padding: 12px 16px; font-size: 11px; font-family: var(--font-ui); pointer-events: none; z-index: 10; box-shadow: 0 8px 32px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.06); max-width: 260px; display: none; border-radius: 8px; transition: opacity 0.15s ease; }
.graph-tooltip.visible { display: block; opacity: 1; }
.graph-tooltip .tt-name { font-weight: 700; color: var(--ink); margin-bottom: 4px; font-family: var(--font-display); font-size: 13px; }
.graph-tooltip .tt-type { font-size: 9px; text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 6px; font-weight: 600; padding: 2px 6px; border-radius: 3px; display: inline-block; }
.graph-tooltip .tt-prop { font-size: 10px; color: var(--ink-muted); padding: 1px 0; }
.graph-tooltip .tt-conns { font-size: 10px; color: var(--ink-faint); margin-top: 6px; border-top: 1px solid rgba(17,17,17,0.08); padding-top: 6px; font-family: var(--font-mono); }
.graph-controls { position: absolute; bottom: 16px; right: 16px; display: flex; flex-direction: column; gap: 2px; z-index: 5; }
.graph-controls button { width: 36px; height: 36px; font-size: 18px; cursor: pointer; background: rgba(255,255,255,0.92); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); border: 1px solid rgba(17,17,17,0.1); color: var(--ink); display: flex; align-items: center; justify-content: center; font-weight: 500; font-family: var(--font-ui); border-radius: 6px; transition: all 0.15s ease; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
.graph-controls button:hover { background: var(--ink); color: var(--bg); transform: scale(1.05); box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
.graph-controls .ctrl-divider { height: 1px; background: var(--border-light); margin: 2px 4px; }
.type-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px; }
.type-chip { font-size: 10px; padding: 3px 10px; border: 1px solid var(--border-light); cursor: pointer; font-family: var(--font-ui); text-transform: uppercase; letter-spacing: 0.06em; font-weight: 600; transition: all 0.15s; background: var(--bg); }
.type-chip:hover { border-color: var(--ink); }
.type-chip.active { background: var(--ink); color: var(--bg); border-color: var(--ink); }
.memory-fact { padding: 8px 0; border-bottom: 1px solid var(--border-light); font-size: 13px; display: flex; justify-content: space-between; align-items: center; gap: 8px; }
.memory-fact:last-child { border-bottom: none; }
.procedure-item { padding: 10px 0; border-bottom: 1px solid var(--border-light); }
.procedure-item:last-child { border-bottom: none; }
.procedure-steps { margin: 6px 0 0 16px; font-size: 12px; color: var(--ink-muted); }
.procedure-steps li { margin-bottom: 2px; }
.consolidation-row { display: flex; justify-content: space-between; padding: 4px 0; font-size: 12px; font-family: var(--font-ui); }
.consolidation-row .cl { color: var(--ink-muted); }
.consolidation-row .cv { color: var(--ink); font-weight: 600; font-family: var(--font-mono); }
.activity-feed-item { display: flex; gap: 10px; padding: 10px 0; border-bottom: 1px solid var(--border-light); font-size: 13px; }
.activity-feed-item:last-child { border-bottom: none; }
.activity-feed-icon { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; font-size: 14px; flex-shrink: 0; border: 1px solid var(--border-light); }
.activity-feed-body { flex: 1; min-width: 0; }
.activity-feed-title { font-weight: 600; color: var(--ink); font-family: var(--font-display); font-size: 13px; }
.activity-feed-meta { font-size: 10px; color: var(--ink-faint); font-family: var(--font-mono); margin-top: 2px; }
/* Personal Second Brain styles */
.personal-grid {
display: grid;
grid-template-columns: 280px 1fr;
gap: 20px;
align-items: start;
}
@media (max-width: 768px) {
.personal-grid {
grid-template-columns: 1fr;
}
}
.personal-sidebar {
display: flex;
flex-direction: column;
gap: 12px;
}
.personal-search-wrapper {
position: relative;
}
.personal-search-input {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
padding: 10px 14px;
font-size: 13px;
font-family: var(--font-ui);
outline: none;
transition: box-shadow 0.15s;
}
.personal-search-input:focus {
box-shadow: 2px 2px 0px 0px var(--border);
}
.personal-file-list {
display: flex;
flex-direction: column;
border: 1px solid var(--border-light);
background: var(--bg);
max-height: 500px;
overflow-y: auto;
}
.personal-file-item {
padding: 12px 16px;
cursor: pointer;
border-bottom: 1px solid var(--border-light);
transition: background 0.1s, border-left 0.1s;
font-family: var(--font-ui);
font-size: 13px;
color: var(--ink);
display: flex;
align-items: center;
gap: 8px;
}
.personal-file-item:last-child {
border-bottom: none;
}
.personal-file-item:hover {
background: var(--bg-alt);
}
.personal-file-item.selected {
background: var(--bg-alt);
border-left: 3px solid var(--accent);
font-weight: 600;
}
.personal-file-name {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.personal-file-size {
font-size: 10px;
color: var(--ink-faint);
font-family: var(--font-mono);
}
.personal-editor-card {
background: var(--bg);
border: 1px solid var(--border);
padding: 24px;
min-height: 450px;
display: flex;
flex-direction: column;
gap: 16px;
}
.personal-editor-header {
border-bottom: 1px solid var(--border-light);
padding-bottom: 12px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.personal-editor-title {
font-size: 16px;
font-weight: 700;
color: var(--ink);
font-family: var(--font-display);
}
.personal-editor-meta {
font-size: 11px;
color: var(--ink-muted);
font-family: var(--font-mono);
}
.personal-editor-textarea {
width: 100%;
height: 380px;
background: var(--bg);
color: var(--ink);
border: 1px solid var(--border-light);
padding: 14px;
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.5;
resize: vertical;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.personal-editor-textarea:focus {
border-color: var(--border);
box-shadow: 2px 2px 0px 0px var(--border);
}
.personal-actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 12px;
}
.personal-status-indicator {
font-size: 12px;
color: var(--green);
font-weight: 500;
font-family: var(--font-ui);
opacity: 0;
transition: opacity 0.35s ease;
}
.personal-status-indicator.visible {
opacity: 1;
}
.personal-status-indicator.error {
color: var(--accent);
}
</style>
</head>
<body>
<div class="app-header">
<a class="brand" href="#dashboard" data-tab-link="dashboard" aria-label="Open dashboard">
<h1>agentmemory</h1>
<span class="version">v__AGENTMEMORY_VERSION__</span>
</a>
<div class="header-right">
<span class="dateline" id="dateline"></span>
<button id="theme-toggle" class="btn" style="font-size:9px;padding:3px 10px;letter-spacing:0.1em;margin-right:8px;" data-action="toggle-theme">DARK</button>
<span id="ws-status" class="ws-status disconnected">live updates off</span>
</div>
</div>
<div class="tab-bar" id="tab-bar">
<button class="active" data-tab="dashboard" aria-current="page">Dashboard</button>
<button data-tab="graph">Graph</button>
<button data-tab="memories">Memories</button>
<button data-tab="timeline">Timeline</button>
<button data-tab="sessions">Sessions</button>
<button data-tab="lessons">Lessons</button>
<button data-tab="actions">Actions</button>
<button data-tab="crystals">Crystals</button>
<button data-tab="audit">Audit</button>
<button data-tab="activity">Activity</button>
<button data-tab="profile">Profile</button>
<button data-tab="replay">Replay</button>
<button data-tab="personal">Personal</button>
</div>
<div id="flag-banners" class="flag-banners"></div>
<div id="viewer-auth" class="viewer-auth"></div>
<div id="view-dashboard" class="view active"></div>
<div id="view-graph" class="view"></div>
<div id="view-memories" class="view"></div>
<div id="view-lessons" class="view"></div>
<div id="view-actions" class="view"></div>
<div id="view-crystals" class="view"></div>
<div id="view-timeline" class="view"></div>
<div id="view-sessions" class="view"></div>
<div id="view-audit" class="view"></div>
<div id="view-activity" class="view"></div>
<div id="view-profile" class="view"></div>
<div id="view-replay" class="view"></div>
<div id="view-personal" class="view"></div>
<div id="modal-overlay" class="modal-overlay">
<div class="modal" id="modal"></div>
</div>
<footer id="viewer-footer" class="viewer-footer">
<span>agentmemory viewer · <span id="footer-version">loading...</span></span>
<span class="footer-sep">·</span>
<a href="https://github.com/rohitg00/agentmemory" target="_blank" rel="noopener">github</a>
<span class="footer-sep">·</span>
<a href="https://github.com/rohitg00/agentmemory#readme" target="_blank" rel="noopener">docs</a>
<span class="footer-sep">·</span>
<a id="footer-feedback" href="#" target="_blank" rel="noopener">report issue &rarr;</a>
</footer>
<script nonce="__AGENTMEMORY_VIEWER_NONCE__">
var params = new URLSearchParams(window.location.search);
var paramPort = params.get('port');
var locPort = window.location.port;
var wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
var REST, WS_URL, WS_DIRECT_URL, wsPort;
if (paramPort) {
var resolvedPort = paramPort;
REST = window.location.protocol + '//' + window.location.hostname + ':' + resolvedPort;
wsPort = params.get('wsPort') || resolvedPort;
WS_URL = wsProto + '//' + window.location.hostname + ':' + wsPort;
WS_DIRECT_URL = WS_URL + '/stream/mem-live/viewer';
} else if (locPort) {
var resolvedPort = locPort;
REST = window.location.protocol + '//' + window.location.hostname + ':' + resolvedPort;
wsPort = params.get('wsPort') || resolvedPort;
WS_URL = wsProto + '//' + window.location.hostname + ':' + wsPort;
WS_DIRECT_URL = WS_URL + '/stream/mem-live/viewer';
} else {
REST = window.location.origin;
wsPort = params.get('wsPort');
WS_URL = wsPort
? wsProto + '//' + window.location.hostname + ':' + wsPort
: wsProto + '//' + window.location.host;
WS_DIRECT_URL = WS_URL + '/stream/mem-live/viewer';
}
var dateEl = document.getElementById('dateline');
if (dateEl) dateEl.textContent = new Date().toLocaleDateString('en-US', { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' });
function isDarkMode() { return document.documentElement.dataset.theme === 'dark'; }
function applyTheme(dark, persist) {
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
var btn = document.getElementById('theme-toggle');
if (btn) btn.textContent = dark ? 'LIGHT' : 'DARK';
if (persist) localStorage.setItem('agentmemory-theme', dark ? 'dark' : 'light');
}
window.toggleTheme = function() { applyTheme(!isDarkMode(), true); };
var savedTheme = localStorage.getItem('agentmemory-theme');
if (savedTheme) {
applyTheme(savedTheme === 'dark', false);
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
applyTheme(true, false);
}
var NODE_COLORS = {
file: '#2D6A4F', function: '#1D4E89', concept: '#B8860B', error: '#CC0000',
decision: '#6B3FA0', pattern: '#2563EB', library: '#C2410C', person: '#111111',
folder: '#4F46E5'
};
function folderColor(id) {
var h = 0;
for (var i = 0; i < id.length; i++) { h = (h * 31 + id.charCodeAt(i)) & 0xfffffff; }
var hue = (h % 360 + 360) % 360;
var sat = 55 + (h % 25);
var lig = isDarkMode() ? 52 + (h % 16) : 38 + (h % 14);
// Return hex so parseInt(color.slice(1,3),16) works in gradient code
var s = sat / 100, l = lig / 100;
var a = s * Math.min(l, 1 - l);
function f(n) {
var k = (n + hue / 30) % 12;
var c = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * c).toString(16).padStart(2, '0');
}
return '#' + f(0) + f(8) + f(4);
}
var OP_BADGES = {
observe: 'badge-blue', compress: 'badge-cyan', remember: 'badge-green',
forget: 'badge-red', evolve: 'badge-purple', consolidate: 'badge-yellow',
share: 'badge-orange', delete: 'badge-red', import: 'badge-blue', export: 'badge-blue'
};
var TYPE_BADGES = {
pattern: 'badge-purple', preference: 'badge-blue', architecture: 'badge-cyan',
bug: 'badge-red', workflow: 'badge-green', fact: 'badge-yellow'
};
var OBS_TYPE_COLORS = {
file_read: '#1D4E89', file_write: '#2D6A4F', file_edit: '#B8860B',
command_run: '#C2410C', search: '#2563EB', web_fetch: '#6B3FA0',
conversation: '#111111', error: '#CC0000', decision: '#B8860B',
discovery: '#2D6A4F', subagent: '#6B3FA0', notification: '#0E7490',
task: '#1D4E89', other: '#666666'
};
var OBS_TYPE_ICONS = {
file_read: '&#128196;', file_write: '&#9999;', file_edit: '&#128221;',
command_run: '&#9889;', search: '&#128270;', web_fetch: '&#127760;',
conversation: '&#128172;', error: '&#9888;', decision: '&#129300;',
discovery: '&#128161;', subagent: '&#129302;', notification: '&#128276;',
task: '&#9745;', other: '&#128196;'
};
var CB_STATE_COLORS = { closed: 'badge-green', open: 'badge-red', 'half-open': 'badge-yellow' };
var TAB_IDS = ['dashboard', 'graph', 'memories', 'timeline', 'sessions', 'lessons', 'actions', 'crystals', 'audit', 'activity', 'profile', 'replay', 'personal'];
var VIEWER_TOKEN_STORAGE_KEY = 'agentmemory-viewer-token';
var state = {
activeTab: 'dashboard',
dashboard: { loaded: false, health: null, sessions: [], memories: [], graphStats: null, recentAudit: [], lessons: [], crystals: [], semantic: [], procedural: [] },
graph: { loaded: false, nodes: [], edges: [], stats: null, filters: {}, selectedNode: null, queryError: null, truncated: false, totalNodes: 0, totalEdges: 0 },
memories: { loaded: false, items: [], search: '', typeFilter: '', folderFilter: '' },
timeline: { loaded: false, observations: [], sessionId: '', minImportance: 0, page: 0, pageSize: 50 },
sessions: { loaded: false, items: [], selectedId: null, folderFilter: '' },
audit: { loaded: false, entries: [], opFilter: '' },
activity: { loaded: false, observations: [], sessions: [], typeFilter: '' },
lessons: { loaded: false, items: [], search: '' },
actions: { loaded: false, items: [], frontier: [], statusFilter: '', search: '' },
crystals: { loaded: false, items: [], search: '', lessonMap: {} },
profile: { loaded: false, projects: [], selectedProject: '', data: null },
replay: { loaded: false, sessions: [], selectedId: '', timeline: null, cursor: 0, playing: false, speed: 1, timer: null, startAt: 0, offsetAt: 0 },
personal: { loaded: false, files: [], selectedFile: '', search: '' },
flagsConfig: null,
flagsDismissed: {},
ws: null
};
function esc(s) {
if (!s) return '';
var d = document.createElement('div');
d.textContent = String(s);
return d.innerHTML;
}
function formatTime(ts) {
if (!ts) return '';
try { return new Date(ts).toLocaleString(); } catch { return ts; }
}
function shortTime(ts) {
if (!ts) return '';
try { return new Date(ts).toLocaleTimeString(); } catch { return ts; }
}
function truncate(s, n) {
if (!s) return '';
return s.length > n ? s.slice(0, n) + '...' : s;
}
function sessionId(s) {
return s && s.id !== undefined && s.id !== null ? String(s.id) : '';
}
function shortSessionId(s, n) {
var id = sessionId(s);
return id ? id.slice(0, n || 8) : '';
}
function sessionDisplayName(s) {
var folder = s && s.project ? String(s.project).split(/[\\/]/).pop() : '';
var title = s && s.title ? String(s.title).trim() : '';
if (title && folder) {
return title + ' (' + folder + ')';
} else if (title) {
return title;
} else if (folder) {
return folder;
}
return shortSessionId(s, 8) || 'Unknown session';
}
function sessionLabel(s) {
var id = shortSessionId(s, 8);
var name = sessionDisplayName(s);
return id ? name + ' (' + id + ')' : name + ' (missing id)';
}
function debounce(fn, ms) {
var t;
return function() {
var args = arguments, ctx = this;
clearTimeout(t);
t = setTimeout(function() { fn.apply(ctx, args); }, ms);
};
}
// IME_SAFE_SEARCH_V2
function bindImeSafeSearch(input, ms, onSearch) {
var composing = false;
var justCommitted = false;
var run = debounce(function(value) { onSearch(value); }, ms);
input.addEventListener('compositionstart', function() { composing = true; });
input.addEventListener('compositionend', function() {
composing = false;
justCommitted = true;
onSearch(input.value);
setTimeout(function() { justCommitted = false; }, 0);
});
input.addEventListener('input', function(e) {
if (composing || e.isComposing) return;
if (justCommitted) return;
run(input.value);
});
}
function captureSearchFocus(ids) {
var a = document.activeElement;
if (!a || ids.indexOf(a.id) < 0) return null;
return { id: a.id, start: a.selectionStart, end: a.selectionEnd };
}
function restoreSearchFocus(focus) {
if (!focus) return;
var el = document.getElementById(focus.id);
if (!el) return;
el.focus();
if (typeof el.setSelectionRange === 'function') {
try { el.setSelectionRange(focus.start, focus.end); } catch (e) {}
}
}
(function() {
var autoToken = '__AGENTMEMORY_AUTO_TOKEN__';
if (autoToken && autoToken.length > 0 && autoToken !== '__AGENTMEMORY_AUTO_TOKEN__') {
try { sessionStorage.setItem(VIEWER_TOKEN_STORAGE_KEY, autoToken); } catch (_) {}
}
})();
function getViewerToken() {
try { return sessionStorage.getItem(VIEWER_TOKEN_STORAGE_KEY) || ''; } catch (_) { return ''; }
}
function setViewerToken(token) {
try {
if (token) sessionStorage.setItem(VIEWER_TOKEN_STORAGE_KEY, token);
else sessionStorage.removeItem(VIEWER_TOKEN_STORAGE_KEY);
} catch (_) {}
}
function showViewerAuthPrompt() {
var host = document.getElementById('viewer-auth');
if (!host) return;
host.classList.add('open');
host.innerHTML =
'<div class="viewer-auth-panel">' +
'<div>' +
'<div class="viewer-auth-title">Viewer authorization required</div>' +
'<div id="viewer-auth-desc" class="viewer-auth-desc">Enter <code>AGENTMEMORY_SECRET</code> to unlock viewer API access.</div>' +
'</div>' +
'<input id="viewer-auth-token" type="password" autocomplete="off" spellcheck="false" aria-label="AGENTMEMORY_SECRET" aria-describedby="viewer-auth-desc" placeholder="AGENTMEMORY_SECRET" />' +
'<button class="btn" data-action="save-viewer-token">Unlock</button>' +
'</div>';
var input = document.getElementById('viewer-auth-token');
if (input && typeof input.focus === 'function') input.focus();
}
function hideViewerAuthPrompt() {
var host = document.getElementById('viewer-auth');
if (!host) return;
host.classList.remove('open');
host.innerHTML = '';
}
async function api(path, opts) {
try {
var url = REST + '/agentmemory/' + path;
var headers = Object.assign({ 'Cache-Control': 'no-cache' }, (opts && opts.headers) || {});
var viewerToken = getViewerToken();
if (viewerToken && !headers.Authorization && !headers.authorization) {
headers.Authorization = 'Bearer ' + viewerToken;
}
var fetchOpts = Object.assign({}, opts || {}, { headers: headers });
var res = await fetch(url, fetchOpts);
if (!res.ok) {
if (res.status === 401) showViewerAuthPrompt();
console.warn('[viewer] API ' + (fetchOpts.method || 'GET') + ' ' + path + ' returned ' + res.status);
return null;
}
hideViewerAuthPrompt();
return await res.json();
} catch (err) {
console.warn('[viewer] API error on ' + path + ':', err);
return null;
}
}
async function apiGet(path) { return api(path); }
async function apiPost(path, body) {
return api(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {})
});
}
async function apiDelete(path, body) {
return api(path, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {})
});
}
function normalizeTab(tab) {
var normalized = String(tab || '').replace(/^#/, '').toLowerCase();
return TAB_IDS.indexOf(normalized) >= 0 ? normalized : 'dashboard';
}
function tabFromRoute() {
try {
return normalizeTab(decodeURIComponent(window.location.hash.slice(1)));
} catch (_) {
return 'dashboard';
}
}
function updateTabRoute(tab, replace) {
var target = '#' + tab;
if (window.location.hash === target) return;
if (replace) {
history.replaceState(null, '', target);
} else {
history.pushState(null, '', target);
}
}
function switchTab(tab, opts) {
opts = opts || {};
tab = normalizeTab(tab);
if (state.activeTab === 'replay' && tab !== 'replay' && typeof stopReplayTimer === 'function') {
stopReplayTimer();
}
if (!opts.skipRoute) {
updateTabRoute(tab, !!opts.replaceRoute);
}
state.activeTab = tab;
document.querySelectorAll('.tab-bar button').forEach(function(b) {
var isActive = b.dataset.tab === tab;
b.classList.toggle('active', isActive);
if (isActive) {
b.setAttribute('aria-current', 'page');
} else {
b.removeAttribute('aria-current');
}
});
document.querySelectorAll('.view').forEach(function(v) {
v.classList.toggle('active', v.id === 'view-' + tab);
});
if (state.flagsConfig) renderFlagBanners(state.flagsConfig);
loadTab(tab);
}
async function loadTab(tab) {
switch(tab) {
case 'dashboard': if (!state.dashboard.loaded) await loadDashboard(); break;
case 'graph': if (!state.graph.loaded) await loadGraph(); break;
case 'memories': if (!state.memories.loaded) await loadMemories(); break;
case 'timeline': if (!state.timeline.loaded) await loadTimeline(); break;
case 'sessions': if (!state.sessions.loaded) await loadSessions(); break;
case 'lessons': if (!state.lessons.loaded) await loadLessons(); break;
case 'actions': if (!state.actions.loaded) await loadActions(); break;
case 'crystals': if (!state.crystals.loaded) await loadCrystals(); break;
case 'audit': if (!state.audit.loaded) await loadAudit(); break;
case 'activity': if (!state.activity.loaded) await loadActivity(); break;
case 'profile': if (!state.profile.loaded) await loadProfile(); break;
case 'replay': if (!state.replay.loaded) await loadReplay(); break;
case 'personal': if (!state.personal.loaded) await loadPersonal(); break;
}
}
async function loadDashboard() {
var el = document.getElementById('view-dashboard');
el.innerHTML = '<div class="loading">Loading dashboard...</div>';
try {
var results = await Promise.all([
apiGet('health'),
apiGet('sessions'),
apiGet('memories?latest=true&limit=500'),
apiGet('graph/stats'),
apiGet('audit?limit=5'),
apiGet('semantic'),
apiGet('procedural'),
apiGet('relations'),
apiGet('lessons'),
apiGet('crystals')
]);
state.dashboard.health = results[0];
state.dashboard.sessions = (results[1] && results[1].sessions) || [];
state.dashboard.memories = (results[2] && results[2].memories) || [];
state.dashboard.graphStats = results[3];
state.dashboard.recentAudit = (results[4] && results[4].entries) || [];
state.dashboard.semantic = (results[5] && results[5].items) || (results[5] && results[5].facts) || (results[5] && results[5].semantic) || [];
state.dashboard.procedural = (results[6] && results[6].items) || (results[6] && results[6].procedures) || (results[6] && results[6].procedural) || [];
state.dashboard.lessons = (results[8] && results[8].lessons) || [];
state.dashboard.crystals = (results[9] && results[9].crystals) || [];
state.dashboard.relations = (results[7] && results[7].relations) || [];
state.dashboard.loaded = true;
renderDashboard();
} catch (err) {
var msg = (err && err.message) ? err.message : String(err);
console.error('[viewer] loadDashboard failed:', err);
el.innerHTML =
'<div class="loading" style="color:var(--accent);">' +
'Dashboard failed to load: ' + msg +
'<br><br><span style="font-size:12px;color:var(--ink-muted);">' +
'Check the browser console for the full error. If you see CSP ' +
'violations, please open an issue with the agentmemory version ' +
'(top-right of the viewer) and the violation text.' +
'</span></div>';
}
}
function renderDashboard() {
var el = document.getElementById('view-dashboard');
var d = state.dashboard;
var h = d.health || {};
var snap = h.health || {};
var healthStatus = h.status || 'unknown';
var dotClass = healthStatus === 'healthy' ? 'healthy' : healthStatus === 'degraded' ? 'degraded' : healthStatus === 'critical' ? 'critical' : '';
var activeSessions = d.sessions.filter(function(s) { return s.status === 'active'; }).length;
var gs = d.graphStats || {};
var nodeCount = gs.totalNodes !== undefined ? gs.totalNodes : (gs.nodes !== undefined ? gs.nodes : (gs.nodeCount || 0));
var edgeCount = gs.totalEdges !== undefined ? gs.totalEdges : (gs.edges !== undefined ? gs.edges : (gs.edgeCount || 0));
var fMetrics = h.functionMetrics || [];
var cb = h.circuitBreaker || null;
var workers = snap.workers || [];
var html = '';
if (d.sessions.length === 0) {
html += '<div class="card" style="margin-bottom:14px;padding:24px 28px;background:var(--bg-subtle);border-left:3px solid var(--accent);">' +
'<div style="font-family:var(--font-ui);font-size:11px;letter-spacing:0.15em;text-transform:uppercase;color:var(--accent);font-weight:700;margin-bottom:8px;">First run &rarr; magical moment in 10 seconds</div>' +
'<div style="font-family:var(--font-display,Lora,Georgia,serif);font-size:22px;font-weight:700;color:var(--ink);margin-bottom:8px;">Seed sample data + prove semantic recall works</div>' +
'<div style="font-size:13px;color:var(--ink-muted);margin-bottom:12px;line-height:1.5;max-width:640px;">agentmemory is running but hasn&rsquo;t seen any sessions yet. Run the demo command in a second terminal: it seeds 3 realistic coding sessions and proves the hybrid search finds semantically-related memories that keyword search would miss.</div>' +
'<pre style="display:inline-block;margin:0;padding:10px 14px;background:var(--bg);border:1px solid var(--border);border-radius:4px;font-family:var(--font-mono);font-size:12px;color:var(--ink);">npx @agentmemory/agentmemory demo</pre>' +
'<div style="margin-top:10px;"><a class="empty-link" href="https://github.com/rohitg00/agentmemory#quick-start" target="_blank" rel="noopener" style="font-size:12px;">Or: wire up your real agent &rarr;</a></div>' +
'</div>';
}
html += '<div class="stats-grid">';
html += '<div class="stat-card"><div class="label">Sessions</div><div class="value">' + d.sessions.length + '</div><div class="sub">' + activeSessions + ' active</div></div>';
html += '<div class="stat-card"><div class="label">Memories</div><div class="value">' + d.memories.length + '</div><div class="sub">latest versions</div></div>';
var lessonCount = (d.lessons || []).length;
var crystalCount = (d.crystals || []).length;
html += '<div class="stat-card"><div class="label">Lessons</div><div class="value">' + lessonCount + '</div><div class="sub">confidence-scored</div></div>';
html += '<div class="stat-card"><div class="label">Crystals</div><div class="value">' + crystalCount + '</div><div class="sub">action digests</div></div>';
html += '<div class="stat-card"><div class="label">Graph Nodes</div><div class="value">' + nodeCount + '</div><div class="sub">' + edgeCount + ' edges</div></div>';
html += '<div class="stat-card"><div class="label">Health</div><div class="value"><div class="health-bar"><span class="health-dot ' + dotClass + '"></span> ' + esc(healthStatus) + '</div></div>';
html += '<div class="sub">' + esc(snap.connectionState || 'unknown') + '</div></div>';
var totalCalls = fMetrics.reduce(function(a, m) { return a + (m.totalCalls || 0); }, 0);
html += '<div class="stat-card"><div class="label">Function Calls</div><div class="value">' + totalCalls + '</div><div class="sub">' + fMetrics.length + ' functions tracked</div></div>';
if (cb) {
var cbClass = cb.state === 'closed' ? 'cb-closed' : cb.state === 'open' ? 'cb-open' : 'cb-half-open';
html += '<div class="stat-card"><div class="label">Circuit Breaker</div><div class="value"><span class="cb-indicator ' + cbClass + '">' + esc(cb.state) + '</span></div>';
html += '<div class="sub">' + (cb.failures || 0) + ' failures</div></div>';
}
var totalObs = d.sessions.reduce(function(a, s) { return a + (s.observationCount || 0); }, 0);
var tokenBudget = parseInt(new URLSearchParams(window.location.search).get('tokenBudget') || '2000', 10) || 2000;
var estFull = totalObs * 80;
var estInjected = d.sessions.length * tokenBudget;
var savings = estFull > 0 ? Math.round((1 - estInjected / Math.max(estFull, 1)) * 100) : 0;
if (savings < 0) savings = 0;
var tokensSaved = Math.max(0, estFull - estInjected);
var costDollars = tokensSaved / 1000 * 0.3;
var costCents = Math.round(costDollars * 100);
var costStr = costCents >= 100 ? '$' + (costCents / 100).toFixed(2) : costCents + 'ct';
html += '<div class="stat-card"><div class="label">Token Savings</div><div class="value">' + savings + '%</div><div class="sub">~' + tokensSaved.toLocaleString() + ' tokens · ' + costStr + ' saved</div></div>';
html += '</div>';
if (snap.memory || snap.cpu) {
html += '<div class="card" style="margin-bottom:16px"><div class="card-title">System Resources</div>';
if (snap.memory) {
var heapUsed = Math.round((snap.memory.heapUsed || 0) / 1024 / 1024);
var heapTotal = Math.round((snap.memory.heapTotal || 0) / 1024 / 1024);
var rss = Math.round((snap.memory.rss || 0) / 1024 / 1024);
var heapPct = heapTotal > 0 ? Math.round((heapUsed / heapTotal) * 100) : 0;
var rssAboveFloor = rss >= 512;
var heapColor = (heapPct > 80 && rssAboveFloor) ? 'var(--red)' : (heapPct > 60 && rssAboveFloor) ? 'var(--yellow)' : 'var(--green)';
html += '<div class="gauge"><span class="gauge-label">Heap</span><div class="gauge-bar"><div class="gauge-fill" style="width:' + heapPct + '%;background:' + heapColor + '"></div></div><span class="gauge-value">' + heapUsed + ' / ' + heapTotal + ' MB</span></div>';
html += '<div class="gauge"><span class="gauge-label">RSS</span><div class="gauge-bar"><div class="gauge-fill" style="width:' + Math.min(100, Math.round(rss / 512 * 100)) + '%;background:var(--blue)"></div></div><span class="gauge-value">' + rss + ' MB</span></div>';
if (snap.memory.external) {
var ext = Math.round(snap.memory.external / 1024 / 1024);
html += '<div class="gauge"><span class="gauge-label">External</span><div class="gauge-bar"><div class="gauge-fill" style="width:' + Math.min(100, Math.round(ext / 128 * 100)) + '%;background:var(--purple)"></div></div><span class="gauge-value">' + ext + ' MB</span></div>';
}
}
if (snap.cpu) {
var cpuPct = snap.cpu.percent || 0;
var cpuColor = cpuPct > 80 ? 'var(--red)' : cpuPct > 50 ? 'var(--yellow)' : 'var(--green)';
html += '<div class="gauge"><span class="gauge-label">CPU</span><div class="gauge-bar"><div class="gauge-fill" style="width:' + Math.min(100, cpuPct) + '%;background:' + cpuColor + '"></div></div><span class="gauge-value">' + cpuPct.toFixed(1) + '%</span></div>';
}
if (snap.eventLoopLagMs !== undefined) {
var lag = snap.eventLoopLagMs;
var lagColor = lag > 100 ? 'var(--red)' : lag > 20 ? 'var(--yellow)' : 'var(--green)';
html += '<div class="gauge"><span class="gauge-label">Event Loop</span><div class="gauge-bar"><div class="gauge-fill" style="width:' + Math.min(100, lag) + '%;background:' + lagColor + '"></div></div><span class="gauge-value">' + lag.toFixed(1) + ' ms</span></div>';
}
if (snap.uptimeSeconds) {
var mins = Math.floor(snap.uptimeSeconds / 60);
var hrs = Math.floor(mins / 60);
var upStr = hrs > 0 ? hrs + 'h ' + (mins % 60) + 'm' : mins + 'm';
html += '<div style="font-size:10px;color:var(--ink-faint);margin-top:6px;font-family:var(--font-mono);letter-spacing:0.04em;">UPTIME: ' + upStr + '</div>';
}
html += '</div>';
}
if (snap.alerts && snap.alerts.length > 0) {
html += '<div class="card" style="margin-bottom:16px;border-color:var(--accent);border-width:2px;"><div class="card-title" style="color:var(--accent);border-bottom-color:var(--accent);">Alerts (' + snap.alerts.length + ')</div>';
snap.alerts.forEach(function(al) {
html += '<div style="font-size:12px;color:var(--accent);padding:4px 0;border-bottom:1px solid var(--border-light);font-family:var(--font-ui);">' + esc(al) + '</div>';
});
html += '</div>';
}
if (snap.notes && snap.notes.length > 0) {
html += '<div class="card" style="margin-bottom:16px;"><div class="card-title" style="color:var(--ink-muted);">Notes (' + snap.notes.length + ')</div>';
snap.notes.forEach(function(n) {
html += '<div style="font-size:12px;color:var(--ink-muted);padding:4px 0;border-bottom:1px solid var(--border-light);font-family:var(--font-ui);">' + esc(n) + '</div>';
});
html += '</div>';
}
html += '<div class="two-col">';
html += '<div class="card"><div class="card-title">Recent Sessions</div>';
if (d.sessions.length === 0) {
html += '<div class="empty-state"><p>No sessions yet. Start a coding session with agentmemory hooks enabled.</p></div>';
} else {
var recent = d.sessions.slice().sort(function(a, b) { return (b.startedAt || '').localeCompare(a.startedAt || ''); }).slice(0, 5);
html += '<table><tr><th>Project</th><th>Status</th><th>Obs</th><th>Started</th></tr>';
recent.forEach(function(s) {
var statusBadge = s.status === 'active' ? 'badge-green' : s.status === 'completed' ? 'badge-blue' : 'badge-muted';
html += '<tr><td style="color:var(--ink);font-weight:500;">' + esc(sessionDisplayName(s)) + '</td>';
html += '<td><span class="badge ' + statusBadge + '">' + esc(s.status) + '</span></td>';
html += '<td style="color:var(--ink-muted);font-family:var(--font-mono);font-size:12px;">' + (s.observationCount || 0) + '</td>';
html += '<td style="font-family:var(--font-mono);font-size:11px;color:var(--ink-faint);">' + esc(shortTime(s.startedAt)) + '</td></tr>';
});
html += '</table>';
}
html += '</div>';
html += '<div class="card"><div class="card-title">Recent Activity</div>';
if (d.recentAudit.length === 0) {
html += '<div class="empty-state"><p>No activity recorded yet</p></div>';
} else {
d.recentAudit.forEach(function(a) {
var badgeClass = OP_BADGES[a.operation] || 'badge-muted';
html += '<div style="padding:6px 0;border-bottom:1px solid var(--border-light);font-size:13px;">';
html += '<span class="badge ' + badgeClass + '">' + esc(a.operation) + '</span> ';
if (a.functionId) html += '<span style="font-size:11px;color:var(--ink-muted);font-family:var(--font-mono);">' + esc(a.functionId) + '</span> ';
html += '<span style="color:var(--ink-faint);font-size:10px;font-family:var(--font-mono);">' + esc(shortTime(a.timestamp)) + '</span>';
if (a.targetIds && a.targetIds.length) html += '<span style="font-size:10px;color:var(--ink-faint);margin-left:4px;">(' + a.targetIds.length + ' targets)</span>';
html += '</div>';
});
}
html += '</div>';
html += '</div>';
html += '<div class="two-col" style="margin-top:16px;">';
html += '<div class="card"><div class="card-title">Semantic Memory (Consolidated Facts)</div>';
var semanticList = d.semantic || [];
if (semanticList.length === 0) {
html += '<div class="empty-state"><p>No semantic memories consolidated yet. Run consolidation to synthesize episodic facts.</p></div>';
} else {
html += '<div style="display:flex; flex-direction:column; gap:8px; max-height: 300px; overflow-y: auto; padding-right: 4px;">';
semanticList.forEach(function(item) {
var confPct = Math.round((item.confidence || 0) * 100);
var confClass = confPct > 80 ? 'badge-green' : confPct > 50 ? 'badge-blue' : 'badge-muted';
html += '<div style="padding:8px 10px; border:1px solid var(--border-light); border-radius:4px; font-size:13px; background:var(--bg);">';
html += '<div style="color:var(--ink); font-weight:500; line-height:1.4;">' + esc(item.fact) + '</div>';
html += '<div style="margin-top:6px; display:flex; gap:8px; align-items:center; font-size:11px; color:var(--ink-muted); font-family:var(--font-mono);">';
html += '<span class="badge ' + confClass + '">' + confPct + '% confidence</span>';
html += '<span>accessed: ' + (item.accessCount || 1) + 'x</span>';
html += '</div></div>';
});
html += '</div>';
}
html += '</div>';
html += '<div class="card"><div class="card-title">Procedural Memory (Action Workflows)</div>';
var proceduralList = d.procedural || [];
if (proceduralList.length === 0) {
html += '<div class="empty-state"><p>No procedural memories extracted yet. Consolidated sessions with recurring patterns will generate reusable workflows.</p></div>';
} else {
html += '<div style="display:flex; flex-direction:column; gap:10px; max-height: 300px; overflow-y: auto; padding-right: 4px;">';
proceduralList.forEach(function(item) {
html += '<div style="padding:10px; border:1px solid var(--border-light); border-radius:4px; background:var(--bg);">';
html += '<div style="font-weight:700; color:var(--ink); font-size:14px; font-family:var(--font-display);">' + esc(item.name) + '</div>';
if (item.triggerCondition) {
html += '<div style="font-size:11px; color:var(--ink-muted); font-style:italic; margin:4px 0;">Trigger: ' + esc(item.triggerCondition) + '</div>';
}
if (item.steps && item.steps.length > 0) {
html += '<ol style="margin-left:16px; margin-top:6px; font-size:12px; line-height:1.5; color:var(--ink-secondary);">';
item.steps.forEach(function(step) {
html += '<li>' + esc(step) + '</li>';
});
html += '</ol>';
}
html += '<div style="margin-top:6px; font-size:10px; color:var(--ink-faint); font-family:var(--font-mono);">';
html += 'Frequency: ' + (item.frequency || 1) + 'x';
html += '</div></div>';
});
html += '</div>';
}
html += '</div>';
html += '</div>';
if (fMetrics.length > 0) {
var sorted = fMetrics.slice().sort(function(a, b) { return (b.totalCalls || 0) - (a.totalCalls || 0); });
html += '<div class="card" style="margin-top:16px"><div class="card-title">Function Metrics (OTel)</div>';
html += '<table class="metric-table"><tr><th>Function</th><th style="text-align:right">Calls</th><th style="text-align:right">Success</th><th style="text-align:right">Fail</th><th style="text-align:right">Avg Latency</th><th style="text-align:right">Quality</th></tr>';
sorted.forEach(function(m) {
var successRate = m.totalCalls > 0 ? Math.round((m.successCount / m.totalCalls) * 100) : 0;
var rateColor = successRate >= 95 ? 'var(--green)' : successRate >= 80 ? 'var(--yellow)' : 'var(--red)';
var latencyColor = m.avgLatencyMs > 1000 ? 'var(--red)' : m.avgLatencyMs > 200 ? 'var(--yellow)' : 'var(--green)';
html += '<tr>';
html += '<td class="metric-fn">' + esc(m.functionId) + '</td>';
html += '<td class="metric-num">' + m.totalCalls + '</td>';
html += '<td class="metric-num" style="color:' + rateColor + '">' + m.successCount + ' (' + successRate + '%)</td>';
html += '<td class="metric-num" style="color:' + (m.failureCount > 0 ? 'var(--red)' : 'var(--ink-faint)') + '">' + m.failureCount + '</td>';
html += '<td class="metric-num" style="color:' + latencyColor + '">' + Math.round(m.avgLatencyMs) + ' ms</td>';
html += '<td class="metric-num">' + (m.avgQualityScore > 0 ? m.avgQualityScore.toFixed(2) : '-') + '</td>';
html += '</tr>';
});
html += '</table></div>';
}
if (workers.length > 0) {
html += '<div class="card" style="margin-top:16px"><div class="card-title">Workers</div>';
workers.forEach(function(w) {
var statusClass = w.status === 'running' ? 'running' : w.status === 'starting' ? 'starting' : 'stopped';
html += '<div class="worker-row"><span class="worker-dot ' + statusClass + '"></span>';
html += '<span style="color:var(--ink);font-weight:600;font-family:var(--font-ui);font-size:12px;">' + esc(w.name) + '</span>';
html += '<span class="badge ' + (w.status === 'running' ? 'badge-green' : 'badge-muted') + '">' + esc(w.status) + '</span>';
html += '<span style="font-size:10px;color:var(--ink-faint);font-family:var(--font-mono);">' + esc(w.id) + '</span></div>';
});
html += '</div>';
}
if (cb && cb.state !== 'closed') {
html += '<div class="card" style="margin-top:16px;border-color:var(--accent);border-width:2px;"><div class="card-title" style="color:var(--accent);">Circuit Breaker Details</div>';
html += '<div class="detail-row"><div class="dl">State</div><div class="dv"><span class="cb-indicator ' + (cb.state === 'open' ? 'cb-open' : 'cb-half-open') + '">' + esc(cb.state) + '</span></div></div>';
html += '<div class="detail-row"><div class="dl">Failures</div><div class="dv" style="color:var(--accent);font-family:var(--font-mono);">' + (cb.failures || 0) + '</div></div>';
if (cb.lastFailureAt) html += '<div class="detail-row"><div class="dl">Last Failure</div><div class="dv" style="font-family:var(--font-mono);font-size:12px;">' + esc(formatTime(cb.lastFailureAt)) + '</div></div>';
if (cb.openedAt) html += '<div class="detail-row"><div class="dl">Opened At</div><div class="dv" style="font-family:var(--font-mono);font-size:12px;">' + esc(formatTime(cb.openedAt)) + '</div></div>';
html += '</div>';
}
var semFacts = d.semantic || [];
var procItems = d.procedural || [];
var relItems = d.relations || [];
html += '<div class="card" style="margin-top:16px;"><div class="card-title">Consolidation Status</div>';
html += '<div class="consolidation-row"><span class="cl">Semantic facts</span><span class="cv">' + semFacts.length + '</span></div>';
html += '<div class="consolidation-row"><span class="cl">Procedures</span><span class="cv">' + procItems.length + '</span></div>';
html += '<div class="consolidation-row"><span class="cl">Relations</span><span class="cv">' + relItems.length + '</span></div>';
html += '</div>';
if (relItems.length > 0) {
html += '<div class="card" style="margin-top:16px;"><div class="card-title">Memory Relations</div>';
relItems.slice(0, 8).forEach(function(r) {
var relType = r.type || r.relationType || 'related';
var badgeClass = relType === 'supersedes' ? 'badge-red' : relType === 'extends' ? 'badge-green' : relType === 'contradicts' ? 'badge-yellow' : 'badge-muted';
html += '<div style="padding:4px 0;border-bottom:1px solid var(--border-light);font-size:12px;display:flex;align-items:center;gap:6px;">';
html += '<span style="font-family:var(--font-mono);color:var(--blue);font-size:11px;">' + esc(truncate(r.sourceId || r.fromId || '', 8)) + '</span>';
html += '<span class="badge ' + badgeClass + '">' + esc(relType) + '</span>';
html += '<span style="font-family:var(--font-mono);color:var(--blue);font-size:11px;">' + esc(truncate(r.targetId || r.toId || '', 8)) + '</span>';
html += '</div>';
});
html += '</div>';
}
html += '<div style="text-align:center;margin-top:20px;"><button class="btn btn-primary" data-action="refresh-dashboard">Refresh</button>';
html += '<span style="font-size:10px;color:var(--ink-faint);margin-left:10px;font-family:var(--font-mono);text-transform:uppercase;letter-spacing:0.08em;">Auto-refresh 30s</span></div>';
el.innerHTML = html;
}
var dashboardTimer = null;
function refreshDashboard() {
state.dashboard.loaded = false;
loadDashboard();
}
function startDashboardAutoRefresh() {
if (dashboardTimer) clearInterval(dashboardTimer);
dashboardTimer = setInterval(function() {
if (pollTimer) return;
if (state.activeTab === 'dashboard') refreshDashboard();
}, 30000);
}
var graphSim = { nodes: [], edges: [], running: false, canvas: null, ctx: null, raf: null, panX: 0, panY: 0, zoom: 1, dragNode: null, mouseX: 0, mouseY: 0, tickCount: 0, quietTicks: 0 };
function wakeGraphSim() {
graphSim.quietTicks = 0;
if (graphSim.running && !graphSim.raf) {
graphSim.raf = requestAnimationFrame(runSimulation);
}
}
var GRAPH_INITIAL_LIMIT = 500;
async function loadGraph() {
var el = document.getElementById('view-graph');
el.innerHTML = '<div class="graph-container"><div class="graph-canvas-wrap"><canvas id="graph-canvas"></canvas><div class="graph-controls"><button title="Zoom In" data-action="zoom-graph" data-dir="1">+</button><button title="Zoom Out" data-action="zoom-graph" data-dir="-1">&minus;</button><div class="ctrl-divider"></div><button title="Recenter" data-action="recenter-graph">⌖</button></div><div class="graph-tooltip" id="graph-tooltip"></div></div><div class="graph-sidebar" id="graph-sidebar"></div></div>';
var results = await Promise.all([
apiGet('sessions'),
apiGet('memories?latest=true&limit=2000'),
apiGet('graph/stats')
]);
var sessions = (results[0] && results[0].sessions) || [];
var memories = (results[1] && results[1].memories) || [];
var stats = results[2] || {};
state.graph.queryError = null;
// Extract unique folders (projects)
var folderMap = {};
sessions.forEach(function(s) {
var path = s.project ? String(s.project).trim() : '';
if (!path) return;
if (!path.includes('/') && !path.includes('\\')) return;
if (!folderMap[path]) {
folderMap[path] = {
id: path,
name: path.split(/[\\/]/).pop() || path,
fullPath: path,
type: 'folder',
sessions: [],
memories: [],
concepts: new Set(),
firstPrompts: [],
summaries: []
};
}
folderMap[path].sessions.push(s);
if (s.firstPrompt) folderMap[path].firstPrompts.push(s.firstPrompt);
if (s.summary) folderMap[path].summaries.push(s.summary);
});
memories.forEach(function(m) {
var path = m.project ? String(m.project).trim() : '';
if (!path) return;
if (!path.includes('/') && !path.includes('\\')) return;
if (!folderMap[path]) {
// If the folder is not in sessions, create it
folderMap[path] = {
id: path,
name: path.split(/[\\/]/).pop() || path,
fullPath: path,
type: 'folder',
sessions: [],
memories: [],
concepts: new Set(),
firstPrompts: [],
summaries: []
};
}
folderMap[path].memories.push(m);
if (m.concepts && Array.isArray(m.concepts)) {
m.concepts.forEach(function(c) {
folderMap[path].concepts.add(c.toLowerCase());
});
}
});
var folders = Object.values(folderMap);
// Compute detailed properties/descriptions
folders.forEach(function(f) {
var desc = '';
if (f.summaries.length > 0) {
desc += '<strong>Summaries:</strong><br>' + f.summaries.slice(0, 3).map(function(s) { return '&bull; ' + esc(s); }).join('<br>') + '<br><br>';
} else if (f.firstPrompts.length > 0) {
desc += '<strong>Prompts:</strong><br>' + f.firstPrompts.slice(0, 3).map(function(p) { return '&bull; ' + esc(p); }).join('<br>') + '<br><br>';
}
var conceptsArr = Array.from(f.concepts);
if (conceptsArr.length > 0) {
desc += '<strong>Concepts:</strong><br>' + conceptsArr.slice(0, 10).map(function(c) { return '<span class="tag">' + esc(c) + '</span>'; }).join(' ') + '<br><br>';
}
if (f.memories.length > 0) {
desc += '<strong>Memories:</strong><br>' + f.memories.slice(0, 5).map(function(m) { return '&bull; ' + esc(m.title); }).join('<br>');
}
f.properties = {
description: desc || 'No description available for this folder.',
fullPath: f.fullPath,
sessionCount: f.sessions.length,
memoryCount: f.memories.length
};
});
// Compute edges (connections between folders)
var edges = [];
for (var i = 0; i < folders.length; i++) {
for (var j = i + 1; j < folders.length; j++) {
var f1 = folders[i];
var f2 = folders[j];
var sharedConcepts = [];
f1.concepts.forEach(function(c) {
if (f2.concepts.has(c)) {
sharedConcepts.push(c);
}
});
var path1 = f1.fullPath.split(/[\\/]/).filter(Boolean);
var path2 = f2.fullPath.split(/[\\/]/).filter(Boolean);
var commonSubdirs = 0;
for (var k = 0; k < Math.min(path1.length, path2.length); k++) {
if (path1[k].toLowerCase() === path2[k].toLowerCase()) {
var p = path1[k].toLowerCase();
if (p !== 'c:' && p !== 'd:' && p !== 'downloads' && p !== 'projects' && p !== 'other projects') {
commonSubdirs++;
}
} else {
break;
}
}
var connectionType = [];
var weight = 0;
if (sharedConcepts.length > 0) {
connectionType.push('Shared concepts: ' + sharedConcepts.slice(0, 2).join(', '));
weight += sharedConcepts.length * 0.8;
}
if (commonSubdirs > 0) {
connectionType.push('Common parent path');
weight += commonSubdirs * 1.5;
}
if (weight > 0) {
edges.push({
id: f1.fullPath + '->' + f2.fullPath,
sourceNodeId: f1.fullPath,
targetNodeId: f2.fullPath,
type: connectionType.join(' & '),
weight: Math.min(5, weight)
});
}
}
}
state.graph.nodes = folders;
state.graph.edges = edges;
state.graph.truncated = false;
state.graph.totalNodes = folders.length;
state.graph.totalEdges = edges.length;
state.graph.stats = { totalNodes: folders.length, totalEdges: edges.length };
state.graph.loaded = true;
var types = {};
state.graph.nodes.forEach(function(n) { types[n.type] = true; });
state.graph.filters = types;
renderGraphSidebar();
initGraph();
}
var NODE_SHAPES = {
file: 'rect', function: 'circle', concept: 'circle', error: 'diamond',
decision: 'diamond', pattern: 'circle', library: 'hexagon', person: 'circle',
folder: 'rect'
};
var graphSearchTerm = '';
function renderGraphSidebar() {
var sb = document.getElementById('graph-sidebar');
if (!sb) return;
var gs = state.graph.stats || {};
var nodeCount = gs.totalNodes !== undefined ? gs.totalNodes : (gs.nodes !== undefined ? gs.nodes : (gs.nodeCount || state.graph.nodes.length));
var edgeCount = gs.totalEdges !== undefined ? gs.totalEdges : (gs.edges !== undefined ? gs.edges : (gs.edgeCount || state.graph.edges.length));
var html = '';
if (state.graph.queryError) {
html += '<div style="margin:8px 0;padding:10px 12px;border:1px solid var(--accent);background:var(--bg-alt);font-size:12px;color:var(--ink);line-height:1.4;">';
html += '<div style="font-weight:600;margin-bottom:4px;">Graph query failed</div>';
html += '<div style="font-size:11px;color:var(--ink-muted);">' + esc(state.graph.queryError) + '</div>';
html += '<button class="btn" data-action="rebuild-graph" style="margin-top:8px;font-size:11px;">Retry</button>';
html += '</div>';
} else if (state.graph.truncated) {
html += '<div style="margin:8px 0;padding:10px 12px;border:1px solid var(--border);background:var(--bg-alt);font-size:11px;color:var(--ink-muted);line-height:1.4;">';
html += 'Showing ' + state.graph.nodes.length + ' of ' + state.graph.totalNodes + ' nodes (most-connected first). The full graph is too large to render at once.';
html += '</div>';
}
html += '<input type="text" class="graph-search" id="graph-search" placeholder="Search nodes...">';
html += '<h3 style="margin-top:16px;font-size:10px;text-transform:uppercase;letter-spacing:0.12em;color:var(--ink-muted);font-family:var(--font-ui);font-weight:700;">Graph Stats</h3>';
html += '<div style="display:flex;gap:20px;margin:10px 0 16px;padding:12px;background:var(--bg-alt);border:1px solid var(--border-light);border-radius:4px;">';
html += '<div style="text-align:center;flex:1;"><span style="font-size:28px;font-weight:900;font-family:var(--font-display);color:var(--ink);line-height:1;">' + nodeCount + '</span><div style="font-size:8px;color:var(--ink-faint);text-transform:uppercase;letter-spacing:0.12em;font-family:var(--font-ui);font-weight:600;margin-top:4px;">Nodes</div></div>';
html += '<div style="width:1px;background:var(--border-light);"></div>';
html += '<div style="text-align:center;flex:1;"><span style="font-size:28px;font-weight:900;font-family:var(--font-display);color:var(--ink);line-height:1;">' + edgeCount + '</span><div style="font-size:8px;color:var(--ink-faint);text-transform:uppercase;letter-spacing:0.12em;font-family:var(--font-ui);font-weight:600;margin-top:4px;">Edges</div></div>';
html += '</div>';
html += '<h3 style="margin-top:12px;font-size:10px;text-transform:uppercase;letter-spacing:0.12em;color:var(--ink-muted);font-family:var(--font-ui);font-weight:700;">Filter by Type</h3>';
Object.keys(state.graph.filters).forEach(function(type) {
var color = NODE_COLORS[type] || '#666666';
html += '<label class="filter-item"><input type="checkbox" checked data-type="' + esc(type) + '"><span class="filter-dot" style="background:' + color + '"></span>' + esc(type) + '</label>';
});
html += '<div class="graph-legend"><h3>Legend</h3>';
var shapeLabels = { rect: '&#9645;', circle: '&#9679;', diamond: '&#9670;', hexagon: '&#11042;' };
var shownShapes = {};
Object.keys(NODE_COLORS).forEach(function(type) {
var shape = NODE_SHAPES[type] || 'circle';
var color = NODE_COLORS[type];
var key = type;
if (shownShapes[key]) return;
shownShapes[key] = true;
html += '<div class="graph-legend-item"><span class="graph-legend-shape" style="color:' + color + ';font-size:14px;">' + (shapeLabels[shape] || '&#9679;') + '</span><span>' + esc(type) + '</span></div>';
});
html += '</div>';
html += '<button class="btn" style="margin-top:14px;width:100%;font-size:11px;padding:8px;letter-spacing:0.06em;transition:all 0.15s ease;" data-action="rebuild-graph">↻ Rebuild Graph</button>';
html += '<div id="selected-node-panel"></div>';
var __focus = captureSearchFocus(['graph-search']);
sb.innerHTML = html;
sb.querySelectorAll('input[type="checkbox"]').forEach(function(cb) {
cb.addEventListener('change', function() {
state.graph.filters[this.dataset.type] = this.checked;
renderGraph();
});
});
var searchInput = document.getElementById('graph-search');
if (searchInput) {
bindImeSafeSearch(searchInput, 200, function(v){ graphSearchTerm = v.toLowerCase(); renderGraph(); });
}
restoreSearchFocus(__focus);
}
function initGraph() {
var canvas = document.getElementById('graph-canvas');
if (!canvas) return;
graphSim.canvas = canvas;
graphSim.ctx = canvas.getContext('2d');
function resize() {
var r = canvas.parentElement.getBoundingClientRect();
canvas.width = r.width * window.devicePixelRatio;
canvas.height = r.height * window.devicePixelRatio;
canvas.style.width = r.width + 'px';
canvas.style.height = r.height + 'px';
graphSim.ctx.setTransform(window.devicePixelRatio, 0, 0, window.devicePixelRatio, 0, 0);
}
resize();
window.addEventListener('resize', resize);
var cw = canvas.width / window.devicePixelRatio;
var ch = canvas.height / window.devicePixelRatio;
graphSim.panX = cw / 2;
graphSim.panY = ch / 2;
var edgeMap = {};
state.graph.edges.forEach(function(e) {
edgeMap[e.sourceNodeId] = (edgeMap[e.sourceNodeId] || 0) + 1;
edgeMap[e.targetNodeId] = (edgeMap[e.targetNodeId] || 0) + 1;
});
graphSim.nodes = state.graph.nodes.map(function(n, i) {
var angle = (2 * Math.PI * i) / Math.max(state.graph.nodes.length, 1);
var radius = Math.min(cw, ch) * 0.38;
var deg = edgeMap[n.id] || 0;
var activity = ((n.sessions && n.sessions.length) || 0) + ((n.memories && n.memories.length) || 0);
var nodeR = n.type === 'folder'
? Math.max(24, Math.min(52, 24 + Math.sqrt(activity + 1) * 3.5))
: Math.max(10, Math.min(26, 10 + deg * 2.5));
return {
id: n.id, type: n.type, name: n.name, properties: n.properties,
sessions: n.sessions, memories: n.memories,
x: Math.cos(angle) * radius,
y: Math.sin(angle) * radius,
vx: 0, vy: 0,
r: nodeR
};
});
graphSim.edges = state.graph.edges.slice();
graphSim.running = true;
graphSim.dragNode = null;
setupGraphInteraction(canvas);
runSimulation();
}
function setupGraphInteraction(canvas) {
var isPanning = false;
var lastMX = 0, lastMY = 0;
function canvasCoords(e) {
var rect = canvas.getBoundingClientRect();
return {
x: (e.clientX - rect.left - graphSim.panX) / graphSim.zoom,
y: (e.clientY - rect.top - graphSim.panY) / graphSim.zoom
};
}
function findNode(cx, cy) {
for (var i = graphSim.nodes.length - 1; i >= 0; i--) {
var n = graphSim.nodes[i];
if (!state.graph.filters[n.type]) continue;
var dx = n.x - cx, dy = n.y - cy;
if (dx * dx + dy * dy < n.r * n.r + 25) return n;
}
return null;
}
canvas.addEventListener('mousedown', function(e) {
var c = canvasCoords(e);
var node = findNode(c.x, c.y);
if (node) {
graphSim.dragNode = node;
} else {
isPanning = true;
}
lastMX = e.clientX;
lastMY = e.clientY;
wakeGraphSim();
});
canvas.addEventListener('mousemove', function(e) {
var dx = e.clientX - lastMX;
var dy = e.clientY - lastMY;
if (graphSim.dragNode) {
graphSim.dragNode.x += dx / graphSim.zoom;
graphSim.dragNode.y += dy / graphSim.zoom;
graphSim.dragNode.vx = 0;
graphSim.dragNode.vy = 0;
} else if (isPanning) {
graphSim.panX += dx;
graphSim.panY += dy;
}
lastMX = e.clientX;
lastMY = e.clientY;
graphSim.mouseX = e.clientX;
graphSim.mouseY = e.clientY;
var c = canvasCoords(e);
var hoverNode = findNode(c.x, c.y);
var tooltip = document.getElementById('graph-tooltip');
if (tooltip) {
if (hoverNode && !graphSim.dragNode && !isPanning) {
var conns = graphSim.edges.filter(function(ed) { return ed.sourceNodeId === hoverNode.id || ed.targetNodeId === hoverNode.id; }).length;
var ttHtml = '<div class="tt-name">' + esc(hoverNode.name) + '</div>';
ttHtml += '<div class="tt-type" style="color:' + (NODE_COLORS[hoverNode.type] || '#666') + '">' + esc(hoverNode.type) + '</div>';
if (hoverNode.type === 'folder' && hoverNode.properties) {
if (hoverNode.properties.fullPath) {
ttHtml += '<div class="tt-prop">Path: ' + esc(truncate(hoverNode.properties.fullPath, 45)) + '</div>';
}
if (typeof hoverNode.properties.sessionCount === 'number') {
ttHtml += '<div class="tt-prop">Sessions: ' + hoverNode.properties.sessionCount + '</div>';
}
if (typeof hoverNode.properties.memoryCount === 'number') {
ttHtml += '<div class="tt-prop">Memories: ' + hoverNode.properties.memoryCount + '</div>';
}
} else if (hoverNode.properties) {
var propKeys = Object.keys(hoverNode.properties).slice(0, 3);
propKeys.forEach(function(k) {
ttHtml += '<div class="tt-prop">' + esc(k) + ': ' + esc(truncate(String(hoverNode.properties[k]), 30)) + '</div>';
});
}
ttHtml += '<div class="tt-conns">' + conns + ' connection' + (conns !== 1 ? 's' : '') + '</div>';
tooltip.innerHTML = ttHtml;
var rect = canvas.getBoundingClientRect();
tooltip.style.left = (e.clientX - rect.left + 12) + 'px';
tooltip.style.top = (e.clientY - rect.top + 12) + 'px';
tooltip.classList.add('visible');
canvas.style.cursor = 'pointer';
} else {
tooltip.classList.remove('visible');
canvas.style.cursor = graphSim.dragNode || isPanning ? 'grabbing' : 'grab';
}
}
});
canvas.addEventListener('mouseup', function(e) {
if (graphSim.dragNode && !isPanning) {
selectGraphNode(graphSim.dragNode);
}
graphSim.dragNode = null;
isPanning = false;
});
canvas.addEventListener('wheel', function(e) {
e.preventDefault();
var factor = e.deltaY > 0 ? 0.9 : 1.1;
graphSim.zoom = Math.max(0.1, Math.min(5, graphSim.zoom * factor));
wakeGraphSim();
}, { passive: false });
canvas.addEventListener('dblclick', function(e) {
var c = canvasCoords(e);
var node = findNode(c.x, c.y);
if (node) {
selectGraphNode(node);
expandNode(node.id);
}
});
}
window.zoomGraph = function(dir) {
var factor = dir > 0 ? 1.25 : 0.8;
graphSim.zoom = Math.max(0.1, Math.min(5, graphSim.zoom * factor));
wakeGraphSim();
};
window.recenterGraph = function() {
graphSim.zoom = 1;
if (graphSim.canvas) {
var cw = graphSim.canvas.width / window.devicePixelRatio;
var ch = graphSim.canvas.height / window.devicePixelRatio;
graphSim.panX = cw / 2;
graphSim.panY = ch / 2;
}
wakeGraphSim();
};
function selectGraphNode(simNode) {
state.graph.selectedNode = simNode;
var panel = document.getElementById('selected-node-panel');
if (!panel) return;
var color = NODE_COLORS[simNode.type] || '#666666';
var html = '<div class="selected-node-info">';
html += '<h4 style="color:' + color + '">' + esc(simNode.name) + '</h4>';
html += '<div class="prop">Type: ' + esc(simNode.type) + '</div>';
if (simNode.type === 'folder' && simNode.properties && simNode.properties.description) {
html += '<div class="prop-desc" style="margin-top:10px;font-size:12px;color:var(--ink);line-height:1.5;max-height:300px;overflow-y:auto;border-top:1px solid var(--border);padding-top:10px;">' + simNode.properties.description + '</div>';
} else if (simNode.properties) {
Object.keys(simNode.properties).forEach(function(k) {
html += '<div class="prop">' + esc(k) + ': ' + esc(truncate(simNode.properties[k], 50)) + '</div>';
});
}
var conns = graphSim.edges.filter(function(e) { return e.sourceNodeId === simNode.id || e.targetNodeId === simNode.id; }).length;
html += '<div class="prop" style="margin-top:10px;">Connections: ' + conns + '</div>';
if (simNode.type !== 'folder') {
html += '<button class="btn btn-primary" style="margin-top:8px;width:100%;" data-action="expand-node" data-node-id="' + esc(simNode.id) + '">Expand neighbors</button>';
}
html += '</div>';
panel.innerHTML = html;
}
async function expandNode(nodeId) {
var result = await apiPost('graph/query', { startNodeId: nodeId, maxDepth: 1 });
if (!result) return;
var existingIds = {};
graphSim.nodes.forEach(function(n) { existingIds[n.id] = true; });
var parentNode = graphSim.nodes.find(function(n) { return n.id === nodeId; });
var px = parentNode ? parentNode.x : 0;
var py = parentNode ? parentNode.y : 0;
(result.nodes || []).forEach(function(n) {
if (!existingIds[n.id]) {
state.graph.nodes.push(n);
if (!state.graph.filters.hasOwnProperty(n.type)) state.graph.filters[n.type] = true;
var angle = Math.random() * Math.PI * 2;
graphSim.nodes.push({
id: n.id, type: n.type, name: n.name, properties: n.properties,
x: px + Math.cos(angle) * 80,
y: py + Math.sin(angle) * 80,
vx: 0, vy: 0, r: 8
});
}
});
var existingEdges = {};
graphSim.edges.forEach(function(e) { existingEdges[e.id] = true; });
(result.edges || []).forEach(function(e) {
if (!existingEdges[e.id]) {
state.graph.edges.push(e);
graphSim.edges.push(e);
}
});
renderGraphSidebar();
}
function runSimulation() {
if (!graphSim.running) return;
var nodes = graphSim.nodes;
var edges = graphSim.edges;
var nodeCount = nodes.length;
graphSim.tickCount = (graphSim.tickCount || 0) + 1;
var coolBoost = Math.min(0.4, graphSim.tickCount / 1500);
var damping = 0.9 - coolBoost;
var repulsion = nodeCount > 1000 ? 3000 : nodeCount > 100 ? 2000 : nodeCount > 50 ? 1800 : nodeCount > 10 ? 8000 : 18000;
var attraction = nodeCount > 100 ? 0.002 : 0.005;
var centerGravity = nodeCount > 1000 ? 0.012 : nodeCount > 100 ? 0.005 : 0.01;
var velocityCap = nodeCount > 1000 ? 6 : nodeCount > 200 ? 12 : 24;
var nodeMap = {};
nodes.forEach(function(n) { nodeMap[n.id] = n; });
for (var i = 0; i < nodes.length; i++) {
if (graphSim.dragNode === nodes[i]) continue;
var n = nodes[i];
var fx = 0, fy = 0;
for (var j = 0; j < nodes.length; j++) {
if (i === j) continue;
var dx = n.x - nodes[j].x;
var dy = n.y - nodes[j].y;
var dist = Math.sqrt(dx * dx + dy * dy) || 1;
var force = repulsion / (dist * dist);
fx += (dx / dist) * force;
fy += (dy / dist) * force;
}
fx -= n.x * centerGravity;
fy -= n.y * centerGravity;
var nvx = (n.vx + fx) * damping;
var nvy = (n.vy + fy) * damping;
if (nvx > velocityCap) nvx = velocityCap; else if (nvx < -velocityCap) nvx = -velocityCap;
if (nvy > velocityCap) nvy = velocityCap; else if (nvy < -velocityCap) nvy = -velocityCap;
n.vx = nvx;
n.vy = nvy;
}
edges.forEach(function(e) {
var s = nodeMap[e.sourceNodeId];
var t = nodeMap[e.targetNodeId];
if (!s || !t) return;
var dx = t.x - s.x;
var dy = t.y - s.y;
var dist = Math.sqrt(dx * dx + dy * dy) || 1;
var f = (dist - (s.type === 'folder' || t.type === 'folder' ? 220 : 100)) * attraction;
var fx = (dx / dist) * f;
var fy = (dy / dist) * f;
if (graphSim.dragNode !== s) { s.vx += fx; s.vy += fy; }
if (graphSim.dragNode !== t) { t.vx -= fx; t.vy -= fy; }
});
var totalKineticEnergy = 0;
nodes.forEach(function(n) {
if (graphSim.dragNode === n) return;
n.x += n.vx;
n.y += n.vy;
totalKineticEnergy += n.vx * n.vx + n.vy * n.vy;
});
var rmsVelocity = nodes.length > 0 ? Math.sqrt(totalKineticEnergy / nodes.length) : 0;
if (rmsVelocity < 0.05 && graphSim.tickCount > 60 && !graphSim.dragNode) {
graphSim.quietTicks = (graphSim.quietTicks || 0) + 1;
} else {
graphSim.quietTicks = 0;
}
renderGraph();
if (graphSim.quietTicks > 30) {
graphSim.raf = null;
return;
}
graphSim.raf = requestAnimationFrame(runSimulation);
}
async function rebuildGraph() {
var sb = document.getElementById('graph-sidebar');
if (sb) sb.innerHTML = '<h3>Graph</h3><p style="font-size:12px;color:var(--ink-faint);font-style:italic;">Rebuilding graph from observations...</p>';
await apiPost('graph/build', {});
state.graph.loaded = false;
loadGraph();
}
function drawNodeShape(ctx, x, y, r, type) {
var shape = NODE_SHAPES[type] || 'circle';
switch(shape) {
case 'rect':
ctx.beginPath();
ctx.rect(x - r, y - r * 0.75, r * 2, r * 1.5);
break;
case 'diamond':
ctx.beginPath();
ctx.moveTo(x, y - r);
ctx.lineTo(x + r, y);
ctx.lineTo(x, y + r);
ctx.lineTo(x - r, y);
ctx.closePath();
break;
case 'hexagon':
ctx.beginPath();
for (var i = 0; i < 6; i++) {
var angle = (Math.PI / 3) * i - Math.PI / 2;
var hx = x + r * Math.cos(angle);
var hy = y + r * Math.sin(angle);
if (i === 0) ctx.moveTo(hx, hy); else ctx.lineTo(hx, hy);
}
ctx.closePath();
break;
default:
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
break;
}
}
function renderGraph() {
var ctx = graphSim.ctx;
var canvas = graphSim.canvas;
if (!ctx || !canvas) return;
var w = canvas.width / window.devicePixelRatio;
var h = canvas.height / window.devicePixelRatio;
ctx.clearRect(0, 0, w, h);
var gridSize = 24;
ctx.save();
ctx.strokeStyle = isDarkMode() ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.04)';
ctx.lineWidth = 0.5;
for (var gx = 0; gx < w; gx += gridSize) {
ctx.beginPath(); ctx.moveTo(gx, 0); ctx.lineTo(gx, h); ctx.stroke();
}
for (var gy = 0; gy < h; gy += gridSize) {
ctx.beginPath(); ctx.moveTo(0, gy); ctx.lineTo(w, gy); ctx.stroke();
}
ctx.restore();
ctx.save();
ctx.translate(graphSim.panX, graphSim.panY);
ctx.scale(graphSim.zoom, graphSim.zoom);
var nodeMap = {};
graphSim.nodes.forEach(function(n) { nodeMap[n.id] = n; });
var searchActive = graphSearchTerm.length > 0;
var totalVisible = graphSim.nodes.filter(function(n) { return state.graph.filters[n.type]; }).length;
var isDense = totalVisible > 40;
var labelZoomThreshold = isDense ? 1.5 : 0.5;
var edgeLabelZoomThreshold = isDense ? 2.5 : 1.2;
var selectedId = state.graph.selectedNode ? state.graph.selectedNode.id : null;
var hoverNodeId = null;
if (!graphSim.dragNode && graphSim.canvas) {
var rect = graphSim.canvas.getBoundingClientRect();
var hx = (graphSim.mouseX - rect.left - graphSim.panX) / graphSim.zoom;
var hy = (graphSim.mouseY - rect.top - graphSim.panY) / graphSim.zoom;
for (var hi = graphSim.nodes.length - 1; hi >= 0; hi--) {
var hn = graphSim.nodes[hi];
if (!state.graph.filters[hn.type]) continue;
var hdx = hn.x - hx, hdy = hn.y - hy;
if (hdx * hdx + hdy * hdy < hn.r * hn.r + 25) { hoverNodeId = hn.id; break; }
}
}
var focusNodeId = selectedId || hoverNodeId;
graphSim.edges.forEach(function(e) {
var s = nodeMap[e.sourceNodeId];
var t = nodeMap[e.targetNodeId];
if (!s || !t) return;
if (!state.graph.filters[s.type] || !state.graph.filters[t.type]) return;
var edgeDimmed = searchActive && !(s.name.toLowerCase().includes(graphSearchTerm) || t.name.toLowerCase().includes(graphSearchTerm));
var isConnectedToFocus = focusNodeId && (e.sourceNodeId === focusNodeId || e.targetNodeId === focusNodeId);
var isFocusActive = focusNodeId !== null;
var weight = typeof e.weight === 'number' ? e.weight : 0.5;
var lineWidth = isConnectedToFocus ? 2 + weight * 2 : 1 + weight * 1.5;
var dx = t.x - s.x;
var dy = t.y - s.y;
var len = Math.sqrt(dx * dx + dy * dy) || 1;
var curveOffset = isDense ? 12 : 18;
var offsetX = -dy / len * curveOffset;
var offsetY = dx / len * curveOffset;
var cpx = (s.x + t.x) / 2 + offsetX;
var cpy = (s.y + t.y) / 2 + offsetY;
var edgeColor = s.type === 'folder' ? folderColor(s.id) : (NODE_COLORS[s.type] || '#666666');
var edgeAlpha;
if (edgeDimmed) {
edgeAlpha = 0.06;
} else if (isFocusActive && isConnectedToFocus) {
edgeAlpha = 0.65;
} else if (isFocusActive && !isConnectedToFocus) {
edgeAlpha = 0.06;
} else {
edgeAlpha = isDense ? 0.15 : 0.25;
}
ctx.beginPath();
ctx.moveTo(s.x, s.y);
ctx.quadraticCurveTo(cpx, cpy, t.x, t.y);
var r = parseInt(edgeColor.slice(1,3), 16);
var g = parseInt(edgeColor.slice(3,5), 16);
var b = parseInt(edgeColor.slice(5,7), 16);
ctx.strokeStyle = 'rgba(' + r + ',' + g + ',' + b + ',' + edgeAlpha + ')';
ctx.lineWidth = lineWidth;
ctx.stroke();
if (!isDense || isConnectedToFocus) {
var arrowAngle = Math.atan2(t.y - cpy, t.x - cpx);
var arrowLen = 5 + lineWidth;
ctx.beginPath();
ctx.moveTo(t.x - t.r * Math.cos(arrowAngle), t.y - t.r * Math.sin(arrowAngle));
ctx.lineTo(t.x - (t.r + arrowLen) * Math.cos(arrowAngle - 0.3), t.y - (t.r + arrowLen) * Math.sin(arrowAngle - 0.3));
ctx.lineTo(t.x - (t.r + arrowLen) * Math.cos(arrowAngle + 0.3), t.y - (t.r + arrowLen) * Math.sin(arrowAngle + 0.3));
ctx.closePath();
ctx.fillStyle = 'rgba(' + r + ',' + g + ',' + b + ',' + (edgeDimmed ? 0.06 : isConnectedToFocus ? 0.6 : 0.2) + ')';
ctx.fill();
}
var showEdgeLabel = e.type && !edgeDimmed && isConnectedToFocus && graphSim.zoom > 0.4;
if (showEdgeLabel) {
var zoomInv = 1 / graphSim.zoom;
ctx.save();
ctx.fillStyle = isDarkMode() ? (isConnectedToFocus ? 'rgba(238,238,238,0.9)' : 'rgba(180,180,180,0.7)') : (isConnectedToFocus ? 'rgba(17,17,17,0.85)' : 'rgba(80,80,80,0.7)');
ctx.font = (isConnectedToFocus ? '600 ' : '500 ') + (11 * zoomInv).toFixed(1) + 'px Inter, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(e.type, cpx, cpy - (4 * zoomInv));
ctx.restore();
}
});
graphSim.nodes.forEach(function(n) {
if (!state.graph.filters[n.type]) return;
var color = n.type === 'folder' ? folderColor(n.id) : (NODE_COLORS[n.type] || '#666666');
var isSelected = selectedId === n.id;
var isHovered = hoverNodeId === n.id;
var matchesSearch = !searchActive || n.name.toLowerCase().includes(graphSearchTerm);
var isFocusFaded = focusNodeId && n.id !== focusNodeId && !graphSim.edges.some(function(ed) {
return (ed.sourceNodeId === focusNodeId && ed.targetNodeId === n.id) ||
(ed.targetNodeId === focusNodeId && ed.sourceNodeId === n.id);
});
var nodeAlpha = !matchesSearch ? 0.12 : (isFocusFaded ? 0.2 : 1);
ctx.save();
ctx.globalAlpha = nodeAlpha;
if (matchesSearch && !isFocusFaded && (isSelected || isHovered || !searchActive)) {
ctx.shadowColor = color;
ctx.shadowBlur = isSelected ? 20 : isHovered ? 16 : (isDense ? 4 : 8);
}
drawNodeShape(ctx, n.x, n.y, n.r, n.type);
var grad = ctx.createRadialGradient(n.x - n.r * 0.3, n.y - n.r * 0.3, 0, n.x, n.y, n.r * 1.2);
var cr = parseInt(color.slice(1,3), 16);
var cg = parseInt(color.slice(3,5), 16);
var cb = parseInt(color.slice(5,7), 16);
grad.addColorStop(0, 'rgba(' + Math.min(255, cr + 60) + ',' + Math.min(255, cg + 60) + ',' + Math.min(255, cb + 60) + ',0.95)');
grad.addColorStop(1, color);
ctx.fillStyle = grad;
ctx.fill();
ctx.restore();
if (isSelected) {
ctx.save();
drawNodeShape(ctx, n.x, n.y, n.r + 3, n.type);
ctx.strokeStyle = color;
ctx.lineWidth = 3;
ctx.shadowColor = color;
ctx.shadowBlur = 12;
ctx.stroke();
ctx.restore();
} else if (isHovered) {
drawNodeShape(ctx, n.x, n.y, n.r + 2, n.type);
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
} else if (searchActive && matchesSearch) {
drawNodeShape(ctx, n.x, n.y, n.r, n.type);
ctx.strokeStyle = '#CC0000';
ctx.lineWidth = 2;
ctx.stroke();
}
var showLabel = !isFocusFaded && (
n.type === 'folder' ||
isSelected || isHovered ||
(searchActive && matchesSearch) ||
(!isDense && graphSim.zoom > labelZoomThreshold) ||
(isDense && graphSim.zoom > labelZoomThreshold && n.r > 10)
);
if (showLabel) {
var zoomInv = 1 / graphSim.zoom;
ctx.save();
ctx.font = (isSelected || isHovered ? '600 ' : '500 ') + (13 * zoomInv).toFixed(1) + 'px Inter, sans-serif';
ctx.textAlign = 'center';
var label = truncate(n.name, 18);
var textW = ctx.measureText(label).width;
var labelW = textW + (16 * zoomInv);
var labelH = 20 * zoomInv;
var labelY = n.y + n.r + (8 * zoomInv);
ctx.fillStyle = isDarkMode() ? 'rgba(30,30,35,0.92)' : 'rgba(255,255,255,0.92)';
ctx.beginPath();
ctx.roundRect ? ctx.roundRect(n.x - labelW / 2, labelY, labelW, labelH, 4 * zoomInv) : ctx.rect(n.x - labelW / 2, labelY, labelW, labelH);
ctx.fill();
ctx.strokeStyle = isDarkMode() ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)';
ctx.lineWidth = 1 * zoomInv;
ctx.stroke();
ctx.fillStyle = isDarkMode() ? (isSelected || isHovered ? '#eeeeee' : '#bbbbbb') : (isSelected || isHovered ? '#111111' : '#444444');
ctx.fillText(label, n.x, labelY + (14 * zoomInv));
ctx.restore();
}
});
ctx.restore();
if (graphSim.nodes.length === 0) {
ctx.fillStyle = '#999999';
ctx.font = '14px Lora, Georgia, serif';
ctx.textAlign = 'center';
ctx.fillText('No graph data yet.', w / 2, h / 2 - 16);
ctx.font = '12px Inter, sans-serif';
ctx.fillText('Set GRAPH_EXTRACTION_ENABLED=true to enable knowledge graph extraction.', w / 2, h / 2 + 8);
}
}
async function loadMemories() {
var el = document.getElementById('view-memories');
el.innerHTML = '<div class="loading">Loading memories...</div>';
var result = await apiGet('memories?latest=true&limit=2000');
var items = (result && result.memories) || [];
items.sort(function(a, b) {
var ac = (a && a.createdAt) || (a && a.updatedAt) || '';
var bc = (b && b.createdAt) || (b && b.updatedAt) || '';
return bc.localeCompare(ac);
});
state.memories.items = items;
state.memories.total = (result && typeof result.total === 'number') ? result.total : items.length;
state.memories.loaded = true;
renderMemories();
}
function renderMemories() {
var el = document.getElementById('view-memories');
var items = state.memories.items;
var search = state.memories.search.toLowerCase();
var typeFilter = state.memories.typeFilter;
var folderFilter = state.memories.folderFilter || '';
var projects = [];
var hasNoFolder = false;
items.forEach(function(m) {
var pPath = m.project ? String(m.project).trim() : '';
if (pPath) {
if (!projects.includes(pPath)) {
projects.push(pPath);
}
} else {
hasNoFolder = true;
}
});
projects.sort();
var filtered = items.filter(function(m) {
if (typeFilter && m.type !== typeFilter) return false;
var pPath = m.project ? String(m.project).trim() : '';
if (folderFilter) {
if (folderFilter === '_none_') {
if (pPath) return false;
} else if (pPath !== folderFilter) {
return false;
}
}
const normalizedSearch = (search || '').normalize("NFKC").toLowerCase();
const normalizedTitle = (m.title || '').normalize("NFKC").toLowerCase();
const normalizedContent = (m.content || '').normalize("NFKC").toLowerCase();
if (search && !normalizedTitle.includes(normalizedSearch) && !normalizedContent.includes(normalizedSearch)) {
return false;
}
return true;
});
var types = {};
items.forEach(function(m) { types[m.type] = true; });
var typeOptions = Object.keys(types).sort();
var html = '<div class="card" style="margin-bottom:12px;padding:12px;background:var(--bg-subtle);">';
html += '<div style="font-size:13px;color:var(--ink-muted);line-height:1.5;">';
html += '<strong>Memories</strong> are durable facts, architecture notes, conventions, and lessons saved via <code>memory_remember</code> MCP tool or the <code>/agentmemory/remember</code> endpoint. They survive across sessions and supersede each other as v1, v2, etc. ';
html += '<span style="color:var(--ink-faint);">Shown: ' + items.length + ' total.</span>';
html += '</div></div>';
html += '<div class="toolbar">';
html += '<input type="text" id="mem-search" placeholder="Search memories..." value="' + esc(state.memories.search) + '">';
html += '<select id="mem-type-filter"><option value="">All types</option>';
typeOptions.forEach(function(t) {
html += '<option value="' + esc(t) + '"' + (typeFilter === t ? ' selected' : '') + '>' + esc(t) + '</option>';
});
html += '</select>';
html += '<select id="mem-folder-filter"><option value="">All folders</option>';
if (hasNoFolder) {
html += '<option value="_none_"' + (folderFilter === '_none_' ? ' selected' : '') + '>Uncategorized (No Folder)</option>';
}
projects.forEach(function(p) {
html += '<option value="' + esc(p) + '"' + (folderFilter === p ? ' selected' : '') + '>' + esc(p) + '</option>';
});
html += '</select>';
html += '</div>';
if (filtered.length === 0) {
html += '<div class="empty-state">' +
'<div class="empty-icon">&#128218;</div>' +
'<div class="empty-title">No memories yet</div>' +
'<div class="empty-lead">Memories are the distilled facts agentmemory keeps across sessions &mdash; things like file paths, architectural decisions, and user preferences. Hooks capture them automatically during coding sessions; you can also save one directly.</div>' +
'<pre class="empty-cmd">memory_remember {\n title: "auth uses jose middleware",\n content: "src/middleware/auth.ts handles JWT validation",\n type: "architecture"\n}</pre>' +
'<div><a class="empty-link" href="https://github.com/rohitg00/agentmemory#memories" target="_blank" rel="noopener">Memory types &rarr;</a></div>' +
'</div>';
} else {
html += '<table><tr><th>Title</th><th>Type</th><th>Strength</th><th>Version</th><th>Updated</th><th>Actions</th></tr>';
filtered.forEach(function(m) {
var badgeClass = TYPE_BADGES[m.type] || 'badge-muted';
var rawStrength = m.strength || 0;
var strength = Math.round(rawStrength <= 1 ? rawStrength * 100 : rawStrength * 10);
if (strength > 100) strength = 100;
var barColor = strength > 70 ? 'var(--green)' : strength > 40 ? 'var(--yellow)' : 'var(--red)';
html += '<tr>';
var preview = (m.content || '').split('\n').slice(0, 2).join(' ').trim();
var previewHtml = esc(truncate(preview, 150));
if (search && search.length > 2) {
var re = new RegExp('(' + search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
previewHtml = previewHtml.replace(re, '<mark>$1</mark>');
}
html += '<td><span style="color:var(--ink);font-weight:600;">' + esc(truncate(m.title, 50)) + '</span>';
html += '<div style="font-size:12px;color:var(--ink-muted);margin-top:3px;line-height:1.4;max-height:34px;overflow:hidden;">' + previewHtml + '</div>';
if (m.concepts && m.concepts.length > 0) {
html += '<div style="margin-top:3px;display:flex;gap:4px;flex-wrap:wrap;">';
m.concepts.slice(0, 4).forEach(function(c) { html += '<span class="tag">' + esc(c) + '</span>'; });
html += '</div>';
}
html += '</td>';
html += '<td><span class="badge ' + badgeClass + '">' + esc(m.type) + '</span></td>';
html += '<td><div class="strength-bar"><div class="fill" style="width:' + strength + '%;background:' + barColor + '"></div></div> <span style="font-size:10px;color:var(--ink-faint);font-family:var(--font-mono);">' + strength + '%</span></td>';
html += '<td style="color:var(--ink-muted);font-family:var(--font-mono);font-size:12px;">v' + (m.version || 1) + '</td>';
html += '<td style="font-size:11px;color:var(--ink-faint);font-family:var(--font-mono);">' + esc(formatTime(m.updatedAt)) + '</td>';
html += '<td><button class="btn btn-danger" style="font-size:9px;padding:2px 8px;" data-action="delete-memory" data-memory-id="' + esc(m.id) + '" data-memory-title="' + esc(m.title || '') + '">Delete</button></td>';
html += '</tr>';
});
html += '</table>';
}
var __focus = captureSearchFocus(['mem-search']);
el.innerHTML = html;
var searchInput = document.getElementById('mem-search');
if (searchInput) {
bindImeSafeSearch(searchInput, 200, function(v){ state.memories.search = v; renderMemories(); });
}
var typeSelect = document.getElementById('mem-type-filter');
if (typeSelect) {
typeSelect.addEventListener('change', function() {
state.memories.typeFilter = this.value;
renderMemories();
});
}
var folderSelect = document.getElementById('mem-folder-filter');
if (folderSelect) {
folderSelect.addEventListener('change', function() {
state.memories.folderFilter = this.value;
renderMemories();
});
}
restoreSearchFocus(__focus);
}
function deleteMemory(id, title) {
var modal = document.getElementById('modal');
var overlay = document.getElementById('modal-overlay');
modal.innerHTML = '<h3>Delete Memory</h3><p>Are you sure you want to delete "' + esc(title) + '"? This action cannot be undone.</p><div class="modal-actions"><button class="btn" data-action="close-modal">Cancel</button><button class="btn btn-danger" data-action="confirm-delete-memory" data-memory-id="' + esc(id) + '">Delete</button></div>';
overlay.classList.add('open');
}
async function confirmDeleteMemory(id) {
closeModal();
await apiDelete('governance/memories', { memoryIds: [id], reason: 'Deleted via viewer' });
state.memories.loaded = false;
loadMemories();
}
function closeModal() {
document.getElementById('modal-overlay').classList.remove('open');
}
async function loadTimeline() {
var el = document.getElementById('view-timeline');
el.innerHTML = '<div class="loading">Loading timeline...</div>';
var sessResult = await apiGet('sessions');
var sessions = (sessResult && sessResult.sessions) || [];
state.timeline.loaded = true;
if (sessions.length > 0 && !state.timeline.sessionId) {
var sorted = sessions.slice().sort(function(a, b) { return (b.startedAt || '').localeCompare(a.startedAt || ''); });
var firstSelectable = sorted.find(function(s) { return sessionId(s); });
state.timeline.sessionId = firstSelectable ? sessionId(firstSelectable) : '';
}
renderTimelineToolbar(sessions);
if (state.timeline.sessionId) await loadObservations();
}
function renderTimelineToolbar(sessions) {
var el = document.getElementById('view-timeline');
var html = '<div class="toolbar">';
html += '<select id="tl-session"><option value="">Select session</option>';
sessions.sort(function(a, b) { return (b.startedAt || '').localeCompare(a.startedAt || ''); }).forEach(function(s) {
var id = sessionId(s);
var disabled = id ? '' : ' disabled';
html += '<option value="' + esc(id) + '"' + (id && state.timeline.sessionId === id ? ' selected' : '') + disabled + '>' + esc(sessionLabel(s)) + '</option>';
});
html += '</select>';
html += '<select id="tl-importance"><option value="0">All importance</option>';
for (var i = 1; i <= 9; i++) {
html += '<option value="' + i + '"' + (state.timeline.minImportance === i ? ' selected' : '') + '>&ge; ' + i + '</option>';
}
html += '</select></div>';
html += '<div id="tl-content"></div>';
el.innerHTML = html;
document.getElementById('tl-session').addEventListener('change', function() {
state.timeline.sessionId = this.value;
state.timeline.page = 0;
loadObservations();
});
document.getElementById('tl-importance').addEventListener('change', function() {
state.timeline.minImportance = parseInt(this.value);
renderObservations();
});
}
async function loadObservations() {
var content = document.getElementById('tl-content');
if (!content) return;
if (!state.timeline.sessionId) {
content.innerHTML = '<div class="empty-state"><div class="empty-icon">&#128337;</div><p>Select a session to view observations</p></div>';
return;
}
content.innerHTML = '<div class="loading">Loading observations...</div>';
var result = await apiGet('observations?sessionId=' + encodeURIComponent(state.timeline.sessionId));
state.timeline.observations = (result && result.observations) || [];
renderObservations();
}
var tlTypeFilter = '';
function renderObservations() {
var content = document.getElementById('tl-content');
if (!content) return;
var obs = state.timeline.observations;
var minImp = state.timeline.minImportance;
var filtered = minImp > 0 ? obs.filter(function(o) { return (o.importance || 0) >= minImp; }) : obs;
var TOOL_TYPE_MAP = { Read: 'file_read', Write: 'file_write', Edit: 'file_edit', Bash: 'command_run', Grep: 'search', Glob: 'search', WebFetch: 'web_fetch', WebSearch: 'web_fetch', AskUserQuestion: 'conversation', Task: 'subagent' };
var typeCounts = {};
filtered.forEach(function(o) {
var t = o.type || TOOL_TYPE_MAP[o.toolName] || (o.hookType ? o.hookType.replace(/_/g, ' ') : 'other');
typeCounts[t] = (typeCounts[t] || 0) + 1;
});
var typeList = Object.keys(typeCounts).sort(function(a, b) { return typeCounts[b] - typeCounts[a]; });
if (tlTypeFilter) {
filtered = filtered.filter(function(o) {
var t = o.type || TOOL_TYPE_MAP[o.toolName] || (o.hookType ? o.hookType.replace(/_/g, ' ') : 'other');
return t === tlTypeFilter;
});
}
var pageSize = state.timeline.pageSize;
var page = state.timeline.page;
var start = page * pageSize;
var paged = filtered.slice(start, start + pageSize);
var totalPages = Math.ceil(filtered.length / pageSize);
var html = '<div class="type-chips">';
html += '<span class="type-chip' + (!tlTypeFilter ? ' active' : '') + '" data-action="timeline-filter" data-type-filter="">All (' + obs.length + ')</span>';
typeList.forEach(function(t) {
var color = OBS_TYPE_COLORS[t] || '#666666';
html += '<span class="type-chip' + (tlTypeFilter === t ? ' active' : '') + '" data-action="timeline-filter" data-type-filter="' + esc(t) + '" style="' + (tlTypeFilter === t ? 'background:' + color + ';border-color:' + color + ';' : 'border-color:' + color + ';color:' + color + ';') + '">' + esc(t.replace(/_/g, ' ')) + ' (' + typeCounts[t] + ')</span>';
});
html += '</div>';
if (paged.length === 0) {
html += '<div class="empty-state"><div class="empty-icon">&#128337;</div><p>No observations' + (obs.length > 0 ? ' match the filter (' + obs.length + ' total)' : ' for this session') + '</p></div>';
content.innerHTML = html;
return;
}
html += '<div style="font-size:11px;color:var(--ink-faint);margin-bottom:16px;font-family:var(--font-mono);text-transform:uppercase;letter-spacing:0.06em;">' + filtered.length + ' observations shown</div>';
html += '<div class="timeline-container">';
var lastDateGroup = '';
paged.forEach(function(o, idx) {
var isCompressed = !!o.narrative || !!o.type;
var isRaw = !isCompressed;
var type = o.type || TOOL_TYPE_MAP[o.toolName] || 'other';
var impVal = typeof o.importance === 'number' ? o.importance : 5;
var impClass = impVal >= 7 ? 'high' : impVal >= 4 ? 'med' : 'low';
var title = o.title || o.toolName || (o.hookType ? o.hookType.replace(/_/g, ' ') : 'Observation');
var typeColor = OBS_TYPE_COLORS[type] || '#666666';
var icon = OBS_TYPE_ICONS[type] || '&#128196;';
var dateGroup = '';
try {
var d = new Date(o.timestamp);
dateGroup = d.toLocaleDateString() + ' ' + d.getHours() + ':00';
} catch(e) { dateGroup = ''; }
if (dateGroup && dateGroup !== lastDateGroup) {
html += '<div class="timeline-date-marker"><span>' + esc(dateGroup) + '</span></div>';
lastDateGroup = dateGroup;
}
var side = idx % 2 === 0 ? 'tl-left' : 'tl-right';
html += '<div class="timeline-item ' + side + '">';
html += '<div class="timeline-dot" style="background:' + typeColor + ';"></div>';
html += '<div class="timeline-connector"></div>';
html += '<div class="obs-card imp-' + impClass + '" style="border-left-color:' + typeColor + ';text-align:left;">';
html += '<div class="obs-head">';
html += '<div class="obs-title-row">';
html += '<span class="obs-type-icon">' + icon + '</span>';
html += '<span class="obs-title" title="' + esc(title) + '">' + esc(title) + '</span>';
if (isRaw) html += '<span class="badge badge-muted" style="font-size:8px;margin-left:4px;">raw</span>';
html += '</div>';
html += '<div class="obs-meta">';
if (isCompressed) html += '<span class="obs-importance imp-' + impVal + '" title="Importance: ' + impVal + '/10">' + impVal + '</span>';
html += '<span class="obs-time">' + esc(shortTime(o.timestamp)) + '</span>';
html += '</div></div>';
if (o.subtitle) html += '<div class="obs-subtitle">' + esc(o.subtitle) + '</div>';
html += '<div style="margin-top:4px;">';
html += '<span class="badge" style="border-color:' + typeColor + ';color:' + typeColor + ';margin-right:4px;">' + esc(type.replace(/_/g, ' ')) + '</span>';
if (o.hookType) html += '<span class="badge badge-muted" style="margin-right:4px;">' + esc(o.hookType) + '</span>';
html += '</div>';
if (isRaw && o.toolInput) {
var inputStr = typeof o.toolInput === 'string' ? o.toolInput : JSON.stringify(o.toolInput);
html += '<div style="margin-top:6px;"><span style="font-size:10px;color:var(--ink-muted);font-weight:600;font-family:var(--font-ui);text-transform:uppercase;letter-spacing:0.08em;">Input:</span>';
html += '<pre style="font-size:11px;color:var(--ink-muted);background:var(--bg-alt);padding:8px 10px;border:1px solid var(--border-light);margin-top:3px;overflow-x:auto;max-height:80px;font-family:var(--font-mono);">' + esc(truncate(inputStr, 300)) + '</pre></div>';
}
if (isRaw && o.toolOutput) {
var outputStr = typeof o.toolOutput === 'string' ? o.toolOutput : JSON.stringify(o.toolOutput);
html += '<div style="margin-top:4px;"><span style="font-size:10px;color:var(--ink-muted);font-weight:600;font-family:var(--font-ui);text-transform:uppercase;letter-spacing:0.08em;">Output:</span>';
html += '<div class="obs-narrative" style="margin-top:3px;">' + esc(truncate(outputStr, 300)) + '</div></div>';
}
if (o.narrative) html += '<div class="obs-narrative" style="margin-top:8px;">' + esc(o.narrative) + '</div>';
if (o.facts && o.facts.length > 0) {
html += '<ul class="obs-facts">';
o.facts.forEach(function(f) { html += '<li>' + esc(f) + '</li>'; });
html += '</ul>';
}
var hasTags = (o.concepts && o.concepts.length) || (o.files && o.files.length);
if (hasTags) {
html += '<div class="tag-list">';
(o.concepts || []).forEach(function(c) { html += '<span class="tag">' + esc(c) + '</span>'; });
(o.files || []).forEach(function(f) {
var short = f.split('/').pop();
html += '<span class="tag file-tag" title="' + esc(f) + '">' + esc(short) + '</span>';
});
html += '</div>';
}
if (isRaw && o.toolInput) {
var files = [];
var ti = o.toolInput;
if (typeof ti === 'object' && ti !== null) {
if (ti.file_path) files.push(ti.file_path);
if (ti.path) files.push(ti.path);
}
if (files.length > 0) {
html += '<div class="tag-list">';
files.forEach(function(f) {
var short = String(f).split('/').pop();
html += '<span class="tag file-tag" title="' + esc(f) + '">' + esc(short) + '</span>';
});
html += '</div>';
}
}
html += '</div>';
html += '</div>';
});
html += '</div>';
if (totalPages > 1) {
html += '<div class="pagination">';
if (page > 0) html += '<button class="btn" data-action="timeline-page" data-page="' + (page - 1) + '">Prev</button>';
html += '<span style="color:var(--ink-faint);font-size:12px;padding:6px;font-family:var(--font-mono);">Page ' + (page + 1) + ' of ' + totalPages + ' (' + filtered.length + ' total)</span>';
if (page < totalPages - 1) html += '<button class="btn" data-action="timeline-page" data-page="' + (page + 1) + '">Next</button>';
html += '</div>';
}
content.innerHTML = html;
}
function setTlTypeFilter(type) {
tlTypeFilter = type;
state.timeline.page = 0;
renderObservations();
}
function tlPage(p) {
state.timeline.page = p;
renderObservations();
}
async function loadActivity() {
var el = document.getElementById('view-activity');
el.innerHTML = '<div class="loading">Loading activity...</div>';
var results = await Promise.all([
apiGet('sessions'),
apiGet('audit?limit=200')
]);
var sessions = (results[0] && results[0].sessions) || [];
var auditEntries = (results[1] && results[1].entries) || [];
var allObs = [];
var sorted = sessions.slice().sort(function(a, b) { return (b.startedAt || '').localeCompare(a.startedAt || ''); });
var recentSessions = sorted.slice(0, 5);
var obsResults = await Promise.all(recentSessions.filter(function(s) { return sessionId(s); }).map(function(s) {
return apiGet('observations?sessionId=' + encodeURIComponent(sessionId(s)));
}));
obsResults.forEach(function(r) {
if (r && r.observations) allObs = allObs.concat(r.observations);
});
state.activity.sessions = sessions;
state.activity.observations = allObs;
state.activity.audit = auditEntries;
state.activity.loaded = true;
renderActivity();
}
function renderActivity() {
var el = document.getElementById('view-activity');
var obs = state.activity.observations;
var sessions = state.activity.sessions;
var TOOL_TYPE_MAP = { Read: 'file_read', Write: 'file_write', Edit: 'file_edit', Bash: 'command_run', Grep: 'search', Glob: 'search', WebFetch: 'web_fetch', WebSearch: 'web_fetch', AskUserQuestion: 'conversation', Task: 'subagent' };
var html = '';
html += '<div class="card"><div class="card-title">Activity Heatmap (Past Year)</div>';
var dayCounts = {};
obs.forEach(function(o) {
try {
var d = new Date(o.timestamp);
var key = d.toISOString().slice(0, 10);
dayCounts[key] = (dayCounts[key] || 0) + 1;
} catch(e) {}
});
sessions.forEach(function(s) {
try {
var d = new Date(s.startedAt);
var key = d.toISOString().slice(0, 10);
dayCounts[key] = (dayCounts[key] || 0) + 1;
} catch(e) {}
});
var maxCount = 0;
Object.keys(dayCounts).forEach(function(k) { if (dayCounts[k] > maxCount) maxCount = dayCounts[k]; });
var today = new Date();
var dayLabels = ['Mon', '', 'Wed', '', 'Fri', '', ''];
html += '<div class="heatmap-labels">';
dayLabels.forEach(function(l) { html += '<span style="width:10px;text-align:center;">' + l + '</span>'; });
html += '</div>';
html += '<div class="heatmap-wrap"><div class="heatmap-grid">';
for (var w = 51; w >= 0; w--) {
for (var d = 0; d < 7; d++) {
var cellDate = new Date(today);
cellDate.setDate(cellDate.getDate() - (w * 7 + (6 - d)));
var key = cellDate.toISOString().slice(0, 10);
var count = dayCounts[key] || 0;
var level = count === 0 ? '' : count <= (maxCount * 0.25) ? 'level-1' : count <= (maxCount * 0.5) ? 'level-2' : count <= (maxCount * 0.75) ? 'level-3' : 'level-4';
var title = key + ': ' + count + ' event' + (count !== 1 ? 's' : '');
html += '<div class="heatmap-cell ' + level + '" title="' + esc(title) + '"></div>';
}
}
html += '</div></div>';
html += '<div style="display:flex;align-items:center;gap:4px;margin-top:8px;font-size:10px;color:var(--ink-faint);font-family:var(--font-mono);justify-content:flex-end;">Less ';
html += '<div class="heatmap-cell" style="display:inline-block;"></div>';
html += '<div class="heatmap-cell level-1" style="display:inline-block;"></div>';
html += '<div class="heatmap-cell level-2" style="display:inline-block;"></div>';
html += '<div class="heatmap-cell level-3" style="display:inline-block;"></div>';
html += '<div class="heatmap-cell level-4" style="display:inline-block;"></div>';
html += ' More</div>';
html += '</div>';
var typeCounts = {};
obs.forEach(function(o) {
var t = o.type || TOOL_TYPE_MAP[o.toolName] || (o.hookType ? o.hookType.replace(/_/g, ' ') : 'other');
typeCounts[t] = (typeCounts[t] || 0) + 1;
});
var typeList = Object.keys(typeCounts).sort(function(a, b) { return typeCounts[b] - typeCounts[a]; });
var totalObs = obs.length || 1;
html += '<div class="two-col" style="margin-top:16px;">';
html += '<div class="card"><div class="card-title">Type Breakdown</div>';
if (typeList.length === 0) {
html += '<div style="font-size:13px;color:var(--ink-faint);font-style:italic;">No observations yet</div>';
} else {
html += '<div class="bar-chart">';
typeList.slice(0, 12).forEach(function(t) {
var pct = Math.round((typeCounts[t] / totalObs) * 100);
var color = OBS_TYPE_COLORS[t] || '#666666';
html += '<div class="bar-row"><span class="bar-label">' + esc(t.replace(/_/g, ' ')) + '</span><div class="bar-track"><div class="bar-fill" style="width:' + pct + '%;background:' + color + ';"></div></div><span class="bar-value">' + typeCounts[t] + '</span></div>';
});
html += '</div>';
}
html += '</div>';
html += '<div class="card"><div class="card-title">Activity Feed</div>';
var sortedObs = obs.slice().sort(function(a, b) { return (b.timestamp || '').localeCompare(a.timestamp || ''); });
if (sortedObs.length === 0) {
html += '<div style="font-size:13px;color:var(--ink-faint);font-style:italic;">No recent activity</div>';
} else {
sortedObs.slice(0, 20).forEach(function(o) {
var type = o.type || TOOL_TYPE_MAP[o.toolName] || 'other';
var typeColor = OBS_TYPE_COLORS[type] || '#666666';
var icon = OBS_TYPE_ICONS[type] || '&#128196;';
var title = o.title || o.toolName || (o.hookType ? o.hookType.replace(/_/g, ' ') : 'Observation');
html += '<div class="activity-feed-item">';
html += '<div class="activity-feed-icon" style="color:' + typeColor + ';border-color:' + typeColor + ';">' + icon + '</div>';
html += '<div class="activity-feed-body">';
html += '<div class="activity-feed-title">' + esc(truncate(title, 60)) + '</div>';
if (o.narrative) html += '<div style="font-size:12px;color:var(--ink-muted);margin-top:2px;">' + esc(truncate(o.narrative, 100)) + '</div>';
html += '<div class="activity-feed-meta">' + esc(type.replace(/_/g, ' '));
if (o.files && o.files.length) html += ' &middot; <span class="tag file-tag" style="font-size:9px;padding:0 4px;">' + esc(o.files[0].split('/').pop()) + '</span>';
html += ' &middot; ' + esc(shortTime(o.timestamp)) + '</div>';
html += '</div></div>';
});
}
html += '</div>';
html += '</div>';
el.innerHTML = html;
}
async function loadSessions() {
var el = document.getElementById('view-sessions');
el.innerHTML = '<div class="loading">Loading sessions...</div>';
var result = await apiGet('sessions');
state.sessions.items = (result && result.sessions) || [];
state.sessions.loaded = true;
renderSessions();
}
function renderSessions() {
var el = document.getElementById('view-sessions');
var items = state.sessions.items.slice().sort(function(a, b) {
return (b.startedAt || '').localeCompare(a.startedAt || '');
});
var folderFilter = state.sessions.folderFilter || '';
var projects = [];
var hasNoFolder = false;
state.sessions.items.forEach(function(s) {
var pPath = s.project ? String(s.project).trim() : '';
if (pPath) {
if (!projects.includes(pPath)) {
projects.push(pPath);
}
} else {
hasNoFolder = true;
}
});
projects.sort();
var toolbarHtml = '<div class="toolbar" style="margin-bottom: 12px; display: flex; gap: 10px; align-items: center;">';
toolbarHtml += '<span style="font-size: 12px; font-weight: 600; color: var(--ink-muted);">FILTER BY FOLDER PATH:</span>';
toolbarHtml += '<select id="sessions-folder-filter" style="width: 280px; padding: 6px 10px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--ink);">';
toolbarHtml += '<option value="">All Folders</option>';
if (hasNoFolder) {
var selected = folderFilter === '_none_' ? ' selected' : '';
toolbarHtml += '<option value="_none_"' + selected + '>Uncategorized (No Folder)</option>';
}
projects.forEach(function(p) {
var selected = p === folderFilter ? ' selected' : '';
toolbarHtml += '<option value="' + esc(p) + '"' + selected + '>' + esc(p) + '</option>';
});
toolbarHtml += '</select>';
toolbarHtml += '</div>';
var filteredItems = items.filter(function(s) {
if (!folderFilter) return true;
var pPath = s.project ? String(s.project).trim() : '';
if (folderFilter === '_none_') {
return !pPath;
}
return pPath === folderFilter;
});
var html = toolbarHtml + '<div class="session-list">';
if (filteredItems.length === 0) {
html += '<div class="empty-state"><div class="empty-icon">&#128466;</div><p>No sessions</p></div>';
} else {
filteredItems.forEach(function(s) {
var statusBadge = s.status === 'active' ? 'badge-green' : s.status === 'completed' ? 'badge-blue' : 'badge-muted';
var id = sessionId(s);
var selected = id && state.sessions.selectedId === id;
html += '<div class="session-item' + (selected ? ' selected' : '') + '"' + (id ? ' data-action="select-session" data-session-id="' + esc(id) + '"' : '') + '>';
html += '<div class="session-top"><span class="session-project">' + esc(sessionDisplayName(s)) + '</span>';
html += '<span class="badge ' + statusBadge + '">' + esc(s.status) + '</span></div>';
var preview = s.summary || s.firstPrompt || '';
if (preview) {
html += '<div class="session-preview" style="font-size:13px;color:var(--ink);margin:4px 0;line-height:1.4;">' + esc(truncate(preview, 140)) + '</div>';
}
html += '<div class="session-meta">' + esc(shortSessionId(s, 12) || 'missing id') + ' &middot; ' + esc(formatTime(s.startedAt));
html += ' &middot; ' + (s.observationCount || 0) + ' obs';
if (s.model) html += ' &middot; ' + esc(s.model);
html += '</div></div>';
});
}
html += '</div>';
html += '<div id="session-detail"></div>';
el.innerHTML = html;
var selectFilter = document.getElementById('sessions-folder-filter');
if (selectFilter) {
selectFilter.addEventListener('change', function() {
state.sessions.folderFilter = this.value;
renderSessions();
});
}
if (state.sessions.selectedId) renderSessionDetail();
}
function selectSession(id) {
state.sessions.selectedId = state.sessions.selectedId === id ? null : id;
renderSessions();
}
async function renderSessionDetail() {
var panel = document.getElementById('session-detail');
if (!panel) return;
var s = state.sessions.items.find(function(x) { return sessionId(x) === state.sessions.selectedId; });
var id = sessionId(s);
if (!s || !id) { panel.innerHTML = ''; return; }
panel.innerHTML = '<div class="detail-panel"><h3>Loading session details…</h3></div>';
var obsRes = await apiGet('observations?sessionId=' + encodeURIComponent(id));
var obs = (obsRes && obsRes.observations) || [];
var typeCounts = {};
var toolCounts = {};
var fileSet = new Set();
var firstPromptFromObs = '';
obs.forEach(function(o) {
var t = o.type || o.hookType || 'other';
typeCounts[t] = (typeCounts[t] || 0) + 1;
var tool = o.title || o.toolName;
if (tool && t !== 'conversation') toolCounts[tool] = (toolCounts[tool] || 0) + 1;
(o.files || []).forEach(function(f) { fileSet.add(f); });
if (!firstPromptFromObs && (o.userPrompt || (o.type === 'conversation' && o.narrative))) {
firstPromptFromObs = o.userPrompt || o.narrative || '';
}
});
var durationMs = s.endedAt ? new Date(s.endedAt).getTime() - new Date(s.startedAt).getTime() : 0;
var durationLabel = durationMs > 0 ? (durationMs < 60000 ? (durationMs / 1000).toFixed(1) + 's' : (durationMs / 60000).toFixed(1) + 'm') : '-';
var preview = s.summary || s.firstPrompt || firstPromptFromObs || '';
var html = '<div class="detail-panel">';
html += '<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:12px;">';
html += '<h3 style="margin:0;">Session · ' + esc(s.project || 'Unknown') + '</h3>';
html += '<span class="badge ' + (s.status === 'active' ? 'badge-green' : 'badge-blue') + '">' + esc(s.status) + '</span>';
html += '</div>';
if (preview) {
html += '<div style="padding:10px 12px;margin-bottom:12px;background:var(--bg-alt);border-left:3px solid var(--accent);font-size:13px;line-height:1.5;color:var(--ink);">' + esc(truncate(preview, 600)) + '</div>';
}
html += '<div style="display:grid;grid-template-columns:repeat(4, 1fr);gap:10px;margin-bottom:14px;">';
html += '<div class="card" style="padding:10px;"><div style="font-size:10px;letter-spacing:0.08em;color:var(--ink-muted);">OBSERVATIONS</div><div style="font-size:20px;font-weight:600;">' + obs.length + '</div></div>';
html += '<div class="card" style="padding:10px;"><div style="font-size:10px;letter-spacing:0.08em;color:var(--ink-muted);">TOOLS USED</div><div style="font-size:20px;font-weight:600;">' + Object.keys(toolCounts).length + '</div></div>';
html += '<div class="card" style="padding:10px;"><div style="font-size:10px;letter-spacing:0.08em;color:var(--ink-muted);">FILES TOUCHED</div><div style="font-size:20px;font-weight:600;">' + fileSet.size + '</div></div>';
html += '<div class="card" style="padding:10px;"><div style="font-size:10px;letter-spacing:0.08em;color:var(--ink-muted);">DURATION</div><div style="font-size:20px;font-weight:600;">' + esc(durationLabel) + '</div></div>';
html += '</div>';
var topTools = Object.keys(toolCounts).sort(function(a, b) { return toolCounts[b] - toolCounts[a]; }).slice(0, 10);
if (topTools.length > 0) {
var maxC = toolCounts[topTools[0]] || 1;
html += '<div class="card" style="margin-bottom:12px;"><div class="card-title">Tool Invocations</div>';
html += '<div class="bar-chart" style="margin-top:8px;">';
topTools.forEach(function(t) {
var pct = Math.round((toolCounts[t] / maxC) * 100);
html += '<div class="bar-row"><span class="bar-label" style="font-family:var(--font-mono);">' + esc(t) + '</span><div class="bar-track"><div class="bar-fill" style="width:' + pct + '%;background:var(--accent);"></div></div><span class="bar-value">' + toolCounts[t] + '</span></div>';
});
html += '</div></div>';
}
var typeKeys = Object.keys(typeCounts).sort(function(a, b) { return typeCounts[b] - typeCounts[a]; });
if (typeKeys.length > 0) {
html += '<div class="card" style="margin-bottom:12px;"><div class="card-title">Activity Breakdown</div>';
html += '<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:8px;">';
typeKeys.forEach(function(t) {
html += '<span class="badge badge-muted" style="font-family:var(--font-mono);">' + esc(t.replace(/_/g, ' ')) + ' · ' + typeCounts[t] + '</span>';
});
html += '</div></div>';
}
if (fileSet.size > 0) {
var filesArr = Array.from(fileSet).slice(0, 30);
html += '<div class="card" style="margin-bottom:12px;"><div class="card-title">Files</div>';
html += '<div style="font-size:12px;font-family:var(--font-mono);line-height:1.6;margin-top:8px;">';
filesArr.forEach(function(f) { html += '<div>&#8226; ' + esc(f) + '</div>'; });
if (fileSet.size > 30) html += '<div style="color:var(--ink-faint);">+' + (fileSet.size - 30) + ' more</div>';
html += '</div></div>';
}
html += '<div class="card" style="margin-bottom:12px;"><div class="card-title">Metadata</div>';
html += '<div style="font-size:12px;font-family:var(--font-mono);margin-top:8px;line-height:1.7;">';
var detailId = sessionId(s);
html += '<div><span style="color:var(--ink-muted);">id:</span> ' + esc(detailId || 'missing id') + '</div>';
html += '<div><span style="color:var(--ink-muted);">cwd:</span> ' + esc(s.cwd || '-') + '</div>';
html += '<div><span style="color:var(--ink-muted);">started:</span> ' + esc(formatTime(s.startedAt)) + '</div>';
if (s.endedAt) html += '<div><span style="color:var(--ink-muted);">ended:</span> ' + esc(formatTime(s.endedAt)) + '</div>';
if (s.model) html += '<div><span style="color:var(--ink-muted);">model:</span> ' + esc(s.model) + '</div>';
if (s.tags && s.tags.length) html += '<div><span style="color:var(--ink-muted);">tags:</span> ' + s.tags.map(esc).join(', ') + '</div>';
html += '</div></div>';
html += '<div style="display:flex;gap:8px;">';
if (detailId && s.status === 'active') {
html += '<button class="btn btn-danger" data-action="end-session" data-session-id="' + esc(detailId) + '">End Session</button>';
}
if (detailId) {
html += '<button class="btn btn-primary" data-action="summarize-session" data-session-id="' + esc(detailId) + '">Summarize</button>';
} else {
html += '<button class="btn btn-primary" disabled>Summarize unavailable</button>';
}
html += '</div></div>';
panel.innerHTML = html;
}
async function endSession(id) {
await apiPost('session/end', { sessionId: id });
state.sessions.loaded = false;
loadSessions();
}
async function summarizeSession(id, btn) {
if (!btn) return;
btn.textContent = 'Summarizing...';
btn.disabled = true;
await apiPost('summarize', { sessionId: id });
btn.textContent = 'Done';
setTimeout(function() { btn.textContent = 'Summarize'; btn.disabled = false; }, 2000);
}
async function loadLessons() {
var el = document.getElementById('view-lessons');
el.innerHTML = '<div class="loading">Loading lessons...</div>';
var result = await apiGet('lessons');
state.lessons.items = (result && result.lessons) || [];
state.lessons.loaded = true;
renderLessons();
}
function renderLessons() {
var el = document.getElementById('view-lessons');
var items = state.lessons.items;
var search = state.lessons.search.toLowerCase();
if (search) {
items = items.filter(function(l) {
return (l.content + ' ' + l.context + ' ' + (l.tags || []).join(' ')).toLowerCase().indexOf(search) >= 0;
});
}
var html = '<div class="card" style="margin-bottom:12px;padding:12px;background:var(--bg-subtle);">';
html += '<div style="font-size:13px;color:var(--ink-muted);line-height:1.5;">';
html += '<strong>Lessons</strong> are portable heuristics — short imperative rules (always/never/prefer/avoid) extracted from past work. Auto-surface from JSONL imports (low confidence, tag <code>auto-import</code>), get reinforced when the agent applies them, and decay if unused. Higher confidence = more battle-tested.';
html += '</div></div>';
html += '<div style="display:flex;gap:8px;margin-bottom:12px;">';
html += '<input id="lessons-search" class="search-input" type="text" placeholder="Search lessons..." value="' + esc(state.lessons.search) + '" style="flex:1" />';
html += '<span style="font-size:12px;color:var(--ink-faint);align-self:center;">' + items.length + ' lessons</span>';
html += '</div>';
if (items.length === 0) {
html += '<div class="empty-state">' +
'<div class="empty-icon">&#128161;</div>' +
'<div class="empty-title">No lessons yet</div>' +
'<div class="empty-lead">Lessons are confidence-scored pattern observations &mdash; things you corrected once that the agent should never do again. They persist across projects.</div>' +
'<pre class="empty-cmd"># Save a lesson explicitly\nmemory_lesson_save { rule, reason, confidence }\n\n# Or: Replay tab &rarr; Import JSONL auto-extracts lessons\n# from your past Claude Code sessions</pre>' +
'<div><a class="empty-link" href="https://github.com/rohitg00/agentmemory#lessons" target="_blank" rel="noopener">Lesson decay &amp; scoring &rarr;</a></div>' +
'</div>';
} else {
html += '<table><thead><tr><th>Lesson</th><th>Confidence</th><th>Reinforcements</th><th>Source</th><th>Project</th><th>Updated</th></tr></thead><tbody>';
items.forEach(function(l) {
var confPct = Math.round(l.confidence * 100);
var confColor = confPct >= 70 ? 'var(--green)' : confPct >= 40 ? 'var(--yellow)' : 'var(--red)';
html += '<tr>';
html += '<td style="max-width:400px;">' + esc(truncate(l.content, 120)) + (l.context ? '<div style="font-size:11px;color:var(--ink-faint);margin-top:2px;">' + esc(truncate(l.context, 80)) + '</div>' : '') + '</td>';
html += '<td><div class="gauge" style="min-width:80px;"><div class="gauge-bar"><div class="gauge-fill" style="width:' + confPct + '%;background:' + confColor + '"></div></div><span class="gauge-value" style="font-size:11px;">' + confPct + '%</span></div></td>';
html += '<td style="text-align:center;">' + (l.reinforcements || 0) + '</td>';
html += '<td><span class="badge badge-' + (l.source === 'crystal' ? 'purple' : l.source === 'consolidation' ? 'yellow' : 'blue') + '">' + esc(l.source) + '</span></td>';
html += '<td style="font-size:12px;color:var(--ink-muted);">' + esc(l.project || '-') + '</td>';
html += '<td style="font-size:12px;color:var(--ink-muted);">' + shortTime(l.updatedAt) + '</td>';
html += '</tr>';
});
html += '</tbody></table>';
}
var __focus = captureSearchFocus(['lessons-search']);
el.innerHTML = html;
var __ls = document.getElementById('lessons-search');
if (__ls) bindImeSafeSearch(__ls, 200, function(v){ state.lessons.search = v; renderLessons(); });
restoreSearchFocus(__focus);
}
async function loadActions() {
var el = document.getElementById('view-actions');
el.innerHTML = '<div class="loading">Loading actions...</div>';
var results = await Promise.all([apiGet('actions'), apiGet('frontier')]);
state.actions.items = (results[0] && results[0].actions) || [];
state.actions.frontier = (results[1] && (results[1].frontier || results[1].actions)) || [];
state.actions.loaded = true;
renderActions();
}
function renderActions() {
var el = document.getElementById('view-actions');
var items = state.actions.items;
var search = state.actions.search.toLowerCase();
var statusFilter = state.actions.statusFilter;
var frontierIds = new Set((state.actions.frontier || []).map(function(a) { return a.id; }));
if (search) {
items = items.filter(function(a) {
return (a.title + ' ' + (a.description || '') + ' ' + (a.tags || []).join(' ')).toLowerCase().indexOf(search) >= 0;
});
}
if (statusFilter) {
items = items.filter(function(a) { return a.status === statusFilter; });
}
var html = '<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;">';
html += '<input id="actions-search" class="search-input" type="text" placeholder="Search actions..." value="' + esc(state.actions.search) + '" style="flex:1;min-width:200px" />';
html += '<select id="actions-status-filter" style="padding:4px 8px;font-size:12px;border:1px solid var(--border);border-radius:4px;background:var(--bg);color:var(--ink);">';
html += '<option value="">All statuses</option>';
['pending','active','done','blocked','cancelled'].forEach(function(s) {
html += '<option value="' + s + '"' + (statusFilter === s ? ' selected' : '') + '>' + s + '</option>';
});
html += '</select>';
html += '<span style="font-size:12px;color:var(--ink-faint);align-self:center;">' + items.length + ' actions</span>';
html += '</div>';
if (items.length === 0) {
html += '<div class="empty-state">' +
'<div class="empty-icon">&#9745;</div>' +
'<div class="empty-title">No actions tracked yet</div>' +
'<div class="empty-lead">Actions are follow-ups the agent surfaced during a session: <em>decisions to revisit</em>, <em>files to inspect</em>, <em>tasks blocked on input</em>. They show up here with status pending &rarr; active &rarr; done/blocked so nothing slips through between sessions.</div>' +
'<div class="empty-lead" style="margin-top:0;">Three ways to create them:</div>' +
'<pre class="empty-cmd"># 1. MCP tool (from any agent)\nmemory_action_create { title, description, priority }\n\n# 2. Curl\ncurl -X POST http://localhost:3111/agentmemory/actions \\\n -H \'Content-Type: application/json\' \\\n -d \'{"title":"ship v1","priority":"high"}\'\n\n# 3. Hooks auto-extract from long session bodies</pre>' +
'<div><a class="empty-link" href="https://github.com/rohitg00/agentmemory#actions" target="_blank" rel="noopener">Action lifecycle docs &rarr;</a></div>' +
'</div>';
} else {
html += '<table><thead><tr><th>Title</th><th>Status</th><th>Priority</th><th>Tags</th><th>Frontier</th><th>Updated</th></tr></thead><tbody>';
items = items.slice().sort(function(a, b) { return (b.priority || 0) - (a.priority || 0); });
items.forEach(function(a) {
var statusClass = a.status === 'done' ? 'badge-green' : a.status === 'active' ? 'badge-blue' : a.status === 'blocked' ? 'badge-red' : a.status === 'cancelled' ? 'badge-red' : 'badge-yellow';
var isFrontier = frontierIds.has(a.id);
html += '<tr' + (isFrontier ? ' style="background:rgba(45,106,79,0.08);"' : '') + '>';
html += '<td style="max-width:350px;"><strong>' + esc(a.title) + '</strong>';
if (a.description) html += '<div style="font-size:11px;color:var(--ink-faint);margin-top:2px;">' + esc(truncate(a.description, 80)) + '</div>';
html += '</td>';
html += '<td><span class="badge ' + statusClass + '">' + esc(a.status) + '</span></td>';
html += '<td style="text-align:center;font-weight:600;">' + (a.priority || '-') + '</td>';
html += '<td style="font-size:11px;color:var(--ink-muted);">' + (a.tags || []).map(esc).join(', ') + '</td>';
html += '<td style="text-align:center;">' + (isFrontier ? '&#9889;' : '') + '</td>';
html += '<td style="font-size:12px;color:var(--ink-muted);">' + shortTime(a.updatedAt) + '</td>';
html += '</tr>';
});
html += '</tbody></table>';
}
var __focus = captureSearchFocus(['actions-search']);
el.innerHTML = html;
var __as = document.getElementById('actions-search');
if (__as) bindImeSafeSearch(__as, 200, function(v){ state.actions.search = v; renderActions(); });
var __af = document.getElementById('actions-status-filter');
if (__af) __af.addEventListener('change', function(){ state.actions.statusFilter = this.value; renderActions(); });
restoreSearchFocus(__focus);
}
async function loadCrystals() {
var el = document.getElementById('view-crystals');
el.innerHTML = '<div class="loading">Loading crystals...</div>';
var results = await Promise.all([apiGet('crystals'), apiGet('lessons')]);
state.crystals.items = (results[0] && results[0].crystals) || [];
var lessonMap = {};
var lessons = (results[1] && results[1].lessons) || [];
lessons.forEach(function(l) { if (l && l.id) lessonMap[l.id] = l; });
state.crystals.lessonMap = lessonMap;
state.crystals.loaded = true;
renderCrystals();
}
function renderCrystals() {
var el = document.getElementById('view-crystals');
var items = state.crystals.items;
var search = state.crystals.search.toLowerCase();
var lessonMap = state.crystals.lessonMap || {};
if (search) {
items = items.filter(function(c) {
var lessonText = (c.lessons || [])
.map(function(lid) {
var l = lessonMap[lid];
return l && typeof l.content === 'string' ? l.content : lid;
})
.join(' ');
var filesText = (c.filesAffected || []).join(' ');
var haystack = [
c.narrative || '',
(c.keyOutcomes || []).join(' '),
lessonText,
filesText,
c.project || '',
].join(' ').toLowerCase();
return haystack.indexOf(search) >= 0;
});
}
var html = '<div class="card" style="margin-bottom:12px;padding:12px;background:var(--bg-subtle);">';
html += '<div style="font-size:13px;color:var(--ink-muted);line-height:1.5;">';
html += '<strong>Crystals</strong> are frozen snapshots of completed work. Each crystal captures one session\'s narrative, the tools invoked (key outcomes), files touched, and lessons surfaced — a replayable summary you keep after raw observations are pruned. Auto-created on JSONL import or via <code>memory_crystallize</code>.';
html += '</div></div>';
html += '<div style="display:flex;gap:8px;margin-bottom:12px;">';
html += '<input id="crystals-search" class="search-input" type="text" placeholder="Search crystals..." value="' + esc(state.crystals.search) + '" style="flex:1" />';
html += '<span style="font-size:12px;color:var(--ink-faint);align-self:center;">' + items.length + ' crystals</span>';
html += '</div>';
if (items.length === 0) {
html += '<div class="empty-state">' +
'<div class="empty-icon">&#128142;</div>' +
'<div class="empty-title">No crystals yet</div>' +
'<div class="empty-lead">Crystals are compressed action digests &mdash; the 3-line summary of what happened in a session. Generated from long conversations to give the next session fast context without re-reading everything.</div>' +
'<pre class="empty-cmd"># Auto: import a JSONL transcript\n# Replay tab &rarr; Import JSONL\n\n# Manual: crystallize a specific session\nmemory_crystallize { sessionId }</pre>' +
'<div><a class="empty-link" href="https://github.com/rohitg00/agentmemory#crystals" target="_blank" rel="noopener">Crystal pipeline &rarr;</a></div>' +
'</div>';
} else {
items.forEach(function(c) {
html += '<div class="card" style="margin-bottom:12px;border-left:3px solid var(--accent);">';
html += '<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:8px;">';
html += '<div style="flex:1;font-size:14px;font-weight:600;color:var(--ink);line-height:1.4;">' + esc(truncate(c.narrative || 'Untitled crystal', 300)) + '</div>';
html += '<div style="font-size:10px;color:var(--ink-faint);font-family:var(--font-mono);white-space:nowrap;">' + esc(formatTime(c.createdAt)) + '</div>';
html += '</div>';
var pillRow = [];
if (c.project) pillRow.push('<span class="badge badge-muted">' + esc(c.project) + '</span>');
if (c.sessionId) pillRow.push('<span class="badge badge-blue" style="font-family:var(--font-mono);">' + esc(c.sessionId.slice(0, 14)) + '</span>');
if (c.keyOutcomes && c.keyOutcomes.length) pillRow.push('<span style="font-size:11px;color:var(--ink-muted);">' + c.keyOutcomes.length + ' tools</span>');
if (c.filesAffected && c.filesAffected.length) pillRow.push('<span style="font-size:11px;color:var(--ink-muted);">' + c.filesAffected.length + ' files</span>');
if (c.lessons && c.lessons.length) pillRow.push('<span style="font-size:11px;color:var(--ink-muted);">' + c.lessons.length + ' lessons</span>');
if (pillRow.length) html += '<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;">' + pillRow.join('') + '</div>';
if (c.keyOutcomes && c.keyOutcomes.length > 0) {
html += '<div style="margin:10px 0;"><div style="font-size:10px;letter-spacing:0.08em;color:var(--ink-muted);margin-bottom:4px;">TOOLS USED</div>';
html += '<div style="display:flex;gap:4px;flex-wrap:wrap;">';
c.keyOutcomes.forEach(function(o) {
html += '<span class="badge" style="background:var(--bg-alt);color:var(--ink);font-family:var(--font-mono);">' + esc(o) + '</span>';
});
html += '</div></div>';
}
if (c.filesAffected && c.filesAffected.length > 0) {
html += '<div style="margin:10px 0;"><div style="font-size:10px;letter-spacing:0.08em;color:var(--ink-muted);margin-bottom:4px;">FILES TOUCHED</div>';
html += '<div style="font-size:12px;font-family:var(--font-mono);color:var(--ink);line-height:1.6;">';
c.filesAffected.slice(0, 10).forEach(function(f) {
html += '<div>&#8226; ' + esc(f) + '</div>';
});
if (c.filesAffected.length > 10) html += '<div style="color:var(--ink-faint);">+' + (c.filesAffected.length - 10) + ' more</div>';
html += '</div></div>';
}
if (c.lessons && c.lessons.length > 0) {
html += '<div style="margin:10px 0;"><div style="font-size:10px;letter-spacing:0.08em;color:var(--ink-muted);margin-bottom:4px;">LESSONS SURFACED</div>';
c.lessons.slice(0, 8).forEach(function(lid) {
var content = lessonMap[lid] ? lessonMap[lid].content : lid;
html += '<div style="font-size:12px;padding:4px 8px;margin:2px 0;background:var(--bg-alt);border-radius:3px;color:var(--ink);line-height:1.4;">&#128161; ' + esc(content) + '</div>';
});
if (c.lessons.length > 8) html += '<div style="font-size:11px;color:var(--ink-faint);margin-top:4px;">+' + (c.lessons.length - 8) + ' more lessons</div>';
html += '</div>';
}
html += '</div>';
});
}
var __focus = captureSearchFocus(['crystals-search']);
el.innerHTML = html;
var __cs = document.getElementById('crystals-search');
if (__cs) bindImeSafeSearch(__cs, 200, function(v){ state.crystals.search = v; renderCrystals(); });
restoreSearchFocus(__focus);
}
async function loadAudit() {
var el = document.getElementById('view-audit');
el.innerHTML = '<div class="loading">Loading audit log...</div>';
var result = await apiGet('audit?limit=100');
state.audit.entries = (result && result.entries) || [];
state.audit.loaded = true;
renderAudit();
}
function renderAudit() {
var el = document.getElementById('view-audit');
var entries = state.audit.entries;
var opFilter = state.audit.opFilter;
var ops = {};
entries.forEach(function(e) { ops[e.operation] = true; });
var opList = Object.keys(ops).sort();
var filtered = opFilter ? entries.filter(function(e) { return e.operation === opFilter; }) : entries;
var html = '<div class="toolbar">';
html += '<select id="audit-op-filter"><option value="">All operations</option>';
opList.forEach(function(op) {
html += '<option value="' + esc(op) + '"' + (opFilter === op ? ' selected' : '') + '>' + esc(op) + '</option>';
});
html += '</select></div>';
html += '<div class="card">';
if (filtered.length === 0) {
html += '<div class="empty-state"><div class="empty-icon">&#128220;</div><p>No audit entries yet</p><p style="font-size:12px;color:var(--ink-faint);font-style:italic;">Audit entries are created by governance operations (delete, evolve, consolidate).</p></div>';
} else {
filtered.forEach(function(a, idx) {
var badgeClass = OP_BADGES[a.operation] || 'badge-muted';
html += '<div class="audit-entry">';
html += '<div class="audit-head">';
html += '<span class="badge ' + badgeClass + '">' + esc(a.operation) + '</span>';
html += '<span style="font-size:12px;color:var(--ink-muted);font-family:var(--font-mono);">' + esc(a.functionId || '') + '</span>';
html += '<span style="font-size:10px;color:var(--ink-faint);margin-left:auto;font-family:var(--font-mono);">' + esc(formatTime(a.timestamp)) + '</span>';
html += '<button class="btn" style="font-size:9px;padding:1px 6px;margin-left:8px;" data-action="toggle-audit" data-audit-index="' + idx + '">&#9660;</button>';
html += '</div>';
if (a.targetIds && a.targetIds.length) {
html += '<div style="font-size:10px;color:var(--ink-faint);font-family:var(--font-mono);">' + a.targetIds.length + ' target(s): ' + esc(a.targetIds.slice(0, 3).join(', ')) + (a.targetIds.length > 3 ? '...' : '') + '</div>';
}
html += '<div class="audit-detail" id="audit-detail-' + idx + '"><pre>' + esc(JSON.stringify(a.details || {}, null, 2)) + '</pre></div>';
html += '</div>';
});
}
html += '</div>';
el.innerHTML = html;
document.getElementById('audit-op-filter').addEventListener('change', function() {
state.audit.opFilter = this.value;
renderAudit();
});
}
function toggleAuditDetail(idx) {
var el = document.getElementById('audit-detail-' + idx);
if (el) el.classList.toggle('open');
}
async function loadProfile() {
var el = document.getElementById('view-profile');
el.innerHTML = '<div class="loading">Loading profile...</div>';
var sessResult = await apiGet('sessions');
var sessions = (sessResult && sessResult.sessions) || [];
var projects = {};
sessions.forEach(function(s) { if (s.project) projects[s.project] = true; });
state.profile.projects = Object.keys(projects).sort();
state.profile.loaded = true;
if (state.profile.projects.length > 0 && !state.profile.selectedProject) {
state.profile.selectedProject = state.profile.projects[0];
}
renderProfileToolbar();
if (state.profile.selectedProject) await loadProfileData();
}
function renderProfileToolbar() {
var el = document.getElementById('view-profile');
var html = '<div class="toolbar">';
html += '<select id="profile-project">';
if (state.profile.projects.length === 0) {
html += '<option value="">No projects</option>';
} else {
state.profile.projects.forEach(function(p) {
html += '<option value="' + esc(p) + '"' + (state.profile.selectedProject === p ? ' selected' : '') + '>' + esc(p) + '</option>';
});
}
html += '</select></div>';
html += '<div id="profile-content"></div>';
el.innerHTML = html;
document.getElementById('profile-project').addEventListener('change', function() {
state.profile.selectedProject = this.value;
loadProfileData();
});
}
async function loadProfileData() {
var content = document.getElementById('profile-content');
if (!content || !state.profile.selectedProject) return;
content.innerHTML = '<div class="loading">Loading profile data...</div>';
var result = await apiGet('profile?project=' + encodeURIComponent(state.profile.selectedProject));
state.profile.data = (result && result.profile) ? result.profile : result;
renderProfile();
}
function renderProfile() {
var content = document.getElementById('profile-content');
if (!content) return;
var p = state.profile.data;
if (!p) {
content.innerHTML = '<div class="empty-state"><div class="empty-icon">&#128203;</div><p>No profile data for this project</p></div>';
return;
}
var html = '<div class="two-col">';
html += '<div class="card"><div class="card-title">Top Concepts</div>';
var concepts = p.topConcepts || [];
if (concepts.length === 0) {
html += '<div style="font-size:13px;color:var(--ink-faint);font-style:italic;">No concepts yet</div>';
} else {
var maxC = Math.max.apply(null, concepts.map(function(c) { return c.frequency; })) || 1;
html += '<div class="bar-chart">';
concepts.slice(0, 10).forEach(function(c) {
var pct = Math.round((c.frequency / maxC) * 100);
html += '<div class="bar-row"><span class="bar-label">' + esc(c.concept) + '</span><div class="bar-track"><div class="bar-fill" style="width:' + pct + '%;background:var(--yellow);"></div></div><span class="bar-value">' + c.frequency + '</span></div>';
});
html += '</div>';
}
html += '</div>';
html += '<div class="card"><div class="card-title">Top Files</div>';
var files = p.topFiles || [];
if (files.length === 0) {
html += '<div style="font-size:13px;color:var(--ink-faint);font-style:italic;">No files yet</div>';
} else {
var maxF = Math.max.apply(null, files.map(function(f) { return f.frequency; })) || 1;
html += '<div class="bar-chart">';
files.slice(0, 10).forEach(function(f) {
var pct = Math.round((f.frequency / maxF) * 100);
html += '<div class="bar-row"><span class="bar-label">' + esc(f.file.split('/').pop()) + '</span><div class="bar-track"><div class="bar-fill" style="width:' + pct + '%;background:var(--green);"></div></div><span class="bar-value">' + f.frequency + '</span></div>';
});
html += '</div>';
}
html += '</div>';
html += '</div>';
html += '<div class="card" style="margin-top:16px;"><div class="card-title">Conventions</div>';
var conventions = p.conventions || [];
if (conventions.length === 0) {
html += '<div style="font-size:13px;color:var(--ink-faint);font-style:italic;">No conventions detected yet</div>';
} else {
html += '<ul style="padding-left:16px;">';
conventions.forEach(function(c) { html += '<li style="font-size:13px;color:var(--ink-muted);margin-bottom:4px;">' + esc(c) + '</li>'; });
html += '</ul>';
}
html += '</div>';
if (p.summary) {
html += '<div class="card" style="margin-top:16px;"><div class="card-title">Project Summary</div>';
html += '<p style="font-size:13px;color:var(--ink-muted);line-height:1.7;">' + esc(p.summary) + '</p></div>';
}
var stats = '<div class="card" style="margin-top:16px;"><div class="card-title">Project Stats</div>';
stats += '<div class="detail-row"><div class="dl">Sessions</div><div class="dv" style="font-family:var(--font-mono);">' + (p.sessionCount || 0) + '</div></div>';
stats += '<div class="detail-row"><div class="dl">Total Obs</div><div class="dv" style="font-family:var(--font-mono);">' + (p.totalObservations || 0) + '</div></div>';
stats += '<div class="detail-row"><div class="dl">Updated</div><div class="dv" style="font-family:var(--font-mono);font-size:12px;">' + esc(formatTime(p.updatedAt)) + '</div></div>';
stats += '</div>';
content.innerHTML = html + stats;
}
async function loadPersonal() {
var el = document.getElementById('view-personal');
el.innerHTML = '<div class="loading">Loading personal details...</div>';
try {
var res = await apiGet('second-brain');
if (res && res.success) {
state.personal.files = res.files || [];
state.personal.loaded = true;
if (state.personal.files.length > 0 && !state.personal.selectedFile) {
state.personal.selectedFile = state.personal.files[0].name;
}
renderPersonal();
} else {
var errorMsg = (res && res.error) ? res.error : 'Unknown error';
el.innerHTML = '<div class="loading" style="color:var(--accent);">Failed to load personal files: ' + esc(errorMsg) + '</div>';
}
} catch (err) {
var msg = (err && err.message) ? err.message : String(err);
console.error('[viewer] loadPersonal failed:', err);
el.innerHTML = '<div class="loading" style="color:var(--accent);">Failed to load personal files: ' + esc(msg) + '</div>';
}
}
function renderPersonal() {
var el = document.getElementById('view-personal');
if (!el) return;
var searchVal = (state.personal.search || '').toLowerCase();
var filtered = state.personal.files.filter(function(f) {
return f.name.toLowerCase().indexOf(searchVal) >= 0;
});
var fileListHtml = '';
if (filtered.length === 0) {
fileListHtml = '<div class="empty-state" style="padding: 20px;"><p style="font-size:12px;">No matching files</p></div>';
} else {
filtered.forEach(function(f) {
var selected = f.name === state.personal.selectedFile;
var sizeStr = f.size >= 1024 ? (f.size / 1024).toFixed(1) + ' KB' : f.size + ' B';
fileListHtml += '<div class="personal-file-item' + (selected ? ' selected' : '') + '" data-file="' + esc(f.name) + '">' +
'<span style="font-size:14px;">&#128196;</span>' +
'<div class="personal-file-name">' + esc(f.name) + '</div>' +
'<div class="personal-file-size">' + esc(sizeStr) + '</div>' +
'</div>';
});
}
var selectedFileData = state.personal.files.find(function(f) {
return f.name === state.personal.selectedFile;
});
var editorHtml = '';
if (!selectedFileData) {
editorHtml = '<div class="empty-state"><div class="empty-icon">&#128466;</div><p>No file selected</p></div>';
} else {
var sizeStr = selectedFileData.size >= 1024 ? (selectedFileData.size / 1024).toFixed(1) + ' KB' : selectedFileData.size + ' B';
editorHtml = '<div class="personal-editor-card">' +
'<div class="personal-editor-header">' +
'<div>' +
'<div class="personal-editor-title">' + esc(selectedFileData.name) + '</div>' +
'<div class="personal-editor-meta">' + esc(sizeStr) + ' &middot; Markdown Document</div>' +
'</div>' +
'</div>' +
'<textarea class="personal-editor-textarea" id="personal-textarea">' + esc(selectedFileData.content) + '</textarea>' +
'<div class="personal-actions">' +
'<span class="personal-status-indicator" id="personal-status">Saved successfully!</span>' +
'<button class="btn btn-primary" id="personal-save-btn">Save Changes</button>' +
'</div>' +
'</div>';
}
el.innerHTML =
'<div class="personal-grid">' +
'<div class="personal-sidebar">' +
'<div class="personal-search-wrapper">' +
'<input type="text" class="personal-search-input" id="personal-search" placeholder="Search files..." value="' + esc(state.personal.search || '') + '">' +
'</div>' +
'<div class="personal-file-list">' +
fileListHtml +
'</div>' +
'</div>' +
'<div class="personal-editor-pane">' +
editorHtml +
'</div>' +
'</div>';
var searchInput = document.getElementById('personal-search');
if (searchInput) {
searchInput.addEventListener('input', function() {
state.personal.search = this.value;
renderPersonalFileListOnly();
});
}
el.querySelectorAll('.personal-file-item').forEach(function(item) {
item.addEventListener('click', function() {
var fileName = this.getAttribute('data-file');
if (fileName) {
state.personal.selectedFile = fileName;
renderPersonal();
}
});
});
var saveBtn = document.getElementById('personal-save-btn');
if (saveBtn) {
saveBtn.addEventListener('click', async function() {
var textarea = document.getElementById('personal-textarea');
if (!textarea || !state.personal.selectedFile) return;
var content = textarea.value;
saveBtn.disabled = true;
var statusEl = document.getElementById('personal-status');
if (statusEl) {
statusEl.textContent = 'Saving...';
statusEl.className = 'personal-status-indicator visible';
}
try {
var res = await apiPost('second-brain', {
file: state.personal.selectedFile,
content: content
});
if (res && res.success) {
var fileObj = state.personal.files.find(function(f) {
return f.name === state.personal.selectedFile;
});
if (fileObj) {
fileObj.content = content;
fileObj.size = res.size || content.length;
}
if (statusEl) {
statusEl.textContent = 'Saved successfully!';
statusEl.className = 'personal-status-indicator visible';
}
renderPersonalFileListOnly();
} else {
var errorMsg = (res && res.error) ? res.error : 'Unknown error';
if (statusEl) {
statusEl.textContent = 'Error: ' + errorMsg;
statusEl.className = 'personal-status-indicator visible error';
}
}
} catch (err) {
if (statusEl) {
statusEl.textContent = 'Error saving file';
statusEl.className = 'personal-status-indicator visible error';
}
} finally {
saveBtn.disabled = false;
setTimeout(function() {
if (statusEl && statusEl.textContent !== 'Saving...') {
statusEl.className = 'personal-status-indicator';
}
}, 3000);
}
});
}
}
function renderPersonalFileListOnly() {
var searchVal = (state.personal.search || '').toLowerCase();
var filtered = state.personal.files.filter(function(f) {
return f.name.toLowerCase().indexOf(searchVal) >= 0;
});
var fileListHtml = '';
if (filtered.length === 0) {
fileListHtml = '<div class="empty-state" style="padding: 20px;"><p style="font-size:12px;">No matching files</p></div>';
} else {
filtered.forEach(function(f) {
var selected = f.name === state.personal.selectedFile;
var sizeStr = f.size >= 1024 ? (f.size / 1024).toFixed(1) + ' KB' : f.size + ' B';
fileListHtml += '<div class="personal-file-item' + (selected ? ' selected' : '') + '" data-file="' + esc(f.name) + '">' +
'<span style="font-size:14px;">&#128196;</span>' +
'<div class="personal-file-name">' + esc(f.name) + '</div>' +
'<div class="personal-file-size">' + esc(sizeStr) + '</div>' +
'</div>';
});
}
var listContainer = document.querySelector('.personal-file-list');
if (listContainer) {
listContainer.innerHTML = fileListHtml;
listContainer.querySelectorAll('.personal-file-item').forEach(function(item) {
item.addEventListener('click', function() {
var fileName = this.getAttribute('data-file');
if (fileName) {
state.personal.selectedFile = fileName;
renderPersonal();
}
});
});
}
}
var wsReconnectTimer = null;
var wsRetries = 0;
var WS_MAX_RETRIES = 4;
var directFailed = false;
var directFailures = 0;
var DIRECT_FAILURE_THRESHOLD = 2;
var pollTimer = null;
var POLL_INTERVAL_MS = 10000;
function setWsStatus(text, cls) {
var el = document.getElementById('ws-status');
if (!el) return;
el.textContent = text;
el.className = 'ws-status ' + cls;
}
var WS_REPROBE_EVERY_TICKS = 6;
function startPolling() {
if (pollTimer) return;
setWsStatus('polling · ' + (POLL_INTERVAL_MS / 1000) + 's', 'disconnected');
var tick = 0;
pollTimer = setInterval(function() {
tick++;
if (state.activeTab === 'dashboard') {
state.dashboard.loaded = false;
loadDashboard();
} else if (state.activeTab === 'memories') {
state.memories.loaded = false;
loadMemories();
} else if (state.activeTab === 'sessions') {
state.sessions.loaded = false;
loadSessions();
} else if (state.activeTab === 'activity') {
state.activity.loaded = false;
loadActivity();
}
if (tick % WS_REPROBE_EVERY_TICKS === 0) {
var ws = state.ws;
if (!ws || ws.readyState !== WebSocket.OPEN) {
wsRetries = 0;
directFailures = 0;
directFailed = false;
connectWs();
}
}
}, POLL_INTERVAL_MS);
}
function stopPolling() {
if (!pollTimer) return;
clearInterval(pollTimer);
pollTimer = null;
}
var WS_CONNECT_TIMEOUT_MS = 5000;
function connectWs() {
if (wsRetries >= WS_MAX_RETRIES) {
startPolling();
return;
}
var useDirect = !directFailed;
var token = getViewerToken();
var suffix = token ? '?token=' + encodeURIComponent(token) : '';
var ws;
try {
ws = new WebSocket((useDirect ? WS_DIRECT_URL : WS_URL) + suffix);
ws.__direct = useDirect;
} catch (_) {
ws = new WebSocket(WS_URL + suffix);
ws.__direct = false;
}
var connectTimer = setTimeout(function() {
if (ws.readyState === WebSocket.CONNECTING) {
try { ws.close(); } catch {}
}
}, WS_CONNECT_TIMEOUT_MS);
try {
ws.onopen = function() {
clearTimeout(connectTimer);
if (state.ws !== ws) return;
wsRetries = 0;
stopPolling();
if (ws.__direct) {
directFailures = 0;
directFailed = false;
}
if (!ws.__direct) {
ws.send(JSON.stringify({
type: 'join',
data: {
subscriptionId: 'viewer-' + Date.now(),
streamName: 'mem-live',
groupId: 'viewer'
}
}));
}
setWsStatus('live', 'connected');
};
ws.onmessage = function(e) {
if (state.ws !== ws) return;
try {
var msg = JSON.parse(e.data);
if (msg.type === 'stream' && msg.event) {
handleStreamEvent(msg);
} else if (msg.event_type && msg.data) {
handleStreamEvent({ event: { type: 'create', data: msg.data, event_type: msg.event_type } });
}
} catch {}
};
ws.onclose = function(e) {
clearTimeout(connectTimer);
if (state.ws !== ws) return;
if (e && e.code === 1008) {
showViewerAuthPrompt();
return;
}
if (ws.__direct) {
directFailures += 1;
if (directFailures >= DIRECT_FAILURE_THRESHOLD) {
directFailed = true;
}
}
wsRetries++;
if (wsRetries < WS_MAX_RETRIES) {
setWsStatus('connecting...', 'disconnected');
wsReconnectTimer = setTimeout(connectWs, 2000 + Math.min(wsRetries * 1000, 8000));
} else {
startPolling();
}
};
ws.onerror = function() {
if (state.ws !== ws) return;
try { ws.close(); } catch {}
};
state.ws = ws;
} catch {
wsRetries++;
if (wsRetries < WS_MAX_RETRIES) {
wsReconnectTimer = setTimeout(connectWs, 2000 + Math.min(wsRetries * 1000, 8000));
} else {
startPolling();
}
}
}
function looksLikeObservation(obj) {
return !!(obj && typeof obj === 'object' && obj.id && obj.timestamp);
}
function handleStreamEvent(msg) {
var evt = msg.event;
var observation;
if (!evt) return;
if (evt.event_type && evt.event_type !== 'observation' && evt.event_type !== 'create' && evt.event_type !== 'update') {
return;
}
if (evt.type === 'event' && evt.data) {
observation = evt.data.observation || evt.data;
if (looksLikeObservation(observation)) {
routeWsMessage({ observation: observation });
}
return;
}
if ((evt.type === 'create' || evt.type === 'update') && evt.data) {
var payload = evt.data;
observation = payload.observation || payload;
if (looksLikeObservation(observation)) {
routeWsMessage({ observation: observation });
}
} else if (evt.type === 'sync') {
var items = Array.isArray(evt.data) ? evt.data : [];
items.forEach(function(item) {
var payload = item.data || item;
observation = payload.observation || payload;
if (looksLikeObservation(observation)) {
routeWsMessage({ observation: observation });
}
});
}
}
function routeWsMessage(msg) {
if (state.activeTab === 'timeline' && msg.observation) {
if (!state.timeline.sessionId || msg.observation.sessionId === state.timeline.sessionId) {
var existing = state.timeline.observations.findIndex(function(o) { return o.id === msg.observation.id; });
if (existing >= 0) {
state.timeline.observations[existing] = msg.observation;
} else {
state.timeline.observations.unshift(msg.observation);
}
renderObservations();
}
}
if (state.activeTab === 'dashboard') {
state.dashboard.loaded = false;
loadDashboard();
}
if (state.activeTab === 'activity' && msg.observation) {
state.activity.observations.unshift(msg.observation);
renderActivity();
}
}
document.getElementById('tab-bar').addEventListener('click', function(e) {
var btn = e.target instanceof Element ? e.target.closest('button[data-tab]') : null;
if (btn) switchTab(btn.dataset.tab);
});
document.querySelectorAll('[data-tab-link]').forEach(function(link) {
link.addEventListener('click', function(e) {
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
e.preventDefault();
switchTab(link.getAttribute('data-tab-link'));
});
});
function syncTabFromRoute() {
switchTab(tabFromRoute(), { replaceRoute: true });
}
window.addEventListener('hashchange', syncTabFromRoute);
window.addEventListener('popstate', syncTabFromRoute);
function getDismissedFlags() {
if (!state.flagsDismissed) state.flagsDismissed = {};
return state.flagsDismissed;
}
function dismissFlags(keys) {
var dismissed = getDismissedFlags();
keys.forEach(function(key) {
if (key) dismissed[key] = true;
});
}
function renderFlagBanners(cfg) {
var host = document.getElementById('flag-banners');
if (!host) return;
var dismissed = getDismissedFlags();
var banners = [];
(cfg.flags || []).forEach(function(f) {
if (f.enabled) return;
if (dismissed[f.key]) return;
var tabsAffected = (f.affects || []).map(function(t) { return t.toLowerCase(); });
if (tabsAffected.length && tabsAffected.indexOf(state.activeTab) === -1 && state.activeTab !== 'dashboard') return;
banners.push({
kind: 'warn',
icon: '&#9888;',
title: f.label,
keyLabel: f.key,
desc: f.description + (f.needsLlm ? ' Requires an LLM provider key (ANTHROPIC_API_KEY, GEMINI_API_KEY, etc.).' : ''),
enable: f.enableHow,
docs: f.docsHref,
dismissKey: f.key,
});
});
if (cfg.provider === 'noop' && !dismissed['__provider_noop']) {
banners.unshift({
kind: 'warn',
icon: '&#128274;',
title: 'No LLM provider key set',
keyLabel: 'ANTHROPIC_API_KEY',
desc: 'Compression, summarization, and graph extraction stay disabled until a key is provided.',
enable: 'export ANTHROPIC_API_KEY=sk-ant-...\n# then restart: npx @agentmemory/agentmemory',
docs: 'https://github.com/rohitg00/agentmemory#quick-start',
dismissKey: '__provider_noop',
});
}
if (cfg.embeddingProvider === 'none' && !dismissed['__embedding_none']) {
banners.push({
kind: 'info',
icon: '&#9881;',
title: 'Running in BM25-only mode',
keyLabel: 'OPENAI_API_KEY',
desc: 'Semantic vector search is off. BM25 keyword search is active and good for exact matches.',
enable: 'export OPENAI_API_KEY=sk-...\n# or VOYAGE_API_KEY, COHERE_API_KEY, OLLAMA_HOST',
docs: 'https://github.com/rohitg00/agentmemory#embedding-providers',
dismissKey: '__embedding_none',
});
}
if (banners.length === 0) { host.innerHTML = ''; return; }
var warnCount = banners.filter(function(b) { return b.kind === 'warn'; }).length;
var infoCount = banners.filter(function(b) { return b.kind === 'info'; }).length;
var expanded = host.getAttribute('data-expanded') === '1';
var pills = '';
if (warnCount) pills += '<span class="flag-pill">' + warnCount + ' off</span>';
if (infoCount) pills += '<span class="flag-pill info">' + infoCount + ' note</span>';
var escHtml = function(s) {
return String(s).replace(/[<>&"]/g, function(c) {
return { '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[c];
});
};
var listHtml = banners.map(function(b) {
return '<div class="flag-banner ' + b.kind + '" data-flag="' + escHtml(b.dismissKey) + '">' +
'<span class="flag-icon">' + b.icon + '</span>' +
'<div class="flag-body">' +
'<div class="flag-title">' + escHtml(b.title) + ' <code>' + escHtml(b.keyLabel) + '</code></div>' +
'<div class="flag-desc">' + escHtml(b.desc) + '</div>' +
'<code class="flag-enable">' + escHtml(b.enable) + '</code>' +
(b.docs ? ' <a class="empty-link" href="' + escHtml(b.docs) + '" target="_blank" rel="noopener">Learn more &rarr;</a>' : '') +
'</div>' +
'<button type="button" class="flag-close" data-dismiss-flag="' + escHtml(b.dismissKey) + '" aria-label="Dismiss">&times;</button>' +
'</div>';
}).join('');
host.innerHTML = '<button type="button" class="flag-summary" data-action="toggle-flags" aria-expanded="' + (expanded ? 'true' : 'false') + '" aria-controls="flag-list">' +
pills +
'<span class="flag-count">Feature flags</span>' +
'<span style="color:var(--ink-faint);">— click to ' + (expanded ? 'collapse' : 'expand') + '</span>' +
'<span class="flag-toggle" aria-hidden="true">' + (expanded ? '&#9650;' : '&#9660;') + '</span>' +
'</button>' +
'<div class="flag-list' + (expanded ? ' open' : '') + '" id="flag-list">' + listHtml + '</div>';
}
async function fetchFlags() {
var res = await apiGet('config/flags');
if (!res) return;
state.flagsConfig = res;
renderFlagBanners(res);
updateFooter(res);
}
function updateFooter(cfg) {
var vEl = document.getElementById('footer-version');
if (vEl) vEl.textContent = 'v' + (cfg.version || '?');
var fbEl = document.getElementById('footer-feedback');
if (fbEl) {
var flagSummary = (cfg.flags || []).map(function(f) { return f.key + '=' + (f.enabled ? 'on' : 'off'); }).join(', ');
var body = encodeURIComponent(
'**Version:** ' + (cfg.version || '?') + '\n' +
'**Provider:** ' + (cfg.provider || '?') + '\n' +
'**Embedding:** ' + (cfg.embeddingProvider || '?') + '\n' +
'**Flags:** ' + flagSummary + '\n' +
'**User agent:** ' + navigator.userAgent + '\n\n' +
'### What went wrong\n\n' +
'(describe the issue)\n\n' +
'### Steps to reproduce\n\n' +
'1. \n2. \n3. \n'
);
fbEl.href = 'https://github.com/rohitg00/agentmemory/issues/new?title=' +
encodeURIComponent('[viewer] ') + '&body=' + body;
}
}
document.addEventListener('click', function(e) {
if (!(e.target instanceof Element)) return;
var btn = e.target.closest('[data-dismiss-flag]');
if (btn) {
e.preventDefault();
e.stopPropagation();
var key = btn.getAttribute('data-dismiss-flag');
dismissFlags([key]);
if (state.flagsConfig) renderFlagBanners(state.flagsConfig);
return;
}
var toggle = e.target.closest('[data-action="toggle-flags"]');
if (toggle) {
var host = document.getElementById('flag-banners');
var cur = host.getAttribute('data-expanded') === '1';
host.setAttribute('data-expanded', cur ? '0' : '1');
if (state.flagsConfig) renderFlagBanners(state.flagsConfig);
}
});
fetchFlags();
document.addEventListener('click', function(e) {
if (!(e.target instanceof Element)) return;
var target = e.target.closest('[data-action]');
if (!target) return;
var action = target.getAttribute('data-action');
if (!action) return;
if (action === 'toggle-theme') {
toggleTheme();
return;
}
if (action === 'refresh-dashboard') {
refreshDashboard();
return;
}
if (action === 'zoom-graph') {
zoomGraph(parseInt(target.getAttribute('data-dir') || '0', 10));
return;
}
if (action === 'recenter-graph') {
recenterGraph();
return;
}
if (action === 'rebuild-graph') {
rebuildGraph();
return;
}
if (action === 'expand-node') {
var nodeId = target.getAttribute('data-node-id');
if (nodeId) expandNode(nodeId);
return;
}
if (action === 'delete-memory') {
deleteMemory(
target.getAttribute('data-memory-id') || '',
target.getAttribute('data-memory-title') || '',
);
return;
}
if (action === 'close-modal') {
closeModal();
return;
}
if (action === 'confirm-delete-memory') {
var memoryId = target.getAttribute('data-memory-id');
if (memoryId) confirmDeleteMemory(memoryId);
return;
}
if (action === 'save-viewer-token') {
var tokenInput = document.getElementById('viewer-auth-token');
var token = tokenInput ? tokenInput.value.trim() : '';
if (token) {
setViewerToken(token);
hideViewerAuthPrompt();
fetchFlags();
if (state[state.activeTab] && typeof state[state.activeTab] === 'object') {
state[state.activeTab].loaded = false;
}
loadTab(state.activeTab);
}
return;
}
if (action === 'timeline-filter') {
setTlTypeFilter(target.getAttribute('data-type-filter') || '');
return;
}
if (action === 'timeline-page') {
var page = parseInt(target.getAttribute('data-page') || '', 10);
if (!Number.isNaN(page)) tlPage(page);
return;
}
if (action === 'select-session') {
var sessionId = target.getAttribute('data-session-id');
if (sessionId) selectSession(sessionId);
return;
}
if (action === 'end-session') {
var endSessionId = target.getAttribute('data-session-id');
if (endSessionId) endSession(endSessionId);
return;
}
if (action === 'summarize-session') {
var summarizeSessionId = target.getAttribute('data-session-id');
if (summarizeSessionId) summarizeSession(summarizeSessionId, target);
return;
}
if (action === 'toggle-audit') {
var auditIndex = parseInt(target.getAttribute('data-audit-index') || '', 10);
if (!Number.isNaN(auditIndex)) toggleAuditDetail(auditIndex);
}
if (action === 'replay-select') {
var rSid = target.getAttribute('data-session-id');
if (rSid) selectReplaySession(rSid);
return;
}
if (action === 'replay-toggle-play') { toggleReplayPlay(); return; }
if (action === 'replay-step') {
var d = parseInt(target.getAttribute('data-dir') || '1', 10);
stepReplay(d);
return;
}
if (action === 'replay-speed') {
var sp = parseFloat(target.getAttribute('data-speed') || '1');
setReplaySpeed(sp);
return;
}
if (action === 'replay-reset') { resetReplay(); return; }
if (action === 'replay-import') { runReplayImport(); return; }
if (action === 'replay-refresh') { refreshReplaySessions(); return; }
});
document.getElementById('modal-overlay').addEventListener('click', function(e) {
if (e.target === this) closeModal();
});
async function loadReplay() {
var el = document.getElementById('view-replay');
el.innerHTML = '<div class="loading">Loading sessions…</div>';
var res = await apiGet('replay/sessions');
state.replay.sessions = (res && res.sessions) || [];
state.replay.loaded = true;
renderReplay();
}
async function refreshReplaySessions() {
state.replay.loaded = false;
await loadReplay();
}
function renderReplay() {
var el = document.getElementById('view-replay');
var sessions = state.replay.sessions || [];
var options = '<option value="">— pick a session —</option>' + sessions.map(function(s) {
var id = sessionId(s);
var label = sessionDisplayName(s) + ' · ' + (shortSessionId(s, 8) || 'missing id') + ' · ' + (s.observationCount || 0) + ' obs';
return '<option value="' + esc(id) + '"' + (id && id === state.replay.selectedId ? ' selected' : '') + (id ? '' : ' disabled') + '>' + esc(label) + '</option>';
}).join('');
var tl = state.replay.timeline;
var hasTl = tl && tl.events && tl.events.length > 0;
var cursorEvent = hasTl ? tl.events[Math.min(state.replay.cursor, tl.events.length - 1)] : null;
var progress = hasTl && tl.totalDurationMs > 0 ? Math.min(100, (state.replay.offsetAt / tl.totalDurationMs) * 100) : 0;
el.innerHTML =
'<div class="toolbar">' +
'<select id="replay-session-select">' + options + '</select>' +
'<button data-action="replay-refresh">Refresh</button>' +
'<span class="sep"></span>' +
'<input type="text" id="replay-import-path" placeholder="~/.claude/projects or file.jsonl" style="width:280px">' +
'<button data-action="replay-import">Import JSONL</button>' +
'</div>' +
(hasTl
? '<div class="replay-controls">' +
'<button data-action="replay-step" data-dir="-1" title="Previous (←)">◀</button>' +
'<button data-action="replay-toggle-play" title="Play/Pause (Space)">' + (state.replay.playing ? '❚❚ Pause' : '▶ Play') + '</button>' +
'<button data-action="replay-step" data-dir="1" title="Next (→)">▶</button>' +
'<button data-action="replay-reset" title="Reset">⟲</button>' +
'<span class="sep"></span>' +
'<span>Speed</span>' +
['0.5', '1', '2', '4'].map(function(sp) {
var active = Math.abs(state.replay.speed - parseFloat(sp)) < 0.01;
return '<button data-action="replay-speed" data-speed="' + sp + '"' + (active ? ' class="active"' : '') + '>' + sp + '×</button>';
}).join('') +
'<span class="sep"></span>' +
'<span>' + (state.replay.cursor + 1) + ' / ' + tl.eventCount + '</span>' +
'</div>' +
'<div class="replay-progress"><div class="replay-progress-bar" style="width:' + progress.toFixed(1) + '%"></div></div>' +
'<div class="replay-grid">' +
'<div class="replay-list" id="replay-list">' +
tl.events.map(function(ev, i) {
var active = i === state.replay.cursor ? ' replay-event-active' : '';
return '<div class="replay-event replay-event-' + esc(ev.kind) + active + '" data-replay-idx="' + i + '">' +
'<span class="replay-event-kind">' + esc(ev.kind) + '</span>' +
'<span class="replay-event-label">' + esc(ev.label) + '</span>' +
'<span class="replay-event-time">' + (ev.offsetMs / 1000).toFixed(1) + 's</span>' +
'</div>';
}).join('') +
'</div>' +
'<div class="replay-detail">' + renderReplayDetail(cursorEvent) + '</div>' +
'</div>'
: '<div class="empty">Pick a session to replay, or import Claude Code JSONL transcripts from ~/.claude/projects.</div>');
var sel = document.getElementById('replay-session-select');
if (sel) sel.addEventListener('change', function() { selectReplaySession(sel.value); });
}
function renderReplayDetail(ev) {
if (!ev) return '<div class="empty">No event selected.</div>';
var blocks = [];
blocks.push('<div class="replay-detail-header"><b>' + esc(ev.label) + '</b> <span class="muted">' + esc(ev.kind) + '</span></div>');
if (ev.ts) blocks.push('<div class="muted">' + esc(formatTime(ev.ts)) + '</div>');
if (ev.body) {
blocks.push('<pre class="replay-body">' + esc(ev.body) + '</pre>');
}
if (ev.toolName) {
blocks.push('<div class="replay-tool"><b>Tool:</b> ' + esc(ev.toolName) + '</div>');
}
if (ev.toolInput !== undefined && ev.toolInput !== null) {
var inp = typeof ev.toolInput === 'string' ? ev.toolInput : JSON.stringify(ev.toolInput, null, 2);
blocks.push('<div class="replay-tool-block"><b>Input</b><pre>' + esc(truncate(inp, 4000)) + '</pre></div>');
}
if (ev.toolOutput !== undefined && ev.toolOutput !== null) {
var out = typeof ev.toolOutput === 'string' ? ev.toolOutput : JSON.stringify(ev.toolOutput, null, 2);
blocks.push('<div class="replay-tool-block"><b>Output</b><pre>' + esc(truncate(out, 4000)) + '</pre></div>');
}
return blocks.join('');
}
async function selectReplaySession(sessionId) {
stopReplayTimer();
state.replay.selectedId = sessionId;
state.replay.timeline = null;
state.replay.cursor = 0;
state.replay.offsetAt = 0;
state.replay.playing = false;
if (!sessionId) { renderReplay(); return; }
var el = document.getElementById('view-replay');
el.innerHTML = '<div class="loading">Loading replay…</div>';
var res = await apiGet('replay/load?sessionId=' + encodeURIComponent(sessionId));
if (res && res.success && res.timeline) {
state.replay.timeline = res.timeline;
} else {
state.replay.timeline = { events: [], eventCount: 0, totalDurationMs: 0 };
}
renderReplay();
}
function toggleReplayPlay() {
if (!state.replay.timeline || state.replay.timeline.eventCount === 0) return;
if (state.replay.playing) {
stopReplayTimer();
} else {
startReplayTimer();
}
renderReplay();
}
function startReplayTimer() {
state.replay.playing = true;
state.replay.startAt = Date.now();
var baseOffset = state.replay.offsetAt;
if (state.replay.timer) clearInterval(state.replay.timer);
state.replay.timer = setInterval(function() {
if (!state.replay.timeline) return;
var elapsed = (Date.now() - state.replay.startAt) * state.replay.speed;
state.replay.offsetAt = baseOffset + elapsed;
var events = state.replay.timeline.events;
var newCursor = state.replay.cursor;
for (var i = newCursor; i < events.length; i++) {
if (events[i].offsetMs <= state.replay.offsetAt) newCursor = i;
else break;
}
var changed = newCursor !== state.replay.cursor;
state.replay.cursor = newCursor;
if (state.replay.offsetAt >= state.replay.timeline.totalDurationMs) {
state.replay.offsetAt = state.replay.timeline.totalDurationMs;
stopReplayTimer();
renderReplay();
return;
}
if (changed) renderReplay();
}, 100);
}
function stopReplayTimer() {
state.replay.playing = false;
if (state.replay.timer) {
clearInterval(state.replay.timer);
state.replay.timer = null;
}
}
function stepReplay(dir) {
if (!state.replay.timeline) return;
stopReplayTimer();
var next = state.replay.cursor + dir;
if (next < 0) next = 0;
if (next >= state.replay.timeline.eventCount) next = state.replay.timeline.eventCount - 1;
state.replay.cursor = next;
state.replay.offsetAt = state.replay.timeline.events[next].offsetMs;
renderReplay();
}
function setReplaySpeed(sp) {
if (!sp || sp <= 0) return;
var wasPlaying = state.replay.playing;
stopReplayTimer();
state.replay.speed = sp;
if (wasPlaying) startReplayTimer();
renderReplay();
}
function resetReplay() {
stopReplayTimer();
state.replay.cursor = 0;
state.replay.offsetAt = 0;
renderReplay();
}
async function runReplayImport() {
var input = document.getElementById('replay-import-path');
var pathVal = input ? input.value.trim() : '';
var body = {};
if (pathVal) body.path = pathVal;
var el = document.getElementById('view-replay');
var prior = el.innerHTML;
el.innerHTML = '<div class="loading">Importing JSONL…</div>';
var res = await apiPost('replay/import-jsonl', body);
if (!res || res.success === false) {
el.innerHTML = prior;
alert((res && res.error) || 'Import failed');
return;
}
alert('Imported ' + (res.imported || 0) + ' file(s), ' + (res.observations || 0) + ' observation(s)');
await refreshReplaySessions();
}
document.addEventListener('keydown', function(e) {
if (state.activeTab !== 'replay') return;
if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT' || e.target.tagName === 'TEXTAREA')) return;
if (e.key === ' ') { e.preventDefault(); toggleReplayPlay(); }
else if (e.key === 'ArrowLeft') { e.preventDefault(); stepReplay(-1); }
else if (e.key === 'ArrowRight') { e.preventDefault(); stepReplay(1); }
});
switchTab(tabFromRoute(), { replaceRoute: true });
connectWs();
startDashboardAutoRefresh();
</script>
</body>
</html>