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
@@ -0,0 +1,137 @@
1
+ /**
2
+ * mega-game-cmds.test.ts — /mega-game command parsing matrix (S30).
3
+ * Uses an isolated state dir + a fake pi harness (mirrors mega-compact.test.ts).
4
+ * Pi runtime is mocked; the src/ helpers under test are pi-agnostic.
5
+ */
6
+ import { describe, it, before, after } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { mkdtempSync, rmSync } from "node:fs";
11
+ import { createRequire } from "node:module";
12
+ import { closeStore, getGameState } from "../src/store/sqlite.js";
13
+ import { THEME_IDS } from "../src/config/themes.js";
14
+
15
+ // ESM bootstrap so `require()` works in this .test.ts (mirrors
16
+ // mega-compact.test.ts:20-24). Needed for the dynamic `require("./mega-game-cmds.js")`
17
+ // that wires the command against a fake pi without binding to the real pi
18
+ // module at load time.
19
+ const require = createRequire(import.meta.url);
20
+
21
+ type Cmd = { description?: string; handler: (args: string, ctx: any) => Promise<void> };
22
+
23
+ type Harness = {
24
+ commands: Record<string, Cmd>;
25
+ notifies: string[];
26
+ ctx: any;
27
+ };
28
+
29
+ function makeHarness(stateDir: string): Harness {
30
+ const commands: Record<string, Cmd> = {};
31
+ const notifies: string[] = [];
32
+ const runtime = { bindRepo: () => {}, currentStateDir: stateDir, bumpGameState: () => {} };
33
+ const ctx = {
34
+ cwd: stateDir,
35
+ ui: { notify: (s: string) => notifies.push(s) },
36
+ };
37
+ const fakePi = {
38
+ registerCommand: (name: string, opts: Cmd) => {
39
+ commands[name] = opts;
40
+ },
41
+ };
42
+ // Import after env is set so stateDir resolves. Dynamic import keeps the
43
+ // test from binding to the real pi module at load time.
44
+ const mod = require("./mega-game-cmds.js") as {
45
+ registerGameCommands: (pi: unknown, runtime: unknown) => void;
46
+ };
47
+ mod.registerGameCommands(fakePi, runtime);
48
+ return { commands, notifies, ctx };
49
+ }
50
+
51
+ describe("/mega-game (S30)", () => {
52
+ let dir: string;
53
+ before(() => {
54
+ dir = mkdtempSync(join(tmpdir(), "mc-megagame-"));
55
+ process.env.MEGACOMPACT_STATE_DIR = dir;
56
+ });
57
+ after(() => {
58
+ closeStore(dir);
59
+ delete process.env.MEGACOMPACT_STATE_DIR;
60
+ rmSync(dir, { recursive: true, force: true });
61
+ });
62
+
63
+ async function run(args: string): Promise<string[]> {
64
+ const h = makeHarness(dir);
65
+ h.notifies.length = 0;
66
+ await h.commands["mega-game"].handler(args, h.ctx);
67
+ return h.notifies;
68
+ }
69
+
70
+ it("bare command prints current (default) state", async () => {
71
+ const lines = await run("");
72
+ assert.ok(lines.some((l) => l.includes("game mode: off")));
73
+ assert.ok(lines.some((l) => l.includes("transparent")));
74
+ assert.ok(lines.some((l) => l.includes("tui:")));
75
+ });
76
+
77
+ it("on enables game mode and persists", async () => {
78
+ await run("on");
79
+ assert.equal(getGameState().game_mode_on, true);
80
+ });
81
+
82
+ it("off disables game mode and persists", async () => {
83
+ await run("on");
84
+ await run("off");
85
+ assert.equal(getGameState().game_mode_on, false);
86
+ });
87
+
88
+ it("theme <id> sets a valid theme and persists", async () => {
89
+ await run("theme retro");
90
+ assert.equal(getGameState().theme, "retro");
91
+ await run("theme cyan-neon");
92
+ assert.equal(getGameState().theme, "cyan-neon");
93
+ });
94
+
95
+ it("theme <unknown> is rejected with a usage line and does not mutate", async () => {
96
+ await run("theme retro");
97
+ const lines = await run("theme bogus");
98
+ assert.ok(lines.some((l) => l.includes("unknown theme")));
99
+ assert.equal(getGameState().theme, "retro");
100
+ });
101
+
102
+ it("theme next cycles to the next theme and wraps", async () => {
103
+ await run(`theme ${THEME_IDS[0]}`);
104
+ await run("theme next");
105
+ assert.equal(getGameState().theme, THEME_IDS[1]);
106
+ // cycle to the end then wrap
107
+ await run(`theme ${THEME_IDS[THEME_IDS.length - 1]!}`);
108
+ await run("theme next");
109
+ assert.equal(getGameState().theme, THEME_IDS[0]);
110
+ });
111
+
112
+ it("theme (bare) lists all themes", async () => {
113
+ const lines = await run("theme");
114
+ for (const id of THEME_IDS) {
115
+ assert.ok(lines.some((l) => l.includes(id)), `lists ${id}`);
116
+ }
117
+ });
118
+
119
+ it("tui full|minimal sets display mode and persists", async () => {
120
+ await run("tui minimal");
121
+ assert.equal(getGameState().tui_display_mode, "minimal");
122
+ await run("tui full");
123
+ assert.equal(getGameState().tui_display_mode, "full");
124
+ });
125
+
126
+ it("tui <bad> prints usage and does not mutate", async () => {
127
+ await run("tui minimal");
128
+ const lines = await run("tui huge");
129
+ assert.ok(lines.some((l) => l.includes("usage")));
130
+ assert.equal(getGameState().tui_display_mode, "minimal");
131
+ });
132
+
133
+ it("unknown subcommand prints usage", async () => {
134
+ const lines = await run("bogus");
135
+ assert.ok(lines.some((l) => l.includes("usage")));
136
+ });
137
+ });
@@ -0,0 +1,122 @@
1
+ /**
2
+ * mega-game-cmds.ts — /mega-game slash command (S30).
3
+ *
4
+ * Backs the game-mode toggle + theme picker + TUI display mode. All state is
5
+ * the global `game_state` SQLite row (src/store/sqlite/game-state.ts) — local
6
+ * only (PREVENT-PI-004: no network; no guardrails-allow needed because this
7
+ * command touches no fetch/http). All SQL is parameterized (PREVENT-002) and
8
+ * lives in the src/ submodule, not here.
9
+ *
10
+ * Usage:
11
+ * /mega-game print current state
12
+ * /mega-game on enable game mode (scoring + level-up + MEGA CACHE)
13
+ * /mega-game off disable game mode
14
+ * /mega-game theme list available themes
15
+ * /mega-game theme <id> set theme by id
16
+ * /mega-game theme next cycle to next theme
17
+ * /mega-game tui full full TUI widget (bars, stats, flair)
18
+ * /mega-game tui minimal one-line TUI widget (level + cache %)
19
+ * /mega-game achievements list unlocked achievements
20
+ */
21
+
22
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
23
+ import type { MegaRuntime } from "./mega-runtime.js";
24
+ import {
25
+ getGameState,
26
+ setGameState,
27
+ type GameState,
28
+ } from "../src/store/sqlite.js";
29
+ import { listAchievements } from "../src/store/sqlite/game-achievements.js";
30
+ import { THEMES, THEME_IDS, getTheme, isValidTheme, nextTheme, DEFAULT_THEME } from "../src/config/themes.js";
31
+
32
+ /** Format the current state as a human-readable status line set. */
33
+ function fmtState(s: GameState): string[] {
34
+ return [
35
+ `[mega-game] game mode: ${s.game_mode_on ? "ON" : "off"}`,
36
+ ` theme: ${s.theme}${s.theme === DEFAULT_THEME ? " (default)" : ""}`,
37
+ ` tui: ${s.tui_display_mode}`,
38
+ ];
39
+ }
40
+
41
+ /** Register the /mega-game command. */
42
+ export function registerGameCommands(pi: ExtensionAPI, runtime: MegaRuntime): void {
43
+ pi.registerCommand("mega-game", {
44
+ description:
45
+ "Game mode toggle + theme picker + TUI display mode. Usage: /mega-game [on|off|theme [id|next]|tui [full|minimal]|achievements]",
46
+ handler: async (args: string, ctx: ExtensionContext) => {
47
+ runtime.bindRepo(ctx.cwd);
48
+ const stateDir = runtime.currentStateDir;
49
+ const parts = args.trim().split(/\s+/).filter(Boolean);
50
+
51
+ // /mega-game → print current state.
52
+ if (parts.length === 0) {
53
+ const s = getGameState(stateDir);
54
+ for (const line of fmtState(s)) ctx.ui.notify(line);
55
+ return;
56
+ }
57
+
58
+ const sub = parts[0]!;
59
+
60
+ // /mega-game achievements — terse list of unlocked (hidden only once unlocked).
61
+ if (sub === "achievements") {
62
+ const rows = listAchievements(stateDir).filter((r) => r.unlocked_at != null);
63
+ ctx.ui.notify(`[mega-game] achievements unlocked (${rows.length}/9):`);
64
+ for (const r of rows) {
65
+ ctx.ui.notify(` ${r.icon ?? ""} ${r.title}`);
66
+ }
67
+ return;
68
+ }
69
+
70
+ // /mega-game on|off
71
+ if (sub === "on" || sub === "off") {
72
+ const s = setGameState({ game_mode_on: sub === "on" }, stateDir);
73
+ runtime.bumpGameState();
74
+ ctx.ui.notify(`[mega-game] game mode ${s.game_mode_on ? "ON" : "off"}`);
75
+ return;
76
+ }
77
+
78
+ // /mega-game theme [id|next]
79
+ if (sub === "theme") {
80
+ if (parts.length === 1) {
81
+ // list themes
82
+ ctx.ui.notify("[mega-game] themes:");
83
+ for (const t of THEMES) {
84
+ ctx.ui.notify(` ${t.id.padEnd(14)} ${t.label}`);
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;
102
+ }
103
+
104
+ // /mega-game tui full|minimal
105
+ if (sub === "tui") {
106
+ const arg = parts[1];
107
+ if (arg !== "full" && arg !== "minimal") {
108
+ ctx.ui.notify(`[mega-game] usage: /mega-game tui full|minimal`);
109
+ return;
110
+ }
111
+ const s = setGameState({ tui_display_mode: arg }, stateDir);
112
+ runtime.bumpGameState();
113
+ ctx.ui.notify(`[mega-game] tui → ${s.tui_display_mode}`);
114
+ return;
115
+ }
116
+
117
+ ctx.ui.notify(
118
+ `[mega-game] usage: /mega-game [on|off|theme [id|next]|tui [full|minimal]|achievements]`,
119
+ );
120
+ },
121
+ });
122
+ }
@@ -0,0 +1,366 @@
1
+ /**
2
+ * compact.ts — full compaction pipeline (Trident) + pi no-op prediction.
3
+ *
4
+ * `runCompact` runs the full Trident pipeline (fast-gate aside) and persists a
5
+ * checkpoint. `piCompactWouldNoop` predicts whether pi's `ctx.compact()` would
6
+ * throw a no-op error. Both mutate the shared MegaRuntime (token accounting,
7
+ * ticker, status, events) and are driven by the event + command handlers in
8
+ * mega-events.ts / mega-commands.ts.
9
+ */
10
+
11
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
+ import { sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
13
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
14
+ import { compactSession } from "../../src/engine.js";
15
+ import type { EngineMessage } from "../../src/types.js";
16
+ import { normalizeSessionId } from "../../src/store.js";
17
+ import { estimateBlockTokens } from "../../src/tokens.js";
18
+ import { touchSession, logDaily, incCompactCount, incCacheHitTokens } from "../../src/store/sqlite.js";
19
+ import { consolidateMemories } from "../../src/memory.js";
20
+ import {
21
+ type MegaRuntime,
22
+ C,
23
+ MARKER_TYPE,
24
+ } from "../mega-runtime.js";
25
+ import { resolveRepoRoot, preserveRecentForPressure, type MegaConfig } from "../mega-config.js";
26
+ import { runRaptor } from "../../src/dedup/raptor/index.js";
27
+ import { loadDedupConfig } from "../../src/config/dedup.js";
28
+ import { upsertEmbedding as indexUpsertEmbedding } from "../../src/store/vectorIndex.js";
29
+ import { runMemoryReview } from "./memory-review.js";
30
+
31
+ export type RunCompactResult =
32
+ | { skipped: true }
33
+ | { skipped: false; result: ReturnType<typeof compactSession>; keepFrom: number; saved: number };
34
+
35
+ /** Run the full compaction pipeline and persist a checkpoint. Returns the result. */
36
+ export function runCompact(
37
+ pi: ExtensionAPI,
38
+ runtime: MegaRuntime,
39
+ config: MegaConfig,
40
+ ctx: ExtensionContext,
41
+ messages: AgentMessage[],
42
+ opts: { keepFrom?: number; summary?: string; compressionPressure?: number } = {},
43
+ ): RunCompactResult {
44
+ runtime.bindRepo(ctx.cwd);
45
+ const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
46
+ runtime.resetRuntime(sid);
47
+ runtime.rt.sessionId = sid;
48
+
49
+ const view = runtime.engineView(messages);
50
+ // keepFrom deepens with context pressure (Fix E): under high pressure we
51
+ // compact more of the session, down to the preserveRecentMin floor.
52
+ const preserve = preserveRecentForPressure(
53
+ opts.compressionPressure ?? 0,
54
+ config.preserveRecent,
55
+ config.preserveRecentMin,
56
+ );
57
+ const keepFrom = opts.keepFrom ?? Math.max(0, view.length - preserve);
58
+ // For very small sessions (fewer messages than preserveRecent), allow
59
+ // compacting everything except the last message — the user explicitly
60
+ // requested compaction, so don't refuse it just because the session is short.
61
+ if (keepFrom <= 0) {
62
+ if (view.length <= 1) return { skipped: true };
63
+ // Use the fallback: compact everything except the last message
64
+ const fallbackKeepFrom = view.length - 1;
65
+ return doCompact(view, fallbackKeepFrom, opts, sid, config, pi, ctx, runtime);
66
+ }
67
+
68
+ return doCompact(view, keepFrom, opts, sid, config, pi, ctx, runtime);
69
+ }
70
+
71
+ function doCompact(
72
+ view: EngineMessage[],
73
+ keepFrom: number,
74
+ opts: { keepFrom?: number; summary?: string; compressionPressure?: number },
75
+ sid: string,
76
+ config: MegaConfig,
77
+ pi: ExtensionAPI,
78
+ ctx: ExtensionContext,
79
+ runtime: MegaRuntime,
80
+ ): RunCompactResult {
81
+ runtime.pulsing = true; // animate the status line while the (sync) pipeline runs
82
+ // S21.2: reset the per-compaction memory-op counter so the post-compact
83
+ // consolidate pass only fires when memory rows actually changed during the
84
+ // compaction window (turn_end → auto-review may have written some).
85
+ runtime.memoriesTouchedThisCompaction = 0;
86
+ const result = compactSession(
87
+ {
88
+ sessionId: sid,
89
+ messages: view,
90
+ keepFrom,
91
+ summary: opts.summary,
92
+ timestamp: Date.now(),
93
+ onTier: runtime.makeTierCallback(ctx),
94
+ compressionPressure: opts.compressionPressure,
95
+ },
96
+ runtime.store,
97
+ );
98
+ runtime.pulsing = false;
99
+
100
+ if (result.skipped) return { skipped: true };
101
+ if (!result.deduped) {
102
+ runtime.rt.persistedThisSession = true;
103
+ runtime.rt.lastCheckpointId = result.checkpointId;
104
+ }
105
+ runtime.rt.lastCompactedFrom = result.compactedFrom;
106
+ runtime.rt.lastCompactedTokens = result.tokenEstimate;
107
+ runtime.rt.dedupAttempts++;
108
+ // Honest "tokens saved" for this session-instance only:
109
+ // new checkpoint → original − stored
110
+ // deduped onto existing → whole original region (nothing new stored)
111
+ // Resets to 0 on session_start (rt is rebuilt) — so a fresh session shows 0
112
+ // while the repo's cumulative saved (SQLite meta) keeps the running total.
113
+ const saved = result.deduped
114
+ ? result.originalTokenEstimate
115
+ : Math.max(0, result.originalTokenEstimate - result.tokenEstimate);
116
+ runtime.rt.tokensSaved += saved;
117
+ runtime.rt.compactCount += 1;
118
+ incCompactCount(runtime.currentStateDir);
119
+ if (result.deduped) { runtime.rt.cacheHitTokens += saved; incCacheHitTokens(saved, runtime.currentStateDir); }
120
+ runtime.rt.lastCompactAt = Date.now();
121
+ if (result.deduped) runtime.rt.dedupSkips++;
122
+ // Grow the rolling "saved" goal so the progress bar always has a fresh
123
+ // denominator (we don't want it pinned at 100% once we pass an old target).
124
+ if (runtime.rt.tokensSaved > runtime.savedGoal) runtime.savedGoal = Math.ceil((runtime.rt.tokensSaved * 1.25) / 10_000) * 10_000;
125
+
126
+ // Live toolbar activity: what file/region just got compacted or deduped.
127
+ // Rendered via the rotating ticker line (see snapshot); the ring buffer is
128
+ // cycled one-per-repaint so the single line scrolls through recent files.
129
+ const files = result.filesModified ?? [];
130
+ const fileLabel = files.length
131
+ ? files.map((f) => f.split("/").pop() ?? f).slice(0, 2).join(", ")
132
+ : result.regionHash.slice(0, 8);
133
+ runtime.lastActivityAt = Date.now();
134
+ // Explain-why line: surfaced while fresh. Pulls the dedup reason (which for
135
+ // L2 includes the cosine sim) so the user sees WHY a region was kept/dropped.
136
+ runtime.lastWhy = result.deduped
137
+ ? `why: deduped@${result.dedupReason ?? "tier"}`
138
+ : `why: compacted → ${result.checkpointId}`;
139
+ // Recall/activity ticker: record this event in the ring buffer.
140
+ const savedK = (saved / 1000).toFixed(1);
141
+ runtime.pushTicker(
142
+ result.deduped
143
+ ? `${C.green}♻${C.reset} deduped ${fileLabel} · ${savedK}k saved`
144
+ : `${C.cyan}🗜${C.reset} ${result.checkpointId} · +${savedK}k · ${fileLabel}`,
145
+ );
146
+ // The per-tier trace has settled into the final outcome — fold it back into
147
+ // the activity line and stop showing the live trace.
148
+ runtime.tierTrace = undefined;
149
+
150
+ // Record session activity + a daily-log entry in the per-repo SQLite store
151
+ // (foundation for resume-sessions / daily-log features). Best-effort — never
152
+ // block a compaction on bookkeeping.
153
+ try {
154
+ const root = resolveRepoRoot(ctx.cwd);
155
+ touchSession(sid, root, runtime.currentStateDir);
156
+ logDaily(sid, "compact", result.checkpointId, saved, runtime.currentStateDir);
157
+ } catch {
158
+ /* non-fatal: stats bookkeeping only */
159
+ }
160
+
161
+ // S21.2: best-effort consolidation of near-duplicate memories for this repo.
162
+ // Runs after the per-repo stats touch so `consolidateMemories` can use the
163
+ // same stateDir. Non-fatal — a failed consolidate never blocks a compaction.
164
+ // Only runs when new memory ops landed in this pass (otherwise the prior
165
+ // compaction's consolidate already had its shot — re-running would just
166
+ // touch every row again with no merges).
167
+ if (!result.deduped && runtime.memoriesTouchedThisCompaction > 0) {
168
+ try {
169
+ const root = resolveRepoRoot(ctx.cwd);
170
+ void consolidateMemories(runtime.currentStateDir, root).then(
171
+ (n) => {
172
+ if (n > 0) runtime.pushTicker(`${C.green}∫${C.reset} consolidated ${n} memory dup${n === 1 ? "" : "s"}`);
173
+ },
174
+ () => {
175
+ /* swallow: consolidate failures must never surface to the user */
176
+ },
177
+ );
178
+ } catch {
179
+ /* non-fatal */
180
+ }
181
+ }
182
+
183
+ // S24 review-on-compact: when pressure is high, the just-compacted region is
184
+ // exactly the context worth remembering, so review it immediately rather than
185
+ // waiting for the next turn-cadence tick. Uses the shared runMemoryReview
186
+ // helper (fire-and-forget; doCompact is sync). Best-effort + non-fatal. Only
187
+ // fires above the `high` band so low-pressure compactions don't pay the cost.
188
+ if (!result.deduped && config.memoryAutoReview && runtime.pressureBand !== "low" && runtime.pressureBand !== "medium") {
189
+ void runMemoryReview(runtime, view, "pressure");
190
+ }
191
+
192
+ // Sentinel marker: a non-LLM bookkeeping entry so subsequent triggers can
193
+ // skip re-vectorizing an already-compacted region (zero token cost).
194
+ pi.appendEntry(MARKER_TYPE, {
195
+ checkpointId: result.checkpointId,
196
+ regionHash: result.regionHash,
197
+ tokenEstimate: result.tokenEstimate,
198
+ deduped: result.deduped,
199
+ });
200
+
201
+ // Fix D: refresh the RAPTOR tree for this session so live recall (search) can
202
+ // serve high-level summaries. Best-effort + non-fatal: never block compaction.
203
+ // Budget-guarded (RAPTOR_BUDGET_MS) so it can't hang a large session.
204
+ if (config.raptorEnabled && !result.deduped) {
205
+ try {
206
+ const dd = loadDedupConfig();
207
+ const all = runtime.store.list(sid);
208
+ const leaves = all.map((cp) => ({
209
+ id: cp.checkpointId,
210
+ messages: [],
211
+ sourceText: cp.normalizedText ?? cp.summary ?? cp.regionHash,
212
+ embedding: cp.embedding,
213
+ }));
214
+ if (leaves.length >= 2) {
215
+ // S25: stamp the tree with the newest checkpoint epoch so the
216
+ // freshness guard in raptorSearchHits can reject stale trees after a
217
+ // later compaction adds newer checkpoints.
218
+ const builtAt = all.length > 0 ? Math.max(...all.map((c) => c.timestamp)) : Date.now();
219
+ runRaptor(
220
+ leaves,
221
+ {
222
+ stateDir: runtime.currentStateDir,
223
+ sessionId: sid,
224
+ budgetMs: dd.RAPTOR_BUDGET_MS,
225
+ clustersPerLevel: dd.RAPTOR_CLUSTERS_PER_LEVEL,
226
+ consistencyThreshold: dd.RAPTOR_CONSISTENCY,
227
+ logger: runtime.logger,
228
+ builtAt: Number.isFinite(builtAt) ? builtAt : Date.now(),
229
+ },
230
+ );
231
+ }
232
+ } catch {
233
+ /* non-fatal: tree refresh never blocks a compaction */
234
+ }
235
+ }
236
+
237
+ // Slice 2: best-effort mirror of the new checkpoint into the async global
238
+ // PGlite/HNSW vector index. Fires once per compaction (not per-add), so the
239
+ // shared global dir is never hammered by concurrent test workers.
240
+ // Non-fatal: a WASM init failure degrades to the sync scan silently.
241
+ if (!result.deduped) {
242
+ try {
243
+ const all = runtime.store.list(sid);
244
+ const latest = all.find((cp) => cp.checkpointId === result.checkpointId);
245
+ if (latest?.embedding) {
246
+ void indexUpsertEmbedding(
247
+ runtime.currentStateDir,
248
+ sid,
249
+ latest.checkpointId,
250
+ latest.embedding,
251
+ ).catch(() => {
252
+ /* non-fatal: index refresh never blocks a compaction */
253
+ });
254
+ }
255
+ } catch {
256
+ /* non-fatal: index refresh never blocks a compaction */
257
+ }
258
+ }
259
+
260
+ runtime.setStatus(
261
+ ctx,
262
+ runtime.rt.persistedThisSession
263
+ ? `mega-compact: ${result.checkpointId} · ${saved} tok saved`
264
+ : `mega-compact: ready`,
265
+ );
266
+ runtime.logger.info("compact", {
267
+ sessionId: sid,
268
+ checkpointId: result.checkpointId ?? "(deduped)",
269
+ deduped: result.deduped,
270
+ tokenEstimate: saved,
271
+ compactedFrom: result.compactedFrom,
272
+ });
273
+ runtime.dashboard.event("compact", {
274
+ sessionId: sid,
275
+ checkpointId: result.checkpointId ?? "(deduped)",
276
+ deduped: result.deduped,
277
+ tokenEstimate: saved,
278
+ compactedFrom: result.compactedFrom,
279
+ });
280
+ runtime.snapshot(ctx);
281
+ return { skipped: false, result, keepFrom, saved };
282
+ }
283
+
284
+ /**
285
+ * Predict whether pi's `ctx.compact()` would throw a no-op error — "Already
286
+ * compacted" or "Nothing to compact (session too small)" — so the auto-trigger
287
+ * can SKIP the call instead of surfacing a hard, user-facing error.
288
+ *
289
+ * Why we can't intercept or suppress it: pi's public `compact()` computes
290
+ * `prepareCompaction()` and throws *before* it emits `session_before_compact`,
291
+ * so our handler there never runs on the no-op path. And `ctx.compact()`'s
292
+ * `onError` callback fires only AFTER pi has already emitted a `compaction_end`
293
+ * event carrying the error message (which the interactive UI renders) — so
294
+ * `onError` cannot mute it either. The only robust fix is to not call
295
+ * `ctx.compact()` when pi would no-op. (pi's own `_runAutoCompaction` path is
296
+ * silent on this same condition; the public path we're forced through is the
297
+ * one that throws.)
298
+ *
299
+ * Skipping is correct, not a compromise: by the time this runs, `runCompact()`
300
+ * has already persisted the recall checkpoint (Path A). The durable on-disk
301
+ * trim is only useful when pi can actually summarize a region; a transcript
302
+ * under pi's `keepRecentTokens` budget is small enough that reloading it on
303
+ * resume isn't a token-growth problem, so the durable trim is unnecessary
304
+ * there anyway.
305
+ *
306
+ * Mirrors pi's `prepareCompaction()` return-undefined conditions (compaction.js):
307
+ * (1) last entry is a compaction → "Already compacted"
308
+ * (2) <2 cut-point messages since the last compaction → nothing to summarize
309
+ * (a cut point = any non-toolResult message — user/assistant/bash/custom/
310
+ * branchSummary/compactionSummary — matching pi's isCutPointMessage)
311
+ * (3) transcript tokens since the last compaction < keepRecentTokens → pi
312
+ * keeps everything → nothing to summarize
313
+ * `keepRecentTokens` isn't readable from the extension API, so (3) uses the pi
314
+ * default (20000) as a conservative floor; raise it via
315
+ * `MEGACOMPACT_DURABLE_TRIM_FLOOR` if you raise pi's `compact.keepRecentTokens`.
316
+ *
317
+ * Best-effort: on any read error returns true (skip) — skipping a durable trim
318
+ * is always safe; calling `ctx.compact()` on a no-op throws to the user.
319
+ */
320
+ export function piCompactWouldNoop(ctx: ExtensionContext): boolean {
321
+ try {
322
+ const branch = ctx.sessionManager.getBranch();
323
+ if (branch.length === 0) return true;
324
+ // (1) already compacted — pi throws "Already compacted"
325
+ if (branch[branch.length - 1].type === "compaction") return true;
326
+ // boundaryStart = index just after the most recent compaction entry (or 0)
327
+ let boundaryStart = 0;
328
+ for (let i = branch.length - 1; i >= 0; i--) {
329
+ if (branch[i].type === "compaction") { boundaryStart = i + 1; break; }
330
+ }
331
+ let cutPoints = 0;
332
+ let tokens = 0;
333
+ for (let i = boundaryStart; i < branch.length; i++) {
334
+ const e = branch[i];
335
+ if (e.type === "compaction") continue;
336
+ let isCut = false;
337
+ for (const m of sessionEntryToContextMessages(e)) {
338
+ // pi's isCutPointMessage: every role except toolResult
339
+ if ((m as { role?: string }).role !== "toolResult") isCut = true;
340
+ const c = (m as { content?: unknown }).content;
341
+ const text =
342
+ typeof c === "string" ? c
343
+ : Array.isArray(c)
344
+ ? (c as { text?: string }[]).map((b) => b?.text ?? "").join(" ")
345
+ : "";
346
+ if (text) tokens += estimateBlockTokens(text);
347
+ }
348
+ if (isCut) cutPoints++;
349
+ }
350
+ // (2) need >=2 cut points so the kept cut isn't the first message
351
+ if (cutPoints < 2) return true;
352
+ // (3) transcript under pi's keepRecentTokens budget → pi keeps everything
353
+ if (tokens < durableTrimFloorTokens()) return true;
354
+ return false;
355
+ } catch {
356
+ return true; // safe: skip the durable trim rather than risk a user-facing throw
357
+ }
358
+ }
359
+
360
+ /** pi's default keepRecentTokens (compaction settings). Override with
361
+ * MEGACOMPACT_DURABLE_TRIM_FLOOR if you raise pi's compact.keepRecentTokens. */
362
+ function durableTrimFloorTokens(): number {
363
+ const raw = process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
364
+ if (raw !== undefined && Number.isFinite(Number(raw))) return Number(raw);
365
+ return 20_000;
366
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * memory-review.ts — review live conversation & persist durable memories.
3
+ *
4
+ * `runMemoryReview` is shared by the pressure-scaled turn-end cadence
5
+ * (mega-events.ts) AND review-on-compact (compact.ts) so both paths run the
6
+ * identical review body. Best-effort + non-fatal: a review failure is swallowed
7
+ * and never breaks the caller.
8
+ */
9
+
10
+ import {
11
+ type MegaRuntime,
12
+ C,
13
+ } from "../mega-runtime.js";
14
+
15
+ /**
16
+ * Review the live conversation and persist durable memories (S20+S24). Shared by
17
+ * the pressure-scaled turn-end cadence (mega-events.ts) AND review-on-compact
18
+ * (below) so both paths run the identical review body. Best-effort + non-fatal:
19
+ * a review failure is swallowed and never breaks the caller. On success, the
20
+ * number of applied ops is returned so callers can feed the consolidation gate.
21
+ *
22
+ * @param view the engine message view to review (caller builds it)
23
+ * @param label a short source tag for the ticker line (e.g. "pressure" / "turn")
24
+ */
25
+ export async function runMemoryReview(
26
+ runtime: MegaRuntime,
27
+ view: ReturnType<MegaRuntime["engineView"]>,
28
+ label: string,
29
+ ): Promise<number> {
30
+ try {
31
+ const { reviewConversation } = await import("../../src/memory.js");
32
+ const { applyMemoryOps } = await import("../../src/memoryOps.js");
33
+ const ops = reviewConversation(view, []);
34
+ if (ops.length) {
35
+ await applyMemoryOps(ops, runtime.currentStateDir);
36
+ // S21.2: ops landed — the compaction path reads this counter and fires
37
+ // `consolidateMemories` only when > 0.
38
+ runtime.memoriesTouchedThisCompaction += ops.length;
39
+ runtime.pushTicker(`${C.green}🧠${C.reset} reviewed ${ops.length} memory op${ops.length === 1 ? "" : "s"} (${label})`);
40
+ }
41
+ return ops.length;
42
+ } catch {
43
+ /* non-fatal — auto-review must never break the turn loop / compaction */
44
+ return 0;
45
+ }
46
+ }