pi-mega-compact 0.6.7 → 0.7.0

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.
@@ -7,17 +7,39 @@
7
7
  * sync and delegates the heavy lifting to the pipeline + command modules.
8
8
  */
9
9
 
10
- import type { ExtensionAPI, ExtensionContext, ContextEvent, SessionBeforeCompactEvent } from "@earendil-works/pi-coding-agent";
10
+ import type {
11
+ ExtensionAPI,
12
+ ExtensionContext,
13
+ ContextEvent,
14
+ SessionBeforeCompactEvent,
15
+ } from "@earendil-works/pi-coding-agent";
11
16
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
12
17
  import { normalizeSessionId } from "../src/store.js";
13
18
  import { autoCompactCheck } from "../src/compact.js";
14
- import { estimateSessionTokens } from "../src/tokens.js";
15
- import { MegaRuntime, recentUserQuery, WIDGET_KEY } from "./mega-runtime.js";
16
- import { runCompact, doRecall, doRecallAsync, piCompactWouldNoop, runMemoryReview } from "./mega-pipeline.js";
19
+ import { estimateSessionTokens, estimateBlockTokens } from "../src/tokens.js";
20
+ import {
21
+ type MegaRuntime,
22
+ recentUserQuery,
23
+ WIDGET_KEY,
24
+ } from "./mega-runtime.js";
25
+ import {
26
+ runCompact,
27
+ doRecall,
28
+ doRecallAsync,
29
+ piCompactWouldNoop,
30
+ runMemoryReview,
31
+ } from "./mega-pipeline.js";
17
32
  import { recallMemoriesAndInline } from "../src/recall.js";
18
- import { driveNativeCompaction } from "./mega-compact-driver.js";
33
+ import {
34
+ driveNativeCompaction,
35
+ type NativeCompactionResult,
36
+ } from "./mega-compact-driver.js";
19
37
  import { computeLiveTrimCut, liveTrimSummaryMessage } from "./mega-trim.js";
20
- import { pressureFromPct, memoryReviewCadence, type MegaConfig } from "./mega-config.js";
38
+ import {
39
+ pressureFromPct,
40
+ memoryReviewCadence,
41
+ type MegaConfig,
42
+ } from "./mega-config.js";
21
43
 
22
44
  /**
23
45
  * DIAG accessor for the headless test harness: the most recently constructed
@@ -29,376 +51,552 @@ import { pressureFromPct, memoryReviewCadence, type MegaConfig } from "./mega-co
29
51
  export let lastRuntime: MegaRuntime | undefined;
30
52
 
31
53
  /** Register all pi lifecycle event handlers. */
