pi-mega-compact 0.11.2 → 0.11.4

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 (112) hide show
  1. package/dist/extensions/dashboard-server/api-contracts/endpoints.js +9 -1
  2. package/dist/extensions/dashboard-server/api-contracts/provider-cache.js +7 -0
  3. package/dist/extensions/dashboard-server/api-contracts.test.js +3 -2
  4. package/dist/extensions/dashboard-server/index-reader.js +22 -0
  5. package/dist/extensions/dashboard-server/routes-cache.js +80 -0
  6. package/dist/extensions/dashboard-server/routes-cache.test.js +248 -0
  7. package/dist/extensions/dashboard-server/routes-maintenance.js +7 -7
  8. package/dist/extensions/dashboard-server/routes-repo.js +24 -0
  9. package/dist/extensions/dashboard-server/routes.js +1 -0
  10. package/dist/extensions/dashboard-server/server.js +3 -1
  11. package/dist/extensions/mega-cache-replay.test.js +233 -24
  12. package/dist/extensions/mega-compact-s38.test.js +778 -20
  13. package/dist/extensions/mega-config.js +4 -0
  14. package/dist/extensions/mega-events/agent-handlers.js +44 -8
  15. package/dist/extensions/mega-events/context-handler.js +37 -19
  16. package/dist/extensions/mega-events/error-classifier.js +125 -135
  17. package/dist/extensions/mega-events/outage-advisor.js +53 -0
  18. package/dist/extensions/mega-runtime/reset-runtime.js +1 -0
  19. package/dist/extensions/mega-runtime/runtime-snapshot.js +14 -4
  20. package/dist/extensions/mega-runtime/runtime.js +1 -0
  21. package/dist/extensions/mega-runtime/snapshot.js +1 -1
  22. package/dist/extensions/mega-runtime/snapshot.test.js +127 -0
  23. package/dist/extensions/mega-runtime/widget.js +4 -4
  24. package/dist/extensions/mega-runtime/widget.test.js +103 -12
  25. package/dist/src/pricing.js +78 -0
  26. package/dist/src/store/sqlite/perf-samples.js +104 -0
  27. package/dist/src/store/sqlite/perf-samples.test.js +93 -1
  28. package/extensions/dashboard-client/dist/assets/{AchievementTiles-Cml6YC8w.js → AchievementTiles-vEo2MtE2.js} +2 -2
  29. package/extensions/dashboard-client/dist/assets/{AchievementTiles-Cml6YC8w.js.map → AchievementTiles-vEo2MtE2.js.map} +1 -1
  30. package/extensions/dashboard-client/dist/assets/AchievementsTab-DjxfYrDc.js +2 -0
  31. package/extensions/dashboard-client/dist/assets/{AchievementsTab-B8NqDt7I.js.map → AchievementsTab-DjxfYrDc.js.map} +1 -1
  32. package/extensions/dashboard-client/dist/assets/CacheTab-Dnpv94wq.js +2 -0
  33. package/extensions/dashboard-client/dist/assets/CacheTab-Dnpv94wq.js.map +1 -0
  34. package/extensions/dashboard-client/dist/assets/{ConfigTab-CBYvp2mV.js → ConfigTab-Cm6ymbmx.js} +2 -2
  35. package/extensions/dashboard-client/dist/assets/{ConfigTab-CBYvp2mV.js.map → ConfigTab-Cm6ymbmx.js.map} +1 -1
  36. package/extensions/dashboard-client/dist/assets/{EventsTab-DlTJqiDm.js → EventsTab-DXWl_YzI.js} +2 -2
  37. package/extensions/dashboard-client/dist/assets/{EventsTab-DlTJqiDm.js.map → EventsTab-DXWl_YzI.js.map} +1 -1
  38. package/extensions/dashboard-client/dist/assets/{GameTab-DkGTqKJW.js → GameTab-BLDudcO5.js} +2 -2
  39. package/extensions/dashboard-client/dist/assets/{GameTab-DkGTqKJW.js.map → GameTab-BLDudcO5.js.map} +1 -1
  40. package/extensions/dashboard-client/dist/assets/MaintenanceTab-DLkKCztn.js +2 -0
  41. package/extensions/dashboard-client/dist/assets/MaintenanceTab-DLkKCztn.js.map +1 -0
  42. package/extensions/dashboard-client/dist/assets/{MetricsTab-BFo7pKM-.js → MetricsTab-Cq4u-rZF.js} +2 -2
  43. package/extensions/dashboard-client/dist/assets/{MetricsTab-BFo7pKM-.js.map → MetricsTab-Cq4u-rZF.js.map} +1 -1
  44. package/extensions/dashboard-client/dist/assets/{OverviewTab-CClCUd3-.js → OverviewTab-D4kGvsiQ.js} +2 -2
  45. package/extensions/dashboard-client/dist/assets/{OverviewTab-CClCUd3-.js.map → OverviewTab-D4kGvsiQ.js.map} +1 -1
  46. package/extensions/dashboard-client/dist/assets/ReposTab-C0Wnrf2u.js +2 -0
  47. package/extensions/dashboard-client/dist/assets/ReposTab-C0Wnrf2u.js.map +1 -0
  48. package/extensions/dashboard-client/dist/assets/{SessionsTab-CCYd-kSz.js → SessionsTab-DZC2-GCn.js} +2 -2
  49. package/extensions/dashboard-client/dist/assets/{SessionsTab-CCYd-kSz.js.map → SessionsTab-DZC2-GCn.js.map} +1 -1
  50. package/extensions/dashboard-client/dist/assets/TimeSavedCard-UqljSyP-.js +2 -0
  51. package/extensions/dashboard-client/dist/assets/TimeSavedCard-UqljSyP-.js.map +1 -0
  52. package/extensions/dashboard-client/dist/assets/{TopicsTab-DRLJmVv0.js → TopicsTab-01G71hv1.js} +2 -2
  53. package/extensions/dashboard-client/dist/assets/{TopicsTab-DRLJmVv0.js.map → TopicsTab-01G71hv1.js.map} +1 -1
  54. package/extensions/dashboard-client/dist/assets/{TurnsTab-CLNM0T5R.js → TurnsTab-DLRg0LZT.js} +2 -2
  55. package/extensions/dashboard-client/dist/assets/{TurnsTab-CLNM0T5R.js.map → TurnsTab-DLRg0LZT.js.map} +1 -1
  56. package/extensions/dashboard-client/dist/assets/index-BEdKlpAB.css +1 -0
  57. package/extensions/dashboard-client/dist/assets/{index-BcoSz-L4.js → index-BX0bcgLu.js} +4 -4
  58. package/extensions/dashboard-client/dist/assets/{index-BcoSz-L4.js.map → index-BX0bcgLu.js.map} +1 -1
  59. package/extensions/dashboard-client/dist/assets/{useSSE-Cezlih5V.js → useSSE-BBYt5UPB.js} +2 -2
  60. package/extensions/dashboard-client/dist/assets/{useSSE-Cezlih5V.js.map → useSSE-BBYt5UPB.js.map} +1 -1
  61. package/extensions/dashboard-client/dist/index.html +2 -2
  62. package/extensions/dashboard-client/src/api/client.ts +6 -0
  63. package/extensions/dashboard-client/src/components/ActiveReposTable.tsx +13 -1
  64. package/extensions/dashboard-client/src/components/ProviderCacheCard.tsx +141 -0
  65. package/extensions/dashboard-client/src/components/SavingsByModelTable.tsx +31 -3
  66. package/extensions/dashboard-client/src/main.tsx +1 -0
  67. package/extensions/dashboard-client/src/styles/maintenance.css +267 -0
  68. package/extensions/dashboard-client/src/styles/overview-extra.css +9 -0
  69. package/extensions/dashboard-client/src/tabs/CacheTab.tsx +83 -29
  70. package/extensions/dashboard-client/src/tabs/MaintenanceTab.tsx +301 -136
  71. package/extensions/dashboard-client/src/utils/format.ts +42 -7
  72. package/extensions/dashboard-server/api-contracts/endpoints.ts +31 -8
  73. package/extensions/dashboard-server/api-contracts/index.ts +4 -0
  74. package/extensions/dashboard-server/api-contracts/multi-repo.ts +6 -0
  75. package/extensions/dashboard-server/api-contracts/provider-cache.ts +30 -0
  76. package/extensions/dashboard-server/api-contracts.test.ts +3 -2
  77. package/extensions/dashboard-server/index-reader.ts +23 -0
  78. package/extensions/dashboard-server/routes-cache.test.ts +302 -0
  79. package/extensions/dashboard-server/routes-cache.ts +123 -0
  80. package/extensions/dashboard-server/routes-maintenance.ts +18 -11
  81. package/extensions/dashboard-server/routes-repo.ts +31 -4
  82. package/extensions/dashboard-server/routes.ts +1 -0
  83. package/extensions/dashboard-server/server.ts +2 -0
  84. package/extensions/mega-cache-replay.test.ts +481 -166
  85. package/extensions/mega-compact-s38.test.ts +785 -20
  86. package/extensions/mega-config.ts +14 -0
  87. package/extensions/mega-events/agent-handlers.ts +48 -7
  88. package/extensions/mega-events/context-handler.ts +59 -24
  89. package/extensions/mega-events/error-classifier.ts +167 -129
  90. package/extensions/mega-events/outage-advisor.ts +73 -0
  91. package/extensions/mega-runtime/helpers.ts +2 -0
  92. package/extensions/mega-runtime/reset-runtime.ts +1 -0
  93. package/extensions/mega-runtime/runtime-snapshot.ts +31 -9
  94. package/extensions/mega-runtime/runtime.ts +1 -0
  95. package/extensions/mega-runtime/snapshot.test.ts +139 -0
  96. package/extensions/mega-runtime/snapshot.ts +6 -4
  97. package/extensions/mega-runtime/widget.test.ts +290 -105
  98. package/extensions/mega-runtime/widget.ts +29 -6
  99. package/package.json +1 -1
  100. package/src/pricing.ts +95 -0
  101. package/src/store/sqlite/perf-samples.test.ts +107 -3
  102. package/src/store/sqlite/perf-samples.ts +142 -6
  103. package/extensions/dashboard-client/dist/assets/AchievementsTab-B8NqDt7I.js +0 -2
  104. package/extensions/dashboard-client/dist/assets/CacheTab-BCLzVL4S.js +0 -2
  105. package/extensions/dashboard-client/dist/assets/CacheTab-BCLzVL4S.js.map +0 -1
  106. package/extensions/dashboard-client/dist/assets/MaintenanceTab-Cw43zmUx.js +0 -2
  107. package/extensions/dashboard-client/dist/assets/MaintenanceTab-Cw43zmUx.js.map +0 -1
  108. package/extensions/dashboard-client/dist/assets/ReposTab-_3R4RSLZ.js +0 -2
  109. package/extensions/dashboard-client/dist/assets/ReposTab-_3R4RSLZ.js.map +0 -1
  110. package/extensions/dashboard-client/dist/assets/TimeSavedCard-0TqsrY6y.js +0 -2
  111. package/extensions/dashboard-client/dist/assets/TimeSavedCard-0TqsrY6y.js.map +0 -1
  112. package/extensions/dashboard-client/dist/assets/index-gn66lYD2.css +0 -1
