pi-context-engine 0.5.3 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,403 @@
1
+ /**
2
+ * Single-file dashboard SPA: dark theme, system fonts, hand-rolled SVG charts,
3
+ * zero external requests (no CDN fonts, no chart libs) — consistent with the
4
+ * package's all-local ethos. Client JS uses string concatenation (no template
5
+ * literals) so this file stays a plain TS template literal.
6
+ */
7
+ export const PAGE_HTML = `<!doctype html>
8
+ <html lang="en">
9
+ <head>
10
+ <meta charset="utf-8">
11
+ <meta name="viewport" content="width=device-width, initial-scale=1">
12
+ <title>pi-context-engine · dashboard</title>
13
+ <style>
14
+ :root {
15
+ --bg: #0d1117; --panel: #161b22; --panel2: #1c2129; --border: #2d333b;
16
+ --text: #e6edf3; --muted: #8b949e; --accent: #4493f8; --green: #3fb950;
17
+ --warn: #d29922; --red: #f85149; --mono: ui-monospace, 'Cascadia Code', Consolas, monospace;
18
+ }
19
+ * { box-sizing: border-box; }
20
+ body { margin: 0; background: var(--bg); color: var(--text);
21
+ font: 14px/1.5 -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; }
22
+ a { color: var(--accent); text-decoration: none; }
23
+ header { display: flex; align-items: center; gap: 14px; padding: 12px 20px;
24
+ border-bottom: 1px solid var(--border); background: var(--panel); flex-wrap: wrap; }
25
+ header h1 { font-size: 15px; margin: 0; font-weight: 600; }
26
+ header .sub { color: var(--muted); font-size: 12px; }
27
+ .badge { font-size: 11px; padding: 2px 9px; border-radius: 10px; border: 1px solid var(--border);
28
+ color: var(--muted); display: inline-flex; align-items: center; gap: 6px; }
29
+ .badge .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); }
30
+ .badge.live .dot { background: var(--green); }
31
+ .badge.indexing .dot { background: var(--warn); animation: pulse 1.2s infinite; }
32
+ @keyframes pulse { 50% { opacity: .35; } }
33
+ .spacer { flex: 1; }
34
+ .btn { font-size: 12px; padding: 5px 12px; border-radius: 6px; border: 1px solid var(--border);
35
+ background: var(--panel2); color: var(--text); cursor: pointer; }
36
+ .btn:hover { border-color: var(--accent); }
37
+ nav { display: flex; gap: 2px; padding: 0 20px; border-bottom: 1px solid var(--border); background: var(--panel); }
38
+ nav button { appearance: none; border: none; background: none; color: var(--muted); font: inherit;
39
+ padding: 9px 14px; cursor: pointer; border-bottom: 2px solid transparent; }
40
+ nav button.active { color: var(--text); border-bottom-color: var(--accent); }
41
+ nav button:hover { color: var(--text); }
42
+ main { padding: 20px; max-width: 1200px; margin: 0 auto; }
43
+ .grid { display: grid; gap: 14px; }
44
+ .cards { grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); }
45
+ .card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; }
46
+ .card .k { color: var(--muted); font-size: 12px; }
47
+ .card .v { font-size: 22px; font-weight: 650; margin-top: 2px; }
48
+ .card .s { color: var(--muted); font-size: 11px; margin-top: 2px; }
49
+ .panel { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 16px; }
50
+ .panel h2 { font-size: 13px; margin: 0 0 12px; color: var(--muted); font-weight: 600;
51
+ text-transform: uppercase; letter-spacing: .04em; }
52
+ .two-col { grid-template-columns: 1fr 1fr; }
53
+ @media (max-width: 860px) { .two-col { grid-template-columns: 1fr; } }
54
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
55
+ th { text-align: left; color: var(--muted); font-weight: 500; font-size: 12px;
56
+ padding: 6px 10px; border-bottom: 1px solid var(--border); }
57
+ td { padding: 6px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
58
+ tr:last-child td { border-bottom: none; }
59
+ td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
60
+ code, .path { font-family: var(--mono); font-size: 12px; }
61
+ .path { word-break: break-all; }
62
+ .bar-track { background: var(--panel2); border-radius: 4px; height: 8px; overflow: hidden; min-width: 60px; }
63
+ .bar-fill { height: 100%; background: var(--accent); border-radius: 4px; }
64
+ .bar-fill.green { background: var(--green); }
65
+ .muted { color: var(--muted); }
66
+ .empty { color: var(--muted); padding: 18px; text-align: center; }
67
+ .donut-wrap { display: flex; align-items: center; gap: 22px; flex-wrap: wrap; }
68
+ .donut-legend { display: flex; flex-direction: column; gap: 6px; font-size: 13px; }
69
+ .donut-legend b { font-size: 16px; }
70
+ .cfg-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 4px 18px;
71
+ font-size: 12.5px; color: var(--muted); }
72
+ .cfg-list b { color: var(--text); font-weight: 500; }
73
+ .vbars { display: flex; align-items: flex-end; gap: 3px; height: 120px; padding-top: 8px; }
74
+ .vbars .col { flex: 1; display: flex; flex-direction: column; justify-content: flex-end; height: 100%; }
75
+ .vbars .col > div { background: var(--accent); border-radius: 3px 3px 0 0; min-height: 2px; }
76
+ .vbars .col:hover { outline: 1px solid var(--border); }
77
+ .sess-item { padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px;
78
+ cursor: pointer; background: var(--panel2); }
79
+ .sess-item:hover { border-color: var(--accent); }
80
+ .sess-item.active { border-color: var(--accent); }
81
+ .sess-item .when { color: var(--muted); font-size: 11.5px; }
82
+ .turn { border-left: 2px solid var(--border); padding: 6px 12px; margin: 8px 0; }
83
+ .turn .tier { font-size: 10.5px; color: var(--accent); text-transform: uppercase; letter-spacing: .05em; }
84
+ .ev { display: inline-block; font-family: var(--mono); font-size: 11px; background: var(--panel2);
85
+ border: 1px solid var(--border); border-radius: 4px; padding: 1px 6px; margin: 2px 4px 2px 0; }
86
+ .decision { border-left: 3px solid var(--green); padding: 8px 12px; margin-bottom: 10px;
87
+ background: var(--panel2); border-radius: 0 8px 8px 0; }
88
+ .decision .why { color: var(--muted); font-size: 12.5px; margin-top: 3px; }
89
+ .decision .when { color: var(--muted); font-size: 11px; }
90
+ #toast { position: fixed; bottom: 18px; left: 50%; transform: translateX(-50%);
91
+ background: var(--panel2); border: 1px solid var(--border); color: var(--text);
92
+ padding: 8px 16px; border-radius: 8px; font-size: 12.5px; display: none; }
93
+ </style>
94
+ </head>
95
+ <body>
96
+ <header>
97
+ <h1>pi-context-engine</h1>
98
+ <span class="sub" id="project">…</span>
99
+ <span class="badge" id="state"><span class="dot"></span><span id="state-text">connecting</span></span>
100
+ <span class="spacer"></span>
101
+ <span class="sub" id="counts"></span>
102
+ <a class="btn" href="/api/export" download>Export JSON</a>
103
+ </header>
104
+ <nav id="nav"></nav>
105
+ <main id="main"><div class="empty">Loading…</div></main>
106
+ <div id="toast"></div>
107
+ <script>
108
+ 'use strict';
109
+ var TABS = ['overview', 'files', 'sessions', 'memory', 'savings'];
110
+ var state = { tab: 'overview', sessionId: null, status: null };
111
+ var pollTimer = null;
112
+
113
+ function $(id) { return document.getElementById(id); }
114
+ function esc(s) {
115
+ return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
116
+ return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
117
+ });
118
+ }
119
+ function fmt(n) { return Number(n || 0).toLocaleString(); }
120
+ function fmtK(n) {
121
+ n = Number(n || 0);
122
+ if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B';
123
+ if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
124
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
125
+ return String(n);
126
+ }
127
+ function fmtUsd(v) {
128
+ if (!isFinite(v)) return '$0';
129
+ if (v > 0 && v < 0.01) return '$' + v.toFixed(4);
130
+ return '$' + v.toFixed(2);
131
+ }
132
+ function fmtWhen(iso) {
133
+ if (!iso) return '—';
134
+ var d = new Date(iso);
135
+ if (isNaN(d)) return String(iso);
136
+ return d.toLocaleString();
137
+ }
138
+ function api(path) {
139
+ return fetch(path).then(function (r) {
140
+ if (!r.ok) return r.json().then(function (b) { throw new Error(b.error || ('HTTP ' + r.status)); });
141
+ return r.json();
142
+ });
143
+ }
144
+ function toast(msg) {
145
+ var t = $('toast');
146
+ t.textContent = msg;
147
+ t.style.display = 'block';
148
+ clearTimeout(toast._h);
149
+ toast._h = setTimeout(function () { t.style.display = 'none'; }, 3500);
150
+ }
151
+ function pctBar(v, max, cls) {
152
+ var w = max > 0 ? Math.max(1, Math.round((v / max) * 100)) : 0;
153
+ return '<div class="bar-track"><div class="bar-fill ' + (cls || '') + '" style="width:' + w + '%"></div></div>';
154
+ }
155
+
156
+ /* ── header / status poll ─────────────────────────────────────────── */
157
+ function renderStatus(st) {
158
+ state.status = st;
159
+ $('project').textContent = st.project + ' · ' + st.projectDir;
160
+ var badge = $('state');
161
+ if (st.indexing) { badge.className = 'badge indexing'; $('state-text').textContent = 'indexing…'; }
162
+ else if (st.chunks > 0) { badge.className = 'badge live'; $('state-text').textContent = 'live'; }
163
+ else { badge.className = 'badge'; $('state-text').textContent = 'no index'; }
164
+ $('counts').textContent = fmt(st.chunks) + ' chunks · ' + fmt(st.files) + ' files';
165
+ if (state.tab === 'overview') renderOverview(st);
166
+ }
167
+
168
+ function pollStatus() {
169
+ api('/api/status').then(renderStatus).catch(function (e) {
170
+ $('state-text').textContent = 'offline';
171
+ if (e && e.message) toast(e.message);
172
+ });
173
+ }
174
+
175
+ /* ── tabs ─────────────────────────────────────────────────────────── */
176
+ function renderNav() {
177
+ var nav = $('nav');
178
+ nav.innerHTML = '';
179
+ TABS.forEach(function (t) {
180
+ var b = document.createElement('button');
181
+ b.textContent = t.charAt(0).toUpperCase() + t.slice(1);
182
+ b.className = t === state.tab ? 'active' : '';
183
+ b.onclick = function () { switchTab(t); };
184
+ nav.appendChild(b);
185
+ });
186
+ }
187
+
188
+ function switchTab(tab) {
189
+ state.tab = tab;
190
+ renderNav();
191
+ $('main').innerHTML = '<div class="empty">Loading…</div>';
192
+ if (tab === 'overview') { if (state.status) renderOverview(state.status); else pollStatus(); }
193
+ else if (tab === 'files') loadFiles();
194
+ else if (tab === 'sessions') loadSessions();
195
+ else if (tab === 'memory') loadMemory();
196
+ else if (tab === 'savings') loadSavings();
197
+ }
198
+
199
+ function setMain(html) { $('main').innerHTML = html; }
200
+
201
+ /* ── overview ─────────────────────────────────────────────────────── */
202
+ function donut(pct) {
203
+ var r = 52, c = 2 * Math.PI * r;
204
+ var p = Math.max(0, Math.min(100, pct || 0));
205
+ return '<svg width="130" height="130" viewBox="0 0 130 130">' +
206
+ '<circle cx="65" cy="65" r="' + r + '" fill="none" stroke="#21262d" stroke-width="14"/>' +
207
+ '<circle cx="65" cy="65" r="' + r + '" fill="none" stroke="#3fb950" stroke-width="14" ' +
208
+ 'stroke-linecap="round" stroke-dasharray="' + (c * p / 100).toFixed(1) + ' ' + c.toFixed(1) + '" ' +
209
+ 'transform="rotate(-90 65 65)"/>' +
210
+ '<text x="65" y="62" text-anchor="middle" fill="#e6edf3" font-size="24" font-weight="650">' + p + '%</text>' +
211
+ '<text x="65" y="82" text-anchor="middle" fill="#8b949e" font-size="10">tokens saved</text></svg>';
212
+ }
213
+
214
+ function renderOverview(st) {
215
+ var s = st.stats;
216
+ var pct = s.savingsPct == null ? 0 : s.savingsPct;
217
+ setMain(
218
+ '<div class="grid" style="margin-bottom:14px">' +
219
+ '<div class="panel"><div class="donut-wrap">' + donut(pct) +
220
+ '<div class="donut-legend">' +
221
+ '<div><b>' + fmtK(s.fullFileTokensSaved) + '</b> input tokens saved <span class="muted">(full-file baseline)</span></div>' +
222
+ '<div class="muted">' + fmt(s.fullFileTokens) + ' full-file → ' + fmt(s.servedTokens) + ' served</div>' +
223
+ '<div class="muted">' + fmt(s.queries) + ' quer' + (s.queries === 1 ? 'y' : 'ies') + ' recorded' +
224
+ (s.savingsPct == null ? ' — savings appear after the first context_search' : '') + '</div>' +
225
+ '</div></div></div>' +
226
+ '<div class="grid cards">' +
227
+ card('Chunks', fmt(st.chunks), st.files + ' files indexed') +
228
+ card('Queries', fmt(s.queries), 'this project, all sessions') +
229
+ card('Served', fmtK(s.servedTokens), 'raw would be ' + fmtK(s.rawTokens)) +
230
+ card('Index state', st.indexing ? 'Indexing…' : (st.chunks > 0 ? 'Live' : 'Empty'),
231
+ 'watch ' + (st.config.watch ? 'on' : 'off') + ' · auto ' + (st.config.autoIndex ? 'on' : 'off')) +
232
+ '</div>' +
233
+ '</div>' +
234
+ '<div class="panel"><h2>Configuration</h2><div class="cfg-list">' +
235
+ cfg('Embedding', st.config.embeddingModel) +
236
+ cfg('Chunking', st.config.chunking) +
237
+ cfg('Compression', st.config.compression) +
238
+ cfg('Top-K', st.config.topK) +
239
+ cfg('Rerank', st.config.rerank) +
240
+ cfg('Storage', '<span class="path">' + esc(st.storageDir) + '</span>') +
241
+ '</div></div>'
242
+ );
243
+ }
244
+ function card(k, v, s) {
245
+ return '<div class="card"><div class="k">' + k + '</div><div class="v">' + v + '</div>' +
246
+ (s ? '<div class="s">' + s + '</div>' : '') + '</div>';
247
+ }
248
+ function cfg(k, v) { return '<div>' + esc(k) + ': <b>' + v + '</b></div>'; }
249
+
250
+ /* ── files ────────────────────────────────────────────────────────── */
251
+ function loadFiles() {
252
+ api('/api/files').then(function (d) {
253
+ if (d.files.length === 0) { setMain('<div class="empty">No files indexed yet.</div>'); return; }
254
+ var max = d.files[0].chunks;
255
+ var rows = d.files.map(function (f) {
256
+ return '<tr><td class="path">' + esc(f.filePath) + '</td>' +
257
+ '<td class="num" style="width:70px">' + fmt(f.chunks) + '</td>' +
258
+ '<td style="width:180px">' + pctBar(f.chunks, max) + '</td></tr>';
259
+ }).join('');
260
+ setMain('<div class="panel"><h2>' + fmt(d.total) + ' files · ' + fmt(d.chunks) + ' chunks</h2>' +
261
+ '<table><tr><th>File</th><th class="num">Chunks</th><th></th></tr>' + rows + '</table></div>');
262
+ }).catch(errPanel);
263
+ }
264
+
265
+ /* ── sessions ─────────────────────────────────────────────────────── */
266
+ function loadSessions() {
267
+ api('/api/sessions').then(function (d) {
268
+ if (d.sessions.length === 0) { setMain('<div class="empty">No sessions recorded yet.</div>'); return; }
269
+ var items = d.sessions.map(function (s) {
270
+ var active = s.id === d.activeSessionId;
271
+ return '<div class="sess-item' + (s.id === state.sessionId ? ' active' : '') + '" data-id="' + esc(s.id) + '">' +
272
+ '<div><b>' + esc(s.project) + '</b> <span class="muted">· ' + esc(s.status) +
273
+ (active ? ' · current' : '') + '</span></div>' +
274
+ '<div class="when">' + fmtWhen(s.started_at) + ' · ' + s.promptCount + (s.promptCount === 1 ? ' prompt' : ' prompts') +
275
+ (s.rollup ? ' · ' + esc(String(s.rollup).slice(0, 80)) : '') + '</div></div>';
276
+ }).join('');
277
+ setMain('<div class="grid two-col"><div class="grid" id="sess-list" style="gap:8px">' + items + '</div>' +
278
+ '<div class="panel" id="sess-detail"><div class="empty">Select a session</div></div></div>');
279
+ Array.prototype.forEach.call(document.querySelectorAll('.sess-item'), function (el) {
280
+ el.onclick = function () { openSession(el.getAttribute('data-id')); };
281
+ });
282
+ if (!state.sessionId) openSession(d.sessions[0].id, true);
283
+ else openSession(state.sessionId, true);
284
+ }).catch(errPanel);
285
+ }
286
+
287
+ function openSession(id, silent) {
288
+ state.sessionId = id;
289
+ Array.prototype.forEach.call(document.querySelectorAll('.sess-item'), function (el) {
290
+ el.className = 'sess-item' + (el.getAttribute('data-id') === id ? ' active' : '');
291
+ });
292
+ api('/api/sessions/' + encodeURIComponent(id)).then(function (d) {
293
+ var box = $('sess-detail');
294
+ if (!box) return;
295
+ var html = '';
296
+ if (d.session) {
297
+ html += '<h2>' + esc(d.session.project) + ' · ' + esc(d.session.status) + '</h2>' +
298
+ '<div class="muted" style="font-size:12px;margin-bottom:8px">' + fmtWhen(d.session.started_at) +
299
+ ' → ' + (d.session.ended_at ? fmtWhen(d.session.ended_at) : 'open') + '</div>';
300
+ if (d.session.rollup) html += '<div class="turn">' + esc(d.session.rollup) + '</div>';
301
+ }
302
+ if (d.turns.length === 0 && d.events.length === 0) {
303
+ html += '<div class="empty">No turns or events captured for this session.</div>';
304
+ }
305
+ if (d.turns.length > 0) {
306
+ html += '<h2 style="margin-top:14px">Turns</h2>';
307
+ d.turns.forEach(function (t) {
308
+ html += '<div class="turn"><div class="tier">turn ' + t.promptNumber + ' · ' + esc(t.tier) + '</div>' +
309
+ esc(t.summary) + '</div>';
310
+ });
311
+ }
312
+ if (d.events.length > 0) {
313
+ html += '<h2 style="margin-top:14px">' + d.events.length + ' tool events</h2><div>';
314
+ d.events.forEach(function (e) {
315
+ html += '<span class="ev" title="' + esc(e.createdAt) + '">' + esc(e.tool) + '#' + e.id +
316
+ (e.hasPayload ? '' : ' ·') + '</span>';
317
+ });
318
+ html += '</div><div class="muted" style="font-size:11px;margin-top:6px">· = payload aged out, summary only</div>';
319
+ }
320
+ box.innerHTML = html;
321
+ }).catch(function (e) { if (!silent) toast(e.message); });
322
+ }
323
+
324
+ /* ── memory ───────────────────────────────────────────────────────── */
325
+ function loadMemory() {
326
+ api('/api/decisions').then(function (d) {
327
+ var html = '<div class="grid two-col"><div class="panel"><h2>Decisions</h2>';
328
+ if (d.decisions.length === 0 && d.consolidated.length === 0) {
329
+ html += '<div class="empty">No recorded decisions yet. The agent saves them with record_decision.</div>';
330
+ }
331
+ d.decisions.forEach(function (x) {
332
+ html += '<div class="decision"><div>' + esc(x.decision) +
333
+ ' <span class="muted">· ' + esc(x.source) + '</span></div>' +
334
+ '<div class="why">' + esc(x.reason) + '</div>' +
335
+ '<div class="when">' + esc(x.createdAt) + '</div></div>';
336
+ });
337
+ d.consolidated.forEach(function (x) {
338
+ html += '<div class="decision"><div>' + esc(x.decision) + ' <span class="muted">· archive</span></div>' +
339
+ '<div class="why">' + esc(x.reason) + '</div></div>';
340
+ });
341
+ html += '</div><div class="panel"><h2>Code areas</h2>';
342
+ if (d.codeAreas.length === 0) html += '<div class="empty">No recorded code areas yet.</div>';
343
+ else {
344
+ html += '<table><tr><th>File</th><th>Description</th></tr>';
345
+ d.codeAreas.forEach(function (x) {
346
+ html += '<tr><td class="path">' + esc(x.filePath) + '</td><td>' + esc(x.description) + '</td></tr>';
347
+ });
348
+ html += '</table>';
349
+ }
350
+ html += '</div></div>';
351
+ setMain(html);
352
+ }).catch(errPanel);
353
+ }
354
+
355
+ /* ── savings ──────────────────────────────────────────────────────── */
356
+ function loadSavings() {
357
+ api('/api/savings').then(function (d) {
358
+ var t = d.totals;
359
+ var html = '<div class="grid cards" style="margin-bottom:14px">' +
360
+ card('Saved', fmtK(t.savedTokens), 'full-file baseline') +
361
+ card('Estimated value', fmtUsd(t.savedUsd), d.pricing.model + ' input @ $' + d.pricing.inputUsdPer1M + '/1M') +
362
+ card('Savings rate', t.savingsPct == null ? '—' : t.savingsPct + '%', 'served vs full files') +
363
+ '</div>';
364
+ if (d.buckets.length > 0) {
365
+ var max = Math.max.apply(null, d.buckets.map(function (b) { return b.saved; }));
366
+ var rows = d.buckets.map(function (b) {
367
+ return '<tr><td>' + esc(b.bucket) + '</td><td class="num">' + fmt(b.calls) + '</td>' +
368
+ '<td class="num">' + fmtK(b.baseline) + '</td><td class="num">' + fmtK(b.served) + '</td>' +
369
+ '<td class="num">' + fmtK(b.saved) + '</td><td class="num">' + fmtUsd(b.savedUsd) + '</td>' +
370
+ '<td style="width:160px">' + pctBar(b.saved, max, 'green') + '</td></tr>';
371
+ }).join('');
372
+ html += '<div class="panel" style="margin-bottom:14px"><h2>By bucket</h2><table>' +
373
+ '<tr><th>Bucket</th><th class="num">Calls</th><th class="num">Baseline</th><th class="num">Served</th>' +
374
+ '<th class="num">Saved</th><th class="num">Value</th><th></th></tr>' + rows + '</table></div>';
375
+ }
376
+ if (d.byDay.length > 0) {
377
+ var dmax = Math.max.apply(null, d.byDay.map(function (x) { return x.saved; }));
378
+ var cols = d.byDay.map(function (x) {
379
+ var h = dmax > 0 ? Math.max(2, Math.round((x.saved / dmax) * 100)) : 2;
380
+ return '<div class="col" title="' + x.day + ': ' + fmt(x.saved) + ' tokens"><div style="height:' + h + '%"></div></div>';
381
+ }).join('');
382
+ html += '<div class="panel"><h2>Saved tokens · last 30 days</h2><div class="vbars">' + cols + '</div>' +
383
+ '<div class="muted" style="display:flex;justify-content:space-between;font-size:11px;margin-top:6px">' +
384
+ '<span>' + esc(d.byDay[0].day) + '</span><span>' + esc(d.byDay[d.byDay.length - 1].day) + '</span></div></div>';
385
+ }
386
+ if (d.buckets.length === 0 && d.byDay.length === 0) {
387
+ html = '<div class="empty">No savings recorded yet — they accumulate as the agent uses context_search.</div>';
388
+ }
389
+ setMain(html);
390
+ }).catch(errPanel);
391
+ }
392
+
393
+ function errPanel(e) {
394
+ setMain('<div class="empty">Failed to load: ' + esc(e && e.message ? e.message : e) + '</div>');
395
+ }
396
+
397
+ /* ── boot ─────────────────────────────────────────────────────────── */
398
+ renderNav();
399
+ pollStatus();
400
+ setInterval(pollStatus, 5000);
401
+ </script>
402
+ </body>
403
+ </html>`;
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Local read-only dashboard server.
3
+ *
4
+ * Binds 127.0.0.1 only, serves GET/HEAD requests exclusively, and validates
5
+ * the Host header so a page loaded from any other origin (including a
6
+ * DNS-rebinding domain pointing at 127.0.0.1) gets rejected. There are no
7
+ * mutating endpoints by design — every state change goes through pi
8
+ * commands, so no CSRF/token machinery is needed.
9
+ */
10
+ import http from "node:http";
11
+ import type { ContextEngine } from "../engine.js";
12
+ import {
13
+ decisionsPayload,
14
+ exportPayload,
15
+ filesPayload,
16
+ sessionsPayload,
17
+ statusPayload,
18
+ timelinePayload,
19
+ savingsPayload,
20
+ type ExtDashboardState,
21
+ } from "./api.js";
22
+ import { PAGE_HTML } from "./page.js";
23
+
24
+ export interface DashboardServerOptions {
25
+ /** Live-engine accessor; returning null makes every /api route report 503. */
26
+ engine: () => ContextEngine | null;
27
+ /** Live background-indexing flag for the status poll. */
28
+ indexing: () => boolean;
29
+ /** TCP port; 0 (default) picks a free port. */
30
+ port?: number;
31
+ log?: (msg: string, level: "info" | "warning") => void;
32
+ }
33
+
34
+ const MAX_SESSION_ID_CHARS = 200;
35
+
36
+ const sendJson = (res: http.ServerResponse, code: number, body: unknown, extra: Record<string, string> = {}): void => {
37
+ const text = JSON.stringify(body);
38
+ res.writeHead(code, {
39
+ "Content-Type": "application/json; charset=utf-8",
40
+ "Cache-Control": "no-store",
41
+ ...extra,
42
+ });
43
+ res.end(text);
44
+ };
45
+
46
+ const clampLimit = (raw: string | null, fallback: number, max: number): number => {
47
+ const n = Number(raw);
48
+ if (!Number.isFinite(n) || n <= 0) return fallback;
49
+ return Math.min(Math.floor(n), max);
50
+ };
51
+
52
+ export class DashboardServer {
53
+ private server: http.Server | null = null;
54
+ private baseUrlValue: string | null = null;
55
+
56
+ get url(): string | null {
57
+ return this.baseUrlValue;
58
+ }
59
+
60
+ get running(): boolean {
61
+ return this.server !== null;
62
+ }
63
+
64
+ /** Idempotent: returns the existing URL when already listening. */
65
+ start(options: DashboardServerOptions): Promise<string> {
66
+ if (this.server && this.baseUrlValue) return Promise.resolve(this.baseUrlValue);
67
+ return new Promise((resolve, reject) => {
68
+ const server = http.createServer((req, res) => {
69
+ try {
70
+ this.handle(req, res, options);
71
+ } catch (err) {
72
+ // handle() is sync-only except for the async payload builders it
73
+ // routes through sendPayload; this is a last-resort guard.
74
+ sendJson(res, 500, { error: "internal error" });
75
+ options.log?.(`dashboard request failed: ${(err as Error).message}`, "warning");
76
+ }
77
+ });
78
+ server.on("clientError", (_err, socket) => {
79
+ // Malformed HTTP (scanners, port probes): drop the socket, never crash.
80
+ if (socket.writable) socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
81
+ else socket.destroy();
82
+ });
83
+ server.on("error", (err) => {
84
+ this.server = null;
85
+ this.baseUrlValue = null;
86
+ reject(err);
87
+ });
88
+ const port = options.port ?? 0;
89
+ server.listen(port, "127.0.0.1", () => {
90
+ const address = server.address();
91
+ if (!address || typeof address === "string") {
92
+ reject(new Error("dashboard server got an unexpected address"));
93
+ server.close();
94
+ return;
95
+ }
96
+ this.server = server;
97
+ this.baseUrlValue = `http://127.0.0.1:${address.port}`;
98
+ resolve(this.baseUrlValue);
99
+ });
100
+ });
101
+ }
102
+
103
+ async stop(): Promise<void> {
104
+ const server = this.server;
105
+ if (!server) return;
106
+ this.server = null;
107
+ this.baseUrlValue = null;
108
+ // Node 18.2+: terminate keep-alive sockets so close() is deterministic
109
+ // even with a browser tab still polling.
110
+ (server as unknown as { closeAllConnections?: () => void }).closeAllConnections?.();
111
+ await new Promise<void>((resolve) => {
112
+ server.close(() => resolve());
113
+ });
114
+ }
115
+
116
+ private allowedHost(hostHeader: string | undefined, port: number): boolean {
117
+ if (!hostHeader) return false;
118
+ const normalized = hostHeader.trim().toLowerCase();
119
+ return normalized === `127.0.0.1:${port}` || normalized === `localhost:${port}`;
120
+ }
121
+
122
+ private handle(req: http.IncomingMessage, res: http.ServerResponse, options: DashboardServerOptions): void {
123
+ const method = (req.method ?? "GET").toUpperCase();
124
+ if (method !== "GET" && method !== "HEAD") {
125
+ res.setHeader("Allow", "GET, HEAD");
126
+ sendJson(res, 405, { error: "read-only dashboard: only GET is supported" });
127
+ return;
128
+ }
129
+ if (req.headers.upgrade) {
130
+ sendJson(res, 426, { error: "upgrades not supported" });
131
+ return;
132
+ }
133
+ const address = this.server?.address();
134
+ const port = address && typeof address !== "string" ? address.port : 0;
135
+ if (!this.allowedHost(req.headers.host, port)) {
136
+ sendJson(res, 403, { error: "forbidden host" });
137
+ return;
138
+ }
139
+
140
+ const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
141
+ const route = url.pathname;
142
+
143
+ if (route === "/" || route === "/index.html") {
144
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
145
+ res.end(method === "HEAD" ? undefined : PAGE_HTML);
146
+ return;
147
+ }
148
+
149
+ const eng = options.engine();
150
+ if (!eng) {
151
+ sendJson(res, 503, { error: "no active context engine — open the dashboard from a pi session in a project" });
152
+ return;
153
+ }
154
+ const ext: ExtDashboardState = { indexing: options.indexing() };
155
+
156
+ void (async (): Promise<void> => {
157
+ switch (route) {
158
+ case "/api/status":
159
+ sendJson(res, 200, statusPayload(eng, ext));
160
+ return;
161
+ case "/api/savings":
162
+ sendJson(res, 200, await savingsPayload(eng));
163
+ return;
164
+ case "/api/files":
165
+ sendJson(res, 200, filesPayload(eng, clampLimit(url.searchParams.get("limit"), 500, 5000)));
166
+ return;
167
+ case "/api/sessions":
168
+ sendJson(res, 200, sessionsPayload(eng, clampLimit(url.searchParams.get("limit"), 50, 500)));
169
+ return;
170
+ case "/api/decisions":
171
+ sendJson(res, 200, decisionsPayload(eng, clampLimit(url.searchParams.get("limit"), 100, 500)));
172
+ return;
173
+ case "/api/export":
174
+ sendJson(res, 200, await exportPayload(eng, ext), {
175
+ "Content-Disposition": `attachment; filename="context-engine-export-${eng.projectName}.json"`,
176
+ });
177
+ return;
178
+ default:
179
+ break;
180
+ }
181
+ const sessionMatch = /^\/api\/sessions\/([^/]+)$/.exec(route);
182
+ if (sessionMatch) {
183
+ let sessionId: string;
184
+ try {
185
+ sessionId = decodeURIComponent(sessionMatch[1] ?? "");
186
+ } catch {
187
+ sendJson(res, 400, { error: "malformed session id" });
188
+ return;
189
+ }
190
+ if (!sessionId || sessionId.length > MAX_SESSION_ID_CHARS || /[\0-\x1f\x7f]/.test(sessionId)) {
191
+ sendJson(res, 400, { error: "malformed session id" });
192
+ return;
193
+ }
194
+ sendJson(res, 200, timelinePayload(eng, sessionId));
195
+ return;
196
+ }
197
+ sendJson(res, 404, { error: `unknown route ${route}` });
198
+ })().catch((err: unknown) => {
199
+ if (!res.headersSent) sendJson(res, 500, { error: "internal error" });
200
+ else res.end();
201
+ options.log?.(`dashboard request failed: ${(err as Error).message}`, "warning");
202
+ });
203
+ }
204
+ }