pi-mega-compact 0.6.0 → 0.6.2

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
@@ -6,7 +6,7 @@ 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
- > **Current version:** `v0.6.0` — storage backend is **`node:sqlite`**
9
+ > **Current version:** `v0.6.1` — storage backend is **`node:sqlite`**
10
10
  > (`DatabaseSync`, a Node ≥22.13 built-in), replacing the old `better-sqlite3`
11
11
  > native addon and the per-session gzipped JSON checkpoint files. **Zero native
12
12
  > build step, fully local, zero network at runtime.** Legacy
@@ -408,6 +408,54 @@ test("explicit MEGACOMPACT_THRESHOLD_TOKENS overrides the tier", async () => {
408
408
  delete process.env.MEGACOMPACT_TIER;
409
409
  assert.ok(h.notifies.some((n) => n.includes("preset=custom") && n.includes("threshold=777")), "explicit threshold wins over tier (preset=custom)");
410
410
  });
411
+ // ---- S24: memory review tied to pressure / compaction -----------------------
412
+ // Build a decision-bearing session large enough to guarantee a real (non-skipped,
413
+ // non-deduped) compaction. Each user turn contains a decision phrase
414
+ // (/\bactually\b/i, /\bwe (?:use|decided)\b/i) so reviewConversation yields ops.
415
+ function decisionSession() {
416
+ const out = [];
417
+ for (let i = 0; i < 14; i++) {
418
+ out.push({ role: "user", content: `actually we decided to use approach ${i} for module ${i}`, timestamp: i });
419
+ out.push({ role: "assistant", content: [{ type: "toolCall", name: "Edit", id: `c${i}`, arguments: {} }], api: "anthropic-messages", provider: "anthropic", model: "m", usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 }, stopReason: "tool_use", timestamp: i });
420
+ out.push({ role: "toolResult", content: [{ type: "text", text: `edited module ${i}` }], toolCallId: `c${i}`, toolName: "Edit", isError: false, timestamp: i });
421
+ }
422
+ return out;
423
+ }
424
+ test("S24: high pressure triggers a memory review on compaction", async () => {
425
+ const h = harness();
426
+ // Force a real (non-legacy) compaction at full pressure → pressureBand "mega",
427
+ // which must fire the shared runMemoryReview on compact (review-on-compact).
428
+ process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM = "false";
429
+ try {
430
+ const messages = decisionSession();
431
+ const ctx = h.ctx({ getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }) });
432
+ await h.fire("context", { type: "context", messages }, ctx);
433
+ // review-on-compact runs as a fire-and-forget async (doCompact is sync), so
434
+ // let the microtask/macrotask queue drain before asserting the side effect.
435
+ await new Promise((r) => setTimeout(r, 20));
436
+ const { listMemories, listCheckpoints } = await import("../src/store/sqlite.js");
437
+ // A checkpoint must have been persisted (proves compaction ran, not skipped).
438
+ assert.ok(listCheckpoints("sess_ext_001", h.stateDir).length > 0, "checkpoint persisted to local vector db");
439
+ // The just-compacted region is worth remembering, so durable memories must
440
+ // have been written to the SQLite store (review-on-compact path).
441
+ const mem = listMemories(null, 50, h.stateDir);
442
+ assert.ok(mem.length > 0, "memory review wrote durable memories on compact");
443
+ }
444
+ finally {
445
+ delete process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM;
446
+ }
447
+ });
448
+ test("S24: /mega-status reports the live pressure band + %", async () => {
449
+ const h = harness();
450
+ // Populate the runtime's live context first (a context event sets
451
+ // lastCtxTokens/lastCtxPercent), then read /mega-status. At 100% usage the live
452
+ // band must read "mega" and pressure must report 100%.
453
+ const ctx = h.ctx({ getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }) });
454
+ await h.fire("context", { type: "context", messages: h.session }, ctx);
455
+ await h.commands["mega-status"].handler("", ctx);
456
+ assert.ok(h.notifies.some((n) => n.includes("tier=mega (live)")), "live band reported as mega at 100% pressure");
457
+ assert.ok(h.notifies.some((n) => n.includes("pressure=100%")), "live pressure % reported");
458
+ });
411
459
  // ---- /dashboard commands ----------------------------------------------------
