pi-mega-compact 0.7.9 → 0.8.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/dist/extensions/dashboard-server/html.js +333 -66
- package/dist/extensions/dashboard-server/html.test.js +41 -0
- package/dist/extensions/dashboard-server/server.js +161 -1
- package/dist/extensions/dashboard-server/server.test.js +120 -0
- package/dist/extensions/dashboard-server-s32.test.js +181 -0
- package/dist/extensions/mega-compact.js +3 -0
- package/dist/extensions/mega-events/agent-handlers.js +49 -0
- package/dist/extensions/mega-events/compact-handlers.js +29 -0
- package/dist/extensions/mega-game-cmds.js +106 -0
- package/dist/extensions/mega-game-cmds.test.js +113 -0
- package/dist/extensions/mega-runtime/state.js +167 -1
- package/dist/extensions/mega-runtime/state.test.js +171 -0
- package/dist/extensions/mega-runtime/widget.js +93 -20
- package/dist/extensions/mega-runtime/widget.test.js +160 -0
- package/dist/src/config/themes.js +84 -0
- package/dist/src/config/themes.test.js +94 -0
- package/dist/src/game/scoring.js +105 -0
- package/dist/src/game/scoring.test.js +98 -0
- package/dist/src/store/sqlite/game-achievements.js +111 -0
- package/dist/src/store/sqlite/game-achievements.test.js +67 -0
- package/dist/src/store/sqlite/game-scores.js +105 -0
- package/dist/src/store/sqlite/game-scores.test.js +106 -0
- package/dist/src/store/sqlite/game-state.js +54 -0
- package/dist/src/store/sqlite/game-state.test.js +76 -0
- package/dist/src/store/sqlite/schema.js +44 -0
- package/dist/src/store/sqlite.js +3 -0
- package/extensions/dashboard-server/html.test.ts +50 -0
- package/extensions/dashboard-server/html.ts +334 -66
- package/extensions/dashboard-server/server.test.ts +131 -0
- package/extensions/dashboard-server/server.ts +148 -1
- package/extensions/dashboard-server-s32.test.ts +195 -0
- package/extensions/mega-compact.ts +3 -0
- package/extensions/mega-events/agent-handlers.ts +48 -0
- package/extensions/mega-events/compact-handlers.ts +28 -0
- package/extensions/mega-game-cmds.test.ts +137 -0
- package/extensions/mega-game-cmds.ts +122 -0
- package/extensions/mega-runtime/state.test.ts +171 -0
- package/extensions/mega-runtime/state.ts +170 -0
- package/extensions/mega-runtime/widget.test.ts +185 -0
- package/extensions/mega-runtime/widget.ts +119 -18
- package/package.json +1 -1
- package/src/config/themes.test.ts +116 -0
- package/src/config/themes.ts +124 -0
- package/src/game/scoring.test.ts +103 -0
- package/src/game/scoring.ts +158 -0
- package/src/store/sqlite/game-achievements.test.ts +80 -0
- package/src/store/sqlite/game-achievements.ts +147 -0
- package/src/store/sqlite/game-scores.test.ts +132 -0
- package/src/store/sqlite/game-scores.ts +168 -0
- package/src/store/sqlite/game-state.test.ts +89 -0
- package/src/store/sqlite/game-state.ts +87 -0
- package/src/store/sqlite/schema.ts +47 -0
- package/src/store/sqlite.ts +3 -0
- package/dist/extensions/dashboard-server/helpers.js +0 -37
- package/dist/extensions/dashboard-server/html/all-repos-tab.js +0 -26
- package/dist/extensions/dashboard-server/html/body-open.js +0 -23
- package/dist/extensions/dashboard-server/html/current-repo-tab.js +0 -130
- package/dist/extensions/dashboard-server/html/head-open.js +0 -16
- package/dist/extensions/dashboard-server/html/high-score-tab.js +0 -25
- package/dist/extensions/dashboard-server/html/repo-detail-modal.js +0 -26
- package/dist/extensions/dashboard-server/html/script.js +0 -259
- package/dist/extensions/dashboard-server/html/styles.js +0 -103
- package/dist/extensions/dashboard-server/html/summary-tab.js +0 -19
- package/dist/extensions/dashboard-server/html-template.js +0 -41
- package/dist/src/store/sqlite/connection.js +0 -35
- package/dist/src/store/sqlite/index-store.js +0 -167
- package/dist/src/store/sqlite/memory.js +0 -54
- package/dist/src/store/sqlite/minhash-lsh.js +0 -47
- package/dist/src/store/sqlite/sessions.js +0 -39
- package/dist/src/store/sqlite/transaction.js +0 -19
- package/dist/src/vectorStore/add.js +0 -260
- package/dist/src/vectorStore/dedup.js +0 -52
- package/dist/src/vectorStore/index.js +0 -10
- package/dist/src/vectorStore/queries.js +0 -83
- package/dist/src/vectorStore/search.js +0 -95
- package/dist/src/vectorStore/session.js +0 -19
- package/dist/src/vectorStore/store.js +0 -105
- package/dist/src/vectorStore/types.js +0 -6
- package/dist/src/vectorStore/utils.js +0 -23
|
@@ -123,7 +123,7 @@ export async function launchDashboardServer(stateDir) {
|
|
|
123
123
|
// guardrails-allow PREVENT-PI-004: optional, user-triggered /dashboard localhost server (loopback-only) — CORS open for local browser access
|
|
124
124
|
// CORS for local access
|
|
125
125
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
126
|
-
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
|
126
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, PUT, OPTIONS");
|
|
127
127
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
128
128
|
if (req.method === "OPTIONS") {
|
|
129
129
|
res.writeHead(204);
|
|
@@ -301,6 +301,166 @@ export async function launchDashboardServer(stateDir) {
|
|
|
301
301
|
});
|
|
302
302
|
return;
|
|
303
303
|
}
|
|
304
|
+
// /api/game-state — S32 game-mode settings (game_mode_on / theme /
|
|
305
|
+
// tui_display_mode). GET returns the current row; PUT applies a partial
|
|
306
|
+
// patch (validated) and returns the post-write row. The dashboard server is
|
|
307
|
+
// a detached child with no MegaRuntime ref, so it reads/writes the
|
|
308
|
+
// game_state SQLite row directly; the in-process MegaRuntime picks up the
|
|
309
|
+
// change via its fs.watch cache-eviction watcher. PREVENT-PI-004: loopback.
|
|
310
|
+
if (req.url?.startsWith("/api/game-state")) {
|
|
311
|
+
const gsReq = createRequire(import.meta.url);
|
|
312
|
+
const { getGameState, setGameState } = gsReq("../../src/store/sqlite.js");
|
|
313
|
+
const { isValidTheme } = gsReq("../../src/config/themes.js");
|
|
314
|
+
if (req.method === "GET") {
|
|
315
|
+
try {
|
|
316
|
+
const gs = getGameState(stateDir); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
|
|
317
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
318
|
+
res.end(JSON.stringify(gs));
|
|
319
|
+
}
|
|
320
|
+
catch (e) {
|
|
321
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
322
|
+
res.end(JSON.stringify({ error: "game_state_unavailable", detail: String(e) }));
|
|
323
|
+
}
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (req.method === "PUT") {
|
|
327
|
+
// Read + parse the JSON body (capped — the patch is tiny). The handler
|
|
328
|
+
// is sync, so drain the stream via data/end listeners then continue.
|
|
329
|
+
let body = "";
|
|
330
|
+
let tooBig = false;
|
|
331
|
+
req.on("data", (chunk) => {
|
|
332
|
+
if (body.length > 65536) {
|
|
333
|
+
tooBig = true;
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
body += chunk.toString();
|
|
337
|
+
});
|
|
338
|
+
req.on("end", () => {
|
|
339
|
+
if (tooBig) {
|
|
340
|
+
res.writeHead(413, { "Content-Type": "application/json" });
|
|
341
|
+
res.end(JSON.stringify({ error: "body_too_large" }));
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
let patch = {};
|
|
345
|
+
try {
|
|
346
|
+
patch = body ? JSON.parse(body) : {};
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
350
|
+
res.end(JSON.stringify({ error: "invalid_json" }));
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
// Reject valid-but-non-object JSON (null/[]/42) — dereferencing
|
|
354
|
+
// patch.game_mode_on would throw an unhandled TypeError inside this
|
|
355
|
+
// 'end' listener and crash the detached server (audit P1: loopback DoS).
|
|
356
|
+
if (typeof patch !== "object" || patch === null || Array.isArray(patch)) {
|
|
357
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
358
|
+
res.end(JSON.stringify({ error: "invalid_patch_object" }));
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
// Validate the patch fields (unknown keys ignored; invalid values -> 400).
|
|
362
|
+
const clean = {};
|
|
363
|
+
let bad = false;
|
|
364
|
+
if (patch.game_mode_on != null) {
|
|
365
|
+
if (typeof patch.game_mode_on !== "boolean")
|
|
366
|
+
bad = true;
|
|
367
|
+
else
|
|
368
|
+
clean.game_mode_on = patch.game_mode_on;
|
|
369
|
+
}
|
|
370
|
+
if (patch.theme != null) {
|
|
371
|
+
if (typeof patch.theme !== "string" || !isValidTheme(patch.theme))
|
|
372
|
+
bad = true;
|
|
373
|
+
else
|
|
374
|
+
clean.theme = patch.theme;
|
|
375
|
+
}
|
|
376
|
+
if (patch.tui_display_mode != null) {
|
|
377
|
+
if (patch.tui_display_mode !== "full" && patch.tui_display_mode !== "minimal")
|
|
378
|
+
bad = true;
|
|
379
|
+
else
|
|
380
|
+
clean.tui_display_mode = patch.tui_display_mode;
|
|
381
|
+
}
|
|
382
|
+
if (bad) {
|
|
383
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
384
|
+
res.end(JSON.stringify({ error: "invalid_patch" }));
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
try {
|
|
388
|
+
const gs = setGameState(clean, stateDir); // guardrails-allow PREVENT-PI-004: local SQLite write (loopback dashboard)
|
|
389
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
390
|
+
res.end(JSON.stringify(gs));
|
|
391
|
+
}
|
|
392
|
+
catch (e) {
|
|
393
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
394
|
+
res.end(JSON.stringify({ error: "game_state_write_failed", detail: String(e) }));
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
// Any other method on /api/game-state → 405.
|
|
400
|
+
res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
|
|
401
|
+
res.end(JSON.stringify({ error: "method_not_allowed" }));
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
// /api/game-scores — S34 high-score leaderboards. GET returns the leaderboard
|
|
405
|
+
// for a metric (?metric=<m>&limit=<n>). `metric` is validated against the
|
|
406
|
+
// METRICS allow-list from src/game/scoring (re-exported via the sqlite barrel);
|
|
407
|
+
// default limit 10, clamped to [1,100]. The dashboard server is a detached
|
|
408
|
+
// child with no MegaRuntime ref, so it reads the game_scores SQLite table
|
|
409
|
+
// directly. Unknown metric -> 400, non-GET -> 405. PREVENT-PI-004: loopback.
|
|
410
|
+
if (req.url?.startsWith("/api/game-scores")) {
|
|
411
|
+
const gsReq = createRequire(import.meta.url);
|
|
412
|
+
const { leaderboard, METRICS } = gsReq("../../src/store/sqlite.js");
|
|
413
|
+
if (req.method !== "GET") {
|
|
414
|
+
res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
|
|
415
|
+
res.end(JSON.stringify({ error: "method_not_allowed" }));
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
try {
|
|
419
|
+
const url = new URL(req.url, "http://x"); // guardrails-allow PREVENT-PI-004: localhost dashboard URL base (loopback-only)
|
|
420
|
+
const metricParam = url.searchParams.get("metric") ?? "cache";
|
|
421
|
+
if (!METRICS.includes(metricParam)) {
|
|
422
|
+
res.writeHead(400, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
|
|
423
|
+
res.end(JSON.stringify({ error: "unknown_metric", metric: metricParam }));
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const metric = metricParam; // validated against METRICS above
|
|
427
|
+
let limit = Number(url.searchParams.get("limit") ?? "10");
|
|
428
|
+
if (!Number.isFinite(limit) || limit <= 0)
|
|
429
|
+
limit = 10;
|
|
430
|
+
limit = Math.min(Math.max(limit, 1), 100); // clamp to [1,100]
|
|
431
|
+
const rows = leaderboard(stateDir, metric, { limit }); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
|
|
432
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
433
|
+
res.end(JSON.stringify(rows));
|
|
434
|
+
}
|
|
435
|
+
catch (e) {
|
|
436
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
437
|
+
res.end(JSON.stringify({ error: "game_scores_unavailable", detail: String(e) }));
|
|
438
|
+
}
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
// /api/achievements — S35 achievement tiles. GET returns the 9 seeded rows
|
|
442
|
+
// {id,title,description,icon,hidden,unlocked_at}. The dashboard server is a
|
|
443
|
+
// detached child with no MegaRuntime ref, so it reads game_achievements via
|
|
444
|
+
// listAchievements(stateDir) directly. Non-GET -> 405. PREVENT-PI-004: loopback.
|
|
445
|
+
if (req.url?.startsWith("/api/achievements")) {
|
|
446
|
+
const achReq = createRequire(import.meta.url);
|
|
447
|
+
const { listAchievements } = achReq("../../src/store/sqlite.js");
|
|
448
|
+
if (req.method !== "GET") {
|
|
449
|
+
res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
|
|
450
|
+
res.end(JSON.stringify({ error: "method_not_allowed" }));
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
try {
|
|
454
|
+
const rows = listAchievements(stateDir); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
|
|
455
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
456
|
+
res.end(JSON.stringify(rows));
|
|
457
|
+
}
|
|
458
|
+
catch (e) {
|
|
459
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
460
|
+
res.end(JSON.stringify({ error: "achievements_unavailable", detail: String(e) }));
|
|
461
|
+
}
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
304
464
|
// Fallback — serve the dashboard
|
|
305
465
|
const tier = readSnapshot(snapshotPath).tier;
|
|
306
466
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* server.test.ts — S34 /api/game-scores endpoint (GET leaderboard, metric
|
|
3
|
+
* validation, method + limit handling). Mirrors the S32 /api/game-state
|
|
4
|
+
* spawn-and-fetch harness.
|
|
5
|
+
*/
|
|
6
|
+
import { test, describe } from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import { recordScore } from "../../src/store/sqlite.js";
|
|
13
|
+
const SERVER_ENTRY = new URL("./server.js", import.meta.url).pathname;
|
|
14
|
+
function waitFor(cond, timeoutMs = 6000) {
|
|
15
|
+
const start = Date.now();
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
const tick = async () => {
|
|
18
|
+
if (await cond())
|
|
19
|
+
return resolve();
|
|
20
|
+
if (Date.now() - start > timeoutMs)
|
|
21
|
+
return reject(new Error("timeout"));
|
|
22
|
+
setTimeout(tick, 50);
|
|
23
|
+
};
|
|
24
|
+
tick();
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
function freshDir(prefix) {
|
|
28
|
+
return mkdtempSync(join(tmpdir(), prefix));
|
|
29
|
+
}
|
|
30
|
+
async function withServer(port, dir, fn) {
|
|
31
|
+
process.env.MEGACOMPACT_DASHBOARD_PORT = port;
|
|
32
|
+
const child = spawn(process.execPath, [SERVER_ENTRY, dir], { stdio: "ignore" });
|
|
33
|
+
try {
|
|
34
|
+
await waitFor(async () => {
|
|
35
|
+
try {
|
|
36
|
+
const raw = JSON.parse(readFileSync(join(dir, "port.pid"), "utf-8"));
|
|
37
|
+
const res = await fetch(`http://localhost:${raw.port}/api/version`);
|
|
38
|
+
return res.ok;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
const raw = JSON.parse(readFileSync(join(dir, "port.pid"), "utf-8"));
|
|
45
|
+
return await fn(raw.port);
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
child.kill("SIGTERM");
|
|
49
|
+
delete process.env.MEGACOMPACT_DASHBOARD_PORT;
|
|
50
|
+
rmSync(dir, { recursive: true, force: true });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
describe("S34 /api/game-scores", () => {
|
|
54
|
+
test("GET ?metric=cache returns recorded rows as JSON array", async () => {
|
|
55
|
+
const dir = freshDir("dash-gs-cache-");
|
|
56
|
+
recordScore(dir, { repo_root: "/repo/a", metric: "cache", value: 42 });
|
|
57
|
+
await withServer("19430", dir, async (port) => {
|
|
58
|
+
const res = await fetch(`http://localhost:${port}/api/game-scores?metric=cache`);
|
|
59
|
+
assert.equal(res.status, 200);
|
|
60
|
+
const rows = (await res.json());
|
|
61
|
+
assert.ok(Array.isArray(rows));
|
|
62
|
+
assert.equal(rows.length, 1);
|
|
63
|
+
assert.equal(rows[0].repo_root, "/repo/a");
|
|
64
|
+
assert.equal(rows[0].value, 42);
|
|
65
|
+
assert.ok(typeof rows[0].ts === "number");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
test("unknown metric -> 400", async () => {
|
|
69
|
+
const dir = freshDir("dash-gs-bad-");
|
|
70
|
+
await withServer("19431", dir, async (port) => {
|
|
71
|
+
const res = await fetch(`http://localhost:${port}/api/game-scores?metric=bogus`);
|
|
72
|
+
assert.equal(res.status, 400);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
test("non-GET (POST) -> 405", async () => {
|
|
76
|
+
const dir = freshDir("dash-gs-meth-");
|
|
77
|
+
await withServer("19432", dir, async (port) => {
|
|
78
|
+
const res = await fetch(`http://localhost:${port}/api/game-scores`, { method: "POST" });
|
|
79
|
+
assert.equal(res.status, 405);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
test("limit clamp: 3 rows -> default 10 returns 3, limit=2 caps to 2, limit<=0 clamps to >=1", async () => {
|
|
83
|
+
const dir = freshDir("dash-gs-lim-");
|
|
84
|
+
recordScore(dir, { repo_root: "/repo/a", metric: "cache", value: 1 });
|
|
85
|
+
recordScore(dir, { repo_root: "/repo/b", metric: "cache", value: 2 });
|
|
86
|
+
recordScore(dir, { repo_root: "/repo/c", metric: "cache", value: 3 });
|
|
87
|
+
await withServer("19433", dir, async (port) => {
|
|
88
|
+
const all = (await fetch(`http://localhost:${port}/api/game-scores?metric=cache`).then((r) => r.json()));
|
|
89
|
+
assert.equal(all.length, 3);
|
|
90
|
+
const capped = (await fetch(`http://localhost:${port}/api/game-scores?metric=cache&limit=2`).then((r) => r.json()));
|
|
91
|
+
assert.equal(capped.length, 2);
|
|
92
|
+
const zero = (await fetch(`http://localhost:${port}/api/game-scores?metric=cache&limit=0`).then((r) => r.json()));
|
|
93
|
+
assert.ok(zero.length >= 1, "limit<=0 clamps to >=1 row");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
describe("S35 /api/achievements", () => {
|
|
98
|
+
test("GET returns the 9 seeded achievement rows", async () => {
|
|
99
|
+
const dir = freshDir("dash-ach-");
|
|
100
|
+
await withServer("19434", dir, async (port) => {
|
|
101
|
+
const res = await fetch(`http://localhost:${port}/api/achievements`);
|
|
102
|
+
assert.equal(res.status, 200);
|
|
103
|
+
const rows = (await res.json());
|
|
104
|
+
assert.ok(Array.isArray(rows));
|
|
105
|
+
assert.equal(rows.length, 9);
|
|
106
|
+
const opie = rows.find((r) => r.id === "opie_wild_ride");
|
|
107
|
+
assert.ok(opie, "opie_wild_ride seeded");
|
|
108
|
+
assert.equal(opie.hidden, 1);
|
|
109
|
+
assert.equal(opie.unlocked_at, null);
|
|
110
|
+
assert.equal(rows.find((r) => r.id === "first_compact").title, "First Compact");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
test("non-GET (POST) -> 405", async () => {
|
|
114
|
+
const dir = freshDir("dash-ach-meth-");
|
|
115
|
+
await withServer("19435", dir, async (port) => {
|
|
116
|
+
const res = await fetch(`http://localhost:${port}/api/achievements`, { method: "POST" });
|
|
117
|
+
assert.equal(res.status, 405);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dashboard-server.test.ts — S32 game-state API + settings strip tests.
|
|
3
|
+
*
|
|
4
|
+
* GET/PUT /api/game-state round-trips the game_state SQLite row through the
|
|
5
|
+
* real server subprocess (spawn + waitFor + fetch), and the HTML template
|
|
6
|
+
* carries the data-theme attribute + settings strip + theme CSS-var blocks.
|
|
7
|
+
*/
|
|
8
|
+
import { test, describe } from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import { dashboardHtml } from "./dashboard-server/html.js";
|
|
15
|
+
import { setGameState, getGameState } from "../src/store/sqlite.js";
|
|
16
|
+
const SERVER_ENTRY = new URL("./dashboard-server.js", import.meta.url).pathname;
|
|
17
|
+
function waitFor(cond, timeoutMs = 6000) {
|
|
18
|
+
const start = Date.now();
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
const tick = async () => {
|
|
21
|
+
if (await cond())
|
|
22
|
+
return resolve();
|
|
23
|
+
if (Date.now() - start > timeoutMs)
|
|
24
|
+
return reject(new Error("timeout"));
|
|
25
|
+
setTimeout(tick, 50);
|
|
26
|
+
};
|
|
27
|
+
tick();
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
function freshDir(prefix) {
|
|
31
|
+
return mkdtempSync(join(tmpdir(), prefix));
|
|
32
|
+
}
|
|
33
|
+
async function withServer(port, dir, fn) {
|
|
34
|
+
process.env.MEGACOMPACT_DASHBOARD_PORT = port;
|
|
35
|
+
const child = spawn(process.execPath, [SERVER_ENTRY, dir], { stdio: "ignore" });
|
|
36
|
+
try {
|
|
37
|
+
await waitFor(async () => {
|
|
38
|
+
try {
|
|
39
|
+
const raw = JSON.parse(readFileSync(join(dir, "port.pid"), "utf-8"));
|
|
40
|
+
const res = await fetch(`http://localhost:${raw.port}/api/version`);
|
|
41
|
+
return res.ok;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
const raw = JSON.parse(readFileSync(join(dir, "port.pid"), "utf-8"));
|
|
48
|
+
return await fn(raw.port);
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
child.kill("SIGTERM");
|
|
52
|
+
delete process.env.MEGACOMPACT_DASHBOARD_PORT;
|
|
53
|
+
rmSync(dir, { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
describe("S32 /api/game-state", () => {
|
|
57
|
+
test("GET returns the current game_state row (seeded via setGameState)", async () => {
|
|
58
|
+
const dir = freshDir("dash-gs-get-");
|
|
59
|
+
setGameState({ game_mode_on: true, theme: "retro", tui_display_mode: "minimal" }, dir);
|
|
60
|
+
await withServer("19330", dir, async (port) => {
|
|
61
|
+
const gs = (await fetch(`http://localhost:${port}/api/game-state`).then((r) => r.json()));
|
|
62
|
+
assert.equal(gs.game_mode_on, true);
|
|
63
|
+
assert.equal(gs.theme, "retro");
|
|
64
|
+
assert.equal(gs.tui_display_mode, "minimal");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
test("PUT {theme:'retro'} -> 200 and getGameState(dir).theme === 'retro'", async () => {
|
|
68
|
+
const dir = freshDir("dash-gs-theme-");
|
|
69
|
+
await withServer("19331", dir, async (port) => {
|
|
70
|
+
const res = await fetch(`http://localhost:${port}/api/game-state`, {
|
|
71
|
+
method: "PUT",
|
|
72
|
+
headers: { "Content-Type": "application/json" },
|
|
73
|
+
body: JSON.stringify({ theme: "retro" }),
|
|
74
|
+
});
|
|
75
|
+
assert.equal(res.status, 200);
|
|
76
|
+
const body = (await res.json());
|
|
77
|
+
assert.equal(body.theme, "retro");
|
|
78
|
+
// And the authoritative DB read reflects the write.
|
|
79
|
+
assert.equal(getGameState(dir).theme, "retro");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
test("PUT invalid theme 'bogus' -> 400 and row unchanged", async () => {
|
|
83
|
+
const dir = freshDir("dash-gs-invalid-");
|
|
84
|
+
setGameState({ theme: "orange-bold" }, dir);
|
|
85
|
+
const before = getGameState(dir).theme;
|
|
86
|
+
await withServer("19332", dir, async (port) => {
|
|
87
|
+
const res = await fetch(`http://localhost:${port}/api/game-state`, {
|
|
88
|
+
method: "PUT",
|
|
89
|
+
headers: { "Content-Type": "application/json" },
|
|
90
|
+
body: JSON.stringify({ theme: "bogus" }),
|
|
91
|
+
});
|
|
92
|
+
assert.equal(res.status, 400);
|
|
93
|
+
});
|
|
94
|
+
assert.equal(getGameState(dir).theme, before, "row unchanged after invalid PUT");
|
|
95
|
+
});
|
|
96
|
+
test("PUT body 'null' (valid-but-non-object JSON) -> 400, server stays up (audit P1)", async () => {
|
|
97
|
+
const dir = freshDir("dash-gs-nullobj-");
|
|
98
|
+
setGameState({ theme: "amber-mono" }, dir);
|
|
99
|
+
const before = getGameState(dir).theme;
|
|
100
|
+
await withServer("19335", dir, async (port) => {
|
|
101
|
+
// A valid-JSON-but-non-object body must NOT crash the detached server
|
|
102
|
+
// (audit P1: unhandled TypeError on patch.game_mode_on deref).
|
|
103
|
+
const res = await fetch(`http://localhost:${port}/api/game-state`, {
|
|
104
|
+
method: "PUT",
|
|
105
|
+
headers: { "Content-Type": "application/json" },
|
|
106
|
+
body: JSON.stringify(null),
|
|
107
|
+
});
|
|
108
|
+
assert.equal(res.status, 400, "non-object JSON -> 400, not a crash");
|
|
109
|
+
const body = (await res.json());
|
|
110
|
+
assert.equal(body.error, "invalid_patch_object");
|
|
111
|
+
// Server must still be up — a follow-up GET must succeed.
|
|
112
|
+
const follow = await fetch(`http://localhost:${port}/api/game-state`);
|
|
113
|
+
assert.equal(follow.status, 200, "server survived the non-object PUT");
|
|
114
|
+
});
|
|
115
|
+
assert.equal(getGameState(dir).theme, before, "row unchanged after non-object PUT");
|
|
116
|
+
});
|
|
117
|
+
test("PUT body '[]' (array) -> 400, server stays up", async () => {
|
|
118
|
+
const dir = freshDir("dash-gs-arr-");
|
|
119
|
+
await withServer("19336", dir, async (port) => {
|
|
120
|
+
const res = await fetch(`http://localhost:${port}/api/game-state`, {
|
|
121
|
+
method: "PUT",
|
|
122
|
+
headers: { "Content-Type": "application/json" },
|
|
123
|
+
body: JSON.stringify([]),
|
|
124
|
+
});
|
|
125
|
+
assert.equal(res.status, 400);
|
|
126
|
+
const follow = await fetch(`http://localhost:${port}/api/game-state`);
|
|
127
|
+
assert.equal(follow.status, 200, "server survived the array PUT");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
test("PUT {tui_display_mode:'minimal'} round-trips", async () => {
|
|
131
|
+
const dir = freshDir("dash-gs-tui-");
|
|
132
|
+
await withServer("19333", dir, async (port) => {
|
|
133
|
+
const res = await fetch(`http://localhost:${port}/api/game-state`, {
|
|
134
|
+
method: "PUT",
|
|
135
|
+
headers: { "Content-Type": "application/json" },
|
|
136
|
+
body: JSON.stringify({ tui_display_mode: "minimal" }),
|
|
137
|
+
});
|
|
138
|
+
assert.equal(res.status, 200);
|
|
139
|
+
const body = (await res.json());
|
|
140
|
+
assert.equal(body.tui_display_mode, "minimal");
|
|
141
|
+
assert.equal(getGameState(dir).tui_display_mode, "minimal");
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
test("PUT {game_mode_on:true} round-trips", async () => {
|
|
145
|
+
const dir = freshDir("dash-gs-mode-");
|
|
146
|
+
await withServer("19334", dir, async (port) => {
|
|
147
|
+
const res = await fetch(`http://localhost:${port}/api/game-state`, {
|
|
148
|
+
method: "PUT",
|
|
149
|
+
headers: { "Content-Type": "application/json" },
|
|
150
|
+
body: JSON.stringify({ game_mode_on: true }),
|
|
151
|
+
});
|
|
152
|
+
assert.equal(res.status, 200);
|
|
153
|
+
const body = (await res.json());
|
|
154
|
+
assert.equal(body.game_mode_on, true);
|
|
155
|
+
assert.equal(getGameState(dir).game_mode_on, true);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
describe("S32 dashboard HTML skin", () => {
|
|
160
|
+
test("carries data-theme on <html> + settings strip + theme CSS-var blocks", () => {
|
|
161
|
+
const html = dashboardHtml("custom");
|
|
162
|
+
// default transparent theme attribute on <html>
|
|
163
|
+
assert.match(html, /<html lang="en" data-theme="transparent">/);
|
|
164
|
+
// settings strip with the three controls
|
|
165
|
+
assert.match(html, /<div class="settings-strip">/);
|
|
166
|
+
assert.match(html, /id="set-game-mode"/);
|
|
167
|
+
assert.match(html, /id="set-theme"/);
|
|
168
|
+
assert.match(html, /id="set-tui-mode"/);
|
|
169
|
+
// all 6 theme ids appear as :root[data-theme="<id>"] override blocks
|
|
170
|
+
for (const id of ["transparent", "retro", "orange-bold", "cyan-neon", "amber-mono", "grayscale"]) {
|
|
171
|
+
assert.ok(html.includes(`:root[data-theme="${id}"]{`), `${id} data-theme block present`);
|
|
172
|
+
}
|
|
173
|
+
// core CSS vars defined in the base :root
|
|
174
|
+
assert.match(html, /--bg: #0d1117;/);
|
|
175
|
+
assert.match(html, /--fg: #c9d1d9;/);
|
|
176
|
+
assert.match(html, /--accent: #3fb950;/);
|
|
177
|
+
assert.match(html, /--mega: #f0883e;/);
|
|
178
|
+
// body uses the themed var (not a hardcoded hex)
|
|
179
|
+
assert.match(html, /body\s*\{[^}]*background: var\(--bg\)/);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* - mega-runtime.ts shared live state (MegaRuntime) + widget + model capture
|
|
19
19
|
* - mega-pipeline.ts runCompact (Trident+persist) + doRecall (Layer 5)
|
|
20
20
|
* - mega-commands.ts data/inspection slash commands
|
|
21
|
+
* - mega-game-cmds.ts /mega-game toggle + theme + TUI display mode
|
|
21
22
|
* - mega-dashboard-cmds.ts localhost dashboard server lifecycle commands
|
|
22
23
|
* - mega-events.ts pi lifecycle event handlers
|
|
23
24
|
*
|
|
@@ -31,6 +32,7 @@ import { registerCommands } from "./mega-commands.js";
|
|
|
31
32
|
import { registerDashboardCommands } from "./mega-dashboard-cmds.js";
|
|
32
33
|
import { registerConflictCommands } from "./mega-conflict-cmds.js";
|
|
33
34
|
import { registerDbCommands } from "./mega-db-cmds.js";
|
|
35
|
+
import { registerGameCommands } from "./mega-game-cmds.js";
|
|
34
36
|
export default function (pi) {
|
|
35
37
|
const config = loadConfig();
|
|
36
38
|
const runtime = new MegaRuntime(config);
|
|
@@ -39,4 +41,5 @@ export default function (pi) {
|
|
|
39
41
|
registerDashboardCommands(pi, runtime);
|
|
40
42
|
registerConflictCommands(pi, runtime);
|
|
41
43
|
registerDbCommands(pi, runtime);
|
|
44
|
+
registerGameCommands(pi, runtime);
|
|
42
45
|
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { piCompactWouldNoop, runMemoryReview, } from "../mega-pipeline.js";
|
|
2
2
|
import { memoryReviewCadence, } from "../mega-config.js";
|
|
3
|
+
import { recordScore } from "../../src/store/sqlite.js";
|
|
4
|
+
import { evaluateAndUnlockAchievements } from "../../src/store/sqlite/game-achievements.js";
|
|
5
|
+
import { isMegaCache } from "../../src/game/scoring.js";
|
|
6
|
+
import { resolveRepoRoot } from "../mega-config.js";
|
|
3
7
|
/** Register agent/turn tracking event handlers. */
|
|
4
8
|
export function registerAgentHandlers(pi, runtime, config) {
|
|
5
9
|
// ---- Agent tracking for real-time widget + status-line updates ---------
|
|
@@ -143,6 +147,51 @@ export function registerAgentHandlers(pi, runtime, config) {
|
|
|
143
147
|
pi.on("turn_end", async (event, ctx) => {
|
|
144
148
|
runtime.dashboard.event("turn_end", { turnIndex: event.turnIndex });
|
|
145
149
|
runtime.snapshot(ctx);
|
|
150
|
+
// S33: game-mode scoring — record turns + cache metrics per repo, and arm
|
|
151
|
+
// the MEGA CACHE flare (oopsie gag) when the real dedup hit rate exceeds
|
|
152
|
+
// 100%. Gated behind game_mode_on (no scoring when off). Best-effort +
|
|
153
|
+
// non-fatal: a scoring failure must never break the agent loop (G6).
|
|
154
|
+
try {
|
|
155
|
+
if (runtime.getCachedGameState().game_mode_on) {
|
|
156
|
+
const repo = resolveRepoRoot(ctx.cwd) ?? runtime.currentStateDir;
|
|
157
|
+
const st = runtime.store.stats(runtime.rt.sessionId);
|
|
158
|
+
const cachePct = st.dedupHitRate * 100;
|
|
159
|
+
const modelId = runtime.currentModel?.modelId ?? "unknown";
|
|
160
|
+
recordScore(runtime.currentStateDir, {
|
|
161
|
+
repo_root: repo,
|
|
162
|
+
metric: "turns",
|
|
163
|
+
value: runtime.currentTurn,
|
|
164
|
+
meta: { modelId, turnIndex: event.turnIndex },
|
|
165
|
+
});
|
|
166
|
+
recordScore(runtime.currentStateDir, {
|
|
167
|
+
repo_root: repo,
|
|
168
|
+
metric: "cache",
|
|
169
|
+
value: cachePct,
|
|
170
|
+
meta: {
|
|
171
|
+
hits: st.dedupCollapsed + runtime.rt.recallInjections,
|
|
172
|
+
lookups: st.checkpointCount,
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
// MEGA CACHE: the real ratio >1 (dedupHitRate>1) → trophy row + flare.
|
|
176
|
+
if (isMegaCache(cachePct)) {
|
|
177
|
+
recordScore(runtime.currentStateDir, {
|
|
178
|
+
repo_root: repo,
|
|
179
|
+
metric: "mega_cache",
|
|
180
|
+
value: cachePct,
|
|
181
|
+
meta: { peakPct: cachePct, firstSeenTs: Date.now() },
|
|
182
|
+
});
|
|
183
|
+
runtime.armMegaCacheFlare(cachePct);
|
|
184
|
+
}
|
|
185
|
+
// S35: evaluate achievements after scoring; arm a one-time flare for
|
|
186
|
+
// the newly-unlocked ones (consumed by snapshot() → widget toast).
|
|
187
|
+
const newTitles = evaluateAndUnlockAchievements(runtime.currentStateDir);
|
|
188
|
+
if (newTitles.length)
|
|
189
|
+
runtime.armAchievementFlare(newTitles);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
/* non-fatal: scoring must never break the agent loop */
|
|
194
|
+
}
|
|
146
195
|
// S20+S24: auto-review the conversation and persist durable memories. The
|
|
147
196
|
// review cadence scales with pressure (memoryReviewCadence): as context
|
|
148
197
|
// fills, the conversation is reviewed more often so memories keep pace with
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { driveNativeCompaction, } from "../mega-compact-driver.js";
|
|
2
2
|
import { estimateBlockTokens } from "../../src/tokens.js";
|
|
3
|
+
import { recordScore, getDedupStats } from "../../src/store/sqlite.js";
|
|
4
|
+
import { evaluateAndUnlockAchievements } from "../../src/store/sqlite/game-achievements.js";
|
|
5
|
+
import { resolveRepoRoot } from "../mega-config.js";
|
|
3
6
|
/**
|
|
4
7
|
* Build a minimal fallback compaction so pi never runs its throwing compact().
|
|
5
8
|
*
|
|
@@ -129,5 +132,31 @@ export function registerCompactHandlers(pi, runtime, config) {
|
|
|
129
132
|
sessionId: runtime.rt.sessionId,
|
|
130
133
|
at: runtime.rt.lastCompactAt,
|
|
131
134
|
});
|
|
135
|
+
// S33: game-mode dedupe scoring — record the DELTA of cumulative dedup
|
|
136
|
+
// collapses since the last compact (leaderboard SUMs the deltas). Gated
|
|
137
|
+
// behind game_mode_on (no scoring when off). Best-effort + non-fatal (G6).
|
|
138
|
+
try {
|
|
139
|
+
if (runtime.getCachedGameState().game_mode_on) {
|
|
140
|
+
const ds = getDedupStats(runtime.currentStateDir);
|
|
141
|
+
const delta = ds.deduped - runtime.lastDedupCollapsed;
|
|
142
|
+
runtime.lastDedupCollapsed = ds.deduped;
|
|
143
|
+
if (delta > 0) {
|
|
144
|
+
const repo = resolveRepoRoot(_ctx.cwd) ?? runtime.currentStateDir;
|
|
145
|
+
recordScore(runtime.currentStateDir, {
|
|
146
|
+
repo_root: repo,
|
|
147
|
+
metric: "dedupe",
|
|
148
|
+
value: delta,
|
|
149
|
+
meta: { compactCount: runtime.rt.compactCount },
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// S35: evaluate achievements after scoring; arm the one-time flare.
|
|
154
|
+
const newTitles = evaluateAndUnlockAchievements(runtime.currentStateDir);
|
|
155
|
+
if (newTitles.length)
|
|
156
|
+
runtime.armAchievementFlare(newTitles);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
/* non-fatal: scoring must never break compaction */
|
|
160
|
+
}
|
|
132
161
|
});
|
|
133
162
|
}
|