pi-mega-compact 0.8.16 → 0.8.17

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.
package/README.md CHANGED
@@ -80,7 +80,7 @@ Detailed architecture: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)
80
80
 
81
81
  ```bash
82
82
  npm run build # TypeScript compile
83
- npm test # Build + 353 tests
83
+ npm test # Build + 609 tests
84
84
  npm run lint # Type check + guardrails scan
85
85
  ```
86
86
 
@@ -179,6 +179,39 @@ test("S38: classifyError returns 'compaction-noop' for 'Auto compaction failed'
179
179
  assert.equal(classifyErrorFn("Auto compaction failed"), "compaction-noop");
180
180
  assert.equal(classifyErrorFn("Auto-compaction failed"), "compaction-noop");
181
181
  });
182
+ // ---- context-overflow classifier (S38.8: 400 "too long... even after compaction") ----
183
+ test("S38: classifyError returns 'context-overflow' for the literal user 400 string", () => {
184
+ assert.equal(classifyErrorFn("Your conversation is too long for this model's context window even after compaction. Reduce the conversation length or enable/allow compaction."), "context-overflow");
185
+ });
186
+ test("S38: classifyError returns 'context-overflow' for 'All targets failed' wrapper", () => {
187
+ assert.equal(classifyErrorFn("All targets failed: ... Last error: ... too long ... context window even after compaction"), "context-overflow");
188
+ });
189
+ test("S38: classifyError returns 'context-overflow' for invalid_request_error JSON shape", () => {
190
+ assert.equal(classifyErrorFn('{"type":"invalid_request_error","message":"... too long for context window ..."}'), "context-overflow");
191
+ });
192
+ test("S38: classifyError returns 'context-overflow' for OpenRouter 'All targets failed' max-context 400 (FAIL-20260725)", () => {
193
+ // Regression: the OpenAI/OpenRouter provider-side phrasing
194
+ // "maximum context length is N tokens ... requires at least M tokens ...
195
+ // reduce your input or max_tokens" does NOT contain "too long" / "context
196
+ // window" / "reduce the conversation", so it slipped past the original S38.8
197
+ // regex and fell through to the generic transient branch, firing 5 blind
198
+ // retry nudges that re-submitted the same oversized prompt -> re-400 ->
199
+ // busy-loop. The exact user-facing wrapper string from the router:
200
+ assert.equal(classifyErrorFn('Error: 400: {"message":"All targets failed: neuralwatt/glm-5.2-short. Last error: This model\'s maximum context length is 200000 tokens. Your request requires at least 201070 tokens (201070 prompt + 0 max_tokens). Please reduce your input or max_tokens.","type":"invalid_request_error"}'), "context-overflow");
201
+ });
202
+ test("S38: classifyError returns 'context-overflow' for bare 'maximum context length' phrasing", () => {
203
+ assert.equal(classifyErrorFn("This model's maximum context length is 128000 tokens. Your request requires at least 130000 tokens. Please reduce your input or max_tokens."), "context-overflow");
204
+ });
205
+ test("S38: classifyError returns 'context-overflow' for 'context length exceeded' phrasing", () => {
206
+ assert.equal(classifyErrorFn("This model's context length exceeded: 200000 tokens"), "context-overflow");
207
+ });
208
+ test("S38: context-overflow fires NO blind retry nudge and logs 'context_overflow'", async () => {
209
+ const h = harness();
210
+ await s38TurnEnd(h, "error", "Your conversation is too long for this model's context window even after compaction. Reduce the conversation length or enable/allow compaction.");
211
+ const ev = eventTypes(h.stateDir);
212
+ assert.equal(h.sendUserMessages.length, 0, "context-overflow: NO blind retry nudge fired");
213
+ assert.ok(ev.includes("context_overflow"), "context-overflow: 'context_overflow' event logged");
214
+ });
182
215
  // ---- integration tests (fire turn_end through the real extension) ----
