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