pi-mega-compact 0.8.21 → 0.8.22

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.
Files changed (76) hide show
  1. package/dist/extensions/dashboard-server/dashboard-client-core.js +201 -0
  2. package/dist/extensions/dashboard-server/dashboard-client-game.js +241 -0
  3. package/dist/extensions/dashboard-server/dashboard-client-repos.js +212 -0
  4. package/dist/extensions/dashboard-server/dashboard-client.js +19 -0
  5. package/dist/extensions/dashboard-server/html.js +2 -621
  6. package/dist/extensions/dashboard-server/routes-core.js +62 -0
  7. package/dist/extensions/dashboard-server/routes-game.js +323 -0
  8. package/dist/extensions/dashboard-server/routes-repo.js +170 -0
  9. package/dist/extensions/dashboard-server/routes-sessions.js +159 -0
  10. package/dist/extensions/dashboard-server/routes.js +10 -0
  11. package/dist/extensions/dashboard-server/server.js +26 -623
  12. package/dist/extensions/mega-commands.js +4 -3
  13. package/dist/extensions/mega-events/agent-handlers.js +2 -1
  14. package/dist/extensions/mega-events/compact-handlers.js +26 -0
  15. package/dist/extensions/mega-events/session-handlers.js +2 -1
  16. package/dist/extensions/mega-pipeline/compact.js +3 -2
  17. package/dist/extensions/mega-runtime/state.js +7 -7
  18. package/dist/src/dedup/raptor/promote.test.js +5 -5
  19. package/dist/src/dedup/sprint12.test.js +7 -7
  20. package/dist/src/dedup-engine.test.js +29 -29
  21. package/dist/src/e2e.test.js +38 -38
  22. package/dist/src/engine.js +3 -3
  23. package/dist/src/engine.test.js +6 -6
  24. package/dist/src/importance.js +197 -0
  25. package/dist/src/importance.test.js +372 -0
  26. package/dist/src/ratio.bench.test.js +18 -18
  27. package/dist/src/recall.js +6 -5
  28. package/dist/src/recall.test.js +85 -27
  29. package/dist/src/sprint14.test.js +2 -2
  30. package/dist/src/store/migrate.test.js +5 -5
  31. package/dist/src/store/sprint10.test.js +5 -5
  32. package/dist/src/store/sqlite/global-index.js +5 -174
  33. package/dist/src/store/sqlite/global-sessions.js +190 -0
  34. package/dist/src/vector-read.js +168 -0
  35. package/dist/src/vector-search.js +191 -0
  36. package/dist/src/vectorStore.js +10 -297
  37. package/dist/src/vectorStore.test.js +32 -32
  38. package/extensions/dashboard-server/dashboard-client-core.ts +202 -0
  39. package/extensions/dashboard-server/dashboard-client-game.ts +242 -0
  40. package/extensions/dashboard-server/dashboard-client-repos.ts +213 -0
  41. package/extensions/dashboard-server/dashboard-client.ts +21 -0
  42. package/extensions/dashboard-server/html.ts +2 -621
  43. package/extensions/dashboard-server/routes-core.ts +113 -0
  44. package/extensions/dashboard-server/routes-game.ts +386 -0
  45. package/extensions/dashboard-server/routes-repo.ts +212 -0
  46. package/extensions/dashboard-server/routes-sessions.ts +195 -0
  47. package/extensions/dashboard-server/routes.ts +13 -0
  48. package/extensions/dashboard-server/server.ts +37 -700
  49. package/extensions/mega-commands.ts +4 -3
  50. package/extensions/mega-events/agent-handlers.ts +2 -1
  51. package/extensions/mega-events/compact-handlers.ts +28 -0
  52. package/extensions/mega-events/session-handlers.ts +2 -1
  53. package/extensions/mega-pipeline/compact.ts +3 -2
  54. package/extensions/mega-runtime/state.ts +7 -7
  55. package/extensions/openclaw-mega-compact.ts +2 -2
  56. package/package.json +1 -1
  57. package/src/dedup/raptor/promote.test.ts +5 -5
  58. package/src/dedup/sprint12.test.ts +7 -7
  59. package/src/dedup-engine.test.ts +30 -30
  60. package/src/e2e.test.ts +38 -38
  61. package/src/engine.test.ts +6 -6
  62. package/src/engine.ts +3 -3
  63. package/src/importance.test.ts +538 -0
  64. package/src/importance.ts +312 -0
  65. package/src/ratio.bench.test.ts +18 -18
  66. package/src/recall.test.ts +101 -29
  67. package/src/recall.ts +9 -9
  68. package/src/sprint14.test.ts +2 -2
  69. package/src/store/migrate.test.ts +5 -5
  70. package/src/store/sprint10.test.ts +5 -5
  71. package/src/store/sqlite/global-index.ts +18 -290
  72. package/src/store/sqlite/global-sessions.ts +291 -0
  73. package/src/vector-read.ts +237 -0
  74. package/src/vector-search.ts +231 -0
  75. package/src/vectorStore.test.ts +32 -32
  76. package/src/vectorStore.ts +29 -356
