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
@@ -12,32 +12,84 @@
12
12
  * poisoned, choose transient (bounded by R1 dedup + R2 session cap).
13
13
  */
14
14
 
15
- /** S38.2: classify a turn-end error/stop signal into a retry category.
16
- *
17
- * `length` is returned as null — S28 owns the max-output-token length stopReason
18
- * exclusively (its agent_end nudge path is separate and must not be doubled).
15
+ /** R7: known-retryable transient markers network/throughput failures that
16
+ * retry can fix and /clear cannot. SINGLE SOURCE OF TRUTH: classifyError uses
17
+ * this for its transient-marker stage, and the agent-handlers R3 repeat
18
+ * upgrade guard uses it to keep these errors out of poisoned-context. Add new
19
+ * transient phrasings HERE only.
19
20
  *
20
- * @param message the event.message (a pi AgentMessage) or an error string
21
- * @returns 'transient' | 'permanent' | 'compaction-noop' | 'context-overflow' | 'cancelled' | 'poisoned-context' | null (success/unknown)
22
- */
23
- export function classifyError(message: unknown):
21
+ * Non-obvious alternatives:
22
+ * - `time(?:d[\s-]?out|out)` covers "timeout", "timed out", "timed-out"
23
+ * "timed out" does NOT match /timeout/ (the 2026-07-30 incident phrasing).
24
+ * - `etimedout` is Node's timeout errno lowercased — does NOT contain the
25
+ * substring "timeout".
26
+ * - `socket hang up` is Node's ECONNRESET *message*; the errno lives in
27
+ * error.code, which extractErrorSignature never sees.
28
+ * - router phrasings (all targets failed / no healthy target / too many
29
+ * concurrent) -- pi's status prefix is console-only (2026-07-30 incident). */
30
+ export const KNOWN_RETRYABLE_TRANSIENT_PATTERN =
31
+ /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/;
32
+
33
+ /** R7: true when the error text carries a known-retryable transient marker.
34
+ * Defensively lowercases so callers can pass raw or normalized text. */
35
+ export function isKnownRetryableTransient(text: string): boolean {
36
+ return KNOWN_RETRYABLE_TRANSIENT_PATTERN.test(text.toLowerCase());
37
+ }
38
+
39
+ /** R8: best-effort extraction of HTTP status from pi AgentMessage shapes. */
40
+ function extractHttpStatus(m: Record<string, unknown>): number | undefined {
41
+ for (const outer of [m.error, m] as Array<Record<string, unknown> | undefined>) {
42
+ if (!outer || typeof outer !== 'object') continue;
43
+ for (const key of ['status', 'statusCode', 'code']) {
44
+ const v = (outer as Record<string, unknown>)[key];
45
+ if (typeof v === 'number' && v >= 100 && v <= 599) return v;
46
+ }
47
+ }
48
+ return undefined;
49
+ }
50
+
51
+ /** R11: signal tag identifying which classification rule fired. */
52
+ export type ErrorSignal =
53
+ | 'length-guard'
54
+ | 'cancelled'
55
+ | 'success'
56
+ | 'stream-death-no-stopreason'
57
+ | 'compaction-noop'
58
+ | 'context-overflow'
59
+ | 'transient-marker'
60
+ | 'transient-status'
61
+ | 'permanent-status'
62
+ | 'poisoned-invalid-request'
63
+ | 'poisoned-request-failed'
64
+ | 'bare-0-token'
65
+ | 'generic-error'
66
+ | 'permanent-auth'
67
+ | 'unknown';
68
+
69
+ export type ErrorCategory =
24
70
  | 'transient'
25
71
  | 'permanent'
26
72
  | 'compaction-noop'
27
73
  | 'context-overflow'
28
74
  | 'cancelled'
29
75
  | 'poisoned-context'
