pi-mega-compact 0.8.24 → 0.8.26

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 +162 -0
  35. package/dist/src/recall.js +153 -24
  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 +179 -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 +228 -0
  91. package/src/recall.test.ts +220 -4
  92. package/src/recall.ts +462 -265
  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 +302 -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
@@ -14,10 +14,13 @@ import { cosineSimilarity } from "./embedder.js";
14
14
  import { normalizeSessionId } from "./store.js";
15
15
  import { mmrRerank } from "./dedup/mmr.js";
16
16
  import { topK } from "./dedup/topk.js";
17
- import { listCheckpoints, getCheckpoint, maxCheckpointTimestamp, } from "./store/sqlite.js";
17
+ import { listCheckpoints, getCheckpoint, maxCheckpointTimestamp, maxRaptorNodeBuiltAt, } from "./store/sqlite.js";
18
18
  import { initVectorIndex, searchAsync as vectorIndexSearch, } from "./store/vectorIndex.js";
19
19
  import { rehydrateRaptorTree, isShadowMode } from "./dedup/raptor/index.js";
20
20
  import { stagedExpansion } from "./dedup/raptor/retrieval.js";
21
+ import { multilevelRetrieval } from "./dedup/raptor/multilevel.js";
22
+ import { Logger } from "./log.js";
23
+ const logger = new Logger();
21
24
  // ---------------------------------------------------------------------------
22
25
  // raptorSearchHits — internal helper (NOT exported)
23
26
  // ---------------------------------------------------------------------------
@@ -25,8 +28,13 @@ import { stagedExpansion } from "./dedup/raptor/retrieval.js";
25
28
  * Serve the RAPTOR tree for a query (Fix D): rehydrate the persisted tree and
26
29
  * return its staged-expansion leaf hits as SearchHits. Returns [] when no tree
27
30
  * exists (small sessions — flat search remains the path). Best-effort/non-fatal.
31
+ *
32
+ * `checkpoints` is the caller's already-loaded, dedup-status-filtered checkpoint
33
+ * list — pass it in so the flat-search path and the RAPTOR merge path share ONE
34
+ * `listCheckpoints` call per `vectorSearch` (eliminates a redundant full scan +
35
+ * N-row hydration at 500+ checkpoints — QA perf review).
28
36
  */
