pi-mega-compact 0.8.21 → 0.8.23

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 (84) hide show
  1. package/LICENSE +6 -2
  2. package/README.md +1 -1
  3. package/dist/extensions/dashboard-server/dashboard-client-core.js +201 -0
  4. package/dist/extensions/dashboard-server/dashboard-client-game.js +241 -0
  5. package/dist/extensions/dashboard-server/dashboard-client-repos.js +212 -0
  6. package/dist/extensions/dashboard-server/dashboard-client.js +19 -0
  7. package/dist/extensions/dashboard-server/html.js +2 -621
  8. package/dist/extensions/dashboard-server/routes-core.js +62 -0
  9. package/dist/extensions/dashboard-server/routes-game.js +323 -0
  10. package/dist/extensions/dashboard-server/routes-repo.js +170 -0
  11. package/dist/extensions/dashboard-server/routes-sessions.js +159 -0
  12. package/dist/extensions/dashboard-server/routes.js +10 -0
  13. package/dist/extensions/dashboard-server/server.js +26 -623
  14. package/dist/extensions/mega-commands.js +4 -3
  15. package/dist/extensions/mega-events/agent-handlers.js +2 -1
  16. package/dist/extensions/mega-events/compact-handlers.js +26 -0
  17. package/dist/extensions/mega-events/session-handlers.js +2 -1
  18. package/dist/extensions/mega-pipeline/compact.js +3 -2
  19. package/dist/extensions/mega-runtime/state.js +7 -7
  20. package/dist/src/dedup/raptor/multilevel.js +172 -0
  21. package/dist/src/dedup/raptor/multilevel.test.js +203 -0
  22. package/dist/src/dedup/raptor/promote.test.js +5 -5
  23. package/dist/src/dedup/raptor/retrieval.js +1 -1
  24. package/dist/src/dedup/sprint12.test.js +7 -7
  25. package/dist/src/dedup-engine.test.js +29 -29
  26. package/dist/src/e2e.test.js +38 -38
  27. package/dist/src/engine.js +3 -3
  28. package/dist/src/engine.test.js +6 -6
  29. package/dist/src/importance.js +197 -0
  30. package/dist/src/importance.test.js +372 -0
  31. package/dist/src/ratio.bench.test.js +18 -18
  32. package/dist/src/recall.js +6 -5
  33. package/dist/src/recall.test.js +85 -27
  34. package/dist/src/sprint14.test.js +2 -2
  35. package/dist/src/store/migrate.test.js +5 -5
  36. package/dist/src/store/sprint10.test.js +5 -5
  37. package/dist/src/store/sqlite/global-index.js +5 -174
  38. package/dist/src/store/sqlite/global-sessions.js +190 -0
  39. package/dist/src/vector-read.js +168 -0
  40. package/dist/src/vector-search.js +191 -0
  41. package/dist/src/vectorStore.js +10 -297
  42. package/dist/src/vectorStore.test.js +32 -32
  43. package/extensions/dashboard-server/dashboard-client-core.ts +202 -0
  44. package/extensions/dashboard-server/dashboard-client-game.ts +242 -0
  45. package/extensions/dashboard-server/dashboard-client-repos.ts +213 -0
  46. package/extensions/dashboard-server/dashboard-client.ts +21 -0
  47. package/extensions/dashboard-server/html.ts +2 -621
  48. package/extensions/dashboard-server/routes-core.ts +113 -0
  49. package/extensions/dashboard-server/routes-game.ts +386 -0
  50. package/extensions/dashboard-server/routes-repo.ts +212 -0
  51. package/extensions/dashboard-server/routes-sessions.ts +195 -0
  52. package/extensions/dashboard-server/routes.ts +13 -0
  53. package/extensions/dashboard-server/server.ts +37 -700
  54. package/extensions/mega-commands.ts +4 -3
  55. package/extensions/mega-events/agent-handlers.ts +2 -1
  56. package/extensions/mega-events/compact-handlers.ts +28 -0
  57. package/extensions/mega-events/session-handlers.ts +2 -1
  58. package/extensions/mega-pipeline/compact.ts +3 -2
  59. package/extensions/mega-runtime/state.ts +7 -7
  60. package/extensions/openclaw-mega-compact.ts +2 -2
  61. package/package.json +2 -2
  62. package/src/dedup/raptor/multilevel.test.ts +278 -0
  63. package/src/dedup/raptor/multilevel.ts +246 -0
  64. package/src/dedup/raptor/promote.test.ts +5 -5
  65. package/src/dedup/raptor/retrieval.ts +1 -1
  66. package/src/dedup/sprint12.test.ts +7 -7
  67. package/src/dedup-engine.test.ts +30 -30
  68. package/src/e2e.test.ts +38 -38
  69. package/src/engine.test.ts +6 -6
  70. package/src/engine.ts +3 -3
  71. package/src/importance.test.ts +538 -0
  72. package/src/importance.ts +312 -0
  73. package/src/ratio.bench.test.ts +18 -18
  74. package/src/recall.test.ts +101 -29
  75. package/src/recall.ts +9 -9
  76. package/src/sprint14.test.ts +2 -2
  77. package/src/store/migrate.test.ts +5 -5
  78. package/src/store/sprint10.test.ts +5 -5
  79. package/src/store/sqlite/global-index.ts +18 -290
  80. package/src/store/sqlite/global-sessions.ts +291 -0
  81. package/src/vector-read.ts +237 -0
  82. package/src/vector-search.ts +231 -0
  83. package/src/vectorStore.test.ts +32 -32
  84. package/src/vectorStore.ts +29 -356