30
- | null {
31
- // Resolve a searchable text blob from a pi AgentMessage or raw string.
76
+ | null;
77
+
78
+ /** R11: detailed classification result with the signal tag and optional httpStatus. */
79
+ export interface ClassifyErrorDetail {
80
+ category: ErrorCategory;
81
+ signal: ErrorSignal;
82
+ httpStatus?: number;
83
+ }
84
+
85
+ /** R11: classify a turn-end error/stop signal into a retry category with a
86
+ * diagnostic signal tag naming the rule that fired, plus the raw httpStatus
87
+ * when extractHttpStatus found one. */
88
+ export function classifyErrorDetailed(message: unknown): ClassifyErrorDetail {
32
89
  let text = '';
33
- // R3: usage-token signal. Only when `usage` is explicitly present with
34
- // inputTokens=0 AND outputTokens=0 do we know the turn never reached the
35
- // model. An ABSENT usage field means "unknown" (the event didn't carry
36
- // token counts) — we do NOT treat that as the 0-token poisoned signal,
37
- // because mid-response stream deaths and partial-content turns routinely
38
- // omit usage while still being transient (preserves all pre-R3 tests).
39
90
  let usagePresent = false;
40
91
  let totalTokens = 0;
92
+ let httpStatus: number | undefined;
41
93
  if (typeof message === 'string') {
42
94
  text = message;
43
95
  } else if (message && typeof message === 'object') {
@@ -48,14 +100,9 @@ export function classifyError(message: unknown):
48
100
  usage?: { inputTokens?: number; outputTokens?: number } | undefined;
49
101
  };
50
102
  const sr = typeof m.stopReason === 'string' ? m.stopReason : '';
51
- // S28 guard: length stopReason is handled exclusively by the S28 path.
52
- if (sr === 'length') return null;
53
- // User ESC / Ctrl-C abort stopReason === 'aborted'. Not retryable:
54
- // nudging would restart a task the user explicitly stopped.
55
- if (sr === 'aborted') return 'cancelled';
56
- // Success / normal tool flow — not an error, nothing to retry.
57
- if (sr === 'stop' || sr === 'toolUse' || sr === 'tool_use') return null;
58
- // R3: extract usage tokens for the 0-token poisoned-context signal.
103
+ if (sr === 'length') return { category: null, signal: 'length-guard' };
104
+ if (sr === 'aborted') return { category: 'cancelled', signal: 'cancelled' };
105
+ if (sr === 'stop' || sr === 'toolUse' || sr === 'tool_use') return { category: null, signal: 'success' };
59
106
  const usage = m.usage;
60
107
  usagePresent = usage != null && typeof usage === 'object';
61
108
  if (usagePresent) {
@@ -64,6 +111,7 @@ export function classifyError(message: unknown):
64
111
  (typeof u.inputTokens === 'number' ? u.inputTokens : 0) +
65
112
  (typeof u.outputTokens === 'number' ? u.outputTokens : 0);
66
113
  }
114
+ httpStatus = extractHttpStatus(m as Record<string, unknown>);
67
115
  const parts: string[] = [];
68
116
  if (sr) parts.push(sr);
69
117
  const c = m.content;
@@ -76,124 +124,77 @@ export function classifyError(message: unknown):
76
124
  }
77
125
  }
78
126
  if (m.error) {
79
- // Extract message from error objects for pattern matching.
80
127
  const err = m.error;
81
- if (typeof err === 'string') {
82
- parts.push(err);
83
- } else if (err && typeof err === 'object') {
128
+ if (typeof err === 'string') parts.push(err);
129
+ else if (err && typeof err === 'object') {
84
130
  const errObj = err as Record<string, unknown>;
85
- if (typeof errObj.message === 'string') {
86
- parts.push(errObj.message);
87
- } else {
88
- parts.push(JSON.stringify(err));
89
- }
131
+ if (typeof errObj.message === 'string') parts.push(errObj.message);
132
+ else parts.push(JSON.stringify(err));
90
133
  }
91
134
  }
92
- // S38: detect mid-response errors where the stream died without a
93
- // proper stopReason (empty/undefined) — this catches provider failures
94
- // that cut off the response mid-stream, INCLUDING the case where the
95
- // provider emitted partial content before dying (a truncated response
96
- // with no stop reason is a mid-stream death, not a success).
97
- // A genuine success ALWAYS carries stop/tool_use/toolUse (which
98
- // short-circuit to null at the top), so any message reaching here with a
99
- // falsy stopReason is a stream failure → retryable.
100
135
  if (!sr) {
101
- return 'transient';
136
+ const r: ClassifyErrorDetail = { category: 'transient', signal: 'stream-death-no-stopreason' };
137
+ if (httpStatus !== undefined) r.httpStatus = httpStatus;
138
+ return r;
102
139
  }
103
140
  text = parts.join(' ');
104
141
  }
