@nijaru/pi-provider-compaction 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nick Russo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # pi-provider-compaction
2
+
3
+ Provider-native context compaction for [Pi](https://github.com/earendil-works/pi), with a portable Pi summary saved alongside the native checkpoint.
4
+
5
+ [pi-compactor](https://github.com/nijaru/pi-compactor) owns **when** to compact and post-compaction continuation. This extension owns automatic provider protocol selection, native checkpoint persistence, and replay. `pi-fast-mode` and `pi-usage` remain independent.
6
+
7
+ ## Behavior
8
+
9
+ No native compaction mode normally needs to be selected. The extension resolves it from the active Pi API adapter:
10
+
11
+ | Pi API | Automatic native path |
12
+ | --- | --- |
13
+ | `openai-responses` | Standalone Responses `POST .../responses/compact` |
14
+ | `azure-openai-responses` | Standalone Responses `POST .../responses/compact`, preserving the Azure request URL/query/auth produced by Pi |
15
+ | `openai-codex-responses` | Codex Remote V2 via the normal Codex Responses transport plus `compaction_trigger` |
16
+ | other APIs | Not claimed; Pi's normal compaction remains available |
17
+
18
+ A custom provider using the `openai-responses` API is eligible automatically, but eligibility is not proof that its backend implements `/responses/compact`. A failed native attempt falls back to Pi rather than trying unrelated protocols or providers.
19
+
20
+ The implementation deliberately uses Pi's active provider transport to construct request URLs, headers, OAuth/account state, deployment names, API versions, tools, and other provider-specific fields. It does not reconstruct Codex or Azure authentication from a provider label.
21
+
22
+ ## Native + portable state
23
+
24
+ A successful compaction has two representations committed at one boundary:
25
+
26
+ 1. the provider-native opaque/canonical replay window; and
27
+ 2. the meaningful text summary Pi would normally save.
28
+
29
+ The native representation is used when the current provider/API/model and route identity still match. If the model/provider changes, the extension is removed, or a later generic compaction supersedes the native checkpoint, Pi still has the text summary and retained recent messages.
30
+
31
+ Producing both representations costs more than native-only compaction: the native request and the portable summarization request are both accounted in the saved compaction usage. `details.nativeUsage` and `details.portableUsage` keep the two components inspectable.
32
+
33
+ Version-1 sessions containing the old opaque placeholder remain readable and replayable on their original compatible route. New compactions use version 2 and no longer write that placeholder. Historical v1 state is not rewritten in bulk.
34
+
35
+ ## Optional generic compaction-model override
36
+
37
+ Native compaction is the default on supported routes. An explicitly configured generic compaction model still takes precedence, matching `pi-compactor`:
38
+
39
+ 1. `--compaction-model ...`;
40
+ 2. trusted `.pi/compaction-policy.json`; then
41
+ 3. agent `compaction-policy.json`.
42
+
43
+ This is an override, not a required mode selector. An empty `models` list means no generic override, so automatic native routing can run.
44
+
45
+ ## Replay identity
46
+
47
+ Native state is scoped to the producing provider, API, model, endpoint and relevant stable account/deployment metadata. Raw API keys, OAuth access tokens, and headers are never persisted. OAuth refresh for the same Codex account therefore does not invalidate a checkpoint merely because the access token changed.
48
+
49
+ Azure checkpoints additionally record the effective API version and selected deployment when those values can be resolved from Pi's provider environment.
50
+
51
+ ## In-stream compaction
52
+
53
+ OpenAI and Azure Responses can emit compaction items during an ordinary request through `context_management`. This extension does **not** enable that path yet.
54
+
55
+ Pi 0.85.1 exposes a pre-request rewrite hook but no extension hook that receives the provider response body/stream. Adding `context_management` alone would let the server compact without giving the extension a reliable way to validate, persist, and replay the emitted checkpoint. Standalone compaction and Codex Remote V2 are therefore implemented now; in-stream compaction requires a Pi host response/checkpoint hook or equivalent persisted provider metadata.
56
+
57
+ ## Installation
58
+
59
+ ```bash
60
+ pi install npm:@nijaru/pi-provider-compaction
61
+ ```
62
+
63
+ Install [pi-compactor](https://github.com/nijaru/pi-compactor) separately for model-directed timing/context hints.
64
+
65
+ ## Development
66
+
67
+ ```bash
68
+ bun install
69
+ bun run check
70
+ ```
71
+
72
+ Tests cover automatic route selection, standalone request bridging, Azure query preservation, Codex Remote V2 SSE capture, checkpoint validation, legacy parsing, identity-scoped replay, and direct request fixtures. Authenticated smoke tests are still required to claim a particular account/backend as live-verified.
73
+
74
+ ### Protocol references
75
+
76
+ The transport/validation design was checked against Pi 0.85.1, OpenAI Codex's Remote V2 implementation, and `@narumitw/pi-codex-compact` from `narumiruna/pi-extensions` at tree `1c1ac2c0f371b38957719dc197afb54bc13bda43` (MIT). The owned implementation keeps its own product boundaries and portable-summary contract rather than installing that bundle.
77
+
78
+ ## Related extensions
79
+
80
+ - [pi-compactor](https://github.com/nijaru/pi-compactor): when to compact and continuation lifecycle.
81
+ - [pi-fast-mode](https://github.com/nijaru/pi-fast-mode): `/fast` and request service-tier policy.
82
+ - [pi-usage](https://github.com/nijaru/pi-usage): read-only balances and quota reporting.
83
+
84
+ ## License
85
+
86
+ MIT
package/index.ts ADDED
@@ -0,0 +1,511 @@
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import { convertResponsesMessages } from "@earendil-works/pi-ai/api/openai-responses-shared";
3
+ import { calculateCost } from "@earendil-works/pi-ai";
4
+ import type { Context, Model, Tool, Usage } from "@earendil-works/pi-ai";
5
+ import type {
6
+ ExtensionAPI,
7
+ ExtensionContext,
8
+ SessionBeforeCompactEvent,
9
+ SessionEntry,
10
+ } from "@earendil-works/pi-coding-agent";
11
+ import {
12
+ compact,
13
+ convertToLlm,
14
+ sessionEntryToContextMessages,
15
+ } from "@earendil-works/pi-coding-agent";
16
+ import { resolveCompactionModelPolicy } from "./policy";
17
+ import {
18
+ isCompactionItem,
19
+ isObject,
20
+ requestProviderCompaction,
21
+ resolveNativeProtocol,
22
+ supportsResponsesApi,
23
+ type NativeProtocol,
24
+ type ResponseItem,
25
+ } from "./protocol";
26
+
27
+ export const NATIVE_COMPACTION_TYPE = "pi-provider-compaction/openai-responses";
28
+ export const NATIVE_COMPACTION_VERSION = 2;
29
+ /** Legacy v1 visible summary, retained only for old sessions/tests. New compactions persist a real portable summary. */
30
+ export const NATIVE_COMPACTION_SUMMARY =
31
+ "This context was compacted using the provider's native OpenAI Responses state.";
32
+
33
+ const TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]);
34
+
35
+ type JsonObject = Record<string, unknown>;
36
+ type FetchImplementation = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
37
+
38
+ export interface NativeIdentity {
39
+ endpoint: string;
40
+ organization?: string;
41
+ project?: string;
42
+ accountId?: string;
43
+ azureApiVersion?: string;
44
+ azureDeployment?: string;
45
+ }
46
+
47
+ export interface NativeCompactionDetails {
48
+ type: typeof NATIVE_COMPACTION_TYPE;
49
+ version: 1 | typeof NATIVE_COMPACTION_VERSION;
50
+ provider: string;
51
+ api: string;
52
+ model: string;
53
+ protocol: NativeProtocol;
54
+ identity?: NativeIdentity;
55
+ output: ResponseItem[];
56
+ /** Native protocol usage. `usage` is a legacy alias accepted for v1 compatibility. */
57
+ nativeUsage?: Usage;
58
+ usage?: Usage;
59
+ portableUsage?: Usage;
60
+ readFiles?: string[];
61
+ modifiedFiles?: string[];
62
+ }
63
+
64
+ interface NativeCompactionState extends NativeCompactionDetails {
65
+ entryId: string;
66
+ }
67
+
68
+ export function supportsNativeCompaction(model: Model<any> | undefined): model is Model<any> {
69
+ return supportsResponsesApi(model);
70
+ }
71
+
72
+ function readUsage(value: unknown): Usage | undefined {
73
+ if (!isObject(value) || !isObject(value.cost)) return undefined;
74
+ for (const key of ["input", "output", "cacheRead", "cacheWrite", "totalTokens"] as const) {
75
+ if (typeof value[key] !== "number" || !Number.isFinite(value[key])) return undefined;
76
+ }
77
+ for (const key of ["input", "output", "cacheRead", "cacheWrite", "total"] as const) {
78
+ if (typeof value.cost[key] !== "number" || !Number.isFinite(value.cost[key])) return undefined;
79
+ }
80
+ return value as unknown as Usage;
81
+ }
82
+
83
+ function readIdentity(value: unknown): NativeIdentity | undefined {
84
+ if (!isObject(value) || typeof value.endpoint !== "string" || !value.endpoint) return undefined;
85
+ for (const key of ["organization", "project", "accountId", "azureApiVersion", "azureDeployment"] as const) {
86
+ if (value[key] !== undefined && typeof value[key] !== "string") return undefined;
87
+ }
88
+ return {
89
+ endpoint: value.endpoint,
90
+ organization: value.organization as string | undefined,
91
+ project: value.project as string | undefined,
92
+ accountId: value.accountId as string | undefined,
93
+ azureApiVersion: value.azureApiVersion as string | undefined,
94
+ azureDeployment: value.azureDeployment as string | undefined,
95
+ };
96
+ }
97
+
98
+ export function readNativeCompactionDetails(value: unknown): NativeCompactionDetails | undefined {
99
+ if (!isObject(value) || value.type !== NATIVE_COMPACTION_TYPE) return undefined;
100
+ if (value.version !== 1 && value.version !== NATIVE_COMPACTION_VERSION) return undefined;
101
+ if (typeof value.provider !== "string" || typeof value.api !== "string" || typeof value.model !== "string") return undefined;
102
+ if (!Array.isArray(value.output) || !value.output.every(isObject) || !value.output.some(isCompactionItem)) return undefined;
103
+ const protocol = value.version === 1
104
+ ? "responses-compact"
105
+ : value.protocol === "responses-compact" || value.protocol === "remote-v2"
106
+ ? value.protocol
107
+ : undefined;
108
+ if (!protocol) return undefined;
109
+ const strings = (candidate: unknown): string[] | undefined =>
110
+ candidate === undefined ? undefined : Array.isArray(candidate) && candidate.every((item) => typeof item === "string") ? [...candidate] : undefined;
111
+ return {
112
+ type: NATIVE_COMPACTION_TYPE,
113
+ version: value.version,
114
+ provider: value.provider,
115
+ api: value.api,
116
+ model: value.model,
117
+ protocol,
118
+ identity: value.version === 1 ? undefined : readIdentity(value.identity),
119
+ output: structuredClone(value.output) as ResponseItem[],
120
+ nativeUsage: readUsage(value.nativeUsage ?? value.usage),
121
+ usage: readUsage(value.usage),
122
+ portableUsage: readUsage(value.portableUsage),
123
+ readFiles: strings(value.readFiles),
124
+ modifiedFiles: strings(value.modifiedFiles),
125
+ };
126
+ }
127
+
128
+ function identityEqual(left: NativeIdentity | undefined, right: NativeIdentity | undefined): boolean {
129
+ if (!left) return true;
130
+ if (!right) return false;
131
+ return left.endpoint === right.endpoint && left.organization === right.organization && left.project === right.project && left.accountId === right.accountId && left.azureApiVersion === right.azureApiVersion && left.azureDeployment === right.azureDeployment;
132
+ }
133
+
134
+ export function findLatestNativeCompaction(
135
+ entries: readonly SessionEntry[],
136
+ model: Model<any> | undefined,
137
+ identity?: NativeIdentity,
138
+ ): NativeCompactionState | undefined {
139
+ if (!supportsNativeCompaction(model)) return undefined;
140
+ for (let index = entries.length - 1; index >= 0; index--) {
141
+ const entry = entries[index];
142
+ if (entry.type !== "compaction") continue;
143
+ const details = readNativeCompactionDetails(entry.details);
144
+ if (!details) return undefined;
145
+ if (details.provider !== model.provider || details.api !== model.api || details.model !== model.id) return undefined;
146
+ if (!identityEqual(details.identity, identity)) return undefined;
147
+ return { ...details, entryId: entry.id };
148
+ }
149
+ return undefined;
150
+ }
151
+
152
+ function responseItemsFromMessages(model: Model<any>, messages: AgentMessage[]): ResponseItem[] {
153
+ const input = convertResponsesMessages(
154
+ model,
155
+ { messages: convertToLlm(messages), tools: [] },
156
+ TOOL_CALL_PROVIDERS,
157
+ { includeSystemPrompt: false },
158
+ );
159
+ return input.filter(isObject) as unknown as ResponseItem[];
160
+ }
161
+
162
+ function responseItemsFromEntries(model: Model<any>, entries: readonly SessionEntry[]): ResponseItem[] {
163
+ return responseItemsFromMessages(model, entries.flatMap((entry) => sessionEntryToContextMessages(entry)));
164
+ }
165
+
166
+ function nativeRequestInput(
167
+ model: Model<any>,
168
+ branchEntries: readonly SessionEntry[],
169
+ activeEntries: readonly SessionEntry[],
170
+ previous: NativeCompactionState | undefined,
171
+ ): ResponseItem[] {
172
+ if (previous) {
173
+ const previousIndex = branchEntries.findIndex((entry) => entry.id === previous.entryId);
174
+ if (previousIndex >= 0) {
175
+ return [...structuredClone(previous.output), ...responseItemsFromEntries(model, branchEntries.slice(previousIndex + 1))];
176
+ }
177
+ }
178
+ return responseItemsFromEntries(model, activeEntries);
179
+ }
180
+
181
+ function authHeaders(apiKey: string | undefined, headers: Record<string, string | null> | undefined): Record<string, string> {
182
+ const result: Record<string, string> = { "content-type": "application/json" };
183
+ let hasAuthorization = false;
184
+ for (const [name, value] of Object.entries(headers ?? {})) {
185
+ if (value === null) continue;
186
+ result[name] = value;
187
+ if (name.toLowerCase() === "authorization" && value.trim()) hasAuthorization = true;
188
+ }
189
+ if (!hasAuthorization && apiKey) result.authorization = `Bearer ${apiKey}`;
190
+ return result;
191
+ }
192
+
193
+ export interface CompactRequestOptions {
194
+ model: Model<any>;
195
+ baseUrl: string;
196
+ apiKey?: string;
197
+ headers?: Record<string, string | null>;
198
+ input: ResponseItem[];
199
+ instructions?: string;
200
+ signal?: AbortSignal;
201
+ fetchImpl?: FetchImplementation;
202
+ }
203
+
204
+ export async function compactOpenAIResponses(options: CompactRequestOptions): Promise<NativeCompactionDetails> {
205
+ const endpoint = `${options.baseUrl.replace(/\/+$/, "")}/responses/compact`;
206
+ const body: JsonObject = { model: options.model.id, input: options.input };
207
+ if (options.instructions) body.instructions = options.instructions;
208
+ const response = await (options.fetchImpl ?? fetch)(endpoint, {
209
+ method: "POST",
210
+ headers: authHeaders(options.apiKey, options.headers),
211
+ body: JSON.stringify(body),
212
+ signal: options.signal,
213
+ });
214
+ if (!response.ok) {
215
+ const detail = await response.text().catch(() => "");
216
+ throw new Error(`OpenAI Responses compact request failed (${response.status}${detail ? `: ${detail.slice(0, 400)}` : ""})`);
217
+ }
218
+ const result: unknown = await response.json();
219
+ if (!isObject(result) || !Array.isArray(result.output) || !result.output.every(isObject) || !result.output.some(isCompactionItem)) {
220
+ throw new Error("OpenAI Responses compact response did not contain opaque compaction state");
221
+ }
222
+ const usage = compactUsage(options.model, result);
223
+ return {
224
+ type: NATIVE_COMPACTION_TYPE,
225
+ version: NATIVE_COMPACTION_VERSION,
226
+ provider: options.model.provider,
227
+ api: options.model.api,
228
+ model: options.model.id,
229
+ protocol: "responses-compact",
230
+ output: structuredClone(result.output) as ResponseItem[],
231
+ nativeUsage: usage,
232
+ usage,
233
+ };
234
+ }
235
+
236
+ function compactUsage(model: Model<any>, result: JsonObject): Usage | undefined {
237
+ const usage = result.usage;
238
+ if (!isObject(usage)) return undefined;
239
+ const cached = nestedNumber(usage.input_tokens_details, "cached_tokens");
240
+ const cacheWrite = nestedNumber(usage.input_tokens_details, "cache_write_tokens");
241
+ const mapped: Usage = {
242
+ input: Math.max(0, readNumber(usage.input_tokens) - cached - cacheWrite),
243
+ output: readNumber(usage.output_tokens),
244
+ cacheRead: cached,
245
+ cacheWrite,
246
+ reasoning: nestedNumber(usage.output_tokens_details, "reasoning_tokens"),
247
+ totalTokens: readNumber(usage.total_tokens),
248
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
249
+ };
250
+ try { calculateCost(model, mapped); } catch {}
251
+ return mapped;
252
+ }
253
+
254
+ function readNumber(value: unknown): number {
255
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
256
+ }
257
+
258
+ function nestedNumber(value: unknown, key: string): number {
259
+ return isObject(value) ? readNumber(value[key]) : 0;
260
+ }
261
+
262
+ function stableStringify(value: unknown): string {
263
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
264
+ if (isObject(value)) {
265
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
266
+ }
267
+ return JSON.stringify(value) ?? "null";
268
+ }
269
+
270
+ function itemMatches(left: ResponseItem, right: ResponseItem): boolean {
271
+ const leftId = typeof left.id === "string" ? left.id : undefined;
272
+ const rightId = typeof right.id === "string" ? right.id : undefined;
273
+ if (leftId && rightId) return leftId === rightId;
274
+ const leftCallId = typeof left.call_id === "string" ? left.call_id : undefined;
275
+ const rightCallId = typeof right.call_id === "string" ? right.call_id : undefined;
276
+ if (leftCallId && rightCallId) return leftCallId === rightCallId && left.type === right.type;
277
+ if (left.type === "message" && right.type === "message") {
278
+ return left.role === right.role && stableStringify(left.content) === stableStringify(right.content);
279
+ }
280
+ return stableStringify(left) === stableStringify(right);
281
+ }
282
+
283
+ function findLastSubsequence(items: ResponseItem[], sequence: ResponseItem[]): number {
284
+ if (sequence.length === 0) return -1;
285
+ for (let start = items.length - sequence.length; start >= 0; start--) {
286
+ if (sequence.every((item, offset) => itemMatches(items[start + offset], item))) return start;
287
+ }
288
+ return -1;
289
+ }
290
+
291
+ export function rewriteResponsesPayload(
292
+ payload: unknown,
293
+ details: NativeCompactionDetails,
294
+ postCompactionItems: ResponseItem[],
295
+ ): unknown {
296
+ if (!isObject(payload) || !Array.isArray(payload.input)) return payload;
297
+ const currentInput = payload.input.filter(isObject) as ResponseItem[];
298
+ if (currentInput.length !== payload.input.length) return payload;
299
+ const postStart = findLastSubsequence(currentInput, postCompactionItems);
300
+ if (postStart >= 0) return { ...payload, input: [...structuredClone(details.output), ...currentInput.slice(postStart)] };
301
+ const retainedOutput = details.output.filter((item) => item.type !== "compaction");
302
+ const retainedStart = findLastSubsequence(currentInput, retainedOutput);
303
+ if (retainedStart >= 0) {
304
+ return { ...payload, input: [...structuredClone(details.output), ...currentInput.slice(retainedStart + retainedOutput.length)] };
305
+ }
306
+ return { ...payload, input: [...structuredClone(details.output), ...structuredClone(postCompactionItems)] };
307
+ }
308
+
309
+ function genericCompactionSelected(
310
+ pi: Pick<ExtensionAPI, "getFlag">,
311
+ ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted" | "sessionManager">,
312
+ ): boolean {
313
+ return resolveCompactionModelPolicy(pi, ctx).hasSelectors;
314
+ }
315
+
316
+ function normalizedEndpoint(value: string | undefined): string {
317
+ if (!value) return "";
318
+ try {
319
+ const url = new URL(value);
320
+ url.username = "";
321
+ url.password = "";
322
+ url.search = "";
323
+ url.hash = "";
324
+ return url.toString().replace(/\/+$/, "");
325
+ } catch {
326
+ return value.replace(/\/+$/, "");
327
+ }
328
+ }
329
+
330
+ function headerValue(headers: Record<string, string | null> | undefined, name: string): string | undefined {
331
+ const match = Object.entries(headers ?? {}).find(([key]) => key.toLowerCase() === name.toLowerCase());
332
+ return typeof match?.[1] === "string" && match[1] ? match[1] : undefined;
333
+ }
334
+
335
+ function codexAccountId(apiKey: string | undefined): string | undefined {
336
+ if (!apiKey) return undefined;
337
+ const parts = apiKey.split(".");
338
+ if (parts.length !== 3) return undefined;
339
+ try {
340
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
341
+ if (!isObject(payload)) return undefined;
342
+ const auth = payload["https://api.openai.com/auth"];
343
+ return isObject(auth) && typeof auth.chatgpt_account_id === "string" ? auth.chatgpt_account_id : undefined;
344
+ } catch {
345
+ return undefined;
346
+ }
347
+ }
348
+
349
+ function azureDeployment(modelId: string, env: Record<string, string> | undefined): string {
350
+ const raw = env?.AZURE_OPENAI_DEPLOYMENT_NAME_MAP;
351
+ if (!raw) return modelId;
352
+ for (const entry of raw.split(",")) {
353
+ const [candidate, deployment] = entry.split("=", 2).map((part) => part?.trim());
354
+ if (candidate === modelId && deployment) return deployment;
355
+ }
356
+ return modelId;
357
+ }
358
+
359
+ function routeIdentity(
360
+ model: Model<any>,
361
+ auth: { apiKey?: string; headers?: Record<string, string | null>; baseUrl?: string; env?: Record<string, string> },
362
+ ): NativeIdentity {
363
+ const azure = model.api === "azure-openai-responses";
364
+ return {
365
+ endpoint: normalizedEndpoint(auth.baseUrl ?? auth.env?.AZURE_OPENAI_BASE_URL ?? model.baseUrl),
366
+ organization: headerValue(auth.headers, "openai-organization"),
367
+ project: headerValue(auth.headers, "openai-project"),
368
+ accountId: model.api === "openai-codex-responses" ? codexAccountId(auth.apiKey) : undefined,
369
+ azureApiVersion: azure ? (auth.env?.AZURE_OPENAI_API_VERSION || "v1") : undefined,
370
+ azureDeployment: azure ? azureDeployment(model.id, auth.env) : undefined,
371
+ };
372
+ }
373
+
374
+ function activeTools(pi: ExtensionAPI): Tool[] {
375
+ const available = new Map(pi.getAllTools().map((tool) => [tool.name, tool]));
376
+ return pi.getActiveTools().flatMap((name) => {
377
+ const tool = available.get(name);
378
+ return tool ? [{ name: tool.name, description: tool.description, parameters: tool.parameters }] : [];
379
+ });
380
+ }
381
+
382
+ function providerContext(pi: ExtensionAPI, ctx: ExtensionContext, activeEntries: readonly SessionEntry[]): Context {
383
+ const messages = activeEntries.flatMap((entry) => sessionEntryToContextMessages(entry));
384
+ return {
385
+ systemPrompt: ctx.getSystemPrompt(),
386
+ messages: convertToLlm(messages),
387
+ tools: activeTools(pi),
388
+ };
389
+ }
390
+
391
+ function cleanHeaders(headers: Record<string, string | null> | undefined): Record<string, string> | undefined {
392
+ if (!headers) return undefined;
393
+ return Object.fromEntries(Object.entries(headers).filter((entry): entry is [string, string] => entry[1] !== null));
394
+ }
395
+
396
+ function combineUsage(first: Usage | undefined, second: Usage | undefined): Usage | undefined {
397
+ if (!first) return second;
398
+ if (!second) return first;
399
+ return {
400
+ input: first.input + second.input,
401
+ output: first.output + second.output,
402
+ cacheRead: first.cacheRead + second.cacheRead,
403
+ cacheWrite: first.cacheWrite + second.cacheWrite,
404
+ cacheWrite1h: first.cacheWrite1h !== undefined || second.cacheWrite1h !== undefined ? (first.cacheWrite1h ?? 0) + (second.cacheWrite1h ?? 0) : undefined,
405
+ reasoning: first.reasoning !== undefined || second.reasoning !== undefined ? (first.reasoning ?? 0) + (second.reasoning ?? 0) : undefined,
406
+ totalTokens: first.totalTokens + second.totalTokens,
407
+ cost: {
408
+ input: first.cost.input + second.cost.input,
409
+ output: first.cost.output + second.cost.output,
410
+ cacheRead: first.cost.cacheRead + second.cost.cacheRead,
411
+ cacheWrite: first.cost.cacheWrite + second.cost.cacheWrite,
412
+ total: first.cost.total + second.cost.total,
413
+ },
414
+ };
415
+ }
416
+
417
+ async function nativeAndPortableCompaction(
418
+ pi: ExtensionAPI,
419
+ event: SessionBeforeCompactEvent,
420
+ ctx: ExtensionContext,
421
+ ) {
422
+ const model = ctx.model;
423
+ const protocol = resolveNativeProtocol(model);
424
+ if (!model || !protocol || genericCompactionSelected(pi, ctx)) return undefined;
425
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
426
+ if (!auth.ok) return undefined;
427
+ const provider = ctx.modelRegistry.getProvider(model.provider);
428
+ if (!provider) return undefined;
429
+ const identity = routeIdentity(model, auth);
430
+ const branchEntries = event.branchEntries;
431
+ const activeEntries = ctx.sessionManager.buildContextEntries();
432
+ const previous = findLatestNativeCompaction(branchEntries, model, identity);
433
+ const input = nativeRequestInput(model, branchEntries, activeEntries, previous);
434
+ const native = await requestProviderCompaction({
435
+ provider,
436
+ model,
437
+ context: providerContext(pi, ctx, activeEntries),
438
+ input,
439
+ protocol,
440
+ apiKey: auth.apiKey,
441
+ headers: auth.headers,
442
+ env: auth.env,
443
+ signal: event.signal,
444
+ });
445
+ const portable = await compact(
446
+ event.preparation,
447
+ model,
448
+ auth.apiKey,
449
+ cleanHeaders(auth.headers),
450
+ event.customInstructions,
451
+ event.signal,
452
+ ctx.thinkingLevel,
453
+ provider.streamSimple.bind(provider),
454
+ auth.env,
455
+ undefined,
456
+ undefined,
457
+ ctx.sessionManager.getSessionId(),
458
+ );
459
+ const portableDetails = isObject(portable.details) ? portable.details : {};
460
+ const details: NativeCompactionDetails = {
461
+ type: NATIVE_COMPACTION_TYPE,
462
+ version: NATIVE_COMPACTION_VERSION,
463
+ provider: model.provider,
464
+ api: model.api,
465
+ model: model.id,
466
+ protocol,
467
+ identity,
468
+ output: native.output,
469
+ nativeUsage: native.usage,
470
+ portableUsage: portable.usage,
471
+ readFiles: Array.isArray(portableDetails.readFiles) ? portableDetails.readFiles.filter((item): item is string => typeof item === "string") : undefined,
472
+ modifiedFiles: Array.isArray(portableDetails.modifiedFiles) ? portableDetails.modifiedFiles.filter((item): item is string => typeof item === "string") : undefined,
473
+ };
474
+ return {
475
+ compaction: {
476
+ ...portable,
477
+ usage: combineUsage(native.usage, portable.usage),
478
+ details,
479
+ },
480
+ };
481
+ }
482
+
483
+ export default function (pi: ExtensionAPI): void {
484
+ pi.on("before_provider_request", async (event, ctx) => {
485
+ const model = ctx.model;
486
+ if (!supportsNativeCompaction(model)) return;
487
+ const branch = ctx.sessionManager.getBranch();
488
+ const candidate = findLatestNativeCompaction(branch, model);
489
+ if (!candidate || genericCompactionSelected(pi, ctx)) return;
490
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
491
+ if (!auth.ok) return;
492
+ const latest = findLatestNativeCompaction(branch, model, routeIdentity(model, auth));
493
+ if (!latest) return;
494
+ const index = branch.findIndex((entry: SessionEntry) => entry.id === latest.entryId);
495
+ const postItems = index >= 0 ? responseItemsFromEntries(model, branch.slice(index + 1)) : [];
496
+ const rewritten = rewriteResponsesPayload(event.payload, latest, postItems);
497
+ if (rewritten === event.payload || !isObject(rewritten)) return rewritten;
498
+ const systemPrompt = ctx.getSystemPrompt();
499
+ return systemPrompt ? { ...rewritten, instructions: systemPrompt } : rewritten;
500
+ });
501
+
502
+ pi.on("session_before_compact", async (event, ctx) => {
503
+ try {
504
+ return await nativeAndPortableCompaction(pi, event, ctx);
505
+ } catch (error) {
506
+ if (event.signal.aborted || (error instanceof Error && error.name === "AbortError")) return undefined;
507
+ console.error(`[pi-provider-compaction] native compaction failed: ${error instanceof Error ? error.message : String(error)}`);
508
+ return undefined;
509
+ }
510
+ });
511
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@nijaru/pi-provider-compaction",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "description": "Provider-native context compaction adapters for Pi",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi",
9
+ "pi-extension",
10
+ "compaction",
11
+ "openai",
12
+ "responses"
13
+ ],
14
+ "license": "MIT",
15
+
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "author": "Nick Naru <nick@nijaru.com>",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/nijaru/pi-provider-compaction.git"
23
+ },
24
+ "pi": {
25
+ "extensions": [
26
+ "./index.ts"
27
+ ]
28
+ },
29
+ "scripts": {
30
+ "test": "bun test",
31
+ "lint": "oxlint index.ts protocol.ts policy.ts tests/",
32
+ "typecheck": "tsc --noEmit",
33
+ "check": "bun test && oxlint index.ts protocol.ts policy.ts tests/ && tsc --noEmit"
34
+ },
35
+ "files": [
36
+ "index.ts",
37
+ "protocol.ts",
38
+ "policy.ts",
39
+ "README.md",
40
+ "LICENSE"
41
+ ],
42
+ "devDependencies": {
43
+ "@earendil-works/pi-agent-core": "0.85.1",
44
+ "@earendil-works/pi-ai": "0.85.1",
45
+ "@earendil-works/pi-coding-agent": "0.85.1",
46
+ "@types/bun": "latest",
47
+ "oxlint": "^1.81.0",
48
+ "typescript": "^6.0.3"
49
+ },
50
+ "peerDependencies": {
51
+ "@earendil-works/pi-ai": "*",
52
+ "@earendil-works/pi-coding-agent": "*"
53
+ }
54
+ }
package/policy.ts ADDED
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Generic-compaction-model precedence resolution shared with pi-compactor.
3
+ *
4
+ * When pi-compactor's generic compaction model is configured — via the
5
+ * `compaction-model` flag or a `compaction-policy.json` file — the explicit
6
+ * generic model takes precedence over provider-native compaction: this
7
+ * extension must not request a native compaction and must not replay a
8
+ * previously persisted native window.
9
+ *
10
+ * Pi scopes `getFlag` to flags the calling extension registered, and Pi
11
+ * rejects two extensions registering the same flag name. This extension
12
+ * therefore never registers `compaction-model` (owned by pi-compactor) and
13
+ * reads the shared CLI value from `process.argv` instead. The policy-file
14
+ * fallback mirrors pi-compactor exactly: project policy requires project
15
+ * trust; the agent-dir policy does not; a valid file with an empty `models`
16
+ * list disables the generic model. This module must stay in sync with
17
+ * `readModelSelectors` in pi-compactor's policy.ts (see
18
+ * tests/policy-parity.test.ts, which checks the two against each other when
19
+ * the sibling checkout is present).
20
+ *
21
+ * Flag-source note (verified against Pi 0.85.1): extension flag values are
22
+ * populated solely from CLI unknown flags (parseArgs in cli/args.js feeds
23
+ * extensionFlagValues in main.js, applied by applyExtensionFlagValues in
24
+ * agent-session-services.js). There is no config-file flag source, so reading
25
+ * process.argv with Pi's exact parsing rules observes every flag value the
26
+ * host can deliver. A programmatic SDK host injecting extensionFlagValues
27
+ * without argv would not be seen here; flag scoping provides no shared read
28
+ * API for that path.
29
+ */
30
+
31
+ import {
32
+ CONFIG_DIR_NAME,
33
+ getAgentDir,
34
+ hasTrustRequiringProjectResources,
35
+ ProjectTrustStore,
36
+ } from "@earendil-works/pi-coding-agent";
37
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
38
+ import { closeSync, constants as fsConstants, fstatSync, openSync, readSync } from "node:fs";
39
+ import { join, resolve, sep } from "node:path";
40
+
41
+ export const COMPACTION_MODEL_FLAG = "compaction-model";
42
+ const PROJECT_POLICY = "compaction-policy.json";
43
+ const AGENT_DIR_OVERRIDE = "PI_COMPACTOR_AGENT_DIR";
44
+ const MAX_POLICY_BYTES = 64 * 1024;
45
+ const MAX_MODEL_SELECTORS = 8;
46
+ const MAX_SELECTOR_LENGTH = 512;
47
+
48
+ type PolicyContext = Pick<ExtensionContext, "cwd" | "isProjectTrusted"> & {
49
+ sessionManager?: Pick<ExtensionContext["sessionManager"], "getSessionDir">;
50
+ };
51
+
52
+ export type CompactionModelSource = "flag" | "project-policy" | "agent-policy";
53
+
54
+ export interface CompactionModelPolicy {
55
+ /** True when a generic compaction model takes precedence over native compaction. */
56
+ hasSelectors: boolean;
57
+ /** Where the selectors were resolved from, for diagnostics. */
58
+ source?: CompactionModelSource;
59
+ }
60
+
61
+ function normalizeSelector(value: unknown): string | undefined {
62
+ if (typeof value !== "string") return undefined;
63
+ const selector = value.trim();
64
+ if (selector.length === 0 || selector.length > MAX_SELECTOR_LENGTH) return undefined;
65
+ return selector;
66
+ }
67
+
68
+ function parsePolicySelectors(value: unknown): string[] | undefined {
69
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
70
+ if (!("models" in value) || !Array.isArray(value.models)) return undefined;
71
+
72
+ const selectors: string[] = [];
73
+ for (const model of value.models) {
74
+ const selector = normalizeSelector(model);
75
+ if (selector === undefined || selectors.includes(selector)) continue;
76
+ selectors.push(selector);
77
+ if (selectors.length === MAX_MODEL_SELECTORS) break;
78
+ }
79
+ return selectors;
80
+ }
81
+
82
+ function readPolicySelectors(configPath: string): string[] | undefined {
83
+ let fd: number | undefined;
84
+ try {
85
+ fd = openSync(configPath, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK);
86
+ const stat = fstatSync(fd);
87
+ if (!stat.isFile() || stat.size > MAX_POLICY_BYTES) return undefined;
88
+
89
+ // Read a bounded amount from the opened regular file. This avoids both
90
+ // FIFO blocking and a stat/read TOCTOU defeating the size limit.
91
+ const buffer = Buffer.allocUnsafe(MAX_POLICY_BYTES + 1);
92
+ let bytesRead = 0;
93
+ while (bytesRead < buffer.length) {
94
+ const count = readSync(fd, buffer, bytesRead, buffer.length - bytesRead, null);
95
+ if (count === 0) break;
96
+ bytesRead += count;
97
+ }
98
+ if (bytesRead > MAX_POLICY_BYTES) return undefined;
99
+ return parsePolicySelectors(JSON.parse(buffer.subarray(0, bytesRead).toString("utf8")) as unknown);
100
+ } catch {
101
+ // A malformed or unreadable policy must not prevent native compaction.
102
+ return undefined;
103
+ } finally {
104
+ if (fd !== undefined) {
105
+ try {
106
+ closeSync(fd);
107
+ } catch {
108
+ // The policy is optional; a close failure must not break compaction.
109
+ }
110
+ }
111
+ }
112
+ }
113
+
114
+ function activeAgentDir(ctx: PolicyContext): string {
115
+ const override = process.env[AGENT_DIR_OVERRIDE]?.trim();
116
+ if (override) return resolve(override);
117
+
118
+ try {
119
+ const sessionDir = ctx.sessionManager?.getSessionDir();
120
+ if (sessionDir) {
121
+ const resolvedCwd = resolve(ctx.cwd);
122
+ const safePath = `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
123
+ const suffix = `${sep}${join("sessions", safePath)}`;
124
+ const resolvedSessionDir = resolve(sessionDir);
125
+ if (resolvedSessionDir.endsWith(suffix)) {
126
+ return resolvedSessionDir.slice(0, -suffix.length);
127
+ }
128
+ }
129
+ } catch {
130
+ // Fall back to Pi's configured directory when the SDK session is custom.
131
+ }
132
+ return getAgentDir();
133
+ }
134
+
135
+ function isProjectPolicyTrusted(ctx: PolicyContext, agentDir: string): boolean {
136
+ try {
137
+ if (!ctx.isProjectTrusted()) return false;
138
+ // Pi does not treat this custom filename as a trust-requiring resource.
139
+ // Also accept an explicit saved trust decision for policy-only projects.
140
+ return hasTrustRequiringProjectResources(ctx.cwd) || new ProjectTrustStore(agentDir).get(ctx.cwd) === true;
141
+ } catch {
142
+ return false;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Read the shared `--compaction-model` CLI value without registering the flag.
148
+ * Mirrors Pi's unknown-flag parsing: `--compaction-model value` and
149
+ * `--compaction-model=value`; a bare flag yields `true`. Stops at `--`.
150
+ * Last occurrence wins, matching Pi's Map.set behavior.
151
+ */
152
+ export function readCompactionModelFlagFromArgv(argv: readonly string[] = process.argv.slice(2)): string | boolean | undefined {
153
+ let value: string | boolean | undefined;
154
+ const prefix = `--${COMPACTION_MODEL_FLAG}=`;
155
+ for (let i = 0; i < argv.length; i++) {
156
+ const arg = argv[i];
157
+ if (arg === "--") break;
158
+ if (arg === `--${COMPACTION_MODEL_FLAG}`) {
159
+ const next = argv[i + 1];
160
+ if (next !== undefined && !next.startsWith("-") && !next.startsWith("@")) {
161
+ value = next;
162
+ i++;
163
+ } else {
164
+ value = true;
165
+ }
166
+ } else if (arg.startsWith(prefix)) {
167
+ value = arg.slice(prefix.length);
168
+ }
169
+ }
170
+ return value;
171
+ }
172
+
173
+ /** Resolve whether pi-compactor's generic compaction model takes precedence. */
174
+ export function resolveCompactionModelPolicy(
175
+ pi: Pick<ExtensionAPI, "getFlag">,
176
+ ctx: PolicyContext,
177
+ argv: readonly string[] = process.argv.slice(2),
178
+ ): CompactionModelPolicy {
179
+ const argvRaw = readCompactionModelFlagFromArgv(argv);
180
+ if (typeof argvRaw === "string" && argvRaw.trim()) {
181
+ const selector = normalizeSelector(argvRaw);
182
+ return selector ? { hasSelectors: true, source: "flag" } : { hasSelectors: false };
183
+ }
184
+ // A bare `--compaction-model` (true) or whitespace value falls through like
185
+ // pi-compactor: Pi reports "requires a value" and the policy files still apply.
186
+ // An oversized selector above already returned hasSelectors:false.
187
+ try {
188
+ const rawFlag = pi.getFlag(COMPACTION_MODEL_FLAG);
189
+ if (typeof rawFlag === "string" && rawFlag.trim()) {
190
+ const selector = normalizeSelector(rawFlag);
191
+ return selector ? { hasSelectors: true, source: "flag" } : { hasSelectors: false };
192
+ }
193
+ } catch {
194
+ // getFlag can throw on a stale context; fall through to policy files.
195
+ }
196
+
197
+ const agentDir = activeAgentDir(ctx);
198
+ const candidates: Array<{ path: string; source: CompactionModelSource }> = [];
199
+ if (isProjectPolicyTrusted(ctx, agentDir)) {
200
+ candidates.push({ path: join(ctx.cwd, CONFIG_DIR_NAME, PROJECT_POLICY), source: "project-policy" });
201
+ }
202
+ candidates.push({ path: join(agentDir, PROJECT_POLICY), source: "agent-policy" });
203
+
204
+ for (const candidate of candidates) {
205
+ const selectors = readPolicySelectors(candidate.path);
206
+ // A readable policy stops the search: an empty models list is an
207
+ // explicit choice to use no generic compaction model.
208
+ if (selectors !== undefined) {
209
+ return selectors.length > 0 ? { hasSelectors: true, source: candidate.source } : { hasSelectors: false };
210
+ }
211
+ }
212
+ return { hasSelectors: false };
213
+ }
package/protocol.ts ADDED
@@ -0,0 +1,402 @@
1
+ import type {
2
+ AssistantMessageEventStream,
3
+ Context,
4
+ Model,
5
+ Provider,
6
+ ProviderHeaders,
7
+ Usage,
8
+ } from "@earendil-works/pi-ai";
9
+
10
+ export type JsonObject = Record<string, unknown>;
11
+ export type ResponseItem = JsonObject;
12
+ export type NativeProtocol = "responses-compact" | "remote-v2";
13
+
14
+ const RESPONSES_APIS = new Set([
15
+ "openai-responses",
16
+ "openai-codex-responses",
17
+ "azure-openai-responses",
18
+ ]);
19
+ const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
20
+ const MAX_ITEM_BYTES = 2 * 1024 * 1024;
21
+ const REMOTE_RETAINED_BYTES = 8 * 1024 * 1024;
22
+ const REMOTE_RETAINED_CHARS = 64_000 * 4;
23
+
24
+ export function isObject(value: unknown): value is JsonObject {
25
+ return typeof value === "object" && value !== null && !Array.isArray(value);
26
+ }
27
+
28
+ export function supportsResponsesApi(model: Model<any> | undefined): model is Model<any> {
29
+ return model !== undefined && RESPONSES_APIS.has(String(model.api));
30
+ }
31
+
32
+ export function resolveNativeProtocol(model: Model<any> | undefined): NativeProtocol | undefined {
33
+ if (!supportsResponsesApi(model)) return undefined;
34
+ return model.api === "openai-codex-responses" ? "remote-v2" : "responses-compact";
35
+ }
36
+
37
+ export function isCompactionItem(value: unknown): value is ResponseItem {
38
+ return isObject(value) && value.type === "compaction" && typeof value.encrypted_content === "string" && value.encrypted_content.length > 0;
39
+ }
40
+
41
+ function byteLength(value: unknown): number {
42
+ return Buffer.byteLength(JSON.stringify(value), "utf8");
43
+ }
44
+
45
+ function validateItemSize(item: ResponseItem): void {
46
+ if (byteLength(item) > MAX_ITEM_BYTES) throw new Error("Native compaction output item exceeded 2 MiB");
47
+ }
48
+
49
+ export function validateCompactedOutput(value: unknown): ResponseItem[] {
50
+ if (!isObject(value) || !Array.isArray(value.output) || value.output.length === 0) {
51
+ throw new Error("Responses Compact returned an invalid output window");
52
+ }
53
+ const output = value.output.map((item) => {
54
+ if (!isObject(item)) throw new Error("Responses Compact returned a non-object output item");
55
+ validateItemSize(item);
56
+ return structuredClone(item);
57
+ });
58
+ const checkpoints = output.filter(isCompactionItem);
59
+ if (checkpoints.length !== 1) {
60
+ throw new Error(`Responses Compact returned ${checkpoints.length} compaction items; expected exactly one`);
61
+ }
62
+ return output;
63
+ }
64
+
65
+ async function readJsonBounded(response: Response, signal: AbortSignal): Promise<unknown> {
66
+ const declared = Number(response.headers.get("content-length"));
67
+ if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
68
+ await response.body?.cancel().catch(() => undefined);
69
+ throw new Error("Native compaction response exceeded 8 MiB");
70
+ }
71
+ if (!response.body) throw new Error("Native compaction response did not contain a body");
72
+ const reader = response.body.getReader();
73
+ const chunks: Uint8Array[] = [];
74
+ let total = 0;
75
+ const onAbort = () => void reader.cancel(new DOMException("Compaction aborted", "AbortError")).catch(() => undefined);
76
+ signal.addEventListener("abort", onAbort, { once: true });
77
+ try {
78
+ while (true) {
79
+ if (signal.aborted) throw new DOMException("Compaction aborted", "AbortError");
80
+ const { done, value } = await reader.read();
81
+ if (done) break;
82
+ total += value.byteLength;
83
+ if (total > MAX_RESPONSE_BYTES) throw new Error("Native compaction response exceeded 8 MiB");
84
+ chunks.push(value);
85
+ }
86
+ } finally {
87
+ signal.removeEventListener("abort", onAbort);
88
+ reader.releaseLock();
89
+ }
90
+ const bytes = new Uint8Array(total);
91
+ let offset = 0;
92
+ for (const chunk of chunks) {
93
+ bytes.set(chunk, offset);
94
+ offset += chunk.byteLength;
95
+ }
96
+ try {
97
+ return JSON.parse(new TextDecoder().decode(bytes));
98
+ } catch {
99
+ throw new Error("Native compaction response returned malformed JSON");
100
+ }
101
+ }
102
+
103
+ function requestUrl(input: string | URL | Request): URL {
104
+ return new URL(input instanceof Request ? input.url : String(input));
105
+ }
106
+
107
+ export function responsesCompactUrl(input: string | URL | Request): URL {
108
+ const original = requestUrl(input);
109
+ if (!original.pathname.endsWith("/responses")) {
110
+ throw new Error("Provider request URL does not end with /responses");
111
+ }
112
+ const compact = new URL(original);
113
+ compact.pathname = `${compact.pathname}/compact`;
114
+ if (compact.origin !== original.origin) throw new Error("Responses Compact URL changed origin");
115
+ return compact;
116
+ }
117
+
118
+ function mergedHeaders(input: string | URL | Request, init?: RequestInit): Headers {
119
+ const headers = new Headers(input instanceof Request ? input.headers : undefined);
120
+ new Headers(init?.headers).forEach((value, name) => headers.set(name, value));
121
+ headers.delete("content-encoding");
122
+ headers.delete("content-length");
123
+ headers.set("accept", "application/json");
124
+ headers.set("content-type", "application/json");
125
+ return headers;
126
+ }
127
+
128
+ function compactPayload(payload: JsonObject): JsonObject {
129
+ const allowed = [
130
+ "model",
131
+ "input",
132
+ "instructions",
133
+ "previous_response_id",
134
+ "prompt_cache_key",
135
+ "prompt_cache_retention",
136
+ "service_tier",
137
+ ] as const;
138
+ const result: JsonObject = {};
139
+ for (const key of allowed) {
140
+ if (Object.hasOwn(payload, key) && payload[key] !== undefined) result[key] = structuredClone(payload[key]);
141
+ }
142
+ if (typeof result.model !== "string" || !Array.isArray(result.input)) {
143
+ throw new Error("Provider did not expose a valid Responses payload for compaction");
144
+ }
145
+ return result;
146
+ }
147
+
148
+ function errorResponse(error: unknown): Response {
149
+ return Response.json(
150
+ { error: { message: error instanceof Error ? error.message : String(error), type: "invalid_request_error" } },
151
+ { status: 400 },
152
+ );
153
+ }
154
+
155
+ function rawUsage(response: JsonObject): JsonObject {
156
+ if (!isObject(response.usage)) throw new Error("Native compaction response is missing usage");
157
+ return structuredClone(response.usage);
158
+ }
159
+
160
+ function syntheticCompletion(response: JsonObject, payload: JsonObject): Response {
161
+ const completed = {
162
+ id: typeof response.id === "string" ? response.id : "resp_pi_provider_compaction",
163
+ object: "response",
164
+ created_at: typeof response.created_at === "number" ? response.created_at : Math.floor(Date.now() / 1000),
165
+ status: "completed",
166
+ model: payload.model,
167
+ output: [],
168
+ parallel_tool_calls: false,
169
+ tool_choice: "auto",
170
+ tools: [],
171
+ usage: rawUsage(response),
172
+ };
173
+ const body = [
174
+ { type: "response.created", response: { ...completed, status: "in_progress" } },
175
+ { type: "response.completed", response: completed },
176
+ ]
177
+ .map((event) => `data: ${JSON.stringify(event)}\n\n`)
178
+ .join("");
179
+ return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } });
180
+ }
181
+
182
+ async function collectProviderUsage(stream: AssistantMessageEventStream, signal: AbortSignal): Promise<Usage> {
183
+ let usage: Usage | undefined;
184
+ for await (const event of stream) {
185
+ if (signal.aborted) throw new DOMException("Compaction aborted", "AbortError");
186
+ if (event.type === "error") throw new Error(event.error.errorMessage ?? "Native compaction provider request failed");
187
+ if (event.type === "done") usage = event.message.usage;
188
+ }
189
+ if (!usage) throw new Error("Native compaction provider stream ended without usage");
190
+ return usage;
191
+ }
192
+
193
+ interface TransportRequest {
194
+ provider: Provider;
195
+ model: Model<any>;
196
+ context: Context;
197
+ input: ResponseItem[];
198
+ apiKey?: string;
199
+ headers?: ProviderHeaders;
200
+ env?: Record<string, string>;
201
+ signal: AbortSignal;
202
+ fetch?: typeof globalThis.fetch;
203
+ }
204
+
205
+ export interface TransportResponse {
206
+ output: ResponseItem[];
207
+ usage: Usage;
208
+ }
209
+
210
+ async function requestResponsesCompact(request: TransportRequest): Promise<TransportResponse> {
211
+ let preparedPayload: JsonObject | undefined;
212
+ let compacted: { response: JsonObject; output: ResponseItem[] } | undefined;
213
+ let bridgeError: unknown;
214
+ let dispatches = 0;
215
+ const baseFetch = request.fetch ?? globalThis.fetch;
216
+ const bridgeFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
217
+ if (request.signal.aborted) throw new DOMException("Compaction aborted", "AbortError");
218
+ dispatches += 1;
219
+ if (dispatches !== 1 || !preparedPayload) {
220
+ bridgeError = new Error("Responses Compact provider dispatched an unexpected additional request");
221
+ return errorResponse(bridgeError);
222
+ }
223
+ try {
224
+ const compactUrl = responsesCompactUrl(input);
225
+ const response = await baseFetch(compactUrl, {
226
+ ...init,
227
+ method: "POST",
228
+ headers: mergedHeaders(input, init),
229
+ body: JSON.stringify(compactPayload(preparedPayload)),
230
+ signal: request.signal,
231
+ });
232
+ if (!response.ok) return response;
233
+ const parsed = await readJsonBounded(response, request.signal);
234
+ if (!isObject(parsed)) throw new Error("Responses Compact returned a non-object response");
235
+ compacted = { response: parsed, output: validateCompactedOutput(parsed) };
236
+ return syntheticCompletion(parsed, preparedPayload);
237
+ } catch (error) {
238
+ bridgeError = error;
239
+ return errorResponse(error);
240
+ }
241
+ }) as unknown as typeof globalThis.fetch;
242
+
243
+ const stream = request.provider.stream(request.model, request.context, {
244
+ apiKey: request.apiKey,
245
+ headers: request.headers,
246
+ env: request.env,
247
+ signal: request.signal,
248
+ transport: "sse",
249
+ cacheRetention: "none",
250
+ maxRetries: 0,
251
+ fetch: bridgeFetch,
252
+ onPayload: (payload: unknown) => {
253
+ if (!isObject(payload)) throw new Error("Responses provider exposed a non-object payload");
254
+ preparedPayload = { ...structuredClone(payload), input: structuredClone(request.input) };
255
+ return preparedPayload;
256
+ },
257
+ });
258
+
259
+ const usage = await collectProviderUsage(stream, request.signal).catch((error) => {
260
+ if (bridgeError) throw bridgeError;
261
+ throw error;
262
+ });
263
+ if (bridgeError) throw bridgeError;
264
+ if (!compacted || dispatches !== 1) throw new Error("Responses Compact did not complete exactly one request");
265
+ return { output: compacted.output, usage };
266
+ }
267
+
268
+ interface CollectedSse {
269
+ item: ResponseItem;
270
+ }
271
+
272
+ async function collectCompactionSse(stream: ReadableStream<Uint8Array>, signal: AbortSignal): Promise<CollectedSse> {
273
+ const reader = stream.getReader();
274
+ const decoder = new TextDecoder();
275
+ let bytes = 0;
276
+ let pending = "";
277
+ let dataLines: string[] = [];
278
+ let completed = false;
279
+ const items = new Map<string, ResponseItem>();
280
+ const onAbort = () => void reader.cancel(new DOMException("Compaction aborted", "AbortError")).catch(() => undefined);
281
+ signal.addEventListener("abort", onAbort, { once: true });
282
+ const dispatch = () => {
283
+ if (dataLines.length === 0) return;
284
+ const data = dataLines.join("\n");
285
+ dataLines = [];
286
+ if (data === "[DONE]") return;
287
+ let event: unknown;
288
+ try { event = JSON.parse(data); } catch { throw new Error("Remote V2 returned malformed SSE JSON"); }
289
+ if (!isObject(event)) return;
290
+ if (event.type === "response.completed") completed = true;
291
+ const candidates: unknown[] = [];
292
+ if (event.type === "response.output_item.done") candidates.push(event.item);
293
+ if (event.type === "response.completed" && isObject(event.response) && Array.isArray(event.response.output)) {
294
+ candidates.push(...event.response.output);
295
+ }
296
+ for (const candidate of candidates) {
297
+ if (!isCompactionItem(candidate)) continue;
298
+ validateItemSize(candidate);
299
+ items.set(JSON.stringify(candidate), structuredClone(candidate));
300
+ }
301
+ };
302
+ const processLine = (line: string) => {
303
+ if (line === "") return dispatch();
304
+ if (line.startsWith(":")) return;
305
+ if (line.startsWith("data:")) dataLines.push(line.slice(5).replace(/^ /, ""));
306
+ };
307
+ try {
308
+ while (true) {
309
+ if (signal.aborted) throw new DOMException("Compaction aborted", "AbortError");
310
+ const { done, value } = await reader.read();
311
+ if (done) break;
312
+ bytes += value.byteLength;
313
+ if (bytes > MAX_RESPONSE_BYTES) throw new Error("Remote V2 stream exceeded 8 MiB");
314
+ pending += decoder.decode(value, { stream: true });
315
+ let newline = pending.indexOf("\n");
316
+ while (newline >= 0) {
317
+ const raw = pending.slice(0, newline);
318
+ pending = pending.slice(newline + 1);
319
+ processLine(raw.endsWith("\r") ? raw.slice(0, -1) : raw);
320
+ newline = pending.indexOf("\n");
321
+ }
322
+ }
323
+ pending += decoder.decode();
324
+ if (pending) processLine(pending.endsWith("\r") ? pending.slice(0, -1) : pending);
325
+ dispatch();
326
+ } finally {
327
+ signal.removeEventListener("abort", onAbort);
328
+ reader.releaseLock();
329
+ }
330
+ if (!completed) throw new Error("Remote V2 stream ended without response.completed");
331
+ if (items.size !== 1) throw new Error(`Remote V2 returned ${items.size} distinct compaction items; expected exactly one`);
332
+ return { item: [...items.values()][0] };
333
+ }
334
+
335
+ function retainedUserMessage(item: ResponseItem): boolean {
336
+ return (item.type === undefined || item.type === "message") && item.role === "user" && Array.isArray(item.content);
337
+ }
338
+
339
+ function buildRemoteV2History(input: ResponseItem[], item: ResponseItem): ResponseItem[] {
340
+ validateItemSize(item);
341
+ let remainingBytes = REMOTE_RETAINED_BYTES - byteLength(item);
342
+ let remainingChars = REMOTE_RETAINED_CHARS;
343
+ const kept: ResponseItem[] = [];
344
+ for (let index = input.length - 1; index >= 0; index--) {
345
+ const candidate = input[index];
346
+ if (!retainedUserMessage(candidate)) continue;
347
+ const bytes = byteLength(candidate);
348
+ if (bytes > remainingBytes) continue;
349
+ const chars = JSON.stringify(candidate).length;
350
+ if (chars > remainingChars) continue;
351
+ kept.push(structuredClone(candidate));
352
+ remainingBytes -= bytes;
353
+ remainingChars -= chars;
354
+ if (remainingBytes < 256 || remainingChars < 64) break;
355
+ }
356
+ return [...kept.reverse(), structuredClone(item)];
357
+ }
358
+
359
+ async function requestRemoteV2(request: TransportRequest): Promise<TransportResponse> {
360
+ const baseFetch = request.fetch ?? globalThis.fetch;
361
+ let sentInput: ResponseItem[] | undefined;
362
+ const inspections: Promise<CollectedSse>[] = [];
363
+ const inspectedFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
364
+ const response = await baseFetch(input, init);
365
+ if (!response.ok || !response.body) return response;
366
+ const [providerBody, inspectionBody] = response.body.tee();
367
+ inspections.push(collectCompactionSse(inspectionBody, request.signal));
368
+ return new Response(providerBody, {
369
+ status: response.status,
370
+ statusText: response.statusText,
371
+ headers: response.headers,
372
+ });
373
+ }) as unknown as typeof globalThis.fetch;
374
+
375
+ const stream = request.provider.stream(request.model, request.context, {
376
+ apiKey: request.apiKey,
377
+ headers: request.headers,
378
+ env: request.env,
379
+ signal: request.signal,
380
+ transport: "sse",
381
+ cacheRetention: "none",
382
+ maxRetries: 0,
383
+ fetch: inspectedFetch,
384
+ onPayload: (payload: unknown) => {
385
+ if (!isObject(payload)) throw new Error("Codex provider exposed a non-object payload");
386
+ sentInput = structuredClone(request.input);
387
+ return { ...structuredClone(payload), input: [...sentInput, { type: "compaction_trigger" }] };
388
+ },
389
+ });
390
+ const usage = await collectProviderUsage(stream, request.signal);
391
+ if (!sentInput || inspections.length !== 1) {
392
+ throw new Error(`Remote V2 observed ${inspections.length} successful provider responses; expected exactly one`);
393
+ }
394
+ const inspection = await inspections[0];
395
+ return { output: buildRemoteV2History(sentInput, inspection.item), usage };
396
+ }
397
+
398
+ export async function requestProviderCompaction(
399
+ request: TransportRequest & { protocol: NativeProtocol },
400
+ ): Promise<TransportResponse> {
401
+ return request.protocol === "remote-v2" ? requestRemoteV2(request) : requestResponsesCompact(request);
402
+ }