183
216
  test("S38: compaction-noop logs 'compaction_noop_diagnostic' + resets counter + no retry fired", async () => {
184
217
  const h = harness();
@@ -5,6 +5,7 @@ import { evaluateAndUnlockAchievements } from "../../src/store/sqlite/game-achie
5
5
  import { isMegaCache } from "../../src/game/scoring.js";
6
6
  import { resolveRepoRoot } from "../mega-config.js";
7
7
  import { classifyError } from "./error-classifier.js";
8
+ import { safeSendUserMessage } from "./send-safe.js";
8
9
  /** Register agent/turn tracking event handlers. */
9
10
  export function registerAgentHandlers(pi, runtime, config) {
10
11
  // ---- Agent tracking for real-time widget + status-line updates ---------
@@ -188,7 +189,7 @@ export function registerAgentHandlers(pi, runtime, config) {
188
189
  const nudgeMsg = lengthStop && !didDurableTrim
189
190
  ? "[mega-compact] the last response hit the output-token cap; continue from where it stopped."
190
191
  : "[mega-compact] continue from the compacted context above.";
191
- pi.sendUserMessage(nudgeMsg);
192
+ await safeSendUserMessage(pi, nudgeMsg);
192
193
  }
193
194
  }
194
195
  catch {
@@ -312,6 +313,53 @@ export function registerAgentHandlers(pi, runtime, config) {
312
313
  turnIndex: event.turnIndex,
313
314
  });
314
315
  }
316
+ else if (category === 'context-overflow') {
317
+ // (4b) context-window overflow 400 ("too long... even after compaction").
318
+ // NOT a blind retry: re-submitting the same oversized prompt would just
319
+ // re-400 and busy-loop. Reset the counters (this turn is terminal, not
320
+ // retryable in the S38 sense) and force ONE best-effort re-compact with
321
+ // the debounce bypassed + the same race-guard (lastNativeCompactAt
322
+ // cooldown + deferred setTimeout re-check) as the agent_end durable
323
+ // trim. Resume after that forced compact is handled by the existing
324
+ // nudgeResume() inside session_before_compact (it fires after
325
+ // driveNativeCompaction supplies the compaction) — do NOT add a
326
+ // separate nudge here.
327
+ runtime.rt.errorRetryCount = 0;
328
+ runtime.rt.consecutiveErrors = 0;
329
+ runtime.dashboard.event('context_overflow', {
330
+ turnIndex: event.turnIndex,
331
+ sessionId: runtime.rt.sessionId,
332
+ });
333
+ runtime.logger.warn('context-overflow', {
334
+ sessionId: runtime.rt.sessionId,
335
+ turnIndex: event.turnIndex,
336
+ });
337
+ if (config.auto) {
338
+ const now2 = Date.now();
339
+ const cooldownMs2 = config.raceGuardStrict ? 30_000 : 10_000;
340
+ const sinceCompact2 = now2 - (runtime.rt.lastNativeCompactAt ?? 0);
341
+ if (sinceCompact2 >= cooldownMs2 && !piCompactWouldNoop(ctx)) {
342
+ runtime.debounceUntil = now2 + 0;
343
+ const stamp2 = runtime.rt.lastNativeCompactAt;
344
+ const liveSid2 = runtime.rt.sessionId;
345
+ setTimeout(() => {
346
+ try {
347
+ if (runtime.rt.sessionId !== liveSid2)
348
+ return; // session reset
349
+ const since3 = Date.now() - (runtime.rt.lastNativeCompactAt ?? 0);
350
+ if (runtime.rt.lastNativeCompactAt !== stamp2 && since3 < cooldownMs2)
351
+ return;
352
+ if (piCompactWouldNoop(ctx))
353
+ return;
354
+ ctx.compact({ customInstructions: undefined }); // guardrails-allow PREVENT-PI-004: local ctx.compact() — no network; deferred + re-validated. Forced re-compact after a context-overflow 400.
355
+ }
356
+ catch {
357
+ /* non-fatal */
358
+ }
359
+ }, 500);
360
+ }
361
+ }
362
+ }
315
363
  else {
316
364
  // (5) transient or permanent — retry with exponential backoff.
317
365
  // S38.7: hard-stop switch — bypass ALL retry logic when set.
@@ -388,8 +436,8 @@ export function registerAgentHandlers(pi, runtime, config) {
388
436
  count: runtime.rt.errorRetryCount,
389
437
  max,
390
438
  });
391
- // PREVENT-PI-003: user-role sendUserMessage only.
392
- pi.sendUserMessage('[mega-compact] the last turn ended with an error; please retry.');
439
+ // PREVENT-PI-003: user-role sendUserMessage only (queued + catch-guarded).
440
+ await safeSendUserMessage(pi, '[mega-compact] the last turn ended with an error; please retry.');
393
441
  }