29
- function raptorSearchHits(store, sid, query, k) {
37
+ function raptorSearchHits(store, sid, query, k, checkpoints) {
30
38
  const t0 = Date.now();
31
39
  try {
32
40
  const stateDir = store.stateDir;
@@ -38,17 +46,93 @@ function raptorSearchHits(store, sid, query, k) {
38
46
  // RAPTOR_SHADOW_MODE is anything other than "false".
39
47
  if (isShadowMode())
40
48
  return [];
41
- const tree = rehydrateRaptorTree(sid, stateDir);
49
+ // S25 gate (b): freshness + fallback guards with per-session cache.
50
+ // The cache avoids O(n·leaves) rehydrate on every search when the tree
51
+ // hasn't changed. Freshness is validated via maxRaptorNodeBuiltAt (cheap
52
+ // indexed MAX query) — if the persisted builtAt matches the cached builtAt,
53
+ // the tree hasn't been rebuilt and can be served from cache.
54
+ const cacheKey = `${stateDir}::${sid}`;
55
+ const cached = store.raptorCache.get(cacheKey);
56
+ const latestBuiltAt = maxRaptorNodeBuiltAt(sid, stateDir);
57
+ let tree;
58
+ if (cached && cached.builtAt === latestBuiltAt && latestBuiltAt > 0) {
59
+ tree = cached.tree;
60
+ }
61
+ else {
62
+ tree = rehydrateRaptorTree(sid, stateDir);
63
+ if (tree && tree.rootId && !tree.timedOut) {
64
+ store.raptorCache.set(cacheKey, { tree, builtAt: tree.builtAt ?? 0 });
65
+ }
66
+ else {
67
+ store.raptorCache.delete(cacheKey);
68
+ }
69
+ }
42
70
  if (!tree || !tree.rootId)
43
71
  return [];
44
- // S25 gate (b): freshness + fallback guards. Skip a tree built before the
45
- // newest checkpoint (stale may reference trimmed/deduped leaves) or one
46
- // whose root is a budget-exhausted extractive fallback (level 99).
47
- if (tree.timedOut)
48
- return [];
72
+ // Freshness: skip persisted tree if the session has been compacted since
73
+ // the tree was built. The next runRaptor will rebuild.
49
74
  const maxTs = maxCheckpointTimestamp(sid, stateDir);
50
75
  if (tree.builtAt && tree.builtAt < maxTs)
51
76
  return [];
77
+ // S25: skip timedOut extractive-fallback trees (level === 99).
78
+ if (tree.timedOut)
79
+ return [];
80
+ // Use the caller's already-loaded checkpoint list (no second scan).
81
+ const all = checkpoints;
82
+ const qv = embedder.embed(query);
83
+ const hits = [];
84
+ // S42B: multi-level retrieval — score all tree levels (leaves + internal
85
+ // clusters), expand cluster hits to their leaf descendants, dedup overlaps,
86
+ // and MMR-rerank. Default ON (RAPTOR_MULTILEVEL_ENABLED). When OFF, the
87
+ // leaf-only stagedExpansion path below is used (identical to pre-S42B).
88
+ if (cfg.RAPTOR_MULTILEVEL_ENABLED) {
89
+ const mlHits = multilevelRetrieval(query, tree, {
90
+ embedder,
91
+ levelWeights: cfg.RAPTOR_LEVEL_WEIGHTS,
92
+ leafExpansion: cfg.RAPTOR_LEAF_EXPANSION,
93
+ maxLeafExpansion: cfg.RAPTOR_MAX_LEAF_EXPANSION,
94
+ k,
95
+ mmrLambda: cfg.MMR_LAMBDA,
96
+ });
97
+ if (mlHits.length === 0)
98
+ return [];
99
+ for (const mh of mlHits) {
100
+ // Leaf hits: nodeId IS the checkpointId — hydrate from the stored list.
101
+ const cp = all.find((c) => c.checkpointId === mh.nodeId);
102
+ if (cp) {
103
+ hits.push({ checkpoint: cp, score: mh.score });
104
+ continue;
105
+ }
106
+ // Orphaned leaf (checkpoint missing from the stored list, e.g. SemDeDup
107
+ // removed) — skip; synthesizing it would yield an empty cluster block.
108
+ if (mh.isLeaf)
109
+ continue;
110
+ // Cluster hit: synthesize a SearchHit so the recall block can surface
111
+ // the hierarchical summary (not a stored checkpoint). The minimal
112
+ // StoredCheckpoint carries the node's centroid embedding for any
113
+ // downstream cosine / MMR the caller does.
114
+ hits.push({
115
+ checkpoint: {
116
+ checkpointId: mh.nodeId,
117
+ sessionId: sid,
118
+ summary: mh.summary,
119
+ keyDecisions: [],
120
+ nextSteps: [],
121
+ filesModified: [],
122
+ tokenEstimate: 0,
123
+ regionHash: `raptor:${mh.nodeId}`,
124
+ embedding: mh.embedding,
125
+ timestamp: tree.builtAt ?? Date.now(),
126
+ },
127
+ score: mh.score,
128
+ raptorSummary: mh.summary,
129
+ raptorLevel: mh.level,
130
+ });
131
+ }
132
+ record("RAPTOR", hits.length > 0 ? "new" : "mark_only", `ml=${mlHits.length}`, Date.now() - t0);
133
+ return hits;
134
+ }
135
+ // S42B: flag OFF — leaf-only stagedExpansion (pre-S42B production path).
52
136
  const leafIds = stagedExpansion(query, tree, {
53
137
  embedder,
54
138
  k,
@@ -57,20 +141,24 @@ function raptorSearchHits(store, sid, query, k) {
57
141
  });
58
142
  if (leafIds.length === 0)
59
143
  return [];
60
- const all = listCheckpoints(sid, stateDir).filter((cp) => cp.dedupStatus !== "removed");
61
- const qv = embedder.embed(query);
62
- const hits = [];
63
144
  for (const id of leafIds) {
64
145
  const cp = all.find((c) => c.checkpointId === id);
65
146
  if (cp)
66
- hits.push({ checkpoint: cp, score: cosineSimilarity(qv, cp.embedding) });
147
+ hits.push({
148
+ checkpoint: cp,
149
+ score: cosineSimilarity(qv, cp.embedding),
150
+ });
67
151
  }
68
152
  // S25 monitoring: emit a raptor_serve decision so canary.ts can track
69
153
  // p95 latency + the tier's live traffic (non-fatal, best-effort).
70
154
  record("RAPTOR", hits.length > 0 ? "new" : "mark_only", `leaves=${leafIds.length}`, Date.now() - t0);
71
155
  return hits;
72
156
  }
73
- catch {
157
+ catch (e) {
158
+ logger.warn("raptor_search_error", {
159
+ error: String(e),
160
+ sessionId: sid,
161
+ });
74
162
  return [];
75
163
  }
76
164
  }
@@ -109,7 +197,7 @@ export function vectorSearch(store, sessionId, query, k = 3) {
109
197
  // RAPTOR returns fewer, broader hits (O(log n) high-level nodes) than the
110
198
  // O(n) flat leaves, tightening the block at read time.
111
199
  if (cfg.RAPTOR_ENABLED) {
112
- const rh = raptorSearchHits(store, sid, query, k);
200
+ const rh = raptorSearchHits(store, sid, query, k, checkpoints);
113
201
  if (rh.length > 0) {
114
202
  const merged = [...window];
115
203
  for (const h of rh) {
@@ -176,7 +264,11 @@ export async function vectorSearchAsync(store, sessionId, query, k = 3, opts = {
176
264
  const cp = getCheckpoint(h.sessionId, h.checkpointId, h.repoId);
177
265
  if (cp && cp.dedupStatus !== "removed") {
178
266
  const crossRepo = opts.crossRepo && selfRepo && h.repoId && h.repoId !== selfRepo;
179
- hydrated.push({ checkpoint: cp, score: h.score, repoId: crossRepo ? h.repoId : undefined });
267
+ hydrated.push({
268
+ checkpoint: cp,
269
+ score: h.score,
270
+ repoId: crossRepo ? h.repoId : undefined,
271
+ });
180
272
  }
181
273
  }
182
274
  if (hydrated.length === 0)
@@ -9,11 +9,11 @@
9
9
  */
10
10
  import { createHash } from "node:crypto";
11
11
  import { cosineSimilarity, defaultEmbedder } from "./embedder.js";
12
- import { loadDedupConfig } from "./config/dedup.js";
12
+ import { loadDedupConfig, } from "./config/dedup.js";
13
13
  import { logDecision } from "./monitoring.js";
14
14
  import { getStateDir, normalizeSessionId, compressSmart } from "./store.js";
15
15
  import { computeContentDigest } from "./dedup/digest.js";
16
- import { minhashSignature, SIGNATURE_VERSION, NUM_HASHES } from "./dedup/l1-minhash.js";
16
+ import { minhashSignature, SIGNATURE_VERSION, NUM_HASHES, } from "./dedup/l1-minhash.js";
17
17
  import { lshBands } from "./dedup/l1-lsh.js";
18
18
  import { isNearDuplicate } from "./dedup/l1-verify.js";
19
19
  import { openBloom, saveBloom } from "./store/bloom.js";
@@ -47,6 +47,10 @@ export class VectorStore {
47
47
  * global index keys on repoId so recall can span repos.
48
48
  */
49
49
  repoId;
50
+ /** S25: per-session cached rehydrated RaptorTree. Keyed by sessionId.
51
+ * Freshness-validated via maxRaptorNodeBuiltAt on each lookup — a cheap
52
+ * indexed MAX query replaces the O(n·leaves) rehydrate on every search. */
53
+ raptorCache = new Map();
50
54
  constructor(opts = {}) {
51
55
  this.embedder = opts.embedder ?? defaultEmbedder();
52
56
  this.stateDir = opts.stateDir ?? getStateDir();
@@ -125,7 +129,11 @@ export class VectorStore {
125
129
  bumpDedupStats(true, this.stateDir);
126
130
  // Deduped: whole original region discarded, nothing new stored.
127
131
  addTokensSaved(origTokens, this.stateDir);
128
- const r = { checkpoint: contentMatch, deduped: true, reason: "contentHash" };
132
+ const r = {
133
+ checkpoint: contentMatch,
134
+ deduped: true,
135
+ reason: "contentHash",
136
+ };
129
137
  this.record("L0", "deduped", "contentHash", Date.now() - t0);
130
138
  onTier?.({ tier: "L0", status: "deduped", detail: "contentHash" });
131
139
  return r;
@@ -143,7 +151,11 @@ export class VectorStore {
143
151
  bumpDedupStats(true, this.stateDir);
144
152
  // Deduped: whole original region discarded, nothing new stored.
145
153
  addTokensSaved(origTokens, this.stateDir);
146
- const r = { checkpoint: regionMatch, deduped: true, reason: "regionHash" };
154
+ const r = {
155
+ checkpoint: regionMatch,
156
+ deduped: true,
157
+ reason: "regionHash",
158
+ };
147
159
  this.record("L0", "deduped", "regionHash", Date.now() - t0);
148
160
  onTier?.({ tier: "L0", status: "deduped", detail: "regionHash" });
149
161
  return r;
@@ -167,7 +179,11 @@ export class VectorStore {
167
179
  bumpDedupStats(true, this.stateDir);
168
180
  // Deduped: whole original region discarded, nothing new stored.
169
181
  addTokensSaved(origTokens, this.stateDir);
170
- const r = { checkpoint: summaryMatch, deduped: true, reason: "summaryHash" };
182
+ const r = {
183
+ checkpoint: summaryMatch,
184
+ deduped: true,
185
+ reason: "summaryHash",
186
+ };
171
187
  this.record("L0", "deduped", "summaryHash", Date.now() - t0);
172
188
  onTier?.({ tier: "L0", status: "deduped", detail: "summaryHash" });
173
189
  return r;
@@ -226,14 +242,26 @@ export class VectorStore {
226
242
  bumpDedupStats(true, this.stateDir);
227
243
  // Deduped: whole original region discarded, nothing new stored.
228
244
  addTokensSaved(origTokens, this.stateDir);
229
- const r = { checkpoint: nearest.checkpoint, deduped: true, reason: "contentSimilarity" };
245
+ const r = {
246
+ checkpoint: nearest.checkpoint,
247
+ deduped: true,
248
+ reason: "contentSimilarity",
249
+ };
230
250
  this.record("L2", "deduped", "contentSimilarity", Date.now() - t0);
231
- onTier?.({ tier: "L2", status: "deduped", detail: nearest.sim.toFixed(2) });
251
+ onTier?.({
252
+ tier: "L2",
253
+ status: "deduped",
254
+ detail: nearest.sim.toFixed(2),
255
+ });
232
256
  return r;
233
257
  }
234
258
  markOnly = "L2";
235
259
  }
236
- onTier?.({ tier: "L2", status: "passed", detail: `best ${nearest.sim.toFixed(2)}` });
260
+ onTier?.({
261
+ tier: "L2",
262
+ status: "passed",
263
+ detail: `best ${nearest.sim.toFixed(2)}`,
264
+ });
237
265
  }
238
266
  // 4. Genuinely new — create checkpoint
239
267
  const checkpointId = nextCheckpointId(sessionId, this.stateDir);
@@ -119,6 +119,17 @@ async function s38TurnEnd(h: ReturnType<typeof harness>, stopReason: string | un
119
119
  await h.fire("turn_end", { type: "turn_end", turnIndex: 1, message }, lowCtx);
120
120
  }
121
121
 
122
+ /** R3 helper: like s38TurnEnd but attaches a `usage` object so the classifier's
123
+ * 0-token poisoned-context signal is exercised (usage PRESENT with 0 tokens).
124
+ * Pass `tokens` > 0 to simulate a turn that reached the model. */
125
+ async function s38TurnEndUsage(h: ReturnType<typeof harness>, stopReason: string | undefined, text: string | undefined, tokens: number) {
126
+ const lowCtx = h.ctx({ isIdle: () => true, hasPendingMessages: () => false, getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }) });
127
+ const message: any = { role: "assistant", usage: { inputTokens: tokens, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } };
128
+ if (stopReason !== undefined) message.stopReason = stopReason;
129
+ if (text) message.content = text;
130
+ await h.fire("turn_end", { type: "turn_end", turnIndex: 1, message }, lowCtx);
131
+ }
132
+
122
133
  // ---- classifier unit tests (no extension harness needed) ----
123
134
 
124
135
  test("S38: classifyError returns 'transient' for error stopReason", () => {
@@ -301,13 +312,20 @@ test("S38: compaction-noop does NOT fire pi.sendUserMessage (NOT retryable)", as
301
312
  assert.ok(eventTypes(h.stateDir).includes("compaction_noop_diagnostic"));
302
313
  });
303
314
 
304
- test("S38: retry fires up to max (5) for transient errors, then stops", async () => {
315
+ test("S38: R1 burst of immediate transient errors fires 1 nudge (dedup), rest suppressed by retryNudgePending", async () => {
316
+ // R1 redesign: a burst of immediate error turn_ends (no turn_start between)
317
+ // produces ONE nudge — the rest are suppressed by retryNudgePending because
318
+ // the queued nudge (deliverAs:'followUp') has not been consumed by a new
319
+ // agent turn. errorRetryCount still advances for each error turn, so the
320
+ // per-burst max + circuit breaker still bound the burst.
305
321
  const h = harness();
306
- for (let i = 0; i < 5; i++) await s38TurnEnd(h, "error", "internal server error");
307
- assert.equal(h.sendUserMessages.length, 5, "transient: 5 retry nudges (<= max 5)");
308
- await s38TurnEnd(h, "error", "internal server error");
309
- assert.equal(h.sendUserMessages.length, 5, "transient: exhausted -> no 6th nudge");
310
- assert.ok(eventTypes(h.stateDir).includes("error_retry_exhausted"), "exhausted event logged");
322
+ for (let i = 0; i < 5; i++) await s38TurnEnd(h, "error", `internal server error ${i}`);
323
+ assert.equal(h.sendUserMessages.length, 1, "R1 dedup: 1 nudge in burst (rest suppressed)");
324
+ // The 6th turn reaches count=6 > max=5 → exhausted (count advances even for dedup'd turns).
325
+ await s38TurnEnd(h, "error", "internal server error 5");
326
+ assert.equal(h.sendUserMessages.length, 1, "exhausted: still 1 nudge (no 6th)");
327
+ assert.ok(eventTypes(h.stateDir).includes("error_retry_exhausted"), "exhausted event logged on max+1");
328
+ assert.ok(eventTypes(h.stateDir).includes("error_retry_dedup_skip"), "dedup_skip events logged for suppressed turns");
311
329
  });
312
330
 
313
331
  test("S38: retry fires 1x for permanent errors then stops", async () => {
@@ -320,18 +338,31 @@ test("S38: retry fires 1x for permanent errors then stops", async () => {
320
338
  });
321
339
 
322
340
  test("S38: successful turn (stop/toolUse) resets the retry counter", async () => {
323
- const h = harness();
324
- await s38TurnEnd(h, "error", "5xx server error");
325
- assert.equal(h.sendUserMessages.length, 1, "first transient: 1 nudge");
326
- await s38TurnEnd(h, "stop");
327
- await s38TurnEnd(h, "error", "5xx server error");
328
- assert.equal(h.sendUserMessages.length, 2, "success reset counter -> transient fires again from count=1");
341
+ // R1: backoff is now gating, so use a tiny backoff + small wait to let the
342
+ // second nudge fire after the success reset clears retryNudgePending (R4).
343
+ const prev = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
344
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
345
+ try {
346
+ const h = harness();
347
+ await s38TurnEnd(h, "error", "5xx server error 0");
348
+ assert.equal(h.sendUserMessages.length, 1, "first transient: 1 nudge");
349
+ await s38TurnEnd(h, "stop");
350
+ assert.equal(h.sendUserMessages.length, 1, "success: no nudge, resets pending (R4)");
351
+ await new Promise((r) => setTimeout(r, 5)); // let backoff elapse
352
+ await s38TurnEnd(h, "error", "5xx server error 1");
353
+ assert.equal(h.sendUserMessages.length, 2, "success reset -> transient fires again from count=1");
354
+ } finally {
355
+ if (prev === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
356
+ else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prev;
357
+ }
329
358
  });
330
359
 
331
360
  test("S38: error_retry_exhausted event logged when max exceeded", async () => {
361
+ // R3: "malformed bad request" is now poisoned-context (not permanent), so
362
+ // use an auth-derived permanent error to exercise the per-burst exhausted path.
332
363
  const h = harness();
333
- await s38TurnEnd(h, "error", "malformed bad request");
334
- await s38TurnEnd(h, "error", "malformed bad request");
364
+ await s38TurnEnd(h, "error", "unauthorized: invalid api key");
365
+ await s38TurnEnd(h, "error", "unauthorized: invalid api key");
335
366
  assert.ok(eventTypes(h.stateDir).includes("error_retry_exhausted"), "error_retry_exhausted logged");
336
367
  });
337
368
 
@@ -414,10 +445,224 @@ test("S38.5: strict (default) defers ctx.compact() via setTimeout re-check", asy
414
445
  }
415
446
  });
416
447
 
448
+ // ---- R3 classifier unit tests: poisoned-context signals ----
449
+
450
+ test("R3 classifier: 0-token generic error (usage present, 0 tokens) → poisoned-context", () => {
451
+ // The 2026-07-28 incident: stopReason 'error' + usage 0 tokens. The turn
452
+ // never reached the model; retrying re-submits the same poisoned context.
453
+ assert.equal(classifyErrorFn({ stopReason: "error", usage: { inputTokens: 0, outputTokens: 0 } }), "poisoned-context");
454
+ // Bare stopReason 'error' with NO usage field stays transient (unknown tokens
455
+ // — conservative; preserves the pre-R3 mid-response/partial-content behavior).
456
+ assert.equal(classifyErrorFn({ stopReason: "error" }), "transient");
457
+ });
458
+
459
+ test("R3 classifier: ECONNRESET (0-token) → transient (network failures stay transient)", () => {
460
+ // R3: network failures must stay transient even with 0 tokens.
461
+ assert.equal(classifyErrorFn({ stopReason: "error", content: "ECONNRESET", usage: { inputTokens: 0, outputTokens: 0 } }), "transient");
462
+ assert.equal(classifyErrorFn({ stopReason: "error", content: "connection reset by peer", usage: { inputTokens: 0, outputTokens: 0 } }), "transient");
463
+ assert.equal(classifyErrorFn({ stopReason: "error", content: "timeout", usage: { inputTokens: 0, outputTokens: 0 } }), "transient");
464
+ assert.equal(classifyErrorFn({ stopReason: "error", content: "503 service unavailable", usage: { inputTokens: 0, outputTokens: 0 } }), "transient");
465
+ });
466
+
467
+ test("R3 classifier: 'request failed' generic (no transient marker) → poisoned-context", () => {
468
+ // The exact incident phrasing: "Request failed — please retry." with no
469
+ // specific transient cause → deterministic rejection.
470
+ assert.equal(classifyErrorFn({ stopReason: "error", content: "Request failed — please retry.", usage: { inputTokens: 0, outputTokens: 0 } }), "poisoned-context");
471
+ assert.equal(classifyErrorFn("request failed"), "poisoned-context");
472
+ });
473
+
474
+ test("R3 classifier: orphaned-tool-result 400 (non-overflow) → poisoned-context", () => {
475
+ // Provider request-validation 400 that is NOT context-overflow: orphaned
476
+ // tool result / malformed message structure. Previously 'permanent' (1
477
+ // retry), now 'poisoned-context' (retry re-submits the same malformed shape).
478
+ assert.equal(classifyErrorFn('{"type":"invalid_request_error","message":"orphaned tool result: tooluse ids mismatch"}'), "poisoned-context");
479
+ assert.equal(classifyErrorFn("invalid request: unexpected role ordering"), "poisoned-context");
480
+ assert.equal(classifyErrorFn("malformed message structure"), "poisoned-context");
481
+ });
482
+
483
+ test("R3 classifier: context-overflow phrasing still context-overflow (not poisoned)", () => {
484
+ // Regression guard: the context-overflow check runs BEFORE the poisoned
485
+ // signals, so a 400 "too long" stays context-overflow (forced re-compact),
486
+ // not poisoned (advise + compact).
487
+ assert.equal(
488
+ classifyErrorFn({ stopReason: "error", content: "Your conversation is too long for this model's context window even after compaction.", usage: { inputTokens: 0, outputTokens: 0 } }),
489
+ "context-overflow",
490
+ );
491
+ assert.equal(
492
+ classifyErrorFn('{"type":"invalid_request_error","message":"maximum context length is 200000 tokens. requires at least 201070 tokens."}'),
493
+ "context-overflow",
494
+ );
495
+ });
496
+
497
+ test("R3 classifier: auth/permission stays permanent (not poisoned)", () => {
498
+ // Auth errors are retryable-once (permanent), not poisoned — the user can
499
+ // fix the key and retry.
500
+ assert.equal(classifyErrorFn("unauthorized: invalid api key"), "permanent");
501
+ assert.equal(classifyErrorFn("permission denied"), "permanent");
502
+ });
503
+
504
+ // ---- R6 integration tests (retry redesign) ----
505
+
506
+ test("R6(a): 10 consecutive identical 0-token transient failures produce at most errorRetrySessionMax nudges", async () => {
507
+ // Use TRANSIENT 0-token failures (network text so they're transient, not
508
+ // poisoned) + turn_start + tiny backoff between each so the nudge is
509
+ // consumed and the next turn can fire. sessionMax default = 3. Repeat
510
+ // threshold raised to disable the stateful poisoned upgrade so this
511
+ // exercises the SESSION CAP, not the repeat signal.
512
+ const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
513
+ const prevRepeat = process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
514
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
515
+ process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = "999";
516
+ try {
517
+ const h = harness();
518
+ for (let i = 0; i < 10; i++) {
519
+ // 0-token transient: usage present with 0 tokens + "connection reset" (network marker).
520
+ await s38TurnEndUsage(h, "error", "connection reset", 0);
521
+ await h.fire("turn_start", { type: "turn_start", turnIndex: i + 2 }, h.ctx());
522
+ await new Promise((r) => setTimeout(r, 3));
523
+ }
524
+ assert.ok(h.sendUserMessages.length <= 3, `R6(a): at most sessionMax (3) nudges, got ${h.sendUserMessages.length}`);
525
+ assert.ok(eventTypes(h.stateDir).includes("error_retry_session_exhausted"), "session_exhausted event logged");
526
+ } finally {
527
+ if (prevBackoff === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
528
+ else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prevBackoff;
529
+ if (prevRepeat === undefined) delete process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
530
+ else process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = prevRepeat;
531
+ }
532
+ });
533
+
534
+ test("R6(b): poisoned-context fires zero retry nudges and exactly one advise message", async () => {
535
+ // auto=false so the guarded compact attempt (R3c) is skipped — this test
536
+ // focuses on the advise + no-retry behavior. The compact path is the same
537
+ // race-guarded deferred mechanism already covered by the context-overflow tests.
538
+ const prevAuto = process.env.MEGACOMPACT_AUTO;
539
+ process.env.MEGACOMPACT_AUTO = "false";
540
+ try {
541
+ const h = harness();
542
+ // 0-token generic "request failed" (no transient marker) → poisoned.
543
+ await s38TurnEndUsage(h, "error", "Request failed — please retry.", 0);
544
+ assert.equal(h.sendUserMessages.length, 1, "poisoned: exactly one advise message");
545
+ assert.ok(
546
+ h.sendUserMessages[0].includes("/clear") || h.sendUserMessages[0].includes("/new"),
547
+ "advise mentions /clear or /new",
548
+ );
549
+ assert.ok(eventTypes(h.stateDir).includes("poisoned_context"), "poisoned_context event logged");
550
+ assert.ok(!eventTypes(h.stateDir).includes("error_retry"), "poisoned: zero retry nudges (no error_retry event)");
551
+ // Second poisoned turn: advise throttled to one per session.
552
+ await s38TurnEndUsage(h, "error", "Request failed — please retry.", 0);
553
+ assert.equal(h.sendUserMessages.length, 1, "poisoned: advise throttled (one per session)");
554
+ assert.ok(eventTypes(h.stateDir).filter((t) => t === "poisoned_context").length >= 2, "poisoned_context logged each turn");
555
+ } finally {
556
+ if (prevAuto === undefined) delete process.env.MEGACOMPACT_AUTO;
557
+ else process.env.MEGACOMPACT_AUTO = prevAuto;
558
+ }
559
+ });
560
+
561
+ test("R6(c): transient burst retries with backoff gating — second immediate nudge suppressed while one pending", async () => {
562
+ const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
563
+ const prevSession = process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX;
564
+ const prevRepeat = process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
565
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
566
+ process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX = "999"; // don't let session cap bind
567
+ process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = "999"; // don't let repeat upgrade bind
568
+ try {
569
+ const h = harness();
570
+ // Turn 1: transient → nudge 1 fires (pending=true, backoff=1ms).
571
+ await s38TurnEnd(h, "error", "internal server error 0");
572
+ assert.equal(h.sendUserMessages.length, 1, "first transient: 1 nudge");
573
+ // Turn 2: immediate (no turn_start) → suppressed by retryNudgePending.
574
+ await s38TurnEnd(h, "error", "internal server error 1");
575
+ assert.equal(h.sendUserMessages.length, 1, "second immediate nudge suppressed (retryNudgePending)");
576
+ // turn_start consumes the pending nudge (resets pending + count).
577
+ await h.fire("turn_start", { type: "turn_start", turnIndex: 2 }, h.ctx());
578
+ await new Promise((r) => setTimeout(r, 5)); // let backoff elapse
579
+ // Turn 3: transient → nudge 2 fires (pending cleared, backoff elapsed).
580
+ await s38TurnEnd(h, "error", "internal server error 2");
581
+ assert.equal(h.sendUserMessages.length, 2, "after turn_start + backoff: nudge fires");
582
+ } finally {
583
+ if (prevBackoff === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
584
+ else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prevBackoff;
585
+ if (prevSession === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX;
586
+ else process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX = prevSession;
587
+ if (prevRepeat === undefined) delete process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
588
+ else process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = prevRepeat;
589
+ }
590
+ });
591
+
592
+ test("R6(d): user abort (stopReason aborted) never nudges, even across repeated aborts", async () => {
593
+ const h = harness();
594
+ await s38TurnEnd(h, "aborted", "Operation aborted");
595
+ assert.equal(h.sendUserMessages.length, 0, "aborted: no nudge");
596
+ await s38TurnEnd(h, "aborted", "Aborted after 3 retry attempts");
597
+ assert.equal(h.sendUserMessages.length, 0, "aborted: still no nudge after repeated aborts");
598
+ assert.ok(eventTypes(h.stateDir).includes("error_retry_cancelled"), "cancelled event logged");
599
+ });
600
+
601
+ test("R6(e): success resets retry-nudge-pending state", async () => {
602
+ const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
603
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
604
+ try {
605
+ const h = harness();
606
+ // Turn 1: transient → nudge fires, pending=true.
607
+ await s38TurnEnd(h, "error", "internal server error 0");
608
+ assert.equal(h.sendUserMessages.length, 1, "first transient: 1 nudge");
609
+ // Turn 2: immediate transient → suppressed by pending (no turn_start).
610
+ await s38TurnEnd(h, "error", "internal server error 1");
611
+ assert.equal(h.sendUserMessages.length, 1, "second immediate suppressed by pending");
612
+ // Turn 3: success (stop) → resets pending (R4), no nudge.
613
+ await s38TurnEnd(h, "stop");
614
+ assert.equal(h.sendUserMessages.length, 1, "success: no nudge, resets pending");
615
+ await new Promise((r) => setTimeout(r, 5)); // let backoff elapse
616
+ // Turn 4: transient → nudge fires again (pending was reset by success).
617
+ await s38TurnEnd(h, "error", "internal server error 2");
618
+ assert.equal(h.sendUserMessages.length, 2, "after success reset pending: transient nudge fires");
619
+ } finally {
620
+ if (prevBackoff === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
621
+ else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prevBackoff;
622
+ }
623
+ });
624
+
625
+ test("R3: repeated identical transient error text upgrades to poisoned-context at threshold", async () => {
626
+ // The stateful repeat signal: 3 consecutive identical transient errors
627
+ // (default threshold) upgrade to poisoned. Uses "5xx server error" (5xx
628
+ // marker → transient) so the classifier returns transient, then the repeat
629
+ // tracker upgrades it. auto=false to skip the compact attempt.
630
+ const prevAuto = process.env.MEGACOMPACT_AUTO;
631
+ const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
632
+ process.env.MEGACOMPACT_AUTO = "false";
633
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
634
+ try {
635
+ const h = harness();
636
+ // Turns 1-2: transient (repeatCount 1, 2) → nudges fire.
637
+ await s38TurnEnd(h, "error", "5xx server error");
638
+ await h.fire("turn_start", { type: "turn_start", turnIndex: 2 }, h.ctx());
639
+ await new Promise((r) => setTimeout(r, 3));
640
+ await s38TurnEnd(h, "error", "5xx server error");
641
+ await h.fire("turn_start", { type: "turn_start", turnIndex: 3 }, h.ctx());
642
+ await new Promise((r) => setTimeout(r, 3));
643
+ // Turn 3: repeatCount=3 ≥ threshold → upgraded to poisoned. No nudge, advise fires.
644
+ await s38TurnEnd(h, "error", "5xx server error");
645
+ assert.ok(eventTypes(h.stateDir).includes("poisoned_context"), "repeat threshold reached: poisoned_context event logged");
646
+ assert.ok(h.sendUserMessages.some((m) => m.includes("/clear") || m.includes("/new")), "repeat threshold: advise message fired");
647
+ } finally {
648
+ if (prevAuto === undefined) delete process.env.MEGACOMPACT_AUTO;
649
+ else process.env.MEGACOMPACT_AUTO = prevAuto;
650
+ if (prevBackoff === undefined) delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
651
+ else process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prevBackoff;
652
+ }
653
+ });
654
+
417
655
  test("cleanup", async () => {
418
656
  // PGlite WASM close can hang; race with a timeout to prevent 40-min hangs.
419
657
  try {
420
658
  await Promise.race([closeVectorIndex(), new Promise((r) => setTimeout(r, 3000))]);
421
659
  } catch { /* ignore */ }
422
660
  rmSync(baseTmp, { recursive: true, force: true });
661
+ // Force-exit: each harness() creates a MegaRuntime with an fs.watch
662
+ // game-state watcher that is never disposed (no session_shutdown in tests).
663
+ // Those handles keep the event loop alive indefinitely after all tests
664
+ // complete, so `node --test` (without --test-force-exit) would hang. The
665
+ // streaming reporter has already printed every test result by this point;
666
+ // process.exit(0) just forces the exit the watchers are preventing.
667
+ process.exit(0);
423
668
  });
@@ -52,6 +52,21 @@ export default function (pi: ExtensionAPI) {
52
52
  console.warn('[mega-compact] MEGACOMPACT_MAX_CONSECUTIVE_ERRORS must be >= 1; using default 10');
53
53
  config.maxConsecutiveErrors = 10;
54
54
  }
55
+ // R2: session-global cap validation — 0 disables (valid), negative is invalid.
56
+ if (config.errorRetrySessionMax < 0) {
57
+ console.warn('[mega-compact] MEGACOMPACT_ERROR_RETRY_SESSION_MAX must be >= 0; using default 3');
58
+ config.errorRetrySessionMax = 3;
59
+ }
60
+ // R1: backoff base must be >= 0 (0 means no gating, useful for tests).
61
+ if (config.errorRetryBackoffMs < 0) {
62
+ console.warn('[mega-compact] MEGACOMPACT_ERROR_RETRY_BACKOFF_MS must be >= 0; using default 5000');
63
+ config.errorRetryBackoffMs = 5000;
64
+ }
65
+ // R3: repeat threshold must be >= 1.
66
+ if (config.poisonedContextRepeatThreshold < 1) {
67
+ console.warn('[mega-compact] MEGACOMPACT_POISONED_REPEAT_THRESHOLD must be >= 1; using default 3');
68
+ config.poisonedContextRepeatThreshold = 3;
69
+ }
55
70
  const runtime = new MegaRuntime(config);
56
71
  registerEventHandlers(pi, runtime, config);
57
72
  registerCommands(pi, runtime, config);
@@ -89,6 +89,21 @@ export interface MegaConfig {
89
89
  * Default false. Set via env to force S28-only behavior (length-stop continues
90
90
  * only). */
91
91
  errorRetryHardStop: boolean;
92
+ /** R1 (retry redesign): base unit (ms) for errorRetryBackoffMs(count) pacing.
93
+ * The schedule is base, 2*base, 4*base, 6*base (cap) — so the default 5000
94
+ * yields 5s/10s/20s/30s. errorRetryUntil is now GATING (previously it was
95
+ * documented as non-gating); a nudge cannot fire before errorRetryUntil
96
+ * elapses. */
97
+ errorRetryBackoffMs: number;
98
+ /** R2: session-global cap on total S38 nudges across ALL bursts. Hitting it
99
+ * is terminal for the session — the extension stops nudging entirely,
100
+ * independent of the per-burst max and the circuit breaker. Default 3.
101
+ * `0` disables (reverts to per-burst + circuit-breaker only). */
102
+ errorRetrySessionMax: number;
103
+ /** R3: consecutive identical error-text count at which a 'transient'
104
+ * classification is upgraded to 'poisoned-context' (the stateful repeat
105
+ * signal). Default 3. Raise to make the upgrade less aggressive. */
106
+ poisonedContextRepeatThreshold: number;
92
107
  /** S29: override the auto-compact fire point for tiered configs, as a
93
108
  * fraction of the context window (e.g. 0.85). null = inherit the tier's
94
109
  * tierPct (default; preserves existing fire points). The context-handler
@@ -262,6 +277,9 @@ export function loadConfig(): MegaConfig {
262
277
  raceGuardStrict: envBool("MEGACOMPACT_RACE_GUARD_STRICT", true),
263
278
  maxConsecutiveErrors: envFlag("MEGACOMPACT_MAX_CONSECUTIVE_ERRORS", 10),
264
279
  errorRetryHardStop: envBool("MEGACOMPACT_ERROR_RETRY_HARD_STOP", false),
280
+ errorRetryBackoffMs: envFlag("MEGACOMPACT_ERROR_RETRY_BACKOFF_MS", 5000),
281
+ errorRetrySessionMax: envFlag("MEGACOMPACT_ERROR_RETRY_SESSION_MAX", 3),
282
+ poisonedContextRepeatThreshold: envFlag("MEGACOMPACT_POISONED_REPEAT_THRESHOLD", 3),
265
283
  autoPctTrigger,
266
284
  autoInlineK: envFlag("MEGACOMPACT_AUTO_INLINE_K", 3),
267
285
  dedupSim: Number(process.env.MEGACOMPACT_DEDUP_SIM ?? "0.9"),