32
- export function registerEventHandlers(pi: ExtensionAPI, runtime: MegaRuntime, config: MegaConfig): void {
33
- lastRuntime = runtime;
34
- // ---- Session lifecycle (state reset points) -------------------------------
35
- // Capture model/provider whenever it changes (drives real cost estimation).
36
- pi.on("model_select", async (_event, ctx) => {
37
- runtime.captureModel(ctx);
38
- runtime.snapshot(ctx);
39
- });
54
+ export function registerEventHandlers(
55
+ pi: ExtensionAPI,
56
+ runtime: MegaRuntime,
57
+ config: MegaConfig,
58
+ ): void {
59
+ lastRuntime = runtime;
60
+ // ---- Session lifecycle (state reset points) -------------------------------
61
+ // Capture model/provider whenever it changes (drives real cost estimation).
62
+ pi.on("model_select", async (_event, ctx) => {
63
+ runtime.captureModel(ctx);
64
+ runtime.snapshot(ctx);
65
+ });
40
66
 
41
- pi.on("session_start", async (event, ctx) => {
42
- runtime.resetRuntime(ctx.sessionManager.getSessionId());
43
- runtime.captureModel(ctx); // best-effort: ctx.model may be set by session start
44
- runtime.setStatus(ctx, config.auto ? "mega-compact: ready" : "mega-compact: manual only");
45
- // S21: clear any stale memory block from a prior session.
46
- runtime.pendingMemoryRecallBlock = undefined;
47
- // Auto-inline on resume/fork/continue: stage the most relevant checkpoints
48
- // so the next before_agent_start prepends them to the system prompt.
49
- // Triggered whenever this session already has persisted checkpoints AND a
50
- // usable query that covers reason "resume"/"fork" (explicit) and
51
- // reason "startup" (e.g. `pi --continue`s an existing session, which still
52
- // emits "startup" but with a populated message window). A brand-new empty
53
- // session has no checkpoints, so it's naturally excluded.
54
- if (config.autoInline) {
55
- const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
56
- const query = recentUserQuery(ctx);
57
- if (query && runtime.store.stats(sid).checkpointCount > 0) {
58
- // S17: use the async variant on resume so cross-repo HNSW recall can
59
- // augment when this repo's store is thin. session_start is an async-safe
60
- // point (unlike the mid-turn context handler, which stays sync).
61
- const r = await doRecallAsync(runtime, config, ctx, query, "resume", { crossRepo: config.crossRepoEnabled });
62
- if (!r.empty) {
63
- runtime.pendingRecallBlock = r.block;
64
- const crossLabel = r.toInject.some((h) => h.repoId) ? " (cross-repo)" : "";
65
- runtime.setStatus(ctx, `mega-compact: recalled ${r.toInject.length} chkpt${crossLabel}`);
66
- runtime.logger.info("auto-inline", { reason: event.reason, query, injected: r.toInject.map((h) => h.checkpoint.checkpointId), crossRepo: r.toInject.some((h) => h.repoId) });
67
- }
68
- }
69
- // S21: parallel memory recall. Same async context so we can await without
70
- // breaking the handler contract. Best-effort — never throws.
71
- try {
72
- const mr = await recallMemoriesAndInline({
73
- query, stateDir: runtime.getStateDir(), limit: 5,
74
- crossRepo: config.crossRepoEnabled,
75
- crossRepoCosine: config.crossRepoCosine,
76
- });
77
- if (!mr.empty) runtime.pendingMemoryRecallBlock = mr.block;
78
- } catch (err) {
79
- runtime.logger.warn("memory-recall skipped", { err: String(err) });
80
- }
81
- }
82
- runtime.dashboard.event("session_start", { reason: event.reason, sessionId: runtime.rt.sessionId });
83
- runtime.snapshot(ctx);
84
- });
67
+ pi.on("session_start", async (event, ctx) => {
68
+ runtime.resetRuntime(ctx.sessionManager.getSessionId());
69
+ runtime.captureModel(ctx); // best-effort: ctx.model may be set by session start
70
+ runtime.setStatus(
71
+ ctx,
72
+ config.auto ? "mega-compact: ready" : "mega-compact: manual only",
73
+ );
74
+ // S21: clear any stale memory block from a prior session.
75
+ runtime.pendingMemoryRecallBlock = undefined;
76
+ // Auto-inline on resume/fork/continue: stage the most relevant checkpoints
77
+ // so the next before_agent_start prepends them to the system prompt.
78
+ // Triggered whenever this session already has persisted checkpoints AND a
79
+ // usable query that covers reason "resume"/"fork" (explicit) and
80
+ // reason "startup" (e.g. `pi --continue`s an existing session, which still
81
+ // emits "startup" but with a populated message window). A brand-new empty
82
+ // session has no checkpoints, so it's naturally excluded.
83
+ if (config.autoInline) {
84
+ const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
85
+ const query = recentUserQuery(ctx);
86
+ if (query && runtime.store.stats(sid).checkpointCount > 0) {
87
+ // S17: use the async variant on resume so cross-repo HNSW recall can
88
+ // augment when this repo's store is thin. session_start is an async-safe
89
+ // point (unlike the mid-turn context handler, which stays sync).
90
+ const r = await doRecallAsync(runtime, config, ctx, query, "resume", {
91
+ crossRepo: config.crossRepoEnabled,
92
+ });
93
+ if (!r.empty) {
94
+ runtime.pendingRecallBlock = r.block;
95
+ const crossLabel = r.toInject.some((h) => h.repoId)
96
+ ? " (cross-repo)"
97
+ : "";
98
+ runtime.setStatus(
99
+ ctx,
100
+ `mega-compact: recalled ${r.toInject.length} chkpt${crossLabel}`,
101
+ );
102
+ runtime.logger.info("auto-inline", {
103
+ reason: event.reason,
104
+ query,
105
+ injected: r.toInject.map((h) => h.checkpoint.checkpointId),
106
+ crossRepo: r.toInject.some((h) => h.repoId),
107
+ });
108
+ }
109
+ }
110
+ // S21: parallel memory recall. Same async context so we can await without
111
+ // breaking the handler contract. Best-effort — never throws.
112
+ try {
113
+ const mr = await recallMemoriesAndInline({
114
+ query,
115
+ stateDir: runtime.getStateDir(),
116
+ limit: 5,
117
+ crossRepo: config.crossRepoEnabled,
118
+ crossRepoCosine: config.crossRepoCosine,
119
+ });
120
+ if (!mr.empty) runtime.pendingMemoryRecallBlock = mr.block;
121
+ } catch (err) {
122
+ runtime.logger.warn("memory-recall skipped", { err: String(err) });
123
+ }
124
+ }
125
+ runtime.dashboard.event("session_start", {
126
+ reason: event.reason,
127
+ sessionId: runtime.rt.sessionId,
128
+ });
129
+ runtime.snapshot(ctx);
130
+ });
85
131
 
86
- pi.on("session_tree", async (_event, ctx) => {
87
- // Branch navigation invalidates region indexes — reset checkpoint memory but
88
- // keep the on-disk store (markers replayed from entries below if needed).
89
- runtime.resetRuntime(ctx.sessionManager.getSessionId());
90
- runtime.setStatus(ctx, "mega-compact: ready (branch)");
91
- if (config.autoInline) {
92
- const query = recentUserQuery(ctx);
93
- if (query) {
94
- const r = doRecall(runtime, config, ctx, query, "resume");
95
- if (!r.empty) {
96
- runtime.pendingRecallBlock = r.block;
97
- runtime.logger.info("auto-inline", { reason: "session_tree", query, injected: r.toInject.map((h) => h.checkpoint.checkpointId) });
98
- }
99
- // S21: parallel memory recall. Trigram embedder is sub-ms; await is fine.
100
- try {
101
- const mr = await recallMemoriesAndInline({ query, stateDir: runtime.getStateDir(), limit: 5, crossRepo: config.crossRepoEnabled, crossRepoCosine: config.crossRepoCosine });
102
- if (!mr.empty) runtime.pendingMemoryRecallBlock = mr.block;
103
- } catch (err) {
104
- runtime.logger.warn("memory-recall skipped", { err: String(err) });
105
- }
106
- }
107
- }
108
- runtime.dashboard.event("session_tree", { sessionId: runtime.rt.sessionId });
109
- runtime.snapshot(ctx);
110
- });
132
+ pi.on("session_tree", async (_event, ctx) => {
133
+ // Branch navigation invalidates region indexes — reset checkpoint memory but
134
+ // keep the on-disk store (markers replayed from entries below if needed).
135
+ runtime.resetRuntime(ctx.sessionManager.getSessionId());
136
+ runtime.setStatus(ctx, "mega-compact: ready (branch)");
137
+ if (config.autoInline) {
138
+ const query = recentUserQuery(ctx);
139
+ if (query) {
140
+ const r = doRecall(runtime, config, ctx, query, "resume");
141
+ if (!r.empty) {
142
+ runtime.pendingRecallBlock = r.block;
143
+ runtime.logger.info("auto-inline", {
144
+ reason: "session_tree",
145
+ query,
146
+ injected: r.toInject.map((h) => h.checkpoint.checkpointId),
147
+ });
148
+ }
149
+ // S21: parallel memory recall. Trigram embedder is sub-ms; await is fine.
150
+ try {
151
+ const mr = await recallMemoriesAndInline({
152
+ query,
153
+ stateDir: runtime.getStateDir(),
154
+ limit: 5,
155
+ crossRepo: config.crossRepoEnabled,
156
+ crossRepoCosine: config.crossRepoCosine,
157
+ });
158
+ if (!mr.empty) runtime.pendingMemoryRecallBlock = mr.block;
159
+ } catch (err) {
160
+ runtime.logger.warn("memory-recall skipped", { err: String(err) });
161
+ }
162
+ }
163
+ }
164
+ runtime.dashboard.event("session_tree", {
165
+ sessionId: runtime.rt.sessionId,
166
+ });
167
+ runtime.snapshot(ctx);
168
+ });
111
169
 
112
- // ---- Auto-inline injection point: prepend staged recall to systemPrompt ----
113
- pi.on("before_agent_start", async (event, ctx) => {
114
- runtime.captureModel(ctx); // most reliable point ctx.model is populated
115
- const cpBlock = runtime.pendingRecallBlock;
116
- const memBlock = runtime.pendingMemoryRecallBlock;
117
- if (!cpBlock && !memBlock) return;
118
- runtime.pendingRecallBlock = undefined;
119
- runtime.pendingMemoryRecallBlock = undefined;
120
- const composed = [cpBlock, memBlock].filter(Boolean).join("\n\n");
121
- return { systemPrompt: `${event.systemPrompt}\n\n${composed}` };
122
- });
170
+ // ---- Auto-inline injection point: prepend staged recall to systemPrompt ----
171
+ pi.on("before_agent_start", async (event, ctx) => {
172
+ runtime.captureModel(ctx); // most reliable point ctx.model is populated
173
+ const cpBlock = runtime.pendingRecallBlock;
174
+ const memBlock = runtime.pendingMemoryRecallBlock;
175
+ if (!cpBlock && !memBlock) return;
176
+ runtime.pendingRecallBlock = undefined;
177
+ runtime.pendingMemoryRecallBlock = undefined;
178
+ const composed = [cpBlock, memBlock].filter(Boolean).join("\n\n");
179
+ return { systemPrompt: `${event.systemPrompt}\n\n${composed}` };
180
+ });
123
181
 
124
- pi.on("session_shutdown", async (_event, ctx) => {
125
- runtime.setStatus(ctx, undefined);
126
- runtime.activeAgents = 0;
127
- runtime.currentTurn = 0;
128
- ctx.ui.setWidget(WIDGET_KEY, [], { placement: "aboveEditor" });
129
- });
182
+ pi.on("session_shutdown", async (_event, ctx) => {
183
+ runtime.setStatus(ctx, undefined);
184
+ runtime.activeAgents = 0;
185
+ runtime.currentTurn = 0;
186
+ ctx.ui.setWidget(WIDGET_KEY, [], { placement: "aboveEditor" });
187
+ });
130
188
 
131
- // ---- Agent tracking for real-time widget + status-line updates ---------
132
- pi.on("agent_start", async (_event, ctx) => {
133
- runtime.activeAgents++;
134
- runtime.dashboard.event("agent_start", { activeAgents: runtime.activeAgents });
135
- // Surface live agent activity on the status line (toolbar), not just the
136
- // above-editor widget — otherwise concurrent agents look frozen.
137
- runtime.setStatus(ctx, `mega-compact: ${runtime.activeAgents} agent${runtime.activeAgents === 1 ? "" : "s"}`);
138
- runtime.snapshot(ctx);
139
- });
189
+ // ---- Agent tracking for real-time widget + status-line updates ---------
190
+ pi.on("agent_start", async (_event, ctx) => {
191
+ runtime.activeAgents++;
192
+ runtime.dashboard.event("agent_start", {
193
+ activeAgents: runtime.activeAgents,
194
+ });
195
+ // Surface live agent activity on the status line (toolbar), not just the
196
+ // above-editor widget — otherwise concurrent agents look frozen.
197
+ runtime.setStatus(
198
+ ctx,
199
+ `mega-compact: ▶ ${runtime.activeAgents} agent${runtime.activeAgents === 1 ? "" : "s"}`,
200
+ );
201
+ runtime.snapshot(ctx);
202
+ });
140
203
 
141
- pi.on("agent_end", async (_event, ctx) => {
142
- runtime.activeAgents = Math.max(0, runtime.activeAgents - 1);
143
- runtime.dashboard.event("agent_end", { activeAgents: runtime.activeAgents });
144
- if (runtime.activeAgents > 0) {
145
- runtime.setStatus(ctx, `mega-compact: ▶ ${runtime.activeAgents} agent${runtime.activeAgents === 1 ? "" : "s"}`);
146
- } else {
147
- runtime.setStatus(ctx, config.auto ? "mega-compact: ready" : "mega-compact: manual only");
148
- }
149
- // S16 continuation fallback: if the turn settled idle right after a live-trim
150
- // compaction AND there is queued work AND we haven't nudged recently, nudge
151
- // once so the agent continues (the live trim should make this rare). Guarded
152
- // to never busy-loop: one nudge per 30s, only when truly idle + queued.
153
- if (config.auto && runtime.activeAgents === 0) {
154
- try {
155
- const idle = ctx.isIdle?.() ?? true;
156
- const queued = ctx.hasPendingMessages?.() ?? false;
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
- }
198
- if (idle && queued && now >= runtime.resumeNudgeUntil) {
199
- runtime.resumeNudgeUntil = now + 30_000;
200
- pi.sendUserMessage("[mega-compact] continue from the compacted context above.");
201
- }
202
- } catch {
203
- /* non-fatal: a failed nudge never blocks */
204
- }
205
- }
206
- runtime.snapshot(ctx);
207
- });
204
+ pi.on("agent_end", async (_event, ctx) => {
205
+ runtime.activeAgents = Math.max(0, runtime.activeAgents - 1);
206
+ runtime.dashboard.event("agent_end", {
207
+ activeAgents: runtime.activeAgents,
208
+ });
209
+ if (runtime.activeAgents > 0) {
210
+ runtime.setStatus(
211
+ ctx,
212
+ `mega-compact: ${runtime.activeAgents} agent${runtime.activeAgents === 1 ? "" : "s"}`,
213
+ );
214
+ } else {
215
+ runtime.setStatus(
216
+ ctx,
217
+ config.auto ? "mega-compact: ready" : "mega-compact: manual only",
218
+ );
219
+ }
220
+ // S16 continuation fallback: if the turn settled idle right after a live-trim
221
+ // compaction AND there is queued work AND we haven't nudged recently, nudge
222
+ // once so the agent continues (the live trim should make this rare). Guarded
223
+ // to never busy-loop: one nudge per 30s, only when truly idle + queued.
224
+ if (config.auto && runtime.activeAgents === 0) {
225
+ try {
226
+ const idle = ctx.isIdle?.() ?? true;
227
+ const queued = ctx.hasPendingMessages?.() ?? false;
228
+ const now = Date.now();
229
+ // DIAG (team-run relief): surface whether the agent is idle + over
230
+ // threshold at agent_end so we can see if a mid-run durable-trim trigger
231
+ // *should* have fired but didn't.
232
+ const overThreshold =
233
+ (runtime.lastCtxTokens ?? 0) >= runtime.effectiveThreshold;
234
+ runtime.diagAgentEndIdle++;
235
+ runtime.logger.info("agent-end-idle", {
236
+ sessionId: runtime.rt.sessionId,
237
+ idle,
238
+ queued,
239
+ overThreshold,
240
+ ctxPct: runtime.lastCtxPercent,
241
+ ctxTokens: runtime.lastCtxTokens,
242
+ thresholdTokens: config.thresholdTokens,
243
+ wouldNudge:
244
+ idle &&
245
+ (queued || overThreshold) &&
246
+ now >= runtime.resumeNudgeUntil,
247
+ });
248
+ // S16+S24: MID-RUN DURABLE TRIM. During a long team run (sub-agents),
249
+ // pi's native durable compaction only fires from _checkCompaction at
250
+ // PARENT settle (agent-session.js:760/844), so the on-disk transcript +
251
+ // context meter balloon to ~150k and never relieve until the very end
252
+ // ("compacts but doesn't resume"). agent_end with activeAgents===0 is a
253
+ // SAFE, settled point: calling ctx.compact() here does NOT abort an
254
+ // in-flight turn (the S16 danger is only mid-turn). ctx.compact() runs
255
+ // pi's flow, which fires our session_before_compact handler to supply
256
+ // the durable trim (truncates the transcript from firstKeptEntryId).
257
+ // Guarded three ways: only when truly idle + over threshold, only when
258
+ // pi would actually compact (piCompactWouldNoop skips the user-facing
259
+ // no-op throw), and debounced (one durable trim per 2s) to avoid
260
+ // thrashing the transcript while sub-agents keep settling.
261
+ //
262
+ // FIX "compacts but doesn't resume": the manual ctx.compact() path
263
+ // STOPS the agent loop (agent-session.js:1345). The old resume-nudge
264
+ // was gated on `queued`, so when a sub-agent settled with no
265
+ // *immediately* queued message, the trim fired but the nudge did not,
266
+ // and the (stopped) session hung. The trim still fires on
267
+ // `idle && overThreshold` — we intentionally do NOT add a `!queued`
268
+ // guard, because that would suppress mid-run relief exactly during
269
+ // team-run waves where queued is usually true and relief is needed
270
+ // most. Instead we DECOUPLE the nudge from `queued`: after a durable
271
+ // trim we ALWAYS nudge so the agent reliably restarts. Debounced 30s.
272
+ let didDurableTrim = false;
273
+ if (idle && overThreshold && now >= runtime.debounceUntil) {
274
+ if (!piCompactWouldNoop(ctx)) {
275
+ runtime.debounceUntil = now + 2000;
276
+ runtime.diagAgentEndDurable++;
277
+ runtime.logger.info("agent-end-durable-trigger", {
278
+ sessionId: runtime.rt.sessionId,
279
+ ctxTokens: runtime.lastCtxTokens,
280
+ thresholdTokens: config.thresholdTokens,
281
+ queued,
282
+ });
283
+ ctx.compact({ customInstructions: undefined }); // guardrails-allow PREVENT-PI-004: local ctx.compact() — no network; agent settled so no in-flight abort
284
+ didDurableTrim = true;
285
+ }
286
+ }
287
+ // Restart the agent after a mid-run durable trim (which stopped it), or
288
+ // when it settled idle with queued work. Decoupled from `queued` for the
289
+ // durable-trim case — see FIX note above. Debounced 30s; never blocks.
290
+ if (
291
+ idle &&
292
+ now >= runtime.resumeNudgeUntil &&
293
+ (didDurableTrim || queued)
294
+ ) {
295
+ runtime.resumeNudgeUntil = now + 30_000;
296
+ pi.sendUserMessage(
297
+ "[mega-compact] continue from the compacted context above.",
298
+ );
299
+ }
300
+ } catch {
301
+ /* non-fatal: a failed nudge never blocks */
302
+ }
303
+ }
304
+ runtime.snapshot(ctx);
305
+ });
208
306
 
209
- pi.on("turn_start", async (event, ctx) => {
210
- runtime.currentTurn = event.turnIndex;
211
- runtime.dashboard.event("turn_start", { turnIndex: event.turnIndex });
212
- runtime.snapshot(ctx);
213
- });
307
+ pi.on("turn_start", async (event, ctx) => {
308
+ runtime.currentTurn = event.turnIndex;
309
+ runtime.dashboard.event("turn_start", { turnIndex: event.turnIndex });
310
+ runtime.snapshot(ctx);
311
+ });
214
312
 
215
- pi.on("turn_end", async (event, ctx) => {
216
- runtime.dashboard.event("turn_end", { turnIndex: event.turnIndex });
217
- runtime.snapshot(ctx);
313
+ pi.on("turn_end", async (event, ctx) => {
314
+ runtime.dashboard.event("turn_end", { turnIndex: event.turnIndex });
315
+ runtime.snapshot(ctx);
218
316
 
219
- // S20+S24: auto-review the conversation and persist durable memories. The
220
- // review cadence scales with pressure (memoryReviewCadence): as context
221
- // fills, the conversation is reviewed more often so memories keep pace with
222
- // faster churn. Best-effort + non-fatal: a review failure must never break
223
- // the agent loop. Debounced by the pressure-adjusted interval.
224
- if (config.memoryAutoReview && runtime.currentTurn > 0) {
225
- const cadence = memoryReviewCadence(runtime.pressureBand, config.memoryReviewInterval);
226
- if (runtime.currentTurn % cadence === 0) {
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");
234
- }
235
- }
236
- });
317
+ // S20+S24: auto-review the conversation and persist durable memories. The
318
+ // review cadence scales with pressure (memoryReviewCadence): as context
319
+ // fills, the conversation is reviewed more often so memories keep pace with
320
+ // faster churn. Best-effort + non-fatal: a review failure must never break
321
+ // the agent loop. Debounced by the pressure-adjusted interval.
322
+ if (config.memoryAutoReview && runtime.currentTurn > 0) {
323
+ const cadence = memoryReviewCadence(
324
+ runtime.pressureBand,
325
+ config.memoryReviewInterval,
326
+ );
327
+ if (runtime.currentTurn % cadence === 0) {
328
+ // S20+S24: review the conversation and persist durable memories. The
329
+ // cadence scales with pressure (memoryReviewCadence): as context fills,
330
+ // the conversation is reviewed more often so memories keep pace with
331
+ // faster churn. Shared runMemoryReview body (also used on compact).
332
+ const entries = ctx.sessionManager.getEntries();
333
+ const view = runtime.engineView(
334
+ entries.flatMap((e: any) => (e.message ? [e.message] : [])),
335
+ );
336
+ await runMemoryReview(runtime, view, "turn");
337
+ }
338
+ }
339
+ });
237
340
 
238
- // ---- Auto-trigger: live trim (compact and continue) + native durable ----
239
- // S16 redesign: we NO LONGER call ctx.compact() from the auto-trigger by
240
- // default. That mapped to pi's MANUAL compaction path, which abort()s the
241
- // in-flight turn (agent-session.js:1345) and stops the agent. Instead:
242
- // - LIVE: return { messages: trimmedView } from the context event. This
243
- // feeds pi's transformContext (sdk.js:226 → agent-loop.js:180) so the
244
- // model sees a compacted window EVERY LLM call, with no abort. The turn
245
- // continues. We persist our recall checkpoint (the durable value) first.
246
- // - DURABLE: pi's NATIVE auto-compaction fires at agent-end
247
- // (agent-session.js:1565), continues (return hasQueuedMessages()), and
248
- // emits session_before_compact — where OUR driveNativeCompaction supplies
249
- // the summary and pi truncates the transcript on disk. No ctx.compact().
250
- // Legacy: MEGACOMPACT_LEGACY_DURABLE_TRIM=true restores the v0.4.28 ctx.compact
251
- // path (kept one release as rollback).
252
- pi.on("context", async (event: ContextEvent, ctx: ExtensionContext) => {
253
- if (!config.auto) return;
254
- const usage = ctx.getContextUsage();
255
- const pct = usage?.percent;
256
- // Always track context for the dashboard, even if we return early below.
257
- runtime.lastCtxTokens = usage?.tokens ?? null;
258
- runtime.lastCtxPercent = pct ?? null;
259
- runtime.lastCtxWindow = usage?.contextWindow ?? 0;
260
- runtime.snapshot(ctx);
261
- if (pct == null) return;
341
+ // ---- Auto-trigger: live trim (compact and continue) + native durable ----
342
+ // S16 redesign: we NO LONGER call ctx.compact() from the auto-trigger by
343
+ // default. That mapped to pi's MANUAL compaction path, which abort()s the
344
+ // in-flight turn (agent-session.js:1345) and stops the agent. Instead:
345
+ // - LIVE: return { messages: trimmedView } from the context event. This
346
+ // feeds pi's transformContext (sdk.js:226 → agent-loop.js:180) so the
347
+ // model sees a compacted window EVERY LLM call, with no abort. The turn
348
+ // continues. We persist our recall checkpoint (the durable value) first.
349
+ // - DURABLE: pi's NATIVE auto-compaction fires at agent-end
350
+ // (agent-session.js:1565), continues (return hasQueuedMessages()), and
351
+ // emits session_before_compact — where OUR driveNativeCompaction supplies
352
+ // the summary and pi truncates the transcript on disk. No ctx.compact().
353
+ // Legacy: MEGACOMPACT_LEGACY_DURABLE_TRIM=true restores the v0.4.28 ctx.compact
354
+ // path (kept one release as rollback).
355
+ pi.on("context", async (event: ContextEvent, ctx: ExtensionContext) => {
356
+ if (!config.auto) return;
357
+ const usage = ctx.getContextUsage();
358
+ const pct = usage?.percent;
359
+ // Always track context for the dashboard, even if we return early below.
360
+ runtime.lastCtxTokens = usage?.tokens ?? null;
361
+ runtime.lastCtxPercent = pct ?? null;
362
+ runtime.lastCtxWindow = usage?.contextWindow ?? 0;
363
+ runtime.snapshot(ctx);
364
+ if (pct == null) return;
262
365
 
263
- const messages = event.messages;
264
- const view = runtime.engineView(messages);
265
- const currentTokens =
266
- usage?.tokens ?? estimateSessionTokens(view) ??
267
- Math.round((pct / 100) * (usage?.contextWindow ?? 0));
366
+ const messages = event.messages;
367
+ const view = runtime.engineView(messages);
368
+ const currentTokens =
369
+ usage?.tokens ??
370
+ estimateSessionTokens(view) ??
371
+ Math.round((pct / 100) * (usage?.contextWindow ?? 0));
268
372
 
269
- // FAST GATE: token-based (tier threshold), not percentage-based.
270
- if (currentTokens < config.thresholdTokens) { runtime.diagCtxFastGate++; return; }
373
+ // FAST GATE: token-based (tier% of the window), not a static amount.
374
+ if (currentTokens < runtime.effectiveThreshold) {
375
+ runtime.diagCtxFastGate++;
376
+ return;
377
+ }
271
378
 
272
- const check = autoCompactCheck(currentTokens, config.thresholdTokens); // SERVER-STYLE CONFIRM (local)
273
- if (!check.shouldCompact) { runtime.diagCtxNoCompact++; return; }
379
+ const check = autoCompactCheck(currentTokens, runtime.effectiveThreshold); // SERVER-STYLE CONFIRM (local)
380
+ if (!check.shouldCompact) {
381
+ runtime.diagCtxNoCompact++;
382
+ return;
383
+ }
274
384
 
275
- // Debounce so we don't fire on every context event past threshold.
276
- const now = Date.now();
277
- if (now < runtime.debounceUntil) { runtime.diagCtxDebounce++; return; }
278
- runtime.debounceUntil = now + 2000;
385
+ // Debounce so we don't fire on every context event past threshold.
386
+ const now = Date.now();
387
+ if (now < runtime.debounceUntil) {
388
+ runtime.diagCtxDebounce++;
389
+ return;
390
+ }
391
+ runtime.debounceUntil = now + 2000;
279
392
 
280
- // Adaptive compression (Fix E): scale compression strength + keepFrom depth
281
- // with how close we are to the model context limit.
282
- const pressure = pressureFromPct(pct);
283
- const ran = runCompact(pi, runtime, config, ctx, messages, { compressionPressure: pressure });
284
- if (ran.skipped) { runtime.diagCtxRunSkipped++; return; }
393
+ // Adaptive compression (Fix E): scale compression strength + keepFrom depth
394
+ // with how close we are to the model context limit.
395
+ const pressure = pressureFromPct(pct);
396
+ const ran = runCompact(pi, runtime, config, ctx, messages, {
397
+ compressionPressure: pressure,
398
+ });
399
+ if (ran.skipped) {
400
+ runtime.diagCtxRunSkipped++;
401
+ return;
402
+ }
285
403
 
286
- // LEGACY path (rollback): v0.4.28 ctx.compact() + the no-op gate. The
287
- // manual compact path aborts the in-flight turn — only used behind the flag.
288
- // Read live from env (in addition to the load-time config) so the flag can be
289
- // toggled per-test without reloading the module; config.legacyDurableTrim is
290
- // the cached default. (Mirrors how piCompactWouldNoop re-reads its floor.)
291
- const legacy = config.legacyDurableTrim || process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM === "true" || process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM === "1";
292
- if (legacy) {
293
- if (piCompactWouldNoop(ctx)) return;
294
- ctx.compact({ customInstructions: undefined });
295
- return;
296
- }
404
+ // LEGACY path (rollback): v0.4.28 ctx.compact() + the no-op gate. The
405
+ // manual compact path aborts the in-flight turn — only used behind the flag.
406
+ // Read live from env (in addition to the load-time config) so the flag can be
407
+ // toggled per-test without reloading the module; config.legacyDurableTrim is
408
+ // the cached default. (Mirrors how piCompactWouldNoop re-reads its floor.)
409
+ const legacy =
410
+ config.legacyDurableTrim ||
411
+ process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM === "true" ||
412
+ process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM === "1";
413
+ if (legacy) {
414
+ if (piCompactWouldNoop(ctx)) return;
415
+ ctx.compact({ customInstructions: undefined });
416
+ return;
417
+ }
297
418
 
298
- // S16 LIVE trim: collapse the compacted region to a summary + recent anchor.
299
- // Non-destructive: pi keeps the real transcript; only this LLM call sees the
300
- // trimmed window. We compute the cut on the engine view (pure, tested) then
301
- // slice the ORIGINAL pi AgentMessage[] from that index (lossless alignment,
302
- // mirroring dropCompactedRange) and prepend a user-role summary message.
303
- // A build failure or unsafe cut returns nothing (no trim this call — the
304
- // next context event retries). The anchor floor is read live from env (the
305
- // config value is the cached default) so it can be tuned per-test / per-run
306
- // without reloading the module.
307
- try {
308
- const anchorEnv = process.env.MEGACOMPACT_ANCHOR_USER_MESSAGES;
309
- const anchorUserMessages = (anchorEnv != null && anchorEnv !== "" && Number.isFinite(Number(anchorEnv)))
310
- ? Number(anchorEnv)
311
- : config.anchorUserMessages;
312
- const cut = computeLiveTrimCut(view, {
313
- compactedFrom: ran.result.compactedFrom,
314
- summary: ran.result.summary,
315
- anchorUserMessages,
316
- });
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
- }
327
- const summaryMsg = liveTrimSummaryMessage({
328
- compactedFrom: ran.result.compactedFrom,
329
- summary: ran.result.summary,
330
- anchorUserMessages: config.anchorUserMessages,
331
- });
332
- // Synthesize a user-role AgentMessage carrying the compacted summary.
333
- const summaryAgentMsg = {
334
- role: "user" as const,
335
- content: summaryMsg.text,
336
- timestamp: Date.now(),
337
- } as unknown as AgentMessage;
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.
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
- });
353
- return { messages: [summaryAgentMsg, ...recent] };
354
- } catch {
355
- runtime.diagCtxThrown++;
356
- return; // non-fatal: no trim this call; the next context event retries
357
- }
358
- });
419
+ // S16 LIVE trim: collapse the compacted region to a summary + recent anchor.
420
+ // Non-destructive: pi keeps the real transcript; only this LLM call sees the
421
+ // trimmed window. We compute the cut on the engine view (pure, tested) then
422
+ // slice the ORIGINAL pi AgentMessage[] from that index (lossless alignment,
423
+ // mirroring dropCompactedRange) and prepend a user-role summary message.
424
+ // A build failure or unsafe cut returns nothing (no trim this call — the
425
+ // next context event retries). The anchor floor is read live from env (the
426
+ // config value is the cached default) so it can be tuned per-test / per-run
427
+ // without reloading the module.
428
+ try {
429
+ const anchorEnv = process.env.MEGACOMPACT_ANCHOR_USER_MESSAGES;
430
+ const anchorUserMessages =
431
+ anchorEnv != null &&
432
+ anchorEnv !== "" &&
433
+ Number.isFinite(Number(anchorEnv))
434
+ ? Number(anchorEnv)
435
+ : config.anchorUserMessages;
436
+ const cut = computeLiveTrimCut(view, {
437
+ compactedFrom: ran.result.compactedFrom,
438
+ summary: ran.result.summary,
439
+ anchorUserMessages,
440
+ });
441
+ if (cut === null) {
442
+ runtime.diagCtxCutNull++;
443
+ runtime.logger.info("live-trim-skip", {
444
+ sessionId: runtime.rt.sessionId,
445
+ compactedFrom: ran.result.compactedFrom,
446
+ viewLen: view.length,
447
+ anchorUserMessages,
448
+ });
449
+ return; // unsafe / below anchor floor — no trim this call
450
+ }
451
+ const summaryMsg = liveTrimSummaryMessage({
452
+ compactedFrom: ran.result.compactedFrom,
453
+ summary: ran.result.summary,
454
+ anchorUserMessages: config.anchorUserMessages,
455
+ });
456
+ // Synthesize a user-role AgentMessage carrying the compacted summary.
457
+ const summaryAgentMsg = {
458
+ role: "user" as const,
459
+ content: summaryMsg.text,
460
+ timestamp: Date.now(),
461
+ } as unknown as AgentMessage;
462
+ 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.
463
+ runtime.snapshot(ctx);
464
+ // DIAG (team-run relief): confirm the live trim actually fires + how big
465
+ // the window still is. The return is non-durable (per-LLM-call only), so
466
+ // this is the signal that the model is being fed a compacted view while
467
+ // the on-disk transcript + context meter keep growing.
468
+ runtime.diagLiveTrimFires++;
469
+ runtime.logger.info("live-trim", {
470
+ sessionId: runtime.rt.sessionId,
471
+ inputMsgs: messages.length,
472
+ outputMsgs: recent.length + 1,
473
+ compactedFrom: cut,
474
+ ctxPct: pct,
475
+ ctxTokens: usage?.tokens ?? null,
476
+ });
477
+ return { messages: [summaryAgentMsg, ...recent] };
478
+ } catch {
479
+ runtime.diagCtxThrown++;
480
+ return; // non-fatal: no trim this call; the next context event retries
481
+ }
482
+ });
359
483
 