@@ -0,0 +1,62 @@
1
+ /**
2
+ * dashboard-server/routes-core.ts — RouteContext interface + shared helpers.
3
+ *
4
+ * The core module defines the shared context interface and the factory that
5
+ * builds it. Route handler files import from here; the barrel (routes.ts)
6
+ * re-exports everything for server.ts.
7
+ */
8
+ import { readSnapshot } from "./snapshot.js";
9
+ // ---------------------------------------------------------------------------
10
+ // Helper: overlayCurrentRepo
11
+ // ---------------------------------------------------------------------------
12
+ function makeOverlayCurrentRepo(snapshotPath, stateDir) {
13
+ return (idx) => {
14
+ if (!idx || !idx.repos.length)
15
+ return;
16
+ let snap = null;
17
+ try {
18
+ snap = readSnapshot(snapshotPath);
19
+ }
20
+ catch {
21
+ return;
22
+ }
23
+ if (!snap || !snap.repo)
24
+ return;
25
+ const cur = idx.repos.find((r) => r.stateDir === stateDir);
26
+ if (!cur)
27
+ return;
28
+ const prevSaved = cur.tokensSaved;
29
+ const prevCp = cur.checkpointCount;
30
+ const prevBytes = cur.compressedOriginalBytes;
31
+ const comp = snap.compression?.repo;
32
+ const liveSaved = comp
33
+ ? comp.tokensFreed
34
+ : (snap.repo.tokensSaved ?? prevSaved);
35
+ const liveCp = snap.repo.checkpointCount ?? prevCp;
36
+ const liveBytes = snap.integrity?.compressedOriginalBytes ?? prevBytes;
37
+ cur.tokensSaved = liveSaved;
38
+ cur.checkpointCount = liveCp;
39
+ cur.compressedOriginalBytes = liveBytes;
40
+ if (idx.summary) {
41
+ idx.summary.totalTokensSaved += liveSaved - prevSaved;
42
+ idx.summary.totalCheckpoints += liveCp - prevCp;
43
+ idx.summary.totalCompressedOriginalBytes += liveBytes - prevBytes;
44
+ }
45
+ idx.updatedAt = snap.updatedAt ?? idx.updatedAt;
46
+ };
47
+ }
48
+ // ---------------------------------------------------------------------------
49
+ // Factory — builds a fully-populated RouteContext for server.ts to use.
50
+ // ---------------------------------------------------------------------------
51
+ export function buildRouteContext(opts) {
52
+ return {
53
+ snapshotPath: opts.snapshotPath,
54
+ eventsPath: opts.eventsPath,
55
+ stateDir: opts.stateDir,
56
+ SERVER_VERSION: opts.SERVER_VERSION,
57
+ serveClientAsset: opts.serveClientAsset,
58
+ eventOffsetRef: { value: 0 },
59
+ overlayCurrentRepo: makeOverlayCurrentRepo(opts.snapshotPath, opts.stateDir),
60
+ detectCrossRepoDrift: opts.detectCrossRepoDrift,
61
+ };
62
+ }
@@ -0,0 +1,323 @@
1
+ /**
2
+ * dashboard-server/routes-game.ts — Game-mode and performance route handlers.
3
+ */
4
+ import { createRequire } from "node:module";
5
+ import { readFileSync } from "node:fs";
6
+ // ---------------------------------------------------------------------------
7
+ // handleGameState — "/api/game-state" (GET + PUT)
8
+ // ---------------------------------------------------------------------------
9
+ export function handleGameState(req, res, ctx) {
10
+ if (!req.url?.startsWith("/api/game-state"))
11
+ return false;
12
+ const { stateDir } = ctx;
13
+ // /api/game-state — S32 game-mode settings (game_mode_on / theme /
14
+ // tui_display_mode). GET returns the current row; PUT applies a partial
15
+ // patch (validated) and returns the post-write row. The dashboard server is
16
+ // a detached child with no MegaRuntime ref, so it reads/writes the
17
+ // game_state SQLite row directly; the in-process MegaRuntime picks up the
18
+ // change via its fs.watch cache-eviction watcher. PREVENT-PI-004: loopback.
19
+ const gsReq = createRequire(import.meta.url);
20
+ const { getGameState, setGameState } = gsReq("../../src/store/sqlite.js");
21
+ const { isValidTheme } = gsReq("../../src/config/themes.js");
22
+ if (req.method === "GET") {
23
+ try {
24
+ const gs = getGameState(stateDir); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
25
+ res.writeHead(200, { "Content-Type": "application/json" });
26
+ res.end(JSON.stringify(gs));
27
+ }
28
+ catch (e) {
29
+ res.writeHead(500, { "Content-Type": "application/json" });
30
+ res.end(JSON.stringify({
31
+ error: "game_state_unavailable",
32
+ detail: String(e),
33
+ }));
34
+ }
35
+ return true;
36
+ }
37
+ if (req.method === "PUT") {
38
+ // Read + parse the JSON body (capped — the patch is tiny). The handler
39
+ // is sync, so drain the stream via data/end listeners then continue.
40
+ let body = "";
41
+ let tooBig = false;
42
+ req.on("data", (chunk) => {
43
+ // guardrails-allow PREVENT-PI-004: loopback dashboard request body (local)
44
+ if (body.length > 65536) {
45
+ tooBig = true;
46
+ return;
47
+ }
48
+ body += chunk.toString();
49
+ });
50
+ req.on("end", () => {
51
+ if (tooBig) {
52
+ res.writeHead(413, { "Content-Type": "application/json" });
53
+ res.end(JSON.stringify({ error: "body_too_large" }));
54
+ return;
55
+ }
56
+ let patch = {};
57
+ try {
58
+ patch = body ? JSON.parse(body) : {};
59
+ }
60
+ catch {
61
+ res.writeHead(400, { "Content-Type": "application/json" });
62
+ res.end(JSON.stringify({ error: "invalid_json" }));
63
+ return;
64
+ }
65
+ // Reject valid-but-non-object JSON (null/[]/42) — dereferencing
66
+ // patch.game_mode_on would throw an unhandled TypeError inside this
67
+ // 'end' listener and crash the detached server (audit P1: loopback DoS).
68
+ if (typeof patch !== "object" ||
69
+ patch === null ||
70
+ Array.isArray(patch)) {
71
+ res.writeHead(400, { "Content-Type": "application/json" });
72
+ res.end(JSON.stringify({ error: "invalid_patch_object" }));
73
+ return;
74
+ }
75
+ // Validate the patch fields (unknown keys ignored; invalid values -> 400).
76
+ const clean = {};
77
+ let bad = false;
78
+ if (patch.game_mode_on != null) {
79
+ if (typeof patch.game_mode_on !== "boolean")
80
+ bad = true;
81
+ else
82
+ clean.game_mode_on = patch.game_mode_on;
83
+ }
84
+ if (patch.theme != null) {
85
+ if (typeof patch.theme !== "string" || !isValidTheme(patch.theme))
86
+ bad = true;
87
+ else
88
+ clean.theme = patch.theme;
89
+ }
90
+ if (patch.tui_display_mode != null) {
91
+ if (patch.tui_display_mode !== "full" &&
92
+ patch.tui_display_mode !== "minimal")
93
+ bad = true;
94
+ else
95
+ clean.tui_display_mode = patch.tui_display_mode;
96
+ }
97
+ if (bad) {
98
+ res.writeHead(400, { "Content-Type": "application/json" });
99
+ res.end(JSON.stringify({ error: "invalid_patch" }));
100
+ return;
101
+ }
102
+ try {
103
+ const gs = setGameState(clean, stateDir); // guardrails-allow PREVENT-PI-004: local SQLite write (loopback dashboard)
104
+ res.writeHead(200, { "Content-Type": "application/json" });
105
+ res.end(JSON.stringify(gs));
106
+ }
107
+ catch (e) {
108
+ res.writeHead(500, { "Content-Type": "application/json" });
109
+ res.end(JSON.stringify({
110
+ error: "game_state_write_failed",
111
+ detail: String(e),
112
+ }));
113
+ }
114
+ });
115
+ return true;
116
+ }
117
+ // Any other method on /api/game-state -> 405.
118
+ res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
119
+ res.end(JSON.stringify({ error: "method_not_allowed" }));
120
+ return true;
121
+ }
122
+ // ---------------------------------------------------------------------------
123
+ // handleGameScores — "/api/game-scores"
124
+ // ---------------------------------------------------------------------------
125
+ export function handleGameScores(req, res, ctx) {
126
+ if (!req.url?.startsWith("/api/game-scores"))
127
+ return false;
128
+ const { stateDir } = ctx;
129
+ // /api/game-scores — S34 high-score leaderboards. GET returns the leaderboard
130
+ // for a metric (?metric=<m>&limit=<n>). `metric` is validated against the
131
+ // METRICS allow-list from src/game/scoring (re-exported via the sqlite barrel);
132
+ // default limit 10, clamped to [1,100]. The dashboard server is a detached
133
+ // child with no MegaRuntime ref, so it reads the game_scores SQLite table
134
+ // directly. Unknown metric -> 400, non-GET -> 405. PREVENT-PI-004: loopback.
135
+ const gsReq = createRequire(import.meta.url);
136
+ const { leaderboard, METRICS } = gsReq("../../src/store/sqlite.js");
137
+ if (req.method !== "GET") {
138
+ res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
139
+ res.end(JSON.stringify({ error: "method_not_allowed" }));
140
+ return true;
141
+ }
142
+ try {
143
+ const url = new URL(req.url, "http://x"); // guardrails-allow PREVENT-PI-004: localhost dashboard URL base (loopback-only)
144
+ const metricParam = url.searchParams.get("metric") ?? "cache";
145
+ if (!METRICS.includes(metricParam)) {
146
+ res.writeHead(400, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
147
+ res.end(JSON.stringify({ error: "unknown_metric", metric: metricParam }));
148
+ return true;
149
+ }
150
+ const metric = metricParam; // validated against METRICS above
151
+ let limit = Number(url.searchParams.get("limit") ?? "10");
152
+ if (!Number.isFinite(limit) || limit <= 0)
153
+ limit = 10;
154
+ limit = Math.min(Math.max(limit, 1), 100); // clamp to [1,100]
155
+ const rows = leaderboard(stateDir, metric, { limit }); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
156
+ res.writeHead(200, { "Content-Type": "application/json" });
157
+ res.end(JSON.stringify(rows));
158
+ }
159
+ catch (e) {
160
+ res.writeHead(500, { "Content-Type": "application/json" });
161
+ res.end(JSON.stringify({
162
+ error: "game_scores_unavailable",
163
+ detail: String(e),
164
+ }));
165
+ }
166
+ return true;
167
+ }
168
+ // ---------------------------------------------------------------------------
169
+ // handlePerf — "/api/perf"
170
+ // ---------------------------------------------------------------------------
171
+ export function handlePerf(req, res, ctx) {
172
+ if (!req.url?.startsWith("/api/perf"))
173
+ return false;
174
+ const { stateDir, snapshotPath } = ctx;
175
+ // /api/perf — v0.8.8 Perf dashboard tab. GET returns rolling-window
176
+ // aggregates over perf_samples: per-kind p50/p95 (turn/provider latency,
177
+ // tps avg, db recompute, disk write), latest rss/heap, cpu user/sys delta,
178
+ // cache hit %, plus the diag recompute/skip/replay counts (read from
179
+ // dashboard.json snapshot if available). The dashboard server is a detached
180
+ // child with no MegaRuntime ref, so it reads perf_samples via a require()'d
181
+ // sqlite helper (same pattern as /api/game-scores). Unknown/invalid params
182
+ // are clamped (never throw). Non-GET -> 405. PREVENT-PI-004: loopback.
183
+ const pfReq = createRequire(import.meta.url);
184
+ const { readPerfSamples } = pfReq("../../src/store/sqlite.js");
185
+ if (req.method !== "GET") {
186
+ res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
187
+ res.end(JSON.stringify({ error: "method_not_allowed" }));
188
+ return true;
189
+ }
190
+ try {
191
+ const url = new URL(req.url, "http://x"); // guardrails-allow PREVENT-PI-004: localhost dashboard URL base (loopback-only)
192
+ let minutes = Number(url.searchParams.get("minutes") ?? "30");
193
+ if (!Number.isFinite(minutes) || minutes <= 0)
194
+ minutes = 30;
195
+ minutes = Math.min(minutes, 1440); // cap at 24h
196
+ const sinceTs = Date.now() - minutes * 60_000;
197
+ const rows = readPerfSamples(stateDir, sinceTs); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
198
+ const byKind = new Map();
199
+ for (const r of rows) {
200
+ let arr = byKind.get(r.kind);
201
+ if (!arr) {
202
+ arr = [];
203
+ byKind.set(r.kind, arr);
204
+ }
205
+ arr.push(r.value);
206
+ }
207
+ // Nearest-rank percentile (ceil(p/100*n)-1, clamped). Code-controlled,
208
+ // never user input (PREVENT-002 safe).
209
+ function pct(arr, p) {
210
+ if (!arr.length)
211
+ return 0;
212
+ const s = [...arr].sort((a, b) => a - b);
213
+ const idx = Math.min(s.length - 1, Math.max(0, Math.ceil((p / 100) * s.length) - 1));
214
+ return s[idx];
215
+ }
216
+ function avg(arr) {
217
+ if (!arr.length)
218
+ return 0;
219
+ return arr.reduce((a, b) => a + b, 0) / arr.length;
220
+ }
221
+ // rows are ASC by ts, so the last pushed value is the most recent.
222
+ function latest(arr) {
223
+ return arr.length ? arr[arr.length - 1] : 0;
224
+ }
225
+ const get = (k) => byKind.get(k) ?? [];
226
+ // diag counters live in the runtime-written dashboard.json (the server is
227
+ // a detached child with no MegaRuntime ref). Read defensively — absent
228
+ // until the first snapshot() write (PREVENT-001: assign before access).
229
+ let diag = null;
230
+ try {
231
+ const raw = readFileSync(snapshotPath, "utf-8");
232
+ const parsed = JSON.parse(raw);
233
+ if (parsed && typeof parsed === "object" && parsed.diag)
234
+ diag = parsed.diag;
235
+ }
236
+ catch {
237
+ /* dashboard.json not written yet */
238
+ }
239
+ res.writeHead(200, { "Content-Type": "application/json" });
240
+ res.end(JSON.stringify({
241
+ updatedAt: new Date().toISOString(),
242
+ windowMinutes: minutes,
243
+ sampleCount: rows.length,
244
+ turn_latency_ms: {
245
+ p50: pct(get("turn_latency_ms"), 50),
246
+ p95: pct(get("turn_latency_ms"), 95),
247
+ n: get("turn_latency_ms").length,
248
+ },
249
+ provider_latency_ms: {
250
+ p50: pct(get("provider_latency_ms"), 50),
251
+ p95: pct(get("provider_latency_ms"), 95),
252
+ n: get("provider_latency_ms").length,
253
+ },
254
+ tps: { avg: avg(get("tps")), n: get("tps").length },
255
+ cache_hit_pct: {
256
+ avg: avg(get("cache_hit_pct")),
257
+ latest: latest(get("cache_hit_pct")),
258
+ n: get("cache_hit_pct").length,
259
+ },
260
+ db_recompute_ms: {
261
+ p50: pct(get("db_recompute_ms"), 50),
262
+ p95: pct(get("db_recompute_ms"), 95),
263
+ n: get("db_recompute_ms").length,
264
+ },
265
+ disk_write_ms: {
266
+ p50: pct(get("disk_write_ms"), 50),
267
+ p95: pct(get("disk_write_ms"), 95),
268
+ n: get("disk_write_ms").length,
269
+ },
270
+ rss_mb: { latest: latest(get("rss_mb")), n: get("rss_mb").length },
271
+ heap_mb: {
272
+ latest: latest(get("heap_mb")),
273
+ n: get("heap_mb").length,
274
+ },
275
+ cpu_user_ms: {
276
+ latest: latest(get("cpu_user_ms")),
277
+ n: get("cpu_user_ms").length,
278
+ },
279
+ cpu_sys_ms: {
280
+ latest: latest(get("cpu_sys_ms")),
281
+ n: get("cpu_sys_ms").length,
282
+ },
283
+ diag,
284
+ }));
285
+ }
286
+ catch (e) {
287
+ res.writeHead(500, { "Content-Type": "application/json" });
288
+ res.end(JSON.stringify({ error: "perf_unavailable", detail: String(e) }));
289
+ }
290
+ return true;
291
+ }
292
+ // ---------------------------------------------------------------------------
293
+ // handleAchievements — "/api/achievements"
294
+ // ---------------------------------------------------------------------------
295
+ export function handleAchievements(req, res, ctx) {
296
+ if (!req.url?.startsWith("/api/achievements"))
297
+ return false;
298
+ const { stateDir } = ctx;
299
+ // /api/achievements — S35 achievement tiles. GET returns the 9 seeded rows
300
+ // {id,title,description,icon,hidden,unlocked_at}. The dashboard server is a
301
+ // detached child with no MegaRuntime ref, so it reads game_achievements via
302
+ // listAchievements(stateDir) directly. Non-GET -> 405. PREVENT-PI-004: loopback.
303
+ const achReq = createRequire(import.meta.url);
304
+ const { listAchievements } = achReq("../../src/store/sqlite.js");
305
+ if (req.method !== "GET") {
306
+ res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
307
+ res.end(JSON.stringify({ error: "method_not_allowed" }));
308
+ return true;
309
+ }
310
+ try {
311
+ const rows = listAchievements(stateDir); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
312
+ res.writeHead(200, { "Content-Type": "application/json" });
313
+ res.end(JSON.stringify(rows));
314
+ }
315
+ catch (e) {
316
+ res.writeHead(500, { "Content-Type": "application/json" });
317
+ res.end(JSON.stringify({
318
+ error: "achievements_unavailable",
319
+ detail: String(e),
320
+ }));
321
+ }
322
+ return true;
323
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * dashboard-server/routes-repo.ts — Repo-index and static route handlers.
3
+ */
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { readSnapshot } from "./snapshot.js";
7
+ import { dashboardHtml } from "./html.js";
8
+ import { readIndex, getIndexDir } from "./index-reader.js";
9
+ import { ACTIVE_WINDOW_SEC } from "./types.js";
10
+ // ---------------------------------------------------------------------------
11
+ // handleIndex — "/" | "/index.html" | "/api/snapshot" | "/api/version"
12
+ // ---------------------------------------------------------------------------
13
+ export function handleIndex(req, res, ctx) {
14
+ const { snapshotPath, SERVER_VERSION, serveClientAsset } = ctx;
15
+ if (req.url === "/" || req.url === "/index.html") {
16
+ // Sprint B1: prefer the React client build when present; fall back to the
17
+ // legacy inline html.ts template when the client dist is absent.
18
+ if (serveClientAsset("/", res))
19
+ return true;
20
+ const tier = readSnapshot(snapshotPath).tier;
21
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
22
+ res.end(dashboardHtml(tier));
23
+ return true;
24
+ }
25
+ if (req.url === "/api/snapshot") {
26
+ const snap = readSnapshot(snapshotPath);
27
+ res.writeHead(200, { "Content-Type": "application/json" });
28
+ res.end(JSON.stringify(snap));
29
+ return true;
30
+ }
31
+ // Server version — lets the /dashboard launcher detect a stale server from
32
+ // an older build and replace it on upgrade rather than reuse it.
33
+ if (req.url === "/api/version") {
34
+ res.writeHead(200, { "Content-Type": "application/json" });
35
+ res.end(JSON.stringify({ version: SERVER_VERSION }));
36
+ return true;
37
+ }
38
+ return false;
39
+ }
40
+ // ---------------------------------------------------------------------------
41
+ // handleRepoIndex — "/api/index" | "/api/repos" | "/api/summary" | "/api/drift" | "/api/servers"
42
+ // ---------------------------------------------------------------------------
43
+ export function handleRepoIndex(req, res, ctx) {
44
+ const { overlayCurrentRepo, detectCrossRepoDrift } = ctx;
45
+ if (req.url === "/api/index") {
46
+ const idx = readIndex();
47
+ if (idx)
48
+ overlayCurrentRepo(idx);
49
+ res.writeHead(200, { "Content-Type": "application/json" });
50
+ res.end(JSON.stringify(idx ?? { updatedAt: null, summary: null, repos: [] }));
51
+ return true;
52
+ }
53
+ // /api/repos — registry list. Optional `?active=24h` filters to repos
54
+ // seen within the last N hours (default: all). The dashboard uses this to
55
+ // drive its "active vs archived" badge without refetching /api/index.
56
+ if (req.url?.startsWith("/api/repos")) {
57
+ const url = new URL(req.url, "http://x"); // guardrails-allow PREVENT-PI-004: localhost dashboard URL base (loopback-only)
58
+ const activeParam = url.searchParams.get("active");
59
+ const idx = readIndex();
60
+ if (idx)
61
+ overlayCurrentRepo(idx);
62
+ let repos = idx?.repos ?? [];
63
+ if (activeParam) {
64
+ const m = /^(\d+)h$/.exec(activeParam);
65
+ if (m) {
66
+ const cutoffSec = Math.floor(Date.now() / 1000) - Number(m[1]) * 3600;
67
+ repos = repos.filter((r) => (r.lastSeen ?? 0) >= cutoffSec);
68
+ }
69
+ }
70
+ res.writeHead(200, { "Content-Type": "application/json" });
71
+ res.end(JSON.stringify({
72
+ updatedAt: idx?.updatedAt ?? null,
73
+ repos,
74
+ count: repos.length,
75
+ }));
76
+ return true;
77
+ }
78
+ // /api/summary — header tiles without the full repo list (keeps payload
79
+ // small for embed scenarios). activeRepos mirrors the /api/repos?active=24h
80
+ // count so the dashboard can render the active badge alongside totals.
81
+ if (req.url?.startsWith("/api/summary")) {
82
+ const idx = readIndex();
83
+ if (idx)
84
+ overlayCurrentRepo(idx);
85
+ const repos = idx?.repos ?? [];
86
+ const cutoffSec = Math.floor(Date.now() / 1000) - 24 * 3600;
87
+ const activeRepos = repos.filter((r) => (r.lastSeen ?? 0) >= cutoffSec).length;
88
+ res.writeHead(200, { "Content-Type": "application/json" });
89
+ res.end(JSON.stringify({
90
+ updatedAt: idx?.updatedAt ?? null,
91
+ summary: idx?.summary ?? null,
92
+ activeRepos,
93
+ totalRepos: repos.length,
94
+ }));
95
+ return true;
96
+ }
97
+ // /api/drift — R4: cross-repo drift report over repo_registry. Flags stale
98
+ // repos (>30d idle), compaction lag (active but >24h since last
99
+ // compaction), and recent model churn. Read-only.
100
+ if (req.url?.startsWith("/api/drift")) {
101
+ const report = detectCrossRepoDrift(getIndexDir());
102
+ res.writeHead(200, { "Content-Type": "application/json" });
103
+ res.end(JSON.stringify(report));
104
+ return true;
105
+ }
106
+ if (req.url === "/api/servers") {
107
+ try {
108
+ const idx = readIndex();
109
+ const nowSec = Math.floor(Date.now() / 1000);
110
+ const servers = (idx?.repos ?? [])
111
+ .filter((r) => (r.lastSeen ?? 0) >= nowSec - ACTIVE_WINDOW_SEC)
112
+ .map((r) => {
113
+ const out = {
114
+ repoRoot: r.repoRoot,
115
+ displayName: r.displayName,
116
+ model: r.modelName,
117
+ provider: r.providerName,
118
+ lastSeen: r.lastSeen,
119
+ lastCompactedAt: r.lastCompactedAt,
120
+ };
121
+ try {
122
+ const p = join(r.stateDir, "dashboard.json");
123
+ if (existsSync(p)) {
124
+ const snap = JSON.parse(readFileSync(p, "utf-8"));
125
+ out.tier = snap.tier ?? null;
126
+ out.contextPct =
127
+ snap.context && snap.context.percent != null
128
+ ? snap.context.percent
129
+ : null;
130
+ out.state = (snap.session && snap.session.state) || null;
131
+ out.cacheHits = snap.cacheHits ?? null;
132
+ out.compacts = snap.compacts ?? null;
133
+ out.timeSaved = snap.timeSaved ?? null;
134
+ out.updatedAt = snap.updatedAt ?? null;
135
+ }
136
+ }
137
+ catch {
138
+ /* best-effort */
139
+ }
140
+ return out;
141
+ })
142
+ .sort((a, b) => b.lastSeen - a.lastSeen);
143
+ res.writeHead(200, { "Content-Type": "application/json" });
144
+ res.end(JSON.stringify({ updatedAt: new Date().toISOString(), servers }));
145
+ }
146
+ catch {
147
+ res.writeHead(500, { "Content-Type": "application/json" });
148
+ res.end(JSON.stringify({ error: "servers_unavailable" }));
149
+ }
150
+ return true;
151
+ }
152
+ return false;
153
+ }
154
+ // ---------------------------------------------------------------------------
155
+ // handleStatic — non-/api/* GET fallback (SPA client build or legacy HTML).
156
+ // ---------------------------------------------------------------------------
157
+ export function handleStatic(req, res, _ctx) {
158
+ const { snapshotPath, serveClientAsset } = _ctx;
159
+ if (req.method === "GET" &&
160
+ req.url &&
161
+ !req.url.startsWith("/api/") &&
162
+ serveClientAsset(req.url, res)) {
163
+ return true;
164
+ }
165
+ // Legacy SPA fallback — serve inline HTML with current tier.
166
+ const tier = readSnapshot(snapshotPath).tier;
167
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
168
+ res.end(dashboardHtml(tier));
169
+ return true;
170
+ }