pi-mega-compact 0.8.0 → 0.8.2
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/server.js +25 -3
- package/dist/extensions/mega-compact.js +1 -1
- package/dist/extensions/mega-events/context-handler.js +5 -3
- package/dist/extensions/mega-game-cmds.js +196 -80
- package/dist/extensions/mega-game-cmds.test.js +37 -3
- package/dist/extensions/mega-runtime/state.js +16 -2
- package/extensions/dashboard-server/server.ts +25 -3
- package/extensions/mega-compact.ts +1 -1
- package/extensions/mega-events/context-handler.ts +4 -2
- package/extensions/mega-game-cmds.test.ts +44 -3
- package/extensions/mega-game-cmds.ts +218 -82
- package/extensions/mega-runtime/state.ts +17 -3
- package/package.json +1 -1
|
@@ -17,10 +17,16 @@ export async function launchDashboardServer(stateDir) {
|
|
|
17
17
|
// upgrade instead of reuse it.
|
|
18
18
|
let SERVER_VERSION = "0.0.0";
|
|
19
19
|
try {
|
|
20
|
-
// dashboard-server.js lives at
|
|
21
|
-
//
|
|
20
|
+
// Since v0.7.9 (8821ef3) dashboard-server.js lives at
|
|
21
|
+
// <pkg>/dist/extensions/dashboard-server/, so package.json is THREE levels
|
|
22
|
+
// up. Keep the two- and one-level-up candidates as fallbacks for flatter
|
|
23
|
+
// dev-checkout layouts. Guard each candidate so a missing file is skipped.
|
|
22
24
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
23
|
-
const candidates = [
|
|
25
|
+
const candidates = [
|
|
26
|
+
join(here, "..", "..", "..", "package.json"),
|
|
27
|
+
join(here, "..", "..", "package.json"),
|
|
28
|
+
join(here, "..", "package.json"),
|
|
29
|
+
];
|
|
24
30
|
for (const p of candidates) {
|
|
25
31
|
if (!existsSync(p))
|
|
26
32
|
continue;
|
|
@@ -490,6 +496,18 @@ export async function launchDashboardServer(stateDir) {
|
|
|
490
496
|
log("server running", { url });
|
|
491
497
|
// eslint-disable-next-line no-console
|
|
492
498
|
console.log(`[mega-compact] dashboard server running: ${url}`);
|
|
499
|
+
// v0.8.2: also bind the IPv6 loopback (::1). On many systems `localhost`
|
|
500
|
+
// resolves to ::1 first (see /etc/hosts), so an IPv4-only bind makes the
|
|
501
|
+
// browser hit ::1:port and get connection refused. PREVENT-PI-004
|
|
502
|
+
// (loopback-only) means BOTH 127.0.0.1 and ::1. Non-fatal: IPv4-only
|
|
503
|
+
// hosts or a ::1 already in use just skip the mirror.
|
|
504
|
+
let v6;
|
|
505
|
+
const v4Handler = server.listeners("request")[0];
|
|
506
|
+
if (v4Handler) {
|
|
507
|
+
v6 = createServer((r, s) => v4Handler.call(server, r, s));
|
|
508
|
+
v6.on("error", (e) => log("ipv6 loopback bind skipped", { port, code: e.code, message: e.message }));
|
|
509
|
+
v6.listen(port, "::1", () => log("ipv6 loopback bound", { port })); // guardrails-allow PREVENT-PI-004: IPv6 loopback (::1) mirror of the localhost dashboard server
|
|
510
|
+
}
|
|
493
511
|
// Write port.pid
|
|
494
512
|
try {
|
|
495
513
|
writeFileSync(portFile, JSON.stringify({ port, pid: process.pid }));
|
|
@@ -504,6 +522,10 @@ export async function launchDashboardServer(stateDir) {
|
|
|
504
522
|
}
|
|
505
523
|
catch { /* already gone */ }
|
|
506
524
|
server.close();
|
|
525
|
+
try {
|
|
526
|
+
v6?.close();
|
|
527
|
+
}
|
|
528
|
+
catch { /* not bound */ }
|
|
507
529
|
process.exit(0);
|
|
508
530
|
};
|
|
509
531
|
process.on("SIGTERM", cleanup);
|
|
@@ -18,7 +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
|
+
* - mega-game-cmds.ts /mega-compact-settings (+ /mega-game alias) toggle + theme + TUI display mode
|
|
22
22
|
* - mega-dashboard-cmds.ts localhost dashboard server lifecycle commands
|
|
23
23
|
* - mega-events.ts pi lifecycle event handlers
|
|
24
24
|
*
|
|
@@ -50,15 +50,17 @@ export function registerContextHandler(pi, runtime, config) {
|
|
|
50
50
|
// Legacy: MEGACOMPACT_LEGACY_DURABLE_TRIM=true restores the v0.4.28 ctx.compact
|
|
51
51
|
// path (kept one release as rollback).
|
|
52
52
|
pi.on("context", async (event, ctx) => {
|
|
53
|
-
if (!config.auto)
|
|
54
|
-
return;
|
|
55
53
|
const usage = ctx.getContextUsage();
|
|
56
54
|
const pct = usage?.percent;
|
|
57
|
-
// Always track context for the dashboard, even
|
|
55
|
+
// Always track context for the dashboard/widget, even when auto is off.
|
|
56
|
+
// (v0.8 regression: !config.auto gate sat above this, leaving ctx stats
|
|
57
|
+
// null -> widget '?% / ?/?' when auto disabled. Track first, THEN gate.)
|
|
58
58
|
runtime.lastCtxTokens = usage?.tokens ?? null;
|
|
59
59
|
runtime.lastCtxPercent = pct ?? null;
|
|
60
60
|
runtime.lastCtxWindow = usage?.contextWindow ?? 0;
|
|
61
61
|
runtime.snapshot(ctx);
|
|
62
|
+
if (!config.auto)
|
|
63
|
+
return;
|
|
62
64
|
const messages = event.messages;
|
|
63
65
|
const view = runtime.engineView(messages);
|
|
64
66
|
const currentTokens = usage?.tokens ??
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* mega-game-cmds.ts — /mega-
|
|
2
|
+
* mega-game-cmds.ts — /mega-compact-settings slash command (S30; renamed in v0.8.x).
|
|
3
3
|
*
|
|
4
4
|
* Backs the game-mode toggle + theme picker + TUI display mode. All state is
|
|
5
5
|
* the global `game_state` SQLite row (src/store/sqlite/game-state.ts) — local
|
|
@@ -7,100 +7,216 @@
|
|
|
7
7
|
* command touches no fetch/http). All SQL is parameterized (PREVENT-002) and
|
|
8
8
|
* lives in the src/ submodule, not here.
|
|
9
9
|
*
|
|
10
|
+
* The primary command is /mega-compact-settings. /mega-game is retained as a
|
|
11
|
+
* backward-compat alias (same handler) so existing muscle memory + docs keep
|
|
12
|
+
* working.
|
|
13
|
+
*
|
|
10
14
|
* Usage:
|
|
11
|
-
* /mega-
|
|
12
|
-
* /mega-
|
|
13
|
-
* /mega-
|
|
14
|
-
* /mega-
|
|
15
|
-
* /mega-
|
|
16
|
-
* /mega-
|
|
17
|
-
* /mega-
|
|
18
|
-
* /mega-
|
|
19
|
-
* /mega-
|
|
15
|
+
* /mega-compact-settings print current state
|
|
16
|
+
* /mega-compact-settings on enable game mode (scoring + level-up + MEGA CACHE)
|
|
17
|
+
* /mega-compact-settings off disable game mode
|
|
18
|
+
* /mega-compact-settings theme list available themes
|
|
19
|
+
* /mega-compact-settings theme <id> set theme by id
|
|
20
|
+
* /mega-compact-settings theme next cycle to next theme
|
|
21
|
+
* /mega-compact-settings tui full full TUI widget (bars, stats, flair)
|
|
22
|
+
* /mega-compact-settings tui minimal one-line TUI widget (level + cache %)
|
|
23
|
+
* /mega-compact-settings achievements list unlocked achievements
|
|
20
24
|
*/
|
|
21
25
|
import { getGameState, setGameState, } from "../src/store/sqlite.js";
|
|
22
26
|
import { listAchievements } from "../src/store/sqlite/game-achievements.js";
|
|
23
27
|
import { THEMES, THEME_IDS, getTheme, isValidTheme, nextTheme, DEFAULT_THEME } from "../src/config/themes.js";
|
|
28
|
+
/** Notify/usage tag + command name. Primary surface is /mega-compact-settings. */
|
|
29
|
+
const TAG = "mega-compact-settings";
|
|
24
30
|
/** Format the current state as a human-readable status line set. */
|
|
25
31
|
function fmtState(s) {
|
|
26
32
|
return [
|
|
27
|
-
`[
|
|
33
|
+
`[${TAG}] game mode: ${s.game_mode_on ? "ON" : "off"}`,
|
|
28
34
|
` theme: ${s.theme}${s.theme === DEFAULT_THEME ? " (default)" : ""}`,
|
|
29
35
|
` tui: ${s.tui_display_mode}`,
|
|
30
36
|
];
|
|
31
37
|
}
|
|
32
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
38
|
+
/** Shared handler for /mega-compact-settings (primary) + /mega-game (alias). */
|
|
39
|
+
async function handleSettings(args, ctx, runtime) {
|
|
40
|
+
runtime.bindRepo(ctx.cwd);
|
|
41
|
+
const stateDir = runtime.currentStateDir;
|
|
42
|
+
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
43
|
+
// bare → interactive in-app menu (ctx.ui.select picker). Falls back to a
|
|
44
|
+
// static status print when there's no interactive UI (RPC/print mode, or a
|
|
45
|
+
// test harness stubbing only notify). CLI subcommands below still work for
|
|
46
|
+
// power users + scripts.
|
|
47
|
+
if (parts.length === 0) {
|
|
48
|
+
if (typeof ctx.ui.select === "function") {
|
|
49
|
+
try {
|
|
50
|
+
await runInteractiveMenu(ctx, runtime, stateDir);
|
|
45
51
|
return;
|
|
46
52
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (sub === "achievements") {
|
|
50
|
-
const rows = listAchievements(stateDir).filter((r) => r.unlocked_at != null);
|
|
51
|
-
ctx.ui.notify(`[mega-game] achievements unlocked (${rows.length}/9):`);
|
|
52
|
-
for (const r of rows) {
|
|
53
|
-
ctx.ui.notify(` ${r.icon ?? ""} ${r.title}`);
|
|
54
|
-
}
|
|
55
|
-
return;
|
|
53
|
+
catch {
|
|
54
|
+
// select threw (non-interactive impl) → fall through to status print
|
|
56
55
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
runtime.bumpGameState();
|
|
88
|
-
ctx.ui.notify(`[mega-game] theme → ${s.theme} (${getTheme(s.theme)?.label ?? ""})`);
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
// /mega-game tui full|minimal
|
|
92
|
-
if (sub === "tui") {
|
|
93
|
-
const arg = parts[1];
|
|
94
|
-
if (arg !== "full" && arg !== "minimal") {
|
|
95
|
-
ctx.ui.notify(`[mega-game] usage: /mega-game tui full|minimal`);
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
const s = setGameState({ tui_display_mode: arg }, stateDir);
|
|
99
|
-
runtime.bumpGameState();
|
|
100
|
-
ctx.ui.notify(`[mega-game] tui → ${s.tui_display_mode}`);
|
|
101
|
-
return;
|
|
56
|
+
}
|
|
57
|
+
const s = getGameState(stateDir);
|
|
58
|
+
for (const line of fmtState(s))
|
|
59
|
+
ctx.ui.notify(line);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const sub = parts[0];
|
|
63
|
+
// achievements — terse list of unlocked (hidden only once unlocked).
|
|
64
|
+
if (sub === "achievements") {
|
|
65
|
+
const rows = listAchievements(stateDir).filter((r) => r.unlocked_at != null);
|
|
66
|
+
ctx.ui.notify(`[${TAG}] achievements unlocked (${rows.length}/9):`);
|
|
67
|
+
for (const r of rows) {
|
|
68
|
+
ctx.ui.notify(` ${r.icon ?? ""} ${r.title}`);
|
|
69
|
+
}
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
// on|off
|
|
73
|
+
if (sub === "on" || sub === "off") {
|
|
74
|
+
const s = setGameState({ game_mode_on: sub === "on" }, stateDir);
|
|
75
|
+
runtime.bumpGameState();
|
|
76
|
+
ctx.ui.notify(`[${TAG}] game mode ${s.game_mode_on ? "ON" : "off"}`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
// theme [id|next]
|
|
80
|
+
if (sub === "theme") {
|
|
81
|
+
if (parts.length === 1) {
|
|
82
|
+
// list themes
|
|
83
|
+
ctx.ui.notify(`[${TAG}] themes:`);
|
|
84
|
+
for (const t of THEMES) {
|
|
85
|
+
ctx.ui.notify(` ${t.id.padEnd(14)} ${t.label}`);
|
|
102
86
|
}
|
|
103
|
-
|
|
104
|
-
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const arg = parts[1];
|
|
90
|
+
let id;
|
|
91
|
+
if (arg === "next") {
|
|
92
|
+
id = nextTheme(getGameState(stateDir).theme);
|
|
93
|
+
}
|
|
94
|
+
else if (isValidTheme(arg)) {
|
|
95
|
+
id = arg;
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
ctx.ui.notify(`[${TAG}] unknown theme "${arg}". Valid: ${THEME_IDS.join(", ")}`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const s = setGameState({ theme: id }, stateDir);
|
|
102
|
+
runtime.bumpGameState();
|
|
103
|
+
ctx.ui.notify(`[${TAG}] theme → ${s.theme} (${getTheme(s.theme)?.label ?? ""})`);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// tui full|minimal
|
|
107
|
+
if (sub === "tui") {
|
|
108
|
+
const arg = parts[1];
|
|
109
|
+
if (arg !== "full" && arg !== "minimal") {
|
|
110
|
+
ctx.ui.notify(`[${TAG}] usage: /${TAG} tui full|minimal`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const s = setGameState({ tui_display_mode: arg }, stateDir);
|
|
114
|
+
runtime.bumpGameState();
|
|
115
|
+
ctx.ui.notify(`[${TAG}] tui → ${s.tui_display_mode}`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
ctx.ui.notify(`[${TAG}] usage: /${TAG} [on|off|theme [id|next]|tui [full|minimal]|achievements]`);
|
|
119
|
+
}
|
|
120
|
+
/** Interactive in-app menu for bare `/mega-compact-settings`. Uses ctx.ui.select
|
|
121
|
+
* (a real TUI picker in interactive mode). Loops until the user cancels (or
|
|
122
|
+
* picks "Done"). Each action mutates the global game_state row + bumps the
|
|
123
|
+
* runtime cache so the widget/dashboard reflect it immediately.
|
|
124
|
+
*
|
|
125
|
+
* Guarded by the caller via `typeof ctx.ui.select === "function"`; if select is
|
|
126
|
+
* unavailable we fall back to the static fmtState notify print. */
|
|
127
|
+
async function runInteractiveMenu(ctx, runtime, stateDir) {
|
|
128
|
+
for (;;) {
|
|
129
|
+
const s = getGameState(stateDir);
|
|
130
|
+
const toggleLabel = s.game_mode_on ? "Turn game mode OFF" : "Turn game mode ON";
|
|
131
|
+
const choice = await ctx.ui.select(`[${TAG}] settings · game mode: ${s.game_mode_on ? "ON" : "off"} · theme: ${s.theme} · tui: ${s.tui_display_mode}`, [toggleLabel, "Theme…", "TUI display mode…", "Achievements…", "Done"]);
|
|
132
|
+
if (choice === undefined || choice === "Done")
|
|
133
|
+
return;
|
|
134
|
+
if (choice === toggleLabel) {
|
|
135
|
+
const next = !s.game_mode_on;
|
|
136
|
+
setGameState({ game_mode_on: next }, stateDir);
|
|
137
|
+
runtime.bumpGameState();
|
|
138
|
+
ctx.ui.notify(`[${TAG}] game mode ${next ? "ON" : "off"}`, "info");
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (choice === "Theme…") {
|
|
142
|
+
await themeSubmenu(ctx, runtime, stateDir);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (choice === "TUI display mode…") {
|
|
146
|
+
await tuiSubmenu(ctx, runtime, stateDir);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (choice === "Achievements…") {
|
|
150
|
+
await achievementsView(ctx, stateDir);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** Theme picker submenu — lists all themes (current marked ✓) + a cycle option. */
|
|
156
|
+
async function themeSubmenu(ctx, runtime, stateDir) {
|
|
157
|
+
const s = getGameState(stateDir);
|
|
158
|
+
const opts = THEMES.map((t) => {
|
|
159
|
+
const mark = t.id === s.theme ? " ✓" : "";
|
|
160
|
+
return `${t.id}${mark} ${t.label}`;
|
|
161
|
+
});
|
|
162
|
+
opts.push("next (cycle to next theme)");
|
|
163
|
+
opts.push("Back");
|
|
164
|
+
const choice = await ctx.ui.select(`[${TAG}] theme (current: ${s.theme})`, opts);
|
|
165
|
+
if (choice === undefined || choice === "Back")
|
|
166
|
+
return;
|
|
167
|
+
const first = choice.split(/\s+/)[0];
|
|
168
|
+
let id;
|
|
169
|
+
if (first === "next") {
|
|
170
|
+
id = nextTheme(s.theme);
|
|
171
|
+
}
|
|
172
|
+
else if (isValidTheme(first)) {
|
|
173
|
+
id = first;
|
|
174
|
+
}
|
|
175
|
+
if (id && id !== s.theme) {
|
|
176
|
+
setGameState({ theme: id }, stateDir);
|
|
177
|
+
runtime.bumpGameState();
|
|
178
|
+
ctx.ui.notify(`[${TAG}] theme → ${id} (${getTheme(id)?.label ?? ""})`, "info");
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/** TUI display-mode submenu — full vs minimal (current marked ✓). */
|
|
182
|
+
async function tuiSubmenu(ctx, runtime, stateDir) {
|
|
183
|
+
const s = getGameState(stateDir);
|
|
184
|
+
const mark = (m) => (s.tui_display_mode === m ? " ✓" : "");
|
|
185
|
+
const choice = await ctx.ui.select(`[${TAG}] TUI display mode (current: ${s.tui_display_mode})`, [
|
|
186
|
+
`full${mark("full")} — bars, stats, flair`,
|
|
187
|
+
`minimal${mark("minimal")} — one-line level + cache %`,
|
|
188
|
+
"Back",
|
|
189
|
+
]);
|
|
190
|
+
if (choice === undefined || choice === "Back")
|
|
191
|
+
return;
|
|
192
|
+
const mode = choice.split(/\s+/)[0];
|
|
193
|
+
if (mode === "full" || mode === "minimal") {
|
|
194
|
+
setGameState({ tui_display_mode: mode }, stateDir);
|
|
195
|
+
runtime.bumpGameState();
|
|
196
|
+
ctx.ui.notify(`[${TAG}] tui → ${mode}`, "info");
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/** Achievements view — terse notify list + a read-only select viewer. */
|
|
200
|
+
async function achievementsView(ctx, stateDir) {
|
|
201
|
+
const rows = listAchievements(stateDir).filter((r) => r.unlocked_at != null);
|
|
202
|
+
ctx.ui.notify(`[${TAG}] achievements unlocked (${rows.length}/9):`, "info");
|
|
203
|
+
for (const r of rows)
|
|
204
|
+
ctx.ui.notify(` ${r.icon ?? ""} ${r.title}`, "info");
|
|
205
|
+
const lines = rows.length
|
|
206
|
+
? rows.map((r) => `${r.icon ?? ""} ${r.title}`)
|
|
207
|
+
: ["(none unlocked yet — keep compacting!)"];
|
|
208
|
+
// select() as a read-only viewer; any selection / cancel returns to the menu.
|
|
209
|
+
await ctx.ui.select(`[${TAG}] achievements (${rows.length}/9 unlocked)`, lines);
|
|
210
|
+
}
|
|
211
|
+
/** Register /mega-compact-settings (primary) + /mega-game (backward-compat alias). */
|
|
212
|
+
export function registerGameCommands(pi, runtime) {
|
|
213
|
+
const description = "Game mode toggle + theme picker + TUI display mode. Usage: /mega-compact-settings [on|off|theme [id|next]|tui [full|minimal]|achievements]";
|
|
214
|
+
const handler = (args, ctx) => handleSettings(args, ctx, runtime);
|
|
215
|
+
// Primary command (renamed in v0.8.x from /mega-game).
|
|
216
|
+
pi.registerCommand("mega-compact-settings", { description, handler });
|
|
217
|
+
// Backward-compat alias: /mega-game still resolves to the same settings UI.
|
|
218
|
+
pi.registerCommand("mega-game", {
|
|
219
|
+
description: "(alias for /mega-compact-settings) " + description,
|
|
220
|
+
handler,
|
|
105
221
|
});
|
|
106
222
|
}
|
|
@@ -16,13 +16,13 @@ import { THEME_IDS } from "../src/config/themes.js";
|
|
|
16
16
|
// that wires the command against a fake pi without binding to the real pi
|
|
17
17
|
// module at load time.
|
|
18
18
|
const require = createRequire(import.meta.url);
|
|
19
|
-
function makeHarness(stateDir) {
|
|
19
|
+
function makeHarness(stateDir, select) {
|
|
20
20
|
const commands = {};
|
|
21
21
|
const notifies = [];
|
|
22
22
|
const runtime = { bindRepo: () => { }, currentStateDir: stateDir, bumpGameState: () => { } };
|
|
23
23
|
const ctx = {
|
|
24
24
|
cwd: stateDir,
|
|
25
|
-
ui: { notify: (s) => notifies.push(s) },
|
|
25
|
+
ui: { notify: (s) => notifies.push(s), ...(select ? { select } : {}) },
|
|
26
26
|
};
|
|
27
27
|
const fakePi = {
|
|
28
28
|
registerCommand: (name, opts) => {
|
|
@@ -35,7 +35,7 @@ function makeHarness(stateDir) {
|
|
|
35
35
|
mod.registerGameCommands(fakePi, runtime);
|
|
36
36
|
return { commands, notifies, ctx };
|
|
37
37
|
}
|
|
38
|
-
describe("/mega-
|
|
38
|
+
describe("/mega-compact-settings (S30; /mega-game alias)", () => {
|
|
39
39
|
let dir;
|
|
40
40
|
before(() => {
|
|
41
41
|
dir = mkdtempSync(join(tmpdir(), "mc-megagame-"));
|
|
@@ -52,12 +52,46 @@ describe("/mega-game (S30)", () => {
|
|
|
52
52
|
await h.commands["mega-game"].handler(args, h.ctx);
|
|
53
53
|
return h.notifies;
|
|
54
54
|
}
|
|
55
|
+
it("registers /mega-compact-settings as primary + /mega-game as alias", async () => {
|
|
56
|
+
const h = makeHarness(dir);
|
|
57
|
+
assert.ok(h.commands["mega-compact-settings"], "primary registered");
|
|
58
|
+
assert.ok(h.commands["mega-game"], "alias registered");
|
|
59
|
+
});
|
|
55
60
|
it("bare command prints current (default) state", async () => {
|
|
56
61
|
const lines = await run("");
|
|
57
62
|
assert.ok(lines.some((l) => l.includes("game mode: off")));
|
|
58
63
|
assert.ok(lines.some((l) => l.includes("transparent")));
|
|
59
64
|
assert.ok(lines.some((l) => l.includes("tui:")));
|
|
60
65
|
});
|
|
66
|
+
it("bare command opens interactive menu (select) and toggles game mode", async () => {
|
|
67
|
+
const seq = ["Turn game mode ON", "Done"];
|
|
68
|
+
let i = 0;
|
|
69
|
+
const h = makeHarness(dir, () => Promise.resolve(seq[i++] ?? undefined));
|
|
70
|
+
h.notifies.length = 0;
|
|
71
|
+
await h.commands["mega-compact-settings"].handler("", h.ctx);
|
|
72
|
+
assert.equal(getGameState().game_mode_on, true);
|
|
73
|
+
assert.ok(h.notifies.some((l) => l.includes("game mode ON")));
|
|
74
|
+
// toggle back off via the menu
|
|
75
|
+
const seq2 = ["Turn game mode OFF", "Done"];
|
|
76
|
+
let j = 0;
|
|
77
|
+
const h2 = makeHarness(dir, () => Promise.resolve(seq2[j++] ?? undefined));
|
|
78
|
+
await h2.commands["mega-compact-settings"].handler("", h2.ctx);
|
|
79
|
+
assert.equal(getGameState().game_mode_on, false);
|
|
80
|
+
});
|
|
81
|
+
it("bare command falls back to status print when select is unavailable", async () => {
|
|
82
|
+
// default harness has no select — mimics RPC/print mode
|
|
83
|
+
const lines = await run("");
|
|
84
|
+
assert.ok(lines.some((l) => l.includes("game mode: off")));
|
|
85
|
+
});
|
|
86
|
+
it("menu Theme… → picks a theme and persists", async () => {
|
|
87
|
+
const seq = ["Theme…", "retro Retro Terminal", "Done"];
|
|
88
|
+
let i = 0;
|
|
89
|
+
const h = makeHarness(dir, () => Promise.resolve(seq[i++] ?? undefined));
|
|
90
|
+
h.notifies.length = 0;
|
|
91
|
+
await h.commands["mega-compact-settings"].handler("", h.ctx);
|
|
92
|
+
assert.equal(getGameState().theme, "retro");
|
|
93
|
+
assert.ok(h.notifies.some((l) => l.includes("theme → retro")));
|
|
94
|
+
});
|
|
61
95
|
it("on enables game mode and persists", async () => {
|
|
62
96
|
await run("on");
|
|
63
97
|
assert.equal(getGameState().game_mode_on, true);
|
|
@@ -767,11 +767,25 @@ export class MegaRuntime {
|
|
|
767
767
|
this.gameStateWatchDir = undefined;
|
|
768
768
|
}
|
|
769
769
|
try {
|
|
770
|
-
|
|
770
|
+
// Watch the state DIR (not just sqlite.db) and filter by filename.
|
|
771
|
+
// Why: the store is WAL-mode (openStore sets PRAGMA journal_mode=WAL).
|
|
772
|
+
// Cross-process writes (dashboard server child) append to sqlite.db-wal
|
|
773
|
+
// and do NOT modify sqlite.db until a checkpoint — and a long-lived
|
|
774
|
+
// parent connection (VectorStore + dashboard readers) keeps the WAL
|
|
775
|
+
// uncheckpointed, so a watcher on sqlite.db alone never fires and
|
|
776
|
+
// cachedGameState stays stale (theme stuck after a dashboard edit).
|
|
777
|
+
// Watching the dir + matching sqlite.db* catches the main db, the -wal
|
|
778
|
+
// sidecar, and -shm, so the memo evicts on any cross-process write. The
|
|
779
|
+
// filter also excludes events.log / *.log noise in the same dir.
|
|
780
|
+
this.gameStateWatcher = watch(this.currentStateDir, (_eventType, filename) => {
|
|
781
|
+
if (typeof filename === "string" && filename.startsWith("sqlite.db")) {
|
|
782
|
+
this.cachedGameState = undefined;
|
|
783
|
+
}
|
|
784
|
+
});
|
|
771
785
|
this.gameStateWatchDir = this.currentStateDir;
|
|
772
786
|
}
|
|
773
787
|
catch {
|
|
774
|
-
/* non-fatal: missing
|
|
788
|
+
/* non-fatal: missing dir / platform issue — next snapshot re-queries */
|
|
775
789
|
}
|
|
776
790
|
}
|
|
777
791
|
/** S32: release the fs.watch game-state watcher. Called when the runtime is
|
|
@@ -22,10 +22,16 @@ export async function launchDashboardServer(stateDir: string): Promise<{ port: n
|
|
|
22
22
|
// upgrade instead of reuse it.
|
|
23
23
|
let SERVER_VERSION = "0.0.0";
|
|
24
24
|
try {
|
|
25
|
-
// dashboard-server.js lives at
|
|
26
|
-
//
|
|
25
|
+
// Since v0.7.9 (8821ef3) dashboard-server.js lives at
|
|
26
|
+
// <pkg>/dist/extensions/dashboard-server/, so package.json is THREE levels
|
|
27
|
+
// up. Keep the two- and one-level-up candidates as fallbacks for flatter
|
|
28
|
+
// dev-checkout layouts. Guard each candidate so a missing file is skipped.
|
|
27
29
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
28
|
-
const candidates = [
|
|
30
|
+
const candidates = [
|
|
31
|
+
join(here, "..", "..", "..", "package.json"),
|
|
32
|
+
join(here, "..", "..", "package.json"),
|
|
33
|
+
join(here, "..", "package.json"),
|
|
34
|
+
];
|
|
29
35
|
for (const p of candidates) {
|
|
30
36
|
if (!existsSync(p)) continue;
|
|
31
37
|
const pkg = JSON.parse(readFileSync(p, "utf-8"));
|
|
@@ -464,6 +470,21 @@ export async function launchDashboardServer(stateDir: string): Promise<{ port: n
|
|
|
464
470
|
// eslint-disable-next-line no-console
|
|
465
471
|
console.log(`[mega-compact] dashboard server running: ${url}`);
|
|
466
472
|
|
|
473
|
+
// v0.8.2: also bind the IPv6 loopback (::1). On many systems `localhost`
|
|
474
|
+
// resolves to ::1 first (see /etc/hosts), so an IPv4-only bind makes the
|
|
475
|
+
// browser hit ::1:port and get connection refused. PREVENT-PI-004
|
|
476
|
+
// (loopback-only) means BOTH 127.0.0.1 and ::1. Non-fatal: IPv4-only
|
|
477
|
+
// hosts or a ::1 already in use just skip the mirror.
|
|
478
|
+
let v6: ReturnType<typeof createServer> | undefined;
|
|
479
|
+
const v4Handler = server.listeners("request")[0];
|
|
480
|
+
if (v4Handler) {
|
|
481
|
+
v6 = createServer((r, s) => (v4Handler as (a: IncomingMessage, b: ServerResponse) => void).call(server, r, s));
|
|
482
|
+
v6.on("error", (e: NodeJS.ErrnoException) =>
|
|
483
|
+
log("ipv6 loopback bind skipped", { port, code: e.code, message: e.message }),
|
|
484
|
+
);
|
|
485
|
+
v6.listen(port, "::1", () => log("ipv6 loopback bound", { port })); // guardrails-allow PREVENT-PI-004: IPv6 loopback (::1) mirror of the localhost dashboard server
|
|
486
|
+
}
|
|
487
|
+
|
|
467
488
|
// Write port.pid
|
|
468
489
|
try {
|
|
469
490
|
writeFileSync(portFile, JSON.stringify({ port, pid: process.pid }));
|
|
@@ -475,6 +496,7 @@ export async function launchDashboardServer(stateDir: string): Promise<{ port: n
|
|
|
475
496
|
const cleanup = () => {
|
|
476
497
|
try { unlinkSync(portFile); } catch { /* already gone */ }
|
|
477
498
|
server.close();
|
|
499
|
+
try { v6?.close(); } catch { /* not bound */ }
|
|
478
500
|
process.exit(0);
|
|
479
501
|
};
|
|
480
502
|
process.on("SIGTERM", cleanup);
|
|
@@ -18,7 +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
|
+
* - mega-game-cmds.ts /mega-compact-settings (+ /mega-game alias) toggle + theme + TUI display mode
|
|
22
22
|
* - mega-dashboard-cmds.ts localhost dashboard server lifecycle commands
|
|
23
23
|
* - mega-events.ts pi lifecycle event handlers
|
|
24
24
|
*
|
|
@@ -83,14 +83,16 @@ export function registerContextHandler(
|
|
|
83
83
|
// Legacy: MEGACOMPACT_LEGACY_DURABLE_TRIM=true restores the v0.4.28 ctx.compact
|
|
84
84
|
// path (kept one release as rollback).
|
|
85
85
|
pi.on("context", async (event: ContextEvent, ctx: ExtensionContext) => {
|
|
86
|
-
if (!config.auto) return;
|
|
87
86
|
const usage = ctx.getContextUsage();
|
|
88
87
|
const pct = usage?.percent;
|
|
89
|
-
// Always track context for the dashboard, even
|
|
88
|
+
// Always track context for the dashboard/widget, even when auto is off.
|
|
89
|
+
// (v0.8 regression: !config.auto gate sat above this, leaving ctx stats
|
|
90
|
+
// null -> widget '?% / ?/?' when auto disabled. Track first, THEN gate.)
|
|
90
91
|
runtime.lastCtxTokens = usage?.tokens ?? null;
|
|
91
92
|
runtime.lastCtxPercent = pct ?? null;
|
|
92
93
|
runtime.lastCtxWindow = usage?.contextWindow ?? 0;
|
|
93
94
|
runtime.snapshot(ctx);
|
|
95
|
+
if (!config.auto) return;
|
|
94
96
|
|
|
95
97
|
const messages = event.messages;
|
|
96
98
|
const view = runtime.engineView(messages);
|
|
@@ -26,13 +26,16 @@ type Harness = {
|
|
|
26
26
|
ctx: any;
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
-
function makeHarness(
|
|
29
|
+
function makeHarness(
|
|
30
|
+
stateDir: string,
|
|
31
|
+
select?: (title: string, options: string[]) => Promise<string | undefined>,
|
|
32
|
+
): Harness {
|
|
30
33
|
const commands: Record<string, Cmd> = {};
|
|
31
34
|
const notifies: string[] = [];
|
|
32
35
|
const runtime = { bindRepo: () => {}, currentStateDir: stateDir, bumpGameState: () => {} };
|
|
33
36
|
const ctx = {
|
|
34
37
|
cwd: stateDir,
|
|
35
|
-
ui: { notify: (s: string) => notifies.push(s) },
|
|
38
|
+
ui: { notify: (s: string) => notifies.push(s), ...(select ? { select } : {}) },
|
|
36
39
|
};
|
|
37
40
|
const fakePi = {
|
|
38
41
|
registerCommand: (name: string, opts: Cmd) => {
|
|
@@ -48,7 +51,7 @@ function makeHarness(stateDir: string): Harness {
|
|
|
48
51
|
return { commands, notifies, ctx };
|
|
49
52
|
}
|
|
50
53
|
|
|
51
|
-
describe("/mega-
|
|
54
|
+
describe("/mega-compact-settings (S30; /mega-game alias)", () => {
|
|
52
55
|
let dir: string;
|
|
53
56
|
before(() => {
|
|
54
57
|
dir = mkdtempSync(join(tmpdir(), "mc-megagame-"));
|
|
@@ -67,6 +70,12 @@ describe("/mega-game (S30)", () => {
|
|
|
67
70
|
return h.notifies;
|
|
68
71
|
}
|
|
69
72
|
|
|
73
|
+
it("registers /mega-compact-settings as primary + /mega-game as alias", async () => {
|
|
74
|
+
const h = makeHarness(dir);
|
|
75
|
+
assert.ok(h.commands["mega-compact-settings"], "primary registered");
|
|
76
|
+
assert.ok(h.commands["mega-game"], "alias registered");
|
|
77
|
+
});
|
|
78
|
+
|
|
70
79
|
it("bare command prints current (default) state", async () => {
|
|
71
80
|
const lines = await run("");
|
|
72
81
|
assert.ok(lines.some((l) => l.includes("game mode: off")));
|
|
@@ -74,6 +83,38 @@ describe("/mega-game (S30)", () => {
|
|
|
74
83
|
assert.ok(lines.some((l) => l.includes("tui:")));
|
|
75
84
|
});
|
|
76
85
|
|
|
86
|
+
it("bare command opens interactive menu (select) and toggles game mode", async () => {
|
|
87
|
+
const seq = ["Turn game mode ON", "Done"];
|
|
88
|
+
let i = 0;
|
|
89
|
+
const h = makeHarness(dir, () => Promise.resolve(seq[i++] ?? undefined));
|
|
90
|
+
h.notifies.length = 0;
|
|
91
|
+
await h.commands["mega-compact-settings"].handler("", h.ctx);
|
|
92
|
+
assert.equal(getGameState().game_mode_on, true);
|
|
93
|
+
assert.ok(h.notifies.some((l) => l.includes("game mode ON")));
|
|
94
|
+
// toggle back off via the menu
|
|
95
|
+
const seq2 = ["Turn game mode OFF", "Done"];
|
|
96
|
+
let j = 0;
|
|
97
|
+
const h2 = makeHarness(dir, () => Promise.resolve(seq2[j++] ?? undefined));
|
|
98
|
+
await h2.commands["mega-compact-settings"].handler("", h2.ctx);
|
|
99
|
+
assert.equal(getGameState().game_mode_on, false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("bare command falls back to status print when select is unavailable", async () => {
|
|
103
|
+
// default harness has no select — mimics RPC/print mode
|
|
104
|
+
const lines = await run("");
|
|
105
|
+
assert.ok(lines.some((l) => l.includes("game mode: off")));
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("menu Theme… → picks a theme and persists", async () => {
|
|
109
|
+
const seq = ["Theme…", "retro Retro Terminal", "Done"];
|
|
110
|
+
let i = 0;
|
|
111
|
+
const h = makeHarness(dir, () => Promise.resolve(seq[i++] ?? undefined));
|
|
112
|
+
h.notifies.length = 0;
|
|
113
|
+
await h.commands["mega-compact-settings"].handler("", h.ctx);
|
|
114
|
+
assert.equal(getGameState().theme, "retro");
|
|
115
|
+
assert.ok(h.notifies.some((l) => l.includes("theme → retro")));
|
|
116
|
+
});
|
|
117
|
+
|
|
77
118
|
it("on enables game mode and persists", async () => {
|
|
78
119
|
await run("on");
|
|
79
120
|
assert.equal(getGameState().game_mode_on, true);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* mega-game-cmds.ts — /mega-
|
|
2
|
+
* mega-game-cmds.ts — /mega-compact-settings slash command (S30; renamed in v0.8.x).
|
|
3
3
|
*
|
|
4
4
|
* Backs the game-mode toggle + theme picker + TUI display mode. All state is
|
|
5
5
|
* the global `game_state` SQLite row (src/store/sqlite/game-state.ts) — local
|
|
@@ -7,16 +7,20 @@
|
|
|
7
7
|
* command touches no fetch/http). All SQL is parameterized (PREVENT-002) and
|
|
8
8
|
* lives in the src/ submodule, not here.
|
|
9
9
|
*
|
|
10
|
+
* The primary command is /mega-compact-settings. /mega-game is retained as a
|
|
11
|
+
* backward-compat alias (same handler) so existing muscle memory + docs keep
|
|
12
|
+
* working.
|
|
13
|
+
*
|
|
10
14
|
* Usage:
|
|
11
|
-
* /mega-
|
|
12
|
-
* /mega-
|
|
13
|
-
* /mega-
|
|
14
|
-
* /mega-
|
|
15
|
-
* /mega-
|
|
16
|
-
* /mega-
|
|
17
|
-
* /mega-
|
|
18
|
-
* /mega-
|
|
19
|
-
* /mega-
|
|
15
|
+
* /mega-compact-settings print current state
|
|
16
|
+
* /mega-compact-settings on enable game mode (scoring + level-up + MEGA CACHE)
|
|
17
|
+
* /mega-compact-settings off disable game mode
|
|
18
|
+
* /mega-compact-settings theme list available themes
|
|
19
|
+
* /mega-compact-settings theme <id> set theme by id
|
|
20
|
+
* /mega-compact-settings theme next cycle to next theme
|
|
21
|
+
* /mega-compact-settings tui full full TUI widget (bars, stats, flair)
|
|
22
|
+
* /mega-compact-settings tui minimal one-line TUI widget (level + cache %)
|
|
23
|
+
* /mega-compact-settings achievements list unlocked achievements
|
|
20
24
|
*/
|
|
21
25
|
|
|
22
26
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
@@ -29,94 +33,226 @@ import {
|
|
|
29
33
|
import { listAchievements } from "../src/store/sqlite/game-achievements.js";
|
|
30
34
|
import { THEMES, THEME_IDS, getTheme, isValidTheme, nextTheme, DEFAULT_THEME } from "../src/config/themes.js";
|
|
31
35
|
|
|
36
|
+
/** Notify/usage tag + command name. Primary surface is /mega-compact-settings. */
|
|
37
|
+
const TAG = "mega-compact-settings";
|
|
38
|
+
|
|
32
39
|
/** Format the current state as a human-readable status line set. */
|
|
33
40
|
function fmtState(s: GameState): string[] {
|
|
34
41
|
return [
|
|
35
|
-
`[
|
|
42
|
+
`[${TAG}] game mode: ${s.game_mode_on ? "ON" : "off"}`,
|
|
36
43
|
` theme: ${s.theme}${s.theme === DEFAULT_THEME ? " (default)" : ""}`,
|
|
37
44
|
` tui: ${s.tui_display_mode}`,
|
|
38
45
|
];
|
|
39
46
|
}
|
|
40
47
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
48
|
+
/** Shared handler for /mega-compact-settings (primary) + /mega-game (alias). */
|
|
49
|
+
async function handleSettings(
|
|
50
|
+
args: string,
|
|
51
|
+
ctx: ExtensionContext,
|
|
52
|
+
runtime: MegaRuntime,
|
|
53
|
+
): Promise<void> {
|
|
54
|
+
runtime.bindRepo(ctx.cwd);
|
|
55
|
+
const stateDir = runtime.currentStateDir;
|
|
56
|
+
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
57
|
+
|
|
58
|
+
// bare → interactive in-app menu (ctx.ui.select picker). Falls back to a
|
|
59
|
+
// static status print when there's no interactive UI (RPC/print mode, or a
|
|
60
|
+
// test harness stubbing only notify). CLI subcommands below still work for
|
|
61
|
+
// power users + scripts.
|
|
62
|
+
if (parts.length === 0) {
|
|
63
|
+
if (typeof ctx.ui.select === "function") {
|
|
64
|
+
try {
|
|
65
|
+
await runInteractiveMenu(ctx, runtime, stateDir);
|
|
55
66
|
return;
|
|
67
|
+
} catch {
|
|
68
|
+
// select threw (non-interactive impl) → fall through to status print
|
|
56
69
|
}
|
|
70
|
+
}
|
|
71
|
+
const s = getGameState(stateDir);
|
|
72
|
+
for (const line of fmtState(s)) ctx.ui.notify(line);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
57
75
|
|
|
58
|
-
|
|
76
|
+
const sub = parts[0]!;
|
|
59
77
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
78
|
+
// achievements — terse list of unlocked (hidden only once unlocked).
|
|
79
|
+
if (sub === "achievements") {
|
|
80
|
+
const rows = listAchievements(stateDir).filter((r) => r.unlocked_at != null);
|
|
81
|
+
ctx.ui.notify(`[${TAG}] achievements unlocked (${rows.length}/9):`);
|
|
82
|
+
for (const r of rows) {
|
|
83
|
+
ctx.ui.notify(` ${r.icon ?? ""} ${r.title}`);
|
|
84
|
+
}
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
69
87
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
88
|
+
// on|off
|
|
89
|
+
if (sub === "on" || sub === "off") {
|
|
90
|
+
const s = setGameState({ game_mode_on: sub === "on" }, stateDir);
|
|
91
|
+
runtime.bumpGameState();
|
|
92
|
+
ctx.ui.notify(`[${TAG}] game mode ${s.game_mode_on ? "ON" : "off"}`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
77
95
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
const arg = parts[1]!;
|
|
89
|
-
let id: string;
|
|
90
|
-
if (arg === "next") {
|
|
91
|
-
id = nextTheme(getGameState(stateDir).theme);
|
|
92
|
-
} else if (isValidTheme(arg)) {
|
|
93
|
-
id = arg;
|
|
94
|
-
} else {
|
|
95
|
-
ctx.ui.notify(`[mega-game] unknown theme "${arg}". Valid: ${THEME_IDS.join(", ")}`);
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
const s = setGameState({ theme: id }, stateDir);
|
|
99
|
-
runtime.bumpGameState();
|
|
100
|
-
ctx.ui.notify(`[mega-game] theme → ${s.theme} (${getTheme(s.theme)?.label ?? ""})`);
|
|
101
|
-
return;
|
|
96
|
+
// theme [id|next]
|
|
97
|
+
if (sub === "theme") {
|
|
98
|
+
if (parts.length === 1) {
|
|
99
|
+
// list themes
|
|
100
|
+
ctx.ui.notify(`[${TAG}] themes:`);
|
|
101
|
+
for (const t of THEMES) {
|
|
102
|
+
ctx.ui.notify(` ${t.id.padEnd(14)} ${t.label}`);
|
|
102
103
|
}
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const arg = parts[1]!;
|
|
107
|
+
let id: string;
|
|
108
|
+
if (arg === "next") {
|
|
109
|
+
id = nextTheme(getGameState(stateDir).theme);
|
|
110
|
+
} else if (isValidTheme(arg)) {
|
|
111
|
+
id = arg;
|
|
112
|
+
} else {
|
|
113
|
+
ctx.ui.notify(`[${TAG}] unknown theme "${arg}". Valid: ${THEME_IDS.join(", ")}`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const s = setGameState({ theme: id }, stateDir);
|
|
117
|
+
runtime.bumpGameState();
|
|
118
|
+
ctx.ui.notify(`[${TAG}] theme → ${s.theme} (${getTheme(s.theme)?.label ?? ""})`);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
103
121
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
122
|
+
// tui full|minimal
|
|
123
|
+
if (sub === "tui") {
|
|
124
|
+
const arg = parts[1];
|
|
125
|
+
if (arg !== "full" && arg !== "minimal") {
|
|
126
|
+
ctx.ui.notify(`[${TAG}] usage: /${TAG} tui full|minimal`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const s = setGameState({ tui_display_mode: arg }, stateDir);
|
|
130
|
+
runtime.bumpGameState();
|
|
131
|
+
ctx.ui.notify(`[${TAG}] tui → ${s.tui_display_mode}`);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
ctx.ui.notify(
|
|
136
|
+
`[${TAG}] usage: /${TAG} [on|off|theme [id|next]|tui [full|minimal]|achievements]`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
116
139
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
140
|
+
/** Interactive in-app menu for bare `/mega-compact-settings`. Uses ctx.ui.select
|
|
141
|
+
* (a real TUI picker in interactive mode). Loops until the user cancels (or
|
|
142
|
+
* picks "Done"). Each action mutates the global game_state row + bumps the
|
|
143
|
+
* runtime cache so the widget/dashboard reflect it immediately.
|
|
144
|
+
*
|
|
145
|
+
* Guarded by the caller via `typeof ctx.ui.select === "function"`; if select is
|
|
146
|
+
* unavailable we fall back to the static fmtState notify print. */
|
|
147
|
+
async function runInteractiveMenu(
|
|
148
|
+
ctx: ExtensionContext,
|
|
149
|
+
runtime: MegaRuntime,
|
|
150
|
+
stateDir: string,
|
|
151
|
+
): Promise<void> {
|
|
152
|
+
for (;;) {
|
|
153
|
+
const s = getGameState(stateDir);
|
|
154
|
+
const toggleLabel = s.game_mode_on ? "Turn game mode OFF" : "Turn game mode ON";
|
|
155
|
+
const choice = await ctx.ui.select(
|
|
156
|
+
`[${TAG}] settings · game mode: ${s.game_mode_on ? "ON" : "off"} · theme: ${s.theme} · tui: ${s.tui_display_mode}`,
|
|
157
|
+
[toggleLabel, "Theme…", "TUI display mode…", "Achievements…", "Done"],
|
|
158
|
+
);
|
|
159
|
+
if (choice === undefined || choice === "Done") return;
|
|
160
|
+
if (choice === toggleLabel) {
|
|
161
|
+
const next = !s.game_mode_on;
|
|
162
|
+
setGameState({ game_mode_on: next }, stateDir);
|
|
163
|
+
runtime.bumpGameState();
|
|
164
|
+
ctx.ui.notify(`[${TAG}] game mode ${next ? "ON" : "off"}`, "info");
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (choice === "Theme…") {
|
|
168
|
+
await themeSubmenu(ctx, runtime, stateDir);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (choice === "TUI display mode…") {
|
|
172
|
+
await tuiSubmenu(ctx, runtime, stateDir);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (choice === "Achievements…") {
|
|
176
|
+
await achievementsView(ctx, stateDir);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Theme picker submenu — lists all themes (current marked ✓) + a cycle option. */
|
|
183
|
+
async function themeSubmenu(
|
|
184
|
+
ctx: ExtensionContext,
|
|
185
|
+
runtime: MegaRuntime,
|
|
186
|
+
stateDir: string,
|
|
187
|
+
): Promise<void> {
|
|
188
|
+
const s = getGameState(stateDir);
|
|
189
|
+
const opts = THEMES.map((t) => {
|
|
190
|
+
const mark = t.id === s.theme ? " ✓" : "";
|
|
191
|
+
return `${t.id}${mark} ${t.label}`;
|
|
192
|
+
});
|
|
193
|
+
opts.push("next (cycle to next theme)");
|
|
194
|
+
opts.push("Back");
|
|
195
|
+
const choice = await ctx.ui.select(`[${TAG}] theme (current: ${s.theme})`, opts);
|
|
196
|
+
if (choice === undefined || choice === "Back") return;
|
|
197
|
+
const first = choice.split(/\s+/)[0]!;
|
|
198
|
+
let id: string | undefined;
|
|
199
|
+
if (first === "next") {
|
|
200
|
+
id = nextTheme(s.theme);
|
|
201
|
+
} else if (isValidTheme(first)) {
|
|
202
|
+
id = first;
|
|
203
|
+
}
|
|
204
|
+
if (id && id !== s.theme) {
|
|
205
|
+
setGameState({ theme: id }, stateDir);
|
|
206
|
+
runtime.bumpGameState();
|
|
207
|
+
ctx.ui.notify(`[${TAG}] theme → ${id} (${getTheme(id)?.label ?? ""})`, "info");
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** TUI display-mode submenu — full vs minimal (current marked ✓). */
|
|
212
|
+
async function tuiSubmenu(
|
|
213
|
+
ctx: ExtensionContext,
|
|
214
|
+
runtime: MegaRuntime,
|
|
215
|
+
stateDir: string,
|
|
216
|
+
): Promise<void> {
|
|
217
|
+
const s = getGameState(stateDir);
|
|
218
|
+
const mark = (m: string) => (s.tui_display_mode === m ? " ✓" : "");
|
|
219
|
+
const choice = await ctx.ui.select(`[${TAG}] TUI display mode (current: ${s.tui_display_mode})`, [
|
|
220
|
+
`full${mark("full")} — bars, stats, flair`,
|
|
221
|
+
`minimal${mark("minimal")} — one-line level + cache %`,
|
|
222
|
+
"Back",
|
|
223
|
+
]);
|
|
224
|
+
if (choice === undefined || choice === "Back") return;
|
|
225
|
+
const mode = choice.split(/\s+/)[0];
|
|
226
|
+
if (mode === "full" || mode === "minimal") {
|
|
227
|
+
setGameState({ tui_display_mode: mode }, stateDir);
|
|
228
|
+
runtime.bumpGameState();
|
|
229
|
+
ctx.ui.notify(`[${TAG}] tui → ${mode}`, "info");
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Achievements view — terse notify list + a read-only select viewer. */
|
|
234
|
+
async function achievementsView(ctx: ExtensionContext, stateDir: string): Promise<void> {
|
|
235
|
+
const rows = listAchievements(stateDir).filter((r) => r.unlocked_at != null);
|
|
236
|
+
ctx.ui.notify(`[${TAG}] achievements unlocked (${rows.length}/9):`, "info");
|
|
237
|
+
for (const r of rows) ctx.ui.notify(` ${r.icon ?? ""} ${r.title}`, "info");
|
|
238
|
+
const lines = rows.length
|
|
239
|
+
? rows.map((r) => `${r.icon ?? ""} ${r.title}`)
|
|
240
|
+
: ["(none unlocked yet — keep compacting!)"];
|
|
241
|
+
// select() as a read-only viewer; any selection / cancel returns to the menu.
|
|
242
|
+
await ctx.ui.select(`[${TAG}] achievements (${rows.length}/9 unlocked)`, lines);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Register /mega-compact-settings (primary) + /mega-game (backward-compat alias). */
|
|
246
|
+
export function registerGameCommands(pi: ExtensionAPI, runtime: MegaRuntime): void {
|
|
247
|
+
const description =
|
|
248
|
+
"Game mode toggle + theme picker + TUI display mode. Usage: /mega-compact-settings [on|off|theme [id|next]|tui [full|minimal]|achievements]";
|
|
249
|
+
const handler = (args: string, ctx: ExtensionContext) => handleSettings(args, ctx, runtime);
|
|
250
|
+
|
|
251
|
+
// Primary command (renamed in v0.8.x from /mega-game).
|
|
252
|
+
pi.registerCommand("mega-compact-settings", { description, handler });
|
|
253
|
+
// Backward-compat alias: /mega-game still resolves to the same settings UI.
|
|
254
|
+
pi.registerCommand("mega-game", {
|
|
255
|
+
description: "(alias for /mega-compact-settings) " + description,
|
|
256
|
+
handler,
|
|
121
257
|
});
|
|
122
258
|
}
|
|
@@ -846,13 +846,27 @@ export class MegaRuntime {
|
|
|
846
846
|
this.gameStateWatchDir = undefined;
|
|
847
847
|
}
|
|
848
848
|
try {
|
|
849
|
+
// Watch the state DIR (not just sqlite.db) and filter by filename.
|
|
850
|
+
// Why: the store is WAL-mode (openStore sets PRAGMA journal_mode=WAL).
|
|
851
|
+
// Cross-process writes (dashboard server child) append to sqlite.db-wal
|
|
852
|
+
// and do NOT modify sqlite.db until a checkpoint — and a long-lived
|
|
853
|
+
// parent connection (VectorStore + dashboard readers) keeps the WAL
|
|
854
|
+
// uncheckpointed, so a watcher on sqlite.db alone never fires and
|
|
855
|
+
// cachedGameState stays stale (theme stuck after a dashboard edit).
|
|
856
|
+
// Watching the dir + matching sqlite.db* catches the main db, the -wal
|
|
857
|
+
// sidecar, and -shm, so the memo evicts on any cross-process write. The
|
|
858
|
+
// filter also excludes events.log / *.log noise in the same dir.
|
|
849
859
|
this.gameStateWatcher = watch(
|
|
850
|
-
|
|
851
|
-
() => {
|
|
860
|
+
this.currentStateDir,
|
|
861
|
+
(_eventType, filename) => {
|
|
862
|
+
if (typeof filename === "string" && filename.startsWith("sqlite.db")) {
|
|
863
|
+
this.cachedGameState = undefined;
|
|
864
|
+
}
|
|
865
|
+
},
|
|
852
866
|
);
|
|
853
867
|
this.gameStateWatchDir = this.currentStateDir;
|
|
854
868
|
} catch {
|
|
855
|
-
/* non-fatal: missing
|
|
869
|
+
/* non-fatal: missing dir / platform issue — next snapshot re-queries */
|
|
856
870
|
}
|
|
857
871
|
}
|
|
858
872
|
|
package/package.json
CHANGED