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
@@ -0,0 +1,63 @@
1
+ /**
2
+ * dashboard-client/src/hooks/useSSE.ts — Server-Sent Events hook.
3
+ *
4
+ * Connects to /api/events, provides real-time event stream.
5
+ * SPRINT-B1: basic connection. SPRINT-D1: reconnect with backoff.
6
+ */
7
+ import { useState, useEffect, useRef, useCallback } from 'react';
8
+ // SPRINT-D1-REMAINING: exponential backoff (1s → 2s → 4s → max 30s).
9
+ // SPRINT-D1-REMAINING: after 5 failures, set status='disconnected'.
10
+ export function useSSE(options = {}) {
11
+ const { maxEvents = 500, maxReconnects = 5 } = options;
12
+ const [events, setEvents] = useState([]);
13
+ const [status, setStatus] = useState('connecting');
14
+ const [eventCount, setEventCount] = useState(0);
15
+ const [lastEventAt, setLastEventAt] = useState(null);
16
+ const reconnectCount = useRef(0);
17
+ const sourceRef = useRef(null);
18
+ const connect = useCallback(() => {
19
+ if (sourceRef.current) {
20
+ sourceRef.current.close();
21
+ }
22
+ setStatus('connecting');
23
+ const source = new EventSource('/api/events');
24
+ sourceRef.current = source;
25
+ source.onopen = () => {
26
+ setStatus('connected');
27
+ reconnectCount.current = 0;
28
+ };
29
+ source.onmessage = (event) => {
30
+ try {
31
+ const parsed = JSON.parse(event.data);
32
+ setEvents(prev => {
33
+ const next = [...prev, parsed];
34
+ return next.length > maxEvents ? next.slice(-maxEvents) : next;
35
+ });
36
+ setEventCount(c => c + 1);
37
+ setLastEventAt(Date.now());
38
+ }
39
+ catch {
40
+ // non-fatal: skip malformed events
41
+ }
42
+ };
43
+ source.onerror = () => {
44
+ source.close();
45
+ if (reconnectCount.current < maxReconnects) {
46
+ reconnectCount.current += 1;
47
+ setStatus('connecting');
48
+ // SPRINT-D1: use exponential backoff instead of fixed delay
49
+ setTimeout(connect, 1000);
50
+ }
51
+ else {
52
+ setStatus('disconnected');
53
+ }
54
+ };
55
+ }, [maxEvents, maxReconnects]);
56
+ useEffect(() => {
57
+ connect();
58
+ return () => {
59
+ sourceRef.current?.close();
60
+ };
61
+ }, [connect]);
62
+ return { events, status, eventCount, lastEventAt };
63
+ }
@@ -176,8 +176,8 @@ export function dashboardHtml(tierName) {
176
176
 
177
177
  <div class="settings-strip">
178
178
  <label title="Turn game mode on/off (themes the widget + dashboard)"><input type="checkbox" id="set-game-mode"> Game mode</label>
179
- <label title="Visual theme (applies instantly)">Theme <select id="set-theme">${THEME_OPTIONS}</select></label>
180
- <label title="TUI widget display density">TUI <select id="set-tui-mode"><option value="full">Full</option><option value="minimal">Minimal</option></select></label>
179
+ <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>
180
+ <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>
181
181
  </div>
182
182
 
183
183
  <nav class="tabs">
@@ -869,6 +869,19 @@ export function dashboardHtml(tierName) {
869
869
  }
870
870
  if (gmCheckbox) gmCheckbox.addEventListener('change', function() { putGameState({ game_mode_on: gmCheckbox.checked }); });
871
871
  if (gmTheme) gmTheme.addEventListener('change', function() { putGameState({ theme: gmTheme.value }); });
872
+ // P3: client-side theme cycling (no new endpoint). Reads the already-fetched
873
+ // <option> values from the theme select, so it stays in sync with the server's
874
+ // THEMES list without a second fetch.
875
+ var gmThemeNext = document.getElementById('set-theme-next');
876
+ if (gmThemeNext) gmThemeNext.addEventListener('click', function() {
877
+ var opts = gmTheme ? gmTheme.options : [];
878
+ var n = opts.length;
879
+ var cur = gmTheme ? gmTheme.value : 'transparent';
880
+ var idx = -1;
881
+ for (var i = 0; i < n; i++) { if (opts[i].value === cur) { idx = i; break; } }
882
+ var next = n > 0 ? opts[(idx < 0 ? 0 : (idx + 1) % n)].value : 'transparent';
883
+ putGameState({ theme: next });
884
+ });
872
885
  if (gmTui) gmTui.addEventListener('change', function() { putGameState({ tui_display_mode: gmTui.value }); });
873
886
  pollGameState();
874
887
  setInterval(pollGameState, 5000);
@@ -96,7 +96,8 @@ function harness() {
96
96
  };
97
97
  }