@@ -148,6 +148,7 @@ export function loadConfig() {
148
148
  errorRetryBackoffMs: envFlag("MEGACOMPACT_ERROR_RETRY_BACKOFF_MS", 5000),
149
149
  errorRetrySessionMax: envFlag("MEGACOMPACT_ERROR_RETRY_SESSION_MAX", 3),
150
150
  poisonedContextRepeatThreshold: envFlag("MEGACOMPACT_POISONED_REPEAT_THRESHOLD", 3),
151
+ providerOutageAdviseThreshold: envFlag("MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD", 3),
151
152
  autoPctTrigger,
152
153
  autoInlineK: envFlag("MEGACOMPACT_AUTO_INLINE_K", 3),
153
154
  dedupSim: Number(process.env.MEGACOMPACT_DEDUP_SIM ?? "0.9"),
@@ -165,6 +166,9 @@ export function loadConfig() {
165
166
  recallMaxTokens: envFlag("MEGACOMPACT_RECALL_MAX_TOKENS", 1500),
166
167
  windowDedupe: envBool("MEGACOMPACT_WINDOW_DEDUPE", true),
167
168
  tuiWidget: envBool("MEGACOMPACT_TUI_WIDGET", true),
169
+ // D.1: env-overridable recompact delta (minimum context growth % before
170
+ // re-compacting instead of replaying the cached live trim). Default 50.
171
+ recompactPctDelta: envFlag("MEGACOMPACT_RECOMPACT_PCT_DELTA", 50),
168
172
  debug: envBool("MEGACOMPACT_DEBUG", false),
169
173
  };
170
174
  }