394
442
  }
395
443
  }
@@ -3,6 +3,7 @@ import { estimateBlockTokens } from "../../src/tokens.js";
3
3
  import { recordScore, getDedupStats } from "../../src/store/sqlite.js";
4
4
  import { evaluateAndUnlockAchievements } from "../../src/store/sqlite/game-achievements.js";
5
5
  import { resolveRepoRoot } from "../mega-config.js";
6
+ import { safeSendUserMessage } from "./send-safe.js";
6
7
  /**
7
8
  * Build a minimal fallback compaction so pi never runs its throwing compact().
8
9
  *
@@ -34,13 +35,18 @@ function fallbackCompaction(event) {
34
35
  /**
35
36
  * Debounced resume-nudge: restart the agent loop after a compaction (which
36
37
  * may have stopped it). Idempotent — one nudge per 30s, never blocks.
38
+ *
39
+ * Uses safeSendUserMessage ({ deliverAs: 'followUp' } + catch-guard) so that a
40
+ * nudge fired during session_before_compact (which is mid-prompt-submission,
41
+ * so the agent can be busy) QUEUES instead of throwing
42
+ * "Agent is already processing. Specify streamingBehavior (steer or followUp)".
37
43
  */
38
- function nudgeResume(pi, runtime) {
44
+ async function nudgeResume(pi, runtime) {
39
45
  try {
40
46
  const now = Date.now();
41
47
  if (now >= runtime.resumeNudgeUntil) {
42
48
  runtime.resumeNudgeUntil = now + 30_000;
43
- pi.sendUserMessage("[mega-compact] continue from the compacted context above.");
49
+ await safeSendUserMessage(pi, "[mega-compact] continue from the compacted context above.");
44
50
  }
45
51
  }
46
52
  catch {
@@ -83,7 +89,7 @@ export function registerCompactHandlers(pi, runtime, config) {
83
89
  tokensBefore: result.compaction.tokensBefore,
84
90
  summaryTokens: result.compaction.estimatedTokensAfter,
85
91
  });
86
- nudgeResume(pi, runtime);
92
+ await nudgeResume(pi, runtime);
87
93
  return { compaction: result.compaction };
88
94
  }
89
95
  // FIX "compacts but doesn't resume" + "Nothing to compact" regression:
@@ -104,7 +110,7 @@ export function registerCompactHandlers(pi, runtime, config) {
104
110
  tokensBefore: fb.compaction.tokensBefore,
105
111
  reason: event.reason,
106
112
  });
107
- nudgeResume(pi, runtime);
113
+ await nudgeResume(pi, runtime);
108
114
  return { compaction: fb.compaction };
109
115
  }
110
116
  }
@@ -11,7 +11,7 @@
11
11
  * exclusively (its agent_end nudge path is separate and must not be doubled).
12
12
  *
13
13
  * @param message the event.message (a pi AgentMessage) or an error string
14
- * @returns 'transient' | 'permanent' | 'compaction-noop' | null (success/unknown)
14
+ * @returns 'transient' | 'permanent' | 'compaction-noop' | 'context-overflow' | null (success/unknown)
15
15
  */