98
98
  const pi = {
99
- on: (ev, h) => { handlers[ev] = h; },
99
+ on: (ev, h) => { if (!handlers[ev])
100
+ handlers[ev] = []; handlers[ev].push(h); },
100
101
  registerCommand: () => { }, registerTool: () => { }, registerShortcut: () => { },
101
102
  registerFlag: () => { }, getFlag: () => undefined, registerMessageRenderer: () => { },
102
103
  registerEntryRenderer: () => { }, sendMessage: () => { }, sendUserMessage: () => { },
@@ -109,7 +110,8 @@ function harness() {
109
110
  const mod = require("./mega-compact.js");
110
111
  mod.default(pi);
111
112
  const { lastRuntime } = require("./mega-events.js");
112
- const fire = (ev, event, ctx) => handlers[ev](event, ctx);
113
+ const fire = async (ev, event, ctx) => { let r; for (const h of handlers[ev] || [])
114
+ r = await h(event, ctx); return r; };
113
115
  return {
114
116
  stateDir, handlers, compactCalls, fire, ctx: makeCtx, usage, buildSession,
115
117
  runtime: lastRuntime, // MegaRuntime with diag* counters + rt + trimCache
@@ -0,0 +1,317 @@
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
+ const require = createRequire(import.meta.url);
25
+ const baseTmp = mkdtempSync(join(tmpdir(), "mc-s38-"));
26
+ // Isolate the machine-wide repo index so test runs never pollute the real one.
27
+ process.env.MEGACOMPACT_INDEX_DIR = join(baseTmp, "index");
28
+ let counter = 0;
29
+ /** Read events.log and return an array of event type strings. */
30
+ function eventTypes(stateDir) {
31
+ const { readFileSync: rf, existsSync: ex } = require("node:fs");
32
+ const { join: j } = require("node:path");
33
+ const logPath = j(stateDir, "events.log");
34
+ if (!ex(logPath))
35
+ return [];
36
+ const content = rf(logPath, "utf-8").trim();
37
+ if (content.length === 0)
38
+ return [];
39
+ return content
40
+ .split("\n")
41
+ .map((line) => { try {
42
+ return JSON.parse(line).type;
43
+ }
44
+ catch {
45
+ return undefined;
46
+ } })
47
+ .filter((t) => typeof t === "string");
48
+ }
49
+ /** Build a mock pi + ctx and load the extension into them. */
50
+ function harness(opts = {}) {
51
+ const stateDir = join(baseTmp, `run-${counter++}`);
52
+ process.env.MEGACOMPACT_STATE_DIR = stateDir;
53
+ process.env.MEGACOMPACT_DEBUG = "true";
54
+ if (!opts.keepThreshold)
55
+ process.env.MEGACOMPACT_THRESHOLD_TOKENS = "50";
56
+ if (!opts.keepTier)
57
+ delete process.env.MEGACOMPACT_TIER;
58
+ process.env.MEGACOMPACT_FAST_GATE_PCT = "1";
59
+ const handlers = {};
60
+ const appended = [];
61
+ const sendUserMessages = [];
62
+ const compactCalls = [];
63
+ const session = [
64
+ { role: "user", content: "read src/vec.ts and understand the index", timestamp: 0 },
65
+ { 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 },
66
+ { role: "user", content: "edit src/vec.ts to add a cosine helper", timestamp: 0 },
67
+ { 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 },
68
+ ];
69
+ const toEntry = (m, i) => ({ type: "message", id: `e${i}`, parentId: null, timestamp: String(i), message: m });
70
+ const sessionManager = { getSessionId: () => "sess_ext_001", getEntries: () => session.map(toEntry), getBranch: () => session.map(toEntry) };
71
+ function makeCtx(over = {}) {
72
+ return {
73
+ ui: { setStatus: () => { }, notify: () => { }, select: () => { }, confirm: async () => true, input: async () => "", setWidget: () => { } },
74
+ mode: "tui", hasUI: true, cwd: stateDir, sessionManager,
75
+ modelRegistry: {}, model: undefined,
76
+ isIdle: () => true, isProjectTrusted: () => true,
77
+ signal: undefined, abort: () => { }, hasPendingMessages: () => false, shutdown: () => { },
78
+ getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }),
79
+ compact: (opts) => {
80
+ compactCalls.push(opts);
81
+ const _sbc = handlers["session_before_compact"];
82
+ if (_sbc && _sbc.length)
83
+ return _sbc[0]({ type: "session_before_compact", reason: "threshold", willRetry: false, signal: undefined, preparation: { firstKeptEntryId: "e2", messagesToSummarize: session.slice(0, 2), tokensBefore: 500 } }, makeCtx());
84
+ return undefined;
85
+ },
86
+ getSystemPrompt: () => "system base", ...over,
87
+ };
88
+ }
89
+ const pi = {
90
+ on: (ev, h) => { if (!handlers[ev])
91
+ handlers[ev] = []; handlers[ev].push(h); },
92
+ registerCommand: () => { }, registerTool: () => { }, registerShortcut: () => { },
93
+ registerFlag: () => { }, getFlag: () => undefined,
94
+ registerMessageRenderer: () => { }, registerEntryRenderer: () => { },
95
+ sendMessage: () => { }, sendUserMessage: (m) => { sendUserMessages.push(m); },
96
+ appendEntry: (t, d) => appended.push({ t, d }),
97
+ setSessionName: () => { }, getSessionName: () => undefined, setLabel: () => { },
98
+ exec: async () => ({ stdout: "", stderr: "", code: 0 }),
99
+ getActiveTools: () => [], getAllTools: () => { }, setActiveTools: () => { },
100
+ getCommands: () => [], setModel: async () => false,
101
+ getThinkingLevel: () => "off", setThinkingLevel: () => { },
102
+ };
103
+ const mod = require("./mega-compact.js");
104
+ mod.default(pi);
105
+ return {
106
+ stateDir, handlers, appended, compactCalls, sendUserMessages,
107
+ fire: async (ev, event, ctx) => { let r; for (const h of handlers[ev] || [])
108
+ r = await h(event, ctx); return r; },
109
+ ctx: makeCtx, session,
110
+ };
111
+ }
112
+ const { classifyError: classifyErrorFn } = require("./mega-events.js");
113
+ /** S38 helper: fire a turn_end with a given stopReason + optional text, using a
114
+ * low-pressure ctx so the durable-trim branch (ctx.compact) does NOT fire. */
115
+ async function s38TurnEnd(h, stopReason, text) {
116
+ const lowCtx = h.ctx({ isIdle: () => true, hasPendingMessages: () => false, getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }) });
117
+ const message = { role: "assistant" };
118
+ if (stopReason !== undefined)
119
+ message.stopReason = stopReason;
120
+ if (text)
121
+ message.content = text;
122
+ await h.fire("turn_end", { type: "turn_end", turnIndex: 1, message }, lowCtx);
123
+ }
124
+ // ---- classifier unit tests (no extension harness needed) ----
125
+ test("S38: classifyError returns 'transient' for error/aborted stopReasons", () => {
126
+ assert.equal(classifyErrorFn({ stopReason: "error" }), "transient");
127
+ assert.equal(classifyErrorFn({ stopReason: "aborted" }), "transient");
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
+ test("S38: classifyError returns 'permanent' for auth/unauthorized text", () => {
134
+ assert.equal(classifyErrorFn("unauthorized: invalid api key"), "permanent");
135
+ assert.equal(classifyErrorFn("permission denied"), "permanent");
136
+ });
137
+ test("S38: classifyError returns null for stop/toolUse stopReasons (success)", () => {
138
+ assert.equal(classifyErrorFn({ stopReason: "stop" }), null);
139
+ assert.equal(classifyErrorFn({ stopReason: "toolUse" }), null);
140
+ assert.equal(classifyErrorFn({ stopReason: "tool_use" }), null);
141
+ });
142
+ test("S38: classifyError returns null for 'length' stopReason (S28 guard)", () => {
143
+ assert.equal(classifyErrorFn({ stopReason: "length" }), null);
144
+ });
145
+ test("S38: classifyError returns 'transient' for mid-response errors with no stopReason", () => {
146
+ assert.equal(classifyErrorFn({}), "transient");
147
+ assert.equal(classifyErrorFn({ content: [] }), "transient");
148
+ assert.equal(classifyErrorFn({ stopReason: undefined }), "transient");
149
+ assert.equal(classifyErrorFn({ stopReason: "" }), "transient");
150
+ });
151
+ test("S38: classifyError returns 'transient' for error objects with message field", () => {
152
+ assert.equal(classifyErrorFn({ error: { message: "Stream interrupted" } }), "transient");
153
+ assert.equal(classifyErrorFn({ error: { message: "Connection lost" } }), "transient");
154
+ assert.equal(classifyErrorFn({ error: { message: "500 Internal Server Error" } }), "transient");
155
+ assert.equal(classifyErrorFn({ error: "Connection lost" }), "transient");
156
+ });
157
+ test("S38: classifyError returns 'transient' for mid-response stream failures in content", () => {
158
+ assert.equal(classifyErrorFn({ content: [{ type: "text", text: "Processing... Error: connection reset" }] }), "transient");
159
+ assert.equal(classifyErrorFn({ content: [{ type: "text", text: "Here is the answer..." }], stopReason: "error" }), "transient");
160
+ });
161
+ test("S38: classifyError returns 'transient' for partial content with NO stopReason (stream died after emitting text)", () => {
162
+ // The mid-response disconnect case: provider streamed partial content then
163
+ // died without a stopReason. MUST be transient (retryable), NOT null.
164
+ assert.equal(classifyErrorFn({ role: "assistant", content: [{ type: "text", text: "partial response..." }], stopReason: undefined }), "transient");
165
+ assert.equal(classifyErrorFn({ role: "assistant", content: [{ type: "text", text: "Here is the start of the answer" }] }), "transient");
166
+ assert.equal(classifyErrorFn({ role: "assistant", content: "partial response..." }), "transient");
167
+ });
168
+ test("S38: classifyError returns null for success stopReasons even with empty content", () => {
169
+ assert.equal(classifyErrorFn({ stopReason: "stop", content: [] }), null);
170
+ assert.equal(classifyErrorFn({ stopReason: "tool_use", content: [] }), null);
171
+ });
172
+ test("S38: classifyError returns 'compaction-noop' for 'Already compacted' text", () => {
173
+ assert.equal(classifyErrorFn("Error: Already compacted"), "compaction-noop");
174
+ });
175
+ test("S38: classifyError returns 'compaction-noop' for 'Nothing to compact' text", () => {
176
+ assert.equal(classifyErrorFn("Nothing to compact (session too small)"), "compaction-noop");
177
+ });
178
+ test("S38: classifyError returns 'compaction-noop' for 'Auto compaction failed' text", () => {
179
+ assert.equal(classifyErrorFn("Auto compaction failed"), "compaction-noop");
180
+ assert.equal(classifyErrorFn("Auto-compaction failed"), "compaction-noop");
181
+ });
182
+ // ---- integration tests (fire turn_end through the real extension) ----
183
+ test("S38: compaction-noop logs 'compaction_noop_diagnostic' + resets counter + no retry fired", async () => {
184
+ const h = harness();
185
+ await s38TurnEnd(h, "error", "Already compacted");
186
+ const ev = eventTypes(h.stateDir);
187
+ assert.ok(ev.includes("compaction_noop_diagnostic"), "compaction-noop: diagnostic event logged");
188
+ assert.equal(h.sendUserMessages.length, 0, "compaction-noop: NO retry nudge fired");
189
+ });
190
+ test("S38: compaction-noop does NOT fire pi.sendUserMessage (NOT retryable)", async () => {
191
+ const h = harness();
192
+ await s38TurnEnd(h, "error", "Nothing to compact");
193
+ assert.equal(h.sendUserMessages.length, 0, "compaction-noop: no sendUserMessage");
194
+ assert.ok(eventTypes(h.stateDir).includes("compaction_noop_diagnostic"));
195
+ });
196
+ test("S38: retry fires up to max (5) for transient errors, then stops", async () => {
197
+ const h = harness();
198
+ for (let i = 0; i < 5; i++)
199
+ await s38TurnEnd(h, "error", "internal server error");
200
+ assert.equal(h.sendUserMessages.length, 5, "transient: 5 retry nudges (<= max 5)");
201
+ await s38TurnEnd(h, "error", "internal server error");
202
+ assert.equal(h.sendUserMessages.length, 5, "transient: exhausted -> no 6th nudge");
203
+ assert.ok(eventTypes(h.stateDir).includes("error_retry_exhausted"), "exhausted event logged");
204
+ });
205
+ test("S38: retry fires 1x for permanent errors then stops", async () => {
206
+ const h = harness();
207
+ await s38TurnEnd(h, "error", "invalid api key");
208
+ assert.equal(h.sendUserMessages.length, 1, "permanent: 1 retry nudge (<= max 1)");
209
+ await s38TurnEnd(h, "error", "invalid api key");
210
+ assert.equal(h.sendUserMessages.length, 1, "permanent: exhausted -> no 2nd nudge");
211
+ assert.ok(eventTypes(h.stateDir).includes("error_retry_exhausted"), "permanent exhausted logged");
212
+ });
213
+ test("S38: successful turn (stop/toolUse) resets the retry counter", async () => {
214
+ const h = harness();
215
+ await s38TurnEnd(h, "error", "5xx server error");
216
+ assert.equal(h.sendUserMessages.length, 1, "first transient: 1 nudge");
217
+ await s38TurnEnd(h, "stop");
218
+ await s38TurnEnd(h, "error", "5xx server error");
219
+ assert.equal(h.sendUserMessages.length, 2, "success reset counter -> transient fires again from count=1");
220
+ });
221
+ test("S38: error_retry_exhausted event logged when max exceeded", async () => {
222
+ const h = harness();
223
+ await s38TurnEnd(h, "error", "malformed bad request");
224
+ await s38TurnEnd(h, "error", "malformed bad request");
225
+ assert.ok(eventTypes(h.stateDir).includes("error_retry_exhausted"), "error_retry_exhausted logged");
226
+ });
227
+ test("S38: MEGACOMPACT_AUTO_RETRY_TRANSIENT_MAX=0 disables transient retries cleanly", async () => {
228
+ const prev = process.env.MEGACOMPACT_AUTO_RETRY_TRANSIENT_MAX;
229
+ process.env.MEGACOMPACT_AUTO_RETRY_TRANSIENT_MAX = "0";
230
+ try {
231
+ const h = harness();
232
+ await s38TurnEnd(h, "error", "network timeout");
233
+ assert.equal(h.sendUserMessages.length, 0, "max=0: no transient retry nudge");
234
+ assert.ok(!eventTypes(h.stateDir).includes("error_retry"), "max=0: no error_retry event");
235
+ }
236
+ finally {
237
+ if (prev === undefined)
238
+ delete process.env.MEGACOMPACT_AUTO_RETRY_TRANSIENT_MAX;
239
+ else
240
+ process.env.MEGACOMPACT_AUTO_RETRY_TRANSIENT_MAX = prev;
241
+ }
242
+ });
243
+ test("S38: retry fires for mid-response errors (no stopReason — stream died silently)", async () => {
244
+ const h = harness();
245
+ const lowCtx = h.ctx({ isIdle: () => true, hasPendingMessages: () => false, getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }) });
246
+ await h.fire("turn_end", { type: "turn_end", turnIndex: 1, message: { role: "assistant" } }, lowCtx);
247
+ assert.equal(h.sendUserMessages.length, 1, "mid-response silent failure: 1 retry nudge fired");
248
+ assert.ok(eventTypes(h.stateDir).includes("error_retry"), "error_retry event logged for mid-response failure");
249
+ });
250
+ test("S38: retry fires for error objects with message field", async () => {
251
+ const h = harness();
252
+ const lowCtx = h.ctx({ isIdle: () => true, hasPendingMessages: () => false, getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }) });
253
+ await h.fire("turn_end", { type: "turn_end", turnIndex: 1, message: { role: "assistant", error: { message: "Connection reset by peer" } } }, lowCtx);
254
+ assert.equal(h.sendUserMessages.length, 1, "error object with message: 1 retry nudge fired");
255
+ });
256
+ test("S38: retry fires for partial content with no stopReason (disconnect after emitting text)", async () => {
257
+ // The post-resume disconnect case: provider streamed partial content then
258
+ // died mid-stream with NO stopReason. Must fire a retry nudge.
259
+ const h = harness();
260
+ const lowCtx = h.ctx({ isIdle: () => true, hasPendingMessages: () => false, getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }) });
261
+ await h.fire("turn_end", {
262
+ type: "turn_end",
263
+ turnIndex: 1,
264
+ message: { role: "assistant", content: [{ type: "text", text: "Here is the start of the answer" }], stopReason: undefined },
265
+ }, lowCtx);
266
+ assert.equal(h.sendUserMessages.length, 1, "partial-content mid-response failure: 1 retry nudge fired");
267
+ assert.ok(eventTypes(h.stateDir).includes("error_retry"), "error_retry event logged for partial-content failure");
268
+ });
269
+ // ---- S38.5: race-guard strengthening (cooldown 10s->30s + deferred re-check) ---
270
+ test("S38.5: MEGACOMPACT_RACE_GUARD_STRICT=false reverts to synchronous 10s guard", async () => {
271
+ const prev = process.env.MEGACOMPACT_RACE_GUARD_STRICT;
272
+ const prevFloor = process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
273
+ process.env.MEGACOMPACT_RACE_GUARD_STRICT = "false";
274
+ process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR = "0";
275
+ try {
276
+ const h = harness();
277
+ const hiCtx = h.ctx({ isIdle: () => true, hasPendingMessages: () => false, getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }) });
278
+ await h.fire("agent_end", { type: "agent_end", messages: [] }, hiCtx);
279
+ assert.ok(h.compactCalls.length >= 1, "non-strict: synchronous ctx.compact() fired");
280
+ }
281
+ finally {
282
+ if (prev === undefined)
283
+ delete process.env.MEGACOMPACT_RACE_GUARD_STRICT;
284
+ else
285
+ process.env.MEGACOMPACT_RACE_GUARD_STRICT = prev;
286
+ if (prevFloor === undefined)
287
+ delete process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
288
+ else
289
+ process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR = prevFloor;
290
+ }
291
+ });
292
+ test("S38.5: strict (default) defers ctx.compact() via setTimeout re-check", async () => {
293
+ const prevFloor = process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
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.equal(h.compactCalls.length, 0, "strict: ctx.compact() NOT called synchronously (deferred)");
300
+ await new Promise((r) => setTimeout(r, 700));
301
+ assert.ok(h.compactCalls.length >= 1, "strict: deferred ctx.compact() fired after re-check");
302
+ }
303
+ finally {
304
+ if (prevFloor === undefined)
305
+ delete process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
306
+ else
307
+ process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR = prevFloor;
308
+ }
309
+ });
310
+ test("cleanup", async () => {
311
+ // PGlite WASM close can hang; race with a timeout to prevent 40-min hangs.
312
+ try {
313
+ await Promise.race([closeVectorIndex(), new Promise((r) => setTimeout(r, 3000))]);
314
+ }
315
+ catch { /* ignore */ }
316
+ rmSync(baseTmp, { recursive: true, force: true });
317
+ });
@@ -35,6 +35,20 @@ import { registerDbCommands } from "./mega-db-cmds.js";
35
35
  import { registerGameCommands } from "./mega-game-cmds.js";