105
- if (!text) return null;
142
+ if (!text) return { category: null, signal: 'unknown' };
106
143
  const s = text.toLowerCase();
107
- // --- compaction-noop (ORDER FIRST: pi race / manual compact catch) ---
108
- // FAIL-2026071701: these are NOT retryable — the compaction already
109
- // succeeded via pi's native path; retrying would race again.
110
- if (/already compacted/.test(s)) return 'compaction-noop';
111
- if (/compaction failed/.test(s)) return 'compaction-noop';
112
- if (/nothing to compact/.test(s)) return 'compaction-noop';
113
- if (/auto[\s-]?compaction failed/.test(s)) 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).
144
+ if (/already compacted|compaction failed|nothing to compact|auto[\s-]?compaction failed/.test(s)) {
145
+ return { category: 'compaction-noop', signal: 'compaction-noop' };
146
+ }
134
147
  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)) return 'transient';
142
- if (/rate[\s.-]?limit|429|too many requests/.test(s)) return 'transient';
143
- if (/5\d\d|internal server|bad gateway|service unavailable/.test(s)) return 'transient';
144
- // 'connection aborted' is a network failure (ECONNABORTED), NOT a user ESC
145
- // (which is stopReason 'aborted', handled by the early-return above). Added
146
- // so "Connection aborted by peer" stays transient under R3. ECONNRESET and
147
- // common errno names are included so a bare 0-token 'ECONNRESET' turn is not
148
- // misclassified as poisoned (R6: ECONNRESET → transient).
149
- if (/network|timeout|econnreset|econnrefused|epipe|connection (lost|refused|reset|aborted)|stream (interrupted|closed|ended|failed)|disconnected/.test(s)) return 'transient';
150
- // --- poisoned-context (R3: ORDER AFTER specific transient markers, BEFORE
151
- // the generic 'error' transient fallthrough) ---
152
- // A DETERMINISTIC request-rejection that retrying cannot fix. Re-submitting
153
- // the same prompt re-triggers the same rejection. The 2026-07-28 incident
154
- // was a provider returning 0-token "Request failed — please retry." turns
155
- // for every request; classifyError mapped stopReason 'error' → 'transient'
156
- // and the session emitted ~9 nudge bursts (~60 context-bloating messages)
157
- // before stopping.
158
- //
159
- // Signal 1: provider request-validation 400 that is NOT context-overflow
160
- // (context-overflow already returned above). Orphaned tool result / malformed
161
- // message structure / unexpected role ordering / empty content. These were
162
- // previously classified 'permanent' (max 1 retry) — but a single retry
163
- // re-submits the SAME malformed structure and re-400s, so they are poisoned,
164
- // not permanent. Auth/permission 400s stay permanent (separate check below).
148
+ return { category: 'context-overflow', signal: 'context-overflow' };
149
+ }
150
+ if (isKnownRetryableTransient(s)) {
151
+ const r: ClassifyErrorDetail = { category: 'transient', signal: 'transient-marker' };
152
+ if (httpStatus !== undefined) r.httpStatus = httpStatus;
153
+ return r;
154
+ }
155
+ if (httpStatus === 429 || (httpStatus !== undefined && httpStatus >= 500)) {
156
+ return { category: 'transient', signal: 'transient-status', httpStatus };
157
+ }
158
+ if (httpStatus === 401 || httpStatus === 403) {
159
+ return { category: 'permanent', signal: 'permanent-status', httpStatus };
160
+ }
165
161
  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)) {
166
- return 'poisoned-context';
162
+ return { category: 'poisoned-context', signal: 'poisoned-invalid-request' };
167
163
  }