16
16
  export function classifyError(message) {
17
17
  // Resolve a searchable text blob from a pi AgentMessage or raw string.
@@ -84,6 +84,29 @@ export function classifyError(message) {
84
84
  return 'compaction-noop';
85
85
  if (/auto[\s-]?compaction failed/.test(s))
86
86
  return 'compaction-noop';
87
+ // --- context-overflow (ORDER BEFORE generic transient!) ---
88
+ // A 400 from the model meaning "the prompt is bigger than the context window."
89
+ // Catches BOTH provider phrasings so the classification does not depend on
90
+ // which backend the router landed on:
91
+ // - pi/Anthropic wrapper: "too long for this model's context window even
92
+ // after compaction. Reduce the conversation length..." ->
93
+ // too long | context window | even after compaction | reduce the conversation
94
+ // - OpenAI/OpenRouter provider-side (often wrapped in "All targets failed:
95
+ // <model>. Last error: ..."): "This model's maximum context length is N
96
+ // tokens. Your request requires at least M tokens. Please reduce your
97
+ // input or max_tokens." -> maximum context length | context length
98
+ // exceeded | requires at least N tokens | reduce your input
99
+ // All of these carry `invalid_request_error` (UNDERSCORE, not 'invalid
100
+ // request' space) and would otherwise fall through to the generic
101
+ // `s.includes('error')` transient branch below, misclassifying as
102
+ // 'transient' and firing up to 5 blind retry nudges that re-submit the same
103
+ // oversized prompt -> re-400 -> busy-loop. 'context-overflow' instead forces
104
+ // ONE deferred re-compact (debounce-bypassed, race-guarded) and fires NO
105
+ // blind retry nudge. The forced re-compact is shaped by the existing
106
+ // session_before_compact durable trim (it cannot lower pi's firstKeptEntryId).
107
+ 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)) {
108
+ return 'context-overflow';
109
+ }
87
110
  // --- transient (retryable) ---
88
111
  if (s.includes('error') && !/\b(permanent|invalid request|malformed|bad request|auth|unauthorized|invalid (api )?key|permission)\b/.test(s)) {
89
112
  return 'transient'; // generic pi stopReason 'error' / 'aborted'
@@ -0,0 +1,12 @@
1
+ /**
2
+ * safeSendUserMessage — await + catch-guard + queue-safe wrapper for
3
+ * pi.sendUserMessage. Never throws; never produces an unhandled rejection.
4
+ */
5
+ export async function safeSendUserMessage(pi, content) {
6
+ try {
7
+ await pi.sendUserMessage(content, { deliverAs: "followUp" });
8
+ }
9
+ catch {
10
+ /* non-fatal: a failed/queued nudge never blocks the agent loop */
11
+ }
12
+ }
@@ -17,3 +17,4 @@ export * from "./mega-events/context-handler.js";
17
17
  export * from "./mega-events/compact-handlers.js";
18
18
  export * from "./mega-events/perf-handler.js";
19
19
  export * from "./mega-events/error-classifier.js";
20
+ export * from "./mega-events/send-safe.js";
@@ -191,6 +191,69 @@ test("S38: classifyError returns 'compaction-noop' for 'Auto compaction failed'
191
191
  assert.equal(classifyErrorFn("Auto-compaction failed"), "compaction-noop");
192
192
  });
193
193
 
