@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 +1 -1
- package/package.json +1 -1
- package/src/compact-client-v2.ts +25 -7
- package/src/compact-client.ts +1 -1
- package/src/payload-rewrite.ts +45 -11
- package/src/runtime.ts +16 -30
- package/src/shared-headers.ts +23 -17
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
|
|
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.
|
|
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",
|
package/src/compact-client-v2.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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;
|
package/src/compact-client.ts
CHANGED
|
@@ -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/payload-rewrite.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
471
|
-
const
|
|
472
|
-
args.payload.input.
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
265
|
+
baseUrl,
|
|
280
266
|
apiKey,
|
|
281
267
|
headers,
|
|
282
268
|
compactPath: buildCompactPath(descriptor.api),
|
|
283
|
-
compactUrl: buildCompactUrl(
|
|
284
|
-
responsesUrl: buildResponsesUrl(
|
|
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
|
}
|
package/src/shared-headers.ts
CHANGED
|
@@ -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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|