@lll9p/pi-better-compaction 0.6.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lll9p/pi-better-compaction",
3
- "version": "0.6.1",
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",
@@ -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,14 +298,25 @@ 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.
@@ -310,6 +325,9 @@ export async function executeV2Compaction(
310
325
  input: [...request.input, { type: "compaction_trigger" }],
311
326
  store: false,
312
327
  stream: true,
328
+ ...(runtime.provider === "github-copilot" ? {
329
+ max_output_tokens: COPILOT_COMPACTION_OUTPUT_CEILING,
330
+ } : {}),
313
331
  };
314
332
 
315
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 = {
@@ -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 {
@@ -202,7 +190,7 @@ export async function resolveNativeCompactionEnvironment(
202
190
  }
203
191
 
204
192
  let sessionModel: RuntimeModel | undefined;
205
- const branch = ctx.sessionManager.getBranch();
193
+ const branch = ctx.sessionManager?.getBranch?.() ?? [];
206
194
  for (let index = branch.length - 1; index >= 0; index -= 1) {
207
195
  const entry = branch[index];
208
196
  if (entry?.type === "model_change") {
@@ -232,14 +220,6 @@ export async function resolveNativeCompactionEnvironment(
232
220
  };
233
221
  }
234
222
 
235
- if (!descriptor.baseUrl) {
236
- return {
237
- ok: false,
238
- reason: "missing-base-url",
239
- ...descriptor,
240
- };
241
- }
242
-
243
223
  let requestPayload: ResponsesCompatibleRequestPayload | undefined;
244
224
  if (payload !== undefined) {
245
225
  if (!isResponsesCompatiblePayload(payload)) {
@@ -261,7 +241,13 @@ export async function resolveNativeCompactionEnvironment(
261
241
  requestPayload = payload;
262
242
  }
263
243
 
264
- 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
+ }
265
251
  if (!apiKey) {
266
252
  return {
267
253
  ok: false,
@@ -276,14 +262,14 @@ export async function resolveNativeCompactionEnvironment(
276
262
  provider: descriptor.provider,
277
263
  api: descriptor.api,
278
264
  model: descriptor.model,
279
- baseUrl: descriptor.baseUrl,
265
+ baseUrl,
280
266
  apiKey,
281
267
  headers,
282
268
  compactPath: buildCompactPath(descriptor.api),
283
- compactUrl: buildCompactUrl(descriptor.baseUrl, descriptor.api),
284
- responsesUrl: buildResponsesUrl(descriptor.baseUrl, descriptor.api),
269
+ compactUrl: buildCompactUrl(baseUrl, descriptor.api),
270
+ responsesUrl: buildResponsesUrl(baseUrl, descriptor.api),
285
271
  payload: requestPayload,
286
- currentModel,
272
+ currentModel: { ...currentModel, baseUrl },
287
273
  },
288
274
  };
289
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