194
+ // ---- context-overflow classifier (S38.8: 400 "too long... even after compaction") ----
195
+
196
+ test("S38: classifyError returns 'context-overflow' for the literal user 400 string", () => {
197
+ assert.equal(
198
+ classifyErrorFn("Your conversation is too long for this model's context window even after compaction. Reduce the conversation length or enable/allow compaction."),
199
+ "context-overflow",
200
+ );
201
+ });
202
+
203
+ test("S38: classifyError returns 'context-overflow' for 'All targets failed' wrapper", () => {
204
+ assert.equal(
205
+ classifyErrorFn("All targets failed: ... Last error: ... too long ... context window even after compaction"),
206
+ "context-overflow",
207
+ );
208
+ });
209
+
210
+ test("S38: classifyError returns 'context-overflow' for invalid_request_error JSON shape", () => {
211
+ assert.equal(
212
+ classifyErrorFn('{"type":"invalid_request_error","message":"... too long for context window ..."}'),
213
+ "context-overflow",
214
+ );
215
+ });
216
+
217
+ test("S38: classifyError returns 'context-overflow' for OpenRouter 'All targets failed' max-context 400 (FAIL-20260725)", () => {
218
+ // Regression: the OpenAI/OpenRouter provider-side phrasing
219
+ // "maximum context length is N tokens ... requires at least M tokens ...
220
+ // reduce your input or max_tokens" does NOT contain "too long" / "context
221
+ // window" / "reduce the conversation", so it slipped past the original S38.8
222
+ // regex and fell through to the generic transient branch, firing 5 blind
223
+ // retry nudges that re-submitted the same oversized prompt -> re-400 ->
224
+ // busy-loop. The exact user-facing wrapper string from the router:
225
+ assert.equal(
226
+ classifyErrorFn('Error: 400: {"message":"All targets failed: neuralwatt/glm-5.2-short. Last error: This model\'s maximum context length is 200000 tokens. Your request requires at least 201070 tokens (201070 prompt + 0 max_tokens). Please reduce your input or max_tokens.","type":"invalid_request_error"}'),
227
+ "context-overflow",
228
+ );
229
+ });
230
+
231
+ test("S38: classifyError returns 'context-overflow' for bare 'maximum context length' phrasing", () => {
232
+ assert.equal(
233
+ classifyErrorFn("This model's maximum context length is 128000 tokens. Your request requires at least 130000 tokens. Please reduce your input or max_tokens."),
234
+ "context-overflow",
235
+ );
236
+ });
237
+
238
+ test("S38: classifyError returns 'context-overflow' for 'context length exceeded' phrasing", () => {
239
+ assert.equal(
240
+ classifyErrorFn("This model's context length exceeded: 200000 tokens"),
241
+ "context-overflow",
242
+ );
243
+ });
244
+
245
+ test("S38: context-overflow fires NO blind retry nudge and logs 'context_overflow'", async () => {
246
+ const h = harness();
247
+ await s38TurnEnd(
248
+ h,
249
+ "error",
250
+ "Your conversation is too long for this model's context window even after compaction. Reduce the conversation length or enable/allow compaction.",
251
+ );
252
+ const ev = eventTypes(h.stateDir);
253
+ assert.equal(h.sendUserMessages.length, 0, "context-overflow: NO blind retry nudge fired");
254
+ assert.ok(ev.includes("context_overflow"), "context-overflow: 'context_overflow' event logged");
255
+ });
256
+
194
257
  // ---- integration tests (fire turn_end through the real extension) ----
195
258
 