168
- // Signal 2: generic catch-all "request failed" / "request error" with no
169
- // specific transient marker (transient markers already returned above).
170
- // This is the exact phrasing the 2026-07-28 incident used. Without a
171
- // specific retryable cause, treat the rejection as deterministic.
172
164
  if (/request failed|request error/.test(s)) {
173
- return 'poisoned-context';
174
- }
175
- // Signal 3: stopReason 'error' + usage explicitly 0 tokens (the turn never
176
- // reached the model). Conservative: only when `usage` is PRESENT and 0 — an
177
- // absent usage field means "unknown" and stays transient (mid-response
178
- // deaths / partial-content turns often omit usage). The transient markers
179
- // above already returned, so reaching here with 'error' text and 0 tokens
180
- // means the request was rejected before any model work.
165
+ return { category: 'poisoned-context', signal: 'poisoned-request-failed' };
166
+ }
181
167
  if (usagePresent && totalTokens === 0 && /error/.test(s)) {
182
- return 'poisoned-context';
168
+ return { category: 'transient', signal: 'bare-0-token' };
183
169
  }
184
- // --- transient (generic 'error' stopReason fallthrough) ---
185
- // Reaches here for a generic stopReason 'error' with no specific marker and
186
- // no 0-token signal (usage absent or > 0). Stays transient — conservative.
187
170
  if (s.includes('error') && !/\b(auth|unauthorized|invalid (api )?key|permission)\b/.test(s)) {
188
- return 'transient'; // generic pi stopReason 'error'
189
- }
190
- // NOTE: 'aborted' stopReason is handled by the sr==='aborted' early-return above.
191
- // The text-based s.includes('aborted') was removed — it was the old path that
192
- // misclassified user ESC/Ctrl-C as 'transient' (5 retry nudges after cancel).
193
- // --- permanent (NOT retryable beyond 1) ---
194
- if (/auth|unauthorized|invalid (api )?key|permission/.test(s)) return 'permanent';
195
- // Unknown — do not retry (avoid busy-looping on an unclassified signal).
196
- return null;
171
+ return { category: 'transient', signal: 'generic-error' };
172
+ }
173
+ if (/auth|unauthorized|invalid (api )?key|permission/.test(s)) {
174
+ return { category: 'permanent', signal: 'permanent-auth' };
175
+ }
176
+ return { category: null, signal: 'unknown' };
177
+ }
178
+
179
+ /** S38.2: classify a turn-end error/stop signal into a retry category.
180
+ *
181
+ * `length` is returned as null — S28 owns the max-output-token length stopReason
182
+ * exclusively (its agent_end nudge path is separate and must not be doubled).
183
+ *
184
+ * Thin wrapper around classifyErrorDetailed — returns only the category.
185
+ *
186
+ * @param message the event.message (a pi AgentMessage) or an error string
187
+ * @returns 'transient' | 'permanent' | 'compaction-noop' | 'context-overflow' | 'cancelled' | 'poisoned-context' | null (success/unknown)
188
+ */
189
+ export function classifyError(message: unknown):
190
+ | 'transient'
191
+ | 'permanent'
192
+ | 'compaction-noop'
193
+ | 'context-overflow'
194
+ | 'cancelled'
195
+ | 'poisoned-context'
196
+ | null {
197
+ return classifyErrorDetailed(message).category;
197
198
  }
198
199
 
