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 +35 -4
- package/package.json +1 -1
- package/src/config.ts +9 -0
- package/src/dashboard/api.ts +155 -0
- package/src/dashboard/page.ts +403 -0
- package/src/dashboard/server.ts +204 -0
- package/src/extension.ts +55 -0
- package/src/memory/db.ts +162 -0
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
|
|
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):
|
|
295
|
-
|
|
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.
|
|
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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 } {
|