36
36
  export default function (pi) {
37
37
  const config = loadConfig();
38
+ // S38.9: preflight env validation — check for obviously invalid values at startup.
39
+ // Non-fatal: log warnings and fall back to defaults.
40
+ if (config.autoRetryTransientMax < 0) {
41
+ console.warn('[mega-compact] MEGACOMPACT_AUTO_RETRY_TRANSIENT_MAX must be >= 0; using default 5');
42
+ config.autoRetryTransientMax = 5;
43
+ }
44
+ if (config.autoRetryPermanentMax < 0) {
45
+ console.warn('[mega-compact] MEGACOMPACT_AUTO_RETRY_PERMANENT_MAX must be >= 0; using default 1');
46
+ config.autoRetryPermanentMax = 1;
47
+ }
48
+ if (config.maxConsecutiveErrors < 1) {
49
+ console.warn('[mega-compact] MEGACOMPACT_MAX_CONSECUTIVE_ERRORS must be >= 1; using default 10');
50
+ config.maxConsecutiveErrors = 10;
51
+ }
38
52
  const runtime = new MegaRuntime(config);
39
53
  registerEventHandlers(pi, runtime, config);
40
54
  registerCommands(pi, runtime, config);
@@ -144,8 +144,9 @@ function harness(opts = {}) {
144
144
  // session_before_compact handler (where WE supply the durable trim).
145
145
  compact: (opts) => {
146
146
  compactCalls.push(opts);
147
- if (handlers["session_before_compact"]) {
148
- return handlers["session_before_compact"]({
147
+ const _sbc = handlers["session_before_compact"];
148
+ if (_sbc && _sbc.length) {
149
+ return _sbc[0]({
149
150
  type: "session_before_compact",
150
151
  reason: "threshold",
151
152
  willRetry: false,
@@ -167,7 +168,9 @@ function harness(opts = {}) {
167
168
  }
168
169
  const pi = {
169
170
  on: (ev, h) => {
170
- handlers[ev] = h;
171
+ if (!handlers[ev])
172
+ handlers[ev] = [];
173
+ handlers[ev].push(h);
171
174
  },
172
175
  registerCommand: (name, opts) => {
173
176
  commands[name] = opts;
@@ -209,7 +212,8 @@ function harness(opts = {}) {
209
212
  notifies,
210
213
  compactCalls,
211
214
  sendUserMessages,
212
- fire: (ev, event, ctx) => handlers[ev](event, ctx),
215
+ fire: async (ev, event, ctx) => { let r; for (const h of handlers[ev] || [])
216
+ r = await h(event, ctx); return r; },
213
217
  ctx: makeCtx,
214
218
  session,
215
219
  };
@@ -235,6 +239,11 @@ test("auto-trigger (legacy): past threshold persists a chkpt and starts a durabl
235
239
  }),
236
240
  });
237
241
  const res = await h.fire("context", { type: "context", messages }, ctx);
242
+ // S38.5: the strict race guard (default) defers ctx.compact() via a
243
+ // setTimeout(500) re-check. The synchronous return is still undefined
244
+ // (no local drop), but ctx.compact() only lands after the timer — wait
245
+ // for it before asserting the durable-trim call count.
246
+ await new Promise((r) => setTimeout(r, 700));
238
247
  // L1->L4 ran: a checkpoint was persisted to the SQLite store + a marker entry written.
239
248
  const { listCheckpoints } = await import("../src/store/sqlite.js");
240
249
  assert.ok(listCheckpoints("sess_ext_001", h.stateDir).length > 0, "checkpoint persisted to local vector db");
@@ -919,23 +928,36 @@ test("S28: length_stop + length_stop_continue dashboard events fire on the right
919
928
  assert.ok(afterAgentEnd.includes("length_stop_continue"), "length stop: length_stop_continue dashboard event fired on agent_end");
920
929
  assert.equal(h.sendUserMessages.length, 1, "length stop: exactly one nudge");
921
930
  });
922
- test("S28: non-length stopReasons do not arm the flag (no nudge, no length_stop event)", async () => {
931
+ test("S28: non-length stopReasons do not arm the length-stop flag (no length_stop event); S38 owns error/aborted retries", async () => {
923
932
  const h = harness();
924
933
  const lowPressureCtx = h.ctx({
925
934
  isIdle: () => true,
926
935
  hasPendingMessages: () => false,
927
936
  getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }),
928
937
  });
929
- // Every other pi-ai StopReason must leave the flag unset → no nudge + no event.
938
+ // S28 contract: every non-length StopReason must leave lengthStopPending unset,
939
+ // so no length_stop event fires and no length-stop resume nudge is armed.
940
+ // (S38 separately owns error/aborted — it WILL fire error-retry nudges for those;
941
+ // this test only asserts the S28 length-stop flag is not armed.)
942
+ let transientRetries = 0;
930
943
  for (const stopReason of ["tool_use", "error", "aborted"]) {
944
+ const before = h.sendUserMessages.length;
931
945
  await h.fire("turn_end", {
932
946
  type: "turn_end",
933
947
  turnIndex: 1,
934
948
  message: { role: "assistant", stopReason },
935
949
  }, lowPressureCtx);
936
950
  await h.fire("agent_end", { type: "agent_end", messages: [] }, lowPressureCtx);
951
+ // tool_use is success (0 nudges); error/aborted are transient under S38 (1 each).
952
+ if (stopReason === "tool_use") {
953
+ assert.equal(h.sendUserMessages.length - before, 0, "tool_use (success): no nudge");
954
+ }
955
+ else {
956
+ transientRetries += h.sendUserMessages.length - before;
957
+ }
937
958
  }
938
- assert.equal(h.sendUserMessages.length, 0, "non-length stopReasons: no nudge");
959
+ // error + aborted each produced one S38 transient retry nudge (2 total).
960
+ assert.equal(transientRetries, 2, "S38 owns error/aborted: 1 transient retry each");
939
961
  assert.ok(!eventTypes(h.stateDir).includes("length_stop"), "non-length stopReasons: no length_stop dashboard event");
940
962
  });
941
963
  // ---- S29: percent-based auto-compact trigger (gate on context %, not tokens) -
@@ -1078,6 +1100,13 @@ test("cleanup", async () => {
1078
1100
  // Terminate the global PGlite cross-repo index (WASM worker thread) so the
1079
1101
  // test process can exit. Without this, node --test never returns even though
1080
1102
  // every test passed — the leaked worker keeps the event loop alive.
1081
- await closeVectorIndex();
1103
+ // Race with a timeout to prevent 40-min hangs if PGlite WASM close stalls.
1104
+ try {
1105
+ await Promise.race([
1106
+ closeVectorIndex(),
1107
+ new Promise((r) => setTimeout(r, 3000)),
1108
+ ]);
1109
+ }
1110
+ catch { /* ignore */ }
1082
1111
  rmSync(baseTmp, { recursive: true, force: true });
1083
1112
  });
@@ -140,6 +140,11 @@ export function loadConfig() {
140
140
  auto: envBool("MEGACOMPACT_AUTO", true),
141
141
  autoInline: envBool("MEGACOMPACT_AUTO_INLINE", true),
142
142
  autoContinueLengthStop: envBool("MEGACOMPACT_AUTO_CONTINUE_LENGTH_STOP", true),
143
+ autoRetryTransientMax: envFlag("MEGACOMPACT_AUTO_RETRY_TRANSIENT_MAX", 5),
144
+ autoRetryPermanentMax: envFlag("MEGACOMPACT_AUTO_RETRY_PERMANENT_MAX", 1),
145
+ raceGuardStrict: envBool("MEGACOMPACT_RACE_GUARD_STRICT", true),
146
+ maxConsecutiveErrors: envFlag("MEGACOMPACT_MAX_CONSECUTIVE_ERRORS", 10),
147
+ errorRetryHardStop: envBool("MEGACOMPACT_ERROR_RETRY_HARD_STOP", false),
143
148
  autoPctTrigger,
144
149
  autoInlineK: envFlag("MEGACOMPACT_AUTO_INLINE_K", 3),
145
150
  dedupSim: Number(process.env.MEGACOMPACT_DEDUP_SIM ?? "0.9"),