pi-mega-compact 0.7.8 → 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.
Files changed (122) hide show
  1. package/README.md +11 -12
  2. package/dist/extensions/dashboard-server/html.js +1023 -0
  3. package/dist/extensions/dashboard-server/html.test.js +41 -0
  4. package/dist/extensions/dashboard-server/index-reader.js +133 -0
  5. package/dist/extensions/dashboard-server/server.js +530 -0
  6. package/dist/extensions/dashboard-server/server.test.js +120 -0
  7. package/dist/extensions/dashboard-server/snapshot.js +43 -0
  8. package/dist/extensions/dashboard-server/state.js +30 -0
  9. package/dist/extensions/dashboard-server/types.js +5 -0
  10. package/dist/extensions/dashboard-server-s32.test.js +181 -0
  11. package/dist/extensions/dashboard-server.js +7 -1315
  12. package/dist/extensions/mega-commands.js +162 -134
  13. package/dist/extensions/mega-compact.js +3 -0
  14. package/dist/extensions/mega-compact.test.js +90 -21
  15. package/dist/extensions/mega-conflict-cmds.js +5 -1
  16. package/dist/extensions/mega-dashboard-cmds.js +29 -22
  17. package/dist/extensions/mega-db-cmds.js +11 -2
  18. package/dist/extensions/mega-events/agent-handlers.js +222 -0
  19. package/dist/extensions/mega-events/compact-handlers.js +162 -0
  20. package/dist/extensions/mega-events/context-handler.js +249 -0
  21. package/dist/extensions/mega-events/register.js +21 -0
  22. package/dist/extensions/mega-events/session-handlers.js +142 -0
  23. package/dist/extensions/mega-events.js +15 -699
  24. package/dist/extensions/mega-game-cmds.js +106 -0
  25. package/dist/extensions/mega-game-cmds.test.js +113 -0
  26. package/dist/extensions/mega-pipeline/compact.js +324 -0
  27. package/dist/extensions/mega-pipeline/memory-review.js +38 -0
  28. package/dist/extensions/mega-pipeline/recall.js +147 -0
  29. package/dist/extensions/mega-pipeline.js +9 -480
  30. package/dist/extensions/mega-runtime/helpers.js +40 -0
  31. package/dist/extensions/mega-runtime/query.js +29 -0
  32. package/dist/extensions/mega-runtime/state.js +877 -0
  33. package/dist/extensions/mega-runtime/state.test.js +171 -0
  34. package/dist/extensions/mega-runtime/widget.js +270 -0
  35. package/dist/extensions/mega-runtime/widget.test.js +160 -0
  36. package/dist/extensions/mega-runtime.js +15 -947
  37. package/dist/src/config/themes.js +84 -0
  38. package/dist/src/config/themes.test.js +94 -0
  39. package/dist/src/game/scoring.js +105 -0
  40. package/dist/src/game/scoring.test.js +98 -0
  41. package/dist/src/store/sqlite/checkpoints.js +145 -0
  42. package/dist/src/store/sqlite/dedup-mirror.js +64 -0
  43. package/dist/src/store/sqlite/foundation.js +38 -0
  44. package/dist/src/store/sqlite/game-achievements.js +111 -0
  45. package/dist/src/store/sqlite/game-achievements.test.js +67 -0
  46. package/dist/src/store/sqlite/game-scores.js +105 -0
  47. package/dist/src/store/sqlite/game-scores.test.js +106 -0
  48. package/dist/src/store/sqlite/game-state.js +54 -0
  49. package/dist/src/store/sqlite/game-state.test.js +76 -0
  50. package/dist/src/store/sqlite/global-index.js +224 -0
  51. package/dist/src/store/sqlite/maintenance.js +235 -0
  52. package/dist/src/store/sqlite/memories.js +164 -0
  53. package/dist/src/store/sqlite/meta.js +82 -0
  54. package/dist/src/store/sqlite/model-snapshots.js +47 -0
  55. package/dist/src/store/sqlite/raptor.js +57 -0
  56. package/dist/src/store/sqlite/raw-transcript.js +134 -0
  57. package/dist/src/store/sqlite/schema.js +294 -0
  58. package/dist/src/store/sqlite/session-state.js +28 -0
  59. package/dist/src/store/sqlite/stats.js +66 -0
  60. package/dist/src/store/sqlite/utils.js +120 -0
  61. package/dist/src/store/sqlite.js +23 -1607
  62. package/extensions/dashboard-server/html.test.ts +50 -0
  63. package/extensions/dashboard-server/html.ts +1026 -0
  64. package/extensions/dashboard-server/index-reader.ts +130 -0
  65. package/extensions/dashboard-server/server.test.ts +131 -0
  66. package/extensions/dashboard-server/server.ts +505 -0
  67. package/extensions/dashboard-server/snapshot.ts +44 -0
  68. package/extensions/dashboard-server/state.ts +33 -0
  69. package/extensions/dashboard-server/types.ts +134 -0
  70. package/extensions/dashboard-server-s32.test.ts +195 -0
  71. package/extensions/dashboard-server.ts +7 -1431
  72. package/extensions/mega-commands.ts +33 -10
  73. package/extensions/mega-compact.test.ts +198 -43
  74. package/extensions/mega-compact.ts +3 -0
  75. package/extensions/mega-conflict-cmds.ts +6 -2
  76. package/extensions/mega-dashboard-cmds.ts +30 -23
  77. package/extensions/mega-db-cmds.ts +11 -3
  78. package/extensions/mega-events/agent-handlers.ts +262 -0
  79. package/extensions/mega-events/compact-handlers.ts +192 -0
  80. package/extensions/mega-events/context-handler.ts +290 -0
  81. package/extensions/mega-events/register.ts +37 -0
  82. package/extensions/mega-events/session-handlers.ts +165 -0
  83. package/extensions/mega-events.ts +15 -780
  84. package/extensions/mega-game-cmds.test.ts +137 -0
  85. package/extensions/mega-game-cmds.ts +122 -0
  86. package/extensions/mega-pipeline/compact.ts +366 -0
  87. package/extensions/mega-pipeline/memory-review.ts +46 -0
  88. package/extensions/mega-pipeline/recall.ts +165 -0
  89. package/extensions/mega-pipeline.ts +9 -537
  90. package/extensions/mega-runtime/helpers.ts +68 -0
  91. package/extensions/mega-runtime/query.ts +29 -0
  92. package/extensions/mega-runtime/state.test.ts +171 -0
  93. package/extensions/mega-runtime/state.ts +967 -0
  94. package/extensions/mega-runtime/widget.test.ts +185 -0
  95. package/extensions/mega-runtime/widget.ts +359 -0
  96. package/extensions/mega-runtime.ts +15 -1093
  97. package/package.json +4 -3
  98. package/src/config/themes.test.ts +116 -0
  99. package/src/config/themes.ts +124 -0
  100. package/src/game/scoring.test.ts +103 -0
  101. package/src/game/scoring.ts +158 -0
  102. package/src/store/sqlite/checkpoints.ts +204 -0
  103. package/src/store/sqlite/dedup-mirror.ts +114 -0
  104. package/src/store/sqlite/foundation.ts +63 -0
  105. package/src/store/sqlite/game-achievements.test.ts +80 -0
  106. package/src/store/sqlite/game-achievements.ts +147 -0
  107. package/src/store/sqlite/game-scores.test.ts +132 -0
  108. package/src/store/sqlite/game-scores.ts +168 -0
  109. package/src/store/sqlite/game-state.test.ts +89 -0
  110. package/src/store/sqlite/game-state.ts +87 -0
  111. package/src/store/sqlite/global-index.ts +305 -0
  112. package/src/store/sqlite/maintenance.ts +294 -0
  113. package/src/store/sqlite/memories.ts +217 -0
  114. package/src/store/sqlite/meta.ts +108 -0
  115. package/src/store/sqlite/model-snapshots.ts +83 -0
  116. package/src/store/sqlite/raptor.ts +107 -0
  117. package/src/store/sqlite/raw-transcript.ts +221 -0
  118. package/src/store/sqlite/schema.ts +305 -0
  119. package/src/store/sqlite/session-state.ts +38 -0
  120. package/src/store/sqlite/stats.ts +127 -0
  121. package/src/store/sqlite/utils.ts +125 -0
  122. package/src/store/sqlite.ts +23 -2204
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.7.8",
3
+ "version": "0.8.0",
4
4
  "description": "Layered, local, vector-backed context compressor for pi — supersede/collapse/cluster compaction with deduped inline recall.",
