Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 56 additions & 36 deletions src/viewer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1005,7 +1005,7 @@ <h1>agentmemory</h1>

var state = {
activeTab: 'dashboard',
dashboard: { loaded: false, health: null, sessions: [], memories: [], graphStats: null, recentAudit: [], lessons: [], crystals: [] },
dashboard: { loaded: false, health: null, sessions: [], memories: [], graphStats: null, recentAudit: [], lessons: [], crystals: [], semantic: [], procedural: [], relations: [] },
graph: { loaded: false, nodes: [], edges: [], stats: null, filters: {}, selectedNode: null },
memories: { loaded: false, items: [], search: '', typeFilter: '' },
timeline: { loaded: false, observations: [], sessionId: '', minImportance: 0, page: 0, pageSize: 50 },
Expand Down Expand Up @@ -1039,6 +1039,18 @@ <h1>agentmemory</h1>
if (!s) return '';
return s.length > n ? s.slice(0, n) + '...' : s;
}
function asArray(value) {
return Array.isArray(value) ? value : [];
}
function firstArray() {
for (var i = 0; i < arguments.length; i++) {
if (Array.isArray(arguments[i])) return arguments[i];
}
return [];
}
function asString(value) {
return value === undefined || value === null ? '' : String(value);
}
function debounce(fn, ms) {
var t;
return function() {
Expand Down Expand Up @@ -1128,15 +1140,15 @@ <h1>agentmemory</h1>
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.sessions = asArray(results[1] && results[1].sessions);
state.dashboard.memories = asArray(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].facts) || (results[5] && results[5].semantic) || [];
state.dashboard.procedural = (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.recentAudit = asArray(results[4] && results[4].entries);
state.dashboard.semantic = firstArray(results[5] && results[5].facts, results[5] && results[5].semantic);
state.dashboard.procedural = firstArray(results[6] && results[6].procedures, results[6] && results[6].procedural);
state.dashboard.lessons = asArray(results[8] && results[8].lessons);
state.dashboard.crystals = asArray(results[9] && results[9].crystals);
state.dashboard.relations = asArray(results[7] && results[7].relations);
state.dashboard.loaded = true;
renderDashboard();
} catch (err) {
Expand Down Expand Up @@ -1167,17 +1179,20 @@ <h1>agentmemory</h1>
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 sessions = asArray(d.sessions).filter(function(s) { return s && typeof s === 'object'; });
var memories = asArray(d.memories).filter(function(m) { return m && typeof m === 'object'; });
var recentAudit = asArray(d.recentAudit).filter(function(a) { return a && typeof a === 'object'; });
var activeSessions = 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 fMetrics = asArray(h.functionMetrics).filter(function(m) { return m && typeof m === 'object'; });
var cb = h.circuitBreaker || null;
var workers = snap.workers || [];
var workers = asArray(snap.workers).filter(function(w) { return w && typeof w === 'object'; });

var html = '';
// First-run hero: empty dashboard = guided next step
if (d.sessions.length === 0) {
if (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>' +
Expand All @@ -1187,10 +1202,10 @@ <h1>agentmemory</h1>
'</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">Sessions</div><div class="value">' + sessions.length + '</div><div class="sub">' + activeSessions + ' active</div></div>';
html += '<div class="stat-card"><div class="label">Memories</div><div class="value">' + memories.length + '</div><div class="sub">latest versions</div></div>';
var lessonCount = asArray(d.lessons).length;
var crystalCount = asArray(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>';
Expand All @@ -1203,10 +1218,10 @@ <h1>agentmemory</h1>
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 totalObs = 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 estInjected = 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);
Expand Down Expand Up @@ -1252,17 +1267,19 @@ <h1>agentmemory</h1>
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) {
var alerts = asArray(snap.alerts);
if (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 (' + alerts.length + ')</div>';
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) {
var notes = asArray(snap.notes);
if (notes.length > 0) {
html += '<div class="card" style="margin-bottom:16px;"><div class="card-title" style="color:var(--ink-muted);">Notes (' + notes.length + ')</div>';
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>';
Expand All @@ -1271,14 +1288,16 @@ <h1>agentmemory</h1>
html += '<div class="two-col">';

html += '<div class="card"><div class="card-title">Recent Sessions</div>';
if (d.sessions.length === 0) {
if (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);
var recent = 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(s.project ? s.project.split('/').pop() : s.id.slice(0,8)) + '</td>';
var sessionId = asString(s.id || s.sessionId);
var projectLabel = s.project ? asString(s.project).split('/').pop() : truncate(sessionId, 8);
html += '<tr><td style="color:var(--ink);font-weight:500;">' + esc(projectLabel || 'unknown') + '</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>';
Expand All @@ -1288,10 +1307,10 @@ <h1>agentmemory</h1>
html += '</div>';

html += '<div class="card"><div class="card-title">Recent Activity</div>';
if (d.recentAudit.length === 0) {
if (recentAudit.length === 0) {
html += '<div class="empty-state"><p>No activity recorded yet</p></div>';
} else {
d.recentAudit.forEach(function(a) {
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> ';
Expand Down Expand Up @@ -1346,9 +1365,9 @@ <h1>agentmemory</h1>
html += '</div>';
}

var semFacts = d.semantic || [];
var procItems = d.procedural || [];
var relItems = d.relations || [];
var semFacts = asArray(d.semantic).filter(function(f) { return f && typeof f === 'object'; });
var procItems = asArray(d.procedural).filter(function(p) { return p && typeof p === 'object'; });
var relItems = asArray(d.relations).filter(function(r) { return r && typeof r === 'object'; });

html += '<hr class="section-rule">';
html += '<div class="two-col">';
Expand Down Expand Up @@ -1380,10 +1399,11 @@ <h1>agentmemory</h1>
html += '<div style="font-weight:600;color:var(--ink);font-family:var(--font-display);font-size:13px;">' + esc(p.name || p.title || 'Procedure') + '</div>';
if (p.trigger || p.triggerCondition) html += '<div style="font-size:11px;color:var(--ink-faint);font-family:var(--font-mono);margin-top:2px;">Trigger: ' + esc(p.trigger || p.triggerCondition) + '</div>';
if (p.frequency) html += '<div style="font-size:11px;color:var(--ink-faint);margin-top:2px;">Freq: ' + p.frequency + '</div>';
if (p.steps && p.steps.length > 0) {
var steps = asArray(p.steps);
if (steps.length > 0) {
html += '<ol class="procedure-steps">';
p.steps.slice(0, 4).forEach(function(s) { html += '<li>' + esc(typeof s === 'string' ? s : s.description || s.action || JSON.stringify(s)) + '</li>'; });
if (p.steps.length > 4) html += '<li style="color:var(--ink-faint);font-style:italic;">+ ' + (p.steps.length - 4) + ' more...</li>';
steps.slice(0, 4).forEach(function(s) { html += '<li>' + esc(typeof s === 'string' ? s : s.description || s.action || JSON.stringify(s)) + '</li>'; });
if (steps.length > 4) html += '<li style="color:var(--ink-faint);font-style:italic;">+ ' + (steps.length - 4) + ' more...</li>';
html += '</ol>';
}
html += '</div>';
Expand Down
168 changes: 168 additions & 0 deletions test/viewer-dashboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
import { describe, expect, it, vi } from "vitest";

type ViewerElement = {
innerHTML: string;
textContent: string;
value: string;
dataset: Record<string, string>;
classList: {
toggle: ReturnType<typeof vi.fn>;
add: ReturnType<typeof vi.fn>;
remove: ReturnType<typeof vi.fn>;
};
addEventListener: ReturnType<typeof vi.fn>;
removeEventListener: ReturnType<typeof vi.fn>;
querySelector: ReturnType<typeof vi.fn>;
querySelectorAll: ReturnType<typeof vi.fn>;
};

function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}

function loadViewerScript(): string {
const html = readFileSync(
new URL("../src/viewer/index.html", import.meta.url),
"utf8",
);
const match = html.match(
/<script nonce="__AGENTMEMORY_VIEWER_NONCE__">([\s\S]*?)<\/script>/,
);
if (!match) throw new Error("viewer script not found");

return match[1].replace(
/\s+loadTab\('dashboard'\);\s+connectWs\(\);\s+startDashboardAutoRefresh\(\);\s*$/,
"\n",
);
}

function createViewerSandbox() {
const elements = new Map<string, ViewerElement>();
const getElement = (id: string) => {
if (!elements.has(id)) {
elements.set(id, {
innerHTML: "",
textContent: "",
value: "",
dataset: {},
classList: { toggle: vi.fn(), add: vi.fn(), remove: vi.fn() },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
querySelector: vi.fn(() => null),
querySelectorAll: vi.fn(() => []),
});
}
return elements.get(id);
};

const sandbox: Record<string, unknown> = {
console: { log: vi.fn(), warn: vi.fn(), error: vi.fn() },
URLSearchParams,
Date,
JSON,
Math,
Number,
Object,
Promise,
String,
Array,
parseInt,
setTimeout,
clearTimeout,
setInterval: vi.fn(() => 0),
clearInterval: vi.fn(),
fetch: vi.fn(),
alert: vi.fn(),
localStorage: { getItem: vi.fn(() => null), setItem: vi.fn() },
WebSocket: vi.fn(),
document: {
documentElement: { dataset: {} },
addEventListener: vi.fn(),
querySelectorAll: vi.fn(() => []),
getElementById: vi.fn(getElement),
createElement: vi.fn(() => {
const node: { innerHTML: string; value: string; textContent?: string } = {
innerHTML: "",
value: "",
};
Object.defineProperty(node, "textContent", {
get: () => node.value,
set(value: unknown) {
node.value = String(value);
node.innerHTML = escapeHtml(String(value));
},
});
return node;
}),
},
};

sandbox.window = sandbox;
sandbox.location = {
search: "",
protocol: "http:",
hostname: "localhost",
port: "3113",
host: "localhost:3113",
origin: "http://localhost:3113",
};
sandbox.matchMedia = vi.fn(() => ({ matches: false }));

return { sandbox, elements };
}

describe("viewer dashboard", () => {
it("renders partial dashboard payloads without crashing", () => {
const { sandbox, elements } = createViewerSandbox();
runInNewContext(loadViewerScript(), sandbox, { filename: "viewer.html" });

const state = sandbox.state as { dashboard: unknown };
state.dashboard = {
loaded: true,
health: {
status: "healthy",
health: {
connectionState: "connected",
alerts: { malformed: true },
notes: "not-an-array",
},
functionMetrics: undefined,
},
sessions: [
{
status: "completed",
observationCount: 2,
startedAt: "2026-05-14T03:00:00.000Z",
},
null,
{
id: "ses_good",
project: "C:/work/app",
status: "active",
observationCount: 1,
startedAt: "2026-05-14T04:00:00.000Z",
},
],
memories: { malformed: true },
graphStats: { nodes: 1, edges: 0 },
recentAudit: undefined,
lessons: undefined,
crystals: undefined,
semantic: { malformed: true },
procedural: [{ title: "Recovered procedure", steps: undefined }],
relations: [{ type: "related" }],
};

expect(() => (sandbox.renderDashboard as () => void)()).not.toThrow();
const dashboard = elements.get("view-dashboard");
expect(dashboard?.innerHTML).toContain("Recent Sessions");
expect(dashboard?.innerHTML).toContain("unknown");
expect(dashboard?.innerHTML).toContain("Recovered procedure");
});
});