@lll9p/pi-better-compaction 0.2.1 → 0.5.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.
@@ -6,15 +6,18 @@ import type {
6
6
  SessionBeforeCompactEvent,
7
7
  } from "@earendil-works/pi-coding-agent";
8
8
  import { executeNativeCompaction } from "./compact-client";
9
+ import { executeV2Compaction } from "./compact-client-v2";
9
10
  import { loadExtensionConfig } from "./config";
10
11
  import { writeDebugArtifact } from "./debug";
11
12
  import { resolveLatestNativeCompactionEntry } from "./details-store";
13
+ import { registerMidRunGuard } from "./midrun";
12
14
  import { runNativeFallbackCompaction } from "./native-fallback";
13
15
  import {
14
16
  rewriteResponsesPayloadWithNativeReplay,
15
17
  serializeLiveTailToResponsesInput,
16
18
  } from "./payload-rewrite";
17
19
  import { getCompactionRequestExtras, rememberRequestContext } from "./request-context-cache";
20
+ import { buildRetainedMessages } from "./retained-messages";
18
21
  import {
19
22
  isResponsesCompatiblePayload,
20
23
  resolveNativeCompactionEnvironment,
@@ -26,6 +29,8 @@ import {
26
29
  createNativeCompactionResult,
27
30
  EXTENSION_ID,
28
31
  isNativeCompactionDetails,
32
+ NATIVE_COMPACTION_STRATEGY,
33
+ NATIVE_COMPACTION_STRATEGY_V2,
29
34
  type ExtensionConfig,
30
35
  type NativeCompactionDetails,
31
36
  type NativeCompactionRequestMeta,
@@ -36,6 +41,20 @@ type ResponsesCompactOutcome =
36
41
  | { outcome: "aborted" }
37
42
  | { outcome: "failed" };
38
43
 
44
+ export type ExtensionRuntimeDependencies = {
45
+ loadExtensionConfig: typeof loadExtensionConfig;
46
+ executeNativeCompaction: typeof executeNativeCompaction;
47
+ executeV2Compaction: typeof executeV2Compaction;
48
+ runNativeFallbackCompaction: typeof runNativeFallbackCompaction;
49
+ };
50
+
51
+ const DEFAULT_DEPENDENCIES: ExtensionRuntimeDependencies = {
52
+ loadExtensionConfig,
53
+ executeNativeCompaction,
54
+ executeV2Compaction,
55
+ runNativeFallbackCompaction,
56
+ };
57
+
39
58
  function buildCompactionRequestMeta(event: SessionBeforeCompactEvent): NativeCompactionRequestMeta {
40
59
  return {
41
60
  tokensBefore: event.preparation.tokensBefore,
@@ -87,14 +106,15 @@ function buildCompactionInstructions(systemPrompt: string, customInstructions?:
87
106
  return systemPrompt;
88
107
  }
89
108
 
90
- return `${systemPrompt}\n\nAdditional user guidance for this manual /compact request:\n${guidance}`;
109
+ return `${systemPrompt}\n\nAdditional compaction guidance:\n${guidance}`;
91
110
  }
92
111
 
93
- async function runResponsesNativeCompact(
112
+ async function runResponsesV1Compact(
94
113
  event: SessionBeforeCompactEvent,
95
114
  ctx: ExtensionContext,
96
115
  config: ExtensionConfig,
97
116
  runtime: NativeCompactionRuntime,
117
+ dependencies: ExtensionRuntimeDependencies,
98
118
  ): Promise<ResponsesCompactOutcome> {
99
119
  const instructions = buildCompactionInstructions(ctx.getSystemPrompt(), event.customInstructions);
100
120
  const branchEntries = ctx.sessionManager.getBranch();
@@ -135,7 +155,7 @@ async function runResponsesNativeCompact(
135
155
  writeDebugArtifact(
136
156
  "compaction-event",
137
157
  {
138
- event: "session_before_compact.responses-compact-skip",
158
+ event: "session_before_compact.v1-compact-skip",
139
159
  reason: latestNativeCompaction.reason,
140
160
  provider: runtime.provider,
141
161
  api: runtime.api,
@@ -157,7 +177,7 @@ async function runResponsesNativeCompact(
157
177
  request = { ...request, ...extras };
158
178
  }
159
179
 
160
- const compactResult = await executeNativeCompaction({
180
+ const compactResult = await dependencies.executeNativeCompaction({
161
181
  runtime,
162
182
  request,
163
183
  signal: event.signal,
@@ -169,7 +189,7 @@ async function runResponsesNativeCompact(
169
189
  writeDebugArtifact(
170
190
  "compaction-event",
171
191
  {
172
- event: "session_before_compact.responses-compact-failure",
192
+ event: "session_before_compact.v1-compact-failure",
173
193
  reason: compactResult.reason,
174
194
  status: compactResult.status,
175
195
  errorMessage: compactResult.errorMessage,
@@ -196,7 +216,7 @@ async function runResponsesNativeCompact(
196
216
  writeDebugArtifact(
197
217
  "compaction-event",
198
218
  {
199
- event: "session_before_compact.invalid-native-details",
219
+ event: "session_before_compact.v1-invalid-native-details",
200
220
  reason: error instanceof Error ? error.message : String(error),
201
221
  provider: runtime.provider,
202
222
  api: runtime.api,
@@ -219,7 +239,7 @@ async function runResponsesNativeCompact(
219
239
  writeDebugArtifact(
220
240
  "compaction-event",
221
241
  {
222
- event: "session_before_compact.responses-compact-success",
242
+ event: "session_before_compact.v1-compact-success",
223
243
  provider: runtime.provider,
224
244
  api: runtime.api,
225
245
  model: runtime.model,
@@ -238,8 +258,171 @@ async function runResponsesNativeCompact(
238
258
  return { outcome: "success", compaction };
239
259
  }
240
260
 
241
- async function handleSessionBeforeCompact(event: SessionBeforeCompactEvent, ctx: ExtensionContext) {
242
- const { config } = loadExtensionConfig();
261
+ /**
262
+ * V2 compaction: stream a Responses request with compaction_trigger appended.
263
+ * On success, returns retained messages + encrypted compaction blob.
264
+ */
265
+ async function runResponsesV2Compact(
266
+ event: SessionBeforeCompactEvent,
267
+ ctx: ExtensionContext,
268
+ config: ExtensionConfig,
269
+ runtime: NativeCompactionRuntime,
270
+ dependencies: ExtensionRuntimeDependencies,
271
+ ): Promise<ResponsesCompactOutcome> {
272
+ const instructions = buildCompactionInstructions(ctx.getSystemPrompt(), event.customInstructions);
273
+ const branchEntries = ctx.sessionManager.getBranch();
274
+ const latestNativeCompaction = resolveLatestNativeCompactionEntry(branchEntries, {
275
+ provider: runtime.provider,
276
+ api: runtime.api,
277
+ model: runtime.model,
278
+ baseUrl: runtime.baseUrl,
279
+ });
280
+
281
+ let requestSource: "session-context" | "non-native-session-context" | "latest-native-replay";
282
+ let request: NativeCompactionRequestBody;
283
+ if (latestNativeCompaction.ok) {
284
+ const liveTailEntries = branchEntries.slice(latestNativeCompaction.index + 1);
285
+ requestSource = "latest-native-replay";
286
+ const input: ResponsesInputItem[] = [
287
+ ...(cloneOpaqueWindow(latestNativeCompaction.entry.details.compactedWindow) as ResponsesInputItem[]),
288
+ ...serializeLiveTailToResponsesInput({ model: runtime.currentModel, entries: liveTailEntries }),
289
+ ];
290
+ request = {
291
+ model: runtime.currentModel.id,
292
+ input,
293
+ instructions,
294
+ };
295
+ } else if (
296
+ latestNativeCompaction.reason === "no-compaction" ||
297
+ (latestNativeCompaction.reason === "latest-compaction-not-native" &&
298
+ config.allowCompactionContinuityBreak)
299
+ ) {
300
+ requestSource =
301
+ latestNativeCompaction.reason === "no-compaction" ? "session-context" : "non-native-session-context";
302
+ request = serializeMessagesToCompactRequest({
303
+ model: runtime.currentModel,
304
+ messages: ctx.sessionManager.buildSessionContext().messages,
305
+ instructions,
306
+ });
307
+ } else {
308
+ writeDebugArtifact(
309
+ "compaction-event",
310
+ {
311
+ event: "session_before_compact.v2-compact-skip",
312
+ reason: latestNativeCompaction.reason,
313
+ provider: runtime.provider,
314
+ api: runtime.api,
315
+ model: runtime.model,
316
+ baseUrl: runtime.baseUrl,
317
+ latestCompactionIndex: latestNativeCompaction.latestCompactionIndex,
318
+ latestCompactionIdentity: getCompactionIdentityDebugInfo(latestNativeCompaction.latestCompaction),
319
+ },
320
+ config,
321
+ ctx,
322
+ );
323
+ return { outcome: "failed" };
324
+ }
325
+
326
+ const extras = getCompactionRequestExtras(runtime.model, getSessionId(ctx));
327
+ if (extras) {
328
+ request = { ...request, ...extras };
329
+ }
330
+
331
+ const v2Result = await dependencies.executeV2Compaction({
332
+ runtime,
333
+ request,
334
+ signal: event.signal,
335
+ settings: config,
336
+ context: ctx,
337
+ });
338
+
339
+ if (!v2Result.ok) {
340
+ writeDebugArtifact(
341
+ "compaction-event",
342
+ {
343
+ event: "session_before_compact.v2-compact-failure",
344
+ reason: v2Result.reason,
345
+ status: v2Result.status,
346
+ errorMessage: v2Result.errorMessage,
347
+ },
348
+ config,
349
+ ctx,
350
+ );
351
+ return v2Result.reason === "aborted" ? { outcome: "aborted" } : { outcome: "failed" };
352
+ }
353
+
354
+ // Build compacted window: retained messages + compaction blob.
355
+ const retainedMessages = buildRetainedMessages(request.input);
356
+ const compactedWindow = [...retainedMessages, v2Result.compactionItem];
357
+
358
+ let details: NativeCompactionDetails;
359
+ try {
360
+ details = createNativeCompactionDetails(
361
+ {
362
+ provider: runtime.provider,
363
+ api: runtime.api,
364
+ model: runtime.model,
365
+ baseUrl: runtime.baseUrl,
366
+ compactedWindow,
367
+ compactResponseId: v2Result.responseId,
368
+ createdAt: v2Result.createdAt,
369
+ requestMeta: buildCompactionRequestMeta(event),
370
+ },
371
+ NATIVE_COMPACTION_STRATEGY_V2,
372
+ );
373
+ } catch (error) {
374
+ writeDebugArtifact(
375
+ "compaction-event",
376
+ {
377
+ event: "session_before_compact.v2-invalid-native-details",
378
+ reason: error instanceof Error ? error.message : String(error),
379
+ provider: runtime.provider,
380
+ api: runtime.api,
381
+ model: runtime.model,
382
+ baseUrl: runtime.baseUrl,
383
+ },
384
+ config,
385
+ ctx,
386
+ );
387
+ return { outcome: "failed" };
388
+ }
389
+
390
+ // V2 blob is encrypted; no summary text can be extracted.
391
+ const compaction = createNativeCompactionResult({
392
+ firstKeptEntryId: event.preparation.firstKeptEntryId,
393
+ tokensBefore: event.preparation.tokensBefore,
394
+ details,
395
+ });
396
+
397
+ writeDebugArtifact(
398
+ "compaction-event",
399
+ {
400
+ event: "session_before_compact.v2-compact-success",
401
+ provider: runtime.provider,
402
+ api: runtime.api,
403
+ model: runtime.model,
404
+ requestSource,
405
+ requestInputItems: request.input.length,
406
+ requestExtras: extras ? Object.keys(extras) : [],
407
+ compactResponseId: v2Result.responseId,
408
+ retainedMessageCount: retainedMessages.length,
409
+ compactedItems: compactedWindow.length,
410
+ usage: v2Result.usage,
411
+ firstKeptEntryId: event.preparation.firstKeptEntryId,
412
+ },
413
+ config,
414
+ ctx,
415
+ );
416
+
417
+ return { outcome: "success", compaction };
418
+ }
419
+
420
+ async function handleSessionBeforeCompact(
421
+ event: SessionBeforeCompactEvent,
422
+ ctx: ExtensionContext,
423
+ dependencies: ExtensionRuntimeDependencies,
424
+ ) {
425
+ const { config } = dependencies.loadExtensionConfig();
243
426
  if (!config.enabled) {
244
427
  return undefined;
245
428
  }
@@ -271,7 +454,14 @@ async function handleSessionBeforeCompact(event: SessionBeforeCompactEvent, ctx:
271
454
  responsesCompactApis: config.responsesCompactApis,
272
455
  });
273
456
  if (resolution.ok) {
274
- const responsesOutcome = await runResponsesNativeCompact(event, ctx, config, resolution.runtime);
457
+ let responsesOutcome: ResponsesCompactOutcome;
458
+
459
+ if (config.compactionVersion === "v2") {
460
+ responsesOutcome = await runResponsesV2Compact(event, ctx, config, resolution.runtime, dependencies);
461
+ } else {
462
+ responsesOutcome = await runResponsesV1Compact(event, ctx, config, resolution.runtime, dependencies);
463
+ }
464
+
275
465
  if (responsesOutcome.outcome === "success") {
276
466
  return { compaction: responsesOutcome.compaction };
277
467
  }
@@ -296,7 +486,12 @@ async function handleSessionBeforeCompact(event: SessionBeforeCompactEvent, ctx:
296
486
  }
297
487
 
298
488
  // Branch 2: run pi's native compaction method with the configured model.
299
- const fallback = await runNativeFallbackCompaction({ ctx, event, config });
489
+ const fallback = await dependencies.runNativeFallbackCompaction({
490
+ ctx,
491
+ event,
492
+ config,
493
+ sessionId: getSessionId(ctx),
494
+ });
300
495
  if (fallback.ok) {
301
496
  if (ctx.hasUI) {
302
497
  ctx.ui.notify(
@@ -309,6 +504,7 @@ async function handleSessionBeforeCompact(event: SessionBeforeCompactEvent, ctx:
309
504
  {
310
505
  event: "session_before_compact.fallback-success",
311
506
  model: fallback.model,
507
+ usage: fallback.usage,
312
508
  },
313
509
  config,
314
510
  ctx,
@@ -344,8 +540,12 @@ async function handleSessionBeforeCompact(event: SessionBeforeCompactEvent, ctx:
344
540
  return undefined;
345
541
  }
346
542
 
347
- async function handleBeforeProviderRequest(event: BeforeProviderRequestEvent, ctx: ExtensionContext) {
348
- const { config } = loadExtensionConfig();
543
+ async function handleBeforeProviderRequest(
544
+ event: BeforeProviderRequestEvent,
545
+ ctx: ExtensionContext,
546
+ dependencies: ExtensionRuntimeDependencies,
547
+ ) {
548
+ const { config } = dependencies.loadExtensionConfig();
349
549
  if (!config.enabled) {
350
550
  return undefined;
351
551
  }
@@ -468,9 +668,14 @@ async function handleBeforeProviderRequest(event: BeforeProviderRequestEvent, ct
468
668
  return rewrite.rewrittenPayload;
469
669
  }
470
670
 
471
- export default function (pi: ExtensionAPI) {
671
+ export function registerExtensionRuntime(
672
+ pi: ExtensionAPI,
673
+ dependencies: ExtensionRuntimeDependencies = DEFAULT_DEPENDENCIES,
674
+ ): void {
675
+ registerMidRunGuard(pi, dependencies.loadExtensionConfig);
676
+
472
677
  pi.on("session_start", (_event, ctx) => {
473
- const { config, source, warnings } = loadExtensionConfig();
678
+ const { config, source, warnings } = dependencies.loadExtensionConfig();
474
679
  if (!config.enabled) return;
475
680
 
476
681
  if (warnings.length > 0 && ctx.hasUI && config.debug) {
@@ -499,6 +704,31 @@ export default function (pi: ExtensionAPI) {
499
704
  }
500
705
  });
501
706
 
502
- pi.on("session_before_compact", handleSessionBeforeCompact);
503
- pi.on("before_provider_request", handleBeforeProviderRequest);
707
+ pi.on("session_before_compact", (event, ctx) =>
708
+ handleSessionBeforeCompact(event, ctx, dependencies),
709
+ );
710
+ pi.on("before_provider_request", (event, ctx) =>
711
+ handleBeforeProviderRequest(event, ctx, dependencies),
712
+ );
713
+
714
+ pi.on("session_compact_failed", (event, ctx) => {
715
+ const { config } = dependencies.loadExtensionConfig();
716
+ if (!config.enabled) return;
717
+
718
+ writeDebugArtifact(
719
+ "compaction-event",
720
+ {
721
+ event: "session_compact_failed",
722
+ reason: event.reason,
723
+ errorMessage: event.errorMessage,
724
+ aborted: event.aborted,
725
+ willRetry: event.willRetry,
726
+ fromExtension: event.fromExtension,
727
+ },
728
+ config,
729
+ ctx,
730
+ );
731
+ });
504
732
  }
733
+
734
+ export default registerExtensionRuntime;
package/src/midrun.ts ADDED
@@ -0,0 +1,229 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { loadExtensionConfig } from "./config";
3
+ import { writeDebugArtifact } from "./debug";
4
+ import { EXTENSION_ID, type LoadedExtensionConfig } from "./types";
5
+
6
+ type MidRunPhase = "idle" | "abort-pending" | "compacting" | "resume-pending" | "failed";
7
+
8
+ type MidRunState = {
9
+ phase: MidRunPhase;
10
+ generation: number;
11
+ sessionId?: string;
12
+ baselineCompactionId?: string;
13
+ triggerTokens?: number;
14
+ triggerPercent?: number;
15
+ triggerContextWindow?: number;
16
+ };
17
+
18
+ type ConfigLoader = () => LoadedExtensionConfig;
19
+
20
+ const RESUME_CUSTOM_TYPE = "pi-better-compaction-midrun-resume";
21
+ const RESUME_PROMPT = `[pi-better-compaction/midrun]
22
+ Context compaction completed. Continue the interrupted task from the exact point where execution stopped.`;
23
+ const MIDRUN_COMPACTION_INSTRUCTIONS =
24
+ "Preserve the active task, completed work, decisions, changed files, failures, current tool-loop state, and exact next steps so execution can resume immediately after compaction.";
25
+
26
+ function getSessionId(ctx: ExtensionContext): string | undefined {
27
+ try {
28
+ return ctx.sessionManager.getSessionId();
29
+ } catch {
30
+ return undefined;
31
+ }
32
+ }
33
+
34
+ function getLatestCompactionId(ctx: ExtensionContext): string | undefined {
35
+ const branch = ctx.sessionManager.getBranch();
36
+ for (let index = branch.length - 1; index >= 0; index--) {
37
+ const entry = branch[index];
38
+ if (entry?.type === "compaction") return entry.id;
39
+ }
40
+ return undefined;
41
+ }
42
+
43
+ function resetState(state: MidRunState): void {
44
+ state.phase = "idle";
45
+ state.generation += 1;
46
+ state.sessionId = undefined;
47
+ state.baselineCompactionId = undefined;
48
+ state.triggerTokens = undefined;
49
+ state.triggerPercent = undefined;
50
+ state.triggerContextWindow = undefined;
51
+ }
52
+
53
+ function sameSession(state: MidRunState, ctx: ExtensionContext): boolean {
54
+ return state.sessionId !== undefined && state.sessionId === getSessionId(ctx);
55
+ }
56
+
57
+ function notifyFailure(ctx: ExtensionContext, message: string): void {
58
+ if (ctx.hasUI) {
59
+ ctx.ui.notify(`${EXTENSION_ID}: ${message}`, "error");
60
+ }
61
+ }
62
+
63
+ function scheduleResume(
64
+ pi: ExtensionAPI,
65
+ ctx: ExtensionContext,
66
+ state: MidRunState,
67
+ generation: number,
68
+ ): void {
69
+ state.phase = "resume-pending";
70
+
71
+ setImmediate(() => {
72
+ if (
73
+ state.generation !== generation ||
74
+ state.phase !== "resume-pending" ||
75
+ !sameSession(state, ctx)
76
+ ) {
77
+ return;
78
+ }
79
+
80
+ if (!ctx.isIdle() || ctx.hasPendingMessages()) {
81
+ resetState(state);
82
+ return;
83
+ }
84
+
85
+ resetState(state);
86
+ pi.sendMessage(
87
+ {
88
+ customType: RESUME_CUSTOM_TYPE,
89
+ content: RESUME_PROMPT,
90
+ display: false,
91
+ details: { source: "midrun-compaction" },
92
+ },
93
+ { triggerTurn: true },
94
+ );
95
+ });
96
+ }
97
+
98
+ export function registerMidRunGuard(
99
+ pi: ExtensionAPI,
100
+ loadConfig: ConfigLoader = loadExtensionConfig,
101
+ ): void {
102
+ const state: MidRunState = { phase: "idle", generation: 0 };
103
+
104
+ pi.on("turn_end", (event, ctx) => {
105
+ const { config } = loadConfig();
106
+ if (!config.enabled || !config.midRun.enabled || state.phase !== "idle") return;
107
+ if (event.toolResults.length === 0 || ctx.hasPendingMessages()) return;
108
+
109
+ const usage = ctx.getContextUsage();
110
+ if (!usage || usage.tokens == null || usage.percent == null) return;
111
+ if (usage.percent < config.midRun.thresholdPercent) return;
112
+
113
+ const sessionId = getSessionId(ctx);
114
+ if (!sessionId) return;
115
+
116
+ state.phase = "abort-pending";
117
+ state.generation += 1;
118
+ state.sessionId = sessionId;
119
+ state.baselineCompactionId = getLatestCompactionId(ctx);
120
+ state.triggerTokens = usage.tokens;
121
+ state.triggerPercent = usage.percent;
122
+ state.triggerContextWindow = usage.contextWindow;
123
+
124
+ writeDebugArtifact(
125
+ "lifecycle",
126
+ {
127
+ event: "midrun.threshold",
128
+ turnIndex: event.turnIndex,
129
+ tokens: usage.tokens,
130
+ contextWindow: usage.contextWindow,
131
+ percent: usage.percent,
132
+ thresholdPercent: config.midRun.thresholdPercent,
133
+ baselineCompactionId: state.baselineCompactionId,
134
+ },
135
+ config,
136
+ ctx,
137
+ );
138
+
139
+ // Never compact while the agent run is active; let Pi settle first.
140
+ ctx.abort();
141
+ });
142
+
143
+ pi.on("agent_settled", (_event, ctx) => {
144
+ if (state.phase !== "abort-pending") return;
145
+ if (!sameSession(state, ctx)) {
146
+ resetState(state);
147
+ return;
148
+ }
149
+
150
+ // An earlier agent_settled handler may already have started or queued another run.
151
+ if (!ctx.isIdle() || ctx.hasPendingMessages()) {
152
+ resetState(state);
153
+ return;
154
+ }
155
+
156
+ const generation = state.generation;
157
+ const latestCompactionId = getLatestCompactionId(ctx);
158
+ if (latestCompactionId !== state.baselineCompactionId) {
159
+ const { config } = loadConfig();
160
+ writeDebugArtifact(
161
+ "lifecycle",
162
+ {
163
+ event: "midrun.coalesced",
164
+ reason: "compaction-already-occurred-during-abort",
165
+ baselineCompactionId: state.baselineCompactionId,
166
+ latestCompactionId,
167
+ },
168
+ config,
169
+ ctx,
170
+ );
171
+ scheduleResume(pi, ctx, state, generation);
172
+ return;
173
+ }
174
+
175
+ const { config } = loadConfig();
176
+ if (!config.enabled || !config.midRun.enabled) {
177
+ scheduleResume(pi, ctx, state, generation);
178
+ return;
179
+ }
180
+
181
+ const usage = ctx.getContextUsage();
182
+ if (usage?.percent != null && usage.percent < config.midRun.thresholdPercent) {
183
+ scheduleResume(pi, ctx, state, generation);
184
+ return;
185
+ }
186
+
187
+ state.phase = "compacting";
188
+ ctx.compact({
189
+ customInstructions: MIDRUN_COMPACTION_INSTRUCTIONS,
190
+ onComplete: () => {
191
+ if (
192
+ state.generation !== generation ||
193
+ state.phase !== "compacting" ||
194
+ !sameSession(state, ctx)
195
+ ) {
196
+ return;
197
+ }
198
+ scheduleResume(pi, ctx, state, generation);
199
+ },
200
+ onError: (error) => {
201
+ if (state.generation !== generation || !sameSession(state, ctx)) return;
202
+
203
+ const latest = getLatestCompactionId(ctx);
204
+ if (/Already compacted/i.test(error.message) && latest !== state.baselineCompactionId) {
205
+ scheduleResume(pi, ctx, state, generation);
206
+ return;
207
+ }
208
+
209
+ state.phase = "failed";
210
+ writeDebugArtifact(
211
+ "lifecycle",
212
+ {
213
+ event: "midrun.failed",
214
+ errorMessage: error.message,
215
+ triggerTokens: state.triggerTokens,
216
+ triggerPercent: state.triggerPercent,
217
+ triggerContextWindow: state.triggerContextWindow,
218
+ },
219
+ config,
220
+ ctx,
221
+ );
222
+ notifyFailure(ctx, `mid-run compaction failed: ${error.message}`);
223
+ },
224
+ });
225
+ });
226
+
227
+ pi.on("session_start", () => resetState(state));
228
+ pi.on("session_shutdown", () => resetState(state));
229
+ }
@@ -26,6 +26,7 @@ export type NativeFallbackResult =
26
26
  ok: true;
27
27
  result: CompactionResult;
28
28
  model: { provider: string; id: string };
29
+ usage?: CompactionResult["usage"];
29
30
  }
30
31
  | {
31
32
  ok: false;
@@ -38,9 +39,21 @@ export type NativeFallbackResult =
38
39
  export type NativeCompactFn = typeof compact;
39
40
 
40
41
  type ResolvedAuth =
41
- | { ok: true; apiKey?: string; headers?: Record<string, string>; env?: Record<string, string> }
42
+ | { ok: true; apiKey?: string; headers?: Record<string, string | null>; env?: Record<string, string> }
42
43
  | { ok: false; error: string };
43
44
 
45
+ /** Strip null-valued entries so downstream consumers receive a clean Record<string, string>. */
46
+ function filterNullHeaders(headers: Record<string, string | null> | undefined): Record<string, string> | undefined {
47
+ if (!headers) return undefined;
48
+ const filtered: Record<string, string> = {};
49
+ for (const [key, value] of Object.entries(headers)) {
50
+ if (value !== null) {
51
+ filtered[key] = value;
52
+ }
53
+ }
54
+ return Object.keys(filtered).length > 0 ? filtered : undefined;
55
+ }
56
+
44
57
  /** Parse "provider/model-id" (model ids may themselves contain slashes). */
45
58
  export function parseModelSpec(spec: string): ParsedModelSpec | undefined {
46
59
  const trimmed = spec.trim();
@@ -82,6 +95,7 @@ export async function runNativeFallbackCompaction(args: {
82
95
  event: SessionBeforeCompactEvent;
83
96
  config: ExtensionConfig;
84
97
  compactFn?: NativeCompactFn;
98
+ sessionId?: string;
85
99
  }): Promise<NativeFallbackResult> {
86
100
  const { ctx, event, config } = args;
87
101
  const compactFn = args.compactFn ?? compact;
@@ -120,12 +134,15 @@ export async function runNativeFallbackCompaction(args: {
120
134
  event.preparation,
121
135
  model,
122
136
  auth.apiKey,
123
- auth.headers,
137
+ filterNullHeaders(auth.headers),
124
138
  event.customInstructions,
125
139
  event.signal,
126
140
  config.compactionThinkingLevel,
127
- undefined,
141
+ undefined, // streamFn
128
142
  auth.env,
143
+ undefined, // retry (use pi defaults)
144
+ undefined, // callbacks
145
+ args.sessionId,
129
146
  );
130
147
 
131
148
  if (event.signal.aborted) {
@@ -139,6 +156,7 @@ export async function runNativeFallbackCompaction(args: {
139
156
  ok: true,
140
157
  result,
141
158
  model: { provider: model.provider, id: model.id },
159
+ usage: result.usage,
142
160
  };
143
161
  } catch (error) {
144
162
  if (event.signal.aborted || isAbortError(error)) {