pi-context-engine 0.6.0 → 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.
package/README.md CHANGED
@@ -37,7 +37,7 @@ runs fixed queries. Run it yourself; nothing is cherry-picked.
37
37
 
38
38
  ## What you get
39
39
 
40
- Ten tools the agent can call, plus four slash commands.
40
+ Ten tools the agent can call, plus five slash commands.
41
41
 
42
42
  **Search**
43
43
  - **`context_search`**: hybrid vector + BM25 retrieval with confidence scores.
@@ -57,7 +57,7 @@ Ten tools the agent can call, plus four slash commands.
57
57
  - **`reindex`**: refresh a path or the whole project.
58
58
 
59
59
  Commands: `/context-index [--full] [--reset]`, `/context-status`, `/context-savings`,
60
- `/context-warmup`.
60
+ `/context-warmup`, `/context-dashboard`.
61
61
 
62
62
  **Shipped guidance.** The package also injects a ~900-token guidance section
63
63
  into the system prompt while `context_search` is active: how to formulate
@@ -150,6 +150,26 @@ unchanged repo took 237 ms.
150
150
  provisioning step (a devcontainer `postCreateCommand`, a CI cache setup),
151
151
  keeping first searches fast.
152
152
 
153
+ ### Web dashboard
154
+
155
+ `/context-dashboard` starts a small read-only web server inside the pi process
156
+ and opens it in your browser. Port is picked per session; set `dashboard.port`
157
+ in config for a stable URL.
158
+
159
+ - **Overview**: donut of measured savings, chunk/file counts, live indexing
160
+ state (polled every 5 s), and the active configuration.
161
+ - **Files**: every indexed file with its chunk count, largest first.
162
+ - **Sessions**: past sessions with per-turn summaries and tool events.
163
+ - **Memory**: recorded decisions and code areas.
164
+ - **Savings**: the per-bucket ledger with dollar estimates and a 30-day trend.
165
+ - **Export**: one JSON download of all of the above.
166
+
167
+ The dashboard is strictly read-only: every state change (reindex, reset,
168
+ config) stays in pi commands. It binds to `127.0.0.1` only, serves GET
169
+ requests only, and rejects requests whose `Host` header is not loopback, so a
170
+ malicious page cannot read your index data via DNS rebinding. It stops when
171
+ the session ends.
172
+
153
173
  ## Requirements
154
174
 
155
175
  - **Node 22+** on macOS, Linux or Windows.
@@ -220,6 +240,8 @@ indexer:
220
240
  watch: true # reindex on file changes while the session runs (debounced, incremental)
221
241
  memory:
222
242
  redact_pii: true
243
+ dashboard:
244
+ port: 0 # 0 = pick a free port; set one for a stable dashboard URL
223
245
  pricing:
224
246
  model: opus # opus | sonnet | haiku | gpt-4o | gemini-2.5-pro | ...
225
247
  storage:
@@ -269,6 +291,10 @@ by default:
269
291
  Path arguments are validated against the project root, so a crafted `path` cannot
270
292
  read outside it.
271
293
 
294
+ The web dashboard adds its own guards: loopback binding, GET-only routing, and
295
+ `Host` header validation against DNS rebinding. With no mutating endpoints,
296
+ there is nothing for a cross-site request to trigger.
297
+
272
298
  ## Supported languages
273
299
 
274
300
  **AST chunking** (tree-sitter): Python, JavaScript, TypeScript, TSX, PHP, Go,
@@ -286,13 +312,17 @@ This is a port, not a wrapper. Three deliberate changes:
286
312
  the `curl`-based Claude Code hooks. Session capture rides on pi's own events.
287
313
  - **No Python runtime**: embeddings run through transformers.js or Ollama, so
288
314
  installation is one npm command.
315
+ - **Dashboard rebuilt, not ported**: upstream serves its dashboard from a
316
+ FastAPI app with a mutating REST surface. Ours is a read-only `node:http`
317
+ server (zero dependencies) with a fresh single-file page — same tabs, no
318
+ write endpoints.
289
319
  - **Two upstream bugs fixed**: chunk ids now include column offsets (identical
290
320
  spans on one line used to overwrite each other in the index), and the
291
321
  placeholder filter no longer suppresses unambiguous vendor keys that happen to
292
322
  contain a word like `example`.
293
323
 