360
- // ---- Supply a DURABLE trim to pi's native compaction (Fix B) ----------
361
- // We run the Trident pipeline to produce a compressed summary, then return
362
- // it as a CompactionResult. pi writes the summary into a compactionSummary
363
- // entry AND truncates the on-disk transcript from firstKeptEntryId. This is
364
- // the durable fix for "tokens grow on read": the trim survives resume, so
365
- // there is no full-reload + additive recall inflation.
366
- pi.on("session_before_compact", async (event: SessionBeforeCompactEvent, ctx: ExtensionContext) => {
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
- });
382
- if (!config.auto) return {}; // let pi run its own native compaction
383
- try {
384
- const result = driveNativeCompaction(event, runtime, config);
385
- if (result) {
386
- runtime.diagBeforeCompactSupplied++;
387
- runtime.logger.info("native-compact", {
388
- sessionId: runtime.rt.sessionId,
389
- firstKeptEntryId: result.compaction.firstKeptEntryId,
390
- tokensBefore: result.compaction.tokensBefore,
391
- summaryTokens: result.compaction.estimatedTokensAfter,
392
- });
393
- return { compaction: result.compaction };
394
- }
395
- } catch (err) {
396
- runtime.logger.error("native-compact-failed", {
397
- sessionId: runtime.rt.sessionId,
398
- error: String(err instanceof Error ? err.message : err),
399
- });
400
- }
401
- // Fall back to pi's own native compaction if we can't supply one.
402
- return {};
403
- });
484
+ // ---- Supply a DURABLE trim to pi's native compaction (Fix B) ----------
485
+ // We run the Trident pipeline to produce a compressed summary, then return
486
+ // it as a CompactionResult. pi writes the summary into a compactionSummary
487
+ // entry AND truncates the on-disk transcript from firstKeptEntryId. This is
488
+ // the durable fix for "tokens grow on read": the trim survives resume, so
489
+ // there is no full-reload + additive recall inflation.
490
+ pi.on(
491
+ "session_before_compact",
492
+ async (event: SessionBeforeCompactEvent, ctx: ExtensionContext) => {
493
+ runtime.resetRuntime(ctx.sessionManager.getSessionId());
494
+ // DIAG (team-run relief): this is the ONLY durable-trim entry point. Log
495
+ // every fire + whether we supplied a compaction (truncates transcript) or
496
+ // fell through to {} (pi runs its own). If this is sparse during a team
497
+ // run, the durable trim is firing too late (only at parent settle).
498
+ const prep = event.preparation;
499
+ runtime.diagBeforeCompactFires++;
500
+ runtime.logger.info("before-compact-entry", {
501
+ sessionId: runtime.rt.sessionId,
502
+ reason: event.reason,
503
+ hasPrep: !!prep,
504
+ msgsToSummarize: prep?.messagesToSummarize?.length ?? 0,
505
+ firstKeptEntryId: prep?.firstKeptEntryId ?? null,
506
+ activeAgents: runtime.activeAgents,
507
+ });
508
+ if (!config.auto) return {}; // let pi run its own native compaction
509
+ try {
510
+ const result = driveNativeCompaction(event, runtime, config);
511
+ if (result && result.compaction.summary?.trim()) {
512
+ runtime.diagBeforeCompactSupplied++;
513
+ runtime.logger.info("native-compact", {
514
+ sessionId: runtime.rt.sessionId,
515
+ firstKeptEntryId: result.compaction.firstKeptEntryId,
516
+ tokensBefore: result.compaction.tokensBefore,
517
+ summaryTokens: result.compaction.estimatedTokensAfter,
518
+ });
519
+ nudgeResume(pi, runtime);
520
+ return { compaction: result.compaction };
521
+ }
522
+ // FIX "compacts but doesn't resume" + "Nothing to compact" regression:
523
+ // when we have nothing to summarize (anchor floor protects everything →
524
+ // messagesToSummarize empty) or our Trident/RAPTOR summary came back
525
+ // EMPTY, pi's OWN compact() throws "Nothing to compact (session too
526
+ // small)" and leaves the session stuck with no resume context. Instead
527
+ // of returning {} (which makes pi run its throwing compact()), supply a
528
+ // fallback compaction from prep.firstKeptEntryId with a minimal resume
529
+ // summary. This ALWAYS injects a compact summary so the session
530
+ // resumes, and never surfaces the "Nothing to compact" error to the user.
531
+ const fb = fallbackCompaction(event);
532
+ if (fb) {
533
+ runtime.diagBeforeCompactSupplied++;
534
+ runtime.logger.info("native-compact-fallback", {
535
+ sessionId: runtime.rt.sessionId,
536
+ firstKeptEntryId: fb.compaction.firstKeptEntryId,
537
+ tokensBefore: fb.compaction.tokensBefore,
538
+ reason: event.reason,
539
+ });
540
+ nudgeResume(pi, runtime);
541
+ return { compaction: fb.compaction };
542
+ }
543
+ } catch (err) {
544
+ runtime.logger.error("native-compact-failed", {
545
+ sessionId: runtime.rt.sessionId,
546
+ error: String(err instanceof Error ? err.message : err),
547
+ });
548
+ }
549
+ // Absolute last resort: let pi run its own (may throw "Nothing to compact").
550
+ return {};
551
+ },
552
+ );
553
+
554
+ /**
555
+ * Build a minimal fallback compaction so pi never runs its throwing compact().
556
+ *
557
+ * Used when our Trident/RAPTOR summary is empty or there is nothing to
558
+ * summarize (the anchor floor protects every message). We still record a
559
+ * resume summary + truncate from prep.firstKeptEntryId so the session always
560
+ * gets a compact summary and resumes. Returns undefined only if pi handed us
561
+ * no preparation cut point at all.
562
+ */
563
+ function fallbackCompaction(
564
+ event: SessionBeforeCompactEvent,
565
+ ): NativeCompactionResult | undefined {
566
+ const prep = event.preparation;
567
+ if (!prep?.firstKeptEntryId) return undefined;
568
+ // When messagesToSummarize is empty the anchor floor protects everything,
569
+ // so firstKeptEntryId == current first entry and the trim is a no-op — but
570
+ // we still record a resume summary so the session has context after compaction.
571
+ const tokensBefore = prep.tokensBefore ?? 0;
572
+ const summary =
573
+ `[mega-compact] context compacted at ${tokensBefore.toLocaleString()} tokens ` +
574
+ `(anchor floor active). Continue from the most recent messages above.`;
575
+ return {
576
+ compaction: {
577
+ summary,
578
+ firstKeptEntryId: prep.firstKeptEntryId,
579
+ tokensBefore,
580
+ estimatedTokensAfter: estimateBlockTokens(summary),
581
+ },
582
+ };
583
+ }
584
+
585
+ /**
586
+ * Debounced resume-nudge: restart the agent loop after a compaction (which
587
+ * may have stopped it). Idempotent — one nudge per 30s, never blocks.
588
+ */
589
+ function nudgeResume(pi: ExtensionAPI, runtime: MegaRuntime): void {
590
+ try {
591
+ const now = Date.now();
592
+ if (now >= runtime.resumeNudgeUntil) {
593
+ runtime.resumeNudgeUntil = now + 30_000;
594
+ pi.sendUserMessage(
595
+ "[mega-compact] continue from the compacted context above.",
596
+ );
597
+ }
598
+ } catch {
599
+ /* non-fatal: a failed nudge never blocks */
600
+ }
601
+ }
404
602
  }