@@ -1,12 +1,13 @@
1
1
  import { piCompactWouldNoop, runMemoryReview } from "../mega-pipeline.js";
2
2
  import { memoryReviewCadence } from "../mega-config.js";
3
- import { recordScore } from "../../src/store/sqlite.js";
3
+ import { recordScore, readLatestCacheHitPct } from "../../src/store/sqlite.js";
4
4
  import { evaluateAndUnlockAchievements } from "../../src/store/sqlite/game-achievements.js";
5
5
  import { ensureConversationIdFor, recordTurnWrite, } from "../mega-turn-store.js";
6
6
  import { isMegaCache } from "../../src/game/scoring.js";
7
7
  import { resolveRepoRoot } from "../mega-config.js";
8
- import { classifyError, errorRetryBackoffMs, extractErrorSignature, } from "./error-classifier.js";
8
+ import { classifyError, classifyErrorDetailed, errorRetryBackoffMs, extractErrorSignature, isKnownRetryableTransient, } from "./error-classifier.js";
9
9
  import { safeSendUserMessage } from "./send-safe.js";
10
+ import { maybeSendProviderOutageAdvisory } from "./outage-advisor.js";
10
11
  import { vectorStats } from "../../src/vectorStore.js";
11
12
  /** Register agent/turn tracking event handlers. */
