pi-mega-compact 0.8.14 → 0.8.15
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 +44 -21
- package/dist/extensions/mega-cache-replay.test.js +4 -2
- package/dist/extensions/mega-compact-s38.test.js +317 -0
- package/dist/extensions/mega-compact.js +14 -0
- package/dist/extensions/mega-compact.test.js +37 -8
- package/dist/extensions/mega-config.js +5 -0
- package/dist/extensions/mega-events/agent-handlers.js +184 -4
- package/dist/extensions/mega-events/context-handler.js +35 -2
- package/dist/extensions/mega-events/error-classifier.js +118 -0
- package/dist/extensions/mega-events.js +1 -0
- package/dist/extensions/mega-runtime/state.js +16 -0
- package/dist/extensions/mega-teamrun.test.js +14 -1
- package/extensions/dashboard-client/src/styles/base.css +2 -1
- package/extensions/mega-cache-replay.test.ts +3 -3
- package/extensions/mega-compact-s38.test.ts +330 -0
- package/extensions/mega-compact.test.ts +41 -13
- package/extensions/mega-compact.ts +14 -0
- package/extensions/mega-config.ts +24 -0
- package/extensions/mega-dashboard.ts +7 -0
- package/extensions/mega-events/agent-handlers.ts +179 -5
- package/extensions/mega-events/context-handler.ts +31 -2
- package/extensions/mega-events/error-classifier.ts +109 -0
- package/extensions/mega-events.ts +1 -0
- package/extensions/mega-runtime/helpers.ts +5 -0
- package/extensions/mega-runtime/state.ts +17 -1
- package/extensions/mega-teamrun.test.ts +13 -1
- package/package.json +4 -1
- package/dist/extensions/dashboard-server/helpers.js +0 -37
- package/dist/extensions/dashboard-server/html/all-repos-tab.js +0 -26
- package/dist/extensions/dashboard-server/html/body-open.js +0 -23
- package/dist/extensions/dashboard-server/html/current-repo-tab.js +0 -130
- package/dist/extensions/dashboard-server/html/head-open.js +0 -16
- package/dist/extensions/dashboard-server/html/high-score-tab.js +0 -25
- package/dist/extensions/dashboard-server/html/repo-detail-modal.js +0 -26
- package/dist/extensions/dashboard-server/html/script.js +0 -259
- package/dist/extensions/dashboard-server/html/styles.js +0 -103
- package/dist/extensions/dashboard-server/html/summary-tab.js +0 -19
- package/dist/extensions/dashboard-server/html-template.js +0 -41
- package/dist/src/store/sqlite/connection.js +0 -35
- package/dist/src/store/sqlite/index-store.js +0 -167
- package/dist/src/store/sqlite/memory.js +0 -54
- package/dist/src/store/sqlite/minhash-lsh.js +0 -47
- package/dist/src/store/sqlite/sessions.js +0 -39
- package/dist/src/store/sqlite/transaction.js +0 -19
- package/dist/src/vectorStore/add.js +0 -260
- package/dist/src/vectorStore/dedup.js +0 -52
- package/dist/src/vectorStore/index.js +0 -10
- package/dist/src/vectorStore/queries.js +0 -83
- package/dist/src/vectorStore/search.js +0 -95
- package/dist/src/vectorStore/session.js +0 -19
- package/dist/src/vectorStore/store.js +0 -105
- package/dist/src/vectorStore/types.js +0 -6
- package/dist/src/vectorStore/utils.js +0 -23
|
@@ -18,6 +18,7 @@ import { recordScore } from "../../src/store/sqlite.js";
|
|
|
18
18
|
import { evaluateAndUnlockAchievements } from "../../src/store/sqlite/game-achievements.js";
|
|
19
19
|
import { isMegaCache } from "../../src/game/scoring.js";
|
|
20
20
|
import { resolveRepoRoot } from "../mega-config.js";
|
|
21
|
+
import { classifyError } from "./error-classifier.js";
|
|
21
22
|
|
|
22
23
|
/** Register agent/turn tracking event handlers. */
|
|
23
24
|
export function registerAgentHandlers(
|
|
@@ -65,11 +66,32 @@ export function registerAgentHandlers(
|
|
|
65
66
|
const idle = ctx.isIdle?.() ?? true;
|
|
66
67
|
const queued = ctx.hasPendingMessages?.() ?? false;
|
|
67
68
|
const now = Date.now();
|
|
69
|
+
// S38.5: read LIVE pressure from ctx.getContextUsage() instead of the
|
|
70
|
+
// stale runtime.lastCtxTokens (only updated by the `context` event).
|
|
71
|
+
// agent_end may fire without a preceding context event this turn (e.g.
|
|
72
|
+
// a sub-agent settling), so the cached value can be null/stale and the
|
|
73
|
+
// durable-trim branch would be unreachable. ctx.getContextUsage() is the
|
|
74
|
+
// authoritative live reading (mirrors context-handler.ts:86). Fall back
|
|
75
|
+
// to the cached value only if the ctx omits it.
|
|
76
|
+
const liveUsage = ctx.getContextUsage?.();
|
|
77
|
+
const liveTokens =
|
|
78
|
+
typeof liveUsage?.tokens === "number"
|
|
79
|
+
? liveUsage.tokens
|
|
80
|
+
: runtime.lastCtxTokens ?? 0;
|
|
81
|
+
// Keep the cache fresh for snapshot()/diag regardless of which source we use.
|
|
82
|
+
if (typeof liveUsage?.tokens === "number") {
|
|
83
|
+
runtime.lastCtxTokens = liveUsage.tokens;
|
|
84
|
+
}
|
|
85
|
+
if (typeof liveUsage?.percent === "number") {
|
|
86
|
+
runtime.lastCtxPercent = liveUsage.percent;
|
|
87
|
+
}
|
|
88
|
+
if (typeof liveUsage?.contextWindow === "number") {
|
|
89
|
+
runtime.lastCtxWindow = liveUsage.contextWindow;
|
|
90
|
+
}
|
|
68
91
|
// DIAG (team-run relief): surface whether the agent is idle + over
|
|
69
92
|
// threshold at agent_end so we can see if a mid-run durable-trim trigger
|
|
70
93
|
// *should* have fired but didn't.
|
|
71
|
-
const overThreshold =
|
|
72
|
-
(runtime.lastCtxTokens ?? 0) >= runtime.effectiveThreshold;
|
|
94
|
+
const overThreshold = liveTokens >= runtime.effectiveThreshold;
|
|
73
95
|
runtime.diagAgentEndIdle++;
|
|
74
96
|
runtime.logger.info("agent-end-idle", {
|
|
75
97
|
sessionId: runtime.rt.sessionId,
|
|
@@ -117,11 +139,20 @@ export function registerAgentHandlers(
|
|
|
117
139
|
// synchronous `piCompactWouldNoop` branch check misses a native
|
|
118
140
|
// compaction that hasn't appended its entry yet — calling
|
|
119
141
|
// ctx.compact() then races with pi and throws "Already compacted"
|
|
120
|
-
// to the user. The `
|
|
142
|
+
// to the user. The `lastNativeCompactAt` cooldown (updated by the
|
|
121
143
|
// session_compact listener for EVERY compaction, native or
|
|
122
144
|
// extension-supplied) closes that race window.
|
|
145
|
+
//
|
|
146
|
+
// S38.5: strict race guard widens the cooldown 10s -> 30s AND defers
|
|
147
|
+
// ctx.compact() via setTimeout(500) with a re-check, so pi's
|
|
148
|
+
// about-to-run native _checkCompaction can append its `compaction`
|
|
149
|
+
// branch entry first (closes the first-race-in-burst window). Gated
|
|
150
|
+
// by MEGACOMPACT_RACE_GUARD_STRICT (default true); false reverts to
|
|
151
|
+
// the v0.7.4 synchronous 10s guard. Mirrors the legacy path in
|
|
152
|
+
// context-handler.ts:258-287 so both call sites stay in sync.
|
|
153
|
+
const cooldownMs = config.raceGuardStrict ? 30_000 : 10_000;
|
|
123
154
|
const sinceCompact = now - (runtime.rt.lastNativeCompactAt ?? 0);
|
|
124
|
-
if (sinceCompact <
|
|
155
|
+
if (sinceCompact < cooldownMs) {
|
|
125
156
|
runtime.diagAgentEndDurableSkipRecent++;
|
|
126
157
|
} else if (!piCompactWouldNoop(ctx)) {
|
|
127
158
|
runtime.debounceUntil = now + 2000;
|
|
@@ -132,7 +163,31 @@ export function registerAgentHandlers(
|
|
|
132
163
|
thresholdTokens: config.thresholdTokens,
|
|
133
164
|
queued,
|
|
134
165
|
});
|
|
135
|
-
|
|
166
|
+
if (config.raceGuardStrict) {
|
|
167
|
+
// Strict: defer ctx.compact() with a re-check so pi's
|
|
168
|
+
// about-to-run native _checkCompaction can append its
|
|
169
|
+
// `compaction` branch entry first. setTimeout(500) — pi's
|
|
170
|
+
// compaction-summary append is async I/O, so queueMicrotask
|
|
171
|
+
// would re-check before it lands.
|
|
172
|
+
const stamp = runtime.rt.lastNativeCompactAt;
|
|
173
|
+
const liveSid = runtime.rt.sessionId;
|
|
174
|
+
setTimeout(() => {
|
|
175
|
+
try {
|
|
176
|
+
if (runtime.rt.sessionId !== liveSid) return; // session reset
|
|
177
|
+
const since2 =
|
|
178
|
+
now - (runtime.rt.lastNativeCompactAt ?? 0);
|
|
179
|
+
if (runtime.rt.lastNativeCompactAt !== stamp && since2 < cooldownMs) return;
|
|
180
|
+
if (piCompactWouldNoop(ctx)) return;
|
|
181
|
+
ctx.compact({
|
|
182
|
+
customInstructions: undefined,
|
|
183
|
+
}); // guardrails-allow PREVENT-PI-004: local ctx.compact() — no network; deferred + re-validated.
|
|
184
|
+
} catch {
|
|
185
|
+
/* non-fatal */
|
|
186
|
+
}
|
|
187
|
+
}, 500);
|
|
188
|
+
} else {
|
|
189
|
+
ctx.compact({ customInstructions: undefined }); // guardrails-allow PREVENT-PI-004: local ctx.compact() — no network; agent settled so no in-flight abort. Race-guarded by lastNativeCompactAt cooldown above (ctx.compact returns void → throw is surfaced by pi as compaction_end; the cooldown prevents the call entirely).
|
|
190
|
+
}
|
|
136
191
|
didDurableTrim = true;
|
|
137
192
|
}
|
|
138
193
|
}
|
|
@@ -172,6 +227,7 @@ export function registerAgentHandlers(
|
|
|
172
227
|
pi.on("turn_start", async (event, ctx) => {
|
|
173
228
|
runtime.currentTurn = event.turnIndex;
|
|
174
229
|
runtime.rt.lengthStopPending = false; // S28: re-arm defensively each user turn
|
|
230
|
+
runtime.rt.errorRetryCount = 0; // S38: reset error-retry counter each user turn
|
|
175
231
|
runtime.dashboard.event("turn_start", { turnIndex: event.turnIndex });
|
|
176
232
|
runtime.snapshot(ctx);
|
|
177
233
|
});
|
|
@@ -258,5 +314,123 @@ export function registerAgentHandlers(
|
|
|
258
314
|
runtime.rt.lengthStopPending = true;
|
|
259
315
|
runtime.dashboard.event("length_stop", { turnIndex: event.turnIndex });
|
|
260
316
|
}
|
|
317
|
+
|
|
318
|
+
// S38: broader error-retry safety net. S28 only catches stopReason==='length';
|
|
319
|
+
// this catches ALL other error types (provider failure, network timeout, 5xx,
|
|
320
|
+
// 429, auth, compaction-noop) that surface at turn_end. Non-fatal: wrapped in
|
|
321
|
+
// try/catch so a classifier/retry failure never breaks the agent loop.
|
|
322
|
+
// PREVENT-PI-003: retry nudge fires via pi.sendUserMessage (user-role).
|
|
323
|
+
try {
|
|
324
|
+
// (1) S28 owns length — skip the classifier entirely for it.
|
|
325
|
+
const sr = (event.message as { stopReason?: string } | undefined)?.stopReason;
|
|
326
|
+
if (sr === 'length') {
|
|
327
|
+
// S28 handles; nothing for S38 to do here.
|
|
328
|
+
} else {
|
|
329
|
+
const category = classifyError(event.message);
|
|
330
|
+
if (category === null) {
|
|
331
|
+
// (3) success / normal flow / unknown-but-non-retryable — reset.
|
|
332
|
+
runtime.rt.errorRetryCount = 0;
|
|
333
|
+
runtime.rt.consecutiveErrors = 0; // S38.6: circuit-breaker reset on success
|
|
334
|
+
} else if (category === 'compaction-noop') {
|
|
335
|
+
// (4) pi race / manual compact catch — NOT retryable. The compaction
|
|
336
|
+
// already succeeded via pi's native path; retrying would race again
|
|
337
|
+
// (FAIL-2026071701). Log a diagnostic, reset the counter, and surface
|
|
338
|
+
// the original error WITHOUT firing a retry nudge.
|
|
339
|
+
runtime.rt.errorRetryCount = 0;
|
|
340
|
+
runtime.rt.consecutiveErrors = 0; // S38.6: circuit-breaker reset
|
|
341
|
+
runtime.dashboard.event('compaction_noop_diagnostic', {
|
|
342
|
+
turnIndex: event.turnIndex,
|
|
343
|
+
sessionId: runtime.rt.sessionId,
|
|
344
|
+
});
|
|
345
|
+
runtime.logger.info('compaction-noop-diagnostic', {
|
|
346
|
+
sessionId: runtime.rt.sessionId,
|
|
347
|
+
turnIndex: event.turnIndex,
|
|
348
|
+
});
|
|
349
|
+
} else {
|
|
350
|
+
// (5) transient or permanent — retry with exponential backoff.
|
|
351
|
+
// S38.7: hard-stop switch — bypass ALL retry logic when set.
|
|
352
|
+
if (config.errorRetryHardStop) {
|
|
353
|
+
runtime.rt.errorRetryCount = 0;
|
|
354
|
+
runtime.dashboard.event('error_retry_disabled', {
|
|
355
|
+
category,
|
|
356
|
+
turnIndex: event.turnIndex,
|
|
357
|
+
reason: 'hard-stop',
|
|
358
|
+
});
|
|
359
|
+
return; // early exit — no retry
|
|
360
|
+
}
|
|
361
|
+
// S38.6: circuit-breaker — stop retrying after too many consecutive errors.
|
|
362
|
+
runtime.rt.consecutiveErrors++;
|
|
363
|
+
if (runtime.rt.consecutiveErrors > config.maxConsecutiveErrors) {
|
|
364
|
+
runtime.dashboard.event('error_retry_circuit_open', {
|
|
365
|
+
consecutive: runtime.rt.consecutiveErrors,
|
|
366
|
+
max: config.maxConsecutiveErrors,
|
|
367
|
+
turnIndex: event.turnIndex,
|
|
368
|
+
});
|
|
369
|
+
runtime.logger.warn('error-retry-circuit-open', {
|
|
370
|
+
sessionId: runtime.rt.sessionId,
|
|
371
|
+
consecutive: runtime.rt.consecutiveErrors,
|
|
372
|
+
max: config.maxConsecutiveErrors,
|
|
373
|
+
});
|
|
374
|
+
return; // early exit — circuit breaker tripped
|
|
375
|
+
}
|
|
376
|
+
const max =
|
|
377
|
+
category === 'transient'
|
|
378
|
+
? config.autoRetryTransientMax
|
|
379
|
+
: config.autoRetryPermanentMax;
|
|
380
|
+
// max === 0 disables the category entirely (revert to S28-only).
|
|
381
|
+
if (max <= 0) {
|
|
382
|
+
runtime.rt.errorRetryCount = 0;
|
|
383
|
+
} else {
|
|
384
|
+
runtime.rt.errorRetryCount++;
|
|
385
|
+
if (runtime.rt.errorRetryCount > max) {
|
|
386
|
+
// Exhausted — surface the error, reset for the next burst.
|
|
387
|
+
runtime.dashboard.event('error_retry_exhausted', {
|
|
388
|
+
category,
|
|
389
|
+
count: runtime.rt.errorRetryCount,
|
|
390
|
+
max,
|
|
391
|
+
turnIndex: event.turnIndex,
|
|
392
|
+
});
|
|
393
|
+
runtime.logger.info('error-retry-exhausted', {
|
|
394
|
+
sessionId: runtime.rt.sessionId,
|
|
395
|
+
category,
|
|
396
|
+
count: runtime.rt.errorRetryCount,
|
|
397
|
+
max,
|
|
398
|
+
});
|
|
399
|
+
runtime.rt.errorRetryCount = 0;
|
|
400
|
+
} else {
|
|
401
|
+
// S38: fire the retry nudge. Each error turn fires its own
|
|
402
|
+
// nudge up to `max` — we intentionally do NOT debounce on the
|
|
403
|
+
// backoff window (errorRetryUntil) here, because that would
|
|
404
|
+
// suppress turns 2..max on a fast-erroring provider (the exact
|
|
405
|
+
// scenario this feature targets) and the user could see zero
|
|
406
|
+
// retries followed by error_retry_exhausted. The per-turn cap
|
|
407
|
+
// (errorRetryCount <= max) plus the session circuit breaker
|
|
408
|
+
// (consecutiveErrors > maxConsecutiveErrors) already bound the
|
|
409
|
+
// loop, so a tight turn_end storm cannot busy-loop unbounded.
|
|
410
|
+
// errorRetryUntil + errorRetryBackoffMs() are retained on the
|
|
411
|
+
// runtime for future/optional pacing but are not gating.
|
|
412
|
+
runtime.dashboard.event('error_retry', {
|
|
413
|
+
category,
|
|
414
|
+
count: runtime.rt.errorRetryCount,
|
|
415
|
+
max,
|
|
416
|
+
turnIndex: event.turnIndex,
|
|
417
|
+
});
|
|
418
|
+
runtime.logger.info('error-retry', {
|
|
419
|
+
sessionId: runtime.rt.sessionId,
|
|
420
|
+
category,
|
|
421
|
+
count: runtime.rt.errorRetryCount,
|
|
422
|
+
max,
|
|
423
|
+
});
|
|
424
|
+
// PREVENT-PI-003: user-role sendUserMessage only.
|
|
425
|
+
pi.sendUserMessage(
|
|
426
|
+
'[mega-compact] the last turn ended with an error; please retry.',
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
} catch {
|
|
433
|
+
/* non-fatal: a classifier/retry failure never breaks the agent loop */
|
|
434
|
+
}
|
|
261
435
|
});
|
|
262
436
|
}
|
|
@@ -253,9 +253,38 @@ export function registerContextHandler(
|
|
|
253
253
|
// NATIVE compaction just fired (avoids racing pi and surfacing a spurious
|
|
254
254
|
// "Already compacted" / "Nothing to compact" toast). Uses lastNativeCompactAt
|
|
255
255
|
// (NOT lastCompactAt, which runCompact also stamps for our own checkpoint).
|
|
256
|
+
// S38.5: strict race guard widens the cooldown 10s -> 30s (gated by
|
|
257
|
+
// MEGACOMPACT_RACE_GUARD_STRICT; false reverts to v0.7.4 10s).
|
|
258
|
+
const cooldownMs = config.raceGuardStrict ? 30_000 : 10_000;
|
|
256
259
|
const sinceCompact = Date.now() - (runtime.rt.lastNativeCompactAt ?? 0);
|
|
257
|
-
if (sinceCompact <
|
|
258
|
-
|
|
260
|
+
if (sinceCompact < cooldownMs || piCompactWouldNoop(ctx)) return;
|
|
261
|
+
// S38.5: defer ctx.compact() with a re-check so pi's about-to-run native
|
|
262
|
+
// _checkCompaction can append its `compaction` branch entry first (closes
|
|
263
|
+
// the first-race-in-burst window). setTimeout(500) — pi's compaction-summary
|
|
264
|
+
// append is async I/O, so queueMicrotask would re-check before it lands.
|
|
265
|
+
// Non-strict (v0.7.4) keeps the synchronous call.
|
|
266
|
+
if (config.raceGuardStrict) {
|
|
267
|
+
const stamp = runtime.rt.lastNativeCompactAt;
|
|
268
|
+
const liveSid = runtime.rt.sessionId;
|
|
269
|
+
setTimeout(() => {
|
|
270
|
+
try {
|
|
271
|
+
if (runtime.rt.sessionId !== liveSid) return; // session reset
|
|
272
|
+
const since2 =
|
|
273
|
+
Date.now() - (runtime.rt.lastNativeCompactAt ?? 0);
|
|
274
|
+
if (runtime.rt.lastNativeCompactAt !== stamp && since2 < cooldownMs) return;
|
|
275
|
+
if (piCompactWouldNoop(ctx)) return;
|
|
276
|
+
ctx.compact({
|
|
277
|
+
customInstructions: undefined,
|
|
278
|
+
}); // guardrails-allow PREVENT-PI-004: local ctx.compact() — no network; deferred + re-validated.
|
|
279
|
+
} catch {
|
|
280
|
+
/* non-fatal */
|
|
281
|
+
}
|
|
282
|
+
}, 500);
|
|
283
|
+
} else {
|
|
284
|
+
ctx.compact({
|
|
285
|
+
customInstructions: undefined,
|
|
286
|
+
}); // race-guarded by lastNativeCompactAt cooldown (ctx.compact returns void → not catchable; the cooldown prevents the call)
|
|
287
|
+
}
|
|
259
288
|
return;
|
|
260
289
|
}
|
|
261
290
|
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* error-classifier.ts — S38.2 error classification for retry logic.
|
|
3
|
+
*
|
|
4
|
+
* Classifies turn-end error/stop signals into retry categories.
|
|
5
|
+
* Ordering matters: compaction-noop is matched BEFORE generic transient
|
|
6
|
+
* so a pi race / manual compact catch is never misclassified.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** S38.2: classify a turn-end error/stop signal into a retry category.
|
|
10
|
+
*
|
|
11
|
+
* `length` is returned as null — S28 owns the max-output-token length stopReason
|
|
12
|
+
* exclusively (its agent_end nudge path is separate and must not be doubled).
|
|
13
|
+
*
|
|
14
|
+
* @param message the event.message (a pi AgentMessage) or an error string
|
|
15
|
+
* @returns 'transient' | 'permanent' | 'compaction-noop' | null (success/unknown)
|
|
16
|
+
*/
|
|
17
|
+
export function classifyError(message: unknown):
|
|
18
|
+
| 'transient'
|
|
19
|
+
| 'permanent'
|
|
20
|
+
| 'compaction-noop'
|
|
21
|
+
| null {
|
|
22
|
+
// Resolve a searchable text blob from a pi AgentMessage or raw string.
|
|
23
|
+
let text = '';
|
|
24
|
+
if (typeof message === 'string') {
|
|
25
|
+
text = message;
|
|
26
|
+
} else if (message && typeof message === 'object') {
|
|
27
|
+
const m = message as {
|
|
28
|
+
stopReason?: string;
|
|
29
|
+
content?: unknown;
|
|
30
|
+
error?: unknown;
|
|
31
|
+
};
|
|
32
|
+
const sr = typeof m.stopReason === 'string' ? m.stopReason : '';
|
|
33
|
+
// S28 guard: length stopReason is handled exclusively by the S28 path.
|
|
34
|
+
if (sr === 'length') return null;
|
|
35
|
+
// Success / normal tool flow — not an error, nothing to retry.
|
|
36
|
+
if (sr === 'stop' || sr === 'toolUse' || sr === 'tool_use') return null;
|
|
37
|
+
const parts: string[] = [];
|
|
38
|
+
if (sr) parts.push(sr);
|
|
39
|
+
const c = m.content;
|
|
40
|
+
if (typeof c === 'string') parts.push(c);
|
|
41
|
+
else if (Array.isArray(c)) {
|
|
42
|
+
for (const b of c) {
|
|
43
|
+
if (b && typeof b === 'object' && 'text' in b) {
|
|
44
|
+
parts.push(String((b as { text?: string }).text ?? ''));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (m.error) {
|
|
49
|
+
// Extract message from error objects for pattern matching.
|
|
50
|
+
const err = m.error;
|
|
51
|
+
if (typeof err === 'string') {
|
|
52
|
+
parts.push(err);
|
|
53
|
+
} else if (err && typeof err === 'object') {
|
|
54
|
+
const errObj = err as Record<string, unknown>;
|
|
55
|
+
if (typeof errObj.message === 'string') {
|
|
56
|
+
parts.push(errObj.message);
|
|
57
|
+
} else {
|
|
58
|
+
parts.push(JSON.stringify(err));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// S38: detect mid-response errors where the stream died without a
|
|
63
|
+
// proper stopReason (empty/undefined) — this catches provider failures
|
|
64
|
+
// that cut off the response mid-stream, INCLUDING the case where the
|
|
65
|
+
// provider emitted partial content before dying (a truncated response
|
|
66
|
+
// with no stop reason is a mid-stream death, not a success).
|
|
67
|
+
// A genuine success ALWAYS carries stop/tool_use/toolUse (which
|
|
68
|
+
// short-circuit to null at the top), so any message reaching here with a
|
|
69
|
+
// falsy stopReason is a stream failure → retryable.
|
|
70
|
+
if (!sr) {
|
|
71
|
+
return 'transient';
|
|
72
|
+
}
|
|
73
|
+
text = parts.join(' ');
|
|
74
|
+
}
|
|
75
|
+
if (!text) return null;
|
|
76
|
+
const s = text.toLowerCase();
|
|
77
|
+
// --- compaction-noop (ORDER FIRST: pi race / manual compact catch) ---
|
|
78
|
+
// FAIL-2026071701: these are NOT retryable — the compaction already
|
|
79
|
+
// succeeded via pi's native path; retrying would race again.
|
|
80
|
+
if (/already compacted/.test(s)) return 'compaction-noop';
|
|
81
|
+
if (/compaction failed/.test(s)) return 'compaction-noop';
|
|
82
|
+
if (/nothing to compact/.test(s)) return 'compaction-noop';
|
|
83
|
+
if (/auto[\s-]?compaction failed/.test(s)) return 'compaction-noop';
|
|
84
|
+
// --- transient (retryable) ---
|
|
85
|
+
if (s.includes('error') && !/\b(permanent|invalid request|malformed|bad request|auth|unauthorized|invalid (api )?key|permission)\b/.test(s)) {
|
|
86
|
+
return 'transient'; // generic pi stopReason 'error' / 'aborted'
|
|
87
|
+
}
|
|
88
|
+
if (s.includes('aborted')) return 'transient';
|
|
89
|
+
if (/max(imum)? output token/.test(s)) return 'transient';
|
|
90
|
+
if (/rate[\s.-]?limit|429|too many requests/.test(s)) return 'transient';
|
|
91
|
+
if (/5\d\d|internal server|bad gateway|service unavailable/.test(s)) return 'transient';
|
|
92
|
+
if (/network|timeout|connection (lost|refused|reset)|stream (interrupted|closed|ended|failed)|disconnected/.test(s)) return 'transient';
|
|
93
|
+
// --- permanent (NOT retryable beyond 1) ---
|
|
94
|
+
if (/auth|unauthorized|invalid (api )?key|permission/.test(s)) return 'permanent';
|
|
95
|
+
if (/invalid request|malformed|bad request/.test(s)) return 'permanent';
|
|
96
|
+
// Unknown — do not retry (avoid busy-looping on an unclassified signal).
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** S38.2: exponential backoff for error-retry nudges (5s,10s,20s,30s,30s cap).
|
|
101
|
+
* count is 1-based (the retry about to fire). */
|
|
102
|
+
export function errorRetryBackoffMs(count: number): number {
|
|
103
|
+
switch (count) {
|
|
104
|
+
case 1: return 5_000;
|
|
105
|
+
case 2: return 10_000;
|
|
106
|
+
case 3: return 20_000;
|
|
107
|
+
default: return 30_000; // cap from the 4th retry onward
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -43,6 +43,11 @@ export interface SessionRuntime {
|
|
|
43
43
|
recallInjections: number; // recall blocks injected this session-instance
|
|
44
44
|
cacheHitTokens: number; // tokens saved via cache hits (dedup + recall) this session
|
|
45
45
|
lengthStopPending: boolean; // S28: set on turn_end when stopReason==='length'
|
|
46
|
+
errorRetryCount: number; // S38: consecutive error turns, reset on success/turn_start
|
|
47
|
+
errorRetryUntil: number; // S38: wall-clock ms debounce for error-retry nudge
|
|
48
|
+
// S38.6: circuit-breaker state — consecutive error turns across the session.
|
|
49
|
+
// When this exceeds maxConsecutiveErrors, the extension stops retrying.
|
|
50
|
+
consecutiveErrors: number; // reset to 0 on successful turn_end
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
// ── ownVersion ─────────────────────────────────────────────────────────────
|
|
@@ -87,6 +87,9 @@ export class MegaRuntime {
|
|
|
87
87
|
recallInjections: 0,
|
|
88
88
|
cacheHitTokens: 0,
|
|
89
89
|
lengthStopPending: false,
|
|
90
|
+
errorRetryCount: 0,
|
|
91
|
+
errorRetryUntil: 0,
|
|
92
|
+
consecutiveErrors: 0,
|
|
90
93
|
};
|
|
91
94
|
// v0.8.6 cache-stability: the cached live-trim view for the current
|
|
92
95
|
// compaction epoch. Set after a fresh runCompact + computeLiveTrimCut, and
|
|
@@ -548,6 +551,16 @@ export class MegaRuntime {
|
|
|
548
551
|
cacheHit: { sessionSec: sec(this.rt.cacheHitTokens), totalSec: sec(cacheHitsTotalTokens) },
|
|
549
552
|
},
|
|
550
553
|
model,
|
|
554
|
+
// S38.8: error-retry state for the dashboard "retries" tile. The field is
|
|
555
|
+
// declared on DashboardSnapshot (mega-dashboard.ts) and surfaced here so the
|
|
556
|
+
// dashboard can render live retry/circuit-breaker status alongside the event
|
|
557
|
+
// stream (which already carries per-retry events).
|
|
558
|
+
retries: {
|
|
559
|
+
errorRetryCount: this.rt.errorRetryCount,
|
|
560
|
+
consecutiveErrors: this.rt.consecutiveErrors,
|
|
561
|
+
maxConsecutiveErrors: this.config.maxConsecutiveErrors,
|
|
562
|
+
errorRetryHardStop: this.config.errorRetryHardStop,
|
|
563
|
+
},
|
|
551
564
|
diag: {
|
|
552
565
|
ctxFastGate: this.diagCtxFastGate,
|
|
553
566
|
liveTrimFires: this.diagLiveTrimFires,
|
|
@@ -829,8 +842,11 @@ export class MegaRuntime {
|
|
|
829
842
|
recallInjections: 0,
|
|
830
843
|
cacheHitTokens: 0,
|
|
831
844
|
lengthStopPending: false,
|
|
845
|
+
errorRetryCount: 0,
|
|
846
|
+
errorRetryUntil: 0,
|
|
847
|
+
consecutiveErrors: 0,
|
|
832
848
|
};
|
|
833
|
-
|
|
849
|
+
this.trimCache = null; // v0.8.6: never replay a stale trim into a new session
|
|
834
850
|
this.statusKey = undefined;
|
|
835
851
|
this.activeAgents = 0;
|
|
836
852
|
this.currentTurn = 0;
|
|
@@ -42,6 +42,12 @@ function harness() {
|
|
|
42
42
|
process.env.MEGACOMPACT_DEBUG = "true";
|
|
43
43
|
process.env.MEGACOMPACT_THRESHOLD_TOKENS = "50";
|
|
44
44
|
process.env.MEGACOMPACT_FAST_GATE_PCT = "1";
|
|
45
|
+
// Strict race-guard mode double-counts diagAgentEndDurable (sync++ at branch
|
|
46
|
+
// entry + deferred++ in the setTimeout(500) callback = 6, not 3), lands
|
|
47
|
+
// compactCalls after the synchronous assertions, and leaks timers that hang
|
|
48
|
+
// `node --test`. The strict deferred path is covered by the two S38.5 tests
|
|
49
|
+
// in mega-compact.test.ts. Use the synchronous v0.7.4 path here.
|
|
50
|
+
process.env.MEGACOMPACT_RACE_GUARD_STRICT = "false";
|
|
45
51
|
process.env.MEGACOMPACT_ANCHOR_USER_MESSAGES = "1";
|
|
46
52
|
process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR = "0"; // piCompactWouldNoop must not skip
|
|
47
53
|
process.env.MEGACOMPACT_MEMORY_AUTO_REVIEW = "false";
|
|
@@ -159,6 +165,12 @@ test("control: session_before_compact supplies a durable compaction (parent sett
|
|
|
159
165
|
});
|
|
160
166
|
|
|
161
167
|
test("cleanup", async () => {
|
|
162
|
-
|
|
168
|
+
// Race closeVectorIndex with a timeout to prevent 40-min hangs.
|
|
169
|
+
try {
|
|
170
|
+
await Promise.race([
|
|
171
|
+
closeVectorIndex(),
|
|
172
|
+
new Promise((r) => setTimeout(r, 3000)),
|
|
173
|
+
]);
|
|
174
|
+
} catch { /* ignore */ }
|
|
163
175
|
rmSync(baseTmp, { recursive: true, force: true });
|
|
164
176
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-mega-compact",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.15",
|
|
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",
|
|
@@ -63,5 +63,8 @@
|
|
|
63
63
|
"@electric-sql/pglite": "^0.5.4",
|
|
64
64
|
"@electric-sql/pglite-pgvector": "^0.0.5",
|
|
65
65
|
"@mongodb-js/zstd": "^7.0.0"
|
|
66
|
+
},
|
|
67
|
+
"allowScripts": {
|
|
68
|
+
"@mongodb-js/zstd@7.0.0": true
|
|
66
69
|
}
|
|
67
70
|
}
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* File-reading helper functions for the dashboard server.
|
|
3
|
-
*/
|
|
4
|
-
import { readFileSync } from "node:fs";
|
|
5
|
-
export function readSnapshot(snapshotPath) {
|
|
6
|
-
try {
|
|
7
|
-
const raw = readFileSync(snapshotPath, "utf-8");
|
|
8
|
-
return JSON.parse(raw);
|
|
9
|
-
}
|
|
10
|
-
catch {
|
|
11
|
-
return {
|
|
12
|
-
version: 1,
|
|
13
|
-
updatedAt: null,
|
|
14
|
-
tier: "unknown",
|
|
15
|
-
config: { fastGatePct: 80, thresholdTokens: 100_000, anchorUserMessages: 1, preserveRecent: 2, auto: true, autoInlineK: 3 },
|
|
16
|
-
session: { id: null, state: null, persistedThisSession: false, lastCheckpointId: null, lastCompactedFrom: 0 },
|
|
17
|
-
context: { tokens: null, percent: null, contextWindow: 0 },
|
|
18
|
-
trigger: { armed: false, ready: false, currentTokens: null, thresholdTokens: 100_000, fastGatePct: 80 },
|
|
19
|
-
store: { checkpointCount: 0, totalTokenEstimate: 0, originalTokens: 0, tokensSaved: 0, injectedCount: 0, dedupHitRate: 0, storageDedupRate: 0, dedupCollapsed: 0 },
|
|
20
|
-
crew: { activeAgents: 0, currentTurn: 0 },
|
|
21
|
-
repo: { checkpointCount: 0, totalTokenEstimate: 0, originalTokens: 0, tokensSaved: 0, sessionCount: 0, dedupAttempts: 0, dedupCollapsed: 0, storageDedupRate: 0 },
|
|
22
|
-
integrity: { regionsRetained: 0, compressedOriginalBytes: 0, duplicatesCollapsed: 0, bytesPermanentlyDeleted: 0 },
|
|
23
|
-
model: undefined,
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
export function readFrom(path, charOffset) {
|
|
28
|
-
try {
|
|
29
|
-
const content = readFileSync(path, "utf-8");
|
|
30
|
-
if (content.length <= charOffset)
|
|
31
|
-
return { data: "", offset: charOffset };
|
|
32
|
-
return { data: content.slice(charOffset), offset: content.length };
|
|
33
|
-
}
|
|
34
|
-
catch {
|
|
35
|
-
return { data: "", offset: charOffset };
|
|
36
|
-
}
|
|
37
|
-
}
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* "All repos" tab panel — machine-wide registry table (index.sqlite).
|
|
3
|
-
*
|
|
4
|
-
* Rows are populated by the index poller in `script.ts` (shared with the
|
|
5
|
-
* in-panel table on the Current-repo tab via the same render call).
|
|
6
|
-
*/
|
|
7
|
-
export function allReposTab() {
|
|
8
|
-
return `<!-- All repos (machine-wide registry from index.sqlite) -->
|
|
9
|
-
<div class="tab-panel" id="panel-all">
|
|
10
|
-
<table class="repos">
|
|
11
|
-
<thead>
|
|
12
|
-
<tr>
|
|
13
|
-
<th>Repo</th><th>Model</th>
|
|
14
|
-
<th style="text-align:right">Checkpoints</th>
|
|
15
|
-
<th style="text-align:right">Tokens Saved</th>
|
|
16
|
-
<th style="text-align:right">Retained</th>
|
|
17
|
-
<th style="text-align:right">Last Compacted</th>
|
|
18
|
-
</tr>
|
|
19
|
-
</thead>
|
|
20
|
-
<tbody id="all-rows"><tr><td colspan="6" class="repo-none">loading…</td></tr></tbody>
|
|
21
|
-
</table>
|
|
22
|
-
<div class="updated" id="all-updated"></div>
|
|
23
|
-
</div>
|
|
24
|
-
|
|
25
|
-
`;
|
|
26
|
-
}
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Body opening: </head><body>, offline banner, page heading, and the tab nav.
|
|
3
|
-
*
|
|
4
|
-
* `tierName` is interpolated into the heading tier badge.
|
|
5
|
-
* Includes the new "High Score" future-tab button (next project).
|
|
6
|
-
*/
|
|
7
|
-
export function bodyOpen(tierName) {
|
|
8
|
-
return `</head>
|
|
9
|
-
<body>
|
|
10
|
-
|
|
11
|
-
<div class="offline-banner" id="offline-banner">Dashboard data unavailable — waiting for a pi session to write snapshot...</div>
|
|
12
|
-
|
|
13
|
-
<h1><span>mega-compact</span><span class="tier">${tierName}</span><span class="model-pill" id="hdr-model">—</span></h1>
|
|
14
|
-
|
|
15
|
-
<nav class="tabs">
|
|
16
|
-
<button class="tab active" data-tab="current">Current repo</button>
|
|
17
|
-
<button class="tab" data-tab="all">All repos</button>
|
|
18
|
-
<button class="tab" data-tab="summary">Summary</button>
|
|
19
|
-
<button class="tab future" data-tab="highscore">High Score<span class="soon">soon</span></button>
|
|
20
|
-
</nav>
|
|
21
|
-
|
|
22
|
-
`;
|
|
23
|
-
}
|