196
259
  test("S38: compaction-noop logs 'compaction_noop_diagnostic' + resets counter + no retry fired", async () => {
@@ -19,6 +19,7 @@ import { evaluateAndUnlockAchievements } from "../../src/store/sqlite/game-achie
19
19
  import { isMegaCache } from "../../src/game/scoring.js";
20
20
  import { resolveRepoRoot } from "../mega-config.js";
21
21
  import { classifyError } from "./error-classifier.js";
22
+ import { safeSendUserMessage } from "./send-safe.js";
22
23
 
23
24
  /** Register agent/turn tracking event handlers. */
24
25
  export function registerAgentHandlers(
@@ -215,7 +216,7 @@ export function registerAgentHandlers(
215
216
  const nudgeMsg = lengthStop && !didDurableTrim
216
217
  ? "[mega-compact] the last response hit the output-token cap; continue from where it stopped."
217
218
  : "[mega-compact] continue from the compacted context above.";
218
- pi.sendUserMessage(nudgeMsg);
219
+ await safeSendUserMessage(pi, nudgeMsg);
219
220
  }
220
221
  } catch {
221
222
  /* non-fatal: a failed nudge never blocks */
@@ -346,6 +347,48 @@ export function registerAgentHandlers(
346
347
  sessionId: runtime.rt.sessionId,
347
348
  turnIndex: event.turnIndex,
348
349
  });
350
+ } else if (category === 'context-overflow') {
351
+ // (4b) context-window overflow 400 ("too long... even after compaction").
352
+ // NOT a blind retry: re-submitting the same oversized prompt would just
353
+ // re-400 and busy-loop. Reset the counters (this turn is terminal, not
354
+ // retryable in the S38 sense) and force ONE best-effort re-compact with
355
+ // the debounce bypassed + the same race-guard (lastNativeCompactAt
356
+ // cooldown + deferred setTimeout re-check) as the agent_end durable
357
+ // trim. Resume after that forced compact is handled by the existing
358
+ // nudgeResume() inside session_before_compact (it fires after
359
+ // driveNativeCompaction supplies the compaction) — do NOT add a
360
+ // separate nudge here.
361
+ runtime.rt.errorRetryCount = 0;
362
+ runtime.rt.consecutiveErrors = 0;
363
+ runtime.dashboard.event('context_overflow', {
364
+ turnIndex: event.turnIndex,
365
+ sessionId: runtime.rt.sessionId,
366
+ });
367
+ runtime.logger.warn('context-overflow', {
368
+ sessionId: runtime.rt.sessionId,
369
+ turnIndex: event.turnIndex,
370
+ });
371
+ if (config.auto) {
372
+ const now2 = Date.now();
373
+ const cooldownMs2 = config.raceGuardStrict ? 30_000 : 10_000;
374
+ const sinceCompact2 = now2 - (runtime.rt.lastNativeCompactAt ?? 0);
375
+ if (sinceCompact2 >= cooldownMs2 && !piCompactWouldNoop(ctx)) {
376
+ runtime.debounceUntil = now2 + 0;
377
+ const stamp2 = runtime.rt.lastNativeCompactAt;
378
+ const liveSid2 = runtime.rt.sessionId;
379
+ setTimeout(() => {
380
+ try {
381
+ if (runtime.rt.sessionId !== liveSid2) return; // session reset
382
+ const since3 = Date.now() - (runtime.rt.lastNativeCompactAt ?? 0);
383
+ if (runtime.rt.lastNativeCompactAt !== stamp2 && since3 < cooldownMs2) return;
384
+ if (piCompactWouldNoop(ctx)) return;
385
+ ctx.compact({ customInstructions: undefined }); // guardrails-allow PREVENT-PI-004: local ctx.compact() — no network; deferred + re-validated. Forced re-compact after a context-overflow 400.
386
+ } catch {
387
+ /* non-fatal */
388
+ }
389
+ }, 500);
390
+ }
391
+ }
349
392
  } else {
350
393
  // (5) transient or permanent — retry with exponential backoff.
351
394
  // S38.7: hard-stop switch — bypass ALL retry logic when set.
@@ -421,8 +464,9 @@ export function registerAgentHandlers(
421
464
  count: runtime.rt.errorRetryCount,
422
465
  max,
423
466
  });
424
- // PREVENT-PI-003: user-role sendUserMessage only.
425
- pi.sendUserMessage(
467
+ // PREVENT-PI-003: user-role sendUserMessage only (queued + catch-guarded).
468
+ await safeSendUserMessage(
469
+ pi,
426
470
  '[mega-compact] the last turn ended with an error; please retry.',
427
471
  );
428
472
  }
@@ -22,6 +22,7 @@ import type { MegaConfig } from "../mega-config.js";
22
22
  import { recordScore, getDedupStats } from "../../src/store/sqlite.js";
23
23
  import { evaluateAndUnlockAchievements } from "../../src/store/sqlite/game-achievements.js";
24
24
  import { resolveRepoRoot } from "../mega-config.js";
25
+ import { safeSendUserMessage } from "./send-safe.js";
25
26
 
26
27
  /**
27
28
  * Build a minimal fallback compaction so pi never runs its throwing compact().
@@ -57,13 +58,19 @@ function fallbackCompaction(
57
58
  /**
58
59
  * Debounced resume-nudge: restart the agent loop after a compaction (which
59
60
  * may have stopped it). Idempotent — one nudge per 30s, never blocks.
61
+ *
62
+ * Uses safeSendUserMessage ({ deliverAs: 'followUp' } + catch-guard) so that a
63
+ * nudge fired during session_before_compact (which is mid-prompt-submission,
64
+ * so the agent can be busy) QUEUES instead of throwing
65
+ * "Agent is already processing. Specify streamingBehavior (steer or followUp)".
60
66
  */
61
- function nudgeResume(pi: ExtensionAPI, runtime: MegaRuntime): void {
67
+ async function nudgeResume(pi: ExtensionAPI, runtime: MegaRuntime): Promise<void> {
62
68
  try {
63
69
  const now = Date.now();
64
70
  if (now >= runtime.resumeNudgeUntil) {
65
71
  runtime.resumeNudgeUntil = now + 30_000;
66
- pi.sendUserMessage(
72
+ await safeSendUserMessage(
73
+ pi,
67
74
  "[mega-compact] continue from the compacted context above.",
68
75
  );
69
76
  }
@@ -113,7 +120,7 @@ export function registerCompactHandlers(
113
120
  tokensBefore: result.compaction.tokensBefore,
114
121
  summaryTokens: result.compaction.estimatedTokensAfter,
115
122
  });
116
- nudgeResume(pi, runtime);
123
+ await nudgeResume(pi, runtime);
117
124
  return { compaction: result.compaction };
118
125
  }
119
126
  // FIX "compacts but doesn't resume" + "Nothing to compact" regression:
@@ -134,7 +141,7 @@ export function registerCompactHandlers(
134
141
  tokensBefore: fb.compaction.tokensBefore,
135
142
  reason: event.reason,
136
143
  });
137
- nudgeResume(pi, runtime);
144
+ await nudgeResume(pi, runtime);
138
145
  return { compaction: fb.compaction };
139
146
  }
140
147
  } catch (err) {
@@ -12,12 +12,13 @@
12
12
  * exclusively (its agent_end nudge path is separate and must not be doubled).
13
13
  *
14
14
  * @param message the event.message (a pi AgentMessage) or an error string
15
- * @returns 'transient' | 'permanent' | 'compaction-noop' | null (success/unknown)
15
+ * @returns 'transient' | 'permanent' | 'compaction-noop' | 'context-overflow' | null (success/unknown)
16
16
  */
17
17
  export function classifyError(message: unknown):
18
18
  | 'transient'
19
19
  | 'permanent'
20
20
  | 'compaction-noop'
21
+ | 'context-overflow'
21
22
  | null {
22
23
  // Resolve a searchable text blob from a pi AgentMessage or raw string.
23
24
  let text = '';
@@ -81,6 +82,29 @@ export function classifyError(message: unknown):
81
82
  if (/compaction failed/.test(s)) return 'compaction-noop';
82
83
  if (/nothing to compact/.test(s)) return 'compaction-noop';
83
84
  if (/auto[\s-]?compaction failed/.test(s)) return 'compaction-noop';
85
+ // --- context-overflow (ORDER BEFORE generic transient!) ---
86
+ // A 400 from the model meaning "the prompt is bigger than the context window."
87
+ // Catches BOTH provider phrasings so the classification does not depend on
88
+ // which backend the router landed on:
89
+ // - pi/Anthropic wrapper: "too long for this model's context window even
90
+ // after compaction. Reduce the conversation length..." ->
91
+ // too long | context window | even after compaction | reduce the conversation
92
+ // - OpenAI/OpenRouter provider-side (often wrapped in "All targets failed:
93
+ // <model>. Last error: ..."): "This model's maximum context length is N
94
+ // tokens. Your request requires at least M tokens. Please reduce your
95
+ // input or max_tokens." -> maximum context length | context length
96
+ // exceeded | requires at least N tokens | reduce your input
97
+ // All of these carry `invalid_request_error` (UNDERSCORE, not 'invalid
98
+ // request' space) and would otherwise fall through to the generic
99
+ // `s.includes('error')` transient branch below, misclassifying as
100
+ // 'transient' and firing up to 5 blind retry nudges that re-submit the same
101
+ // oversized prompt -> re-400 -> busy-loop. 'context-overflow' instead forces
102
+ // ONE deferred re-compact (debounce-bypassed, race-guarded) and fires NO
103
+ // blind retry nudge. The forced re-compact is shaped by the existing
104
+ // session_before_compact durable trim (it cannot lower pi's firstKeptEntryId).
105
+ 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)) {
106
+ return 'context-overflow';
107
+ }
84
108
  // --- transient (retryable) ---