294
- Not ported (yet): the web dashboard, output-compression rules, and editor
295
- bootstrapping for non-pi editors.
324
+ Not ported (yet): output-compression rules and editor bootstrapping for
325
+ non-pi editors.
296
326
 
297
327
  ## Development
298
328
 
@@ -302,6 +332,7 @@ npm run check # typecheck + 251 unit tests
302
332
  npm run smoke:env # tree-sitter + sqlite-vec capability check
303
333
  npm run smoke:loader # load the extension through pi's jiti loader
304
334
  npm run smoke:real-world # index this repo with the local embedder
335
+ npx tsx scripts/smoke-dashboard.mts # boot the dashboard, probe every route, write a static UI preview
305
336
  ```
306
337
 
307
338
  ## Credits
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-context-engine",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Semantic code context for pi: tree-sitter index + hybrid vector/BM25 retrieval + cross-session memory, all local. Port of code-context-engine (MIT, elara-labs) as a native pi extension.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/config.ts CHANGED
@@ -105,6 +105,8 @@ export interface Config {
105
105
  indexerWatch: boolean;
106
106
  indexerRedactSecrets: boolean;
107
107
  memoryRedactPii: boolean;
108
+ /** TCP port for the /context-dashboard web server. 0 = pick a free port (default). */
109
+ dashboardPort: number;
108
110
  pricingModel: string;
109
111
  pricingInput: number | null;
110
112
  pricingOutput: number | null;
@@ -140,6 +142,7 @@ export function defaultConfig(): Config {
140
142
  indexerWatch: true,
141
143
  indexerRedactSecrets: true,
142
144
  memoryRedactPii: true,
145
+ dashboardPort: 0,
143
146
  pricingModel: "opus",
144
147
  pricingInput: null,
145
148
  pricingOutput: null,
@@ -176,6 +179,7 @@ const MAPPING: Record<string, keyof Config> = {
176
179
  "indexer.watch": "indexerWatch",
177
180
  "indexer.redact_secrets": "indexerRedactSecrets",
178
181
  "memory.redact_pii": "memoryRedactPii",
182
+ "dashboard.port": "dashboardPort",
179
183
  "storage.path": "storagePath",
180
184
  "pricing.model": "pricingModel",
181
185
  "pricing.input": "pricingInput",
@@ -221,6 +225,11 @@ function applyDict(config: Config, data: any): void {
221
225
  throw new Error(`config ${dotted} must be a non-negative integer`);
222
226
  }
223
227
  config.indexerMaxAutoFiles = value;
228
+ } else if (attr === "dashboardPort") {
229
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > 65535) {
230
+ throw new Error(`config ${dotted} must be an integer between 0 and 65535 (0 = pick a free port)`);
231
+ }
232
+ config.dashboardPort = value;
224
233
  } else if (attr === "pricingInput" || attr === "pricingOutput") {
225
234
  if (value !== null && typeof value !== "number") throw new Error(`config ${dotted} must be a number or null`);
226
235
  (config as any)[attr] = value;
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Read-only JSON snapshots for the web dashboard.
3
+ *
4
+ * Every function here is a pure reader over the engine's stores: no writes,
5
+ * no savings accounting, no embedder/model initialization (a dashboard
6
+ * refresh must never trigger a model download). Dollar estimates use the
7
+ * static/cached pricing table only — resolvePricing(config, false).
8
+ */
9
+ import type { ContextEngine } from "../engine.js";
10
+ import { resolvePricing } from "../pricing.js";
11
+
12
+ export interface ExtDashboardState {
13
+ /** True while a background (auto/watcher) index run is in flight. */
14
+ indexing: boolean;
15
+ }
16
+
17
+ const num = (n: number): number => Math.max(0, Math.floor(n || 0));
18
+
19
+ /** Cheap overview: index size, config summary and savings aggregates. No
20
+ * pricing resolution, no model init — polled every few seconds. */
21
+ export function statusPayload(eng: ContextEngine, ext: ExtDashboardState) {
22
+ const stats = eng.getStats();
23
+ let fileCounts: Record<string, number> = {};
24
+ try {
25
+ fileCounts = eng.backend.fileChunkCounts();
26
+ } catch {
27
+ // index stores may not exist yet
28
+ }
29
+ const saved = num(stats.rawTokens - stats.servedTokens);
30
+ const fullSaved = num(stats.fullFileTokens - stats.servedTokens);
31
+ return {
32
+ project: eng.projectName,
33
+ projectDir: eng.projectDir,
34
+ storageDir: eng.storageBase,
35
+ sessionId: eng.sessionId,
36
+ chunks: eng.chunkCount(),
37
+ files: Object.keys(fileCounts).length,
38
+ indexing: ext.indexing,
39
+ config: {
40
+ embeddingModel: eng.config.embeddingModel,
41
+ topK: eng.config.retrievalTopK,
42
+ rerank: eng.config.retrievalRerank,
43
+ chunking: eng.config.indexerChunking,
44
+ autoIndex: eng.config.indexerAutoIndex,
45
+ watch: eng.config.indexerWatch,
46
+ compression: eng.config.compressionLevel,
47
+ },
48
+ stats: {
49
+ queries: stats.queries,
50
+ rawTokens: stats.rawTokens,
51
+ servedTokens: stats.servedTokens,
52
+ fullFileTokens: stats.fullFileTokens,
53
+ tokensSaved: saved,
54
+ fullFileTokensSaved: fullSaved,
55
+ savingsPct: stats.fullFileTokens > 0 ? Math.floor((fullSaved / stats.fullFileTokens) * 100) : null,
56
+ },
57
+ };
58
+ }
59
+
60
+ /** Bucket ledger + dollar estimates + per-day trend. Never fetches live
61
+ * pricing: the dashboard must stay offline-safe. */
62
+ export async function savingsPayload(eng: ContextEngine) {
63
+ const { label, pricing } = await resolvePricing(eng.config, false);
64
+ const buckets: Record<string, { baseline: number; served: number; calls: number }> = eng.aggregateSavings() ?? {};
65
+ const toUsd = (tokens: number): number => (tokens / 1e6) * pricing.input;
66
+ const rows = Object.entries(buckets)
67
+ .map(([bucket, v]) => ({
68
+ bucket,
69
+ calls: v.calls,
70
+ baseline: v.baseline,
71
+ served: v.served,
72
+ saved: num(v.baseline - v.served),
73
+ savedUsd: toUsd(num(v.baseline - v.served)),
74
+ }))
75
+ .sort((a, b) => b.saved - a.saved);
76
+ let memory = null;
77
+ try {
78
+ memory = eng.getMemory();
79
+ } catch {
80
+ memory = null;
81
+ }
82
+ const byDay = (memory?.savingsByDay(30) ?? []).map((d) => ({
83
+ day: d.day,
84
+ saved: num(d.baseline - d.served),
85
+ }));
86
+ const stats = eng.getStats();
87
+ const fullSaved = num(stats.fullFileTokens - stats.servedTokens);
88
+ return {
89
+ pricing: { model: label, inputUsdPer1M: pricing.input, outputUsdPer1M: pricing.output },
90
+ totals: {
91
+ savedTokens: fullSaved,
92
+ savedUsd: toUsd(fullSaved),
93
+ savingsPct: stats.fullFileTokens > 0 ? Math.floor((fullSaved / stats.fullFileTokens) * 100) : null,
94
+ },
95
+ buckets: rows,
96
+ byDay,
97
+ };
98
+ }
99
+
100
+ /** Indexed files with chunk counts, largest first. */
101
+ export function filesPayload(eng: ContextEngine, limit = 500) {
102
+ let counts: Record<string, number> = {};
103
+ try {
104
+ counts = eng.backend.fileChunkCounts();
105
+ } catch {
106
+ // no index yet
107
+ }
108
+ const rows = Object.entries(counts)
109
+ .map(([filePath, chunks]) => ({ filePath, chunks }))
110
+ .sort((a, b) => b.chunks - a.chunks || a.filePath.localeCompare(b.filePath));
111
+ return {
112
+ total: rows.length,
113
+ chunks: rows.reduce((acc, r) => acc + r.chunks, 0),
114
+ files: rows.slice(0, Math.max(1, Math.min(limit, 5000))),
115
+ };
116
+ }
117
+
118
+ export function sessionsPayload(eng: ContextEngine, limit = 50) {
119
+ const memory = eng.getMemory();
120
+ return {
121
+ sessions: memory ? memory.listSessions(limit) : [],
122
+ // JSON snapshots (SessionCapture) survive memory-db failures and carry
123
+ // decisions/code areas even when SQLite is unavailable.
124
+ activeSessionId: eng.sessionId,
125
+ };
126
+ }
127
+
128
+ export function timelinePayload(eng: ContextEngine, sessionId: string) {
129
+ const memory = eng.getMemory();
130
+ if (!memory) return { session: null, turns: [], events: [] };
131
+ const meta = memory.listSessions(500).find((s) => s.id === sessionId) ?? null;
132
+ const detail = memory.sessionDetail(sessionId);
133
+ return { session: meta, turns: detail.turns, events: detail.events };
134
+ }
135
+
136
+ export function decisionsPayload(eng: ContextEngine, limit = 100) {
137
+ const memory = eng.getMemory();
138
+ return {
139
+ decisions: memory?.listDecisions(limit) ?? [],
140
+ codeAreas: memory?.listCodeAreas(limit) ?? [],
141
+ consolidated: eng.sessionCapture.loadConsolidatedDecisions().slice(0, limit),
142
+ };
143
+ }
144
+
145
+ /** Everything, for /api/export. */
146
+ export async function exportPayload(eng: ContextEngine, ext: ExtDashboardState) {
147
+ return {
148
+ generatedAt: new Date().toISOString(),
149
+ status: statusPayload(eng, ext),
150
+ savings: await savingsPayload(eng),
151
+ files: filesPayload(eng),
152
+ sessions: sessionsPayload(eng),
153
+ decisions: decisionsPayload(eng),
154
+ };
155
+ }
@@ -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
+ }
package/src/extension.ts CHANGED
@@ -10,10 +10,12 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
10
  import { Type } from "typebox";
11
11
  import fs from "node:fs";
12
12
  import path from "node:path";
13
+ import { spawn } from "node:child_process";
13
14
  import { ContextEngine, isPceConfig } from "./engine.js";
14
15
  import { applyContextSearchGuidance } from "./guidance.js";
15
16
  import { ProjectWatcher } from "./indexer/watcher.js";
16
17
  import { AutomaticIndexFileLimitError } from "./indexer/pipeline.js";
18
+ import { DashboardServer } from "./dashboard/server.js";
17
19
 
18
20
  /** Tools whose payloads are worth capturing for session timelines. */
19
21
  const CAPTURED_TOOLS = new Set(["bash", "read", "edit", "write", "grep", "find", "ls"]);
@@ -66,6 +68,14 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
66
68
  let watcherKickPending = false;
67
69
  let watcherNotify: ((msg: string, level: "info" | "warning") => void) | undefined;
68
70
 
71
+ /**
72
+ * Read-only web dashboard (config `dashboard.port`, 0 = free port). Owned
73
+ * by this closure like the watcher: it must outlive individual command
74
+ * invocations, reference the LIVE engine on every request (engine
75
+ * instances are replaced per session), and stop on session_shutdown.
76
+ */
77
+ let dashboard: DashboardServer | null = null;
78
+
69
79
  /**
70
80
  * Watcher-triggered incremental reindex. Unlike `kickBackgroundIndex` this
71
81
  * calls `reindex({ automatic: true })` directly — the watcher must re-fire
@@ -604,6 +614,46 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
604
614
  },
605
615
  });
606
616
 
617
+ pi.registerCommand("context-dashboard", {
618
+ description: "Open the read-only web dashboard (index health, files, sessions, memory, savings) in your browser",
619
+ handler: async (_args, ctx) => {
620
+ const openBrowser = (url: string): void => {
621
+ const cmd = process.platform === "win32" ? "cmd.exe" : process.platform === "darwin" ? "open" : "xdg-open";
622
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
623
+ try {
624
+ spawn(cmd, args, { detached: true, stdio: "ignore" }).unref();
625
+ } catch {
626
+ // best-effort: the URL is printed either way
627
+ }
628
+ };
629
+ let url = dashboard?.url ?? null;
630
+ if (!dashboard || !url) {
631
+ const eng = getEngine(ctx.cwd);
632
+ dashboard = new DashboardServer();
633
+ try {
634
+ url = await dashboard.start({
635
+ engine: () => engine,
636
+ indexing: () => indexInFlight,
637
+ port: eng.config.dashboardPort,
638
+ });
639
+ } catch (err) {
640
+ dashboard = null;
641
+ ctx.ui.notify(
642
+ `⚠ Dashboard failed to start: ${(err as Error).message}. ` +
643
+ `If dashboard.port is set, another process may be using it.`,
644
+ "warning",
645
+ );
646
+ return;
647
+ }
648
+ }
649
+ openBrowser(url);
650
+ ctx.ui.notify(
651
+ `✓ Dashboard: ${url} (read-only, localhost only — stops when this session ends)`,
652
+ "info",
653
+ );
654
+ },
655
+ });
656
+
607
657
  /* ── Lifecycle hooks (replace upstream's curl-based hooks) ───────────── */
608
658
 
609
659
  pi.on("session_start", async (_event, ctx) => {
@@ -705,6 +755,11 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
705
755
  stopWatcher();
706
756
  watcherNotify = undefined;
707
757
  automaticIndexBlock = null;
758
+ if (dashboard) {
759
+ const d = dashboard;
760
+ dashboard = null;
761
+ await d.stop().catch(() => undefined);
762
+ }
708
763
  await engine?.shutdown().catch(() => undefined);
709
764
  engine = null;
710
765
  });
package/src/memory/db.ts CHANGED
@@ -571,6 +571,168 @@ CREATE VIRTUAL TABLE IF NOT EXISTS turn_summaries_vec USING vec0(embedding float
571
571
  return out;
572
572
  }
573
573
 
574
+ // ── Read-only views (dashboard) ───────────────────────────────────────
575
+ // These never write and never touch the savings ledger — they exist so the
576
+ // web dashboard can render history without going through the text renderers
577
+ // (which record progressive_disclosure savings as a side effect).
578
+
579
+ /** Recent sessions, newest first, with prompt/turn counts. */
580
+ listSessions(limit = 50): {
581
+ id: string;
582
+ project: string;
583
+ started_at: string;
584
+ ended_at: string | null;
585
+ status: string;
586
+ promptCount: number;
587
+ rollup: string | null;
588
+ }[] {
589
+ try {
590
+ const rows = this.db
591
+ .prepare(
592
+ `SELECT s.id, s.project, s.started_at, s.ended_at, s.status, s.rollup_summary,
593
+ (SELECT COUNT(*) FROM prompts p WHERE p.session_id = s.id) AS prompt_count
594
+ FROM sessions s ORDER BY s.started_at_epoch DESC LIMIT ?`,
595
+ )
596
+ .all(Math.max(1, Math.min(limit, 500))) as {
597
+ id: string;
598
+ project: string;
599
+ started_at: string;
600
+ ended_at: string | null;
601
+ status: string;
602
+ rollup_summary: string | null;
603
+ prompt_count: number | null;
604
+ }[];
605
+ return rows.map((r) => ({
606
+ id: r.id,
607
+ project: r.project,
608
+ started_at: r.started_at,
609
+ ended_at: r.ended_at,
610
+ status: r.status,
611
+ promptCount: Number(r.prompt_count ?? 0),
612
+ rollup: r.rollup_summary,
613
+ }));
614
+ } catch {
615
+ return [];
616
+ }
617
+ }
618
+
619
+ /** Turn summaries + tool-event activity for one session, in turn order. */
620
+ sessionDetail(
621
+ sessionId: string,
622
+ turnLimit = 200,
623
+ ): {
624
+ turns: { promptNumber: number; tier: string; summary: string }[];
625
+ events: { id: number; tool: string; promptNumber: number; createdAt: string; hasPayload: boolean }[];
626
+ } {
627
+ const empty = { turns: [], events: [] as { id: number; tool: string; promptNumber: number; createdAt: string; hasPayload: boolean }[] };
628
+ try {
629
+ const turns = this.db
630
+ .prepare(
631
+ "SELECT prompt_number, tier, summary FROM turn_summaries WHERE session_id = ? ORDER BY prompt_number ASC LIMIT ?",
632
+ )
633
+ .all(sessionId, Math.max(1, Math.min(turnLimit, 500))) as {
634
+ prompt_number: number;
635
+ tier: string;
636
+ summary: string;
637
+ }[];
638
+ const events = this.db
639
+ .prepare(
640
+ `SELECT te.id, te.tool_name, te.prompt_number, te.created_at, (te.payload_id IS NOT NULL) AS has_payload
641
+ FROM tool_events te WHERE te.session_id = ? ORDER BY te.id ASC LIMIT ?`,
642
+ )
643
+ .all(sessionId, Math.max(1, Math.min(turnLimit * 20, 2000))) as {
644
+ id: number;
645
+ tool_name: string;
646
+ prompt_number: number;
647
+ created_at: string;
648
+ has_payload: number;
649
+ }[];
650
+ return {
651
+ turns: turns.map((t) => ({ promptNumber: t.prompt_number, tier: t.tier, summary: t.summary })),
652
+ events: events.map((e) => ({
653
+ id: e.id,
654
+ tool: e.tool_name,
655
+ promptNumber: e.prompt_number,
656
+ createdAt: e.created_at,
657
+ hasPayload: Number(e.has_payload) === 1,
658
+ })),
659
+ };
660
+ } catch {
661
+ return empty;
662
+ }
663
+ }
664
+
665
+ /** Recent decisions, newest first. */
666
+ listDecisions(limit = 100): { id: number; decision: string; reason: string; source: string; createdAt: string }[] {
667
+ try {
668
+ const rows = this.db
669
+ .prepare("SELECT id, decision, reason, source, created_at FROM decisions ORDER BY created_at_epoch DESC LIMIT ?")
670
+ .all(Math.max(1, Math.min(limit, 500))) as {
671
+ id: number;
672
+ decision: string;
673
+ reason: string;
674
+ source: string;
675
+ created_at: string;
676
+ }[];
677
+ return rows.map((r) => ({ id: r.id, decision: r.decision, reason: r.reason, source: r.source, createdAt: r.created_at }));
678
+ } catch {
679
+ return [];
680
+ }
681
+ }
682
+
683
+ /** Recent code areas, newest first. */
684
+ listCodeAreas(limit = 100): { id: number; filePath: string; description: string; source: string; createdAt: string }[] {
685
+ try {
686
+ const rows = this.db
687
+ .prepare("SELECT id, file_path, description, source, created_at FROM code_areas ORDER BY created_at_epoch DESC LIMIT ?")
688
+ .all(Math.max(1, Math.min(limit, 500))) as {
689
+ id: number;
690
+ file_path: string;
691
+ description: string;
692
+ source: string;
693
+ created_at: string;
694
+ }[];
695
+ return rows.map((r) => ({ id: r.id, filePath: r.file_path, description: r.description, source: r.source, createdAt: r.created_at }));
696
+ } catch {
697
+ return [];
698
+ }
699
+ }
700
+
701
+ /** Raw savings events, newest first. */
702
+ recentSavings(limit = 200): { bucket: string; baseline: number; served: number; ts: number }[] {
703
+ try {
704
+ const rows = this.db
705
+ .prepare("SELECT bucket, baseline, served, ts FROM savings_log ORDER BY ts DESC, id DESC LIMIT ?")
706
+ .all(Math.max(1, Math.min(limit, 2000))) as { bucket: string; baseline: number; served: number; ts: number }[];
707
+ return rows.map((r) => ({ bucket: r.bucket, baseline: r.baseline, served: r.served, ts: r.ts }));
708
+ } catch {
709
+ return [];
710
+ }
711
+ }
712
+
713
+ /** Savings per local calendar day (oldest → newest) for trend charts. */
714
+ savingsByDay(days = 30): { day: string; baseline: number; served: number }[] {
715
+ const out: { day: string; baseline: number; served: number }[] = [];
716
+ try {
717
+ const cutoff = epochSeconds() - Math.max(1, Math.min(days, 365)) * 86400;
718
+ const rows = this.db
719
+ .prepare("SELECT ts, baseline, served FROM savings_log WHERE ts >= ? ORDER BY ts ASC")
720
+ .all(cutoff) as { ts: number; baseline: number; served: number }[];
721
+ const byDay = new Map<string, { baseline: number; served: number }>();
722
+ for (const r of rows) {
723
+ const day = new Date(r.ts * 1000).toISOString().slice(0, 10);
724
+ const acc = byDay.get(day) ?? { baseline: 0, served: 0 };
725
+ acc.baseline += r.baseline;
726
+ acc.served += r.served;
727
+ byDay.set(day, acc);
728
+ }
729
+ for (const [day, v] of [...byDay.entries()].sort()) out.push({ day, ...v });
730
+ } catch {
731
+ // empty series
732
+ }
733
+ return out;
734
+ }
735
+
574
736
  // ── Retention ─────────────────────────────────────────────────────────
575
737
 
576
738
  pruneOldPayloads(days = 30): { payloadsPruned: number; bytesFreedEstimate: number } {