@lll9p/pi-better-compaction 0.6.0 → 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.
package/README.md CHANGED
@@ -4,7 +4,7 @@ English | [中文](README.zh-CN.md)
4
4
 
5
5
  A [pi](https://github.com/nicepkg/pi) extension that upgrades context compaction with two coordinated strategies:
6
6
 
7
- 1. **OpenAI Responses APIs** use the provider's native compaction endpoint, preserving opaque context that plain text summaries lose.
7
+ 1. **OpenAI Responses APIs**, including supported GitHub Copilot models, use the provider's native compaction endpoint, preserving opaque context that plain text summaries lose.
8
8
  2. **All other APIs** (Anthropic, Gemini, etc.) can run pi's built-in compaction with a **dedicated cheaper/faster model**, so summarization doesn't consume quota on your primary model.
9
9
 
10
10
  Everything fails open — if any step cannot proceed, pi's default compaction takes over.
@@ -44,10 +44,6 @@ If the file doesn't exist, all defaults apply. The extension never creates this
44
44
  ```jsonc
45
45
  {
46
46
  "enabled": true,
47
- "midRun": {
48
- "enabled": false,
49
- "thresholdPercent": 80
50
- },
51
47
  "compactionVersion": "v2",
52
48
  "compactionModel": null,
53
49
  "compactionThinkingLevel": "off",
@@ -69,8 +65,6 @@ If the file doesn't exist, all defaults apply. The extension never creates this
69
65
  | Option | Type | Default | Description |
70
66
  |--------|------|---------|-------------|
71
67
  | `enabled` | `boolean` | `true` | Master switch. Set `false` to disable the extension entirely. |
72
- | `midRun.enabled` | `boolean` | `false` | Reserved and currently ignored. The mid-run guard remains disabled even when set to `true`. |
73
- | `midRun.thresholdPercent` | `number` | `80` | Reserved threshold for the disabled mid-run guard. Must be greater than 0 and at most 100. |
74
68
  | `compactionVersion` | `"v1" \| "v2"` | `"v2"` | Protocol for Responses-family APIs. **V2** (streaming, encrypted blob) is the current OpenAI default. **V1** uses the legacy `/responses/compact` endpoint. |
75
69
  | `compactionModel` | `string \| null` | `null` | Model for fallback compaction (non-Responses APIs, or when native compact fails). Format: `"provider/model-id"`, e.g. `"openai/gpt-5.1-mini"`. `null` = let pi use the current chat model. |
76
70
  | `compactionThinkingLevel` | `string` | `"off"` | Thinking level for the fallback compaction model. One of: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. |
package/README.zh-CN.md CHANGED
@@ -44,10 +44,6 @@ cd pi-better-compaction && pi install .
44
44
  ```jsonc
45
45
  {
46
46
  "enabled": true,
47
- "midRun": {
48
- "enabled": false,
49
- "thresholdPercent": 80
50
- },
51
47
  "compactionVersion": "v2",
52
48
  "compactionModel": null,
53
49
  "compactionThinkingLevel": "off",
@@ -69,8 +65,6 @@ cd pi-better-compaction && pi install .
69
65
  | 选项 | 类型 | 默认值 | 说明 |
70
66
  |------|------|--------|------|
71
67
  | `enabled` | `boolean` | `true` | 总开关。设为 `false` 完全禁用扩展。 |
72
- | `midRun.enabled` | `boolean` | `false` | 保留配置,当前会被忽略。即使设为 `true`,mid-run guard 仍保持禁用。 |
73
- | `midRun.thresholdPercent` | `number` | `80` | 已禁用的 mid-run guard 的保留阈值。必须大于 0 且不超过 100。 |
74
68
  | `compactionVersion` | `"v1" \| "v2"` | `"v2"` | Responses 系列 API 的压缩协议。**V2**(流式,加密 blob)是 OpenAI 当前默认协议;**V1** 使用旧版 `/responses/compact` 端点。 |
75
69
  | `compactionModel` | `string \| null` | `null` | 回退压缩使用的模型(用于非 Responses API,或原生压缩失败时)。格式:`"provider/model-id"`,如 `"openai/gpt-5.1-mini"`。`null` = 由 pi 使用当前对话模型。 |
76
70
  | `compactionThinkingLevel` | `string` | `"off"` | 回退压缩模型的思考级别。可选:`off`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`。 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lll9p/pi-better-compaction",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "description": "Better compaction for pi: native /responses/compact replay for OpenAI Responses APIs, plus a configurable compaction model driving pi's native summarization everywhere else.",
6
6
  "author": "Lilin Lao",
@@ -38,7 +38,6 @@
38
38
  "src/debug.ts",
39
39
  "src/details-store.ts",
40
40
  "src/extension-runtime.ts",
41
- "src/midrun.ts",
42
41
  "src/native-fallback.ts",
43
42
  "src/payload-rewrite.ts",
44
43
  "src/request-context-cache.ts",
@@ -67,6 +67,8 @@ export type ExecuteV2CompactionOptions = {
67
67
 
68
68
  const DEFAULT_MAX_RETRIES = 2;
69
69
  const SSE_ACCEPT = "text/event-stream";
70
+ // Copilot rejects compaction_trigger with a smaller explicit output ceiling.
71
+ const COPILOT_COMPACTION_OUTPUT_CEILING = 20_000;
70
72
 
71
73
  // ── Helpers ────────────────────────────────────────────────────────────
72
74
 
@@ -123,7 +125,7 @@ async function collectStreamOutput(response: Response, signal?: AbortSignal): Pr
123
125
  let buffer = "";
124
126
 
125
127
  try {
126
- while (true) {
128
+ stream: while (true) {
127
129
  if (signal?.aborted) {
128
130
  reader.cancel();
129
131
  return { ok: false, reason: "aborted" as const };
@@ -176,7 +178,7 @@ async function collectStreamOutput(response: Response, signal?: AbortSignal): Pr
176
178
  usage = resp.usage as V2CompactionUsage;
177
179
  }
178
180
  }
179
- continue;
181
+ break stream;
180
182
  }
181
183
 
182
184
  if (eventType === "response.failed" || eventType === "error") {
@@ -184,16 +186,18 @@ async function collectStreamOutput(response: Response, signal?: AbortSignal): Pr
184
186
  serverError = isRecord(errorObj)
185
187
  ? (typeof errorObj.message === "string" ? errorObj.message : JSON.stringify(errorObj))
186
188
  : String(errorObj);
187
- continue;
189
+ break stream;
188
190
  }
189
191
  }
190
192
  }
191
193
  } catch (error) {
192
- if (isAbortError(error)) {
194
+ if (signal?.aborted || isAbortError(error)) {
193
195
  return { ok: false, reason: "aborted" as const };
194
196
  }
195
197
  return { ok: false, reason: "stream-parse-error", errorMessage: error instanceof Error ? error.message : String(error) };
196
198
  } finally {
199
+ // Completion is terminal even when a gateway keeps the HTTP body open.
200
+ try { await reader.cancel(); } catch { /* noop */ }
197
201
  try { reader.releaseLock(); } catch { /* noop */ }
198
202
  }
199
203
 
@@ -237,7 +241,7 @@ async function executeV2Attempt(
237
241
  signal,
238
242
  });
239
243
  } catch (error) {
240
- if (isAbortError(error)) {
244
+ if (signal?.aborted || isAbortError(error)) {
241
245
  return { failure: { ok: false, reason: "aborted" } };
242
246
  }
243
247
  return {
@@ -294,21 +298,36 @@ function isRetryable(result: V2CompactionFailure): boolean {
294
298
  * to the input, streams the SSE response, and collects the compaction blob.
295
299
  *
296
300
  * Retries recoverable failures up to `maxRetries` times (default 2).
301
+ *
302
+ * Copilot routes in Pi 0.84.4; tested means live native compaction and replay.
303
+ *
304
+ * | Provider (via Copilot) | API | Tested |
305
+ * | --- | --- | --- |
306
+ * | OpenAI (Astra) | Responses | Yes |
307
+ * | xAI (Grok 4.6) | Responses | Failed (HTTP 422) |
308
+ * | Google (Gemini) | Chat Completions | No; outside this path |
309
+ * | Anthropic (Opus) | Messages | No; outside this path |
297
310
  */
298
311
  export async function executeV2Compaction(
299
312
  options: ExecuteV2CompactionOptions,
300
313
  ): Promise<V2CompactionResult> {
301
- const { runtime, request, signal, settings, context } = options;
314
+ const { runtime, request, settings, context } = options;
315
+ const deadline = AbortSignal.timeout(600_000);
316
+ const signal = options.signal ? AbortSignal.any([options.signal, deadline]) : deadline;
302
317
  const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
303
318
 
304
- const headers = toHeaders(runtime, SSE_ACCEPT);
319
+ const headers = toHeaders(runtime, SSE_ACCEPT, request.input);
305
320
  const url = runtime.responsesUrl;
306
321
 
307
322
  // Build request body: input + compaction_trigger, stream=true.
308
323
  const requestBody = {
309
324
  ...request,
310
325
  input: [...request.input, { type: "compaction_trigger" }],
326
+ store: false,
311
327
  stream: true,
328
+ ...(runtime.provider === "github-copilot" ? {
329
+ max_output_tokens: COPILOT_COMPACTION_OUTPUT_CEILING,
330
+ } : {}),
312
331
  };
313
332
 
314
333
  let lastFailure: V2CompactionFailure | undefined;
@@ -121,7 +121,7 @@ export async function executeNativeCompaction(
121
121
  options: ExecuteNativeCompactionOptions,
122
122
  ): Promise<NativeCompactionClientResult> {
123
123
  const { runtime, request, signal, settings, context } = options;
124
- const headers = toHeaders(runtime);
124
+ const headers = toHeaders(runtime, JSON_CONTENT_TYPE, request.input);
125
125
 
126
126
  if (signal?.aborted) {
127
127
  const aborted: NativeCompactionClientFailure = {
package/src/config.ts CHANGED
@@ -128,7 +128,6 @@ export function loadExtensionConfig(configPath: string = CONFIG_PATH): LoadedExt
128
128
  const warnings: string[] = [];
129
129
  const resolved: ExtensionConfig = {
130
130
  ...DEFAULT_EXTENSION_CONFIG,
131
- midRun: { ...DEFAULT_EXTENSION_CONFIG.midRun },
132
131
  responsesCompactApis: [...DEFAULT_EXTENSION_CONFIG.responsesCompactApis],
133
132
  };
134
133
  let source: string | undefined;
@@ -139,18 +138,6 @@ export function loadExtensionConfig(configPath: string = CONFIG_PATH): LoadedExt
139
138
 
140
139
  resolved.enabled = toBoolean(raw.enabled, "enabled", warnings) ?? resolved.enabled;
141
140
 
142
- if (raw.midRun === undefined) {
143
- // Keep defaults.
144
- } else if (isRecord(raw.midRun)) {
145
- resolved.midRun.enabled =
146
- toBoolean(raw.midRun.enabled, "midRun.enabled", warnings) ?? resolved.midRun.enabled;
147
- resolved.midRun.thresholdPercent =
148
- toThresholdPercent(raw.midRun.thresholdPercent, "midRun.thresholdPercent", warnings) ??
149
- resolved.midRun.thresholdPercent;
150
- } else {
151
- warnings.push("Ignoring midRun: expected a JSON object.");
152
- }
153
-
154
141
  resolved.allowCompactionContinuityBreak =
155
142
  toBoolean(raw.allowCompactionContinuityBreak, "allowCompactionContinuityBreak", warnings) ??
156
143
  resolved.allowCompactionContinuityBreak;
@@ -671,7 +671,6 @@ export function registerExtensionRuntime(
671
671
  pi: ExtensionAPI,
672
672
  dependencies: ExtensionRuntimeDependencies = DEFAULT_DEPENDENCIES,
673
673
  ): void {
674
- // Mid-run compaction is intentionally disabled regardless of config.
675
674
  pi.on("session_start", (_event, ctx) => {
676
675
  const { config, source, warnings } = dependencies.loadExtensionConfig();
677
676
  if (!config.enabled) return;
@@ -227,6 +227,43 @@ function areEquivalentValues(left: unknown, right: unknown): boolean {
227
227
  return false;
228
228
  }
229
229
 
230
+ // Match pruned output runs by call ID; all other items must stay unchanged. -- PI/gpt-6-astra
231
+ /** @internal — exported for unit testing only */
232
+ export function alignPrunedInput(actual: readonly unknown[], expected: readonly unknown[]): number[] | undefined {
233
+ const indices: number[] = [];
234
+ let actualIndex = 0;
235
+ for (let expectedIndex = 0; expectedIndex < expected.length;) {
236
+ const item = expected[expectedIndex];
237
+ if (isRecord(item) && item.type === "function_call_output") {
238
+ const outputs = new Map<string, number>();
239
+ while (expectedIndex < expected.length) {
240
+ const output = expected[expectedIndex];
241
+ if (!isRecord(output) || output.type !== "function_call_output") break;
242
+ if (typeof output.call_id !== "string" || outputs.has(output.call_id)) return undefined;
243
+ outputs.set(output.call_id, expectedIndex++);
244
+ }
245
+ while (actualIndex < actual.length) {
246
+ const output = actual[actualIndex];
247
+ if (!isRecord(output) || output.type !== "function_call_output") break;
248
+ const index = typeof output.call_id === "string" ? outputs.get(output.call_id) : undefined;
249
+ if (index === undefined || !areEquivalentValues(
250
+ { ...output, output: undefined },
251
+ { ...(expected[index] as Record<string, unknown>), output: undefined },
252
+ )) return undefined;
253
+ outputs.delete(output.call_id as string);
254
+ indices.push(index);
255
+ actualIndex++;
256
+ }
257
+ if (outputs.size > 0) return undefined;
258
+ } else {
259
+ if (!areEquivalentValues(actual[actualIndex], item)) return undefined;
260
+ indices.push(expectedIndex++);
261
+ actualIndex++;
262
+ }
263
+ }
264
+ return actualIndex === actual.length ? indices : undefined;
265
+ }
266
+
230
267
  function toBranchSummaryMessage(entry: BranchSummaryEntry): AgentMessage {
231
268
  return {
232
269
  role: "branchSummary",
@@ -449,7 +486,8 @@ function buildNativeReplaySegmentsInternal<TApi extends Api>(args: {
449
486
  ...freshPreamble.trailingInput,
450
487
  ];
451
488
 
452
- if (!areEquivalentValues(args.payload.input, originalPiReplayInput)) {
489
+ const originalIndices = alignPrunedInput(args.payload.input, originalPiReplayInput);
490
+ if (!originalIndices) {
453
491
  const parity = compareResponsesInputParity(args.payload.input, originalPiReplayInput);
454
492
  return {
455
493
  ok: false,
@@ -467,17 +505,13 @@ function buildNativeReplaySegmentsInternal<TApi extends Api>(args: {
467
505
  const compactionSummaryCount = serializeMessagesToResponsesInput(args.model, [compactionSummaryMessage]).length;
468
506
  const preCompactionKeptCount = serializeMessagesToResponsesInput(args.model, preCompactionKeptMessages).length;
469
507
  const tailStartIndex = freshPreambleCount + compactionSummaryCount + preCompactionKeptCount;
470
- const tailEndIndex = args.payload.input.length - trailingPreambleCount;
471
- const actualCompactionSummary = cloneResponsesInputSlice(
472
- args.payload.input.slice(freshPreambleCount, freshPreambleCount + compactionSummaryCount),
473
- );
474
- const actualPreCompactionKeptWindow = cloneResponsesInputSlice(
475
- args.payload.input.slice(
476
- freshPreambleCount + compactionSummaryCount,
477
- freshPreambleCount + compactionSummaryCount + preCompactionKeptCount,
478
- ),
508
+ const tailEndIndex = originalPiReplayInput.length - trailingPreambleCount;
509
+ const actualSlice = (start: number, end: number) => cloneResponsesInputSlice(
510
+ args.payload.input.filter((_, index) => originalIndices[index] >= start && originalIndices[index] < end),
479
511
  );
480
- const actualPostCompactionTail = cloneResponsesInputSlice(args.payload.input.slice(tailStartIndex, tailEndIndex));
512
+ const actualCompactionSummary = actualSlice(freshPreambleCount, freshPreambleCount + compactionSummaryCount);
513
+ const actualPreCompactionKeptWindow = actualSlice(freshPreambleCount + compactionSummaryCount, tailStartIndex);
514
+ const actualPostCompactionTail = actualSlice(tailStartIndex, tailEndIndex);
481
515
  if (!actualCompactionSummary || !actualPreCompactionKeptWindow || !actualPostCompactionTail) {
482
516
  return {
483
517
  ok: false,
package/src/runtime.ts CHANGED
@@ -39,7 +39,7 @@ export type NativeCompactionRuntime = {
39
39
  model: string;
40
40
  baseUrl: string;
41
41
  apiKey: string;
42
- headers?: Record<string, string>;
42
+ headers?: Record<string, string | null>;
43
43
  compactPath: string;
44
44
  compactUrl: string;
45
45
  responsesUrl: string;
@@ -127,25 +127,13 @@ export function buildCompactPath(api: ResponsesCompactApi): string {
127
127
  return api === "openai-codex-responses" ? CODEX_COMPACT_PATH : OPENAI_COMPACT_PATH;
128
128
  }
129
129
 
130
- /** Strip null-valued entries so downstream consumers receive a clean Record<string, string>. */
131
- function filterNullHeaders(headers: Record<string, string | null> | undefined): Record<string, string> | undefined {
132
- if (!headers) return undefined;
133
- const filtered: Record<string, string> = {};
134
- for (const [key, value] of Object.entries(headers)) {
135
- if (value !== null) {
136
- filtered[key] = value;
137
- }
138
- }
139
- return Object.keys(filtered).length > 0 ? filtered : undefined;
140
- }
141
-
142
130
  async function resolveRequestAuth(
143
131
  ctx: ExtensionContext,
144
132
  model: RuntimeModel,
145
- ): Promise<{ apiKey?: string; headers?: Record<string, string> }> {
133
+ ): Promise<{ apiKey?: string; headers?: Record<string, string | null>; baseUrl?: string }> {
146
134
  const modelRegistry = ctx.modelRegistry as {
147
135
  getApiKeyAndHeaders?: (currentModel: RuntimeModel) => Promise<
148
- | { ok: true; apiKey?: string; headers?: Record<string, string | null> }
136
+ | { ok: true; apiKey?: string; headers?: Record<string, string | null>; baseUrl?: string }
149
137
  | { ok: false; error: string }
150
138
  >;
151
139
  };
@@ -155,7 +143,7 @@ async function resolveRequestAuth(
155
143
  }
156
144
 
157
145
  const auth = await modelRegistry.getApiKeyAndHeaders(model);
158
- return auth.ok ? { apiKey: auth.apiKey, headers: filterNullHeaders(auth.headers) } : {};
146
+ return auth.ok ? { apiKey: auth.apiKey, headers: auth.headers, baseUrl: auth.baseUrl } : {};
159
147
  }
160
148
 
161
149
  export function isSupportedApi(api: string): api is ResponsesCompactApi {
@@ -201,7 +189,16 @@ export async function resolveNativeCompactionEnvironment(
201
189
  };
202
190
  }
203
191
 
204
- const currentModel = ctx.model;
192
+ let sessionModel: RuntimeModel | undefined;
193
+ const branch = ctx.sessionManager?.getBranch?.() ?? [];
194
+ for (let index = branch.length - 1; index >= 0; index -= 1) {
195
+ const entry = branch[index];
196
+ if (entry?.type === "model_change") {
197
+ sessionModel = ctx.modelRegistry.find(entry.provider, entry.modelId);
198
+ break;
199
+ }
200
+ }
201
+ const currentModel = ctx.model ?? sessionModel;
205
202
  const descriptor = getRuntimeModelDescriptor(currentModel);
206
203
  if (!currentModel || !descriptor.provider || !descriptor.api || !descriptor.model) {
207
204
  return {
@@ -223,14 +220,6 @@ export async function resolveNativeCompactionEnvironment(
223
220
  };
224
221
  }
225
222
 
226
- if (!descriptor.baseUrl) {
227
- return {
228
- ok: false,
229
- reason: "missing-base-url",
230
- ...descriptor,
231
- };
232
- }
233
-
234
223
  let requestPayload: ResponsesCompatibleRequestPayload | undefined;
235
224
  if (payload !== undefined) {
236
225
  if (!isResponsesCompatiblePayload(payload)) {
@@ -252,7 +241,13 @@ export async function resolveNativeCompactionEnvironment(
252
241
  requestPayload = payload;
253
242
  }
254
243
 
255
- const { apiKey, headers } = await resolveRequestAuth(ctx, currentModel);
244
+ const { apiKey, headers, baseUrl: authBaseUrl } = await resolveRequestAuth(ctx, currentModel);
245
+ // OAuth can route a configured Individual model to an Enterprise endpoint.
246
+ // Use the same resolved endpoint for transport AND persisted replay identity.
247
+ const baseUrl = normalizeBaseUrl(authBaseUrl) ?? descriptor.baseUrl;
248
+ if (!baseUrl) {
249
+ return { ok: false, reason: "missing-base-url", ...descriptor };
250
+ }
256
251
  if (!apiKey) {
257
252
  return {
258
253
  ok: false,
@@ -267,14 +262,14 @@ export async function resolveNativeCompactionEnvironment(
267
262
  provider: descriptor.provider,
268
263
  api: descriptor.api,
269
264
  model: descriptor.model,
270
- baseUrl: descriptor.baseUrl,
265
+ baseUrl,
271
266
  apiKey,
272
267
  headers,
273
268
  compactPath: buildCompactPath(descriptor.api),
274
- compactUrl: buildCompactUrl(descriptor.baseUrl, descriptor.api),
275
- responsesUrl: buildResponsesUrl(descriptor.baseUrl, descriptor.api),
269
+ compactUrl: buildCompactUrl(baseUrl, descriptor.api),
270
+ responsesUrl: buildResponsesUrl(baseUrl, descriptor.api),
276
271
  payload: requestPayload,
277
- currentModel,
272
+ currentModel: { ...currentModel, baseUrl },
278
273
  },
279
274
  };
280
275
  }
@@ -55,31 +55,25 @@ function buildCodexUserAgent(): string {
55
55
  * and Codex-specific headers (account ID, originator, user-agent, beta flag).
56
56
  *
57
57
  * @param accept - The Accept header value. Defaults to `application/json`.
58
+ * @param input - The request input items, used for Copilot vision detection.
58
59
  */
59
60
  export function toHeaders(
60
61
  runtime: NativeCompactionRuntime,
61
62
  accept: string = JSON_CONTENT_TYPE,
63
+ input: readonly unknown[] = [],
62
64
  ): Record<string, string> {
63
65
  const headers = new Headers();
64
- // Model-level headers may contain null values (ProviderHeaders); null means "unset".
65
- for (const [key, value] of Object.entries(runtime.currentModel.headers ?? {})) {
66
- if (value != null) {
67
- headers.set(key, String(value));
66
+ headers.set("authorization", `Bearer ${runtime.apiKey}`);
67
+ if (runtime.provider === "github-copilot") {
68
+ // Compaction is an agent-initiated request, even when history ends with a user.
69
+ headers.set("x-initiator", "agent");
70
+ headers.set("openai-intent", "conversation-edits");
71
+ if (input.some((item) => isRecord(item) && [item.content, item.output].some((blocks) =>
72
+ Array.isArray(blocks) && blocks.some((block) => isRecord(block) && block.type === "input_image"),
73
+ ))) {
74
+ headers.set("copilot-vision-request", "true");
68
75
  }
69
76
  }
70
- // Extension-resolved headers (already filtered by resolveRequestAuth, but defensive).
71
- for (const [key, value] of Object.entries(runtime.headers ?? {})) {
72
- if (value == null) {
73
- headers.delete(key);
74
- } else {
75
- headers.set(key, value);
76
- }
77
- }
78
- headers.set("accept", accept);
79
- headers.set("content-type", JSON_CONTENT_TYPE);
80
- if (!headers.has("authorization")) {
81
- headers.set("authorization", `Bearer ${runtime.apiKey}`);
82
- }
83
77
 
84
78
  if (runtime.api === "openai-codex-responses") {
85
79
  const accountId = extractCodexAccountId(runtime.apiKey);
@@ -91,6 +85,18 @@ export function toHeaders(
91
85
  headers.set("openai-beta", "responses=experimental");
92
86
  }
93
87
 
88
+ // Defaults first, then model headers, then resolved request auth. Null means
89
+ // remove, even for Authorization and Codex-specific headers.
90
+ for (const source of [runtime.currentModel.headers, runtime.headers]) {
91
+ for (const [key, value] of Object.entries(source ?? {})) {
92
+ if (value == null) headers.delete(key);
93
+ else headers.set(key, value);
94
+ }
95
+ }
96
+ // These two are mandatory transport invariants, not overridable auth defaults.
97
+ headers.set("accept", accept);
98
+ headers.set("content-type", JSON_CONTENT_TYPE);
99
+
94
100
  return Object.fromEntries(headers.entries());
95
101
  }
96
102
 
package/src/types.ts CHANGED
@@ -33,14 +33,8 @@ export type DebugArtifactKind =
33
33
  | "compaction-event"
34
34
  | "lifecycle";
35
35
 
36
- export type MidRunConfig = {
37
- enabled: boolean;
38
- thresholdPercent: number;
39
- };
40
-
41
36
  export type ExtensionConfig = {
42
37
  enabled: boolean;
43
- midRun: MidRunConfig;
44
38
  /**
45
39
  * Allow a Responses session whose latest compaction was not created by this extension
46
40
  * to restart native compaction from Pi's current serialized session context.
@@ -310,10 +304,6 @@ export function createNativeCompactionResult(
310
304
 
311
305
  export const DEFAULT_EXTENSION_CONFIG: ExtensionConfig = {
312
306
  enabled: true,
313
- midRun: {
314
- enabled: false,
315
- thresholdPercent: 80,
316
- },
317
307
  allowCompactionContinuityBreak: false,
318
308
  compactionModel: undefined,
319
309
  compactionThinkingLevel: "off",
package/src/midrun.ts DELETED
@@ -1,229 +0,0 @@
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
- }