412
460
  test("/dashboard-status reports no server when pid file missing", async () => {
413
461
  // Private base so this asserts "no server" on a range nothing else uses,
@@ -9,6 +9,8 @@
9
9
  import { detectConflicts } from "./conflict-scan.js";
10
10
  import { addMemory, listMemories, searchMemories, recallMemory } from "../src/store/sqlite.js";
11
11
  import { resolveRepoRoot } from "./mega-config.js";
12
+ import { defaultEmbedder } from "../src/embedder.js";
13
+ import { upsertMemoryEmbedding } from "../src/store/memoryIndex.js";
12
14
  /** Run the conflict scan and format a human-readable report. */
13
15
  export function validateExtensions() {
14
16
  const report = detectConflicts();
@@ -74,6 +76,14 @@ export function registerConflictCommands(pi, runtime) {
74
76
  const tagMatches = [...text.matchAll(/#([\w-]+)/g)].map((m) => m[1]);
75
77
  const content = text.replace(/#[\w-]+/g, "").trim();
76
78
  const id = addMemory({ content, tags: tagMatches }, repo, runtime.currentStateDir);
79
+ // S24: mirror into the cross-repo memory index (fire-and-forget).
80
+ try {
81
+ const vec = defaultEmbedder().embed(content);
82
+ void upsertMemoryEmbedding(repo, id, content, vec);
83
+ }
84
+ catch {
85
+ /* non-fatal */
86
+ }
77
87
  ctx.ui.notify(`[mega-memory] saved #${id} to ${repo.split(/[\\/]/).pop()}`);
78
88
  return;
79
89
  }
@@ -142,6 +152,13 @@ export function registerConflictCommands(pi, runtime) {
142
152
  const tagMatches = [...text.matchAll(/#([\w-]+)/g)].map((m) => m[1]);
143
153
  const content = text.replace(/#[\w-]+/g, "").trim();
144
154
  const id = addMemory({ content, tags: tagMatches }, repo, runtime.currentStateDir);
155
+ try {
156
+ const vec = defaultEmbedder().embed(content);
157
+ void upsertMemoryEmbedding(repo, id, content, vec);
158
+ }
159
+ catch {
160
+ /* non-fatal */
161
+ }
145
162
  ctx.ui.notify(`[/m] saved #${id} to ${repo.split(/[\\/]/).pop()}`);
146
163
  return;
147
164
  }
@@ -10,13 +10,22 @@ import { normalizeSessionId } from "../src/store.js";
10
10
  import { autoCompactCheck } from "../src/compact.js";
11
11
  import { estimateSessionTokens } from "../src/tokens.js";
12
12
  import { recentUserQuery, WIDGET_KEY } from "./mega-runtime.js";
13
- import { runCompact, doRecall, doRecallAsync, piCompactWouldNoop } from "./mega-pipeline.js";
13
+ import { runCompact, doRecall, doRecallAsync, piCompactWouldNoop, runMemoryReview } from "./mega-pipeline.js";
14
14
  import { recallMemoriesAndInline } from "../src/recall.js";
15
15
  import { driveNativeCompaction } from "./mega-compact-driver.js";
16
16
  import { computeLiveTrimCut, liveTrimSummaryMessage } from "./mega-trim.js";
17
17
  import { pressureFromPct, memoryReviewCadence } from "./mega-config.js";
18
+ /**
19
+ * DIAG accessor for the headless test harness: the most recently constructed
20
+ * MegaRuntime, so a test that loads the compiled extension via its default
21
+ * export can read diag counters (diagLiveTrimFires / diagBeforeCompactFires /
22
+ * diagBeforeCompactSupplied / diagAgentEndIdle) after firing synthetic events.
23
+ * No-op in production — nothing reads this outside tests.
24
+ */
25
+ export let lastRuntime;
18
26
  /** Register all pi lifecycle event handlers. */
19
27
  export function registerEventHandlers(pi, runtime, config) {
28
+ lastRuntime = runtime;
20
29
  // ---- Session lifecycle (state reset points) -------------------------------
21
30
  // Capture model/provider whenever it changes (drives real cost estimation).
22
31
  pi.on("model_select", async (_event, ctx) => {
@@ -56,6 +65,8 @@ export function registerEventHandlers(pi, runtime, config) {
56
65
  try {
57
66
  const mr = await recallMemoriesAndInline({
58
67
  query, stateDir: runtime.getStateDir(), limit: 5,
68
+ crossRepo: config.crossRepoEnabled,
69
+ crossRepoCosine: config.crossRepoCosine,
59
70
  });
60
71
  if (!mr.empty)
61
72
  runtime.pendingMemoryRecallBlock = mr.block;
@@ -82,7 +93,7 @@ export function registerEventHandlers(pi, runtime, config) {
82
93
  }
83
94
  // S21: parallel memory recall. Trigram embedder is sub-ms; await is fine.
84
95
  try {
85
- const mr = await recallMemoriesAndInline({ query, stateDir: runtime.getStateDir(), limit: 5 });
96
+ const mr = await recallMemoriesAndInline({ query, stateDir: runtime.getStateDir(), limit: 5, crossRepo: config.crossRepoEnabled, crossRepoCosine: config.crossRepoCosine });
86
97
  if (!mr.empty)
87
98
  runtime.pendingMemoryRecallBlock = mr.block;
88
99
  }
@@ -139,6 +150,46 @@ export function registerEventHandlers(pi, runtime, config) {
139
150
  const idle = ctx.isIdle?.() ?? true;
140
151
  const queued = ctx.hasPendingMessages?.() ?? false;
141
152
  const now = Date.now();
153
+ // DIAG (team-run relief): surface whether the agent is idle + over
154
+ // threshold at agent_end so we can see if a mid-run durable-trim trigger
155
+ // *should* have fired but didn't.
156
+ const overThreshold = (runtime.lastCtxTokens ?? 0) >= config.thresholdTokens;
157
+ runtime.diagAgentEndIdle++;
158
+ runtime.logger.info("agent-end-idle", {
159
+ sessionId: runtime.rt.sessionId,
160
+ idle,
161
+ queued,
162
+ overThreshold,
163
+ ctxPct: runtime.lastCtxPercent,
164
+ ctxTokens: runtime.lastCtxTokens,
165
+ thresholdTokens: config.thresholdTokens,
166
+ wouldNudge: idle && queued && now >= runtime.resumeNudgeUntil,
167
+ });
168
+ // S16+S24: MID-RUN DURABLE TRIM. During a long team run (sub-agents),
169
+ // pi's native durable compaction only fires from _checkCompaction at
170
+ // PARENT settle (agent-session.js:760/844), so the on-disk transcript +
171
+ // context meter balloon to ~150k and never relieve until the very end
172
+ // ("compacts but doesn't resume"). agent_end with activeAgents===0 is a
173
+ // SAFE, settled point: calling ctx.compact() here does NOT abort an
174
+ // in-flight turn (the S16 danger is only mid-turn). ctx.compact() runs
175
+ // pi's flow, which fires our session_before_compact handler to supply
176
+ // the durable trim (truncates the transcript from firstKeptEntryId).
177
+ // Guarded three ways: only when truly idle + over threshold, only when
178
+ // pi would actually compact (piCompactWouldNoop skips the user-facing
179
+ // no-op throw), and debounced (one durable trim per 2s) to avoid
180
+ // thrashing the transcript while sub-agents keep settling.
181
+ if (idle && overThreshold && now >= runtime.debounceUntil) {
182
+ if (!piCompactWouldNoop(ctx)) {
183
+ runtime.debounceUntil = now + 2000;
184
+ runtime.diagAgentEndDurable++;
185
+ runtime.logger.info("agent-end-durable-trigger", {
186
+ sessionId: runtime.rt.sessionId,
187
+ ctxTokens: runtime.lastCtxTokens,
188
+ thresholdTokens: config.thresholdTokens,
189
+ });
190
+ ctx.compact({ customInstructions: undefined }); // guardrails-allow PREVENT-PI-004: local ctx.compact() — no network; agent settled so no in-flight abort
191
+ }
192
+ }
142
193
  if (idle && queued && now >= runtime.resumeNudgeUntil) {
143
194
  runtime.resumeNudgeUntil = now + 30_000;
144
195
  pi.sendUserMessage("[mega-compact] continue from the compacted context above.");
@@ -166,23 +217,13 @@ export function registerEventHandlers(pi, runtime, config) {
166
217
  if (config.memoryAutoReview && runtime.currentTurn > 0) {
167
218
  const cadence = memoryReviewCadence(runtime.pressureBand, config.memoryReviewInterval);
168
219
  if (runtime.currentTurn % cadence === 0) {
169
- try {
170
- const { reviewConversation } = await import("../src/memory.js");
171
- const { applyMemoryOps } = await import("../src/memoryOps.js");
172
- const entries = ctx.sessionManager.getEntries();
173
- const view = runtime.engineView(entries.flatMap((e) => (e.message ? [e.message] : [])));
174
- const ops = reviewConversation(view, []);
175
- if (ops.length) {
176
- await applyMemoryOps(ops, runtime.currentStateDir);
177
- // S21.2: a memory op landed in this turn window. The pipeline reads
178
- // this counter after a successful compaction and fires
179
- // `consolidateMemories` only when it's > 0.
180
- runtime.memoriesTouchedThisCompaction += ops.length;
181
- }
182
- }
183
- catch {
184
- /* non-fatal — auto-review must not break the turn loop */
185
- }
220
+ // S20+S24: review the conversation and persist durable memories. The
221
+ // cadence scales with pressure (memoryReviewCadence): as context fills,
222
+ // the conversation is reviewed more often so memories keep pace with
223
+ // faster churn. Shared runMemoryReview body (also used on compact).
224
+ const entries = ctx.sessionManager.getEntries();
225
+ const view = runtime.engineView(entries.flatMap((e) => (e.message ? [e.message] : [])));
226
+ await runMemoryReview(runtime, view, "turn");
186
227
  }
187
228
  }
188
229
  });
@@ -217,22 +258,30 @@ export function registerEventHandlers(pi, runtime, config) {
217
258
  const currentTokens = usage?.tokens ?? estimateSessionTokens(view) ??
218
259
  Math.round((pct / 100) * (usage?.contextWindow ?? 0));
219
260
  // FAST GATE: token-based (tier threshold), not percentage-based.
220
- if (currentTokens < config.thresholdTokens)
261
+ if (currentTokens < config.thresholdTokens) {
262
+ runtime.diagCtxFastGate++;
221
263
  return;
264
+ }
222
265
  const check = autoCompactCheck(currentTokens, config.thresholdTokens); // SERVER-STYLE CONFIRM (local)
223
- if (!check.shouldCompact)
266
+ if (!check.shouldCompact) {
267
+ runtime.diagCtxNoCompact++;
224
268
  return;
269
+ }
225
270
  // Debounce so we don't fire on every context event past threshold.
226
271
  const now = Date.now();
227
- if (now < runtime.debounceUntil)
272
+ if (now < runtime.debounceUntil) {
273
+ runtime.diagCtxDebounce++;
228
274
  return;
275
+ }
229
276
  runtime.debounceUntil = now + 2000;
230
277
  // Adaptive compression (Fix E): scale compression strength + keepFrom depth
231
278
  // with how close we are to the model context limit.
232
279
  const pressure = pressureFromPct(pct);
233
280
  const ran = runCompact(pi, runtime, config, ctx, messages, { compressionPressure: pressure });
234
- if (ran.skipped)
281
+ if (ran.skipped) {
282
+ runtime.diagCtxRunSkipped++;
235
283
  return;
284
+ }
236
285
  // LEGACY path (rollback): v0.4.28 ctx.compact() + the no-op gate. The
237
286
  // manual compact path aborts the in-flight turn — only used behind the flag.
238
287
  // Read live from env (in addition to the load-time config) so the flag can be
@@ -264,8 +313,16 @@ export function registerEventHandlers(pi, runtime, config) {
264
313
  summary: ran.result.summary,
265
314
  anchorUserMessages,
266
315
  });
267
- if (cut === null)
316
+ if (cut === null) {
317
+ runtime.diagCtxCutNull++;
318
+ runtime.logger.info("live-trim-skip", {
319
+ sessionId: runtime.rt.sessionId,
320
+ compactedFrom: ran.result.compactedFrom,
321
+ viewLen: view.length,
322
+ anchorUserMessages,
323
+ });
268
324
  return; // unsafe / below anchor floor — no trim this call
325
+ }
269
326
  const summaryMsg = liveTrimSummaryMessage({
270
327
  compactedFrom: ran.result.compactedFrom,
271
328
  summary: ran.result.summary,
@@ -279,9 +336,23 @@ export function registerEventHandlers(pi, runtime, config) {
279
336
  };
280
337
  const recent = messages.slice(cut); // guardrails-allow PREVENT-PI-002: `cut` is the pre-sanitized `compactedFrom` produced by src/boundary.ts computeDropRange, so the preserved run begins on a toolPair-safe index.
281
338
  runtime.snapshot(ctx);
339
+ // DIAG (team-run relief): confirm the live trim actually fires + how big
340
+ // the window still is. The return is non-durable (per-LLM-call only), so
341
+ // this is the signal that the model is being fed a compacted view while
342
+ // the on-disk transcript + context meter keep growing.
343
+ runtime.diagLiveTrimFires++;
344
+ runtime.logger.info("live-trim", {
345
+ sessionId: runtime.rt.sessionId,
346
+ inputMsgs: messages.length,
347
+ outputMsgs: recent.length + 1,
348
+ compactedFrom: cut,
349
+ ctxPct: pct,
350
+ ctxTokens: usage?.tokens ?? null,
351
+ });
282
352
  return { messages: [summaryAgentMsg, ...recent] };
283
353
  }
284
354
  catch {
355
+ runtime.diagCtxThrown++;
285
356
  return; // non-fatal: no trim this call; the next context event retries
286
357
  }
287
358
  });
@@ -293,11 +364,26 @@ export function registerEventHandlers(pi, runtime, config) {
293
364
  // there is no full-reload + additive recall inflation.
294
365
  pi.on("session_before_compact", async (event, ctx) => {
295
366
  runtime.resetRuntime(ctx.sessionManager.getSessionId());
367
+ // DIAG (team-run relief): this is the ONLY durable-trim entry point. Log
368
+ // every fire + whether we supplied a compaction (truncates transcript) or
369
+ // fell through to {} (pi runs its own). If this is sparse during a team
370
+ // run, the durable trim is firing too late (only at parent settle).
371
+ const prep = event.preparation;
372
+ runtime.diagBeforeCompactFires++;
373
+ runtime.logger.info("before-compact-entry", {
374
+ sessionId: runtime.rt.sessionId,
375
+ reason: event.reason,
376
+ hasPrep: !!prep,
377
+ msgsToSummarize: prep?.messagesToSummarize?.length ?? 0,
378
+ firstKeptEntryId: prep?.firstKeptEntryId ?? null,
379
+ activeAgents: runtime.activeAgents,
380
+ });
296
381
  if (!config.auto)
297
382
  return {}; // let pi run its own native compaction
298
383
  try {
299
384
  const result = driveNativeCompaction(event, runtime, config);
300
385
  if (result) {
386
+ runtime.diagBeforeCompactSupplied++;
301
387
  runtime.logger.info("native-compact", {
302
388
  sessionId: runtime.rt.sessionId,
303
389
  firstKeptEntryId: result.compaction.firstKeptEntryId,
@@ -18,6 +18,35 @@ import { resolveRepoRoot, preserveRecentForPressure } from "./mega-config.js";
18
18
  import { runRaptor } from "../src/dedup/raptor/index.js";
19
19
  import { loadDedupConfig } from "../src/config/dedup.js";
20
20
  import { upsertEmbedding as indexUpsertEmbedding } from "../src/store/vectorIndex.js";
21
+ /**
22
+ * Review the live conversation and persist durable memories (S20+S24). Shared by
23
+ * the pressure-scaled turn-end cadence (mega-events.ts) AND review-on-compact
24
+ * (below) so both paths run the identical review body. Best-effort + non-fatal:
25
+ * a review failure is swallowed and never breaks the caller. On success, the
26
+ * number of applied ops is returned so callers can feed the consolidation gate.
27
+ *
28
+ * @param view the engine message view to review (caller builds it)
29
+ * @param label a short source tag for the ticker line (e.g. "pressure" / "turn")
30
+ */
31
+ export async function runMemoryReview(runtime, view, label) {
32
+ try {
33
+ const { reviewConversation } = await import("../src/memory.js");
34
+ const { applyMemoryOps } = await import("../src/memoryOps.js");
35
+ const ops = reviewConversation(view, []);
36
+ if (ops.length) {
37
+ await applyMemoryOps(ops, runtime.currentStateDir);
38
+ // S21.2: ops landed — the compaction path reads this counter and fires
39
+ // `consolidateMemories` only when > 0.
40
+ runtime.memoriesTouchedThisCompaction += ops.length;
41
+ runtime.pushTicker(`${C.green}🧠${C.reset} reviewed ${ops.length} memory op${ops.length === 1 ? "" : "s"} (${label})`);
42
+ }
43
+ return ops.length;
44
+ }
45
+ catch {
46
+ /* non-fatal — auto-review must never break the turn loop / compaction */
47
+ return 0;
48
+ }
49
+ }
21
50
  /** Run the full compaction pipeline and persist a checkpoint. Returns the result. */
22
51
  export function runCompact(pi, runtime, config, ctx, messages, opts = {}) {
23
52
  runtime.bindRepo(ctx.cwd);
@@ -135,25 +164,11 @@ function doCompact(view, keepFrom, opts, sid, config, pi, ctx, runtime) {
135
164
  }
136
165
  // S24 review-on-compact: when pressure is high, the just-compacted region is
137
166
  // exactly the context worth remembering, so review it immediately rather than
138
- // waiting for the next turn-cadence tick. Fire-and-forget (doCompact is sync):
139
- // best-effort + non-fatal, paralleling the consolidate pass above. Only fires
140
- // above the `high` band so low-pressure compactions don't pay the review cost.
167
+ // waiting for the next turn-cadence tick. Uses the shared runMemoryReview
168
+ // helper (fire-and-forget; doCompact is sync). Best-effort + non-fatal. Only
169
+ // fires above the `high` band so low-pressure compactions don't pay the cost.
141
170
  if (!result.deduped && config.memoryAutoReview && runtime.pressureBand !== "low" && runtime.pressureBand !== "medium") {
142
- void (async () => {
143
- try {
144
- const { reviewConversation } = await import("../src/memory.js");
145
- const { applyMemoryOps } = await import("../src/memoryOps.js");
146
- const ops = reviewConversation(view, []);
147
- if (ops.length) {
148
- await applyMemoryOps(ops, runtime.currentStateDir);
149
- runtime.memoriesTouchedThisCompaction += ops.length;
150
- runtime.pushTicker(`${C.green}🧠${C.reset} reviewed ${ops.length} memory op${ops.length === 1 ? "" : "s"} (pressure)`);
151
- }
152
- }
153
- catch {
154
- /* non-fatal — review-on-compact must never break the compaction */
155
- }
156
- })();
171
+ void runMemoryReview(runtime, view, "pressure");
157
172
  }
158
173
  // Sentinel marker: a non-LLM bookkeeping entry so subsequent triggers can
159
174
  // skip re-vectorizing an already-compacted region (zero token cost).
@@ -120,6 +120,26 @@ export class MegaRuntime {
120
120
  lastCtxTokens = null;
121
121
  lastCtxPercent = null;
122
122
  lastCtxWindow = 0;
123
+ /**
124
+ * DIAG counters for the "team run doesn't relieve context" investigation.
125
+ * Plain integers, incremented at the three compaction decision points. They
126
+ * let a headless test drive the real event handlers and assert the firing
127
+ * cadence without scraping log files. Inert in production (the live-trim and
128
+ * before-compact probes also emit logger.info, but these counters are always
129
+ * updated and cost nothing).
130
+ */
131
+ diagLiveTrimFires = 0; // context handler returned a trimmed view
132
+ diagBeforeCompactFires = 0; // session_before_compact handler entered
133
+ diagBeforeCompactSupplied = 0; // session_before_compact supplied our trim
134
+ diagAgentEndIdle = 0; // agent_end with activeAgents===0
135
+ diagAgentEndDurable = 0; // agent_end fired ctx.compact() (mid-run durable trim)
136
+ // Per-skip-path counters for the team-run diagnosis.
137
+ diagCtxFastGate = 0; // returned at token fast-gate (below threshold)
138
+ diagCtxNoCompact = 0; // autoCompactCheck().shouldCompact === false
139
+ diagCtxDebounce = 0; // debounceUntil not yet elapsed
140
+ diagCtxRunSkipped = 0; // runCompact() returned skipped
141
+ diagCtxCutNull = 0; // computeLiveTrimCut returned null (anchor/boundary)
142
+ diagCtxThrown = 0; // live-trim try threw (caught)
123
143
  /**
124
144
  * Live 0–1 pressure: how full the context window is relative to the compaction
125
145
  * threshold. Computed from the most recent context event the runtime already
@@ -0,0 +1,143 @@
1
+ /**
2
+ * mega-teamrun.test.ts — regression test for the "auto-compact runs but context
3
+ * never relieves during a team run (sub-agents)" bug.
4
+ *
5
+ * Loads the REAL compiled extension (extensions/mega-compact.js) through a
6
+ * faithful mock pi (mirrors mega-compact.test.ts's harness) and drives the
7
+ * exact event sequence a long team run produces:
8
+ *
9
+ * agent_start -> context (over threshold) xN -> agent_end (repeat x3)
10
+ *
11
+ * Asserts the TWO fixes:
12
+ * 1. live trim FIRES per-call (computeLiveTrimCut no longer returns null on
13
+ * the anchor floor — was `cutNull`, liveTrimFires===0 before the fix).
14
+ * 2. the DURABLE trim fires at agent_end while idle + over threshold
15
+ * (mid-run durable trigger), not only at parent settle.
16
+ *
17
+ * The mock ctx.compact() drives session_before_compact so we observe the
18
+ * durable truncation. Counters come from MegaRuntime.diag* (set behind the
19
+ * real handler code, inert in production).
20
+ *
21
+ * MEGACOMPACT_PGLITE_DISABLED keeps the run fast (no WASM index init).
22
+ */
23
+ import { test } from "node:test";
24
+ import assert from "node:assert/strict";
25
+ import { mkdtempSync, rmSync } from "node:fs";
26
+ import { tmpdir } from "node:os";
27
+ import { join } from "node:path";
28
+ import { createRequire } from "node:module";
29
+ import { closeVectorIndex } from "../src/store/vectorIndex.js";
30
+ const require = createRequire(import.meta.url);
31
+ const baseTmp = mkdtempSync(join(tmpdir(), "mc-team-"));
32
+ process.env.MEGACOMPACT_INDEX_DIR = join(baseTmp, "index");
33
+ process.env.MEGACOMPACT_PGLITE_DISABLED = "true"; // fast: skip WASM index
34
+ let counter = 0;
35
+ function harness() {
36
+ const stateDir = join(baseTmp, `run-${counter++}`);
37
+ process.env.MEGACOMPACT_STATE_DIR = stateDir;
38
+ process.env.MEGACOMPACT_DEBUG = "true";
39
+ process.env.MEGACOMPACT_THRESHOLD_TOKENS = "50";
40
+ process.env.MEGACOMPACT_FAST_GATE_PCT = "1";
41
+ process.env.MEGACOMPACT_ANCHOR_USER_MESSAGES = "1";
42
+ process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR = "0"; // piCompactWouldNoop must not skip
43
+ process.env.MEGACOMPACT_MEMORY_AUTO_REVIEW = "false";
44
+ process.env.MEGACOMPACT_RAPTOR_ENABLED = "false";
45
+ delete process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM;
46
+ const handlers = {};
47
+ const compactCalls = [];
48
+ function msg(role, text, toolName) {
49
+ if (role === "assistant" && toolName) {
50
+ return { role: "assistant", content: [{ type: "toolCall", name: toolName, id: "c1", arguments: {} }], api: "anthropic-messages", provider: "anthropic", model: "m", usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 }, stopReason: "tool_use", timestamp: 0 };
51
+ }
52
+ if (role === "toolResult" && toolName) {
53
+ return { role: "toolResult", content: [{ type: "text", text }], toolCallId: "c1", toolName, isError: false, timestamp: 0 };
54
+ }
55
+ return { role: "user", content: text, timestamp: 0 };
56
+ }
57
+ const session = [];
58
+ for (let i = 0; i < 14; i++) {
59
+ session.push(msg("user", `actually we decided to use approach ${i} for module ${i}`));
60
+ session.push(msg("assistant", `edited module ${i}`, "Edit"));
61
+ session.push(msg("toolResult", `edited module ${i}`, "Edit"));
62
+ }
63
+ const toEntry = (m, i) => ({ type: "message", id: `e${i}`, parentId: null, timestamp: String(i), message: m });
64
+ const sessionManager = {
65
+ getSessionId: () => "sess_team_001",
66
+ getEntries: () => session.map(toEntry),
67
+ getBranch: () => session.map(toEntry),
68
+ };
69
+ function makeCtx(over = {}) {
70
+ return {
71
+ ui: { setStatus: () => { }, notify: () => { }, select: () => { }, confirm: async () => true, input: async () => "", setWidget: () => { } },
72
+ mode: "tui", hasUI: true, cwd: stateDir, sessionManager,
73
+ modelRegistry: {}, model: undefined, isIdle: () => true, isProjectTrusted: () => true,
74
+ signal: undefined, abort: () => { }, hasPendingMessages: () => false, shutdown: () => { },
75
+ getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }),
76
+ // Mock ctx.compact() runs pi's flow and fires session_before_compact.
77
+ compact: (opts) => {
78
+ compactCalls.push(opts);
79
+ if (handlers["session_before_compact"]) {
80
+ return handlers["session_before_compact"]({ type: "session_before_compact", reason: "threshold", willRetry: false, signal: undefined, preparation: { firstKeptEntryId: "e2", messagesToSummarize: session.slice(0, 2), tokensBefore: 500 } }, makeCtx());
81
+ }
82
+ return undefined;
83
+ },
84
+ getSystemPrompt: () => "system base",
85
+ ...over,
86
+ };
87
+ }
88
+ const pi = {
89
+ on: (ev, h) => { handlers[ev] = h; },
90
+ registerCommand: () => { }, registerTool: () => { }, registerShortcut: () => { },
91
+ registerFlag: () => { }, getFlag: () => undefined, registerMessageRenderer: () => { },
92
+ registerEntryRenderer: () => { }, sendMessage: () => { }, sendUserMessage: () => { },
93
+ appendEntry: () => { }, setSessionName: () => { }, getSessionName: () => undefined,
94
+ setLabel: () => { }, exec: async () => ({ stdout: "", stderr: "", code: 0 }),
95
+ getActiveTools: () => [], getAllTools: () => [], setActiveTools: () => { },
96
+ getCommands: () => [], setModel: async () => false, getThinkingLevel: () => "off",
97
+ setThinkingLevel: () => { },
98
+ };
99
+ const mod = require("./mega-compact.js");
100
+ mod.default(pi);
101
+ const { lastRuntime } = require("./mega-events.js");
102
+ const fire = (ev, event, ctx) => handlers[ev](event, ctx);
103
+ return {
104
+ stateDir, handlers, compactCalls, fire, ctx: makeCtx, session,
105
+ runtime: lastRuntime, // MegaRuntime with diag* counters
106
+ // Advance the debounce so agent_end (same instant) can trigger durable trim.
107
+ clearDebounce: () => { if (lastRuntime)
108
+ lastRuntime.debounceUntil = 0; },
109
+ };
110
+ }
111
+ test("team run: live trim fires AND durable trim fires per sub-agent (relieves context)", async () => {
112
+ const h = harness();
113
+ const ctx = h.ctx();
114
+ for (let a = 0; a < 3; a++) {
115
+ await h.fire("agent_start", { type: "agent_start", messages: [] }, ctx);
116
+ for (let i = 0; i < 4; i++) {
117
+ await h.fire("context", { type: "context", messages: h.session }, ctx);
118
+ }
119
+ // Real team runs settle seconds after the last context event; mimic that
120
+ // so the 2s debounce has elapsed and the durable trigger can fire.
121
+ await new Promise((r) => setTimeout(r, 2100));
122
+ h.clearDebounce();
123
+ await h.fire("agent_end", { type: "agent_end", messages: [] }, ctx);
124
+ }
125
+ const rt = h.runtime;
126
+ // FIX 1: live trim must actually fire (was 0 — computeLiveTrimCut returned null).
127
+ assert.ok(rt.diagLiveTrimFires > 0, "live trim fires during the team run (anchor-floor fix)");
128
+ assert.equal(rt.diagCtxCutNull, 0, "no live-trim cut skipped on anchor floor");
129
+ // FIX 2: durable trim must fire at each agent_end (was 0 — only at parent settle).
130
+ assert.equal(rt.diagAgentEndDurable, 3, "mid-run durable trigger fired at each agent_end");
131
+ assert.equal(rt.diagBeforeCompactSupplied, 3, "our durable trim supplied 3x (context relieved)");
132
+ assert.ok(h.compactCalls.length >= 3, "ctx.compact() invoked for durable trim between sub-agents");
133
+ });
134
+ test("control: session_before_compact supplies a durable compaction (parent settles)", async () => {
135
+ const h = harness();
136
+ const res = await h.fire("session_before_compact", { type: "session_before_compact", reason: "threshold", willRetry: false, signal: undefined, preparation: { firstKeptEntryId: "e2", messagesToSummarize: h.session.slice(0, 4), tokensBefore: 500 } }, h.ctx());
137
+ assert.ok(res?.compaction, "compaction result returned to pi");
138
+ assert.equal(res.compaction.firstKeptEntryId, "e2", "reuses pi's boundary (PREVENT-PI-002)");
139
+ });
140
+ test("cleanup", async () => {
141
+ await closeVectorIndex();
142
+ rmSync(baseTmp, { recursive: true, force: true });
143
+ });
@@ -22,8 +22,39 @@ export function computeLiveTrimCut(view, opts) {
22
22
  return null; // nothing safe to cut — keep everything this call
23
23
  const recent = view.slice(cut);
24
24
  const userCount = recent.filter((m) => m.role === "user").length;
25
- if (userCount < opts.anchorUserMessages)
26
- return null;
25
+ // ANCHOR FLOOR (PREVENT-PI-001): the recent window must keep at least
26
+ // `anchorUserMessages` user messages. The original compactedFrom can land on a
27
+ // run that starts with fewer than that (e.g. the preserved region begins on a
28
+ // tool pair, or the session's tail is tool-heavy). Instead of bailing out and
29
+ // skipping the live trim entirely this call (which left the model fed a
30
+ // 150k-context window during long team runs), walk `cut` backward until the
31
+ // preserved run contains enough user messages — bounded by the boundary-safe
32
+ // constraint so we never split a tool pair. Falls back to null only when the
33
+ // whole view can't satisfy the floor (tiny sessions) — the next context event
34
+ // retries.
35
+ if (userCount < opts.anchorUserMessages) {
36
+ let c = cut;
37
+ while (c > 1) {
38
+ c--;
39
+ if (!isBoundarySafe(view, c))
40
+ continue;
41
+ const recentNow = view.slice(c);
42
+ const usersNow = recentNow.filter((m) => m.role === "user").length;
43
+ if (usersNow >= opts.anchorUserMessages) {
44
+ cut = c;
45
+ break;
46
+ }
47
+ }
48
+ if (cut > 1) {
49
+ const finalRecent = view.slice(cut);
50
+ if (finalRecent.filter((m) => m.role === "user").length < opts.anchorUserMessages) {
51
+ return null; // cannot satisfy the floor without dropping too much — retry next call
52
+ }
53
+ }
54
+ else {
55
+ return null;
56
+ }
57
+ }
27
58
  return cut;
28
59
  }
29
60
  /** The formatted compacted-region summary as a user-role engine message. */