199
200
  /** S38.2: exponential backoff for error-retry nudges.
@@ -216,13 +217,39 @@ export function errorRetryBackoffMs(count: number, baseMs = 5000): number {
216
217
  * 'error' for retryable turns) so two turns with the same error content share
217
218
  * a signature. Empty string when there is no error text (e.g. a bare
218
219
  * stopReason 'error' with no content) — the caller skips repeat detection in
219
- * that case (the stateless 0-token signal handles bare errors). */
220
+ * that case (the stateless 0-token signal handles bare errors).
221
+ *
222
+ * R9: when the joined text is empty AND stopReason is 'error' AND usage is
223
+ * present with 0 total tokens, returns the constant "bare-0-token-error" so
224
+ * the repeat detector can corroborate bare 0-token errors (first turn is
225
+ * transient; repeated occurrences upgrade to poisoned at threshold). Bare
226
+ * 'error' WITHOUT usage keeps returning '' (absent usage = unknown; mid-
227
+ * response deaths stay out of repeat tracking). */
228
+ /** R12: normalize volatile tokens in an already-lowercased error string so that
229
+ * equivalent errors with varied model aliases, IPs, hex request-ids, retry
230
+ * counts, and long numeric ids produce the same signature. Order matters.
231
+ * 3-digit HTTP status codes (429/500/502/etc.) survive — the long-number rule
232
+ * only covers 4+ digits. Invariant: normalizeVolatileTokens never empties a
233
+ * non-empty string (every regex replaces with a non-empty literal token). */
234
+ function normalizeVolatileTokens(s: string): string {
235
+ let n = s;
236
+ n = n.replace(/\b\d{1,3}(\.\d{1,3}){3}(:\d+)?\b/g, '<ip>'); // 1. IP:port / bare IPv4
237
+ n = n.replace(/\b[\w-]+(\/[\w.:+-]+)+/g, '<model>'); // 2. slash-separated paths
238
+ n = n.replace(/\b[0-9a-f]{8,}\b/g, '<hex>'); // 3. hex ids (8+ chars)
239
+ n = n.replace(/\b\d{4,}\b/g, '<n>'); // 4. long numbers (4+ digits)
240
+ n = n.replace(/\bafter \d+ attempts?\b/g, 'after <n> attempts'); // 5. attempt phrasing
241
+ n = n.replace(/\s+/g, ' ').trim(); // 6. collapse whitespace
242
+ return n;
243
+ }
244
+
220
245
  export function extractErrorSignature(message: unknown): string {
221
- if (typeof message === 'string') return message.toLowerCase().trim();
246
+ if (typeof message === 'string') return normalizeVolatileTokens(message.toLowerCase().trim());
222
247
  if (!message || typeof message !== 'object') return '';
223
248
  const m = message as {
224
249
  content?: unknown;
225
250
  error?: unknown;
251
+ stopReason?: string;
252
+ usage?: { inputTokens?: number; outputTokens?: number };
226
253
  };
227
254
  const parts: string[] = [];
228
255
  const c = m.content;
@@ -243,5 +270,16 @@ export function extractErrorSignature(message: unknown): string {
243
270
  else parts.push(JSON.stringify(err));
244
271
  }
245
272
  }
246
- return parts.join(' ').toLowerCase().trim();
273
+ const joined = parts.join(' ').toLowerCase().trim();
274
+ const normalized = normalizeVolatileTokens(joined);
275
+ if (
276
+ normalized === '' &&
277
+ m.stopReason === 'error' &&
278
+ m.usage &&
279
+ typeof m.usage === 'object' &&
280
+ (m.usage.inputTokens ?? 0) + (m.usage.outputTokens ?? 0) === 0
281
+ ) {
282
+ return 'bare-0-token-error';
283
+ }
284
+ return normalized;
247
285
  }