@@ -1,16 +1,18 @@
1
1
  /**
2
2
  * dashboard-server/server.ts — HTTP server creation + launch + CLI entry point.
3
+ *
4
+ * Route handlers are extracted to routes.ts. This file owns:
5
+ * - launchDashboardServer (setup, version detection, port finding, IPv6 mirror, lifecycle)
6
+ * - createServer as a thin dispatcher that builds RouteContext and delegates each route
7
+ * - CORS preflight + OPTIONS handling (per-request middleware, not a route)
3
8
  */
4
9
  import { createServer, } from "node:http";
5
- import { existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync, } from "node:fs";
10
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
6
11
  import { join, dirname } from "node:path";
7
12
  import { fileURLToPath } from "node:url";
8
13
  import { createRequire } from "node:module";
9
14
  import { log, setLogPath, setDashboardServerVersion } from "./state.js";
10
- import { readIndex, getIndexDir } from "./index-reader.js";
11
- import { readSnapshot, readFrom } from "./snapshot.js";
12
- import { dashboardHtml } from "./html.js";
13
- import { ACTIVE_WINDOW_SEC } from "./types.js";
15
+ import { buildRouteContext, handleIndex, handleRepoIndex, handleEvents, handleGameState, handleGameScores, handlePerf, handleAchievements, handleSessions, handleStatic, } from "./routes.js";
14
16
  export async function launchDashboardServer(stateDir) {
15
17
  // Our own package version — exposed at /api/version so the launcher can
16
18
  // detect a stale server (started by an older build) and replace it on
@@ -143,48 +145,15 @@ export async function launchDashboardServer(stateDir) {
143
145
  }
144
146
  // ── New server ────────────────────────────────────────────────────────────
145
147
  mkdirSync(stateDir, { recursive: true });
146
- let eventOffset = 0;
147
- // Overlay the live current-repo snapshot (snapshot.json, rewritten every
148
- // context event) onto its registry row so the All-repos / Summary views stay
149
- // in sync with the live menu bar + Current-repo card in real time. The
150
- // registry (index.sqlite) is only written on repo-switch (bindRepo), so
151
- // without this the current repo's row freezes between switches. Read-only —
152
- // no extra writes to index.sqlite. Matched by stateDir, which equals the
153
- // value this server was launched with (runtime.currentStateDir).
154
- function overlayCurrentRepo(idx) {
155
- if (!idx || !idx.repos.length)
156
- return;
157
- let snap = null;
158
- try {
159
- snap = readSnapshot(snapshotPath);
160
- }
161
- catch {
162
- return;
163
- }
164
- if (!snap || !snap.repo)
165
- return;
166
- const cur = idx.repos.find((r) => r.stateDir === stateDir);
167
- if (!cur)
168
- return;
169
- const prevSaved = cur.tokensSaved;
170
- const prevCp = cur.checkpointCount;
171
- const prevBytes = cur.compressedOriginalBytes;
172
- const comp = snap.compression?.repo;
173
- const liveSaved = comp
174
- ? comp.tokensFreed
175
- : (snap.repo.tokensSaved ?? prevSaved);
176
- const liveCp = snap.repo.checkpointCount ?? prevCp;
177
- const liveBytes = snap.integrity?.compressedOriginalBytes ?? prevBytes;
178
- cur.tokensSaved = liveSaved;
179
- cur.checkpointCount = liveCp;
180
- cur.compressedOriginalBytes = liveBytes;
181
- if (idx.summary) {
182
- idx.summary.totalTokensSaved += liveSaved - prevSaved;
183
- idx.summary.totalCheckpoints += liveCp - prevCp;
184
- idx.summary.totalCompressedOriginalBytes += liveBytes - prevBytes;
185
- }
186
- idx.updatedAt = snap.updatedAt ?? idx.updatedAt;
187
- }
148
+ // Build RouteContext once; all handlers receive the same ctx object.
149
+ const ctx = buildRouteContext({
150
+ snapshotPath,
151
+ eventsPath,
152
+ stateDir,
153
+ SERVER_VERSION,
154
+ serveClientAsset,
155
+ detectCrossRepoDrift,
156
+ });
188
157
  const server = createServer((req, res) => {
189
158
  // guardrails-allow PREVENT-PI-004: optional, user-triggered /dashboard localhost server (loopback-only) — CORS restricted to same-origin localhost browsers.
190
159
  // CORS for local access — restricted to loopback origins (the dashboard server only binds to localhost).
@@ -201,590 +170,24 @@ export async function launchDashboardServer(stateDir) {
201
170
  res.end();
202
171
  return;
203
172
  }
204
- if (req.url === "/" || req.url === "/index.html") {
205
- // Sprint B1: prefer the React client build when present; fall back to the
206
- // legacy inline html.ts template when the client dist is absent.
207
- if (serveClientAsset("/", res))
208
- return;
209
- const tier = readSnapshot(snapshotPath).tier;
210
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
211
- res.end(dashboardHtml(tier));
212
- return;
213
- }
214
- if (req.url === "/api/snapshot") {
215
- const snap = readSnapshot(snapshotPath);
216
- res.writeHead(200, { "Content-Type": "application/json" });
217
- res.end(JSON.stringify(snap));
218
- return;
219
- }
220
- // Server version — lets the /dashboard launcher detect a stale server from
221
- // an older build and replace it on upgrade rather than reuse it.
222
- if (req.url === "/api/version") {
223
- res.writeHead(200, { "Content-Type": "application/json" });
224
- res.end(JSON.stringify({ version: SERVER_VERSION }));
225
- return;
226
- }
227
- // Multi-repo aggregate (Phase 5b): the machine-wide repo registry read
228
- // directly from SQLite (index.sqlite). Lets one dashboard show every repo's
229
- // checkpoints, tokens saved, and active model. Read-only.
230
- if (req.url === "/api/index") {
231
- const idx = readIndex();
232
- if (idx)
233
- overlayCurrentRepo(idx);
234
- res.writeHead(200, { "Content-Type": "application/json" });
235
- res.end(JSON.stringify(idx ?? { updatedAt: null, summary: null, repos: [] }));
236
- return;
237
- }
238
- // /api/repos — registry list. Optional `?active=24h` filters to repos
239
- // seen within the last N hours (default: all). The dashboard uses this to
240
- // drive its "active vs archived" badge without refetching /api/index.
241
- if (req.url?.startsWith("/api/repos")) {
242
- const url = new URL(req.url, "http://x"); // guardrails-allow PREVENT-PI-004: localhost dashboard URL base (loopback-only)
243
- const activeParam = url.searchParams.get("active");
244
- const idx = readIndex();
245
- if (idx)
246
- overlayCurrentRepo(idx);
247
- let repos = idx?.repos ?? [];
248
- if (activeParam) {
249
- const m = /^(\d+)h$/.exec(activeParam);
250
- if (m) {
251
- const cutoffSec = Math.floor(Date.now() / 1000) - Number(m[1]) * 3600;
252
- repos = repos.filter((r) => (r.lastSeen ?? 0) >= cutoffSec);
253
- }
254
- }
255
- res.writeHead(200, { "Content-Type": "application/json" });
256
- res.end(JSON.stringify({
257
- updatedAt: idx?.updatedAt ?? null,
258
- repos,
259
- count: repos.length,
260
- }));
261
- return;
262
- }
263
- // /api/summary — header tiles without the full repo list (keeps payload
264
- // small for embed scenarios). activeRepos mirrors the /api/repos?active=24h
265
- // count so the dashboard can render the active badge alongside totals.
266
- if (req.url?.startsWith("/api/summary")) {
267
- const idx = readIndex();
268
- if (idx)
269
- overlayCurrentRepo(idx);
270
- const repos = idx?.repos ?? [];
271
- const cutoffSec = Math.floor(Date.now() / 1000) - 24 * 3600;
272
- const activeRepos = repos.filter((r) => (r.lastSeen ?? 0) >= cutoffSec).length;
273
- res.writeHead(200, { "Content-Type": "application/json" });
274
- res.end(JSON.stringify({
275
- updatedAt: idx?.updatedAt ?? null,
276
- summary: idx?.summary ?? null,
277
- activeRepos,
278
- totalRepos: repos.length,
279
- }));
280
- return;
281
- }
282
- // /api/drift — R4: cross-repo drift report over repo_registry. Flags stale
283
- // repos (>30d idle), compaction lag (active but >24h since last
284
- // compaction), and recent model churn. Read-only.
285
- if (req.url?.startsWith("/api/drift")) {
286
- const report = detectCrossRepoDrift(getIndexDir());
287
- res.writeHead(200, { "Content-Type": "application/json" });
288
- res.end(JSON.stringify(report));
289
- return;
290
- }
291
- if (req.url === "/api/servers") {
292
- try {
293
- const idx = readIndex();
294
- const nowSec = Math.floor(Date.now() / 1000);
295
- const servers = (idx?.repos ?? [])
296
- .filter((r) => (r.lastSeen ?? 0) >= nowSec - ACTIVE_WINDOW_SEC)
297
- .map((r) => {
298
- const out = {
299
- repoRoot: r.repoRoot,
300
- displayName: r.displayName,
301
- model: r.modelName,
302
- provider: r.providerName,
303
- lastSeen: r.lastSeen,
304
- lastCompactedAt: r.lastCompactedAt,
305
- };
306
- try {
307
- const p = join(r.stateDir, "dashboard.json");
308
- if (existsSync(p)) {
309
- const snap = JSON.parse(readFileSync(p, "utf-8"));
310
- out.tier = snap.tier ?? null;
311
- out.contextPct =
312
- snap.context && snap.context.percent != null
313
- ? snap.context.percent
314
- : null;
315
- out.state = (snap.session && snap.session.state) || null;
316
- out.cacheHits = snap.cacheHits ?? null;
317
- out.compacts = snap.compacts ?? null;
318
- out.timeSaved = snap.timeSaved ?? null;
319
- out.updatedAt = snap.updatedAt ?? null;
320
- }
321
- }
322
- catch {
323
- /* best-effort */
324
- }
325
- return out;
326
- })
327
- .sort((a, b) => b.lastSeen - a.lastSeen);
328
- res.writeHead(200, { "Content-Type": "application/json" });
329
- res.end(JSON.stringify({ updatedAt: new Date().toISOString(), servers }));
330
- }
331
- catch {
332
- res.writeHead(500, { "Content-Type": "application/json" });
333
- res.end(JSON.stringify({ error: "servers_unavailable" }));
334
- }
173
+ // Dispatch each handler returns true if it ended the response.
174
+ if (handleIndex(req, res, ctx))
335
175
  return;
336
- }
337
- if (req.url === "/api/events") {
338
- res.writeHead(200, {
339
- "Content-Type": "text/event-stream",
340
- "Cache-Control": "no-cache",
341
- Connection: "keep-alive",
342
- });
343
- // Drain existing events so the client starts with history
344
- const { data: existing, offset: initialOffset } = readFrom(eventsPath, 0);
345
- eventOffset = initialOffset;
346
- const lines = existing.split("\n").filter((l) => l.trim());
347
- for (const line of lines) {
348
- res.write(`data: ${line}\n\n`);
349
- }
350
- // Tail new events via fs.watch (coalesced with 100ms debounce)
351
- let watchTimer = null;
352
- const onWatch = () => {
353
- if (watchTimer)
354
- return;
355
- watchTimer = setTimeout(() => {
356
- watchTimer = null;
357
- const { data, offset } = readFrom(eventsPath, eventOffset);
358
- eventOffset = offset;
359
- const newLines = data.split("\n").filter((l) => l.trim());
360
- for (const line of newLines) {
361
- res.write(`data: ${line}\n\n`);
362
- }
363
- }, 100);
364
- };
365
- // Set up file watching: if file exists, watch it directly;
366
- // otherwise poll for creation every 1s then switch to fs.watch.
367
- let watcher = null;
368
- let pollInterval = null;
369
- function startFileWatch() {
370
- try {
371
- watcher = watch(eventsPath, onWatch);
372
- }
373
- catch {
374
- /* give up */
375
- }
376
- }
377
- if (existsSync(eventsPath)) {
378
- startFileWatch();
379
- }
380
- else {
381
- pollInterval = setInterval(() => {
382
- if (existsSync(eventsPath)) {
383
- if (pollInterval) {
384
- clearInterval(pollInterval);
385
- pollInterval = null;
386
- }
387
- startFileWatch();
388
- }
389
- }, 1000);
390
- }
391
- req.on("close", () => {
392
- if (watchTimer)
393
- clearTimeout(watchTimer);
394
- if (pollInterval)
395
- clearInterval(pollInterval);
396
- watcher?.close();
397
- });
176
+ if (handleRepoIndex(req, res, ctx))
398
177
  return;
399
- }
400
- // /api/game-state — S32 game-mode settings (game_mode_on / theme /
401
- // tui_display_mode). GET returns the current row; PUT applies a partial
402
- // patch (validated) and returns the post-write row. The dashboard server is
403
- // a detached child with no MegaRuntime ref, so it reads/writes the
404
- // game_state SQLite row directly; the in-process MegaRuntime picks up the
405
- // change via its fs.watch cache-eviction watcher. PREVENT-PI-004: loopback.
406
- if (req.url?.startsWith("/api/game-state")) {
407
- const gsReq = createRequire(import.meta.url);
408
- const { getGameState, setGameState } = gsReq("../../src/store/sqlite.js");
409
- const { isValidTheme } = gsReq("../../src/config/themes.js");
410
- if (req.method === "GET") {
411
- try {
412
- const gs = getGameState(stateDir); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
413
- res.writeHead(200, { "Content-Type": "application/json" });
414
- res.end(JSON.stringify(gs));
415
- }
416
- catch (e) {
417
- res.writeHead(500, { "Content-Type": "application/json" });
418
- res.end(JSON.stringify({
419
- error: "game_state_unavailable",
420
- detail: String(e),
421
- }));
422
- }
423
- return;
424
- }
425
- if (req.method === "PUT") {
426
- // Read + parse the JSON body (capped — the patch is tiny). The handler
427
- // is sync, so drain the stream via data/end listeners then continue.
428
- let body = "";
429
- let tooBig = false;
430
- req.on("data", (chunk) => {
431
- // guardrails-allow PREVENT-PI-004: loopback dashboard request body (local)
432
- if (body.length > 65536) {
433
- tooBig = true;
434
- return;
435
- }
436
- body += chunk.toString();
437
- });
438
- req.on("end", () => {
439
- if (tooBig) {
440
- res.writeHead(413, { "Content-Type": "application/json" });
441
- res.end(JSON.stringify({ error: "body_too_large" }));
442
- return;
443
- }
444
- let patch = {};
445
- try {
446
- patch = body ? JSON.parse(body) : {};
447
- }
448
- catch {
449
- res.writeHead(400, { "Content-Type": "application/json" });
450
- res.end(JSON.stringify({ error: "invalid_json" }));
451
- return;
452
- }
453
- // Reject valid-but-non-object JSON (null/[]/42) — dereferencing
454
- // patch.game_mode_on would throw an unhandled TypeError inside this
455
- // 'end' listener and crash the detached server (audit P1: loopback DoS).
456
- if (typeof patch !== "object" ||
457
- patch === null ||
458
- Array.isArray(patch)) {
459
- res.writeHead(400, { "Content-Type": "application/json" });
460
- res.end(JSON.stringify({ error: "invalid_patch_object" }));
461
- return;
462
- }
463
- // Validate the patch fields (unknown keys ignored; invalid values -> 400).
464
- const clean = {};
465
- let bad = false;
466
- if (patch.game_mode_on != null) {
467
- if (typeof patch.game_mode_on !== "boolean")
468
- bad = true;
469
- else
470
- clean.game_mode_on = patch.game_mode_on;
471
- }
472
- if (patch.theme != null) {
473
- if (typeof patch.theme !== "string" || !isValidTheme(patch.theme))
474
- bad = true;
475
- else
476
- clean.theme = patch.theme;
477
- }
478
- if (patch.tui_display_mode != null) {
479
- if (patch.tui_display_mode !== "full" &&
480
- patch.tui_display_mode !== "minimal")
481
- bad = true;
482
- else
483
- clean.tui_display_mode = patch.tui_display_mode;
484
- }
485
- if (bad) {
486
- res.writeHead(400, { "Content-Type": "application/json" });
487
- res.end(JSON.stringify({ error: "invalid_patch" }));
488
- return;
489
- }
490
- try {
491
- const gs = setGameState(clean, stateDir); // guardrails-allow PREVENT-PI-004: local SQLite write (loopback dashboard)
492
- res.writeHead(200, { "Content-Type": "application/json" });
493
- res.end(JSON.stringify(gs));
494
- }
495
- catch (e) {
496
- res.writeHead(500, { "Content-Type": "application/json" });
497
- res.end(JSON.stringify({
498
- error: "game_state_write_failed",
499
- detail: String(e),
500
- }));
501
- }
502
- });
503
- return;
504
- }
505
- // Any other method on /api/game-state → 405.
506
- res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
507
- res.end(JSON.stringify({ error: "method_not_allowed" }));
178
+ if (handleEvents(req, res, ctx))
508
179
  return;
509
- }
510
- // /api/game-scores — S34 high-score leaderboards. GET returns the leaderboard
511
- // for a metric (?metric=<m>&limit=<n>). `metric` is validated against the
512
- // METRICS allow-list from src/game/scoring (re-exported via the sqlite barrel);
513
- // default limit 10, clamped to [1,100]. The dashboard server is a detached
514
- // child with no MegaRuntime ref, so it reads the game_scores SQLite table
515
- // directly. Unknown metric -> 400, non-GET -> 405. PREVENT-PI-004: loopback.
516
- if (req.url?.startsWith("/api/game-scores")) {
517
- const gsReq = createRequire(import.meta.url);
518
- const { leaderboard, METRICS } = gsReq("../../src/store/sqlite.js");
519
- if (req.method !== "GET") {
520
- res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
521
- res.end(JSON.stringify({ error: "method_not_allowed" }));
522
- return;
523
- }
524
- try {
525
- const url = new URL(req.url, "http://x"); // guardrails-allow PREVENT-PI-004: localhost dashboard URL base (loopback-only)
526
- const metricParam = url.searchParams.get("metric") ?? "cache";
527
- if (!METRICS.includes(metricParam)) {
528
- res.writeHead(400, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
529
- res.end(JSON.stringify({ error: "unknown_metric", metric: metricParam }));
530
- return;
531
- }
532
- const metric = metricParam; // validated against METRICS above
533
- let limit = Number(url.searchParams.get("limit") ?? "10");
534
- if (!Number.isFinite(limit) || limit <= 0)
535
- limit = 10;
536
- limit = Math.min(Math.max(limit, 1), 100); // clamp to [1,100]
537
- const rows = leaderboard(stateDir, metric, { limit }); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
538
- res.writeHead(200, { "Content-Type": "application/json" });
539
- res.end(JSON.stringify(rows));
540
- }
541
- catch (e) {
542
- res.writeHead(500, { "Content-Type": "application/json" });
543
- res.end(JSON.stringify({
544
- error: "game_scores_unavailable",
545
- detail: String(e),
546
- }));
547
- }
180
+ if (handleGameState(req, res, ctx))
548
181
  return;
549
- }
550
- // /api/perf — v0.8.8 Perf dashboard tab. GET returns rolling-window
551
- // aggregates over perf_samples: per-kind p50/p95 (turn/provider latency,
552
- // tps avg, db recompute, disk write), latest rss/heap, cpu user/sys delta,
553
- // cache hit %, plus the diag recompute/skip/replay counts (read from
554
- // dashboard.json snapshot if available). The dashboard server is a detached
555
- // child with no MegaRuntime ref, so it reads perf_samples via a require()'d
556
- // sqlite helper (same pattern as /api/game-scores). Unknown/invalid params
557
- // are clamped (never throw). Non-GET -> 405. PREVENT-PI-004: loopback.
558
- if (req.url?.startsWith("/api/perf")) {
559
- const pfReq = createRequire(import.meta.url);
560
- const { readPerfSamples } = pfReq("../../src/store/sqlite.js");
561
- if (req.method !== "GET") {
562
- res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
563
- res.end(JSON.stringify({ error: "method_not_allowed" }));
564
- return;
565
- }
566
- try {
567
- const url = new URL(req.url, "http://x"); // guardrails-allow PREVENT-PI-004: localhost dashboard URL base (loopback-only)
568
- let minutes = Number(url.searchParams.get("minutes") ?? "30");
569
- if (!Number.isFinite(minutes) || minutes <= 0)
570
- minutes = 30;
571
- minutes = Math.min(minutes, 1440); // cap at 24h
572
- const sinceTs = Date.now() - minutes * 60_000;
573
- const rows = readPerfSamples(stateDir, sinceTs); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
574
- const byKind = new Map();
575
- for (const r of rows) {
576
- let arr = byKind.get(r.kind);
577
- if (!arr) {
578
- arr = [];
579
- byKind.set(r.kind, arr);
580
- }
581
- arr.push(r.value);
582
- }
583
- // Nearest-rank percentile (ceil(p/100*n)-1, clamped). Code-controlled,
584
- // never user input (PREVENT-002 safe).
585
- function pct(arr, p) {
586
- if (!arr.length)
587
- return 0;
588
- const s = [...arr].sort((a, b) => a - b);
589
- const idx = Math.min(s.length - 1, Math.max(0, Math.ceil((p / 100) * s.length) - 1));
590
- return s[idx];
591
- }
592
- function avg(arr) {
593
- if (!arr.length)
594
- return 0;
595
- return arr.reduce((a, b) => a + b, 0) / arr.length;
596
- }
597
- // rows are ASC by ts, so the last pushed value is the most recent.
598
- function latest(arr) {
599
- return arr.length ? arr[arr.length - 1] : 0;
600
- }
601
- const get = (k) => byKind.get(k) ?? [];
602
- // diag counters live in the runtime-written dashboard.json (the server is
603
- // a detached child with no MegaRuntime ref). Read defensively — absent
604
- // until the first snapshot() write (PREVENT-001: assign before access).
605
- let diag = null;
606
- try {
607
- const raw = readFileSync(snapshotPath, "utf-8");
608
- const parsed = JSON.parse(raw);
609
- if (parsed && typeof parsed === "object" && parsed.diag)
610
- diag = parsed.diag;
611
- }
612
- catch {
613
- /* dashboard.json not written yet */
614
- }
615
- res.writeHead(200, { "Content-Type": "application/json" });
616
- res.end(JSON.stringify({
617
- updatedAt: new Date().toISOString(),
618
- windowMinutes: minutes,
619
- sampleCount: rows.length,
620
- turn_latency_ms: {
621
- p50: pct(get("turn_latency_ms"), 50),
622
- p95: pct(get("turn_latency_ms"), 95),
623
- n: get("turn_latency_ms").length,
624
- },
625
- provider_latency_ms: {
626
- p50: pct(get("provider_latency_ms"), 50),
627
- p95: pct(get("provider_latency_ms"), 95),
628
- n: get("provider_latency_ms").length,
629
- },
630
- tps: { avg: avg(get("tps")), n: get("tps").length },
631
- cache_hit_pct: {
632
- avg: avg(get("cache_hit_pct")),
633
- latest: latest(get("cache_hit_pct")),
634
- n: get("cache_hit_pct").length,
635
- },
636
- db_recompute_ms: {
637
- p50: pct(get("db_recompute_ms"), 50),
638
- p95: pct(get("db_recompute_ms"), 95),
639
- n: get("db_recompute_ms").length,
640
- },
641
- disk_write_ms: {
642
- p50: pct(get("disk_write_ms"), 50),
643
- p95: pct(get("disk_write_ms"), 95),
644
- n: get("disk_write_ms").length,
645
- },
646
- rss_mb: { latest: latest(get("rss_mb")), n: get("rss_mb").length },
647
- heap_mb: {
648
- latest: latest(get("heap_mb")),
649
- n: get("heap_mb").length,
650
- },
651
- cpu_user_ms: {
652
- latest: latest(get("cpu_user_ms")),
653
- n: get("cpu_user_ms").length,
654
- },
655
- cpu_sys_ms: {
656
- latest: latest(get("cpu_sys_ms")),
657
- n: get("cpu_sys_ms").length,
658
- },
659
- diag,
660
- }));
661
- }
662
- catch (e) {
663
- res.writeHead(500, { "Content-Type": "application/json" });
664
- res.end(JSON.stringify({ error: "perf_unavailable", detail: String(e) }));
665
- }
182
+ if (handleGameScores(req, res, ctx))
666
183
  return;
667
- }
668
- // /api/achievements — S35 achievement tiles. GET returns the 9 seeded rows
669
- // {id,title,description,icon,hidden,unlocked_at}. The dashboard server is a
670
- // detached child with no MegaRuntime ref, so it reads game_achievements via
671
- // listAchievements(stateDir) directly. Non-GET -> 405. PREVENT-PI-004: loopback.
672
- if (req.url?.startsWith("/api/achievements")) {
673
- const achReq = createRequire(import.meta.url);
674
- const { listAchievements } = achReq("../../src/store/sqlite.js");
675
- if (req.method !== "GET") {
676
- res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
677
- res.end(JSON.stringify({ error: "method_not_allowed" }));
678
- return;
679
- }
680
- try {
681
- const rows = listAchievements(stateDir); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
682
- res.writeHead(200, { "Content-Type": "application/json" });
683
- res.end(JSON.stringify(rows));
684
- }
685
- catch (e) {
686
- res.writeHead(500, { "Content-Type": "application/json" });
687
- res.end(JSON.stringify({
688
- error: "achievements_unavailable",
689
- detail: String(e),
690
- }));
691
- }
184
+ if (handlePerf(req, res, ctx))
692
185
  return;
693
- }
694
- // /api/sessions — S39: active pi sessions with latest token usage + heartbeat.
695
- // GET returns {updatedAt, pruned, sessions[]} after pruning stale heartbeats.
696
- // The dashboard server is a detached child with no MegaRuntime ref, so it
697
- // reads session_heartbeats via a require()'d sqlite helper (same pattern as
698
- // /api/achievements, /api/perf). Non-GET -> 405. PREVENT-PI-004: loopback.
699
- if (req.url?.startsWith("/api/sessions")) {
700
- // Guard: /api/sessions/timeseries handled separately below.
701
- if (req.url.startsWith("/api/sessions/timeseries")) {
702
- // Fall through to the timeseries handler below.
703
- }
704
- else {
705
- const sReq = createRequire(import.meta.url);
706
- const { readActiveSessions, pruneStaleSessions, listRepoRegistry, } = sReq("../../src/store/sqlite.js");
707
- if (req.method !== "GET") {
708
- res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
709
- res.end(JSON.stringify({ error: "method_not_allowed" }));
710
- return;
711
- }
712
- try {
713
- const pruned = pruneStaleSessions(); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
714
- const active = readActiveSessions();
715
- const repos = listRepoRegistry();
716
- const repoMap = new Map(repos.map((r) => [r.repoRoot, r]));
717
- const sessions = active.map((s) => ({
718
- pid: s.pid,
719
- sessionId: s.sessionId,
720
- repoRoot: s.repoRoot,
721
- displayName: s.repoRoot
722
- ? (s.repoRoot.split(/[\\/]/).filter(Boolean).pop() ?? s.repoRoot)
723
- : (s.stateDir?.split(/[\\/]/).filter(Boolean).pop() ?? "unknown"),
724
- model: s.repoRoot ? (repoMap.get(s.repoRoot)?.modelName ?? null) : null,
725
- tokens: s.tokens,
726
- percent: s.percent,
727
- ctxWindow: s.ctxWindow,
728
- lastSeen: s.lastSeen,
729
- stateDir: s.stateDir,
730
- }));
731
- res.writeHead(200, { "Content-Type": "application/json" });
732
- res.end(JSON.stringify({ updatedAt: new Date().toISOString(), pruned, sessions }));
733
- }
734
- catch (e) {
735
- res.writeHead(500, { "Content-Type": "application/json" });
736
- res.end(JSON.stringify({ error: "sessions_unavailable", detail: String(e) }));
737
- }
738
- return;
739
- }
740
- }
741
- // /api/sessions/timeseries — S39: stacked per-session token timeseries for
742
- // the recharts memory graph. GET ?minutes=N (clamped [1,1440]) returns
743
- // {updatedAt, windowMinutes, series[], totals[]} in recharts-ready shape.
744
- // Non-GET -> 405. PREVENT-PI-004: loopback.
745
- if (req.url?.startsWith("/api/sessions/timeseries")) {
746
- const tsReq = createRequire(import.meta.url);
747
- const { readSessionTimeseries, pruneTokenSamples, } = tsReq("../../src/store/sqlite.js");
748
- if (req.method !== "GET") {
749
- res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
750
- res.end(JSON.stringify({ error: "method_not_allowed" }));
751
- return;
752
- }
753
- try {
754
- const url = new URL(req.url, "http://x"); // guardrails-allow PREVENT-PI-004: localhost dashboard URL base (loopback-only)
755
- let minutes = Number(url.searchParams.get("minutes") ?? "30");
756
- if (!Number.isFinite(minutes) || minutes <= 0)
757
- minutes = 30;
758
- minutes = Math.min(Math.max(minutes, 1), 1440);
759
- const sinceTs = Date.now() - minutes * 60_000;
760
- const pruneMs = Math.max(minutes * 60_000, 1_800_000);
761
- pruneTokenSamples(pruneMs); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
762
- const result = readSessionTimeseries(sinceTs); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
763
- res.writeHead(200, { "Content-Type": "application/json" });
764
- res.end(JSON.stringify({
765
- updatedAt: new Date().toISOString(),
766
- windowMinutes: minutes,
767
- series: result.series,
768
- totals: result.totals,
769
- }));
770
- }
771
- catch (e) {
772
- res.writeHead(500, { "Content-Type": "application/json" });
773
- res.end(JSON.stringify({ error: "timeseries_unavailable", detail: String(e) }));
774
- }
186
+ if (handleAchievements(req, res, ctx))
775
187
  return;
776
- }
777
- // Fallback — serve the React client build (SPA route) or legacy dashboard.
778
- // Non-/api/* GETs hit here: serve client assets if built, else inline HTML.
779
- if (req.method === "GET" &&
780
- req.url &&
781
- !req.url.startsWith("/api/") &&
782
- serveClientAsset(req.url, res)) {
188
+ if (handleSessions(req, res, ctx))
783
189
  return;
784
- }
785
- const tier = readSnapshot(snapshotPath).tier;
786
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
787
- res.end(dashboardHtml(tier));
190
+ handleStatic(req, res, ctx);
788
191
  });
789
192
  // Bind base + range are env-configurable so tests can use a private,
790
193
  // non-colliding range (parallel runs / leftover servers from killed runs