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
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* game-scores.ts — `game_scores` table accessors (S33).
|
|
3
|
+
*
|
|
4
|
+
* Per-repo leaderboard metrics for game mode. One row per recorded event
|
|
5
|
+
* (turn_end / session_compact); leaderboard() derives rankings (latest / max /
|
|
6
|
+
* SUM / COUNT DISTINCT) per repo_root. 'repos' is derived (never recorded).
|
|
7
|
+
*
|
|
8
|
+
* PREVENT-PI-004: local SQLite only, zero network.
|
|
9
|
+
* PREVENT-002: all SQL parameterized (? placeholders, no string concat of user
|
|
10
|
+
* data). The metric is validated against a fixed allow-list before use; the
|
|
11
|
+
* only interpolated fragments are code-controlled constants (the aggregate
|
|
12
|
+
* selector + the optional repo_root filter clause), never external input.
|
|
13
|
+
* Pi-agnostic: no pi runtime types (mirrors game-state.ts / meta.ts).
|
|
14
|
+
*/
|
|
15
|
+
import { getStateDir } from "../../store.js";
|
|
16
|
+
import { openStore } from "./utils.js";
|
|
17
|
+
import type { SQLInputValue } from "node:sqlite";
|
|
18
|
+
import { GameMetric, METRICS } from "../../game/scoring.js";
|
|
19
|
+
|
|
20
|
+
// Process-local monotonic sequence appended into the low 3 digits of `ts` so
|
|
21
|
+
// back-to-back inserts within the same millisecond get distinct PK values
|
|
22
|
+
// (Date.now()*1000 + seq%1000). Production turns are >1ms apart, so seq is
|
|
23
|
+
// almost always 0; this only disambiguates the burst case. Without it,
|
|
24
|
+
// OR REPLACE would drop the earlier same-ms row and break the dedupe SUM.
|
|
25
|
+
let scoreSeq = 0;
|
|
26
|
+
function nextTs(): number {
|
|
27
|
+
return Date.now() * 1000 + (scoreSeq++ % 1000);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type { GameMetric } from "../../game/scoring.js";
|
|
31
|
+
export { METRICS } from "../../game/scoring.js";
|
|
32
|
+
|
|
33
|
+
/** A single recorded game-score event (as stored). */
|
|
34
|
+
export interface ScoreRow {
|
|
35
|
+
repo_root: string;
|
|
36
|
+
metric: GameMetric;
|
|
37
|
+
ts: number;
|
|
38
|
+
value: number;
|
|
39
|
+
meta?: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A leaderboard row returned by leaderboard(). */
|
|
43
|
+
export interface LeaderboardRow {
|
|
44
|
+
repo_root: string;
|
|
45
|
+
value: number;
|
|
46
|
+
ts: number;
|
|
47
|
+
meta: unknown;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Options for leaderboard(): optional repo filter + result limit. */
|
|
51
|
+
export interface LeaderboardOpts {
|
|
52
|
+
repoRoot?: string;
|
|
53
|
+
limit?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Record one game-score event. `ts` is set to Date.now() by the store. SQL is
|
|
58
|
+
* fully parameterized (PREVENT-002); the metric is validated against the fixed
|
|
59
|
+
* allow-list. Pi-agnostic. Never throws except on a genuinely unknown metric.
|
|
60
|
+
*/
|
|
61
|
+
export function recordScore(
|
|
62
|
+
stateDir: string = getStateDir(),
|
|
63
|
+
args: { repo_root: string; metric: GameMetric; value: number; meta?: unknown },
|
|
64
|
+
): void {
|
|
65
|
+
if (!METRICS.includes(args.metric)) {
|
|
66
|
+
throw new Error(`recordScore: unknown game metric ${String(args.metric)}`);
|
|
67
|
+
}
|
|
68
|
+
const db = openStore(stateDir);
|
|
69
|
+
db.prepare(
|
|
70
|
+
`INSERT INTO game_scores (repo_root, metric, ts, value, meta)
|
|
71
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
72
|
+
).run(
|
|
73
|
+
args.repo_root,
|
|
74
|
+
args.metric,
|
|
75
|
+
nextTs(),
|
|
76
|
+
args.value,
|
|
77
|
+
args.meta != null ? JSON.stringify(args.meta) : null,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Read the leaderboard for a metric:
|
|
83
|
+
* - cache/turns: latest value per repo_root, sorted desc by value.
|
|
84
|
+
* - mega_cache: the max value (trophy) per repo_root, sorted desc.
|
|
85
|
+
* - dedupe: SUM(value) per repo_root (cumulative collapses), sorted desc.
|
|
86
|
+
* - repos: COUNT(DISTINCT repo_root) — derived; returns a single synthesized
|
|
87
|
+
* row (repo_root '*') with the distinct-repo count as `value`.
|
|
88
|
+
* The metric is validated against the allow-list. Pi-agnostic.
|
|
89
|
+
*/
|
|
90
|
+
export function leaderboard(
|
|
91
|
+
stateDir: string = getStateDir(),
|
|
92
|
+
metric: GameMetric,
|
|
93
|
+
opts: LeaderboardOpts = {},
|
|
94
|
+
): LeaderboardRow[] {
|
|
95
|
+
if (!METRICS.includes(metric)) {
|
|
96
|
+
throw new Error(`leaderboard: unknown game metric ${String(metric)}`);
|
|
97
|
+
}
|
|
98
|
+
const db = openStore(stateDir);
|
|
99
|
+
const limit = Math.max(1, opts.limit ?? 10);
|
|
100
|
+
// Code-controlled filter clauses (never external input) — safe under PREVENT-002.
|
|
101
|
+
// Two variants: the dedupe query is single-table (unqualified repo_root);
|
|
102
|
+
// the cache/turns/mega_cache query self-JOINs (must qualify as g.repo_root).
|
|
103
|
+
const repoFilterDedupe = opts.repoRoot != null ? " AND repo_root = ?" : "";
|
|
104
|
+
const repoFilter = opts.repoRoot != null ? " AND g.repo_root = ?" : "";
|
|
105
|
+
const repoParam: SQLInputValue[] = opts.repoRoot != null ? [opts.repoRoot] : [];
|
|
106
|
+
|
|
107
|
+
if (metric === "repos") {
|
|
108
|
+
const row = db
|
|
109
|
+
.prepare(`SELECT COUNT(DISTINCT repo_root) AS value FROM game_scores`)
|
|
110
|
+
.get() as { value: number };
|
|
111
|
+
return [{ repo_root: "*", value: row.value, ts: 0, meta: null }];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (metric === "dedupe") {
|
|
115
|
+
const sql = `SELECT repo_root, SUM(value) AS value, MAX(ts) AS ts, NULL AS meta
|
|
116
|
+
FROM game_scores
|
|
117
|
+
WHERE metric = ?${repoFilterDedupe}
|
|
118
|
+
GROUP BY repo_root
|
|
119
|
+
ORDER BY value DESC
|
|
120
|
+
LIMIT ?`;
|
|
121
|
+
const rows = db.prepare(sql).all(metric, ...repoParam, limit) as Array<{
|
|
122
|
+
repo_root: string;
|
|
123
|
+
value: number;
|
|
124
|
+
ts: number;
|
|
125
|
+
meta: string | null;
|
|
126
|
+
}>;
|
|
127
|
+
return rows.map(toLeaderboardRow);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// cache / turns / mega_cache: pick the qualifying row per repo_root (latest
|
|
131
|
+
// ts, or max value for mega_cache), then rank by value desc. The interpolated
|
|
132
|
+
// `key`/`col` fragments are derived from the validated metric — code constants,
|
|
133
|
+
// not user data (PREVENT-002 safe).
|
|
134
|
+
const key = metric === "mega_cache" ? "MAX(value)" : "MAX(ts)";
|
|
135
|
+
const col = metric === "mega_cache" ? "value" : "ts";
|
|
136
|
+
const sql = `SELECT g.repo_root, g.value AS value, g.ts AS ts, g.meta AS meta
|
|
137
|
+
FROM game_scores g
|
|
138
|
+
JOIN (SELECT repo_root, ${key} AS k
|
|
139
|
+
FROM game_scores WHERE metric = ? GROUP BY repo_root) m
|
|
140
|
+
ON m.repo_root = g.repo_root AND m.k = g.${col}
|
|
141
|
+
WHERE g.metric = ?${repoFilter}
|
|
142
|
+
ORDER BY g.value DESC
|
|
143
|
+
LIMIT ?`;
|
|
144
|
+
const rows = db
|
|
145
|
+
.prepare(sql)
|
|
146
|
+
.all(metric, metric, ...repoParam, limit) as Array<{
|
|
147
|
+
repo_root: string;
|
|
148
|
+
value: number;
|
|
149
|
+
ts: number;
|
|
150
|
+
meta: string | null;
|
|
151
|
+
}>;
|
|
152
|
+
return rows.map(toLeaderboardRow);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Helper: coerce a raw row (meta as JSON text) into a LeaderboardRow. */
|
|
156
|
+
function toLeaderboardRow(r: {
|
|
157
|
+
repo_root: string;
|
|
158
|
+
value: number;
|
|
159
|
+
ts: number;
|
|
160
|
+
meta: string | null;
|
|
161
|
+
}): LeaderboardRow {
|
|
162
|
+
return {
|
|
163
|
+
repo_root: r.repo_root,
|
|
164
|
+
value: r.value,
|
|
165
|
+
ts: r.ts,
|
|
166
|
+
meta: r.meta != null ? JSON.parse(r.meta) : null,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* game-state.test.ts — S30 game_state table round-trip + validation.
|
|
3
|
+
* Pi-agnostic. Uses an isolated state dir (never the real user dir — G7).
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, before, after } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
10
|
+
import { closeStore } from "./utils.js";
|
|
11
|
+
import {
|
|
12
|
+
getGameState,
|
|
13
|
+
setGameState,
|
|
14
|
+
DEFAULT_GAME_STATE,
|
|
15
|
+
} from "./game-state.js";
|
|
16
|
+
|
|
17
|
+
describe("game-state (S30)", () => {
|
|
18
|
+
let dir: string;
|
|
19
|
+
before(() => {
|
|
20
|
+
dir = mkdtempSync(join(tmpdir(), "mc-gamestate-"));
|
|
21
|
+
process.env.MEGACOMPACT_STATE_DIR = dir;
|
|
22
|
+
});
|
|
23
|
+
after(() => {
|
|
24
|
+
closeStore(dir);
|
|
25
|
+
delete process.env.MEGACOMPACT_STATE_DIR;
|
|
26
|
+
rmSync(dir, { recursive: true, force: true });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns DEFAULT_GAME_STATE on a fresh store", () => {
|
|
30
|
+
const s = getGameState();
|
|
31
|
+
assert.deepEqual(s, { ...DEFAULT_GAME_STATE });
|
|
32
|
+
assert.equal(s.game_mode_on, false);
|
|
33
|
+
assert.equal(s.theme, "transparent");
|
|
34
|
+
assert.equal(s.tui_display_mode, "full");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("round-trips game_mode_on", () => {
|
|
38
|
+
setGameState({ game_mode_on: true });
|
|
39
|
+
assert.equal(getGameState().game_mode_on, true);
|
|
40
|
+
setGameState({ game_mode_on: false });
|
|
41
|
+
assert.equal(getGameState().game_mode_on, false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("round-trips a valid theme", () => {
|
|
45
|
+
setGameState({ theme: "retro" });
|
|
46
|
+
assert.equal(getGameState().theme, "retro");
|
|
47
|
+
setGameState({ theme: "cyan-neon" });
|
|
48
|
+
assert.equal(getGameState().theme, "cyan-neon");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("rejects an unknown theme (keeps previous)", () => {
|
|
52
|
+
setGameState({ theme: "orange-bold" });
|
|
53
|
+
setGameState({ theme: "does-not-exist" as string });
|
|
54
|
+
assert.equal(getGameState().theme, "orange-bold");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("round-trips tui_display_mode full|minimal", () => {
|
|
58
|
+
setGameState({ tui_display_mode: "minimal" });
|
|
59
|
+
assert.equal(getGameState().tui_display_mode, "minimal");
|
|
60
|
+
setGameState({ tui_display_mode: "full" });
|
|
61
|
+
assert.equal(getGameState().tui_display_mode, "full");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("persists across a reopen (survives restart)", () => {
|
|
65
|
+
setGameState({ game_mode_on: true, theme: "amber-mono", tui_display_mode: "minimal" });
|
|
66
|
+
// evict + reopen the store to prove it hit disk
|
|
67
|
+
closeStore(dir);
|
|
68
|
+
const s = getGameState();
|
|
69
|
+
assert.equal(s.game_mode_on, true);
|
|
70
|
+
assert.equal(s.theme, "amber-mono");
|
|
71
|
+
assert.equal(s.tui_display_mode, "minimal");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("setGameState is a partial merge (untouched fields preserved)", () => {
|
|
75
|
+
setGameState({ game_mode_on: true, theme: "grayscale", tui_display_mode: "minimal" });
|
|
76
|
+
const s = setGameState({ game_mode_on: false });
|
|
77
|
+
assert.equal(s.game_mode_on, false);
|
|
78
|
+
assert.equal(s.theme, "grayscale");
|
|
79
|
+
assert.equal(s.tui_display_mode, "minimal");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("stays a single row (no duplicate id=1)", () => {
|
|
83
|
+
setGameState({ game_mode_on: true });
|
|
84
|
+
setGameState({ game_mode_on: false });
|
|
85
|
+
setGameState({ theme: "retro" });
|
|
86
|
+
const s = getGameState();
|
|
87
|
+
assert.ok(s); // single state, last-write-wins
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* game-state.ts — `game_state` table: the global game-mode toggle state (S30).
|
|
3
|
+
*
|
|
4
|
+
* Single row (id=1) holding: game_mode_on (bool), theme (one of THEME_IDS),
|
|
5
|
+
* tui_display_mode ('full'|'minimal'). Global — one choice across all repos.
|
|
6
|
+
* Survives restarts; editable from /mega-game (TUI) or the dashboard panel
|
|
7
|
+
* (S32), both reading/writing this same row so they stay in sync.
|
|
8
|
+
*
|
|
9
|
+
* PREVENT-PI-004: local SQLite only, zero network.
|
|
10
|
+
* PREVENT-002: all SQL parameterized (@param / ? placeholders, no concat).
|
|
11
|
+
* Pi-agnostic: no pi runtime types (mirrors meta.ts / session-state.ts).
|
|
12
|
+
*/
|
|
13
|
+
import { DatabaseSync } from "node:sqlite";
|
|
14
|
+
import { getStateDir } from "../../store.js";
|
|
15
|
+
import { openStore } from "./utils.js";
|
|
16
|
+
import { DEFAULT_THEME, isValidTheme } from "../../config/themes.js";
|
|
17
|
+
|
|
18
|
+
/** The persisted game-mode state. `theme` is always a valid THEME_IDS member
|
|
19
|
+
* (callers may assume the invariant; setGameState validates). */
|
|
20
|
+
export interface GameState {
|
|
21
|
+
game_mode_on: boolean;
|
|
22
|
+
theme: string;
|
|
23
|
+
tui_display_mode: "full" | "minimal";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_GAME_STATE: GameState = {
|
|
27
|
+
game_mode_on: false,
|
|
28
|
+
theme: DEFAULT_THEME,
|
|
29
|
+
tui_display_mode: "full",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Read the global game-mode state. Returns DEFAULT_GAME_STATE if the row is
|
|
33
|
+
* absent (fresh install) or any column is unexpectedly null/invalid — never
|
|
34
|
+
* throws, so the widget / command can call it on every render safely. */
|
|
35
|
+
export function getGameState(stateDir: string = getStateDir()): GameState {
|
|
36
|
+
const db = openStore(stateDir);
|
|
37
|
+
return getGameStateRow(db);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Read the game state from an already-open connection (used by callers that
|
|
41
|
+
* hold a db handle, e.g. within a transaction). Same defensive fallback. */
|
|
42
|
+
export function getGameStateRow(db: DatabaseSync): GameState {
|
|
43
|
+
const row = db.prepare(
|
|
44
|
+
"SELECT game_mode_on, theme, tui_display_mode FROM game_state WHERE id = 1",
|
|
45
|
+
).get() as { game_mode_on: number; theme: string; tui_display_mode: string } | undefined;
|
|
46
|
+
if (!row) return { ...DEFAULT_GAME_STATE };
|
|
47
|
+
const mode: "full" | "minimal" =
|
|
48
|
+
row.tui_display_mode === "minimal" ? "minimal" : "full";
|
|
49
|
+
return {
|
|
50
|
+
game_mode_on: row.game_mode_on === 1,
|
|
51
|
+
theme: isValidTheme(row.theme) ? row.theme : DEFAULT_THEME,
|
|
52
|
+
tui_display_mode: mode,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A partial update to the game state (omit fields you don't want to change). */
|
|
57
|
+
export type GameStatePatch = Partial<GameState>;
|
|
58
|
+
|
|
59
|
+
/** Upsert the game state row, applying `patch` on top of the current values.
|
|
60
|
+
* Validates `theme` (keeps previous on an unknown id) and `tui_display_mode`
|
|
61
|
+
* (coerces to 'full' on anything but 'minimal'). Parameterized (PREVENT-002).
|
|
62
|
+
* Returns the post-write state. */
|
|
63
|
+
export function setGameState(patch: GameStatePatch, stateDir: string = getStateDir()): GameState {
|
|
64
|
+
const db = openStore(stateDir);
|
|
65
|
+
return setGameStateRow(db, patch);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Upsert on an already-open connection (for transactional callers). */
|
|
69
|
+
export function setGameStateRow(db: DatabaseSync, patch: GameStatePatch): GameState {
|
|
70
|
+
const cur = getGameStateRow(db);
|
|
71
|
+
const next: GameState = {
|
|
72
|
+
game_mode_on: patch.game_mode_on ?? cur.game_mode_on,
|
|
73
|
+
theme: patch.theme != null && isValidTheme(patch.theme) ? patch.theme : cur.theme,
|
|
74
|
+
tui_display_mode: patch.tui_display_mode ?? cur.tui_display_mode,
|
|
75
|
+
};
|
|
76
|
+
// Single-row upsert (id=1). INSERT ... ON CONFLICT(id) DO UPDATE keeps it
|
|
77
|
+
// one row whether the table is fresh or already seeded.
|
|
78
|
+
db.prepare(
|
|
79
|
+
`INSERT INTO game_state (id, game_mode_on, theme, tui_display_mode)
|
|
80
|
+
VALUES (1, ?, ?, ?)
|
|
81
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
82
|
+
game_mode_on = excluded.game_mode_on,
|
|
83
|
+
theme = excluded.theme,
|
|
84
|
+
tui_display_mode = excluded.tui_display_mode`,
|
|
85
|
+
).run(next.game_mode_on ? 1 : 0, next.theme, next.tui_display_mode);
|
|
86
|
+
return next;
|
|
87
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* schema.ts — table creation, migrations, `ensureColumn`, PRAGMA setup.
|
|
3
3
|
*/
|
|
4
4
|
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
import { ACHIEVEMENT_DEFS } from "../../game/scoring.js";
|
|
5
6
|
|
|
6
7
|
const SCHEMA_VERSION = 2;
|
|
7
8
|
|
|
@@ -231,6 +232,41 @@ export function initSchema(db: DatabaseSync): void {
|
|
|
231
232
|
first_seen_seq INTEGER NOT NULL,
|
|
232
233
|
created_at INTEGER NOT NULL
|
|
233
234
|
);
|
|
235
|
+
|
|
236
|
+
-- S30 game mode: global toggle state (single row, id=1). Holds the
|
|
237
|
+
-- game-mode on/off switch, the active visual-effect theme id, and the TUI
|
|
238
|
+
-- widget display mode. Global across all repos; written by /mega-game and
|
|
239
|
+
-- the dashboard settings strip (S32). Local SQLite (PREVENT-PI-004).
|
|
240
|
+
CREATE TABLE IF NOT EXISTS game_state (
|
|
241
|
+
id INTEGER PRIMARY KEY CHECK(id = 1),
|
|
242
|
+
game_mode_on INTEGER NOT NULL DEFAULT 0,
|
|
243
|
+
theme TEXT NOT NULL DEFAULT 'transparent',
|
|
244
|
+
tui_display_mode TEXT NOT NULL DEFAULT 'full'
|
|
245
|
+
CHECK(tui_display_mode IN ('full','minimal'))
|
|
246
|
+
);
|
|
247
|
+
-- S33 game mode: per-repo leaderboard metrics. One row per recorded event
|
|
248
|
+
-- (turn_end / session_compact); leaderboard() derives rankings. 'repos' is
|
|
249
|
+
-- derived (COUNT DISTINCT, never recorded). Local SQLite (PREVENT-PI-004).
|
|
250
|
+
CREATE TABLE IF NOT EXISTS game_scores (
|
|
251
|
+
repo_root TEXT NOT NULL,
|
|
252
|
+
metric TEXT NOT NULL CHECK(metric IN ('cache','dedupe','turns','repos','mega_cache')),
|
|
253
|
+
ts INTEGER NOT NULL,
|
|
254
|
+
value REAL NOT NULL,
|
|
255
|
+
meta TEXT,
|
|
256
|
+
PRIMARY KEY(repo_root, metric, ts)
|
|
257
|
+
) WITHOUT ROWID;
|
|
258
|
+
CREATE INDEX IF NOT EXISTS idx_game_scores_metric_ts ON game_scores(metric, ts);
|
|
259
|
+
|
|
260
|
+
-- S35 game mode: achievements (9 rows, seeded idempotently on first open).
|
|
261
|
+
-- hidden=1 AND unlocked_at IS NULL => render NOTHING (no teaser). Local SQLite.
|
|
262
|
+
CREATE TABLE IF NOT EXISTS game_achievements (
|
|
263
|
+
id TEXT PRIMARY KEY,
|
|
264
|
+
title TEXT NOT NULL,
|
|
265
|
+
description TEXT NOT NULL,
|
|
266
|
+
hidden INTEGER NOT NULL DEFAULT 0 CHECK(hidden IN (0,1)),
|
|
267
|
+
icon TEXT,
|
|
268
|
+
unlocked_at INTEGER NULL
|
|
269
|
+
) WITHOUT ROWID;
|
|
234
270
|
`);
|
|
235
271
|
// Idempotent column migrations. `CREATE TABLE IF NOT EXISTS` is a no-op on a
|
|
236
272
|
// pre-existing table, so new columns added to context_chunks after a store was
|
|
@@ -249,6 +285,17 @@ export function initSchema(db: DatabaseSync): void {
|
|
|
249
285
|
// S25: RAPTOR freshness-guard timestamp. Additive; old DBs have NULL → 0 →
|
|
250
286
|
// treated as stale → flat fallback (safe).
|
|
251
287
|
ensureColumn(db, "raptor_nodes", "built_at", "INTEGER");
|
|
288
|
+
// S35: idempotent seed of the 9 achievement rows. ON CONFLICT(id) DO
|
|
289
|
+
// NOTHING so a re-open never clobbers an already-unlocked row's
|
|
290
|
+
// unlocked_at. No user input reaches this SQL (PREVENT-002 safe).
|
|
291
|
+
const seedAch = db.prepare(
|
|
292
|
+
`INSERT INTO game_achievements (id, title, description, hidden, icon)
|
|
293
|
+
VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO NOTHING`,
|
|
294
|
+
);
|
|
295
|
+
for (const d of ACHIEVEMENT_DEFS) {
|
|
296
|
+
seedAch.run(d.id, d.title, d.description, d.hidden ? 1 : 0, d.icon);
|
|
297
|
+
}
|
|
298
|
+
|
|
252
299
|
const v = db.prepare("SELECT value FROM meta WHERE key='schema_version'").get() as
|
|
253
300
|
| { value: string }
|
|
254
301
|
| undefined;
|
package/src/store/sqlite.ts
CHANGED
|
@@ -20,3 +20,6 @@ export * from "./sqlite/raptor.js";
|
|
|
20
20
|
export * from "./sqlite/raw-transcript.js";
|
|
21
21
|
export * from "./sqlite/dedup-mirror.js";
|
|
22
22
|
export * from "./sqlite/maintenance.js";
|
|
23
|
+
export * from "./sqlite/game-state.js";
|
|
24
|
+
export * from "./sqlite/game-scores.js";
|
|
25
|
+
export * from "./sqlite/game-achievements.js";
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* File-reading helper functions for the dashboard server.
|
|
3
|
-
*/
|
|
4
|
-
import { readFileSync } from "node:fs";
|
|
5
|
-
export function readSnapshot(snapshotPath) {
|
|
6
|
-
try {
|
|
7
|
-
const raw = readFileSync(snapshotPath, "utf-8");
|
|
8
|
-
return JSON.parse(raw);
|
|
9
|
-
}
|
|
10
|
-
catch {
|
|
11
|
-
return {
|
|
12
|
-
version: 1,
|
|
13
|
-
updatedAt: null,
|
|
14
|
-
tier: "unknown",
|
|
15
|
-
config: { fastGatePct: 80, thresholdTokens: 100_000, anchorUserMessages: 1, preserveRecent: 2, auto: true, autoInlineK: 3 },
|
|
16
|
-
session: { id: null, state: null, persistedThisSession: false, lastCheckpointId: null, lastCompactedFrom: 0 },
|
|
17
|
-
context: { tokens: null, percent: null, contextWindow: 0 },
|
|
18
|
-
trigger: { armed: false, ready: false, currentTokens: null, thresholdTokens: 100_000, fastGatePct: 80 },
|
|
19
|
-
store: { checkpointCount: 0, totalTokenEstimate: 0, originalTokens: 0, tokensSaved: 0, injectedCount: 0, dedupHitRate: 0, storageDedupRate: 0, dedupCollapsed: 0 },
|
|
20
|
-
crew: { activeAgents: 0, currentTurn: 0 },
|
|
21
|
-
repo: { checkpointCount: 0, totalTokenEstimate: 0, originalTokens: 0, tokensSaved: 0, sessionCount: 0, dedupAttempts: 0, dedupCollapsed: 0, storageDedupRate: 0 },
|
|
22
|
-
integrity: { regionsRetained: 0, compressedOriginalBytes: 0, duplicatesCollapsed: 0, bytesPermanentlyDeleted: 0 },
|
|
23
|
-
model: undefined,
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
export function readFrom(path, charOffset) {
|
|
28
|
-
try {
|
|
29
|
-
const content = readFileSync(path, "utf-8");
|
|
30
|
-
if (content.length <= charOffset)
|
|
31
|
-
return { data: "", offset: charOffset };
|
|
32
|
-
return { data: content.slice(charOffset), offset: content.length };
|
|
33
|
-
}
|
|
34
|
-
catch {
|
|
35
|
-
return { data: "", offset: charOffset };
|
|
36
|
-
}
|
|
37
|
-
}
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* "All repos" tab panel — machine-wide registry table (index.sqlite).
|
|
3
|
-
*
|
|
4
|
-
* Rows are populated by the index poller in `script.ts` (shared with the
|
|
5
|
-
* in-panel table on the Current-repo tab via the same render call).
|
|
6
|
-
*/
|
|
7
|
-
export function allReposTab() {
|
|
8
|
-
return `<!-- All repos (machine-wide registry from index.sqlite) -->
|
|
9
|
-
<div class="tab-panel" id="panel-all">
|
|
10
|
-
<table class="repos">
|
|
11
|
-
<thead>
|
|
12
|
-
<tr>
|
|
13
|
-
<th>Repo</th><th>Model</th>
|
|
14
|
-
<th style="text-align:right">Checkpoints</th>
|
|
15
|
-
<th style="text-align:right">Tokens Saved</th>
|
|
16
|
-
<th style="text-align:right">Retained</th>
|
|
17
|
-
<th style="text-align:right">Last Compacted</th>
|
|
18
|
-
</tr>
|
|
19
|
-
</thead>
|
|
20
|
-
<tbody id="all-rows"><tr><td colspan="6" class="repo-none">loading…</td></tr></tbody>
|
|
21
|
-
</table>
|
|
22
|
-
<div class="updated" id="all-updated"></div>
|
|
23
|
-
</div>
|
|
24
|
-
|
|
25
|
-
`;
|
|
26
|
-
}
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Body opening: </head><body>, offline banner, page heading, and the tab nav.
|
|
3
|
-
*
|
|
4
|
-
* `tierName` is interpolated into the heading tier badge.
|
|
5
|
-
* Includes the new "High Score" future-tab button (next project).
|
|
6
|
-
*/
|
|
7
|
-
export function bodyOpen(tierName) {
|
|
8
|
-
return `</head>
|
|
9
|
-
<body>
|
|
10
|
-
|
|
11
|
-
<div class="offline-banner" id="offline-banner">Dashboard data unavailable — waiting for a pi session to write snapshot...</div>
|
|
12
|
-
|
|
13
|
-
<h1><span>mega-compact</span><span class="tier">${tierName}</span><span class="model-pill" id="hdr-model">—</span></h1>
|
|
14
|
-
|
|
15
|
-
<nav class="tabs">
|
|
16
|
-
<button class="tab active" data-tab="current">Current repo</button>
|
|
17
|
-
<button class="tab" data-tab="all">All repos</button>
|
|
18
|
-
<button class="tab" data-tab="summary">Summary</button>
|
|
19
|
-
<button class="tab future" data-tab="highscore">High Score<span class="soon">soon</span></button>
|
|
20
|
-
</nav>
|
|
21
|
-
|
|
22
|
-
`;
|
|
23
|
-
}
|
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* "Current repo" tab panel — the per-repo dashboard view.
|
|
3
|
-
*
|
|
4
|
-
* Contains: context-window meter, trigger status, vector-store stats,
|
|
5
|
-
* repo-wide stats, data-safety card, configuration, model & cost savings,
|
|
6
|
-
* crew/agents, a legend, the live event stream, and the in-panel all-repos
|
|
7
|
-
* table (shared rows with the All-repos tab via the index poller).
|
|
8
|
-
*
|
|
9
|
-
* `tierName` is interpolated into the Configuration card.
|
|
10
|
-
*/
|
|
11
|
-
export function currentRepoTab(tierName) {
|
|
12
|
-
return `<!-- Current repo (existing single-repo view) -->
|
|
13
|
-
<div class="tab-panel" id="panel-current">
|
|
14
|
-
<div class="grid">
|
|
15
|
-
<div class="card">
|
|
16
|
-
<h2>Context Window</h2>
|
|
17
|
-
<div class="meter-label" id="ctx-pct">—</div>
|
|
18
|
-
<div class="meter-track"><div class="meter-fill" id="ctx-bar" style="width:0%"></div></div>
|
|
19
|
-
<div class="meter-sub" id="ctx-sub">waiting for data</div>
|
|
20
|
-
</div>
|
|
21
|
-
<div class="card">
|
|
22
|
-
<h2>Trigger Status</h2>
|
|
23
|
-
<div class="status-row"><div class="bullet" id="tr-armed"></div><span>Armed (context ≥ fast gate)</span></div>
|
|
24
|
-
<div class="status-row"><div class="bullet" id="tr-ready"></div><span>Ready (tokens ≥ threshold)</span></div>
|
|
25
|
-
<div class="state-text" id="tr-state">waiting</div>
|
|
26
|
-
</div>
|
|
27
|
-
<div class="card">
|
|
28
|
-
<h2>Vector Store</h2>
|
|
29
|
-
<div class="stat-grid">
|
|
30
|
-
<span class="label" title="A saved summary of a chunk of your conversation that was compacted to free up space.">Checkpoints</span><span class="value" id="st-count">0</span>
|
|
31
|
-
<span class="label" title="How much conversation we are currently holding as compact summaries (the 'memory' this extension keeps). Smaller is better.">Tokens Stored</span><span class="value" id="st-tokens">0</span>
|
|
32
|
-
<span class="label" title="Total size of the original conversation text before it was compacted.">Original Tokens</span><span class="value" id="st-orig">0</span>
|
|
33
|
-
<span class="label" title="How much conversation space we have freed up for you (original size minus the compact summary we kept).">Tokens Saved</span><span class="value" id="st-saved">0</span>
|
|
34
|
-
<span class="label" title="How many times old context was automatically brought back into the conversation because it was relevant to what you were doing.">Injected</span><span class="value" id="st-injected">0</span>
|
|
35
|
-
<span class="label" title="Of the times we recalled old context, how often it was actually on-topic.">Recall Relevance</span><span class="value" id="st-dedup">0%</span>
|
|
36
|
-
<span class="label" title="How often new content matched something we already had, so we skipped storing a duplicate copy. Higher = less wasted space.">Storage Dedup</span><span class="value" id="st-sdedup">0%</span>
|
|
37
|
-
<span class="label" title="How many duplicate chunks we collapsed into one instead of storing separately.">Collapsed</span><span class="value" id="st-collapsed">0</span>
|
|
38
|
-
<span class="label" title="The ID of the most recent saved checkpoint.">Last ID</span><span class="value" id="st-lastid">—</span>
|
|
39
|
-
</div>
|
|
40
|
-
</div>
|
|
41
|
-
<div class="card">
|
|
42
|
-
<h2>Repo (all sessions)</h2>
|
|
43
|
-
<div class="stat-grid">
|
|
44
|
-
<span class="label">Checkpoints</span><span class="value" id="rp-count">0</span>
|
|
45
|
-
<span class="label">Tokens Stored</span><span class="value" id="rp-tokens">0</span>
|
|
46
|
-
<span class="label">Original Tokens</span><span class="value" id="rp-orig">0</span>
|
|
47
|
-
<span class="label">Tokens Saved</span><span class="value" id="rp-saved">0</span>
|
|
48
|
-
<span class="label">Sessions</span><span class="value" id="rp-sessions">0</span>
|
|
49
|
-
<span class="label">Collapsed</span><span class="value" id="rp-collapsed">0</span>
|
|
50
|
-
<span class="label">Storage Dedup</span><span class="value" id="rp-sdedup">0%</span>
|
|
51
|
-
</div>
|
|
52
|
-
</div>
|
|
53
|
-
<div class="card safe">
|
|
54
|
-
<h2>🛡 Data Safety</h2>
|
|
55
|
-
<div class="stat-grid">
|
|
56
|
-
<span class="label">Regions Retained</span><span class="value" id="ig-retained">0</span>
|
|
57
|
-
<span class="label">Compressed-Original</span><span class="value" id="ig-bytes">0 B</span>
|
|
58
|
-
<span class="label">Dedup Duplicates</span><span class="value" id="ig-dupes">0</span>
|
|
59
|
-
<span class="label">Permanently Deleted</span><span class="value ok" id="ig-deleted">0 B</span>
|
|
60
|
-
</div>
|
|
61
|
-
<p class="safe-note">Every compacted region is kept verbatim (compressed). "Drop" = removed from the live window only. We never delete your data.</p>
|
|
62
|
-
</div>
|
|
63
|
-
<div class="card">
|
|
64
|
-
<h2>Configuration</h2>
|
|
65
|
-
<div class="conf-grid">
|
|
66
|
-
<span class="label">Tier</span><span class="value" id="cf-tier">${tierName}</span>
|
|
67
|
-
<span class="label">Threshold</span><span class="value" id="cf-threshold">—</span>
|
|
68
|
-
<span class="label">Fast Gate</span><span class="value" id="cf-gate">—</span>
|
|
69
|
-
<span class="label">Auto</span><span class="value" id="cf-auto">—</span>
|
|
70
|
-
<span class="label">Anchor</span><span class="value" id="cf-anchor">—</span>
|
|
71
|
-
</div>
|
|
72
|
-
</div>
|
|
73
|
-
<div class="card cost">
|
|
74
|
-
<h2>💰 Model & Cost Savings</h2>
|
|
75
|
-
<div class="cost-usd" id="cost-usd">≈ $0.00 saved</div>
|
|
76
|
-
<div class="cost-sub" id="cost-windows">0 context-windows extended</div>
|
|
77
|
-
<div class="stat-grid" style="margin-top:12px">
|
|
78
|
-
<span class="label" title="The model pi is currently using — its pricing drives the cost figure.">Model</span><span class="value" id="md-name">—</span>
|
|
79
|
-
<span class="label" title="The provider serving the model.">Provider</span><span class="value" id="md-provider">—</span>
|
|
80
|
-
<span class="label" title="USD per input token, from the model's pricing.">Input Rate</span><span class="value" id="md-input">—</span>
|
|
81
|
-
<span class="label" title="USD per output token, from the model's pricing.">Output Rate</span><span class="value" id="md-output">—</span>
|
|
82
|
-
</div>
|
|
83
|
-
</div>
|
|
84
|
-
<div class="card">
|
|
85
|
-
<h2>Crew / Agents</h2>
|
|
86
|
-
<div class="stat-grid">
|
|
87
|
-
<span class="label">Active Agents</span><span class="value" id="cr-agents">0</span>
|
|
88
|
-
<span class="label">Current Turn</span><span class="value" id="cr-turn">0</span>
|
|
89
|
-
<span class="label">Status</span><span class="value" id="cr-status">idle</span>
|
|
90
|
-
</div>
|
|
91
|
-
</div>
|
|
92
|
-
<div class="card legend">
|
|
93
|
-
<h2>What these numbers mean</h2>
|
|
94
|
-
<ul class="legend-list">
|
|
95
|
-
<li><b>Tokens saved</b> — conversation space this extension has freed up for you (it compacted old text into short summaries).</li>
|
|
96
|
-
<li><b>Tokens stored</b> — how much "memory" (compact summaries) the extension is currently holding for this repo.</li>
|
|
97
|
-
<li><b>Injected</b> — times old context was automatically pasted back in because it was relevant to your current task.</li>
|
|
98
|
-
<li><b>Recall relevance</b> — of those, how often the recalled context was actually on-topic.</li>
|
|
99
|
-
<li><b>Storage dedup</b> — how often new content matched something already saved, so a duplicate copy was skipped (saves space).</li>
|
|
100
|
-
<li><b>Data safety</b> — every compacted region is kept verbatim (compressed). Nothing is permanently deleted; you can restore any of it.</li>
|
|
101
|
-
</ul>
|
|
102
|
-
<p class="legend-note">Hover any label above for a quick explanation.</p>
|
|
103
|
-
</div>
|
|
104
|
-
</div>
|
|
105
|
-
|
|
106
|
-
<div class="events">
|
|
107
|
-
<h2>Event Stream</h2>
|
|
108
|
-
<div class="events-wrap" id="events"><div class="empty">connecting…</div></div>
|
|
109
|
-
</div>
|
|
110
|
-
|
|
111
|
-
<h2 style="margin-top:24px;font-size:13px;color:#8b949e;text-transform:uppercase;letter-spacing:.5px">All Repositories</h2>
|
|
112
|
-
<table class="repos">
|
|
113
|
-
<thead>
|
|
114
|
-
<tr>
|
|
115
|
-
<th>Repo</th><th>Model</th>
|
|
116
|
-
<th style="text-align:right">Checkpoints</th>
|
|
117
|
-
<th style="text-align:right">Tokens Saved</th>
|
|
118
|
-
<th style="text-align:right">Retained</th>
|
|
119
|
-
<th style="text-align:right">Last Compacted</th>
|
|
120
|
-
</tr>
|
|
121
|
-
</thead>
|
|
122
|
-
<tbody id="cur-rows"><tr><td colspan="6" class="repo-none">loading…</td></tr></tbody>
|
|
123
|
-
</table>
|
|
124
|
-
<div class="updated" id="cur-updated"></div>
|
|
125
|
-
|
|
126
|
-
<div class="updated" id="updated"></div>
|
|
127
|
-
</div><!-- /panel-current -->
|
|
128
|
-
|
|
129
|
-
`;
|
|
130
|
-
}
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Document opening: DOCTYPE, <html>, <head> opening + meta/title.
|
|
3
|
-
*
|
|
4
|
-
* Styles are injected between this fragment and `bodyOpen()` by the
|
|
5
|
-
* `dashboardHtml` composer in `../html-template.ts`.
|
|
6
|
-
*/
|
|
7
|
-
export function headOpen() {
|
|
8
|
-
return `<!DOCTYPE html>
|
|
9
|
-
<html lang="en">
|
|
10
|
-
<head>
|
|
11
|
-
<meta charset="utf-8">
|
|
12
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
13
|
-
<title>mega-compact dashboard</title>
|
|
14
|
-
`;
|
|
15
|
-
// trailing \n preserves the original blank-line separation to <style> in the composer
|
|
16
|
-
}
|