pi-mega-compact 0.8.12 → 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/dist/assets/AchievementTiles-BH26e95w.js +2 -0
- package/extensions/dashboard-client/dist/assets/AchievementTiles-BH26e95w.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/AchievementsTab-B94wvRV_.js +2 -0
- package/extensions/dashboard-client/dist/assets/AchievementsTab-B94wvRV_.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/CacheTab-DPmw3NHR.js +2 -0
- package/extensions/dashboard-client/dist/assets/CacheTab-DPmw3NHR.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/ConfigTab-C3g3smRL.js +2 -0
- package/extensions/dashboard-client/dist/assets/ConfigTab-C3g3smRL.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/EventsTab-B5PicfFw.js +2 -0
- package/extensions/dashboard-client/dist/assets/EventsTab-B5PicfFw.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/GameTab-CsVwp78a.js +2 -0
- package/extensions/dashboard-client/dist/assets/GameTab-CsVwp78a.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/MetricsTab-Cf72UewX.js +2 -0
- package/extensions/dashboard-client/dist/assets/MetricsTab-Cf72UewX.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/OverviewTab-Dh6aG3gy.js +2 -0
- package/extensions/dashboard-client/dist/assets/OverviewTab-Dh6aG3gy.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/ReposTab-HXf2dorl.js +2 -0
- package/extensions/dashboard-client/dist/assets/ReposTab-HXf2dorl.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/TimeSavedCard-kUnoospE.js +2 -0
- package/extensions/dashboard-client/dist/assets/TimeSavedCard-kUnoospE.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/index-Cfxniu2R.css +1 -0
- package/extensions/dashboard-client/dist/assets/{index-XAIN8km6.js → index-DMntLu-8.js} +11 -10
- package/extensions/dashboard-client/dist/assets/index-DMntLu-8.js.map +1 -0
- package/extensions/dashboard-client/dist/index.html +2 -2
- package/extensions/dashboard-client/src/App.tsx +18 -4
- package/extensions/dashboard-client/src/components/AchievementTiles.tsx +105 -0
- package/extensions/dashboard-client/src/components/ActiveReposTable.tsx +116 -0
- package/extensions/dashboard-client/src/components/CacheHitsCard.tsx +73 -0
- package/extensions/dashboard-client/src/components/CacheStatusPerModel.tsx +78 -35
- package/extensions/dashboard-client/src/components/ConfigSummaryCard.tsx +101 -0
- package/extensions/dashboard-client/src/components/ContextGauge.tsx +11 -15
- package/extensions/dashboard-client/src/components/DataSafetyCard.tsx +42 -33
- package/extensions/dashboard-client/src/components/EventCategoryFilter.tsx +46 -9
- package/extensions/dashboard-client/src/components/LegendCard.tsx +49 -0
- package/extensions/dashboard-client/src/components/ModelBadge.tsx +42 -11
- package/extensions/dashboard-client/src/components/PerfCards.tsx +91 -0
- package/extensions/dashboard-client/src/components/RepoAllSessionsCard.tsx +102 -0
- package/extensions/dashboard-client/src/components/RepoDetailModal.tsx +9 -2
- package/extensions/dashboard-client/src/components/RepoTable.tsx +30 -22
- package/extensions/dashboard-client/src/components/SavingsByModelTable.tsx +226 -0
- package/extensions/dashboard-client/src/components/SessionInfo.tsx +25 -50
- package/extensions/dashboard-client/src/components/SummaryTiles.tsx +20 -11
- package/extensions/dashboard-client/src/components/TimeSavedCard.tsx +61 -0
- package/extensions/dashboard-client/src/components/TriggerStatus.tsx +31 -19
- package/extensions/dashboard-client/src/components/VectorStoreCard.tsx +134 -0
- package/extensions/dashboard-client/src/main.tsx +5 -0
- package/extensions/dashboard-client/src/styles/base.css +77 -9
- package/extensions/dashboard-client/src/styles/config.css +149 -0
- package/extensions/dashboard-client/src/styles/game-achievements.css +265 -0
- package/extensions/dashboard-client/src/styles/metrics-extra.css +69 -0
- package/extensions/dashboard-client/src/styles/overview-extra.css +170 -0
- package/extensions/dashboard-client/src/styles/repos-extra.css +99 -0
- package/extensions/dashboard-client/src/styles/repos-metrics.css +14 -23
- package/extensions/dashboard-client/src/tabs/AchievementsTab.tsx +55 -0
- package/extensions/dashboard-client/src/tabs/CacheTab.tsx +50 -0
- package/extensions/dashboard-client/src/tabs/ConfigTab.tsx +240 -5
- package/extensions/dashboard-client/src/tabs/EventsTab.tsx +55 -3
- package/extensions/dashboard-client/src/tabs/GameTab.tsx +287 -0
- package/extensions/dashboard-client/src/tabs/MetricsTab.tsx +7 -1
- package/extensions/dashboard-client/src/tabs/OverviewTab.tsx +103 -17
- package/extensions/dashboard-client/src/tabs/ReposTab.tsx +57 -22
- package/extensions/dashboard-client/src/utils/format.ts +85 -0
- package/extensions/dashboard-client/src/utils/types.ts +27 -0
- 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
- package/extensions/dashboard-client/dist/assets/ConfigTab-RrjXWfvr.js +0 -2
- package/extensions/dashboard-client/dist/assets/ConfigTab-RrjXWfvr.js.map +0 -1
- package/extensions/dashboard-client/dist/assets/EventsTab-BH_FxJ08.js +0 -2
- package/extensions/dashboard-client/dist/assets/EventsTab-BH_FxJ08.js.map +0 -1
- package/extensions/dashboard-client/dist/assets/MetricsTab-C-PoFV4p.js +0 -2
- package/extensions/dashboard-client/dist/assets/MetricsTab-C-PoFV4p.js.map +0 -1
- package/extensions/dashboard-client/dist/assets/OverviewTab-Db-qBqFN.js +0 -2
- package/extensions/dashboard-client/dist/assets/OverviewTab-Db-qBqFN.js.map +0 -1
- package/extensions/dashboard-client/dist/assets/ReposTab-DhCjJvuj.js +0 -2
- package/extensions/dashboard-client/dist/assets/ReposTab-DhCjJvuj.js.map +0 -1
- package/extensions/dashboard-client/dist/assets/index-Buer5yxB.css +0 -1
- package/extensions/dashboard-client/dist/assets/index-XAIN8km6.js.map +0 -1
package/README.md
CHANGED
|
@@ -6,10 +6,10 @@ sessions into a **local SQLite store** and offers **deduped inline recall** —
|
|
|
6
6
|
running **locally inside the extension**, with **no remote MCP server** and
|
|
7
7
|
**zero network calls at runtime** (PREVENT-PI-004).
|
|
8
8
|
|
|
9
|
-
> **Status - v0.
|
|
10
|
-
>
|
|
11
|
-
>
|
|
12
|
-
> changelog.
|
|
9
|
+
> **Status - v0.8.14.** The React dashboard (8 tabs, responsive scaling from
|
|
10
|
+
> 1280×720 to 4K) now ships in the npm tarball alongside the fallback html.ts.
|
|
11
|
+
> Game Mode adds themes, levels, achievements, and leaderboards. See
|
|
12
|
+
> [RELEASE_NOTES.md](RELEASE_NOTES.md) for the full changelog.
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
@@ -19,7 +19,9 @@ running **locally inside the extension**, with **no remote MCP server** and
|
|
|
19
19
|
- **Two-layer compaction** - a non-destructive live summary every LLM call, plus durable checkpoints that relieve context mid-run (not just at the end).
|
|
20
20
|
- **Vector store + dedup** - each checkpoint is embedded and stored locally; an L0->L2 + RAPTOR cascade collapses duplicate work so storage and recall stay lean.
|
|
21
21
|
- **Automatic recall** - the most relevant checkpoints are re-inlined on resume or branch switch; cross-repo memory-RAG augments a thin store with decisions from other repos.
|
|
22
|
-
- **
|
|
22
|
+
- **Game Mode** - optional progression layer with 6 themes, player levels (one per turn-doubling), 9 achievements (8 visible + 1 hidden easter-egg), and per-metric leaderboards. Toggle with `/mega-compact-settings`.
|
|
23
|
+
- **Live dashboard** - React-based 8-tab dashboard (Overview, Repos, Events, Config, Metrics, Cache, Game, Achievements) with responsive scaling from laptop (1280×720) to ultrawide/4K. Active Repos tab shows every currently-open session side by side.
|
|
24
|
+
- **Perf metrics** - Model latency (turn/provider p50/p95), throughput (TPS/cache hit %), process (RSS/heap/CPU), snapshot cost (DB recompute/disk write), and TUI lag proxy — all polled on a 2s interval while the dashboard is open.
|
|
23
25
|
- **Database maintenance** - `/mega-db-*` commands plus best-effort auto-maintenance on session start.
|
|
24
26
|
|
|
25
27
|
## Table of contents
|
|
@@ -198,6 +200,15 @@ building.
|
|
|
198
200
|
> To validate a real install, bump the version, `npm publish`, then
|
|
199
201
|
> `pi update --extensions` on the device. (`.gitignore` rejects `*.tgz` so one can't
|
|
200
202
|
> be committed by accident.)
|
|
203
|
+
>
|
|
204
|
+
> **Releasing (authoritative pipeline):** every release MUST go through
|
|
205
|
+
> `./scripts/deploy.sh <new-version>`. It enforces a clean tree, the full gate
|
|
206
|
+
> (`build` + `test` + `lint` + `regression_check` + `guardrails-scan`), builds the
|
|
207
|
+
> React dashboard, and — critically — verifies `extensions/dashboard-client/dist/index.html`
|
|
208
|
+
> is present AND listed by `npm pack --dry-run` **before** `npm publish`. This is
|
|
209
|
+
> the gate that was missing when v0.8.5 shipped without the dashboard bundle. The
|
|
210
|
+
> script then bumps the version, commits, publishes via npm only, tags, pushes,
|
|
211
|
+
> and prints device-side verification steps. Never publish by hand.
|
|
201
212
|
|
|
202
213
|
### Storage
|
|
203
214
|
|
|
@@ -246,6 +257,7 @@ The commands (slash commands inside pi):
|
|
|
246
257
|
| --- | --- |
|
|
247
258
|
| `/mega-compact [summary...]` | Manually compact the current session. A summary arg is used verbatim; otherwise the COLLAPSE heuristics build one. Persists a `chkpt_xxx`. |
|
|
248
259
|
| `/mega-compact off` | Disable auto-compaction for this session. |
|
|
260
|
+
| `/mega-compact-settings [on\|off\|theme [id\|next]\|tui [full\|minimal]\|achievements]` | Toggle game mode, pick a theme, switch TUI display mode, list unlocked achievements. (Alias: `/mega-game`.) |
|
|
249
261
|
| `/mega-status` | Show config + current context usage + store stats (checkpoint count, dedup rate, tokens saved) + the **installed version**. |
|
|
250
262
|
| `/mega-recall [query]` | Semantic-search the local store, dedupe against the current window, and inline the top-K relevant checkpoints. No query → uses your latest message. `--cross-repo` searches all repos. |
|
|
251
263
|
| `/mega-memory save <text>` / `save <category> <text>` / `list` / `search <query>` / `forget <text>` / `consolidate` | Manage durable memories (decisions, facts, preferences) written by auto-review and recalled as RAG context. Also `/m` shortform. |
|
|
@@ -267,13 +279,23 @@ The commands (slash commands inside pi):
|
|
|
267
279
|
|
|
268
280
|
The **tier** you see in the toolbar and dashboard is a *live pressure band* (`low` → `medium` → `high` → `ultra` → `mega`) that climbs automatically as your context window fills and falls back as it's relieved — it is driven by `currentTokens / effectiveThreshold`, not a manual setting. The base compaction *threshold* is set by `MEGACOMPACT_TIER` at startup as a **% of the model context window** (`low` 50% · `medium` 60% · `high` 70% · `ultra` 70% · `mega` 75%; default `low`) — the fire point is `tierPct × contextWindow`, so it always lands below pi's native ~80% auto-compaction (any model size). The old static token amounts (50k/100k/200k/1M/10M) are now only the boot fallback used before the first context event reports a window. `/mega-tier` was removed in v0.7.6. Higher pressure also deepens the live trim and reviews durable memory more often — the whole system reacts as one.
|
|
269
281
|
|
|
282
|
+
### Game Mode
|
|
283
|
+
|
|
284
|
+
Optional progression layer (toggle with `/mega-compact-settings on`):
|
|
285
|
+
|
|
286
|
+
- **6 themes** — transparent (default), neon, retro, ocean, forest, cyber. CSS-variable skins for the TUI widget and dashboard.
|
|
287
|
+
- **Player levels** — one level per turn-doubling (`turnLevel(n) = floor(log2(n+1))+1`). Level-up fires a one-cycle ANSI blink on the TUI + a CSS pulse on the dashboard.
|
|
288
|
+
- **Achievements** — 9 total (8 visible + 1 hidden easter-egg). Unlock conditions: First Compact, Compact Streak (5 in one session), Turn Veteran (25 turns), Level 5, Dedupe Master (100 chunks), Repo Explorer (3 repos), Night Owl (00:00–05:00), Flawless (exactly 100% cache), and Opie's Wild Ride (hidden: push cache past 100%).
|
|
289
|
+
- **Leaderboards** — per-metric (Cache %, Dedupe collapsed, Turns LVL, MEGA CACHE trophies) with repos badge. Stored in SQLite `game_scores`.
|
|
290
|
+
- **MEGA CACHE easter-egg** — when the dedup hit rate exceeds 100% (real ratio > 1), a transient "oopsie" toast fires (TUI + dashboard) and the hidden Opie achievement unlocks.
|
|
291
|
+
|
|
270
292
|
### Live stats widget
|
|
271
293
|
|
|
272
294
|
Above the pi editor the extension shows a compact widget:
|
|
273
295
|
|
|
274
296
|
```
|
|
275
|
-
⚡ high·low v0.
|
|
276
|
-
◐ armed │ dedup: 92% │ saved: 45k tok
|
|
297
|
+
⚡ high·low v0.8.14 │ 142k/200k tokens (71%) │ 3 chkpts │ 🤖 2 agents │ turn 5
|
|
298
|
+
◐ armed │ dedup: 92% │ saved: 45k tok │ LVL 4
|
|
277
299
|
```
|
|
278
300
|
|
|
279
301
|
- **Version** — the installed npm version (read from `package.json` at runtime),
|
|
@@ -285,6 +307,7 @@ Above the pi editor the extension shows a compact widget:
|
|
|
285
307
|
- **Trigger state** — ○ idle, ◐ armed, ● ready
|
|
286
308
|
- **Dedup hit rate** — % of checkpoints collapsed as duplicates
|
|
287
309
|
- **Active agents / turn** — sub-agent count and conversation turn (when > 0)
|
|
310
|
+
- **LVL** — player level (one per turn-doubling; game mode only)
|
|
288
311
|
|
|
289
312
|
> **DB housekeeping** — `/mega-db-stats` / `prune` / `vacuum` / `check` / `reconcile`
|
|
290
313
|
> give you manual control over the SQLite store. In addition, a best-effort
|
|
@@ -365,20 +388,20 @@ store. It reads the machine-wide `repo_registry`
|
|
|
365
388
|
(`~/.mega-compact-index/index.sqlite`) plus the current repo's own `node:sqlite`
|
|
366
389
|
store.
|
|
367
390
|
|
|
368
|
-
### Tabs
|
|
369
|
-
|
|
370
|
-
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
- **
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
- **
|
|
380
|
-
|
|
381
|
-
|
|
391
|
+
### Tabs (React dashboard)
|
|
392
|
+
|
|
393
|
+
The dashboard (started with `/mega-dashboard`) now ships as a React SPA with 8 tabs, responsive scaling from 1280×720 to 4K:
|
|
394
|
+
|
|
395
|
+
- **Overview** — context-window gauge (green/yellow/red), trigger status (armed/ready/idle), Vector Store (9 fields + compression bar), Repo (all sessions, 7 fields), Data Safety shield (regions retained, dedup %), Configuration (tier/preset/threshold), Model & Cost Savings ($, rates), Crew/Agents (active agents/turn/status), "What these numbers mean" legend.
|
|
396
|
+
- **Repos** — All Repositories table, Active Repos live table, Savings by Model table, per-repo detail modal, summary tiles.
|
|
397
|
+
- **Events** — live SSE stream with category filter (all/compact/recall/config/crew/game).
|
|
398
|
+
- **Config** — game mode toggle, theme picker (6 themes), TUI display mode (full/minimal), read-only config display.
|
|
399
|
+
- **Metrics** — model latency (turn/provider p50/p95), throughput (TPS/cache hit %), process (RSS/heap/CPU), snapshot cost (DB recompute/disk write), TUI lag proxy. Per-model cache status table.
|
|
400
|
+
- **Cache** — Cache Hits (session/total), Tokens Saved (session/total), Compactions (session/total), Time Saved (session/total).
|
|
401
|
+
- **Game** — MEGA CACHE banner, Opie unlock tile, leaderboards (Cache %, Dedupe collapsed, Turns LVL, MEGA CACHE trophies), repos badge, achievements sub-section.
|
|
402
|
+
- **Achievements** — achievement tiles grid with unlock states, toast area.
|
|
403
|
+
|
|
404
|
+
The older html.ts fallback (1071 lines, all data) is retained for environments without the React build.
|
|
382
405
|
|
|
383
406
|
### Active Repos tab
|
|
384
407
|
|
|
@@ -96,7 +96,8 @@ function harness() {
|
|
|
96
96
|
};
|
|
97
97
|
}
|
|
98
98
|
const pi = {
|
|
99
|
-
on: (ev, h) => { handlers[ev]
|
|
99
|
+
on: (ev, h) => { if (!handlers[ev])
|
|
100
|
+
handlers[ev] = []; handlers[ev].push(h); },
|
|
100
101
|
registerCommand: () => { }, registerTool: () => { }, registerShortcut: () => { },
|
|
101
102
|
registerFlag: () => { }, getFlag: () => undefined, registerMessageRenderer: () => { },
|
|
102
103
|
registerEntryRenderer: () => { }, sendMessage: () => { }, sendUserMessage: () => { },
|
|
@@ -109,7 +110,8 @@ function harness() {
|
|
|
109
110
|
const mod = require("./mega-compact.js");
|
|
110
111
|
mod.default(pi);
|
|
111
112
|
const { lastRuntime } = require("./mega-events.js");
|
|
112
|
-
const fire = (ev, event, ctx) => handlers[ev]
|
|
113
|
+
const fire = async (ev, event, ctx) => { let r; for (const h of handlers[ev] || [])
|
|
114
|
+
r = await h(event, ctx); return r; };
|
|
113
115
|
return {
|
|
114
116
|
stateDir, handlers, compactCalls, fire, ctx: makeCtx, usage, buildSession,
|
|
115
117
|
runtime: lastRuntime, // MegaRuntime with diag* counters + rt + trimCache
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mega-compact.s38-error-retry.test.ts — S38 error-retry safety net tests.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from mega-compact.test.ts so the error-retry feature has a focused,
|
|
5
|
+
* fast-isolated test file. Exercises the real extension entry through a mock pi:
|
|
6
|
+
* - classifyError classifier (transient / permanent / compaction-noop / null)
|
|
7
|
+
* - turn_end retry nudges (max 5 transient, max 1 permanent)
|
|
8
|
+
* - compaction-noop DOES NOT retry (pi race guard)
|
|
9
|
+
* - MAX=0 disables transient retries
|
|
10
|
+
* - mid-response errors (stream died without a stopReason)
|
|
11
|
+
* - S38.5 race-guard (strict deferred vs synchronous)
|
|
12
|
+
*
|
|
13
|
+
* The harness is duplicated from mega-compact.test.ts (per guardrails guidance to
|
|
14
|
+
* keep each test file self-contained; the harness is ~200 lines and does not
|
|
15
|
+
* warrant a shared helper module that would itself exceed the 500-line target).
|
|
16
|
+
*/
|
|
17
|
+
import { test } from "node:test";
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { createRequire } from "node:module";
|
|
23
|
+
import { closeVectorIndex } from "../src/store/vectorIndex.js";
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const baseTmp = mkdtempSync(join(tmpdir(), "mc-s38-"));
|
|
26
|
+
// Isolate the machine-wide repo index so test runs never pollute the real one.
|
|
27
|
+
process.env.MEGACOMPACT_INDEX_DIR = join(baseTmp, "index");
|
|
28
|
+
let counter = 0;
|
|
29
|
+
/** Read events.log and return an array of event type strings. */
|
|
30
|
+
function eventTypes(stateDir) {
|
|
31
|
+
const { readFileSync: rf, existsSync: ex } = require("node:fs");
|
|
32
|
+
const { join: j } = require("node:path");
|
|
33
|
+
const logPath = j(stateDir, "events.log");
|
|
34
|
+
if (!ex(logPath))
|
|
35
|
+
return [];
|
|
36
|
+
const content = rf(logPath, "utf-8").trim();
|
|
37
|
+
if (content.length === 0)
|
|
38
|
+
return [];
|
|
39
|
+
return content
|
|
40
|
+
.split("\n")
|
|
41
|
+
.map((line) => { try {
|
|
42
|
+
return JSON.parse(line).type;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
} })
|
|
47
|
+
.filter((t) => typeof t === "string");
|
|
48
|
+
}
|
|
49
|
+
/** Build a mock pi + ctx and load the extension into them. */
|
|
50
|
+
function harness(opts = {}) {
|
|
51
|
+
const stateDir = join(baseTmp, `run-${counter++}`);
|
|
52
|
+
process.env.MEGACOMPACT_STATE_DIR = stateDir;
|
|
53
|
+
process.env.MEGACOMPACT_DEBUG = "true";
|
|
54
|
+
if (!opts.keepThreshold)
|
|
55
|
+
process.env.MEGACOMPACT_THRESHOLD_TOKENS = "50";
|
|
56
|
+
if (!opts.keepTier)
|
|
57
|
+
delete process.env.MEGACOMPACT_TIER;
|
|
58
|
+
process.env.MEGACOMPACT_FAST_GATE_PCT = "1";
|
|
59
|
+
const handlers = {};
|
|
60
|
+
const appended = [];
|
|
61
|
+
const sendUserMessages = [];
|
|
62
|
+
const compactCalls = [];
|
|
63
|
+
const session = [
|
|
64
|
+
{ role: "user", content: "read src/vec.ts and understand the index", timestamp: 0 },
|
|
65
|
+
{ role: "assistant", content: [{ type: "toolCall", name: "Read", id: "c1", arguments: {} }], api: "anthropic-messages", provider: "anthropic", model: "m", usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 }, stopReason: "tool_use", timestamp: 0 },
|
|
66
|
+
{ role: "user", content: "edit src/vec.ts to add a cosine helper", timestamp: 0 },
|
|
67
|
+
{ role: "assistant", content: [{ type: "toolCall", name: "Edit", id: "c1", arguments: {} }], api: "anthropic-messages", provider: "anthropic", model: "m", usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 }, stopReason: "tool_use", timestamp: 0 },
|
|
68
|
+
];
|
|
69
|
+
const toEntry = (m, i) => ({ type: "message", id: `e${i}`, parentId: null, timestamp: String(i), message: m });
|
|
70
|
+
const sessionManager = { getSessionId: () => "sess_ext_001", getEntries: () => session.map(toEntry), getBranch: () => session.map(toEntry) };
|
|
71
|
+
function makeCtx(over = {}) {
|
|
72
|
+
return {
|
|
73
|
+
ui: { setStatus: () => { }, notify: () => { }, select: () => { }, confirm: async () => true, input: async () => "", setWidget: () => { } },
|
|
74
|
+
mode: "tui", hasUI: true, cwd: stateDir, sessionManager,
|
|
75
|
+
modelRegistry: {}, model: undefined,
|
|
76
|
+
isIdle: () => true, isProjectTrusted: () => true,
|
|
77
|
+
signal: undefined, abort: () => { }, hasPendingMessages: () => false, shutdown: () => { },
|
|
78
|
+
getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }),
|
|
79
|
+
compact: (opts) => {
|
|
80
|
+
compactCalls.push(opts);
|
|
81
|
+
const _sbc = handlers["session_before_compact"];
|
|
82
|
+
if (_sbc && _sbc.length)
|
|
83
|
+
return _sbc[0]({ type: "session_before_compact", reason: "threshold", willRetry: false, signal: undefined, preparation: { firstKeptEntryId: "e2", messagesToSummarize: session.slice(0, 2), tokensBefore: 500 } }, makeCtx());
|
|
84
|
+
return undefined;
|
|
85
|
+
},
|
|
86
|
+
getSystemPrompt: () => "system base", ...over,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const pi = {
|
|
90
|
+
on: (ev, h) => { if (!handlers[ev])
|
|
91
|
+
handlers[ev] = []; handlers[ev].push(h); },
|
|
92
|
+
registerCommand: () => { }, registerTool: () => { }, registerShortcut: () => { },
|
|
93
|
+
registerFlag: () => { }, getFlag: () => undefined,
|
|
94
|
+
registerMessageRenderer: () => { }, registerEntryRenderer: () => { },
|
|
95
|
+
sendMessage: () => { }, sendUserMessage: (m) => { sendUserMessages.push(m); },
|
|
96
|
+
appendEntry: (t, d) => appended.push({ t, d }),
|
|
97
|
+
setSessionName: () => { }, getSessionName: () => undefined, setLabel: () => { },
|
|
98
|
+
exec: async () => ({ stdout: "", stderr: "", code: 0 }),
|
|
99
|
+
getActiveTools: () => [], getAllTools: () => { }, setActiveTools: () => { },
|
|
100
|
+
getCommands: () => [], setModel: async () => false,
|
|
101
|
+
getThinkingLevel: () => "off", setThinkingLevel: () => { },
|
|
102
|
+
};
|
|
103
|
+
const mod = require("./mega-compact.js");
|
|
104
|
+
mod.default(pi);
|
|
105
|
+
return {
|
|
106
|
+
stateDir, handlers, appended, compactCalls, sendUserMessages,
|
|
107
|
+
fire: async (ev, event, ctx) => { let r; for (const h of handlers[ev] || [])
|
|
108
|
+
r = await h(event, ctx); return r; },
|
|
109
|
+
ctx: makeCtx, session,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const { classifyError: classifyErrorFn } = require("./mega-events.js");
|
|
113
|
+
/** S38 helper: fire a turn_end with a given stopReason + optional text, using a
|
|
114
|
+
* low-pressure ctx so the durable-trim branch (ctx.compact) does NOT fire. */
|
|
115
|
+
async function s38TurnEnd(h, stopReason, text) {
|
|
116
|
+
const lowCtx = h.ctx({ isIdle: () => true, hasPendingMessages: () => false, getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }) });
|
|
117
|
+
const message = { role: "assistant" };
|
|
118
|
+
if (stopReason !== undefined)
|
|
119
|
+
message.stopReason = stopReason;
|
|
120
|
+
if (text)
|
|
121
|
+
message.content = text;
|
|
122
|
+
await h.fire("turn_end", { type: "turn_end", turnIndex: 1, message }, lowCtx);
|
|
123
|
+
}
|
|
124
|
+
// ---- classifier unit tests (no extension harness needed) ----
|
|
125
|
+
test("S38: classifyError returns 'transient' for error/aborted stopReasons", () => {
|
|
126
|
+
assert.equal(classifyErrorFn({ stopReason: "error" }), "transient");
|
|
127
|
+
assert.equal(classifyErrorFn({ stopReason: "aborted" }), "transient");
|
|
128
|
+
});
|
|
129
|
+
test("S38: classifyError returns 'transient' for max-output-token text", () => {
|
|
130
|
+
assert.equal(classifyErrorFn({ stopReason: "error", content: "reached the maximum output token limit" }), "transient");
|
|
131
|
+
assert.equal(classifyErrorFn("max output token exceeded"), "transient");
|
|
132
|
+
});
|
|
133
|
+
test("S38: classifyError returns 'permanent' for auth/unauthorized text", () => {
|
|
134
|
+
assert.equal(classifyErrorFn("unauthorized: invalid api key"), "permanent");
|
|
135
|
+
assert.equal(classifyErrorFn("permission denied"), "permanent");
|
|
136
|
+
});
|
|
137
|
+
test("S38: classifyError returns null for stop/toolUse stopReasons (success)", () => {
|
|
138
|
+
assert.equal(classifyErrorFn({ stopReason: "stop" }), null);
|
|
139
|
+
assert.equal(classifyErrorFn({ stopReason: "toolUse" }), null);
|
|
140
|
+
assert.equal(classifyErrorFn({ stopReason: "tool_use" }), null);
|
|
141
|
+
});
|
|
142
|
+
test("S38: classifyError returns null for 'length' stopReason (S28 guard)", () => {
|
|
143
|
+
assert.equal(classifyErrorFn({ stopReason: "length" }), null);
|
|
144
|
+
});
|
|
145
|
+
test("S38: classifyError returns 'transient' for mid-response errors with no stopReason", () => {
|
|
146
|
+
assert.equal(classifyErrorFn({}), "transient");
|
|
147
|
+
assert.equal(classifyErrorFn({ content: [] }), "transient");
|
|
148
|
+
assert.equal(classifyErrorFn({ stopReason: undefined }), "transient");
|
|
149
|
+
assert.equal(classifyErrorFn({ stopReason: "" }), "transient");
|
|
150
|
+
});
|
|
151
|
+
test("S38: classifyError returns 'transient' for error objects with message field", () => {
|
|
152
|
+
assert.equal(classifyErrorFn({ error: { message: "Stream interrupted" } }), "transient");
|
|
153
|
+
assert.equal(classifyErrorFn({ error: { message: "Connection lost" } }), "transient");
|
|
154
|
+
assert.equal(classifyErrorFn({ error: { message: "500 Internal Server Error" } }), "transient");
|
|
155
|
+
assert.equal(classifyErrorFn({ error: "Connection lost" }), "transient");
|
|
156
|
+
});
|
|
157
|
+
test("S38: classifyError returns 'transient' for mid-response stream failures in content", () => {
|
|
158
|
+
assert.equal(classifyErrorFn({ content: [{ type: "text", text: "Processing... Error: connection reset" }] }), "transient");
|
|
159
|
+
assert.equal(classifyErrorFn({ content: [{ type: "text", text: "Here is the answer..." }], stopReason: "error" }), "transient");
|
|
160
|
+
});
|
|
161
|
+
test("S38: classifyError returns 'transient' for partial content with NO stopReason (stream died after emitting text)", () => {
|
|
162
|
+
// The mid-response disconnect case: provider streamed partial content then
|
|
163
|
+
// died without a stopReason. MUST be transient (retryable), NOT null.
|
|
164
|
+
assert.equal(classifyErrorFn({ role: "assistant", content: [{ type: "text", text: "partial response..." }], stopReason: undefined }), "transient");
|
|
165
|
+
assert.equal(classifyErrorFn({ role: "assistant", content: [{ type: "text", text: "Here is the start of the answer" }] }), "transient");
|
|
166
|
+
assert.equal(classifyErrorFn({ role: "assistant", content: "partial response..." }), "transient");
|
|
167
|
+
});
|
|
168
|
+
test("S38: classifyError returns null for success stopReasons even with empty content", () => {
|
|
169
|
+
assert.equal(classifyErrorFn({ stopReason: "stop", content: [] }), null);
|
|
170
|
+
assert.equal(classifyErrorFn({ stopReason: "tool_use", content: [] }), null);
|
|
171
|
+
});
|
|
172
|
+
test("S38: classifyError returns 'compaction-noop' for 'Already compacted' text", () => {
|
|
173
|
+
assert.equal(classifyErrorFn("Error: Already compacted"), "compaction-noop");
|
|
174
|
+
});
|
|
175
|
+
test("S38: classifyError returns 'compaction-noop' for 'Nothing to compact' text", () => {
|
|
176
|
+
assert.equal(classifyErrorFn("Nothing to compact (session too small)"), "compaction-noop");
|
|
177
|
+
});
|
|
178
|
+
test("S38: classifyError returns 'compaction-noop' for 'Auto compaction failed' text", () => {
|
|
179
|
+
assert.equal(classifyErrorFn("Auto compaction failed"), "compaction-noop");
|
|
180
|
+
assert.equal(classifyErrorFn("Auto-compaction failed"), "compaction-noop");
|
|
181
|
+
});
|
|
182
|
+
// ---- integration tests (fire turn_end through the real extension) ----
|
|
183
|
+
test("S38: compaction-noop logs 'compaction_noop_diagnostic' + resets counter + no retry fired", async () => {
|
|
184
|
+
const h = harness();
|
|
185
|
+
await s38TurnEnd(h, "error", "Already compacted");
|
|
186
|
+
const ev = eventTypes(h.stateDir);
|
|
187
|
+
assert.ok(ev.includes("compaction_noop_diagnostic"), "compaction-noop: diagnostic event logged");
|
|
188
|
+
assert.equal(h.sendUserMessages.length, 0, "compaction-noop: NO retry nudge fired");
|
|
189
|
+
});
|
|
190
|
+
test("S38: compaction-noop does NOT fire pi.sendUserMessage (NOT retryable)", async () => {
|
|
191
|
+
const h = harness();
|
|
192
|
+
await s38TurnEnd(h, "error", "Nothing to compact");
|
|
193
|
+
assert.equal(h.sendUserMessages.length, 0, "compaction-noop: no sendUserMessage");
|
|
194
|
+
assert.ok(eventTypes(h.stateDir).includes("compaction_noop_diagnostic"));
|
|
195
|
+
});
|
|
196
|
+
test("S38: retry fires up to max (5) for transient errors, then stops", async () => {
|
|
197
|
+
const h = harness();
|
|
198
|
+
for (let i = 0; i < 5; i++)
|
|
199
|
+
await s38TurnEnd(h, "error", "internal server error");
|
|
200
|
+
assert.equal(h.sendUserMessages.length, 5, "transient: 5 retry nudges (<= max 5)");
|
|
201
|
+
await s38TurnEnd(h, "error", "internal server error");
|
|
202
|
+
assert.equal(h.sendUserMessages.length, 5, "transient: exhausted -> no 6th nudge");
|
|
203
|
+
assert.ok(eventTypes(h.stateDir).includes("error_retry_exhausted"), "exhausted event logged");
|
|
204
|
+
});
|
|
205
|
+
test("S38: retry fires 1x for permanent errors then stops", async () => {
|
|
206
|
+
const h = harness();
|
|
207
|
+
await s38TurnEnd(h, "error", "invalid api key");
|
|
208
|
+
assert.equal(h.sendUserMessages.length, 1, "permanent: 1 retry nudge (<= max 1)");
|
|
209
|
+
await s38TurnEnd(h, "error", "invalid api key");
|
|
210
|
+
assert.equal(h.sendUserMessages.length, 1, "permanent: exhausted -> no 2nd nudge");
|
|
211
|
+
assert.ok(eventTypes(h.stateDir).includes("error_retry_exhausted"), "permanent exhausted logged");
|
|
212
|
+
});
|
|
213
|
+
test("S38: successful turn (stop/toolUse) resets the retry counter", async () => {
|
|
214
|
+
const h = harness();
|
|
215
|
+
await s38TurnEnd(h, "error", "5xx server error");
|
|
216
|
+
assert.equal(h.sendUserMessages.length, 1, "first transient: 1 nudge");
|
|
217
|
+
await s38TurnEnd(h, "stop");
|
|
218
|
+
await s38TurnEnd(h, "error", "5xx server error");
|
|
219
|
+
assert.equal(h.sendUserMessages.length, 2, "success reset counter -> transient fires again from count=1");
|
|
220
|
+
});
|
|
221
|
+
test("S38: error_retry_exhausted event logged when max exceeded", async () => {
|
|
222
|
+
const h = harness();
|
|
223
|
+
await s38TurnEnd(h, "error", "malformed bad request");
|
|
224
|
+
await s38TurnEnd(h, "error", "malformed bad request");
|
|
225
|
+
assert.ok(eventTypes(h.stateDir).includes("error_retry_exhausted"), "error_retry_exhausted logged");
|
|
226
|
+
});
|
|
227
|
+
test("S38: MEGACOMPACT_AUTO_RETRY_TRANSIENT_MAX=0 disables transient retries cleanly", async () => {
|
|
228
|
+
const prev = process.env.MEGACOMPACT_AUTO_RETRY_TRANSIENT_MAX;
|
|
229
|
+
process.env.MEGACOMPACT_AUTO_RETRY_TRANSIENT_MAX = "0";
|
|
230
|
+
try {
|
|
231
|
+
const h = harness();
|
|
232
|
+
await s38TurnEnd(h, "error", "network timeout");
|
|
233
|
+
assert.equal(h.sendUserMessages.length, 0, "max=0: no transient retry nudge");
|
|
234
|
+
assert.ok(!eventTypes(h.stateDir).includes("error_retry"), "max=0: no error_retry event");
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
if (prev === undefined)
|
|
238
|
+
delete process.env.MEGACOMPACT_AUTO_RETRY_TRANSIENT_MAX;
|
|
239
|
+
else
|
|
240
|
+
process.env.MEGACOMPACT_AUTO_RETRY_TRANSIENT_MAX = prev;
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
test("S38: retry fires for mid-response errors (no stopReason — stream died silently)", async () => {
|
|
244
|
+
const h = harness();
|
|
245
|
+
const lowCtx = h.ctx({ isIdle: () => true, hasPendingMessages: () => false, getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }) });
|
|
246
|
+
await h.fire("turn_end", { type: "turn_end", turnIndex: 1, message: { role: "assistant" } }, lowCtx);
|
|
247
|
+
assert.equal(h.sendUserMessages.length, 1, "mid-response silent failure: 1 retry nudge fired");
|
|
248
|
+
assert.ok(eventTypes(h.stateDir).includes("error_retry"), "error_retry event logged for mid-response failure");
|
|
249
|
+
});
|
|
250
|
+
test("S38: retry fires for error objects with message field", async () => {
|
|
251
|
+
const h = harness();
|
|
252
|
+
const lowCtx = h.ctx({ isIdle: () => true, hasPendingMessages: () => false, getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }) });
|
|
253
|
+
await h.fire("turn_end", { type: "turn_end", turnIndex: 1, message: { role: "assistant", error: { message: "Connection reset by peer" } } }, lowCtx);
|
|
254
|
+
assert.equal(h.sendUserMessages.length, 1, "error object with message: 1 retry nudge fired");
|
|
255
|
+
});
|
|
256
|
+
test("S38: retry fires for partial content with no stopReason (disconnect after emitting text)", async () => {
|
|
257
|
+
// The post-resume disconnect case: provider streamed partial content then
|
|
258
|
+
// died mid-stream with NO stopReason. Must fire a retry nudge.
|
|
259
|
+
const h = harness();
|
|
260
|
+
const lowCtx = h.ctx({ isIdle: () => true, hasPendingMessages: () => false, getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }) });
|
|
261
|
+
await h.fire("turn_end", {
|
|
262
|
+
type: "turn_end",
|
|
263
|
+
turnIndex: 1,
|
|
264
|
+
message: { role: "assistant", content: [{ type: "text", text: "Here is the start of the answer" }], stopReason: undefined },
|
|
265
|
+
}, lowCtx);
|
|
266
|
+
assert.equal(h.sendUserMessages.length, 1, "partial-content mid-response failure: 1 retry nudge fired");
|
|
267
|
+
assert.ok(eventTypes(h.stateDir).includes("error_retry"), "error_retry event logged for partial-content failure");
|
|
268
|
+
});
|
|
269
|
+
// ---- S38.5: race-guard strengthening (cooldown 10s->30s + deferred re-check) ---
|
|
270
|
+
test("S38.5: MEGACOMPACT_RACE_GUARD_STRICT=false reverts to synchronous 10s guard", async () => {
|
|
271
|
+
const prev = process.env.MEGACOMPACT_RACE_GUARD_STRICT;
|
|
272
|
+
const prevFloor = process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
|
|
273
|
+
process.env.MEGACOMPACT_RACE_GUARD_STRICT = "false";
|
|
274
|
+
process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR = "0";
|
|
275
|
+
try {
|
|
276
|
+
const h = harness();
|
|
277
|
+
const hiCtx = h.ctx({ isIdle: () => true, hasPendingMessages: () => false, getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }) });
|
|
278
|
+
await h.fire("agent_end", { type: "agent_end", messages: [] }, hiCtx);
|
|
279
|
+
assert.ok(h.compactCalls.length >= 1, "non-strict: synchronous ctx.compact() fired");
|
|
280
|
+
}
|
|
281
|
+
finally {
|
|
282
|
+
if (prev === undefined)
|
|
283
|
+
delete process.env.MEGACOMPACT_RACE_GUARD_STRICT;
|
|
284
|
+
else
|
|
285
|
+
process.env.MEGACOMPACT_RACE_GUARD_STRICT = prev;
|
|
286
|
+
if (prevFloor === undefined)
|
|
287
|
+
delete process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
|
|
288
|
+
else
|
|
289
|
+
process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR = prevFloor;
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
test("S38.5: strict (default) defers ctx.compact() via setTimeout re-check", async () => {
|
|
293
|
+
const prevFloor = process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
|
|
294
|
+
process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR = "0";
|
|
295
|
+
try {
|
|
296
|
+
const h = harness();
|
|
297
|
+
const hiCtx = h.ctx({ isIdle: () => true, hasPendingMessages: () => false, getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }) });
|
|
298
|
+
await h.fire("agent_end", { type: "agent_end", messages: [] }, hiCtx);
|
|
299
|
+
assert.equal(h.compactCalls.length, 0, "strict: ctx.compact() NOT called synchronously (deferred)");
|
|
300
|
+
await new Promise((r) => setTimeout(r, 700));
|
|
301
|
+
assert.ok(h.compactCalls.length >= 1, "strict: deferred ctx.compact() fired after re-check");
|
|
302
|
+
}
|
|
303
|
+
finally {
|
|
304
|
+
if (prevFloor === undefined)
|
|
305
|
+
delete process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
|
|
306
|
+
else
|
|
307
|
+
process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR = prevFloor;
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
test("cleanup", async () => {
|
|
311
|
+
// PGlite WASM close can hang; race with a timeout to prevent 40-min hangs.
|
|
312
|
+
try {
|
|
313
|
+
await Promise.race([closeVectorIndex(), new Promise((r) => setTimeout(r, 3000))]);
|
|
314
|
+
}
|
|
315
|
+
catch { /* ignore */ }
|
|
316
|
+
rmSync(baseTmp, { recursive: true, force: true });
|
|
317
|
+
});
|
|
@@ -35,6 +35,20 @@ import { registerDbCommands } from "./mega-db-cmds.js";
|
|
|
35
35
|
import { registerGameCommands } from "./mega-game-cmds.js";
|
|
36
36
|
export default function (pi) {
|
|
37
37
|
const config = loadConfig();
|
|
38
|
+
// S38.9: preflight env validation — check for obviously invalid values at startup.
|
|
39
|
+
// Non-fatal: log warnings and fall back to defaults.
|
|
40
|
+
if (config.autoRetryTransientMax < 0) {
|
|
41
|
+
console.warn('[mega-compact] MEGACOMPACT_AUTO_RETRY_TRANSIENT_MAX must be >= 0; using default 5');
|
|
42
|
+
config.autoRetryTransientMax = 5;
|
|
43
|
+
}
|
|
44
|
+
if (config.autoRetryPermanentMax < 0) {
|
|
45
|
+
console.warn('[mega-compact] MEGACOMPACT_AUTO_RETRY_PERMANENT_MAX must be >= 0; using default 1');
|
|
46
|
+
config.autoRetryPermanentMax = 1;
|
|
47
|
+
}
|
|
48
|
+
if (config.maxConsecutiveErrors < 1) {
|
|
49
|
+
console.warn('[mega-compact] MEGACOMPACT_MAX_CONSECUTIVE_ERRORS must be >= 1; using default 10');
|
|
50
|
+
config.maxConsecutiveErrors = 10;
|
|
51
|
+
}
|
|
38
52
|
const runtime = new MegaRuntime(config);
|
|
39
53
|
registerEventHandlers(pi, runtime, config);
|
|
40
54
|
registerCommands(pi, runtime, config);
|
|
@@ -144,8 +144,9 @@ function harness(opts = {}) {
|
|
|
144
144
|
// session_before_compact handler (where WE supply the durable trim).
|
|
145
145
|
compact: (opts) => {
|
|
146
146
|
compactCalls.push(opts);
|
|
147
|
-
|
|
148
|
-
|
|
147
|
+
const _sbc = handlers["session_before_compact"];
|
|
148
|
+
if (_sbc && _sbc.length) {
|
|
149
|
+
return _sbc[0]({
|
|
149
150
|
type: "session_before_compact",
|
|
150
151
|
reason: "threshold",
|
|
151
152
|
willRetry: false,
|
|
@@ -167,7 +168,9 @@ function harness(opts = {}) {
|
|
|
167
168
|
}
|
|
168
169
|
const pi = {
|
|
169
170
|
on: (ev, h) => {
|
|
170
|
-
handlers[ev]
|
|
171
|
+
if (!handlers[ev])
|
|
172
|
+
handlers[ev] = [];
|
|
173
|
+
handlers[ev].push(h);
|
|
171
174
|
},
|
|
172
175
|
registerCommand: (name, opts) => {
|
|
173
176
|
commands[name] = opts;
|
|
@@ -209,7 +212,8 @@ function harness(opts = {}) {
|
|
|
209
212
|
notifies,
|
|
210
213
|
compactCalls,
|
|
211
214
|
sendUserMessages,
|
|
212
|
-
fire: (ev, event, ctx) => handlers[ev]
|
|
215
|
+
fire: async (ev, event, ctx) => { let r; for (const h of handlers[ev] || [])
|
|
216
|
+
r = await h(event, ctx); return r; },
|
|
213
217
|
ctx: makeCtx,
|
|
214
218
|
session,
|
|
215
219
|
};
|
|
@@ -235,6 +239,11 @@ test("auto-trigger (legacy): past threshold persists a chkpt and starts a durabl
|
|
|
235
239
|
}),
|
|
236
240
|
});
|
|
237
241
|
const res = await h.fire("context", { type: "context", messages }, ctx);
|
|
242
|
+
// S38.5: the strict race guard (default) defers ctx.compact() via a
|
|
243
|
+
// setTimeout(500) re-check. The synchronous return is still undefined
|
|
244
|
+
// (no local drop), but ctx.compact() only lands after the timer — wait
|
|
245
|
+
// for it before asserting the durable-trim call count.
|
|
246
|
+
await new Promise((r) => setTimeout(r, 700));
|
|
238
247
|
// L1->L4 ran: a checkpoint was persisted to the SQLite store + a marker entry written.
|
|
239
248
|
const { listCheckpoints } = await import("../src/store/sqlite.js");
|
|
240
249
|
assert.ok(listCheckpoints("sess_ext_001", h.stateDir).length > 0, "checkpoint persisted to local vector db");
|
|
@@ -919,23 +928,36 @@ test("S28: length_stop + length_stop_continue dashboard events fire on the right
|
|
|
919
928
|
assert.ok(afterAgentEnd.includes("length_stop_continue"), "length stop: length_stop_continue dashboard event fired on agent_end");
|
|
920
929
|
assert.equal(h.sendUserMessages.length, 1, "length stop: exactly one nudge");
|
|
921
930
|
});
|
|
922
|
-
test("S28: non-length stopReasons do not arm the flag (no
|
|
931
|
+
test("S28: non-length stopReasons do not arm the length-stop flag (no length_stop event); S38 owns error/aborted retries", async () => {
|
|
923
932
|
const h = harness();
|
|
924
933
|
const lowPressureCtx = h.ctx({
|
|
925
934
|
isIdle: () => true,
|
|
926
935
|
hasPendingMessages: () => false,
|
|
927
936
|
getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }),
|
|
928
937
|
});
|
|
929
|
-
//
|
|
938
|
+
// S28 contract: every non-length StopReason must leave lengthStopPending unset,
|
|
939
|
+
// so no length_stop event fires and no length-stop resume nudge is armed.
|
|
940
|
+
// (S38 separately owns error/aborted — it WILL fire error-retry nudges for those;
|
|
941
|
+
// this test only asserts the S28 length-stop flag is not armed.)
|
|
942
|
+
let transientRetries = 0;
|
|
930
943
|
for (const stopReason of ["tool_use", "error", "aborted"]) {
|
|
944
|
+
const before = h.sendUserMessages.length;
|
|
931
945
|
await h.fire("turn_end", {
|
|
932
946
|
type: "turn_end",
|
|
933
947
|
turnIndex: 1,
|
|
934
948
|
message: { role: "assistant", stopReason },
|
|
935
949
|
}, lowPressureCtx);
|
|
936
950
|
await h.fire("agent_end", { type: "agent_end", messages: [] }, lowPressureCtx);
|
|
951
|
+
// tool_use is success (0 nudges); error/aborted are transient under S38 (1 each).
|
|
952
|
+
if (stopReason === "tool_use") {
|
|
953
|
+
assert.equal(h.sendUserMessages.length - before, 0, "tool_use (success): no nudge");
|
|
954
|
+
}
|
|
955
|
+
else {
|
|
956
|
+
transientRetries += h.sendUserMessages.length - before;
|
|
957
|
+
}
|
|
937
958
|
}
|
|
938
|
-
|
|
959
|
+
// error + aborted each produced one S38 transient retry nudge (2 total).
|
|
960
|
+
assert.equal(transientRetries, 2, "S38 owns error/aborted: 1 transient retry each");
|
|
939
961
|
assert.ok(!eventTypes(h.stateDir).includes("length_stop"), "non-length stopReasons: no length_stop dashboard event");
|
|
940
962
|
});
|
|
941
963
|
// ---- S29: percent-based auto-compact trigger (gate on context %, not tokens) -
|
|
@@ -1078,6 +1100,13 @@ test("cleanup", async () => {
|
|
|
1078
1100
|
// Terminate the global PGlite cross-repo index (WASM worker thread) so the
|
|
1079
1101
|
// test process can exit. Without this, node --test never returns even though
|
|
1080
1102
|
// every test passed — the leaked worker keeps the event loop alive.
|
|
1081
|
-
|
|
1103
|
+
// Race with a timeout to prevent 40-min hangs if PGlite WASM close stalls.
|
|
1104
|
+
try {
|
|
1105
|
+
await Promise.race([
|
|
1106
|
+
closeVectorIndex(),
|
|
1107
|
+
new Promise((r) => setTimeout(r, 3000)),
|
|
1108
|
+
]);
|
|
1109
|
+
}
|
|
1110
|
+
catch { /* ignore */ }
|
|
1082
1111
|
rmSync(baseTmp, { recursive: true, force: true });
|
|
1083
1112
|
});
|
|
@@ -140,6 +140,11 @@ export function loadConfig() {
|
|
|
140
140
|
auto: envBool("MEGACOMPACT_AUTO", true),
|
|
141
141
|
autoInline: envBool("MEGACOMPACT_AUTO_INLINE", true),
|
|
142
142
|
autoContinueLengthStop: envBool("MEGACOMPACT_AUTO_CONTINUE_LENGTH_STOP", true),
|
|
143
|
+
autoRetryTransientMax: envFlag("MEGACOMPACT_AUTO_RETRY_TRANSIENT_MAX", 5),
|
|
144
|
+
autoRetryPermanentMax: envFlag("MEGACOMPACT_AUTO_RETRY_PERMANENT_MAX", 1),
|
|
145
|
+
raceGuardStrict: envBool("MEGACOMPACT_RACE_GUARD_STRICT", true),
|
|
146
|
+
maxConsecutiveErrors: envFlag("MEGACOMPACT_MAX_CONSECUTIVE_ERRORS", 10),
|
|
147
|
+
errorRetryHardStop: envBool("MEGACOMPACT_ERROR_RETRY_HARD_STOP", false),
|
|
143
148
|
autoPctTrigger,
|
|
144
149
|
autoInlineK: envFlag("MEGACOMPACT_AUTO_INLINE_K", 3),
|
|
145
150
|
dedupSim: Number(process.env.MEGACOMPACT_DEDUP_SIM ?? "0.9"),
|