pi-mega-compact 0.8.24 → 0.8.25

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 (111) hide show
  1. package/README.md +26 -0
  2. package/dist/extensions/mega-compact-s38.test.js +263 -14
  3. package/dist/extensions/mega-compact.js +15 -0
  4. package/dist/extensions/mega-config.js +3 -0
  5. package/dist/extensions/mega-events/agent-handlers.js +211 -26
  6. package/dist/extensions/mega-events/context-handler.js +45 -7
  7. package/dist/extensions/mega-events/error-classifier.js +125 -18
  8. package/dist/extensions/mega-pipeline/compact.js +24 -13
  9. package/dist/extensions/mega-pipeline/recall.js +31 -2
  10. package/dist/extensions/mega-runtime/dashboard-snapshot.js +4 -0
  11. package/dist/extensions/mega-runtime/runtime-snapshot.js +4 -0
  12. package/dist/extensions/mega-runtime/runtime.js +58 -5
  13. package/dist/src/boundary.js +79 -43
  14. package/dist/src/boundary.test.js +119 -2
  15. package/dist/src/canary.js +10 -0
  16. package/dist/src/config/dedup.js +14 -0
  17. package/dist/src/config.js +3 -1
  18. package/dist/src/dedup/raptor/buildHistory.js +164 -0
  19. package/dist/src/dedup/raptor/buildHistory.test.js +292 -0
  20. package/dist/src/dedup/raptor/index.js +38 -0
  21. package/dist/src/dedup/raptor/multilevel-serve.test.js +229 -0
  22. package/dist/src/dedup/raptor/multilevel.js +17 -5
  23. package/dist/src/dedup/raptor/multilevel.test.js +36 -1
  24. package/dist/src/dedup/raptor/raptor.test.js +43 -0
  25. package/dist/src/dedup/raptor/retrieval.js +14 -2
  26. package/dist/src/dedup/raptor/retrieval.test.js +95 -0
  27. package/dist/src/dedup/raptor/serve-gate.test.js +298 -0
  28. package/dist/src/dedup/raptor/summarizer.js +1 -0
  29. package/dist/src/dedup/raptor/tree.js +16 -2
  30. package/dist/src/engine.js +18 -2
  31. package/dist/src/httpEmbedder.js +96 -6
  32. package/dist/src/httpEmbedder.test.js +277 -0
  33. package/dist/src/mechanical-fix.test.js +65 -0
  34. package/dist/src/raptor-inject-summaries.test.js +155 -0
  35. package/dist/src/recall.js +135 -21
  36. package/dist/src/recall.test.js +179 -4
  37. package/dist/src/store/sqlite/dedup-mirror.js +32 -15
  38. package/dist/src/store/sqlite/maintenance.js +2 -2
  39. package/dist/src/store/sqlite/mechanical-fix.test.js +146 -0
  40. package/dist/src/store/sqlite/memories.js +5 -5
  41. package/dist/src/store/sqlite/meta.js +1 -1
  42. package/dist/src/store/sqlite/raptor.js +56 -17
  43. package/dist/src/store/sqlite/raptor.test.js +106 -0
  44. package/dist/src/store/sqlite/schema.js +90 -1
  45. package/dist/src/store/sqlite/session-state.js +9 -3
  46. package/dist/src/store/sqlite/stats.js +9 -5
  47. package/dist/src/store/sqlite/turns.js +181 -0
  48. package/dist/src/store/sqlite/turns.test.js +183 -0
  49. package/dist/src/store/sqlite/utils.js +15 -4
  50. package/dist/src/store/sqlite.js +1 -0
  51. package/dist/src/store.js +2 -2
  52. package/dist/src/vector-search-cache.test.js +157 -0
  53. package/dist/src/vector-search.js +107 -15
  54. package/dist/src/vectorStore.js +36 -8
  55. package/extensions/mega-compact-s38.test.ts +259 -14
  56. package/extensions/mega-compact.ts +15 -0
  57. package/extensions/mega-config.ts +18 -0
  58. package/extensions/mega-dashboard.ts +10 -1
  59. package/extensions/mega-events/agent-handlers.ts +211 -26
  60. package/extensions/mega-events/context-handler.ts +43 -7
  61. package/extensions/mega-events/error-classifier.ts +125 -17
  62. package/extensions/mega-pipeline/compact.ts +28 -16
  63. package/extensions/mega-pipeline/recall.ts +34 -2
  64. package/extensions/mega-runtime/dashboard-snapshot.ts +8 -0
  65. package/extensions/mega-runtime/helpers.ts +25 -1
  66. package/extensions/mega-runtime/runtime-snapshot.ts +4 -0
  67. package/extensions/mega-runtime/runtime.ts +69 -23
  68. package/package.json +1 -1
  69. package/src/boundary.test.ts +128 -2
  70. package/src/boundary.ts +75 -39
  71. package/src/canary.ts +10 -0
  72. package/src/config/dedup.ts +25 -0
  73. package/src/config.ts +3 -1
  74. package/src/dedup/raptor/buildHistory.test.ts +353 -0
  75. package/src/dedup/raptor/buildHistory.ts +259 -0
  76. package/src/dedup/raptor/index.ts +38 -0
  77. package/src/dedup/raptor/multilevel-serve.test.ts +273 -0
  78. package/src/dedup/raptor/multilevel.test.ts +47 -0
  79. package/src/dedup/raptor/multilevel.ts +18 -8
  80. package/src/dedup/raptor/raptor.test.ts +59 -0
  81. package/src/dedup/raptor/retrieval.test.ts +118 -0
  82. package/src/dedup/raptor/retrieval.ts +14 -2
  83. package/src/dedup/raptor/serve-gate.test.ts +348 -0
  84. package/src/dedup/raptor/summarizer.ts +1 -0
  85. package/src/dedup/raptor/tree.ts +17 -2
  86. package/src/engine.ts +32 -3
  87. package/src/httpEmbedder.test.ts +286 -0
  88. package/src/httpEmbedder.ts +98 -8
  89. package/src/mechanical-fix.test.ts +70 -0
  90. package/src/raptor-inject-summaries.test.ts +212 -0
  91. package/src/recall.test.ts +220 -4
  92. package/src/recall.ts +151 -22
  93. package/src/store/sqlite/dedup-mirror.ts +35 -18
  94. package/src/store/sqlite/maintenance.ts +2 -2
  95. package/src/store/sqlite/mechanical-fix.test.ts +162 -0
  96. package/src/store/sqlite/memories.ts +5 -5
  97. package/src/store/sqlite/meta.ts +1 -1
  98. package/src/store/sqlite/raptor.test.ts +139 -0
  99. package/src/store/sqlite/raptor.ts +135 -81
  100. package/src/store/sqlite/schema.ts +90 -1
  101. package/src/store/sqlite/session-state.ts +9 -3
  102. package/src/store/sqlite/stats.ts +10 -8
  103. package/src/store/sqlite/turns.test.ts +218 -0
  104. package/src/store/sqlite/turns.ts +292 -0
  105. package/src/store/sqlite/utils.ts +14 -4
  106. package/src/store/sqlite.ts +1 -0
  107. package/src/store.ts +9 -2
  108. package/src/vector-search-cache.test.ts +190 -0
  109. package/src/vector-search.ts +273 -156
  110. package/src/vectorStore.ts +443 -382
  111. package/extensions/mega-runtime/reset-runtime.ts +0 -80
