pi-mega-compact 0.8.14 → 0.8.16

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 (89) hide show
  1. package/README.md +51 -463
  2. package/dist/extensions/dashboard-client/src/hooks/useApi.js +51 -0
  3. package/dist/extensions/dashboard-client/src/hooks/useSSE.js +63 -0
  4. package/dist/extensions/dashboard-server/html.js +15 -2
  5. package/dist/extensions/mega-cache-replay.test.js +4 -2
  6. package/dist/extensions/mega-compact-s38.test.js +317 -0
  7. package/dist/extensions/mega-compact.js +14 -0
  8. package/dist/extensions/mega-compact.test.js +37 -8
  9. package/dist/extensions/mega-config.js +5 -0
  10. package/dist/extensions/mega-events/agent-handlers.js +184 -4
  11. package/dist/extensions/mega-events/context-handler.js +35 -2
  12. package/dist/extensions/mega-events/error-classifier.js +118 -0
  13. package/dist/extensions/mega-events.js +1 -0
  14. package/dist/extensions/mega-runtime/state.js +82 -0
  15. package/dist/extensions/mega-runtime/state.test.js +37 -0
  16. package/dist/extensions/mega-runtime/widget.js +6 -2
  17. package/dist/extensions/mega-runtime/widget.test.js +47 -0
  18. package/dist/extensions/mega-teamrun.test.js +14 -1
  19. package/extensions/dashboard-client/dist/assets/{AchievementTiles-BH26e95w.js → AchievementTiles-BozP_h3c.js} +2 -2
  20. package/extensions/dashboard-client/dist/assets/{AchievementTiles-BH26e95w.js.map → AchievementTiles-BozP_h3c.js.map} +1 -1
  21. package/extensions/dashboard-client/dist/assets/{AchievementsTab-B94wvRV_.js → AchievementsTab-DKgqx1RQ.js} +2 -2
  22. package/extensions/dashboard-client/dist/assets/{AchievementsTab-B94wvRV_.js.map → AchievementsTab-DKgqx1RQ.js.map} +1 -1
  23. package/extensions/dashboard-client/dist/assets/{CacheTab-DPmw3NHR.js → CacheTab-CPvso4-0.js} +2 -2
  24. package/extensions/dashboard-client/dist/assets/{CacheTab-DPmw3NHR.js.map → CacheTab-CPvso4-0.js.map} +1 -1
  25. package/extensions/dashboard-client/dist/assets/ConfigTab-DXD7zbqy.js +2 -0
  26. package/extensions/dashboard-client/dist/assets/ConfigTab-DXD7zbqy.js.map +1 -0
  27. package/extensions/dashboard-client/dist/assets/{EventsTab-B5PicfFw.js → EventsTab-BGeuYvM3.js} +2 -2
  28. package/extensions/dashboard-client/dist/assets/{EventsTab-B5PicfFw.js.map → EventsTab-BGeuYvM3.js.map} +1 -1
  29. package/extensions/dashboard-client/dist/assets/{GameTab-CsVwp78a.js → GameTab-Qj19W1Y-.js} +2 -2
  30. package/extensions/dashboard-client/dist/assets/{GameTab-CsVwp78a.js.map → GameTab-Qj19W1Y-.js.map} +1 -1
  31. package/extensions/dashboard-client/dist/assets/{MetricsTab-Cf72UewX.js → MetricsTab-DA27vA95.js} +2 -2
  32. package/extensions/dashboard-client/dist/assets/{MetricsTab-Cf72UewX.js.map → MetricsTab-DA27vA95.js.map} +1 -1
  33. package/extensions/dashboard-client/dist/assets/{OverviewTab-Dh6aG3gy.js → OverviewTab-lVGR1Jl_.js} +2 -2
  34. package/extensions/dashboard-client/dist/assets/{OverviewTab-Dh6aG3gy.js.map → OverviewTab-lVGR1Jl_.js.map} +1 -1
  35. package/extensions/dashboard-client/dist/assets/{ReposTab-HXf2dorl.js → ReposTab-_HNR3qYj.js} +2 -2
  36. package/extensions/dashboard-client/dist/assets/{ReposTab-HXf2dorl.js.map → ReposTab-_HNR3qYj.js.map} +1 -1
  37. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-kUnoospE.js → TimeSavedCard-B7aPcBMs.js} +2 -2
  38. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-kUnoospE.js.map → TimeSavedCard-B7aPcBMs.js.map} +1 -1
  39. package/extensions/dashboard-client/dist/assets/{index-DMntLu-8.js → index-B2JJExE9.js} +3 -3
  40. package/extensions/dashboard-client/dist/assets/{index-DMntLu-8.js.map → index-B2JJExE9.js.map} +1 -1
  41. package/extensions/dashboard-client/dist/index.html +1 -1
  42. package/extensions/dashboard-client/src/styles/base.css +2 -1
  43. package/extensions/dashboard-client/src/tabs/ConfigTab.tsx +19 -2
  44. package/extensions/dashboard-server/html.ts +15 -2
  45. package/extensions/mega-cache-replay.test.ts +3 -3
  46. package/extensions/mega-compact-s38.test.ts +330 -0
  47. package/extensions/mega-compact.test.ts +41 -13
  48. package/extensions/mega-compact.ts +14 -0
  49. package/extensions/mega-config.ts +24 -0
  50. package/extensions/mega-dashboard.ts +7 -0
  51. package/extensions/mega-events/agent-handlers.ts +179 -5
  52. package/extensions/mega-events/context-handler.ts +31 -2
  53. package/extensions/mega-events/error-classifier.ts +109 -0
  54. package/extensions/mega-events.ts +1 -0
  55. package/extensions/mega-runtime/helpers.ts +5 -0
  56. package/extensions/mega-runtime/state.test.ts +33 -0
  57. package/extensions/mega-runtime/state.ts +81 -1
  58. package/extensions/mega-runtime/widget.test.ts +48 -0
  59. package/extensions/mega-runtime/widget.ts +6 -2
  60. package/extensions/mega-teamrun.test.ts +13 -1
  61. package/package.json +4 -1
  62. package/dist/extensions/dashboard-server/helpers.js +0 -37
  63. package/dist/extensions/dashboard-server/html/all-repos-tab.js +0 -26
  64. package/dist/extensions/dashboard-server/html/body-open.js +0 -23
  65. package/dist/extensions/dashboard-server/html/current-repo-tab.js +0 -130
  66. package/dist/extensions/dashboard-server/html/head-open.js +0 -16
  67. package/dist/extensions/dashboard-server/html/high-score-tab.js +0 -25
  68. package/dist/extensions/dashboard-server/html/repo-detail-modal.js +0 -26
  69. package/dist/extensions/dashboard-server/html/script.js +0 -259
  70. package/dist/extensions/dashboard-server/html/styles.js +0 -103
  71. package/dist/extensions/dashboard-server/html/summary-tab.js +0 -19
  72. package/dist/extensions/dashboard-server/html-template.js +0 -41
  73. package/dist/src/store/sqlite/connection.js +0 -35
  74. package/dist/src/store/sqlite/index-store.js +0 -167
  75. package/dist/src/store/sqlite/memory.js +0 -54
  76. package/dist/src/store/sqlite/minhash-lsh.js +0 -47
  77. package/dist/src/store/sqlite/sessions.js +0 -39
  78. package/dist/src/store/sqlite/transaction.js +0 -19
  79. package/dist/src/vectorStore/add.js +0 -260
  80. package/dist/src/vectorStore/dedup.js +0 -52
  81. package/dist/src/vectorStore/index.js +0 -10
  82. package/dist/src/vectorStore/queries.js +0 -83
  83. package/dist/src/vectorStore/search.js +0 -95
  84. package/dist/src/vectorStore/session.js +0 -19
  85. package/dist/src/vectorStore/store.js +0 -105
  86. package/dist/src/vectorStore/types.js +0 -6
  87. package/dist/src/vectorStore/utils.js +0 -23
  88. package/extensions/dashboard-client/dist/assets/ConfigTab-C3g3smRL.js +0 -2
  89. package/extensions/dashboard-client/dist/assets/ConfigTab-C3g3smRL.js.map +0 -1
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>mega-compact dashboard</title>
7
- <script type="module" crossorigin src="./assets/index-DMntLu-8.js"></script>
7
+ <script type="module" crossorigin src="./assets/index-B2JJExE9.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="./assets/index-Cfxniu2R.css">
9
9
  </head>