@@ -0,0 +1,73 @@
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
+
16
+ import type { MegaRuntime } from "../mega-runtime.js";
17
+ import { safeSendUserMessage } from "./send-safe.js";
18
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
+
20
+ /** R11: optional diagnostic detail passed from the classifier. */
21
+ export interface OutageDetail {
22
+ signal?: string;
23
+ rawText?: string;
24
+ }
25
+
26
+ /**
27
+ * Check the provider-outage advisory condition and fire once per episode.
28
+ *
29
+ * Called from the transient/permanent retry branch of the turn_end handler,
30
+ * AFTER consecutiveErrors is incremented.
31
+ *
32
+ * @param effectiveCategory — must be "transient" for the advisory to fire.
33
+ * @param runtime — the live MegaRuntime (mutated on advisory fire).
34
+ * @param pi — pi ExtensionAPI for safeSendUserMessage.
35
+ * @param config — providerOutageAdviseThreshold config.
36
+ * @param detail — R11: optional signal + rawText for forensics.
37
+ */
38
+ export async function maybeSendProviderOutageAdvisory(
39
+ effectiveCategory: string,
40
+ runtime: MegaRuntime,
41
+ pi: ExtensionAPI,
42
+ config: { providerOutageAdviseThreshold: number },
43
+ detail?: OutageDetail,
44
+ ): Promise<void> {
45
+ if (effectiveCategory !== "transient") return;
46
+ if (config.providerOutageAdviseThreshold <= 0) return;
47
+ if (runtime.rt.providerOutageAdvised) return;
48
+ if (runtime.rt.consecutiveErrors < config.providerOutageAdviseThreshold)
49
+ return;
50
+
51
+ runtime.rt.providerOutageAdvised = true;
52
+
53
+ runtime.dashboard.event("provider_outage_advised", {
54
+ consecutiveErrors: runtime.rt.consecutiveErrors,
55
+ turnIndex: runtime.currentTurn,
56
+ sessionId: runtime.rt.sessionId,
57
+ ...(detail?.signal !== undefined ? { signal: detail.signal } : {}),
58
+ ...(detail?.rawText !== undefined ? { rawText: detail.rawText } : {}),
59
+ });
60
+
61
+ runtime.logger.info("provider-outage-advised", {
62
+ sessionId: runtime.rt.sessionId,
63
+ consecutiveErrors: runtime.rt.consecutiveErrors,
64
+ turnIndex: runtime.currentTurn,
65
+ ...(detail?.signal !== undefined ? { signal: detail.signal } : {}),
66
+ ...(detail?.rawText !== undefined ? { rawText: detail.rawText } : {}),
67
+ });
68
+
69
+ await safeSendUserMessage(
70
+ pi,
71
+ `[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.`,
72
+ );
73
+ }
@@ -67,6 +67,8 @@ export interface SessionRuntime {
67
67
  errorTextRepeatCount: number; // consecutive count of identical error signatures
68
68
  // R3b: one-per-session /clear advise message throttle.
69
69
  poisonedAdviseSent: boolean; // true after the advise message fires once
70
+ // R10: provider-outage advisory throttle (one per flapping episode).
71
+ providerOutageAdvised: boolean; // true after the outage advise message fires once
70
72
  // R3c: one guarded compact per error signature (avoids re-compacting the
71
73
  // same poisoned region repeatedly).
72
74
  poisonedCompactSignatures: Set<string>; // signatures already attempted a compact for
@@ -68,6 +68,7 @@ export function resetRuntimeImpl(
68
68
  lastErrorText: undefined,
69
69
  errorTextRepeatCount: 0,
70
70
  poisonedAdviseSent: false,
71
+ providerOutageAdvised: false,
71
72
  poisonedCompactSignatures: new Set(),
72
73
  poisonedCount: 0,
73
74
  };
@@ -20,20 +20,25 @@
20
20
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
21
21
  import { join } from "node:path";
22
22
  import {
23
- VectorStore,
23
+ type VectorStore,
24
24
  vectorStats,
25
25
  vectorRepoStats,
26
26
  vectorDataInvariant,
27
27
  } from "../../src/vectorStore.js";
28
28
  import {
29
29
  latestModelSnapshot,
30
+ readLatestCacheHitPct,
30
31
  recordPerfSample,
31
32
  recordSessionHeartbeat,
32
33
  appendTokenSample,
33
34
  type GameState,
34
35
  } from "../../src/store/sqlite.js";
35
- import { resolveRepoRoot, type MegaConfig, type PressureBand } from "../mega-config.js";
36
- import { Dashboard } from "../mega-dashboard.js";
36
+ import {
37
+ resolveRepoRoot,
38
+ type MegaConfig,
39
+ type PressureBand,
40
+ } from "../mega-config.js";
41
+ import type { Dashboard } from "../mega-dashboard.js";
37
42
  import type { WidgetData } from "./widget.js";
38
43
  import { computeMegaSnapshot } from "./snapshot.js";
39
44
  import { buildDashboardSnapshot } from "./dashboard-snapshot.js";
@@ -93,7 +98,10 @@ export interface RuntimeSnapshotContext extends RuntimeHelpersContext {
93
98
  /** Collect live state and write it to disk (+ paint the above-editor widget).
94
99
  * Extracted verbatim from `MegaRuntime.snapshot()` (runtime.ts); the public
95
100
  * method there is now `snapshotImpl(this, ctx)`. */
96
- export function snapshotImpl(self: RuntimeSnapshotContext, ctx?: ExtensionContext): void {
101
+ export function snapshotImpl(
102
+ self: RuntimeSnapshotContext,
103
+ ctx?: ExtensionContext,
104
+ ): void {
97
105
  if (ctx) self.lastWidgetCtx = ctx;
98
106
  if (ctx) self.bindRepo(ctx.cwd);
99
107
  // v0.8.5: gate the expensive body (6 sync SQLite opens +
@@ -179,7 +187,8 @@ export function snapshotImpl(self: RuntimeSnapshotContext, ctx?: ExtensionContex
179
187
  // try/catch mirrors the recordPerfSample pattern below. Skip the token
180
188
  // sample when lastCtxTokens is null (no context data yet).
181
189
  try {
182
- const repo = resolveRepoRoot(ctx?.cwd ?? self.currentStateDir) ?? self.currentStateDir;
190
+ const repo =
191
+ resolveRepoRoot(ctx?.cwd ?? self.currentStateDir) ?? self.currentStateDir;
183
192
  recordSessionHeartbeat(
184
193
  process.pid,
185
194
  self.rt.sessionId,
@@ -194,7 +203,7 @@ export function snapshotImpl(self: RuntimeSnapshotContext, ctx?: ExtensionContex
194
203
  self.lastCtxTokens,
195
204
  self.lastCtxPercent ?? 0,
196
205
  self.lastCtxWindow || 0,
197
- join(self.currentStateDir, "events.log"),
206
+ join(self.currentStateDir, "events.log"),
198
207
  );
199
208
  }
200
209
  } catch {
@@ -217,6 +226,15 @@ export function snapshotImpl(self: RuntimeSnapshotContext, ctx?: ExtensionContex
217
226
  }
218
227
  // ── gather widget data (computed per snapshot, rendered per frame) ────
219
228
  const modelSnap = latestModelSnapshot(self.currentStateDir);
229
+ // Provider prompt cache hit % for the widget (B/C): latest
230
+ // cache_hit_pct sample. Non-fatal — one extra sync open per
231
+ // material-change-gated recompute, acceptably cheap.
232
+ let providerCachePct = 0;
233
+ try {
234
+ providerCachePct = readLatestCacheHitPct(self.currentStateDir);
235
+ } catch {
236
+ /* non-fatal */
237
+ }
220
238
  const _snapResult = computeMegaSnapshot({
221
239
  lastCtxTokens: self.lastCtxTokens,
222
240
  lastCtxWindow: self.lastCtxWindow,
@@ -248,6 +266,7 @@ export function snapshotImpl(self: RuntimeSnapshotContext, ctx?: ExtensionContex
248
266
  ready,
249
267
  armed,
250
268
  modelSnap,
269
+ providerCachePct,
251
270
  });
252
271
  self.widgetData = _snapResult.widgetData;
253
272
  // S33: consume the flare after copying it into widgetData so it fires
@@ -271,8 +290,7 @@ export function snapshotImpl(self: RuntimeSnapshotContext, ctx?: ExtensionContex
271
290
  // free the slot and prevent a stale effect lingering between snapshots.
272
291
  if (
273
292
  self.activeEffect &&
274
- Date.now() - self.activeEffect.startedAt >=
275
- self.activeEffect.durationMs
293
+ Date.now() - self.activeEffect.startedAt >= self.activeEffect.durationMs
276
294
  ) {
277
295
  self.activeEffect = null;
278
296
  }
@@ -284,7 +302,11 @@ export function snapshotImpl(self: RuntimeSnapshotContext, ctx?: ExtensionContex
284
302
  // v0.8.5: record the material-change signature computed at the top so the
285
303
  // next snapshot() can skip this whole body when nothing material changed.
286
304
  try {
287
- recordPerfSample(self.currentStateDir, "db_recompute_ms", performance.now() - perfT0);
305
+ recordPerfSample(
306
+ self.currentStateDir,
307
+ "db_recompute_ms",
308
+ performance.now() - perfT0,
309
+ );
288
310
  recordPerfSample(self.currentStateDir, "disk_write_ms", perfDiskMs);
289
311
  } catch {
290
312
  /* non-fatal: perf instrumentation never blocks the agent */
@@ -101,6 +101,7 @@ export class MegaRuntime {
101
101
  lastErrorText: undefined,
102
102
  errorTextRepeatCount: 0,
103
103
  poisonedAdviseSent: false,
104
+ providerOutageAdvised: false,
104
105
  poisonedCompactSignatures: new Set(),
105
106
  poisonedCount: 0,
106
107
  };