12
13
  export function registerAgentHandlers(pi, runtime, config) {
@@ -255,7 +256,9 @@ export function registerAgentHandlers(pi, runtime, config) {
255
256
  if (runtime.getCachedGameState().game_mode_on) {
256
257
  const repo = resolveRepoRoot(ctx.cwd) ?? runtime.currentStateDir;
257
258
  const st = vectorStats(runtime.store, runtime.rt.sessionId);
258
- const cachePct = st.dedupHitRate * 100;
259
+ // C.3: prefer provider cache hit rate, fall back to dedup hit rate
260
+ const providerPct = readLatestCacheHitPct(runtime.currentStateDir);
261
+ const cachePct = providerPct != null ? providerPct : st.dedupHitRate * 100;
259
262
  const modelId = runtime.currentModel?.modelId ?? "unknown";
260
263
  recordScore(runtime.currentStateDir, {
261
264
  repo_root: repo,
@@ -334,14 +337,17 @@ export function registerAgentHandlers(pi, runtime, config) {
334
337
  }
335
338
  else {
336
339
  const category = classifyError(event.message);
340
+ const detail = classifyErrorDetailed(event.message);
337
341
  // R3: stateful poisoned-context signal — repeated identical error text
338
342
  // across consecutive turns. The classifier is stateless, so this
339
343
  // upgrade happens here. A 'transient' (or 'poisoned-context') turn
340
344
  // whose normalized error signature matches the previous one bumps
341
345
  // errorTextRepeatCount; once it crosses the threshold, the category
342
- // is upgraded to 'poisoned-context' (the request is deterministic).
343
- // Conservative: only upgrade, never downgrade, and only for transient
344
- // (a 'permanent' auth error repeating is still permanent).
346
+ // is upgraded to 'poisoned-context' (the request is deterministic)
347
+ // R7: unless the signature carries a known-retryable network/
348
+ // throughput marker (guard below). Conservative: only upgrade, never
349
+ // downgrade, and only for transient (a 'permanent' auth error
350
+ // repeating is still permanent).
345
351
  let effectiveCategory = category;
346
352
  const errSig = extractErrorSignature(event.message);
347
353
  if (category === "transient" || category === "poisoned-context") {
@@ -356,7 +362,26 @@ export function registerAgentHandlers(pi, runtime, config) {
356
362
  if (runtime.rt.errorTextRepeatCount >=
357
363
  config.poisonedContextRepeatThreshold &&
358
364
  category === "transient") {
359
- effectiveCategory = "poisoned-context";
365
+ // R3/R7: upgrade only when the repeating error carries NO
366
+ // known-retryable marker. Network/throughput failures (timeout,
367
+ // ECONNRESET, 5xx, 429, connection lost, …) that repeat are
368
+ // still retryable — the API is just flaky, and /clear cannot fix
369
+ // them (2026-07-30 false-alarm incident). Only upgrade
370
+ // transient → poisoned-context for errors that repeat identically
371
+ // with no known-retryable cause, like the 2026-07-28 incident
372
+ // where the provider returned "Request failed — please retry."
373
+ // deterministically. The marker set is shared with classifyError
374
+ // (error-classifier.ts) so the two never drift.
375
+ if (!isKnownRetryableTransient(errSig)) {
376
+ effectiveCategory = "poisoned-context";
377
+ }
378
+ else {
379
+ runtime.logger.info("repeat-upgrade-declined", {
380
+ sessionId: runtime.rt.sessionId,
381
+ signature: (errSig || '').slice(0, 500),
382
+ repeatCount: runtime.rt.errorTextRepeatCount,
383
+ });
384
+ }
360
385
  }
361
386
  }
362
387
  }
@@ -372,6 +397,9 @@ export function registerAgentHandlers(pi, runtime, config) {
372
397
  runtime.rt.consecutiveErrors = 0; // S38.6: circuit-breaker reset on success
373
398
  // R4: a successful assistant turn consumes any queued nudge.
374
399
  runtime.rt.retryNudgePending = false;
400
+ // R10: reset outage advisory so a recovered-then-flapping
401
+ // provider re-advises once per episode.
402
+ runtime.rt.providerOutageAdvised = false;
375
403
  }
376
404
  else if (effectiveCategory === "compaction-noop") {
377
405
  // (4) pi race / manual compact catch — NOT retryable. The compaction
@@ -471,18 +499,24 @@ export function registerAgentHandlers(pi, runtime, config) {
471
499
  runtime.rt.consecutiveErrors++; // still counts toward the circuit breaker
472
500
  runtime.rt.poisonedCount++; // R7: dashboard counter
473
501
  const sig = errSig || "unknown";
474
- // (a) dashboard + log
502
+ // (a) dashboard + log — R11: include signal + rawText for forensics
475
503
  runtime.dashboard.event("poisoned_context", {
476
504
  signature: sig,
477
505
  repeatCount: runtime.rt.errorTextRepeatCount,
478
506
  turnIndex: event.turnIndex,
479
507
  sessionId: runtime.rt.sessionId,
508
+ signal: detail.signal,
509
+ rawText: (errSig || '').slice(0, 500),
510
+ ...(detail.httpStatus !== undefined ? { httpStatus: detail.httpStatus } : {}),
480
511
  });
481
512
  runtime.logger.warn("poisoned-context", {
482
513
  sessionId: runtime.rt.sessionId,
483
514
  turnIndex: event.turnIndex,
484
515
  signature: sig,
485
516
  repeatCount: runtime.rt.errorTextRepeatCount,
517
+ signal: detail.signal,
518
+ rawText: (errSig || '').slice(0, 500),
519
+ ...(detail.httpStatus !== undefined ? { httpStatus: detail.httpStatus } : {}),
486
520
  });
487
521
  // (b) one-per-session advise message (throttled by poisonedAdviseSent)
488
522
  if (!runtime.rt.poisonedAdviseSent) {
@@ -536,6 +570,8 @@ export function registerAgentHandlers(pi, runtime, config) {
536
570
  }
537
571
  // S38.6: circuit-breaker — stop retrying after too many consecutive errors.
538
572
  runtime.rt.consecutiveErrors++;
573
+ // R10: send calm "provider outage" advisory once per episode.
574
+ await maybeSendProviderOutageAdvisory(effectiveCategory, runtime, pi, config, { signal: detail.signal, rawText: (errSig || '').slice(0, 500) });
539
575
  if (runtime.rt.consecutiveErrors > config.maxConsecutiveErrors) {
540
576
  runtime.dashboard.event("error_retry_circuit_open", {
541
577
  consecutive: runtime.rt.consecutiveErrors,
@@ -170,30 +170,24 @@ export function registerContextHandler(pi, runtime, config) {
170
170
  runtime.diagCtxFastGate++;
171
171
  return;
172
172
  }
173
- // Debounce so we don't fire on every context event past threshold.
174
- const now = Date.now();
175
- if (now < runtime.debounceUntil) {
176
- runtime.diagCtxDebounce++;
177
- return;
178
- }
179
- runtime.debounceUntil = now + 2000;
173
+ // D.2: Replay MUST be exempt from debounce replay is free (no compute,
174
+ // no re-write) and prevents unnecessary KV-cache invalidation. Check
175
+ // replay FIRST, before debounce, so two context events <2s apart both
176
+ // return the cached sentinel verbatim (re-stabilises the provider prefix).
177
+ //
180
178
  // v0.8.6 cache-stability: replay the cached trim view when still in the
181
179
  // same compaction epoch AND context hasn't grown enough to warrant a
182
- // re-compact. This stabilizes the provider KV-cache prefix (the summary +
183
- // cut are reused verbatim) instead of regenerating a fresh summary +
184
- // sentinel every fire, which invalidated the prefix on every other turn
185
- // (the alternating cache-miss regression). Re-compact only when context
186
- // grew >=10% of the window (percent basis) or >=50% of the effective
187
- // threshold (token basis, when percent is unavailable). The cached `cut`
188
- // is only valid while the transcript grows within the epoch — it is
189
- // cleared on session_compact (durable truncation) + resetRuntime, so we
190
- // never replay a stale cut into a truncated transcript (PREVENT-PI-001/002).
191
- const RECOMPACT_PCT_DELTA = 10;
180
+ // re-compact. Re-compact only when context grew >=config.recompactPctDelta%
181
+ // of the window (percent basis) or >=50% of the effective threshold
182
+ // (token basis, when percent is unavailable). The cached `cut` is only
183
+ // valid while the transcript grows within the epoch it is cleared on
184
+ // session_compact (durable truncation) + resetRuntime, so we never replay
185
+ // a stale cut into a truncated transcript (PREVENT-PI-001/002).
192
186
  if (runtime.trimCache &&
193
187
  runtime.trimCache.checkpointId === runtime.rt.lastCheckpointId &&
194
188
  runtime.trimCache.cut <= messages.length) {
195
189
  const grewEnough = pct != null && runtime.trimCache.ctxPct != null
196
- ? pct - runtime.trimCache.ctxPct >= RECOMPACT_PCT_DELTA
190
+ ? pct - runtime.trimCache.ctxPct >= config.recompactPctDelta
197
191
  : currentTokens - (runtime.trimCache.ctxTokens ?? 0) >=
198
192
  runtime.effectiveThreshold * 0.5;
199
193
  if (!grewEnough) {
@@ -209,6 +203,14 @@ export function registerContextHandler(pi, runtime, config) {
209
203
  }
210
204
  // else: context grew enough → fall through to re-compact (cache is stale)
211
205
  }
206
+ // Debounce so we don't fire on every context event past threshold.
207
+ // (Replay already returned above — only fresh compacts reach this point.)
208
+ const now = Date.now();
209
+ if (now < runtime.debounceUntil) {
210
+ runtime.diagCtxDebounce++;
211
+ return;
212
+ }
213
+ runtime.debounceUntil = now + 2000;
212
214
  // Adaptive compression (Fix E): scale compression strength + keepFrom depth
213
215
  // with how close we are to the model context limit. Null-safe: when the
214
216
  // token-fallback path ran (pct unavailable) use the token-basis pressure
@@ -219,8 +221,22 @@ export function registerContextHandler(pi, runtime, config) {
219
221
  const ran = runCompact(pi, runtime, config, ctx, messages, {
220
222
  compressionPressure: pressure,
221
223
  });
224
+ // D.3: skip paths fall back to replay instead of returning empty.
225
+ // If runCompact skipped and we have a valid trimCache, replay it
226
+ // (free stability win) — otherwise defer to the next event.
222
227
  if (ran.skipped) {
223
228
  runtime.diagCtxRunSkipped++;
229
+ if (runtime.trimCache &&
230
+ runtime.trimCache.checkpointId === runtime.rt.lastCheckpointId &&
231
+ runtime.trimCache.cut <= messages.length) {
232
+ const recent = messages.slice(runtime.trimCache.cut); // guardrails-allow PREVENT-PI-002
233
+ runtime.diagLiveTrimFires++;
234
+ runtime.diagLiveTrimReplays++;
235
+ runtime.snapshot(ctx);
236
+ return {
237
+ messages: [{ ...runtime.trimCache.summaryAgentMsg }, ...recent],
238
+ };
239
+ }
224
240
  return;
225
241
  }
226
242
  // S27 DB-mirror: write checkpoint_epoch with deterministic nonce.
@@ -277,7 +293,9 @@ export function registerContextHandler(pi, runtime, config) {
277
293
  }
278
294
  }
279
295
  catch (wikiErr) {
280
- runtime.logger.warn("wiki_rebuild_failed", { error: String(wikiErr) });
296
+ runtime.logger.warn("wiki_rebuild_failed", {
297
+ error: String(wikiErr),
298
+ });
281
299
  }
282
300
  // S27 Task 6: Fire-and-forget dedup pipeline.
283
301
  // Deduplicates raw_transcript rows for the compacted range.
@@ -11,42 +11,60 @@
11
11
  * compact per signature). Conservative: when unsure between transient and
12
12
  * poisoned, choose transient (bounded by R1 dedup + R2 session cap).
13
13
  */
14
- /** S38.2: classify a turn-end error/stop signal into a retry category.
14
+ /** R7: known-retryable transient markers network/throughput failures that
15
+ * retry can fix and /clear cannot. SINGLE SOURCE OF TRUTH: classifyError uses
16
+ * this for its transient-marker stage, and the agent-handlers R3 repeat
17
+ * upgrade guard uses it to keep these errors out of poisoned-context. Add new
18
+ * transient phrasings HERE only.
15
19
  *
16
- * `length` is returned as null — S28 owns the max-output-token length stopReason
17
- * exclusively (its agent_end nudge path is separate and must not be doubled).
18
- *
19
- * @param message the event.message (a pi AgentMessage) or an error string
20
- * @returns 'transient' | 'permanent' | 'compaction-noop' | 'context-overflow' | 'cancelled' | 'poisoned-context' | null (success/unknown)
21
- */
22
- export function classifyError(message) {
23
- // Resolve a searchable text blob from a pi AgentMessage or raw string.
20
+ * Non-obvious alternatives:
21
+ * - `time(?:d[\s-]?out|out)` covers "timeout", "timed out", "timed-out"
22
+ * "timed out" does NOT match /timeout/ (the 2026-07-30 incident phrasing).
23
+ * - `etimedout` is Node's timeout errno lowercased does NOT contain the
24
+ * substring "timeout".
25
+ * - `socket hang up` is Node's ECONNRESET *message*; the errno lives in
26
+ * error.code, which extractErrorSignature never sees.
27
+ * - router phrasings (all targets failed / no healthy target / too many
28
+ * concurrent) -- pi's status prefix is console-only (2026-07-30 incident). */
29
+ export const KNOWN_RETRYABLE_TRANSIENT_PATTERN = /max(imum)? output token|rate[\s.-]?limit|429|too many requests|too many concurrent|overloaded|5\d\d|internal server|bad gateway|service unavailable|all targets failed|no healthy target|fetch failed|network|time(?:d[\s-]?out|out)|etimedout|econnreset|econnrefused|epipe|eai_again|socket|closed unexpectedly|premature close|other side closed|connection (lost|refused|reset|aborted?|was closed)|stream (interrupted|closed|ended|failed)|disconnected/;
30
+ /** R7: true when the error text carries a known-retryable transient marker.
31
+ * Defensively lowercases so callers can pass raw or normalized text. */
32
+ export function isKnownRetryableTransient(text) {
33
+ return KNOWN_RETRYABLE_TRANSIENT_PATTERN.test(text.toLowerCase());
34
+ }
35
+ /** R8: best-effort extraction of HTTP status from pi AgentMessage shapes. */
36
+ function extractHttpStatus(m) {
37
+ for (const outer of [m.error, m]) {
38
+ if (!outer || typeof outer !== 'object')
39
+ continue;
40
+ for (const key of ['status', 'statusCode', 'code']) {
41
+ const v = outer[key];
42
+ if (typeof v === 'number' && v >= 100 && v <= 599)
43
+ return v;
44
+ }
45
+ }
46
+ return undefined;
47
+ }
48
+ /** R11: classify a turn-end error/stop signal into a retry category with a
49
+ * diagnostic signal tag naming the rule that fired, plus the raw httpStatus
50
+ * when extractHttpStatus found one. */
51
+ export function classifyErrorDetailed(message) {
24
52
  let text = '';
25
- // R3: usage-token signal. Only when `usage` is explicitly present with
26
- // inputTokens=0 AND outputTokens=0 do we know the turn never reached the
27
- // model. An ABSENT usage field means "unknown" (the event didn't carry
28
- // token counts) — we do NOT treat that as the 0-token poisoned signal,
29
- // because mid-response stream deaths and partial-content turns routinely
30
- // omit usage while still being transient (preserves all pre-R3 tests).
31
53
  let usagePresent = false;
32
54
  let totalTokens = 0;
55
+ let httpStatus;
33
56
  if (typeof message === 'string') {
34
57
  text = message;
35
58
  }
36
59
  else if (message && typeof message === 'object') {
37
60
  const m = message;
38
61
  const sr = typeof m.stopReason === 'string' ? m.stopReason : '';
39
- // S28 guard: length stopReason is handled exclusively by the S28 path.
40
62
  if (sr === 'length')
41
- return null;
42
- // User ESC / Ctrl-C abort — stopReason === 'aborted'. Not retryable:
43
- // nudging would restart a task the user explicitly stopped.
63
+ return { category: null, signal: 'length-guard' };
44
64
  if (sr === 'aborted')
45
- return 'cancelled';
46
- // Success / normal tool flow — not an error, nothing to retry.
65
+ return { category: 'cancelled', signal: 'cancelled' };
47
66
  if (sr === 'stop' || sr === 'toolUse' || sr === 'tool_use')
48
- return null;
49
- // R3: extract usage tokens for the 0-token poisoned-context signal.
67
+ return { category: null, signal: 'success' };
50
68
  const usage = m.usage;
51
69
  usagePresent = usage != null && typeof usage === 'object';
52
70
  if (usagePresent) {
@@ -55,6 +73,7 @@ export function classifyError(message) {
55
73
  (typeof u.inputTokens === 'number' ? u.inputTokens : 0) +
56
74
  (typeof u.outputTokens === 'number' ? u.outputTokens : 0);
57
75
  }
76
+ httpStatus = extractHttpStatus(m);
58
77
  const parts = [];
59
78
  if (sr)
60
79
  parts.push(sr);
@@ -69,136 +88,75 @@ export function classifyError(message) {
69
88
  }
70
89
  }
71
90
  if (m.error) {
72
- // Extract message from error objects for pattern matching.
73
91
  const err = m.error;
74
- if (typeof err === 'string') {
92
+ if (typeof err === 'string')
75
93
  parts.push(err);
76
- }
77
94
  else if (err && typeof err === 'object') {
78
95
  const errObj = err;
79
- if (typeof errObj.message === 'string') {
96
+ if (typeof errObj.message === 'string')
80
97
  parts.push(errObj.message);
81
- }
82
- else {
98
+ else
83
99
  parts.push(JSON.stringify(err));
84
- }
85
100
  }
86
101
  }
87
- // S38: detect mid-response errors where the stream died without a
88
- // proper stopReason (empty/undefined) — this catches provider failures
89
- // that cut off the response mid-stream, INCLUDING the case where the
90
- // provider emitted partial content before dying (a truncated response
91
- // with no stop reason is a mid-stream death, not a success).
92
- // A genuine success ALWAYS carries stop/tool_use/toolUse (which
93
- // short-circuit to null at the top), so any message reaching here with a
94
- // falsy stopReason is a stream failure → retryable.
95
102
  if (!sr) {
96
- return 'transient';
103
+ const r = { category: 'transient', signal: 'stream-death-no-stopreason' };
104
+ if (httpStatus !== undefined)
105
+ r.httpStatus = httpStatus;
106
+ return r;
97
107
  }
98
108
  text = parts.join(' ');
99
109
  }
100
110
  if (!text)
101
- return null;
111
+ return { category: null, signal: 'unknown' };
102
112
  const s = text.toLowerCase();
103
- // --- compaction-noop (ORDER FIRST: pi race / manual compact catch) ---
104
- // FAIL-2026071701: these are NOT retryable — the compaction already
105
- // succeeded via pi's native path; retrying would race again.
106
- if (/already compacted/.test(s))
107
- return 'compaction-noop';
108
- if (/compaction failed/.test(s))
109
- return 'compaction-noop';
110
- if (/nothing to compact/.test(s))
111
- return 'compaction-noop';
112
- if (/auto[\s-]?compaction failed/.test(s))
113
- return 'compaction-noop';
114
- // --- context-overflow (ORDER BEFORE generic transient!) ---
115
- // A 400 from the model meaning "the prompt is bigger than the context window."
116
- // Catches BOTH provider phrasings so the classification does not depend on
117
- // which backend the router landed on:
118
- // - pi/Anthropic wrapper: "too long for this model's context window even
119
- // after compaction. Reduce the conversation length..." ->
120
- // too long | context window | even after compaction | reduce the conversation
121
- // - OpenAI/OpenRouter provider-side (often wrapped in "All targets failed:
122
- // <model>. Last error: ..."): "This model's maximum context length is N
123
- // tokens. Your request requires at least M tokens. Please reduce your
124
- // input or max_tokens." -> maximum context length | context length
125
- // exceeded | requires at least N tokens | reduce your input
126
- // All of these carry `invalid_request_error` (UNDERSCORE, not 'invalid
127
- // request' space) and would otherwise fall through to the generic
128
- // `s.includes('error')` transient branch below, misclassifying as
129
- // 'transient' and firing up to 5 blind retry nudges that re-submit the same
130
- // oversized prompt -> re-400 -> busy-loop. 'context-overflow' instead forces
131
- // ONE deferred re-compact (debounce-bypassed, race-guarded) and fires NO
132
- // blind retry nudge. The forced re-compact is shaped by the existing
133
- // session_before_compact durable trim (it cannot lower pi's firstKeptEntryId).
113
+ if (/already compacted|compaction failed|nothing to compact|auto[\s-]?compaction failed/.test(s)) {
114
+ return { category: 'compaction-noop', signal: 'compaction-noop' };
115
+ }
134
116
  if (/too long|context window|maximum context length|context length exceeded|requires at least \d+ tokens|even after compaction|reduce the conversation|reduce your input/.test(s)) {
135
- return 'context-overflow';
136
- }
137
- // --- transient (specific markers FIRST — these override poisoned signals) ---
138
- // R3: network failures (timeout, ECONNRESET, 5xx, 429) MUST stay transient
139
- // even when usage is 0 tokens, because they are retryable. The specific
140
- // markers below return before any poisoned signal is evaluated.
141
- if (/max(imum)? output token/.test(s))
142
- return 'transient';
143
- if (/rate[\s.-]?limit|429|too many requests/.test(s))
144
- return 'transient';
145
- if (/5\d\d|internal server|bad gateway|service unavailable/.test(s))
146
- return 'transient';
147
- // 'connection aborted' is a network failure (ECONNABORTED), NOT a user ESC
148
- // (which is stopReason 'aborted', handled by the early-return above). Added
149
- // so "Connection aborted by peer" stays transient under R3. ECONNRESET and
150
- // common errno names are included so a bare 0-token 'ECONNRESET' turn is not
151
- // misclassified as poisoned (R6: ECONNRESET → transient).
152
- if (/network|timeout|econnreset|econnrefused|epipe|connection (lost|refused|reset|aborted)|stream (interrupted|closed|ended|failed)|disconnected/.test(s))
153
- return 'transient';
154
- // --- poisoned-context (R3: ORDER AFTER specific transient markers, BEFORE
155
- // the generic 'error' transient fallthrough) ---
156
- // A DETERMINISTIC request-rejection that retrying cannot fix. Re-submitting
157
- // the same prompt re-triggers the same rejection. The 2026-07-28 incident
158
- // was a provider returning 0-token "Request failed — please retry." turns
159
- // for every request; classifyError mapped stopReason 'error' → 'transient'
160
- // and the session emitted ~9 nudge bursts (~60 context-bloating messages)
161
- // before stopping.
162
- //
163
- // Signal 1: provider request-validation 400 that is NOT context-overflow
164
- // (context-overflow already returned above). Orphaned tool result / malformed
165
- // message structure / unexpected role ordering / empty content. These were
166
- // previously classified 'permanent' (max 1 retry) — but a single retry
167
- // re-submits the SAME malformed structure and re-400s, so they are poisoned,
168
- // not permanent. Auth/permission 400s stay permanent (separate check below).
117
+ return { category: 'context-overflow', signal: 'context-overflow' };
118
+ }
119
+ if (isKnownRetryableTransient(s)) {
120
+ const r = { category: 'transient', signal: 'transient-marker' };
121
+ if (httpStatus !== undefined)
122
+ r.httpStatus = httpStatus;
123
+ return r;
124
+ }
125
+ if (httpStatus === 429 || (httpStatus !== undefined && httpStatus >= 500)) {
126
+ return { category: 'transient', signal: 'transient-status', httpStatus };
127
+ }
128
+ if (httpStatus === 401 || httpStatus === 403) {
129
+ return { category: 'permanent', signal: 'permanent-status', httpStatus };
130
+ }
169
131
  if (/invalid_request_error|invalid request|malformed|bad request|orphaned tool|tool (result )?(without|for )|unexpected role|role (ordering|sequence)|empty (content|message|request)/.test(s)) {
170
- return 'poisoned-context';
132
+ return { category: 'poisoned-context', signal: 'poisoned-invalid-request' };
171
133
  }
172
- // Signal 2: generic catch-all "request failed" / "request error" with no
173
- // specific transient marker (transient markers already returned above).
174
- // This is the exact phrasing the 2026-07-28 incident used. Without a
175
- // specific retryable cause, treat the rejection as deterministic.
176
134
  if (/request failed|request error/.test(s)) {
177
- return 'poisoned-context';
178
- }
179
- // Signal 3: stopReason 'error' + usage explicitly 0 tokens (the turn never
180
- // reached the model). Conservative: only when `usage` is PRESENT and 0 — an
181
- // absent usage field means "unknown" and stays transient (mid-response
182
- // deaths / partial-content turns often omit usage). The transient markers
183
- // above already returned, so reaching here with 'error' text and 0 tokens
184
- // means the request was rejected before any model work.
135
+ return { category: 'poisoned-context', signal: 'poisoned-request-failed' };
136
+ }
185
137
  if (usagePresent && totalTokens === 0 && /error/.test(s)) {
186
- return 'poisoned-context';
138
+ return { category: 'transient', signal: 'bare-0-token' };
187
139
  }
188
- // --- transient (generic 'error' stopReason fallthrough) ---
189
- // Reaches here for a generic stopReason 'error' with no specific marker and
190
- // no 0-token signal (usage absent or > 0). Stays transient — conservative.
191
140
  if (s.includes('error') && !/\b(auth|unauthorized|invalid (api )?key|permission)\b/.test(s)) {
192
- return 'transient'; // generic pi stopReason 'error'
193
- }
194
- // NOTE: 'aborted' stopReason is handled by the sr==='aborted' early-return above.
195
- // The text-based s.includes('aborted') was removed — it was the old path that
196
- // misclassified user ESC/Ctrl-C as 'transient' (5 retry nudges after cancel).
197
- // --- permanent (NOT retryable beyond 1) ---
198
- if (/auth|unauthorized|invalid (api )?key|permission/.test(s))
199
- return 'permanent';
200
- // Unknown — do not retry (avoid busy-looping on an unclassified signal).
201
- return null;
141
+ return { category: 'transient', signal: 'generic-error' };
142
+ }
143
+ if (/auth|unauthorized|invalid (api )?key|permission/.test(s)) {
144
+ return { category: 'permanent', signal: 'permanent-auth' };
145
+ }
146
+ return { category: null, signal: 'unknown' };
147
+ }
148
+ /** S38.2: classify a turn-end error/stop signal into a retry category.
149
+ *
150
+ * `length` is returned as null — S28 owns the max-output-token length stopReason
151
+ * exclusively (its agent_end nudge path is separate and must not be doubled).
152
+ *
153
+ * Thin wrapper around classifyErrorDetailed — returns only the category.
154
+ *
155
+ * @param message the event.message (a pi AgentMessage) or an error string
156
+ * @returns 'transient' | 'permanent' | 'compaction-noop' | 'context-overflow' | 'cancelled' | 'poisoned-context' | null (success/unknown)
157
+ */
158
+ export function classifyError(message) {
159
+ return classifyErrorDetailed(message).category;
202
160
  }
203
161
  /** S38.2: exponential backoff for error-retry nudges.
204
162
  * count is 1-based (the retry about to fire). baseMs is the unit
@@ -219,10 +177,33 @@ export function errorRetryBackoffMs(count, baseMs = 5000) {
219
177
  * 'error' for retryable turns) so two turns with the same error content share
220
178
  * a signature. Empty string when there is no error text (e.g. a bare
221
179
  * stopReason 'error' with no content) — the caller skips repeat detection in
222
- * that case (the stateless 0-token signal handles bare errors). */
180
+ * that case (the stateless 0-token signal handles bare errors).
181
+ *
182
+ * R9: when the joined text is empty AND stopReason is 'error' AND usage is
183
+ * present with 0 total tokens, returns the constant "bare-0-token-error" so
184
+ * the repeat detector can corroborate bare 0-token errors (first turn is
185
+ * transient; repeated occurrences upgrade to poisoned at threshold). Bare
186
+ * 'error' WITHOUT usage keeps returning '' (absent usage = unknown; mid-
187
+ * response deaths stay out of repeat tracking). */
188
+ /** R12: normalize volatile tokens in an already-lowercased error string so that
189
+ * equivalent errors with varied model aliases, IPs, hex request-ids, retry
190
+ * counts, and long numeric ids produce the same signature. Order matters.
191
+ * 3-digit HTTP status codes (429/500/502/etc.) survive — the long-number rule
192
+ * only covers 4+ digits. Invariant: normalizeVolatileTokens never empties a
193
+ * non-empty string (every regex replaces with a non-empty literal token). */
194
+ function normalizeVolatileTokens(s) {
195
+ let n = s;
196
+ n = n.replace(/\b\d{1,3}(\.\d{1,3}){3}(:\d+)?\b/g, '<ip>'); // 1. IP:port / bare IPv4
197
+ n = n.replace(/\b[\w-]+(\/[\w.:+-]+)+/g, '<model>'); // 2. slash-separated paths
198
+ n = n.replace(/\b[0-9a-f]{8,}\b/g, '<hex>'); // 3. hex ids (8+ chars)
199
+ n = n.replace(/\b\d{4,}\b/g, '<n>'); // 4. long numbers (4+ digits)
200
+ n = n.replace(/\bafter \d+ attempts?\b/g, 'after <n> attempts'); // 5. attempt phrasing
201
+ n = n.replace(/\s+/g, ' ').trim(); // 6. collapse whitespace
202
+ return n;
203
+ }
223
204
  export function extractErrorSignature(message) {
224
205
  if (typeof message === 'string')
225
- return message.toLowerCase().trim();
206
+ return normalizeVolatileTokens(message.toLowerCase().trim());
226
207
  if (!message || typeof message !== 'object')
227
208
  return '';
228
209
  const m = message;
@@ -249,5 +230,14 @@ export function extractErrorSignature(message) {
249
230
  parts.push(JSON.stringify(err));
250
231
  }
251
232
  }
252
- return parts.join(' ').toLowerCase().trim();
233
+ const joined = parts.join(' ').toLowerCase().trim();
234
+ const normalized = normalizeVolatileTokens(joined);
235
+ if (normalized === '' &&
236
+ m.stopReason === 'error' &&
237
+ m.usage &&
238
+ typeof m.usage === 'object' &&
239
+ (m.usage.inputTokens ?? 0) + (m.usage.outputTokens ?? 0) === 0) {
240
+ return 'bare-0-token-error';
241
+ }
242
+ return normalized;
253
243
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * outage-advisor.ts — R10: calm "provider outage" advisory.
3
+ *
4
+ * When a provider/router flaps (transient errors: timeouts, 5xx, 429s),
5
+ * the extension retries silently and eventually goes quiet after caps. This
6
+ * module sends ONE user-facing advisory per outage episode so the user knows
7
+ * to wait rather than running /clear.
8
+ *
9
+ * Distinct from the poisoned-context /clear advise: the outage advisory is
10
+ * for transient errors where the user's context is fine.
11
+ *
12
+ * PREVENT-PI-003: sends via safeSendUserMessage (user-role only).
13
+ * PREVENT-PI-004: local ctx call, no network.
14
+ */
15
+ import { safeSendUserMessage } from "./send-safe.js";
16
+ /**
17
+ * Check the provider-outage advisory condition and fire once per episode.
18
+ *
19
+ * Called from the transient/permanent retry branch of the turn_end handler,
20
+ * AFTER consecutiveErrors is incremented.
21
+ *
22
+ * @param effectiveCategory — must be "transient" for the advisory to fire.
23
+ * @param runtime — the live MegaRuntime (mutated on advisory fire).
24
+ * @param pi — pi ExtensionAPI for safeSendUserMessage.
25
+ * @param config — providerOutageAdviseThreshold config.
26
+ * @param detail — R11: optional signal + rawText for forensics.
27
+ */
28
+ export async function maybeSendProviderOutageAdvisory(effectiveCategory, runtime, pi, config, detail) {
29
+ if (effectiveCategory !== "transient")
30
+ return;
31
+ if (config.providerOutageAdviseThreshold <= 0)
32
+ return;
33
+ if (runtime.rt.providerOutageAdvised)
34
+ return;
35
+ if (runtime.rt.consecutiveErrors < config.providerOutageAdviseThreshold)
36
+ return;
37
+ runtime.rt.providerOutageAdvised = true;
38
+ runtime.dashboard.event("provider_outage_advised", {
39
+ consecutiveErrors: runtime.rt.consecutiveErrors,
40
+ turnIndex: runtime.currentTurn,
41
+ sessionId: runtime.rt.sessionId,
42
+ ...(detail?.signal !== undefined ? { signal: detail.signal } : {}),
43
+ ...(detail?.rawText !== undefined ? { rawText: detail.rawText } : {}),
44
+ });
45
+ runtime.logger.info("provider-outage-advised", {
46
+ sessionId: runtime.rt.sessionId,
47
+ consecutiveErrors: runtime.rt.consecutiveErrors,
48
+ turnIndex: runtime.currentTurn,
49
+ ...(detail?.signal !== undefined ? { signal: detail.signal } : {}),
50
+ ...(detail?.rawText !== undefined ? { rawText: detail.rawText } : {}),
51
+ });
52
+ await safeSendUserMessage(pi, `[mega-compact] the provider is having issues (${runtime.rt.consecutiveErrors} consecutive failures — timeouts/5xx/rate-limits). Retries are bounded and continue automatically; your context is fine — do NOT clear or reset it. Work resumes as soon as the provider recovers.`);
53
+ }
@@ -38,6 +38,7 @@ export function resetRuntimeImpl(self, sessionId) {
38
38
  lastErrorText: undefined,
39
39
  errorTextRepeatCount: 0,
40
40
  poisonedAdviseSent: false,
41
+ providerOutageAdvised: false,
41
42
  poisonedCompactSignatures: new Set(),
42
43
  poisonedCount: 0,
43
44
  };