package/README.md CHANGED
@@ -86,6 +86,32 @@ npm run lint # Type check + guardrails scan
86
86
 
87
87
  Testing guide: [`TESTER_GUIDE.md`](TESTER_GUIDE.md)
88
88
 
89
+ ## Troubleshooting
90
+
91
+ ### npm lifecycle scripts disabled
92
+
93
+ Some package managers disable lifecycle scripts by default. PGlite and `@mongodb-js/zstd` need scripts enabled to build their native components.
94
+
95
+ ```bash
96
+ # Re-enable install scripts
97
+ npm config set ignore-scripts false
98
+ npm install
99
+
100
+ # Or allow scripts for specific packages only
101
+ npm install --install-strategy=linked
102
+ ```
103
+
104
+ ### node:sqlite not found / experimental flag required
105
+
106
+ pi-mega-compact requires Node ≥22.13 for the built-in `node:sqlite` module. If you're on an older version or your Node build doesn't include it by default:
107
+
108
+ ```bash
109
+ export NODE_OPTIONS="--experimental-sqlite"
110
+ pi
111
+ ```
112
+
113
+ Add the export to your shell profile (`.bashrc`, `.zshrc`, etc.) to make it permanent.
114
+
89
115
  ## License
90
116
 
91
117
  BSD 3-Clause