10
10
  <body>
@@ -137,7 +137,8 @@ body {
137
137
  font-size: clamp(12px, 0.8vw, 14px);
138
138
  }
139
139
 
140
- .repos th, .repos td {
140
+ .repos th,
141
+ .repos td {
141
142
  padding: 6px 10px;
142
143
  border-bottom: 1px solid var(--border-soft, var(--border));
143
144
  text-align: left;
@@ -17,7 +17,7 @@ import type React from "react";
17
17
  import { useState, useEffect, useCallback } from "react";
18
18
  import type { SnapshotResponse, GameStatePatch } from "@contracts";
19
19
  import { fetchSnapshot, fetchGameState, putGameState } from "../api/client";
20
- import { THEMES } from "../../../../src/config/themes";
20
+ import { THEMES, THEME_IDS } from "../../../../src/config/themes";
21
21
 
22
22
  /** Actual flat game state returned by the server (the server returns a flat
23
23
  * { game_mode_on, theme, tui_display_mode } shape, NOT the contract's nested
@@ -168,6 +168,20 @@ export default function ConfigTab(): React.ReactElement {
168
168
  </option>
169
169
  ))}
170
170
  </select>
171
+ <button
172
+ type="button"
173
+ disabled={saving}
174
+ className="theme-cycle-btn"
175
+ title="Cycle to the next theme (P3)"
176
+ onClick={() => {
177
+ const i = THEME_IDS.indexOf(themeVal);
178
+ const next =
179
+ THEME_IDS[(i < 0 ? 0 : i + 1) % THEME_IDS.length];
180
+ if (next) void handlePatch({ theme: next });
181
+ }}
182
+ >
183
+ Next theme →
184
+ </button>
171
185
  </label>
172
186
  </div>
173
187
  <div className="settings-row">
@@ -184,7 +198,10 @@ export default function ConfigTab(): React.ReactElement {
184
198
  >
185
199
  <option value="full">Full</option>
186
200
  <option value="minimal">Minimal</option>
187
- </select>
201
+ </select>{" "}
202
+ <span className="tui-mode-hint">
203
+ (affects the in-app TUI widget; not shown here)
204
+ </span>
188
205
  </label>
189
206
  </div>
190
207
  </section>
@@ -179,8 +179,8 @@ export function dashboardHtml(tierName: string): string {
179
179
 
180
180
  <div class="settings-strip">
181
181
  <label title="Turn game mode on/off (themes the widget + dashboard)"><input type="checkbox" id="set-game-mode"> Game mode</label>
182
- <label title="Visual theme (applies instantly)">Theme <select id="set-theme">${THEME_OPTIONS}</select></label>
183
- <label title="TUI widget display density">TUI <select id="set-tui-mode"><option value="full">Full</option><option value="minimal">Minimal</option></select></label>
182
+ <label title="Visual theme (applies instantly)">Theme <select id="set-theme">${THEME_OPTIONS}</select> <button type="button" id="set-theme-next" title="Cycle to the next theme (P3)">Next theme →</button></label>
183
+ <label title="TUI widget display density">TUI <select id="set-tui-mode"><option value="full">Full</option><option value="minimal">Minimal</option></select> <span class="tui-mode-hint">(affects the in-app TUI widget; not shown here)</span></label>
184
184
  </div>
185
185
 
186
186
  <nav class="tabs">
@@ -872,6 +872,19 @@ export function dashboardHtml(tierName: string): string {
872
872
  }
873
873
  if (gmCheckbox) gmCheckbox.addEventListener('change', function() { putGameState({ game_mode_on: gmCheckbox.checked }); });
874
874
  if (gmTheme) gmTheme.addEventListener('change', function() { putGameState({ theme: gmTheme.value }); });
875
+ // P3: client-side theme cycling (no new endpoint). Reads the already-fetched
876
+ // <option> values from the theme select, so it stays in sync with the server's
877
+ // THEMES list without a second fetch.
878
+ var gmThemeNext = document.getElementById('set-theme-next');
879
+ if (gmThemeNext) gmThemeNext.addEventListener('click', function() {
880
+ var opts = gmTheme ? gmTheme.options : [];
881
+ var n = opts.length;
882
+ var cur = gmTheme ? gmTheme.value : 'transparent';
883
+ var idx = -1;
884
+ for (var i = 0; i < n; i++) { if (opts[i].value === cur) { idx = i; break; } }
885
+ var next = n > 0 ? opts[(idx < 0 ? 0 : (idx + 1) % n)].value : 'transparent';
886
+ putGameState({ theme: next });
887
+ });
875
888
  if (gmTui) gmTui.addEventListener('change', function() { putGameState({ tui_display_mode: gmTui.value }); });
876
889
  pollGameState();
877
890
  setInterval(pollGameState, 5000);
@@ -59,7 +59,7 @@ function harness() {
59
59
  // the percent-basis grewEnough (>=10) never trips (no re-compact → pure replay).
60
60
  const usage = { tokens: 200000, contextWindow: 200000, percent: 100 as number | null };
61
61
 
62
- const handlers: Record<string, Function> = {};
62
+ const handlers: Record<string, Function[]> = {};
63
63
  const compactCalls: any[] = [];
64
64
 
65
65
  function msg(role: string, text: string, toolName?: string): AgentMessage {
@@ -106,7 +106,7 @@ function harness() {
106
106
  }
107
107
 
108
108
  const pi = {
109
- on: (ev: string, h: Function) => { handlers[ev] = h; },
109
+ on: (ev: string, h: Function) => { if (!handlers[ev]) handlers[ev] = []; handlers[ev].push(h); },
110
110
  registerCommand: () => {}, registerTool: () => {}, registerShortcut: () => {},
111
111
  registerFlag: () => {}, getFlag: () => undefined, registerMessageRenderer: () => {},
112
112
  registerEntryRenderer: () => {}, sendMessage: () => {}, sendUserMessage: () => {},
@@ -121,7 +121,7 @@ function harness() {
121
121
  mod.default(pi);
122
122
  const { lastRuntime } = require("./mega-events.js") as { lastRuntime: any };
123
123
 
124
- const fire = (ev: string, event: any, ctx: any) => handlers[ev](event, ctx);
124
+ const fire = async (ev: string, event: any, ctx: any) => { let r: any; for (const h of handlers[ev] || []) r = await h(event, ctx); return r; };
125
125
  return {
126
126
  stateDir, handlers, compactCalls, fire, ctx: makeCtx, usage, buildSession,
127
127
  runtime: lastRuntime, // MegaRuntime with diag* counters + rt + trimCache
@@ -0,0 +1,330 @@
1
+ /**
2
+ * mega-compact.s38-error-retry.test.ts — S38 error-retry safety net tests.
3
+ *
4
+ * Extracted from mega-compact.test.ts so the error-retry feature has a focused,
5
+ * fast-isolated test file. Exercises the real extension entry through a mock pi:
6
+ * - classifyError classifier (transient / permanent / compaction-noop / null)
7
+ * - turn_end retry nudges (max 5 transient, max 1 permanent)
8
+ * - compaction-noop DOES NOT retry (pi race guard)
9
+ * - MAX=0 disables transient retries
10
+ * - mid-response errors (stream died without a stopReason)
11
+ * - S38.5 race-guard (strict deferred vs synchronous)
12
+ *
13
+ * The harness is duplicated from mega-compact.test.ts (per guardrails guidance to
14
+ * keep each test file self-contained; the harness is ~200 lines and does not
15
+ * warrant a shared helper module that would itself exceed the 500-line target).
16
+ */
17
+ import { test } from "node:test";
18
+ import assert from "node:assert/strict";
19
+ import { mkdtempSync, rmSync } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { join } from "node:path";
22
+ import { createRequire } from "node:module";
23
+ import { closeVectorIndex } from "../src/store/vectorIndex.js";
24
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
25
+
26
+ const require = createRequire(import.meta.url);
27
+ const baseTmp = mkdtempSync(join(tmpdir(), "mc-s38-"));
28
+ // Isolate the machine-wide repo index so test runs never pollute the real one.
29
+ process.env.MEGACOMPACT_INDEX_DIR = join(baseTmp, "index");
30
+ let counter = 0;
31
+
32
+ /** Read events.log and return an array of event type strings. */
33
+ function eventTypes(stateDir: string): string[] {
34
+ const { readFileSync: rf, existsSync: ex } =
35
+ require("node:fs") as typeof import("node:fs");
36
+ const { join: j } = require("node:path") as typeof import("node:path");
37
+ const logPath = j(stateDir, "events.log");
38
+ if (!ex(logPath)) return [];
39
+ const content = rf(logPath, "utf-8").trim();
40
+ if (content.length === 0) return [];
41
+ return content
42
+ .split("\n")
43
+ .map((line) => { try { return JSON.parse(line).type; } catch { return undefined; } })
44
+ .filter((t): t is string => typeof t === "string");
45
+ }
46
+
47
+ /** Build a mock pi + ctx and load the extension into them. */
48
+ function harness(opts: { keepTier?: boolean; keepThreshold?: boolean } = {}) {
49
+ const stateDir = join(baseTmp, `run-${counter++}`);
50
+ process.env.MEGACOMPACT_STATE_DIR = stateDir;
51
+ process.env.MEGACOMPACT_DEBUG = "true";
52
+ if (!opts.keepThreshold) process.env.MEGACOMPACT_THRESHOLD_TOKENS = "50";
53
+ if (!opts.keepTier) delete process.env.MEGACOMPACT_TIER;
54
+ process.env.MEGACOMPACT_FAST_GATE_PCT = "1";
55
+
56
+ const handlers: Record<string, Function[]> = {};
57
+ const appended: any[] = [];
58
+ const sendUserMessages: string[] = [];
59
+ const compactCalls: any[] = [];
60
+
61
+ const session: AgentMessage[] = [
62
+ { role: "user", content: "read src/vec.ts and understand the index", timestamp: 0 } as unknown as AgentMessage,
63
+ { role: "assistant", content: [{ type: "toolCall", name: "Read", id: "c1", arguments: {} }], api: "anthropic-messages", provider: "anthropic", model: "m", usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 }, stopReason: "tool_use", timestamp: 0 } as unknown as AgentMessage,
64
+ { role: "user", content: "edit src/vec.ts to add a cosine helper", timestamp: 0 } as unknown as AgentMessage,
65
+ { role: "assistant", content: [{ type: "toolCall", name: "Edit", id: "c1", arguments: {} }], api: "anthropic-messages", provider: "anthropic", model: "m", usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 }, stopReason: "tool_use", timestamp: 0 } as unknown as AgentMessage,
66
+ ];
67
+ const toEntry = (m: AgentMessage, i: number): any => ({ type: "message", id: `e${i}`, parentId: null, timestamp: String(i), message: m });
68
+ const sessionManager = { getSessionId: () => "sess_ext_001", getEntries: () => session.map(toEntry), getBranch: () => session.map(toEntry) };
69
+
70
+ function makeCtx(over: Partial<any> = {}) {
71
+ return {
72
+ ui: { setStatus: () => {}, notify: () => {}, select: () => {}, confirm: async () => true, input: async () => "", setWidget: () => {} },
73
+ mode: "tui" as any, hasUI: true, cwd: stateDir, sessionManager,
74
+ modelRegistry: {} as any, model: undefined,
75
+ isIdle: () => true, isProjectTrusted: () => true,
76
+ signal: undefined, abort: () => {}, hasPendingMessages: () => false, shutdown: () => {},
77
+ getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }),
78
+ compact: (opts?: any) => {
79
+ compactCalls.push(opts);
80
+ const _sbc = handlers["session_before_compact"];
81
+ if (_sbc && _sbc.length) return _sbc[0]({ type: "session_before_compact", reason: "threshold", willRetry: false, signal: undefined, preparation: { firstKeptEntryId: "e2", messagesToSummarize: session.slice(0, 2), tokensBefore: 500 } } as any, makeCtx());
82
+ return undefined;
83
+ },
84
+ getSystemPrompt: () => "system base", ...over,
85
+ } as any;
86
+ }
87
+ const pi = {
88
+ on: (ev: string, h: Function) => { if (!handlers[ev]) handlers[ev] = []; handlers[ev].push(h); },
89
+ registerCommand: () => {}, registerTool: () => {}, registerShortcut: () => {},
90
+ registerFlag: () => {}, getFlag: () => undefined,
91
+ registerMessageRenderer: () => {}, registerEntryRenderer: () => {},
92
+ sendMessage: () => {}, sendUserMessage: (m: string) => { sendUserMessages.push(m); },
93
+ appendEntry: (t: string, d: any) => appended.push({ t, d }),
94
+ setSessionName: () => {}, getSessionName: () => undefined, setLabel: () => {},
95
+ exec: async () => ({ stdout: "", stderr: "", code: 0 }),
96
+ getActiveTools: () => [], getAllTools: () => {}, setActiveTools: () => {},
97
+ getCommands: () => [], setModel: async () => false,
98
+ getThinkingLevel: () => "off" as any, setThinkingLevel: () => {},
99
+ } as any;
100
+ const mod = require("./mega-compact.js") as { default: (p: any) => void };
101
+ mod.default(pi);
102
+ return {
103
+ stateDir, handlers, appended, compactCalls, sendUserMessages,
104
+ fire: async (ev: string, event: any, ctx: any) => { let r: any; for (const h of handlers[ev] || []) r = await h(event, ctx); return r; },
105
+ ctx: makeCtx, session,
106
+ };
107
+ }
108
+
109
+ const { classifyError: classifyErrorFn } =
110
+ require("./mega-events.js") as { classifyError: typeof import("./mega-events.js").classifyError };
111
+
112
+ /** S38 helper: fire a turn_end with a given stopReason + optional text, using a
113
+ * low-pressure ctx so the durable-trim branch (ctx.compact) does NOT fire. */
114
+ async function s38TurnEnd(h: ReturnType<typeof harness>, stopReason: string | undefined, text?: string) {
115
+ const lowCtx = h.ctx({ isIdle: () => true, hasPendingMessages: () => false, getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }) });
116
+ const message: any = { role: "assistant" };
117
+ if (stopReason !== undefined) message.stopReason = stopReason;
118
+ if (text) message.content = text;
119
+ await h.fire("turn_end", { type: "turn_end", turnIndex: 1, message }, lowCtx);
120
+ }
121
+
122
+ // ---- classifier unit tests (no extension harness needed) ----
123
+
124
+ test("S38: classifyError returns 'transient' for error/aborted stopReasons", () => {
125
+ assert.equal(classifyErrorFn({ stopReason: "error" }), "transient");
126
+ assert.equal(classifyErrorFn({ stopReason: "aborted" }), "transient");
127
+ });
128
+
129
+ test("S38: classifyError returns 'transient' for max-output-token text", () => {
130
+ assert.equal(classifyErrorFn({ stopReason: "error", content: "reached the maximum output token limit" }), "transient");
131
+ assert.equal(classifyErrorFn("max output token exceeded"), "transient");
132
+ });
133
+
134
+ test("S38: classifyError returns 'permanent' for auth/unauthorized text", () => {
135
+ assert.equal(classifyErrorFn("unauthorized: invalid api key"), "permanent");
136
+ assert.equal(classifyErrorFn("permission denied"), "permanent");
137
+ });
138
+
139
+ test("S38: classifyError returns null for stop/toolUse stopReasons (success)", () => {
140
+ assert.equal(classifyErrorFn({ stopReason: "stop" }), null);
141
+ assert.equal(classifyErrorFn({ stopReason: "toolUse" }), null);
142
+ assert.equal(classifyErrorFn({ stopReason: "tool_use" }), null);
143
+ });
144
+
145
+ test("S38: classifyError returns null for 'length' stopReason (S28 guard)", () => {
146
+ assert.equal(classifyErrorFn({ stopReason: "length" }), null);
147
+ });
148
+
149
+ test("S38: classifyError returns 'transient' for mid-response errors with no stopReason", () => {
150
+ assert.equal(classifyErrorFn({}), "transient");
151
+ assert.equal(classifyErrorFn({ content: [] }), "transient");
152
+ assert.equal(classifyErrorFn({ stopReason: undefined }), "transient");
153
+ assert.equal(classifyErrorFn({ stopReason: "" }), "transient");
154
+ });
155
+
156
+ test("S38: classifyError returns 'transient' for error objects with message field", () => {
157
+ assert.equal(classifyErrorFn({ error: { message: "Stream interrupted" } }), "transient");
158
+ assert.equal(classifyErrorFn({ error: { message: "Connection lost" } }), "transient");
159
+ assert.equal(classifyErrorFn({ error: { message: "500 Internal Server Error" } }), "transient");
160
+ assert.equal(classifyErrorFn({ error: "Connection lost" }), "transient");
161
+ });
162
+
163
+ test("S38: classifyError returns 'transient' for mid-response stream failures in content", () => {
164
+ assert.equal(classifyErrorFn({ content: [{ type: "text", text: "Processing... Error: connection reset" }] }), "transient");
165
+ assert.equal(classifyErrorFn({ content: [{ type: "text", text: "Here is the answer..." }], stopReason: "error" }), "transient");
166
+ });
167
+
168
+ test("S38: classifyError returns 'transient' for partial content with NO stopReason (stream died after emitting text)", () => {
169
+ // The mid-response disconnect case: provider streamed partial content then
170
+ // died without a stopReason. MUST be transient (retryable), NOT null.
171
+ assert.equal(classifyErrorFn({ role: "assistant", content: [{ type: "text", text: "partial response..." }], stopReason: undefined }), "transient");
172
+ assert.equal(classifyErrorFn({ role: "assistant", content: [{ type: "text", text: "Here is the start of the answer" }] }), "transient");
173
+ assert.equal(classifyErrorFn({ role: "assistant", content: "partial response..." }), "transient");
174
+ });
175
+
176
+ test("S38: classifyError returns null for success stopReasons even with empty content", () => {
177
+ assert.equal(classifyErrorFn({ stopReason: "stop", content: [] }), null);
178
+ assert.equal(classifyErrorFn({ stopReason: "tool_use", content: [] }), null);
179
+ });
180
+
181
+ test("S38: classifyError returns 'compaction-noop' for 'Already compacted' text", () => {
182
+ assert.equal(classifyErrorFn("Error: Already compacted"), "compaction-noop");
183
+ });
184
+
185
+ test("S38: classifyError returns 'compaction-noop' for 'Nothing to compact' text", () => {
186
+ assert.equal(classifyErrorFn("Nothing to compact (session too small)"), "compaction-noop");
187
+ });
188
+
189
+ test("S38: classifyError returns 'compaction-noop' for 'Auto compaction failed' text", () => {
190
+ assert.equal(classifyErrorFn("Auto compaction failed"), "compaction-noop");
191
+ assert.equal(classifyErrorFn("Auto-compaction failed"), "compaction-noop");
192
+ });
193
+
194
+ // ---- integration tests (fire turn_end through the real extension) ----
195
+
196
+ test("S38: compaction-noop logs 'compaction_noop_diagnostic' + resets counter + no retry fired", async () => {
197
+ const h = harness();
198
+ await s38TurnEnd(h, "error", "Already compacted");
199
+ const ev = eventTypes(h.stateDir);
200
+ assert.ok(ev.includes("compaction_noop_diagnostic"), "compaction-noop: diagnostic event logged");
201
+ assert.equal(h.sendUserMessages.length, 0, "compaction-noop: NO retry nudge fired");
202
+ });
203
+
204
+ test("S38: compaction-noop does NOT fire pi.sendUserMessage (NOT retryable)", async () => {
205
+ const h = harness();
206
+ await s38TurnEnd(h, "error", "Nothing to compact");
207
+ assert.equal(h.sendUserMessages.length, 0, "compaction-noop: no sendUserMessage");
208
+ assert.ok(eventTypes(h.stateDir).includes("compaction_noop_diagnostic"));
209
+ });
210
+
211
+ test("S38: retry fires up to max (5) for transient errors, then stops", async () => {
212
+ const h = harness();
213
+ for (let i = 0; i < 5; i++) await s38TurnEnd(h, "error", "internal server error");
214
+ assert.equal(h.sendUserMessages.length, 5, "transient: 5 retry nudges (<= max 5)");
215
+ await s38TurnEnd(h, "error", "internal server error");
216
+ assert.equal(h.sendUserMessages.length, 5, "transient: exhausted -> no 6th nudge");
217
+ assert.ok(eventTypes(h.stateDir).includes("error_retry_exhausted"), "exhausted event logged");
218
+ });
219
+
220
+ test("S38: retry fires 1x for permanent errors then stops", async () => {
221
+ const h = harness();
222
+ await s38TurnEnd(h, "error", "invalid api key");
223
+ assert.equal(h.sendUserMessages.length, 1, "permanent: 1 retry nudge (<= max 1)");
224
+ await s38TurnEnd(h, "error", "invalid api key");
225
+ assert.equal(h.sendUserMessages.length, 1, "permanent: exhausted -> no 2nd nudge");
226
+ assert.ok(eventTypes(h.stateDir).includes("error_retry_exhausted"), "permanent exhausted logged");
227
+ });
228
+
229
+ test("S38: successful turn (stop/toolUse) resets the retry counter", async () => {
230
+ const h = harness();
231
+ await s38TurnEnd(h, "error", "5xx server error");
232
+ assert.equal(h.sendUserMessages.length, 1, "first transient: 1 nudge");
233
+ await s38TurnEnd(h, "stop");
234
+ await s38TurnEnd(h, "error", "5xx server error");
235
+ assert.equal(h.sendUserMessages.length, 2, "success reset counter -> transient fires again from count=1");
236
+ });
237
+
238
+ test("S38: error_retry_exhausted event logged when max exceeded", async () => {
239
+ const h = harness();
240
+ await s38TurnEnd(h, "error", "malformed bad request");
241
+ await s38TurnEnd(h, "error", "malformed bad request");
242
+ assert.ok(eventTypes(h.stateDir).includes("error_retry_exhausted"), "error_retry_exhausted logged");
243
+ });
244
+
245
+ test("S38: MEGACOMPACT_AUTO_RETRY_TRANSIENT_MAX=0 disables transient retries cleanly", async () => {
246
+ const prev = process.env.MEGACOMPACT_AUTO_RETRY_TRANSIENT_MAX;
247
+ process.env.MEGACOMPACT_AUTO_RETRY_TRANSIENT_MAX = "0";
248
+ try {
249
+ const h = harness();
250
+ await s38TurnEnd(h, "error", "network timeout");
251
+ assert.equal(h.sendUserMessages.length, 0, "max=0: no transient retry nudge");
252
+ assert.ok(!eventTypes(h.stateDir).includes("error_retry"), "max=0: no error_retry event");
253
+ } finally {
254
+ if (prev === undefined) delete process.env.MEGACOMPACT_AUTO_RETRY_TRANSIENT_MAX;
255
+ else process.env.MEGACOMPACT_AUTO_RETRY_TRANSIENT_MAX = prev;
256
+ }
257
+ });
258
+
259
+ test("S38: retry fires for mid-response errors (no stopReason — stream died silently)", async () => {
260
+ const h = harness();
261
+ const lowCtx = h.ctx({ isIdle: () => true, hasPendingMessages: () => false, getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }) });
262
+ await h.fire("turn_end", { type: "turn_end", turnIndex: 1, message: { role: "assistant" } }, lowCtx);
263
+ assert.equal(h.sendUserMessages.length, 1, "mid-response silent failure: 1 retry nudge fired");
264
+ assert.ok(eventTypes(h.stateDir).includes("error_retry"), "error_retry event logged for mid-response failure");
265
+ });
266
+
267
+ test("S38: retry fires for error objects with message field", async () => {
268
+ const h = harness();
269
+ const lowCtx = h.ctx({ isIdle: () => true, hasPendingMessages: () => false, getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }) });
270
+ await h.fire("turn_end", { type: "turn_end", turnIndex: 1, message: { role: "assistant", error: { message: "Connection reset by peer" } } }, lowCtx);
271
+ assert.equal(h.sendUserMessages.length, 1, "error object with message: 1 retry nudge fired");
272
+ });
273
+
274
+ test("S38: retry fires for partial content with no stopReason (disconnect after emitting text)", async () => {
275
+ // The post-resume disconnect case: provider streamed partial content then
276
+ // died mid-stream with NO stopReason. Must fire a retry nudge.
277
+ const h = harness();
278
+ const lowCtx = h.ctx({ isIdle: () => true, hasPendingMessages: () => false, getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }) });
279
+ await h.fire("turn_end", {
280
+ type: "turn_end",
281
+ turnIndex: 1,
282
+ message: { role: "assistant", content: [{ type: "text", text: "Here is the start of the answer" }], stopReason: undefined },
283
+ }, lowCtx);
284
+ assert.equal(h.sendUserMessages.length, 1, "partial-content mid-response failure: 1 retry nudge fired");
285
+ assert.ok(eventTypes(h.stateDir).includes("error_retry"), "error_retry event logged for partial-content failure");
286
+ });
287
+
288
+ // ---- S38.5: race-guard strengthening (cooldown 10s->30s + deferred re-check) ---
289
+
290
+ test("S38.5: MEGACOMPACT_RACE_GUARD_STRICT=false reverts to synchronous 10s guard", async () => {
291
+ const prev = process.env.MEGACOMPACT_RACE_GUARD_STRICT;
292
+ const prevFloor = process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
293
+ process.env.MEGACOMPACT_RACE_GUARD_STRICT = "false";
294
+ process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR = "0";
295
+ try {
296
+ const h = harness();
297
+ const hiCtx = h.ctx({ isIdle: () => true, hasPendingMessages: () => false, getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }) });
298
+ await h.fire("agent_end", { type: "agent_end", messages: [] }, hiCtx);
299
+ assert.ok(h.compactCalls.length >= 1, "non-strict: synchronous ctx.compact() fired");
300
+ } finally {
301
+ if (prev === undefined) delete process.env.MEGACOMPACT_RACE_GUARD_STRICT;
302
+ else process.env.MEGACOMPACT_RACE_GUARD_STRICT = prev;
303
+ if (prevFloor === undefined) delete process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
304
+ else process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR = prevFloor;
305
+ }
306
+ });
307
+
308
+ test("S38.5: strict (default) defers ctx.compact() via setTimeout re-check", async () => {
309
+ const prevFloor = process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
310
+ process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR = "0";
311
+ try {
312
+ const h = harness();
313
+ const hiCtx = h.ctx({ isIdle: () => true, hasPendingMessages: () => false, getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }) });
314
+ await h.fire("agent_end", { type: "agent_end", messages: [] }, hiCtx);
315
+ assert.equal(h.compactCalls.length, 0, "strict: ctx.compact() NOT called synchronously (deferred)");
316
+ await new Promise((r) => setTimeout(r, 700));
317
+ assert.ok(h.compactCalls.length >= 1, "strict: deferred ctx.compact() fired after re-check");
318
+ } finally {
319
+ if (prevFloor === undefined) delete process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
320
+ else process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR = prevFloor;
321
+ }
322
+ });
323
+
324
+ test("cleanup", async () => {
325
+ // PGlite WASM close can hang; race with a timeout to prevent 40-min hangs.
326
+ try {
327
+ await Promise.race([closeVectorIndex(), new Promise((r) => setTimeout(r, 3000))]);
328
+ } catch { /* ignore */ }
329
+ rmSync(baseTmp, { recursive: true, force: true });
330
+ });
@@ -40,7 +40,7 @@ function harness(opts: { keepTier?: boolean; keepThreshold?: boolean } = {}) {
40
40
  if (!opts.keepTier) delete process.env.MEGACOMPACT_TIER;
41
41
  process.env.MEGACOMPACT_FAST_GATE_PCT = "1";
42
42
 
43
- const handlers: Record<string, Function> = {};
43
+ const handlers: Record<string, Function[]> = {};
44
44
  const commands: Record<
45
45
  string,
46
46
  { handler: (a: string, c: any) => Promise<void> }
@@ -154,8 +154,8 @@ function harness(opts: { keepTier?: boolean; keepThreshold?: boolean } = {}) {
154
154
  // session_before_compact handler (where WE supply the durable trim).
155
155
  compact: (opts?: any) => {
156
156
  compactCalls.push(opts);
157
- if (handlers["session_before_compact"]) {
158
- return handlers["session_before_compact"](
157
+ const _sbc = handlers["session_before_compact"]; if (_sbc && _sbc.length) {
158
+ return _sbc[0](
159
159
  {
160
160
  type: "session_before_compact",
161
161
  reason: "threshold",
@@ -181,7 +181,8 @@ function harness(opts: { keepTier?: boolean; keepThreshold?: boolean } = {}) {
181
181
 
182
182
  const pi = {
183
183
  on: (ev: string, h: Function) => {
184
- handlers[ev] = h;
184
+ if (!handlers[ev]) handlers[ev] = [];
185
+ handlers[ev].push(h);
185
186
  },
186
187
  registerCommand: (name: string, opts: any) => {
187
188
  commands[name] = opts;
@@ -225,7 +226,7 @@ function harness(opts: { keepTier?: boolean; keepThreshold?: boolean } = {}) {
225
226
  notifies,
226
227
  compactCalls,
227
228
  sendUserMessages,
228
- fire: (ev: string, event: any, ctx: any) => handlers[ev](event, ctx),
229
+ fire: async (ev: string, event: any, ctx: any) => { let r: any; for (const h of handlers[ev] || []) r = await h(event, ctx); return r; },
229
230
  ctx: makeCtx,
230
231
  session,
231
232
  };
@@ -251,8 +252,13 @@ test("auto-trigger (legacy): past threshold persists a chkpt and starts a durabl
251
252
  percent: 100,
252
253
  }),
253
254
  });
254
- const res = await h.fire("context", { type: "context", messages }, ctx);
255
- // L1->L4 ran: a checkpoint was persisted to the SQLite store + a marker entry written.
255
+ const res = await h.fire("context", { type: "context", messages }, ctx);
256
+ // S38.5: the strict race guard (default) defers ctx.compact() via a
257
+ // setTimeout(500) re-check. The synchronous return is still undefined
258
+ // (no local drop), but ctx.compact() only lands after the timer — wait
259
+ // for it before asserting the durable-trim call count.
260
+ await new Promise((r) => setTimeout(r, 700));
261
+ // L1->L4 ran: a checkpoint was persisted to the SQLite store + a marker entry written.
256
262
  const { listCheckpoints } = await import("../src/store/sqlite.js");
257
263
  assert.ok(
258
264
  listCheckpoints("sess_ext_001", h.stateDir).length > 0,
@@ -1238,15 +1244,20 @@ test("S28: length_stop + length_stop_continue dashboard events fire on the right
1238
1244
  assert.equal(h.sendUserMessages.length, 1, "length stop: exactly one nudge");
1239
1245
  });
1240
1246
 
1241
- test("S28: non-length stopReasons do not arm the flag (no nudge, no length_stop event)", async () => {
1247
+ test("S28: non-length stopReasons do not arm the length-stop flag (no length_stop event); S38 owns error/aborted retries", async () => {
1242
1248
  const h = harness();
1243
1249
  const lowPressureCtx = h.ctx({
1244
1250
  isIdle: () => true,
1245
1251
  hasPendingMessages: () => false,
1246
1252
  getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }),
1247
1253
  });
1248
- // Every other pi-ai StopReason must leave the flag unset → no nudge + no event.
1254
+ // S28 contract: every non-length StopReason must leave lengthStopPending unset,
1255
+ // so no length_stop event fires and no length-stop resume nudge is armed.
1256
+ // (S38 separately owns error/aborted — it WILL fire error-retry nudges for those;
1257
+ // this test only asserts the S28 length-stop flag is not armed.)
1258
+ let transientRetries = 0;
1249
1259
  for (const stopReason of ["tool_use", "error", "aborted"] as const) {
1260
+ const before = h.sendUserMessages.length;
1250
1261
  await h.fire(
1251
1262
  "turn_end",
1252
1263
  {
@@ -1261,11 +1272,22 @@ test("S28: non-length stopReasons do not arm the flag (no nudge, no length_stop
1261
1272
  { type: "agent_end", messages: [] },
1262
1273
  lowPressureCtx,
1263
1274
  );
1275
+ // tool_use is success (0 nudges); error/aborted are transient under S38 (1 each).
1276
+ if (stopReason === "tool_use") {
1277
+ assert.equal(
1278
+ h.sendUserMessages.length - before,
1279
+ 0,
1280
+ "tool_use (success): no nudge",
1281
+ );
1282
+ } else {
1283
+ transientRetries += h.sendUserMessages.length - before;
1284
+ }
1264
1285
  }
1286
+ // error + aborted each produced one S38 transient retry nudge (2 total).
1265
1287
  assert.equal(
1266
- h.sendUserMessages.length,
1267
- 0,
1268
- "non-length stopReasons: no nudge",
1288
+ transientRetries,
1289
+ 2,
1290
+ "S38 owns error/aborted: 1 transient retry each",
1269
1291
  );
1270
1292
  assert.ok(
1271
1293
  !eventTypes(h.stateDir).includes("length_stop"),
@@ -1476,6 +1498,12 @@ test("cleanup", async () => {
1476
1498
  // Terminate the global PGlite cross-repo index (WASM worker thread) so the
1477
1499
  // test process can exit. Without this, node --test never returns even though
1478
1500
  // every test passed — the leaked worker keeps the event loop alive.
1479
- await closeVectorIndex();
1501
+ // Race with a timeout to prevent 40-min hangs if PGlite WASM close stalls.
1502
+ try {
1503
+ await Promise.race([
1504
+ closeVectorIndex(),
1505
+ new Promise((r) => setTimeout(r, 3000)),
1506
+ ]);
1507
+ } catch { /* ignore */ }
1480
1508
  rmSync(baseTmp, { recursive: true, force: true });
1481
1509
  });
@@ -38,6 +38,20 @@ import { registerGameCommands } from "./mega-game-cmds.js";
38
38
 
39
39
  export default function (pi: ExtensionAPI) {
40
40
  const config = loadConfig();
41
+ // S38.9: preflight env validation — check for obviously invalid values at startup.
42
+ // Non-fatal: log warnings and fall back to defaults.
43
+ if (config.autoRetryTransientMax < 0) {
44
+ console.warn('[mega-compact] MEGACOMPACT_AUTO_RETRY_TRANSIENT_MAX must be >= 0; using default 5');
45
+ config.autoRetryTransientMax = 5;
46
+ }
47
+ if (config.autoRetryPermanentMax < 0) {
48
+ console.warn('[mega-compact] MEGACOMPACT_AUTO_RETRY_PERMANENT_MAX must be >= 0; using default 1');
49
+ config.autoRetryPermanentMax = 1;
50
+ }
51
+ if (config.maxConsecutiveErrors < 1) {
52
+ console.warn('[mega-compact] MEGACOMPACT_MAX_CONSECUTIVE_ERRORS must be >= 1; using default 10');
53
+ config.maxConsecutiveErrors = 10;
54
+ }
41
55
  const runtime = new MegaRuntime(config);
42
56
  registerEventHandlers(pi, runtime, config);
43
57
  registerCommands(pi, runtime, config);
@@ -70,6 +70,25 @@ export interface MegaConfig {
70
70
  * reusing the existing S16 resume-nudge. Default true. Off = silent (the
71
71
  * prior behavior). PREVENT-PI-003: restart via user-role sendUserMessage. */
72
72
  autoContinueLengthStop: boolean;
73
+ /** S38: max retries for transient errors (5xx/429/network/max-output-token
74
+ * text that is NOT a length stopReason — S28 owns those). Default 5.
75
+ * `0` disables all transient retries (reverts to S28-only). */
76
+ autoRetryTransientMax: number;
77
+ /** S38: max retries for permanent errors (auth/config/malformed). Default 1.
78
+ * `0` disables permanent-error retries. */
79
+ autoRetryPermanentMax: number;
80
+ /** S38.5: strict race-guard — 30s cooldown + deferred ctx.compact() re-check
81
+ * (closes the first-race-in-burst window). Default true. `false` reverts to
82
+ * the v0.7.4 synchronous 10s-cooldown behavior. */
83
+ raceGuardStrict: boolean;
84
+ /** S38.6: max consecutive errors before circuit-breaker trips (stops retrying).
85
+ * Default 10. When `errorRetryCount` exceeds this across multiple turns,
86
+ * the extension stops retrying until a successful turn resets it. */
87
+ maxConsecutiveErrors: number;
88
+ /** S38.7: hard-stop switch — when true, ALL error retries are disabled.
89
+ * Default false. Set via env to force S28-only behavior (length-stop continues
90
+ * only). */
91
+ errorRetryHardStop: boolean;
73
92
  /** S29: override the auto-compact fire point for tiered configs, as a
74
93
  * fraction of the context window (e.g. 0.85). null = inherit the tier's
75
94
  * tierPct (default; preserves existing fire points). The context-handler
@@ -238,6 +257,11 @@ export function loadConfig(): MegaConfig {
238
257
  auto: envBool("MEGACOMPACT_AUTO", true),
239
258
  autoInline: envBool("MEGACOMPACT_AUTO_INLINE", true),
240
259
  autoContinueLengthStop: envBool("MEGACOMPACT_AUTO_CONTINUE_LENGTH_STOP", true),
260
+ autoRetryTransientMax: envFlag("MEGACOMPACT_AUTO_RETRY_TRANSIENT_MAX", 5),
261
+ autoRetryPermanentMax: envFlag("MEGACOMPACT_AUTO_RETRY_PERMANENT_MAX", 1),
262
+ raceGuardStrict: envBool("MEGACOMPACT_RACE_GUARD_STRICT", true),
263
+ maxConsecutiveErrors: envFlag("MEGACOMPACT_MAX_CONSECUTIVE_ERRORS", 10),
264
+ errorRetryHardStop: envBool("MEGACOMPACT_ERROR_RETRY_HARD_STOP", false),
241
265
  autoPctTrigger,
242
266
  autoInlineK: envFlag("MEGACOMPACT_AUTO_INLINE_K", 3),
243
267
  dedupSim: Number(process.env.MEGACOMPACT_DEDUP_SIM ?? "0.9"),
@@ -146,6 +146,13 @@ export interface DashboardSnapshot {
146
146
  liveTrimFires: number;
147
147
  liveTrimReplays: number;
148
148
  };
149
+ /** S38.8: error-retry state for dashboard "retries" tile. */
150
+ retries?: {
151
+ errorRetryCount: number;
152
+ consecutiveErrors: number;
153
+ maxConsecutiveErrors: number;
154
+ errorRetryHardStop: boolean;
155
+ };
149
156
  }
150
157
 
151
158
  export class Dashboard {