85
109
  if (s.includes('error') && !/\b(permanent|invalid request|malformed|bad request|auth|unauthorized|invalid (api )?key|permission)\b/.test(s)) {
86
110
  return 'transient'; // generic pi stopReason 'error' / 'aborted'
@@ -0,0 +1,37 @@
1
+ /**
2
+ * send-safe.ts — queue-safe wrapper for pi.sendUserMessage.
3
+ *
4
+ * All extension-initiated user-role nudges (resume after compaction, length-stop
5
+ * continue, error-retry) MUST go through this wrapper. It:
6
+ * 1. Passes { deliverAs: 'followUp' } so that when the agent is busy (e.g. a
7
+ * resume nudge fired during session_before_compact, which is mid-prompt
8
+ * submission) pi QUEUES the message instead of throwing
9
+ * "Agent is already processing. Specify streamingBehavior (steer or
10
+ * followUp) to queue the message" (pi agent-session.js:830).
11
+ * 2. Awaits the call and catch-guards it so a failed/queued nudge never throws
12
+ * or produces an unhandled rejection — it must never block the agent loop.
13
+ *
14
+ * PREVENT-PI-003: user-role sendUserMessage only (no role:'system' injection).
15
+ * PREVENT-PI-004: local pi ctx call, no network.
16
+ */
17
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
+
19
+ /**
20
+ * safeSendUserMessage — await + catch-guard + queue-safe wrapper for
21
+ * pi.sendUserMessage. Never throws; never produces an unhandled rejection.
22
+ */
23
+ export async function safeSendUserMessage(
24
+ pi: ExtensionAPI,
25
+ content: string,
26
+ ): Promise<void> {
27
+ try {
28
+ await (
29
+ pi.sendUserMessage as (
30
+ c: string,
31
+ o?: { deliverAs?: "steer" | "followUp" },
32
+ ) => Promise<void> | void
33
+ )(content, { deliverAs: "followUp" });
34
+ } catch {
35
+ /* non-fatal: a failed/queued nudge never blocks the agent loop */
36
+ }
37
+ }
@@ -17,3 +17,4 @@ export * from "./mega-events/context-handler.js";
17
17
  export * from "./mega-events/compact-handlers.js";
18
18
  export * from "./mega-events/perf-handler.js";
19
19
  export * from "./mega-events/error-classifier.js";
20
+ export * from "./mega-events/send-safe.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.8.16",
3
+ "version": "0.8.17",
4
4
  "description": "Layered, local, vector-backed context compressor for pi — supersede/collapse/cluster compaction with deduped inline recall.",
5
5
  "type": "module",
6
6
  "license": "BSD-2-Clause",