@@ -121,6 +121,18 @@ async function s38TurnEnd(h, stopReason, text) {
121
121
  message.content = text;
122
122
  await h.fire("turn_end", { type: "turn_end", turnIndex: 1, message }, lowCtx);
123
123
  }
124
+ /** R3 helper: like s38TurnEnd but attaches a `usage` object so the classifier's
125
+ * 0-token poisoned-context signal is exercised (usage PRESENT with 0 tokens).
126
+ * Pass `tokens` > 0 to simulate a turn that reached the model. */
127
+ async function s38TurnEndUsage(h, stopReason, text, tokens) {
128
+ const lowCtx = h.ctx({ isIdle: () => true, hasPendingMessages: () => false, getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }) });
129
+ const message = { role: "assistant", usage: { inputTokens: tokens, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } };
130
+ if (stopReason !== undefined)
131
+ message.stopReason = stopReason;
132
+ if (text)
133
+ message.content = text;
134
+ await h.fire("turn_end", { type: "turn_end", turnIndex: 1, message }, lowCtx);
135
+ }
124
136
  // ---- classifier unit tests (no extension harness needed) ----
125
137
  test("S38: classifyError returns 'transient' for error stopReason", () => {
126
138
  assert.equal(classifyErrorFn({ stopReason: "error" }), "transient");
@@ -252,14 +264,21 @@ test("S38: compaction-noop does NOT fire pi.sendUserMessage (NOT retryable)", as
252
264
  assert.equal(h.sendUserMessages.length, 0, "compaction-noop: no sendUserMessage");
253
265
  assert.ok(eventTypes(h.stateDir).includes("compaction_noop_diagnostic"));
254
266
  });
255
- test("S38: retry fires up to max (5) for transient errors, then stops", async () => {
267
+ test("S38: R1 burst of immediate transient errors fires 1 nudge (dedup), rest suppressed by retryNudgePending", async () => {
268
+ // R1 redesign: a burst of immediate error turn_ends (no turn_start between)
269
+ // produces ONE nudge — the rest are suppressed by retryNudgePending because
270
+ // the queued nudge (deliverAs:'followUp') has not been consumed by a new
271
+ // agent turn. errorRetryCount still advances for each error turn, so the
272
+ // per-burst max + circuit breaker still bound the burst.
256
273
  const h = harness();
257
274
  for (let i = 0; i < 5; i++)
258
- await s38TurnEnd(h, "error", "internal server error");
259
- assert.equal(h.sendUserMessages.length, 5, "transient: 5 retry nudges (<= max 5)");
260
- await s38TurnEnd(h, "error", "internal server error");
261
- assert.equal(h.sendUserMessages.length, 5, "transient: exhausted -> no 6th nudge");
262
- assert.ok(eventTypes(h.stateDir).includes("error_retry_exhausted"), "exhausted event logged");
275
+ await s38TurnEnd(h, "error", `internal server error ${i}`);
276
+ assert.equal(h.sendUserMessages.length, 1, "R1 dedup: 1 nudge in burst (rest suppressed)");
277
+ // The 6th turn reaches count=6 > max=5 → exhausted (count advances even for dedup'd turns).
278
+ await s38TurnEnd(h, "error", "internal server error 5");
279
+ assert.equal(h.sendUserMessages.length, 1, "exhausted: still 1 nudge (no 6th)");
280
+ assert.ok(eventTypes(h.stateDir).includes("error_retry_exhausted"), "exhausted event logged on max+1");
281
+ assert.ok(eventTypes(h.stateDir).includes("error_retry_dedup_skip"), "dedup_skip events logged for suppressed turns");
263
282
  });
264
283
  test("S38: retry fires 1x for permanent errors then stops", async () => {
265
284
  const h = harness();
@@ -270,17 +289,33 @@ test("S38: retry fires 1x for permanent errors then stops", async () => {
270
289
  assert.ok(eventTypes(h.stateDir).includes("error_retry_exhausted"), "permanent exhausted logged");
271
290
  });
272
291
  test("S38: successful turn (stop/toolUse) resets the retry counter", async () => {
273
- const h = harness();
274
- await s38TurnEnd(h, "error", "5xx server error");
275
- assert.equal(h.sendUserMessages.length, 1, "first transient: 1 nudge");
276
- await s38TurnEnd(h, "stop");
277
- await s38TurnEnd(h, "error", "5xx server error");
278
- assert.equal(h.sendUserMessages.length, 2, "success reset counter -> transient fires again from count=1");
292
+ // R1: backoff is now gating, so use a tiny backoff + small wait to let the
293
+ // second nudge fire after the success reset clears retryNudgePending (R4).
294
+ const prev = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
295
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
296
+ try {
297
+ const h = harness();
298
+ await s38TurnEnd(h, "error", "5xx server error 0");
299
+ assert.equal(h.sendUserMessages.length, 1, "first transient: 1 nudge");
300
+ await s38TurnEnd(h, "stop");
301
+ assert.equal(h.sendUserMessages.length, 1, "success: no nudge, resets pending (R4)");
302
+ await new Promise((r) => setTimeout(r, 5)); // let backoff elapse
303
+ await s38TurnEnd(h, "error", "5xx server error 1");
304
+ assert.equal(h.sendUserMessages.length, 2, "success reset -> transient fires again from count=1");
305
+ }
306
+ finally {
307
+ if (prev === undefined)
308
+ delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
309
+ else
310
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prev;
311
+ }
279
312
  });
280
313
  test("S38: error_retry_exhausted event logged when max exceeded", async () => {
314
+ // R3: "malformed bad request" is now poisoned-context (not permanent), so
315
+ // use an auth-derived permanent error to exercise the per-burst exhausted path.
281
316
  const h = harness();
282
- await s38TurnEnd(h, "error", "malformed bad request");
283
- await s38TurnEnd(h, "error", "malformed bad request");
317
+ await s38TurnEnd(h, "error", "unauthorized: invalid api key");
318
+ await s38TurnEnd(h, "error", "unauthorized: invalid api key");
284
319
  assert.ok(eventTypes(h.stateDir).includes("error_retry_exhausted"), "error_retry_exhausted logged");
285
320
  });
286
321
  test("S38: MEGACOMPACT_AUTO_RETRY_TRANSIENT_MAX=0 disables transient retries cleanly", async () => {
@@ -366,6 +401,213 @@ test("S38.5: strict (default) defers ctx.compact() via setTimeout re-check", asy
366
401
  process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR = prevFloor;
367
402
  }
368
403
  });
404
+ // ---- R3 classifier unit tests: poisoned-context signals ----
405
+ test("R3 classifier: 0-token generic error (usage present, 0 tokens) → poisoned-context", () => {
406
+ // The 2026-07-28 incident: stopReason 'error' + usage 0 tokens. The turn
407
+ // never reached the model; retrying re-submits the same poisoned context.
408
+ assert.equal(classifyErrorFn({ stopReason: "error", usage: { inputTokens: 0, outputTokens: 0 } }), "poisoned-context");
409
+ // Bare stopReason 'error' with NO usage field stays transient (unknown tokens
410
+ // — conservative; preserves the pre-R3 mid-response/partial-content behavior).
411
+ assert.equal(classifyErrorFn({ stopReason: "error" }), "transient");
412
+ });
413
+ test("R3 classifier: ECONNRESET (0-token) → transient (network failures stay transient)", () => {
414
+ // R3: network failures must stay transient even with 0 tokens.
415
+ assert.equal(classifyErrorFn({ stopReason: "error", content: "ECONNRESET", usage: { inputTokens: 0, outputTokens: 0 } }), "transient");
416
+ assert.equal(classifyErrorFn({ stopReason: "error", content: "connection reset by peer", usage: { inputTokens: 0, outputTokens: 0 } }), "transient");
417
+ assert.equal(classifyErrorFn({ stopReason: "error", content: "timeout", usage: { inputTokens: 0, outputTokens: 0 } }), "transient");
418
+ assert.equal(classifyErrorFn({ stopReason: "error", content: "503 service unavailable", usage: { inputTokens: 0, outputTokens: 0 } }), "transient");
419
+ });
420
+ test("R3 classifier: 'request failed' generic (no transient marker) → poisoned-context", () => {
421
+ // The exact incident phrasing: "Request failed — please retry." with no
422
+ // specific transient cause → deterministic rejection.
423
+ assert.equal(classifyErrorFn({ stopReason: "error", content: "Request failed — please retry.", usage: { inputTokens: 0, outputTokens: 0 } }), "poisoned-context");
424
+ assert.equal(classifyErrorFn("request failed"), "poisoned-context");
425
+ });
426
+ test("R3 classifier: orphaned-tool-result 400 (non-overflow) → poisoned-context", () => {
427
+ // Provider request-validation 400 that is NOT context-overflow: orphaned
428
+ // tool result / malformed message structure. Previously 'permanent' (1
429
+ // retry), now 'poisoned-context' (retry re-submits the same malformed shape).
430
+ assert.equal(classifyErrorFn('{"type":"invalid_request_error","message":"orphaned tool result: tooluse ids mismatch"}'), "poisoned-context");
431
+ assert.equal(classifyErrorFn("invalid request: unexpected role ordering"), "poisoned-context");
432
+ assert.equal(classifyErrorFn("malformed message structure"), "poisoned-context");
433
+ });
434
+ test("R3 classifier: context-overflow phrasing still context-overflow (not poisoned)", () => {
435
+ // Regression guard: the context-overflow check runs BEFORE the poisoned
436
+ // signals, so a 400 "too long" stays context-overflow (forced re-compact),
437
+ // not poisoned (advise + compact).
438
+ assert.equal(classifyErrorFn({ stopReason: "error", content: "Your conversation is too long for this model's context window even after compaction.", usage: { inputTokens: 0, outputTokens: 0 } }), "context-overflow");
439
+ assert.equal(classifyErrorFn('{"type":"invalid_request_error","message":"maximum context length is 200000 tokens. requires at least 201070 tokens."}'), "context-overflow");
440
+ });
441
+ test("R3 classifier: auth/permission stays permanent (not poisoned)", () => {
442
+ // Auth errors are retryable-once (permanent), not poisoned — the user can
443
+ // fix the key and retry.
444
+ assert.equal(classifyErrorFn("unauthorized: invalid api key"), "permanent");
445
+ assert.equal(classifyErrorFn("permission denied"), "permanent");
446
+ });
447
+ // ---- R6 integration tests (retry redesign) ----
448
+ test("R6(a): 10 consecutive identical 0-token transient failures produce at most errorRetrySessionMax nudges", async () => {
449
+ // Use TRANSIENT 0-token failures (network text so they're transient, not
450
+ // poisoned) + turn_start + tiny backoff between each so the nudge is
451
+ // consumed and the next turn can fire. sessionMax default = 3. Repeat
452
+ // threshold raised to disable the stateful poisoned upgrade so this
453
+ // exercises the SESSION CAP, not the repeat signal.
454
+ const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
455
+ const prevRepeat = process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
456
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
457
+ process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = "999";
458
+ try {
459
+ const h = harness();
460
+ for (let i = 0; i < 10; i++) {
461
+ // 0-token transient: usage present with 0 tokens + "connection reset" (network marker).
462
+ await s38TurnEndUsage(h, "error", "connection reset", 0);
463
+ await h.fire("turn_start", { type: "turn_start", turnIndex: i + 2 }, h.ctx());
464
+ await new Promise((r) => setTimeout(r, 3));
465
+ }
466
+ assert.ok(h.sendUserMessages.length <= 3, `R6(a): at most sessionMax (3) nudges, got ${h.sendUserMessages.length}`);
467
+ assert.ok(eventTypes(h.stateDir).includes("error_retry_session_exhausted"), "session_exhausted event logged");
468
+ }
469
+ finally {
470
+ if (prevBackoff === undefined)
471
+ delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
472
+ else
473
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prevBackoff;
474
+ if (prevRepeat === undefined)
475
+ delete process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
476
+ else
477
+ process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = prevRepeat;
478
+ }
479
+ });
480
+ test("R6(b): poisoned-context fires zero retry nudges and exactly one advise message", async () => {
481
+ // auto=false so the guarded compact attempt (R3c) is skipped — this test
482
+ // focuses on the advise + no-retry behavior. The compact path is the same
483
+ // race-guarded deferred mechanism already covered by the context-overflow tests.
484
+ const prevAuto = process.env.MEGACOMPACT_AUTO;
485
+ process.env.MEGACOMPACT_AUTO = "false";
486
+ try {
487
+ const h = harness();
488
+ // 0-token generic "request failed" (no transient marker) → poisoned.
489
+ await s38TurnEndUsage(h, "error", "Request failed — please retry.", 0);
490
+ assert.equal(h.sendUserMessages.length, 1, "poisoned: exactly one advise message");
491
+ assert.ok(h.sendUserMessages[0].includes("/clear") || h.sendUserMessages[0].includes("/new"), "advise mentions /clear or /new");
492
+ assert.ok(eventTypes(h.stateDir).includes("poisoned_context"), "poisoned_context event logged");
493
+ assert.ok(!eventTypes(h.stateDir).includes("error_retry"), "poisoned: zero retry nudges (no error_retry event)");
494
+ // Second poisoned turn: advise throttled to one per session.
495
+ await s38TurnEndUsage(h, "error", "Request failed — please retry.", 0);
496
+ assert.equal(h.sendUserMessages.length, 1, "poisoned: advise throttled (one per session)");
497
+ assert.ok(eventTypes(h.stateDir).filter((t) => t === "poisoned_context").length >= 2, "poisoned_context logged each turn");
498
+ }
499
+ finally {
500
+ if (prevAuto === undefined)
501
+ delete process.env.MEGACOMPACT_AUTO;
502
+ else
503
+ process.env.MEGACOMPACT_AUTO = prevAuto;
504
+ }
505
+ });
506
+ test("R6(c): transient burst retries with backoff gating — second immediate nudge suppressed while one pending", async () => {
507
+ const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
508
+ const prevSession = process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX;
509
+ const prevRepeat = process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
510
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
511
+ process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX = "999"; // don't let session cap bind
512
+ process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = "999"; // don't let repeat upgrade bind
513
+ try {
514
+ const h = harness();
515
+ // Turn 1: transient → nudge 1 fires (pending=true, backoff=1ms).
516
+ await s38TurnEnd(h, "error", "internal server error 0");
517
+ assert.equal(h.sendUserMessages.length, 1, "first transient: 1 nudge");
518
+ // Turn 2: immediate (no turn_start) → suppressed by retryNudgePending.
519
+ await s38TurnEnd(h, "error", "internal server error 1");
520
+ assert.equal(h.sendUserMessages.length, 1, "second immediate nudge suppressed (retryNudgePending)");
521
+ // turn_start consumes the pending nudge (resets pending + count).
522
+ await h.fire("turn_start", { type: "turn_start", turnIndex: 2 }, h.ctx());
523
+ await new Promise((r) => setTimeout(r, 5)); // let backoff elapse
524
+ // Turn 3: transient → nudge 2 fires (pending cleared, backoff elapsed).
525
+ await s38TurnEnd(h, "error", "internal server error 2");
526
+ assert.equal(h.sendUserMessages.length, 2, "after turn_start + backoff: nudge fires");
527
+ }
528
+ finally {
529
+ if (prevBackoff === undefined)
530
+ delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
531
+ else
532
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prevBackoff;
533
+ if (prevSession === undefined)
534
+ delete process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX;
535
+ else
536
+ process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX = prevSession;
537
+ if (prevRepeat === undefined)
538
+ delete process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
539
+ else
540
+ process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = prevRepeat;
541
+ }
542
+ });
543
+ test("R6(d): user abort (stopReason aborted) never nudges, even across repeated aborts", async () => {
544
+ const h = harness();
545
+ await s38TurnEnd(h, "aborted", "Operation aborted");
546
+ assert.equal(h.sendUserMessages.length, 0, "aborted: no nudge");
547
+ await s38TurnEnd(h, "aborted", "Aborted after 3 retry attempts");
548
+ assert.equal(h.sendUserMessages.length, 0, "aborted: still no nudge after repeated aborts");
549
+ assert.ok(eventTypes(h.stateDir).includes("error_retry_cancelled"), "cancelled event logged");
550
+ });
551
+ test("R6(e): success resets retry-nudge-pending state", async () => {
552
+ const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
553
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
554
+ try {
555
+ const h = harness();
556
+ // Turn 1: transient → nudge fires, pending=true.
557
+ await s38TurnEnd(h, "error", "internal server error 0");
558
+ assert.equal(h.sendUserMessages.length, 1, "first transient: 1 nudge");
559
+ // Turn 2: immediate transient → suppressed by pending (no turn_start).
560
+ await s38TurnEnd(h, "error", "internal server error 1");
561
+ assert.equal(h.sendUserMessages.length, 1, "second immediate suppressed by pending");
562
+ // Turn 3: success (stop) → resets pending (R4), no nudge.
563
+ await s38TurnEnd(h, "stop");
564
+ assert.equal(h.sendUserMessages.length, 1, "success: no nudge, resets pending");
565
+ await new Promise((r) => setTimeout(r, 5)); // let backoff elapse
566
+ // Turn 4: transient → nudge fires again (pending was reset by success).
567
+ await s38TurnEnd(h, "error", "internal server error 2");
568
+ assert.equal(h.sendUserMessages.length, 2, "after success reset pending: transient nudge fires");
569
+ }
570
+ finally {
571
+ if (prevBackoff === undefined)
572
+ delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
573
+ else
574
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prevBackoff;
575
+ }
576
+ });
577
+ test("R3: repeated identical transient error text upgrades to poisoned-context at threshold", async () => {
578
+ // The stateful repeat signal: 3 consecutive identical transient errors
579
+ // (default threshold) upgrade to poisoned. Uses "5xx server error" (5xx
580
+ // marker → transient) so the classifier returns transient, then the repeat
581
+ // tracker upgrades it. auto=false to skip the compact attempt.
582
+ const prevAuto = process.env.MEGACOMPACT_AUTO;
583
+ const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
584
+ process.env.MEGACOMPACT_AUTO = "false";
585
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
586
+ try {
587
+ const h = harness();
588
+ // Turns 1-2: transient (repeatCount 1, 2) → nudges fire.
589
+ await s38TurnEnd(h, "error", "5xx server error");
590
+ await h.fire("turn_start", { type: "turn_start", turnIndex: 2 }, h.ctx());
591
+ await new Promise((r) => setTimeout(r, 3));
592
+ await s38TurnEnd(h, "error", "5xx server error");
593
+ await h.fire("turn_start", { type: "turn_start", turnIndex: 3 }, h.ctx());
594
+ await new Promise((r) => setTimeout(r, 3));
595
+ // Turn 3: repeatCount=3 ≥ threshold → upgraded to poisoned. No nudge, advise fires.
596
+ await s38TurnEnd(h, "error", "5xx server error");
597
+ assert.ok(eventTypes(h.stateDir).includes("poisoned_context"), "repeat threshold reached: poisoned_context event logged");
598
+ assert.ok(h.sendUserMessages.some((m) => m.includes("/clear") || m.includes("/new")), "repeat threshold: advise message fired");
599
+ }
600
+ finally {
601
+ if (prevAuto === undefined)
602
+ delete process.env.MEGACOMPACT_AUTO;
603
+ else
604
+ process.env.MEGACOMPACT_AUTO = prevAuto;
605
+ if (prevBackoff === undefined)
606
+ delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
607
+ else
608
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prevBackoff;
609
+ }
610
+ });
369
611
  test("cleanup", async () => {
370
612
  // PGlite WASM close can hang; race with a timeout to prevent 40-min hangs.
371
613
  try {
@@ -373,4 +615,11 @@ test("cleanup", async () => {
373
615
  }
374
616
  catch { /* ignore */ }
375
617
  rmSync(baseTmp, { recursive: true, force: true });
618
+ // Force-exit: each harness() creates a MegaRuntime with an fs.watch
619
+ // game-state watcher that is never disposed (no session_shutdown in tests).
620
+ // Those handles keep the event loop alive indefinitely after all tests
621
+ // complete, so `node --test` (without --test-force-exit) would hang. The
622
+ // streaming reporter has already printed every test result by this point;
623
+ // process.exit(0) just forces the exit the watchers are preventing.
624
+ process.exit(0);
376
625
  });
@@ -49,6 +49,21 @@ export default function (pi) {
49
49
  console.warn('[mega-compact] MEGACOMPACT_MAX_CONSECUTIVE_ERRORS must be >= 1; using default 10');
50
50
  config.maxConsecutiveErrors = 10;
51
51
  }
52
+ // R2: session-global cap validation — 0 disables (valid), negative is invalid.
53
+ if (config.errorRetrySessionMax < 0) {
54
+ console.warn('[mega-compact] MEGACOMPACT_ERROR_RETRY_SESSION_MAX must be >= 0; using default 3');
55
+ config.errorRetrySessionMax = 3;
56
+ }
57
+ // R1: backoff base must be >= 0 (0 means no gating, useful for tests).
58
+ if (config.errorRetryBackoffMs < 0) {
59
+ console.warn('[mega-compact] MEGACOMPACT_ERROR_RETRY_BACKOFF_MS must be >= 0; using default 5000');
60
+ config.errorRetryBackoffMs = 5000;
61
+ }
62
+ // R3: repeat threshold must be >= 1.
63
+ if (config.poisonedContextRepeatThreshold < 1) {
64
+ console.warn('[mega-compact] MEGACOMPACT_POISONED_REPEAT_THRESHOLD must be >= 1; using default 3');
65
+ config.poisonedContextRepeatThreshold = 3;
66
+ }
52
67
  const runtime = new MegaRuntime(config);
53
68
  registerEventHandlers(pi, runtime, config);
54
69
  registerCommands(pi, runtime, config);
@@ -145,6 +145,9 @@ export function loadConfig() {
145
145
  raceGuardStrict: envBool("MEGACOMPACT_RACE_GUARD_STRICT", true),
146
146
  maxConsecutiveErrors: envFlag("MEGACOMPACT_MAX_CONSECUTIVE_ERRORS", 10),
147
147
  errorRetryHardStop: envBool("MEGACOMPACT_ERROR_RETRY_HARD_STOP", false),
148
+ errorRetryBackoffMs: envFlag("MEGACOMPACT_ERROR_RETRY_BACKOFF_MS", 5000),
149
+ errorRetrySessionMax: envFlag("MEGACOMPACT_ERROR_RETRY_SESSION_MAX", 3),
150
+ poisonedContextRepeatThreshold: envFlag("MEGACOMPACT_POISONED_REPEAT_THRESHOLD", 3),
148
151
  autoPctTrigger,
149
152
  autoInlineK: envFlag("MEGACOMPACT_AUTO_INLINE_K", 3),
150
153
  dedupSim: Number(process.env.MEGACOMPACT_DEDUP_SIM ?? "0.9"),