5
5
  "type": "module",
6
6
  "license": "BSD-2-Clause",
@@ -41,15 +41,16 @@
41
41
  },
42
42
  "scripts": {
43
43
  "build": "tsc -p tsconfig.json",
44
- "lint": "tsc --noEmit && node scripts/guardrails-scan.mjs",
44
+ "lint": "tsc --noEmit && node scripts/guardrails-scan.mjs && node scripts/semantic-scan.mjs",
45
45
  "test": "npm run build && node scripts/run-tests.mjs",
46
- "guardrails": "python3 scripts/regression_check.py --all || node scripts/guardrails-scan.mjs",
46
+ "guardrails": "python3 scripts/regression_check.py --all || node scripts/guardrails-scan.mjs || node scripts/semantic-scan.mjs",
47
47
  "precommit": "bash .claude/hooks/pre-commit.sh",
48
48
  "prepublishOnly": "npm run build",
49
49
  "postinstall": "npm rebuild @mongodb-js/zstd || true"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "@earendil-works/pi-coding-agent": "*",
53
+ "@earendil-works/pi-tui": ">=0.80",
53
54
  "openclaw": ">=0.1.0"
54
55
  },
55
56
  "devDependencies": {
@@ -0,0 +1,116 @@
1
+ /**
2
+ * themes.test.ts — S30 theme palette source-of-truth tests.
3
+ * Pi-agnostic: no pi runtime imports.
4
+ */
5
+ import { describe, it } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import {
8
+ THEMES,
9
+ THEME_IDS,
10
+ DEFAULT_THEME,
11
+ getTheme,
12
+ isValidTheme,
13
+ nextTheme,
14
+ themeCssVars,
15
+ themeDataBlock,
16
+ } from "./themes.js";
17
+
18
+ describe("themes (S30)", () => {
19
+ it("defines exactly 6 themes", () => {
20
+ assert.equal(THEMES.length, 6);
21
+ assert.equal(THEME_IDS.length, 6);
22
+ });
23
+
24
+ it("has unique ids", () => {
25
+ assert.equal(new Set(THEME_IDS).size, 6);
26
+ });
27
+
28
+ it("default theme is transparent", () => {
29
+ assert.equal(DEFAULT_THEME, "transparent");
30
+ assert.ok(isValidTheme(DEFAULT_THEME));
31
+ });
32
+
33
+ it("includes retro, orange-bold, cyan-neon, amber-mono, grayscale", () => {
34
+ for (const id of ["retro", "orange-bold", "cyan-neon", "amber-mono", "grayscale"]) {
35
+ assert.ok(isValidTheme(id), `missing ${id}`);
36
+ }
37
+ });
38
+
39
+ it("transparent theme has null bg (CSS + ANSI)", () => {
40
+ const t = getTheme("transparent");
41
+ assert.ok(t);
42
+ assert.equal(t!.css.bg, null);
43
+ assert.equal(t!.ansi.bg, null);
44
+ });
45
+
46
+ it("every non-transparent theme has a non-null bg", () => {
47
+ for (const t of THEMES) {
48
+ if (t.id === "transparent") continue;
49
+ assert.ok(t.css.bg, `${t.id} css.bg`);
50
+ assert.ok(t.ansi.bg, `${t.id} ansi.bg`);
51
+ }
52
+ });
53
+
54
+ it("every theme has css + ansi {fg,accent,mega} strings", () => {
55
+ for (const t of THEMES) {
56
+ assert.ok(typeof t.css.fg === "string" && t.css.fg.startsWith("#"));
57
+ assert.ok(typeof t.css.accent === "string");
58
+ assert.ok(typeof t.css.mega === "string");
59
+ assert.ok(typeof t.ansi.fg === "string");
60
+ assert.ok(typeof t.ansi.accent === "string");
61
+ assert.ok(typeof t.ansi.mega === "string");
62
+ }
63
+ });
64
+
65
+ it("getTheme returns undefined for unknown id", () => {
66
+ assert.equal(getTheme("nope"), undefined);
67
+ });
68
+
69
+ it("isValidTheme is false for unknown", () => {
70
+ assert.equal(isValidTheme("nope"), false);
71
+ });
72
+
73
+ it("nextTheme cycles through all themes and wraps", () => {
74
+ const seen: string[] = [];
75
+ let cur = DEFAULT_THEME;
76
+ for (let i = 0; i < THEME_IDS.length; i++) {
77
+ cur = nextTheme(cur);
78
+ seen.push(cur);
79
+ }
80
+ // after N steps we've visited every theme once
81
+ assert.equal(new Set(seen).size, THEME_IDS.length);
82
+ // wraps: one more step from the last returns to the first
83
+ const first = nextTheme(THEME_IDS[THEME_IDS.length - 1]!);
84
+ assert.equal(first, THEME_IDS[0]);
85
+ });
86
+
87
+ it("nextTheme falls back to DEFAULT_THEME for unknown current", () => {
88
+ assert.equal(nextTheme("garbage"), DEFAULT_THEME);
89
+ });
90
+ });
91
+
92
+ describe("themeCssVars / themeDataBlock (S32)", () => {
93
+ it("themeCssVars emits --bg/--fg/--accent/--mega for a normal theme", () => {
94
+ const t = getTheme("retro")!;
95
+ const vars = themeCssVars(t);
96
+ assert.match(vars, /--bg: #003300;/);
97
+ assert.match(vars, /--fg: #33ff33;/);
98
+ assert.match(vars, /--accent: #00ff41;/);
99
+ assert.match(vars, /--mega: #39ff14;/);
100
+ });
101
+
102
+ it("themeCssVars maps null bg to 'transparent'", () => {
103
+ const t = getTheme("transparent")!;
104
+ const vars = themeCssVars(t);
105
+ assert.match(vars, /--bg: transparent;/);
106
+ assert.match(vars, /--fg: #c9d1d9;/);
107
+ });
108
+
109
+ it("themeDataBlock wraps vars in a :root[data-theme=\"<id\"] selector", () => {
110
+ const t = getTheme("cyan-neon")!;
111
+ const block = themeDataBlock(t);
112
+ assert.equal(block.startsWith(':root[data-theme="cyan-neon"]{ '), true);
113
+ assert.equal(block.endsWith(" }"), true);
114
+ assert.ok(block.includes("--accent: #22d3ee;"));
115
+ });
116
+ });
@@ -0,0 +1,124 @@
1
+ /**
2
+ * themes.ts — SINGLE SOURCE OF TRUTH for game-mode visual-effect themes (S30).
3
+ *
4
+ * Each theme defines BOTH an HTML/CSS palette (dashboard) AND an ANSI accent
5
+ * mapping (TUI widget), since the TUI is ANSI and the dashboard is HTML.
6
+ *
7
+ * Theme scope (QA1 correction): themes restyle the mega-compact statusbar
8
+ * widget + the whole dashboard HTML page. We do NOT theme pi's chrome.
9
+ *
10
+ * `transparent` (default) semantics: no background fill, colored accents only —
11
+ * so game-mode stays visible against any terminal background.
12
+ *
13
+ * PREVENT-PI-004: pure config, no network. Pi-agnostic: no pi runtime types.
14
+ */
15
+
16
+ /** HTML/CSS palette for the dashboard (hex strings, or `null` for "no bg"). */
17
+ export interface ThemeCss {
18
+ /** Page/cell background hex, or `null` for transparent (no bg fill). */
19
+ bg: string | null;
20
+ /** Default foreground (text) hex. */
21
+ fg: string;
22
+ /** Accent hex (bars, highlights, level numbers). */
23
+ accent: string;
24
+ /** MEGA CACHE highlight hex (fires at cache >= 100%). */
25
+ mega: string;
26
+ }
27
+
28
+ /** ANSI accent mapping for the TUI widget (SGR parameter strings, no `\x1b[`).
29
+ * `bg` is `null` for transparent themes (no background fill). */
30
+ export interface ThemeAnsi {
31
+ /** Background SGR params (e.g. "48;5;23"), or `null` for no bg fill. */
32
+ bg: string | null;
33
+ /** Foreground SGR params (e.g. "39" for default, "92" for bright green). */
34
+ fg: string;
35
+ /** Accent SGR params. */
36
+ accent: string;
37
+ /** MEGA CACHE SGR params (fires at cache >= 100%). */
38
+ mega: string;
39
+ }
40
+
41
+ /** A complete theme = CSS palette + ANSI mapping + display metadata. */
42
+ export interface Theme {
43
+ id: string;
44
+ label: string;
45
+ css: ThemeCss;
46
+ ansi: ThemeAnsi;
47
+ }
48
+
49
+ export const DEFAULT_THEME = "transparent";
50
+
51
+ export const THEMES: readonly Theme[] = [
52
+ {
53
+ id: "transparent",
54
+ label: "Transparent (accents only)",
55
+ css: { bg: null, fg: "#c9d1d9", accent: "#3fb950", mega: "#f0883e" },
56
+ ansi: { bg: null, fg: "39", accent: "32", mega: "33" },
57
+ },
58
+ {
59
+ id: "retro",
60
+ label: "Retro 8-bit green",
61
+ css: { bg: "#003300", fg: "#33ff33", accent: "#00ff41", mega: "#39ff14" },
62
+ ansi: { bg: "40", fg: "92", accent: "92", mega: "1;92" },
63
+ },
64
+ {
65
+ id: "orange-bold",
66
+ label: "Orange bold",
67
+ css: { bg: "#1a0f00", fg: "#ff8c00", accent: "#ff6b00", mega: "#ff4500" },
68
+ ansi: { bg: "48;5;52", fg: "38;5;208", accent: "38;5;208", mega: "1;38;5;202" },
69
+ },
70
+ {
71
+ id: "cyan-neon",
72
+ label: "Cyan neon",
73
+ css: { bg: "#001a1a", fg: "#00ffff", accent: "#22d3ee", mega: "#7df9ff" },
74
+ ansi: { bg: "48;5;23", fg: "38;5;51", accent: "38;5;51", mega: "1;38;5;51" },
75
+ },
76
+ {
77
+ id: "amber-mono",
78
+ label: "Amber phosphor",
79
+ css: { bg: "#1a1200", fg: "#ffb000", accent: "#ff8c00", mega: "#ff5500" },
80
+ ansi: { bg: "48;5;58", fg: "38;5;214", accent: "38;5;214", mega: "1;38;5;208" },
81
+ },
82
+ {
83
+ id: "grayscale",
84
+ label: "Grayscale minimal",
85
+ css: { bg: "#161616", fg: "#b0b0b0", accent: "#8b949e", mega: "#e6edf3" },
86
+ ansi: { bg: "48;5;235", fg: "38;5;249", accent: "38;5;249", mega: "1;38;5;255" },
87
+ },
88
+ ] as const;
89
+
90
+ /** All valid theme ids, in definition order (used for `theme next` cycling). */
91
+ export const THEME_IDS: readonly string[] = THEMES.map((t) => t.id);
92
+
93
+ /** Look up a theme by id; returns `undefined` for an unknown id (caller
94
+ * validates + falls back to DEFAULT_THEME). */
95
+ export function getTheme(id: string): Theme | undefined {
96
+ return THEMES.find((t) => t.id === id);
97
+ }
98
+
99
+ /** True if `id` is a known theme id. */
100
+ export function isValidTheme(id: string): boolean {
101
+ return THEME_IDS.includes(id);
102
+ }
103
+
104
+ /** Cycle to the next theme id (wraps at the end). Falls back to DEFAULT_THEME
105
+ * if `current` is unknown (defensive — the stored id should always be valid). */
106
+ export function nextTheme(current: string): string {
107
+ const i = THEME_IDS.indexOf(current);
108
+ if (i < 0) return DEFAULT_THEME;
109
+ return THEME_IDS[(i + 1) % THEME_IDS.length]!;
110
+ }
111
+
112
+ /** CSS variable declarations for a theme's CSS palette. `bg` is `null` for the
113
+ * transparent theme → `--bg: transparent` (no page fill, accents only). Pure,
114
+ * no pi types. */
115
+ export function themeCssVars(theme: Theme): string {
116
+ const bg = theme.css.bg ?? "transparent";
117
+ return `--bg: ${bg}; --fg: ${theme.css.fg}; --accent: ${theme.css.accent}; --mega: ${theme.css.mega};`;
118
+ }
119
+
120
+ /** A `:root[data-theme="<id>"]` override block carrying the theme's CSS vars,
121
+ * for client-side instant theme switching. Pure, no pi types. */
122
+ export function themeDataBlock(theme: Theme): string {
123
+ return `:root[data-theme="${theme.id}"]{ ${themeCssVars(theme)} }`;
124
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * scoring.test.ts — S33 pure scoring helpers. Pi-agnostic, no I/O.
3
+ */
4
+ import { describe, it } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { turnLevel, isMegaCache, cacheScore, evaluateAchievements, maxCompactCount, ACHIEVEMENT_DEFS } from "./scoring.js";
7
+ import type { LeaderboardRow } from "../store/sqlite/game-scores.js";
8
+
9
+ describe("scoring (S33)", () => {
10
+ it("turnLevel: floor(log2(n+1))+1 sequence", () => {
11
+ assert.equal(turnLevel(0), 1);
12
+ assert.equal(turnLevel(1), 2);
13
+ assert.equal(turnLevel(2), 2);
14
+ assert.equal(turnLevel(3), 3);
15
+ assert.equal(turnLevel(4), 3);
16
+ assert.equal(turnLevel(7), 4);
17
+ assert.equal(turnLevel(15), 5);
18
+ // defensive: non-finite / negative inputs collapse to level 1
19
+ assert.equal(turnLevel(-1), 1);
20
+ assert.equal(turnLevel(NaN), 1);
21
+ });
22
+
23
+ it("isMegaCache: 100% is the boundary", () => {
24
+ assert.equal(isMegaCache(99.9), false);
25
+ assert.equal(isMegaCache(100), true);
26
+ assert.equal(isMegaCache(150), true);
27
+ assert.equal(isMegaCache(0), false);
28
+ assert.equal(isMegaCache(NaN), false);
29
+ });
30
+
31
+ it("cacheScore: 0 lookups -> 0 (not NaN), else hits/lookups*100", () => {
32
+ assert.equal(cacheScore(0, 0), 0);
33
+ assert.equal(cacheScore(7, 10), 70);
34
+ assert.equal(cacheScore(0, 5), 0);
35
+ assert.equal(cacheScore(10, 10), 100);
36
+ // non-finite inputs are guarded (no NaN leaks)
37
+ assert.equal(cacheScore(1, NaN), 0);
38
+ assert.equal(cacheScore(NaN, 5), 0);
39
+ });
40
+ });
41
+
42
+ function achRow(value: number, opts: { ts?: number; meta?: unknown } = {}): LeaderboardRow {
43
+ return { repo_root: "/repo/a", value, ts: opts.ts ?? 1_700_000_000_000, meta: opts.meta ?? null };
44
+ }
45
+
46
+ describe("evaluateAchievements (S35)", () => {
47
+ const empty = { dedupe: [] as LeaderboardRow[], turns: [] as LeaderboardRow[], cache: [] as LeaderboardRow[], megaCache: [] as LeaderboardRow[], reposCount: 0 };
48
+ it("ACHIEVEMENT_DEFS: 9 achievements, exactly 1 hidden", () => {
49
+ assert.equal(ACHIEVEMENT_DEFS.length, 9);
50
+ assert.equal(ACHIEVEMENT_DEFS.filter((d) => d.hidden).length, 1);
51
+ });
52
+ it("first_compact: >=1 dedupe row", () => {
53
+ const r = evaluateAchievements({ ...empty, dedupe: [achRow(1)] }, []);
54
+ assert.ok(r.unlocked.includes("first_compact"));
55
+ assert.ok(r.newlyUnlocked.includes("first_compact"));
56
+ });
57
+ it("compact_streak: max meta.compactCount >= 5", () => {
58
+ const r = evaluateAchievements({ ...empty, dedupe: [achRow(1, { meta: { compactCount: 5 } })] }, []);
59
+ assert.ok(r.unlocked.includes("compact_streak"));
60
+ });
61
+ it("turn_veteran: max turns >= 25", () => {
62
+ const r = evaluateAchievements({ ...empty, turns: [achRow(25)] }, []);
63
+ assert.ok(r.unlocked.includes("turn_veteran"));
64
+ });
65
+ it("level_five: turnLevel(maxTurns) >= 5 (15 turns)", () => {
66
+ const r = evaluateAchievements({ ...empty, turns: [achRow(15)] }, []);
67
+ assert.ok(r.unlocked.includes("level_five"));
68
+ });
69
+ it("dedupe_master: sum dedupe >= 100", () => {
70
+ const r = evaluateAchievements({ ...empty, dedupe: [achRow(60), achRow(40)] }, []);
71
+ assert.ok(r.unlocked.includes("dedupe_master"));
72
+ });
73
+ it("repo_explorer: reposCount >= 3", () => {
74
+ const r = evaluateAchievements({ ...empty, reposCount: 3 }, []);
75
+ assert.ok(r.unlocked.includes("repo_explorer"));
76
+ });
77
+ it("night_owl: a row whose local hour is in [0,5)", () => {
78
+ const d = new Date(); d.setHours(2, 0, 0, 0);
79
+ const r = evaluateAchievements({ ...empty, cache: [achRow(50, { ts: d.getTime() })] }, []);
80
+ assert.ok(r.unlocked.includes("night_owl"));
81
+ });
82
+ it("flawless: maxCache >= 100 AND no mega_cache overshoot", () => {
83
+ const r = evaluateAchievements({ ...empty, cache: [achRow(100)], megaCache: [] }, []);
84
+ assert.ok(r.unlocked.includes("flawless"));
85
+ });
86
+ it("flawless NOT when a mega_cache overshoot row (>100) exists", () => {
87
+ const r = evaluateAchievements({ ...empty, cache: [achRow(100)], megaCache: [achRow(101)] }, []);
88
+ assert.ok(!r.unlocked.includes("flawless"));
89
+ });
90
+ it("opie_wild_ride: any mega_cache trophy row exists", () => {
91
+ const r = evaluateAchievements({ ...empty, megaCache: [achRow(100)] }, []);
92
+ assert.ok(r.unlocked.includes("opie_wild_ride"));
93
+ });
94
+ it("newlyUnlocked excludes ids already unlocked", () => {
95
+ const r = evaluateAchievements({ ...empty, dedupe: [achRow(1)] }, ["first_compact"]);
96
+ assert.ok(r.unlocked.includes("first_compact"));
97
+ assert.ok(!r.newlyUnlocked.includes("first_compact"));
98
+ });
99
+ it("maxCompactCount: 0 when meta absent, else the max", () => {
100
+ assert.equal(maxCompactCount([achRow(1)]), 0);
101
+ assert.equal(maxCompactCount([achRow(1, { meta: { compactCount: 7 } })]), 7);
102
+ });
103
+ });
@@ -0,0 +1,158 @@
1
+ /**
2
+ * scoring.ts — pure game-mode scoring helpers (S33) + achievements eval (S35).
3
+ *
4
+ * Pi-agnostic: no pi runtime types, no I/O, no dependencies. Every helper is a
5
+ * pure function so it can be unit-tested in isolation and reused by the
6
+ * extension event handlers (src/store stays free of pi types too).
7
+ */
8
+ import type { LeaderboardRow } from "../store/sqlite/game-scores.js";
9
+
10
+ /** The set of leaderboard metrics tracked in the `game_scores` table. */
11
+ export type GameMetric = "cache" | "dedupe" | "turns" | "repos" | "mega_cache";
12
+
13
+ /** Canonical allow-list of game metrics (mirrors the schema CHECK constraint). */
14
+ export const METRICS: readonly GameMetric[] = [
15
+ "cache",
16
+ "dedupe",
17
+ "turns",
18
+ "repos",
19
+ "mega_cache",
20
+ ];
21
+
22
+ /**
23
+ * Player level as a function of completed turns.
24
+ * Sequence: n=0→1, 1→2, 2→2, 3→3, 4→3, 7→4, 15→5, ...
25
+ * i.e. floor(log2(n+1)) + 1 (one level per doubling of turns). Defensive: any
26
+ * non-finite / negative input collapses to level 1 (never throws, never NaN).
27
+ */
28
+ export function turnLevel(n: number): number {
29
+ if (!Number.isFinite(n) || n < 0) return 1;
30
+ return Math.floor(Math.log2(n + 1)) + 1;
31
+ }
32
+
33
+ /**
34
+ * MEGA CACHE trigger test: the cache hit-rate (real ratio, may exceed 100%)
35
+ * has crossed the 100% threshold. Used to award the mega_cache trophy + arm
36
+ * the widget flare (the oopsie gag).
37
+ */
38
+ export function isMegaCache(pct: number): boolean {
39
+ return Number.isFinite(pct) && pct >= 100;
40
+ }
41
+
42
+ /**
43
+ * Cache-hit percentage (0..100+). Returns 0 when there are no lookups so the
44
+ * ratio can never be NaN / Infinity (QA3). Safe against non-finite inputs.
45
+ */
46
+ export function cacheScore(hits: number, lookups: number): number {
47
+ if (!Number.isFinite(hits) || !Number.isFinite(lookups)) return 0;
48
+ return lookups > 0 ? (hits / lookups) * 100 : 0;
49
+ }
50
+
51
+ // ── S35: achievement definitions + evaluation ─────────────────────────────
52
+
53
+ /** A single achievement definition (the seed row shape, minus unlocked_at). */
54
+ export interface AchievementDef {
55
+ id: string;
56
+ title: string;
57
+ description: string;
58
+ hidden: boolean;
59
+ icon: string;
60
+ }
61
+
62
+ /**
63
+ * The 9 achievements (8 visible + 1 hidden easter egg = Opie's Wild Ride).
64
+ * Seeded idempotently into the `game_achievements` table on first open
65
+ * (schema.ts). Pure, pi-agnostic — no I/O.
66
+ */
67
+ export const ACHIEVEMENT_DEFS: readonly AchievementDef[] = [
68
+ { id: "first_compact", title: "First Compact", description: "Compact a conversation once.", hidden: false, icon: "\u{1F476}" },
69
+ { id: "compact_streak", title: "Compact Streak", description: "Compact 5 times in one session.", hidden: false, icon: "\u{1F525}" },
70
+ { id: "turn_veteran", title: "Turn Veteran", description: "Reach 25 turns in a repo.", hidden: false, icon: "\u{1F3C3}" },
71
+ { id: "level_five", title: "Level 5", description: "Reach player level 5.", hidden: false, icon: "\u{2B50}" },
72
+ { id: "dedupe_master", title: "Dedupe Master", description: "Collapse 100 chunks total.", hidden: false, icon: "\u{1F5DC}\u{FE0F}" },
73
+ { id: "repo_explorer", title: "Repo Explorer", description: "Use game mode across 3 repos.", hidden: false, icon: "\u{1F30D}" },
74
+ { id: "night_owl", title: "Night Owl", description: "Record a score after midnight (00:00–05:00 local).", hidden: false, icon: "\u{1F989}" },
75
+ { id: "flawless", title: "Flawless", description: "Hit exactly 100% cache with no overshoot.", hidden: false, icon: "\u{1F4AF}" },
76
+ { id: "opie_wild_ride", title: "Opie's Wild Ride", description: "Push the cache past 100%.", hidden: true, icon: "\u{1F3C6}" },
77
+ ];
78
+
79
+ /** Inputs to evaluateAchievements — the S33 leaderboard aggregates. */
80
+ export interface AchievementContext {
81
+ dedupe: LeaderboardRow[];
82
+ turns: LeaderboardRow[];
83
+ cache: LeaderboardRow[];
84
+ megaCache: LeaderboardRow[];
85
+ reposCount: number;
86
+ }
87
+
88
+ /** Result of evaluateAchievements. */
89
+ export interface AchievementEvalResult {
90
+ unlocked: string[];
91
+ newlyUnlocked: string[];
92
+ }
93
+
94
+ /**
95
+ * Max `compactCount` carried in a dedupe row's `meta` (S33 records
96
+ * `{ compactCount }` on each session_compact). Returns 0 when absent.
97
+ */
98
+ export function maxCompactCount(rows: LeaderboardRow[]): number {
99
+ return rows.reduce((m, r) => {
100
+ const meta = r.meta;
101
+ const cm =
102
+ meta && typeof meta === "object" && "compactCount" in meta
103
+ ? Number((meta as Record<string, unknown>).compactCount) || 0
104
+ : 0;
105
+ return Math.max(m, cm);
106
+ }, 0);
107
+ }
108
+
109
+ /**
110
+ * Pure evaluation of the 9 achievements against the leaderboard aggregates.
111
+ * `alreadyUnlocked` is the set of ids already persisted (so `newlyUnlocked`
112
+ * excludes them). No I/O, no pi types. Mirrors the S35.9 condition table.
113
+ */
114
+ export function evaluateAchievements(
115
+ ctx: AchievementContext,
116
+ alreadyUnlocked: readonly string[],
117
+ ): AchievementEvalResult {
118
+ const sumBy = (rows: LeaderboardRow[]) =>
119
+ rows.reduce((s, r) => s + (Number(r.value) || 0), 0);
120
+ const maxBy = (rows: LeaderboardRow[]) =>
121
+ rows.reduce((m, r) => Math.max(m, Number(r.value) || 0), 0);
122
+
123
+ const dedupeSum = sumBy(ctx.dedupe);
124
+ const maxTurns = maxBy(ctx.turns);
125
+ const maxCache = maxBy(ctx.cache);
126
+ const maxCompact = maxCompactCount(ctx.dedupe);
127
+
128
+ // Night owl: any row (across all metrics) recorded between 00:00–05:00 local.
129
+ const allTs = [
130
+ ...ctx.dedupe,
131
+ ...ctx.turns,
132
+ ...ctx.cache,
133
+ ...ctx.megaCache,
134
+ ];
135
+ const nightOwl = allTs.some((r) => {
136
+ const h = new Date(Number(r.ts) || 0).getHours();
137
+ return h >= 0 && h < 5;
138
+ });
139
+
140
+ // Flawless: cache hit 100%+, but NO mega_cache overshoot (>100).
141
+ const megaOvershoot = ctx.megaCache.some((r) => Number(r.value) > 100);
142
+
143
+ const conditions: Record<string, boolean> = {
144
+ first_compact: ctx.dedupe.length >= 1,
145
+ compact_streak: maxCompact >= 5,
146
+ turn_veteran: maxTurns >= 25,
147
+ level_five: turnLevel(maxTurns) >= 5,
148
+ dedupe_master: dedupeSum >= 100,
149
+ repo_explorer: ctx.reposCount >= 3,
150
+ night_owl: nightOwl,
151
+ flawless: maxCache >= 100 && !megaOvershoot,
152
+ opie_wild_ride: ctx.megaCache.length >= 1,
153
+ };
154
+
155
+ const unlocked = Object.keys(conditions).filter((id) => conditions[id]);
156
+ const newlyUnlocked = unlocked.filter((id) => !alreadyUnlocked.includes(id));
157
+ return { unlocked, newlyUnlocked };
158
+ }