pi-codex-compaction 0.1.0 → 0.1.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/CHANGELOG.md CHANGED
@@ -6,6 +6,22 @@ This project follows the spirit of [Keep a Changelog](https://keepachangelog.com
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.1] - 2026-08-06
10
+
11
+ ### Fixed
12
+
13
+ - Let `enableInstallTelemetry: false` override an enabled `PI_TELEMETRY` environment flag.
14
+ - Keep the normal Codex Responses request envelope while excluding Pi's retained user input from compaction history.
15
+ - Avoid reusing opaque checkpoints across model, endpoint, account, or authentication-mode changes.
16
+ - Avoid reusing opaque checkpoints when the authentication mode cannot be verified.
17
+ - Reject version 1 opaque checkpoints from existing sessions and use readable fallback context until a new checkpoint is created.
18
+ - Fall back to Pi's standard compactor when custom compaction instructions are supplied.
19
+
20
+ ### Added
21
+
22
+ - Incremental bounded SSE parsing, transient request retries, response idle timeouts, provider usage capture, and file-operation fallback metadata.
23
+ - Trusted-origin and redirect protections for direct Codex compaction requests.
24
+
9
25
  ## [0.1.0] - 2026-08-02
10
26
 
11
27
  ### Added
package/README.md CHANGED
@@ -6,8 +6,11 @@ Keep long Pi sessions usable on OpenAI Codex models by replacing Pi's local summ
6
6
 
7
7
  - Uses the current `openai-codex` model for each compaction; it never silently changes the session model.
8
8
  - Sends only the history Pi is discarding, plus the previous Codex checkpoint, so the incoming/kept user message is not duplicated.
9
+ - Retains the normal Codex Responses request envelope, including system instructions, active tool schemas, reasoning settings, prompt-cache fields, and routing fields.
9
10
  - Persists Codex's opaque encrypted checkpoint and rehydrates it only for supported Codex requests.
10
- - Caps compaction input and response size and falls back to standard Pi compaction on failure.
11
+ - Reuses checkpoints only for the same model, trusted endpoint, Codex account, and authentication mode.
12
+ - Bounds input with a UTF-8-aware token estimate, trims tool output when necessary, retries transient failures, and honors cancellation.
13
+ - Falls back to standard Pi compaction on failure or when custom compaction instructions are requested.
11
14
  - Keeps a bounded readable transcript excerpt so switching models or providers remains usable.
12
15
 
13
16
  The current Codex catalog includes models such as `gpt-5.6-sol`, `gpt-5.6-terra`, and `gpt-5.6-luna`. Capability detection follows the provider/API contract (`openai-codex` + `openai-codex-responses`) rather than a brittle model-name list.
@@ -32,11 +35,11 @@ pi -e /path/to/pi-mono/packages/pi-codex-compaction
32
35
 
33
36
  ## Behavior
34
37
 
35
- When Pi starts compaction on a supported Codex model, the extension sends a streamed Responses request containing a `compaction_trigger` item and the `remote_compaction_v2` beta feature. The returned opaque checkpoint is stored in the Pi compaction entry. Later supported Codex requests replace the textual compaction marker with the raw checkpoint; other providers/models receive the bounded textual fallback instead.
38
+ When Pi starts compaction on a supported Codex model, the extension sends a streamed Responses request whose `input` contains only discardable history, any compatible prior checkpoint, and a `compaction_trigger` item. The normal request envelope is retained because Codex's compaction path is parity-tested against ordinary Responses requests; this includes the effective system prompt, active tool definitions, reasoning level, prompt-cache fields, and routing fields. The request uses the `remote_compaction_v2` beta feature, and the returned opaque checkpoint and bounded provider usage are stored in the Pi compaction entry. Later requests rehydrate the raw checkpoint only when the model, endpoint, account, and authentication mode match; other providers/models receive the bounded textual fallback instead.
36
39
 
37
- Compaction uses the model active when Pi triggers it. If a session switches from a larger to a smaller model, the remote request is bounded against the new model's context window and tool outputs are reduced before sending. If it still cannot fit, the extension leaves compaction to Pi's normal implementation.
40
+ Compaction uses the model active when Pi triggers it. If a session switches from a larger to a smaller model, the remote request is bounded against the new model's context window and tool outputs are reduced before sending. A previous opaque checkpoint is treated as incompatible after a model, endpoint, account, or authentication-mode switch; Pi's readable previous summary is sent instead. If the full request still cannot fit, the extension leaves compaction to Pi's normal implementation.
38
41
 
39
- If the remote request fails, is cancelled, or returns an unexpected response, Pi's standard compaction path runs. No configuration is required.
42
+ If the remote request fails, is cancelled, or returns an unexpected response, Pi's standard compaction path runs. Custom compaction instructions also use Pi's standard path because RemoteCompactionV2 has no documented custom-instructions field. The direct checkpoint request is restricted to `https://chatgpt.com`, rejects redirects, limits request/response size, and never decodes or logs `encrypted_content`. No configuration is required.
40
43
 
41
44
  ## Development
42
45
 
package/SECURITY.md CHANGED
@@ -19,9 +19,9 @@ Report privately through [GitHub Security Advisories](https://github.com/jvm/pi-
19
19
 
20
20
  `pi-codex-compaction` is a Pi package. Pi extensions execute with the same permissions as the local user running Pi. Users should review installed Pi packages and only install packages from sources they trust.
21
21
 
22
- For supported `openai-codex` models, the extension sends the portion of conversation Pi is about to discard to the OpenAI Codex Responses endpoint over HTTPS, using credentials and provider headers resolved by Pi. It stores the provider-issued opaque `encrypted_content` checkpoint in the local Pi session's compaction details so supported Codex requests can reuse it. The checkpoint is not decoded, printed, or logged.
22
+ For supported `openai-codex` models, the extension sends the portion of conversation Pi is about to discard to the OpenAI Codex Responses endpoint over HTTPS, using credentials and provider headers resolved by Pi. The request also includes the normal Codex Responses envelope: the effective system prompt, active tool schemas, reasoning settings, and provider-generated cache/routing fields. It stores the provider-issued opaque `encrypted_content` checkpoint in the local Pi session's compaction details so compatible Codex requests can reuse it. The checkpoint is not decoded, printed, or logged.
23
23
 
24
- The extension bounds compaction input and response size, validates the Codex HTTPS endpoint and account claim, honors Pi cancellation, and falls back to standard Pi compaction on authentication, transport, response, or context-limit failures. A bounded textual transcript excerpt remains in the compaction summary so switching to another model or provider does not leave the session with only an unusable Codex checkpoint. The fallback may contain conversation content already present in the local session and is still subject to the user's normal Pi session-file permissions.
24
+ The extension bounds request, compaction input, and response size, validates the HTTPS endpoint against the official `chatgpt.com` origin, rejects redirects, validates the account claim shape, binds checkpoints to a hashed account identity/model/endpoint/authentication mode, honors Pi cancellation, and retries only transient transport failures. It falls back to standard Pi compaction on authentication, transport, response, or context-limit failures. A bounded textual transcript excerpt remains in the compaction summary so switching to another model or provider does not leave the session with only an unusable Codex checkpoint. The fallback may contain conversation content already present in the local session and is still subject to the user's normal Pi session-file permissions.
25
25
 
26
26
  The extension never logs prompts, conversation contents, credentials, authorization headers, or raw provider responses. Install/update telemetry is best-effort and sends only package/version/runtime metadata; it can be disabled with `PI_OFFLINE=1`, `PI_TELEMETRY=0`, or Pi's `enableInstallTelemetry: false` setting.
27
27
 
@@ -3,12 +3,14 @@ import type {
3
3
  ExtensionHandler,
4
4
  SessionBeforeCompactEvent,
5
5
  } from "@earendil-works/pi-coding-agent";
6
- import { BETA_FEATURE } from "../src/codex-wire.js";
6
+ import { BETA_FEATURE, getCodexAccountFingerprint } from "../src/codex-wire.js";
7
7
  import { reportInstallTelemetry } from "../src/install-telemetry.js";
8
8
  import {
9
9
  applyRemoteCompactionMarker,
10
10
  createRemoteCompaction,
11
11
  findActiveRemoteCompaction,
12
+ getCodexAuthKind,
13
+ isRemoteCompactionCompatible,
12
14
  supportsRemoteCompaction,
13
15
  } from "../src/remote-compaction.js";
14
16
 
@@ -24,7 +26,7 @@ export default function piCodexCompaction(pi: ExtensionAPI): void {
24
26
  return pi.getAllTools()
25
27
  .filter((tool) => active.has(tool.name))
26
28
  .map(({ name, description, parameters }) => ({ name, description, parameters }));
27
- });
29
+ }, pi.getThinkingLevel?.());
28
30
  return compaction ? { compaction } : undefined;
29
31
  } catch {
30
32
  if (!event.signal.aborted && ctx.hasUI) {
@@ -47,6 +49,23 @@ export default function piCodexCompaction(pi: ExtensionAPI): void {
47
49
  if (!supportsRemoteCompaction(ctx.model)) return;
48
50
  const details = findActiveRemoteCompaction(ctx.sessionManager.buildContextEntries());
49
51
  if (!details) return;
50
- return applyRemoteCompactionMarker(event.payload, details);
52
+
53
+ return (async () => {
54
+ const model = ctx.model;
55
+ if (!model) return undefined;
56
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
57
+ if (!auth.ok || !auth.apiKey) return undefined;
58
+
59
+ let accountFingerprint: string;
60
+ try {
61
+ accountFingerprint = getCodexAccountFingerprint(auth.apiKey);
62
+ } catch {
63
+ return undefined;
64
+ }
65
+ if (!isRemoteCompactionCompatible(details, model, accountFingerprint, getCodexAuthKind(ctx.modelRegistry, model))) {
66
+ return undefined;
67
+ }
68
+ return applyRemoteCompactionMarker(event.payload, details);
69
+ })();
51
70
  });
52
71
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-codex-compaction",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Use OpenAI Codex RemoteCompactionV2 for supported Pi sessions.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -52,9 +52,9 @@
52
52
  "@earendil-works/pi-coding-agent": "*"
53
53
  },
54
54
  "devDependencies": {
55
- "@earendil-works/pi-ai": "^0.80.10",
56
- "@earendil-works/pi-coding-agent": "^0.80.10",
57
- "@types/node": "^26.1.1",
55
+ "@earendil-works/pi-ai": "^0.82.1",
56
+ "@earendil-works/pi-coding-agent": "^0.82.1",
57
+ "@types/node": "^26.1.2",
58
58
  "tsx": "^4.23.1",
59
59
  "typescript": "^7.0.2"
60
60
  },
package/src/codex-wire.ts CHANGED
@@ -1,7 +1,15 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ const MAX_REQUEST_BYTES = 16 * 1024 * 1024;
1
4
  const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
2
5
  const MAX_ENCRYPTED_CONTENT_CHARS = 2_000_000;
3
- const REQUEST_TIMEOUT_MS = 120_000;
6
+ const REQUEST_HEADER_TIMEOUT_MS = 30_000;
7
+ const REQUEST_IDLE_TIMEOUT_MS = 120_000;
8
+ const MAX_RETRIES = 2;
9
+ const RETRY_BASE_DELAY_MS = 200;
10
+ const MAX_RETRY_DELAY_MS = 30_000;
4
11
  const ACCOUNT_ID_CLAIM = "https://api.openai.com/auth";
12
+ const TRUSTED_CODEX_HOST = "chatgpt.com";
5
13
  export const BETA_FEATURE = "remote_compaction_v2";
6
14
 
7
15
  export interface CodexCompactionRequest {
@@ -17,28 +25,84 @@ export interface CodexCompactionRequest {
17
25
  signal?: AbortSignal;
18
26
  }
19
27
 
28
+ export interface CodexCompactionUsage {
29
+ inputTokens?: number;
30
+ outputTokens?: number;
31
+ totalTokens?: number;
32
+ cachedInputTokens?: number;
33
+ reasoningTokens?: number;
34
+ }
35
+
36
+ export interface CodexCompactionResult {
37
+ encryptedContent: string;
38
+ usage?: CodexCompactionUsage;
39
+ }
40
+
41
+ /**
42
+ * Keep the original string-returning API for callers that only need the
43
+ * checkpoint. The extension uses requestRemoteCompactionWithUsage below.
44
+ */
20
45
  export async function requestRemoteCompaction(request: CodexCompactionRequest): Promise<string> {
46
+ return (await requestRemoteCompactionWithUsage(request)).encryptedContent;
47
+ }
48
+
49
+ export async function requestRemoteCompactionWithUsage(
50
+ request: CodexCompactionRequest,
51
+ ): Promise<CodexCompactionResult> {
52
+ const endpoint = resolveCodexResponsesUrl(request.model.baseUrl);
21
53
  const accountId = extractAccountId(request.apiKey);
22
54
  const headers = buildHeaders(request, accountId);
23
- const { signal, cleanup } = requestSignal(request.signal);
55
+ const body = JSON.stringify(request.body);
56
+ if (new TextEncoder().encode(body).byteLength > MAX_REQUEST_BYTES) {
57
+ throw new Error("Codex compaction request exceeded the size limit");
58
+ }
24
59
 
60
+ const requestState = requestSignal(request.signal);
25
61
  try {
26
- const response = await fetch(resolveCodexResponsesUrl(request.model.baseUrl), {
27
- method: "POST",
28
- headers,
29
- body: JSON.stringify(request.body),
30
- signal,
31
- });
32
-
33
- if (!response.ok) {
34
- if (response.body) await response.body.cancel().catch(() => undefined);
35
- throw new Error(`Codex compaction request returned HTTP ${response.status}`);
36
- }
62
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
63
+ requestState.startAttempt();
64
+
65
+ try {
66
+ const response = await fetch(endpoint, {
67
+ method: "POST",
68
+ headers,
69
+ body,
70
+ redirect: "error",
71
+ signal: requestState.signal,
72
+ });
73
+ requestState.headersReceived();
37
74
 
38
- return parseCompactionSse(await readBoundedBody(response));
75
+ if (!response.ok) {
76
+ await response.body?.cancel().catch(() => undefined);
77
+ const error = new CodexCompactionError(
78
+ `Codex compaction request returned HTTP ${response.status}`,
79
+ isRetryableStatus(response.status),
80
+ );
81
+ if (!error.retryable || attempt >= MAX_RETRIES) throw error;
82
+ await waitForRetry(response, attempt, requestState.signal);
83
+ continue;
84
+ }
85
+
86
+ try {
87
+ return await readCompactionSse(response, requestState.signal, requestState.touch);
88
+ } catch (error) {
89
+ if (!isRetryableError(error) || attempt >= MAX_RETRIES || requestState.signal.aborted) {
90
+ throw error;
91
+ }
92
+ await waitForRetry(undefined, attempt, requestState.signal);
93
+ }
94
+ } catch (error) {
95
+ if (requestState.signal.aborted || !isRetryableError(error) || attempt >= MAX_RETRIES) {
96
+ throw error;
97
+ }
98
+ await waitForRetry(undefined, attempt, requestState.signal);
99
+ }
100
+ }
39
101
  } finally {
40
- cleanup();
102
+ requestState.cleanup();
41
103
  }
104
+
105
+ throw new Error("Codex compaction request failed");
42
106
  }
43
107
 
44
108
  export function resolveCodexResponsesUrl(baseUrl: string): string {
@@ -46,6 +110,9 @@ export function resolveCodexResponsesUrl(baseUrl: string): string {
46
110
  if (url.protocol !== "https:") {
47
111
  throw new Error("Codex compaction requires an HTTPS endpoint");
48
112
  }
113
+ if (url.hostname.toLowerCase() !== TRUSTED_CODEX_HOST) {
114
+ throw new Error("Codex compaction endpoint is not a trusted Codex origin");
115
+ }
49
116
  if (url.username || url.password) {
50
117
  throw new Error("Codex compaction endpoint must not contain URL credentials");
51
118
  }
@@ -59,48 +126,16 @@ export function resolveCodexResponsesUrl(baseUrl: string): string {
59
126
  }
60
127
 
61
128
  export function parseCompactionSse(sse: string): string {
62
- const encryptedContents = new Set<string>();
63
- let completed = false;
64
-
65
- for (const frame of sse.split(/\r?\n\r?\n/)) {
66
- const data = frame
67
- .split(/\r?\n/)
68
- .filter((line) => line.startsWith("data:"))
69
- .map((line) => line.slice(5).trim())
70
- .join("\n")
71
- .trim();
72
- if (!data || data === "[DONE]") continue;
73
-
74
- let event: unknown;
75
- try {
76
- event = JSON.parse(data) as unknown;
77
- } catch {
78
- throw new Error("Codex compaction returned invalid SSE data");
79
- }
80
- if (!isRecord(event)) continue;
81
-
82
- if (event.type === "error" || event.type === "response.failed") {
83
- throw new Error("Codex compaction response failed");
84
- }
85
-
86
- if (event.type === "response.output_item.done") {
87
- collectCompactionItem(event.item, encryptedContents);
88
- }
89
-
90
- if (event.type === "response.completed" || event.type === "response.done") {
91
- const response = isRecord(event.response) ? event.response : undefined;
92
- collectOutputItems(response?.output, encryptedContents);
93
- completed = response?.status === undefined || response.status === "completed";
94
- }
95
- }
129
+ return parseCompactionSseResult(sse).encryptedContent;
130
+ }
96
131
 
97
- if (!completed) {
98
- throw new Error("Codex compaction stream ended before response completion");
132
+ export function parseCompactionSseResult(sse: string): CodexCompactionResult {
133
+ if (new TextEncoder().encode(sse).byteLength > MAX_RESPONSE_BYTES) {
134
+ throw new Error("Codex compaction response exceeded the size limit");
99
135
  }
100
- if (encryptedContents.size !== 1) {
101
- throw new Error(`Codex compaction returned ${encryptedContents.size} checkpoint items`);
102
- }
103
- return [...encryptedContents][0];
136
+ const parser = new CompactionSseParser();
137
+ parser.push(sse);
138
+ return parser.finish();
104
139
  }
105
140
 
106
141
  function buildHeaders(request: CodexCompactionRequest, accountId: string): Headers {
@@ -124,6 +159,10 @@ function buildHeaders(request: CodexCompactionRequest, accountId: string): Heade
124
159
  return headers;
125
160
  }
126
161
 
162
+ export function getCodexAccountFingerprint(token: string): string {
163
+ return `sha256:${createHash("sha256").update(extractAccountId(token)).digest("hex")}`;
164
+ }
165
+
127
166
  function extractAccountId(token: string): string {
128
167
  const segment = token.split(".")[1];
129
168
  if (!segment) throw new Error("Codex authentication token has no account claim");
@@ -146,61 +185,290 @@ function safeSessionId(value: string | undefined): string | undefined {
146
185
  return value;
147
186
  }
148
187
 
149
- function collectOutputItems(value: unknown, encryptedContents: Set<string>): void {
150
- if (!Array.isArray(value)) return;
151
- for (const item of value) collectCompactionItem(item, encryptedContents);
152
- }
153
-
154
- function collectCompactionItem(value: unknown, encryptedContents: Set<string>): void {
155
- if (!isRecord(value) || value.type !== "compaction") return;
156
- const encryptedContent = value.encrypted_content;
157
- if (
158
- typeof encryptedContent === "string" &&
159
- encryptedContent.length > 0 &&
160
- encryptedContent.length <= MAX_ENCRYPTED_CONTENT_CHARS
161
- ) {
162
- encryptedContents.add(encryptedContent);
188
+ async function readCompactionSse(
189
+ response: Response,
190
+ signal: AbortSignal,
191
+ touch: () => void,
192
+ ): Promise<CodexCompactionResult> {
193
+ if (!response.body) {
194
+ throw new CodexCompactionError("Codex compaction response has no body", true);
163
195
  }
164
- }
165
-
166
- async function readBoundedBody(response: Response): Promise<string> {
167
- if (!response.body) throw new Error("Codex compaction response has no body");
168
196
 
169
197
  const reader = response.body.getReader();
170
198
  const decoder = new TextDecoder();
199
+ const parser = new CompactionSseParser();
171
200
  let bytes = 0;
172
- let text = "";
173
201
  try {
174
202
  while (true) {
175
203
  const { done, value } = await reader.read();
176
204
  if (done) break;
205
+ touch();
177
206
  bytes += value.byteLength;
178
207
  if (bytes > MAX_RESPONSE_BYTES) {
179
- throw new Error("Codex compaction response exceeded the size limit");
208
+ throw new CodexCompactionError("Codex compaction response exceeded the size limit", false);
180
209
  }
181
- text += decoder.decode(value, { stream: true });
210
+ parser.push(decoder.decode(value, { stream: true }));
182
211
  }
183
- return text + decoder.decode();
212
+ parser.push(decoder.decode());
213
+ return parser.finish();
214
+ } catch (error) {
215
+ if (error instanceof CodexCompactionError) throw error;
216
+ if (signal.aborted) throw error;
217
+ throw new CodexCompactionError("Codex compaction response stream failed", true);
184
218
  } finally {
185
219
  await reader.cancel().catch(() => undefined);
186
220
  reader.releaseLock();
187
221
  }
188
222
  }
189
223
 
190
- function requestSignal(signal: AbortSignal | undefined): { signal: AbortSignal; cleanup: () => void } {
224
+ class CompactionSseParser {
225
+ private buffer = "";
226
+ private completed = false;
227
+ private status: string | undefined;
228
+ private compactionItems: Array<string | undefined> = [];
229
+ private usage: CodexCompactionUsage | undefined;
230
+
231
+ push(chunk: string): void {
232
+ this.buffer += chunk;
233
+ this.buffer = this.buffer.replace(/\r\n/g, "\n");
234
+ while (true) {
235
+ const separator = this.buffer.indexOf("\n\n");
236
+ if (separator < 0) break;
237
+ const frame = this.buffer.slice(0, separator);
238
+ this.buffer = this.buffer.slice(separator + 2);
239
+ this.processFrame(frame);
240
+ }
241
+ }
242
+
243
+ finish(): CodexCompactionResult {
244
+ if (this.buffer.trim()) this.processFrame(this.buffer);
245
+ if (!this.completed) {
246
+ throw new CodexCompactionError("Codex compaction stream ended before response completion", true);
247
+ }
248
+ if (this.status && this.status !== "completed") {
249
+ throw new CodexCompactionError("Codex compaction response failed", false);
250
+ }
251
+ if (this.compactionItems.length !== 1) {
252
+ throw new CodexCompactionError(
253
+ `Codex compaction returned ${this.compactionItems.length} checkpoint items`,
254
+ false,
255
+ );
256
+ }
257
+
258
+ const encryptedContent = this.compactionItems[0];
259
+ if (!encryptedContent) {
260
+ throw new CodexCompactionError("Codex compaction returned an invalid checkpoint", false);
261
+ }
262
+ return {
263
+ encryptedContent,
264
+ ...(this.usage ? { usage: this.usage } : {}),
265
+ };
266
+ }
267
+
268
+ private processFrame(frame: string): void {
269
+ const data = frame
270
+ .split("\n")
271
+ .filter((line) => line.startsWith("data:"))
272
+ .map((line) => line.slice(5).trim())
273
+ .join("\n")
274
+ .trim();
275
+ if (!data || data === "[DONE]") return;
276
+
277
+ let event: unknown;
278
+ try {
279
+ event = JSON.parse(data) as unknown;
280
+ } catch {
281
+ throw new CodexCompactionError("Codex compaction returned invalid SSE data", false);
282
+ }
283
+ if (!isRecord(event)) return;
284
+
285
+ if (event.type === "error" || event.type === "response.failed") {
286
+ throw new CodexCompactionError("Codex compaction response failed", false);
287
+ }
288
+
289
+ if (event.type === "response.output_item.done") {
290
+ this.collectCompactionItem(event.item);
291
+ }
292
+
293
+ if (event.type === "response.completed" || event.type === "response.done") {
294
+ const response = isRecord(event.response) ? event.response : undefined;
295
+ this.status = typeof response?.status === "string" ? response.status : undefined;
296
+ this.usage = parseUsage(response?.usage ?? event.usage);
297
+
298
+ // Codex emits output_item.done before response.completed. Retain this
299
+ // fallback for compact Responses implementations that only return the
300
+ // completed response's output array.
301
+ if (this.compactionItems.length === 0) {
302
+ collectOutputItems(response?.output, (item) => this.collectCompactionItem(item));
303
+ }
304
+ this.completed = true;
305
+ }
306
+ }
307
+
308
+ private collectCompactionItem(value: unknown): void {
309
+ if (!isRecord(value) || value.type !== "compaction") return;
310
+ const encryptedContent = value.encrypted_content;
311
+ this.compactionItems.push(
312
+ typeof encryptedContent === "string" &&
313
+ encryptedContent.length > 0 &&
314
+ encryptedContent.length <= MAX_ENCRYPTED_CONTENT_CHARS
315
+ ? encryptedContent
316
+ : undefined,
317
+ );
318
+ }
319
+ }
320
+
321
+ function collectOutputItems(value: unknown, collect: (item: unknown) => void): void {
322
+ if (!Array.isArray(value)) return;
323
+ for (const item of value) collect(item);
324
+ }
325
+
326
+ function parseUsage(value: unknown): CodexCompactionUsage | undefined {
327
+ if (!isRecord(value)) return undefined;
328
+ const inputTokens = finiteNonNegativeNumber(value.input_tokens);
329
+ const outputTokens = finiteNonNegativeNumber(value.output_tokens);
330
+ const totalTokens = finiteNonNegativeNumber(value.total_tokens);
331
+ const inputDetails = isRecord(value.input_tokens_details) ? value.input_tokens_details : undefined;
332
+ const outputDetails = isRecord(value.output_tokens_details) ? value.output_tokens_details : undefined;
333
+ const cachedInputTokens = finiteNonNegativeNumber(inputDetails?.cached_tokens);
334
+ const reasoningTokens = finiteNonNegativeNumber(outputDetails?.reasoning_tokens);
335
+ if (
336
+ inputTokens === undefined &&
337
+ outputTokens === undefined &&
338
+ totalTokens === undefined &&
339
+ cachedInputTokens === undefined &&
340
+ reasoningTokens === undefined
341
+ ) {
342
+ return undefined;
343
+ }
344
+ return {
345
+ ...(inputTokens !== undefined ? { inputTokens } : {}),
346
+ ...(outputTokens !== undefined ? { outputTokens } : {}),
347
+ ...(totalTokens !== undefined ? { totalTokens } : {}),
348
+ ...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}),
349
+ ...(reasoningTokens !== undefined ? { reasoningTokens } : {}),
350
+ };
351
+ }
352
+
353
+ function finiteNonNegativeNumber(value: unknown): number | undefined {
354
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
355
+ }
356
+
357
+ function isRetryableStatus(status: number): boolean {
358
+ return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
359
+ }
360
+
361
+ async function waitForRetry(
362
+ response: Response | undefined,
363
+ attempt: number,
364
+ signal: AbortSignal,
365
+ ): Promise<void> {
366
+ const retryAfter = response ? parseRetryAfter(response.headers) : undefined;
367
+ const delay = Math.min(
368
+ MAX_RETRY_DELAY_MS,
369
+ retryAfter ?? RETRY_BASE_DELAY_MS * 2 ** attempt,
370
+ );
371
+ await new Promise<void>((resolve, reject) => {
372
+ let settled = false;
373
+ const onAbort = () => {
374
+ if (settled) return;
375
+ settled = true;
376
+ clearTimeout(timer);
377
+ signal.removeEventListener("abort", onAbort);
378
+ reject(signal.reason ?? new Error("Codex compaction request aborted"));
379
+ };
380
+ const timer = setTimeout(() => {
381
+ if (settled) return;
382
+ settled = true;
383
+ signal.removeEventListener("abort", onAbort);
384
+ resolve();
385
+ }, delay);
386
+ signal.addEventListener("abort", onAbort, { once: true });
387
+ if (signal.aborted) onAbort();
388
+ });
389
+ }
390
+
391
+ function parseRetryAfter(headers: Headers): number | undefined {
392
+ const milliseconds = headers.get("retry-after-ms");
393
+ if (milliseconds) {
394
+ const value = Number(milliseconds);
395
+ if (Number.isFinite(value) && value >= 0) return value;
396
+ }
397
+ const seconds = headers.get("retry-after");
398
+ if (!seconds) return undefined;
399
+ const numeric = Number(seconds);
400
+ if (Number.isFinite(numeric) && numeric >= 0) return numeric * 1_000;
401
+ const date = Date.parse(seconds);
402
+ return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined;
403
+ }
404
+
405
+ class CodexCompactionError extends Error {
406
+ constructor(
407
+ message: string,
408
+ readonly retryable: boolean,
409
+ ) {
410
+ super(message);
411
+ this.name = "CodexCompactionError";
412
+ }
413
+ }
414
+
415
+ function isRetryableError(error: unknown): boolean {
416
+ return error instanceof CodexCompactionError
417
+ ? error.retryable
418
+ : error instanceof Error;
419
+ }
420
+
421
+ interface RequestSignal {
422
+ signal: AbortSignal;
423
+ startAttempt: () => void;
424
+ headersReceived: () => void;
425
+ touch: () => void;
426
+ cleanup: () => void;
427
+ }
428
+
429
+ function requestSignal(signal: AbortSignal | undefined): RequestSignal {
191
430
  const controller = new AbortController();
431
+ let headerTimeout: ReturnType<typeof setTimeout> | undefined;
432
+ let idleTimeout: ReturnType<typeof setTimeout> | undefined;
433
+
192
434
  const onAbort = () => controller.abort(signal?.reason);
193
435
  if (signal?.aborted) {
194
436
  controller.abort(signal.reason);
195
437
  } else {
196
438
  signal?.addEventListener("abort", onAbort, { once: true });
197
439
  }
198
- const timeout = setTimeout(() => controller.abort(new Error("Codex compaction request timed out")), REQUEST_TIMEOUT_MS);
440
+
441
+ const clearTimers = () => {
442
+ if (headerTimeout) clearTimeout(headerTimeout);
443
+ if (idleTimeout) clearTimeout(idleTimeout);
444
+ headerTimeout = undefined;
445
+ idleTimeout = undefined;
446
+ };
447
+ const touch = () => {
448
+ if (idleTimeout) clearTimeout(idleTimeout);
449
+ idleTimeout = setTimeout(
450
+ () => controller.abort(new Error("Codex compaction response idle timeout")),
451
+ REQUEST_IDLE_TIMEOUT_MS,
452
+ );
453
+ };
199
454
 
200
455
  return {
201
456
  signal: controller.signal,
457
+ startAttempt: () => {
458
+ clearTimers();
459
+ headerTimeout = setTimeout(
460
+ () => controller.abort(new Error("Codex compaction request timed out waiting for response headers")),
461
+ REQUEST_HEADER_TIMEOUT_MS,
462
+ );
463
+ },
464
+ headersReceived: () => {
465
+ if (headerTimeout) clearTimeout(headerTimeout);
466
+ headerTimeout = undefined;
467
+ touch();
468
+ },
469
+ touch,
202
470
  cleanup: () => {
203
- clearTimeout(timeout);
471
+ clearTimers();
204
472
  signal?.removeEventListener("abort", onAbort);
205
473
  },
206
474
  };
@@ -46,14 +46,15 @@ function isPresentEnvFlag(value: string | undefined): boolean {
46
46
  return normalized !== "0" && normalized !== "false" && normalized !== "no";
47
47
  }
48
48
 
49
- function isInstallTelemetryEnabled(): boolean {
50
- if (isTruthyEnvFlag(process.env.CI)) return false;
51
- if (CI_ENVIRONMENT_VARIABLES.some((name) => isPresentEnvFlag(process.env[name]))) return false;
52
- if (isTruthyEnvFlag(process.env.PI_OFFLINE)) return false;
53
- if (process.env.PI_TELEMETRY !== undefined) return isTruthyEnvFlag(process.env.PI_TELEMETRY);
49
+ export function isInstallTelemetryEnabled(env: NodeJS.ProcessEnv = process.env, settingsPath = join(getAgentDir(), "settings.json")): boolean {
50
+ if (isTruthyEnvFlag(env.CI)) return false;
51
+ if (CI_ENVIRONMENT_VARIABLES.some((name) => isPresentEnvFlag(env[name]))) return false;
52
+ if (isTruthyEnvFlag(env.PI_OFFLINE)) return false;
54
53
 
55
- const settings = readJsonFile(join(getAgentDir(), "settings.json")) as PiSettingsDocument;
56
- return settings.enableInstallTelemetry !== false;
54
+ const settings = readJsonFile(settingsPath) as PiSettingsDocument;
55
+ if (settings.enableInstallTelemetry === false) return false;
56
+ if (env.PI_TELEMETRY !== undefined) return isTruthyEnvFlag(env.PI_TELEMETRY);
57
+ return true;
57
58
  }
58
59
 
59
60
  function getPackageVersion(): string {
@@ -3,7 +3,12 @@ import type { Model, Tool } from "@earendil-works/pi-ai";
3
3
  import type { ExtensionContext, SessionBeforeCompactEvent } from "@earendil-works/pi-coding-agent";
4
4
  import { convertToLlm, serializeConversation, sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
5
5
  import type { SessionEntry } from "@earendil-works/pi-coding-agent";
6
- import { requestRemoteCompaction } from "./codex-wire.js";
6
+ import {
7
+ getCodexAccountFingerprint,
8
+ requestRemoteCompactionWithUsage,
9
+ resolveCodexResponsesUrl,
10
+ type CodexCompactionUsage,
11
+ } from "./codex-wire.js";
7
12
 
8
13
  export const REMOTE_SUMMARY_MARKER = "[pi-codex-compaction:v1]";
9
14
  export const REMOTE_COMPACTION_KIND = "pi-codex-compaction";
@@ -16,14 +21,24 @@ const PI_COMPACTION_SUMMARY_PREFIX = "The conversation history before this point
16
21
 
17
22
  export interface RemoteCompactionDetails {
18
23
  kind: typeof REMOTE_COMPACTION_KIND;
19
- version: 1;
24
+ version: 2;
20
25
  provider: "openai-codex";
21
26
  model: string;
27
+ endpoint: string;
28
+ accountFingerprint: string;
29
+ authKind: CodexAuthKind;
22
30
  encryptedContent: string;
31
+ usage?: CodexCompactionUsage;
23
32
  }
24
33
 
25
- type CodexModel = Pick<Model<any>, "id" | "api" | "provider" | "baseUrl" | "contextWindow" | "headers">;
34
+ export type CodexAuthKind = "oauth" | "api-key" | "unknown";
35
+
36
+ type CodexModel = Pick<
37
+ Model<any>,
38
+ "id" | "api" | "provider" | "baseUrl" | "contextWindow" | "headers" | "reasoning"
39
+ >;
26
40
  type AgentMessage = ReturnType<typeof sessionEntryToContextMessages>[number];
41
+ type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
27
42
 
28
43
  export function supportsRemoteCompaction(model: Pick<CodexModel, "api" | "provider"> | undefined): boolean {
29
44
  return model?.provider === "openai-codex" && model.api === "openai-codex-responses";
@@ -38,6 +53,28 @@ export function findActiveRemoteCompaction(entries: readonly SessionEntry[]): Re
38
53
  return undefined;
39
54
  }
40
55
 
56
+ /**
57
+ * Check the model, endpoint, and account before reusing an opaque checkpoint.
58
+ * The checkpoint is intentionally not treated as portable across those
59
+ * boundaries because the service can bind its encryption to them.
60
+ */
61
+ export function isRemoteCompactionCompatible(
62
+ details: RemoteCompactionDetails,
63
+ model: Pick<CodexModel, "id" | "api" | "provider" | "baseUrl">,
64
+ accountFingerprint: string,
65
+ authKind: CodexAuthKind = "unknown",
66
+ ): boolean {
67
+ if (!supportsRemoteCompaction(model) || details.model !== model.id) return false;
68
+ if (details.accountFingerprint !== accountFingerprint) return false;
69
+ if (details.authKind === "unknown" || authKind === "unknown") return false;
70
+ if (details.authKind !== authKind) return false;
71
+ try {
72
+ return details.endpoint === resolveCodexResponsesUrl(model.baseUrl);
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
41
78
  export function applyRemoteCompactionMarker(payload: unknown, details: RemoteCompactionDetails): unknown | undefined {
42
79
  if (!isRecord(payload) || !Array.isArray(payload.input)) return undefined;
43
80
 
@@ -57,19 +94,31 @@ export async function createRemoteCompaction(
57
94
  event: SessionBeforeCompactEvent,
58
95
  ctx: ExtensionContext,
59
96
  getTools: () => readonly ToolInfoLike[],
97
+ thinkingLevel?: string,
60
98
  ): Promise<{
61
99
  summary: string;
62
100
  firstKeptEntryId: string;
63
101
  tokensBefore: number;
64
102
  details: RemoteCompactionDetails;
65
103
  } | undefined> {
104
+ // Pi's custom focus is part of the standard summarizer contract. The
105
+ // Responses compaction envelope has no documented equivalent, so do not
106
+ // silently discard it.
107
+ if (event.customInstructions?.trim()) return undefined;
108
+
66
109
  const model = ctx.model as CodexModel | undefined;
67
110
  if (!model || !supportsRemoteCompaction(model)) return undefined;
68
111
 
112
+ const endpoint = resolveCodexResponsesUrl(model.baseUrl);
69
113
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model as Model<any>);
70
114
  if (!auth.ok || !auth.apiKey) return undefined;
115
+ const accountFingerprint = getCodexAccountFingerprint(auth.apiKey);
116
+ const authKind = getCodexAuthKind(ctx.modelRegistry, model as Model<any>);
71
117
 
72
118
  const previous = findActiveRemoteCompaction(ctx.sessionManager.buildContextEntries());
119
+ const compatiblePrevious = previous && isRemoteCompactionCompatible(previous, model, accountFingerprint, authKind)
120
+ ? previous
121
+ : undefined;
73
122
  const messages = [...event.preparation.messagesToSummarize, ...event.preparation.turnPrefixMessages];
74
123
  const instructions = ctx.getSystemPrompt() || "You are a helpful assistant.";
75
124
  const sessionId = ctx.sessionManager.getSessionId();
@@ -79,28 +128,39 @@ export async function createRemoteCompaction(
79
128
  convertToLlm(messages),
80
129
  getTools(),
81
130
  auth.apiKey,
131
+ auth.headers,
82
132
  sessionId,
83
133
  event.signal,
134
+ thinkingLevel,
84
135
  );
85
136
  if (!providerPayload) return undefined;
86
137
  const providerInput = providerPayload.input;
87
138
  if (!Array.isArray(providerInput)) return undefined;
88
139
  const providerTools = Array.isArray(providerPayload.tools) ? providerPayload.tools : [];
89
140
 
90
- const input = appendCompactionItems(providerInput, event.preparation, previous);
91
- const boundedInput = boundCompactionInput(input, instructions, providerTools, model.contextWindow);
141
+ const input = appendCompactionItems(providerInput, event.preparation, compatiblePrevious);
142
+ const requestBody = {
143
+ ...providerPayload,
144
+ model: model.id,
145
+ store: false,
146
+ stream: true,
147
+ };
148
+ const boundedInput = boundCompactionInput(
149
+ input,
150
+ instructions,
151
+ providerTools,
152
+ model.contextWindow,
153
+ requestBody,
154
+ );
92
155
  if (!boundedInput) return undefined;
93
156
 
94
- const encryptedContent = await requestRemoteCompaction({
157
+ const result = await requestRemoteCompactionWithUsage({
95
158
  model,
96
159
  apiKey: auth.apiKey,
97
160
  authHeaders: auth.headers,
98
161
  sessionId,
99
162
  body: {
100
- ...providerPayload,
101
- model: model.id,
102
- store: false,
103
- stream: true,
163
+ ...requestBody,
104
164
  input: boundedInput,
105
165
  },
106
166
  signal: event.signal,
@@ -113,10 +173,14 @@ export async function createRemoteCompaction(
113
173
  tokensBefore: event.preparation.tokensBefore,
114
174
  details: {
115
175
  kind: REMOTE_COMPACTION_KIND,
116
- version: 1,
176
+ version: 2,
117
177
  provider: "openai-codex",
118
178
  model: model.id,
119
- encryptedContent,
179
+ endpoint,
180
+ accountFingerprint,
181
+ authKind,
182
+ encryptedContent: result.encryptedContent,
183
+ ...(result.usage ? { usage: result.usage } : {}),
120
184
  },
121
185
  };
122
186
  }
@@ -135,29 +199,28 @@ export function boundCompactionInput(
135
199
  instructions: string,
136
200
  tools: readonly unknown[],
137
201
  contextWindow: number,
202
+ requestPayload?: Record<string, unknown>,
138
203
  ): unknown[] | undefined {
139
- const budget = Math.max(1, contextWindow - COMPACTION_RESPONSE_RESERVE_TOKENS);
204
+ const budget = Math.max(1, Math.floor(contextWindow - COMPACTION_RESPONSE_RESERVE_TOKENS));
205
+ // A byte-level bound is conservative when the active model's tokenizer is unavailable:
206
+ // a token can be represented by a single UTF-8 byte, but not fewer.
207
+ const budgetBytes = budget;
140
208
  const bounded = input.map((item) => item);
141
- let estimatedTokens = estimateConservativeTokens({ instructions, input: bounded, tools });
142
- const fits = () => estimatedTokens <= budget;
143
- if (fits()) return bounded;
209
+ let requestBytes = estimateConservativeBytes(
210
+ buildCompactionRequest(requestPayload, bounded, instructions, tools),
211
+ );
212
+ if (requestBytes <= budgetBytes) return bounded;
144
213
 
145
214
  // ponytail: trim tool outputs first; if structural content still exceeds the active model window,
146
215
  // let Pi's standard compaction path handle the request instead of inventing a lossy transcript rewrite.
147
216
  for (let index = bounded.length - 1; index >= 0; index--) {
148
217
  const item = bounded[index];
149
- if (!isRecord(item)) continue;
150
-
151
- const replacement = item.type === "function_call_output"
152
- ? { ...item, output: TRUNCATED_TOOL_OUTPUT }
153
- : item.type === "tool_search_output"
154
- ? { ...item, tools: [] }
155
- : undefined;
218
+ const replacement = trimToolOutput(item);
156
219
  if (!replacement) continue;
157
220
 
158
- estimatedTokens += estimateConservativeTokens(replacement) - estimateConservativeTokens(item);
221
+ requestBytes += estimateConservativeBytes(replacement) - estimateConservativeBytes(item);
159
222
  bounded[index] = replacement;
160
- if (fits()) return bounded;
223
+ if (requestBytes <= budgetBytes) return bounded;
161
224
  }
162
225
 
163
226
  return undefined;
@@ -166,10 +229,12 @@ export function boundCompactionInput(
166
229
  export function buildFallbackSummary(preparation: SessionBeforeCompactEvent["preparation"], messages: AgentMessage[]): string {
167
230
  const transcript = serializeConversation(convertToLlm(messages));
168
231
  const previous = preparation.previousSummary ? `Previous checkpoint fallback:\n${preparation.previousSummary}` : "";
232
+ const fileOperations = formatFileOperations(preparation.fileOps);
169
233
  const content = [
170
234
  "This checkpoint is opaque to non-Codex providers. Use the transcript excerpt below when continuing this session.",
171
235
  previous,
172
236
  transcript ? `Discarded conversation excerpt:\n${transcript}` : "",
237
+ fileOperations,
173
238
  ]
174
239
  .filter(Boolean)
175
240
  .join("\n\n");
@@ -182,26 +247,34 @@ async function captureCodexPayload(
182
247
  messages: ReturnType<typeof convertToLlm>,
183
248
  tools: readonly ToolInfoLike[],
184
249
  apiKey: string,
250
+ authHeaders: Record<string, string> | undefined,
185
251
  sessionId: string,
186
252
  signal: AbortSignal,
253
+ thinkingLevel: string | undefined,
187
254
  ): Promise<Record<string, unknown> | undefined> {
188
255
  if (signal.aborted) return undefined;
189
256
 
190
257
  let payload: unknown;
191
258
  const captureError = new Error("capture Codex request payload");
192
- const stream = captureProviderPayload(model as Model<any>, {
193
- systemPrompt: instructions,
194
- messages,
195
- tools: [...tools],
196
- }, {
259
+ const options: Record<string, unknown> = {
197
260
  apiKey,
261
+ headers: authHeaders,
198
262
  sessionId,
199
263
  signal,
200
- onPayload(next) {
264
+ onPayload(next: unknown) {
201
265
  payload = next;
202
266
  throw captureError;
203
267
  },
204
- });
268
+ };
269
+ if (model.reasoning && isThinkingLevel(thinkingLevel) && thinkingLevel !== "off") {
270
+ options.reasoningEffort = thinkingLevel;
271
+ }
272
+
273
+ const stream = captureProviderPayload(model as Model<any>, {
274
+ systemPrompt: instructions,
275
+ messages,
276
+ tools: [...tools],
277
+ }, options);
205
278
 
206
279
  await stream.result();
207
280
  return isRecord(payload) ? payload : undefined;
@@ -219,7 +292,9 @@ function appendCompactionItems(
219
292
  output.unshift({
220
293
  type: "message",
221
294
  role: "user",
222
- content: [{ type: "input_text", text: limitText(preparation.previousSummary, FALLBACK_SUMMARY_MAX_CHARS) }],
295
+ // This is bounded together with the complete request envelope below;
296
+ // the readable fallback limit is only for non-Codex continuation.
297
+ content: [{ type: "input_text", text: preparation.previousSummary }],
223
298
  status: "completed",
224
299
  });
225
300
  }
@@ -358,19 +433,97 @@ function isRemoteCompactionDetails(value: unknown): value is RemoteCompactionDet
358
433
  return (
359
434
  isRecord(value) &&
360
435
  value.kind === REMOTE_COMPACTION_KIND &&
361
- value.version === 1 &&
436
+ value.version === 2 &&
362
437
  value.provider === "openai-codex" &&
363
438
  typeof value.model === "string" &&
364
439
  value.model.length > 0 &&
440
+ value.model.length <= 256 &&
441
+ typeof value.endpoint === "string" &&
442
+ value.endpoint.length <= 2_048 &&
443
+ typeof value.accountFingerprint === "string" &&
444
+ /^sha256:[a-f0-9]{64}$/.test(value.accountFingerprint) &&
445
+ (value.authKind === "oauth" || value.authKind === "api-key" || value.authKind === "unknown") &&
365
446
  typeof value.encryptedContent === "string" &&
366
447
  value.encryptedContent.length > 0 &&
367
- value.encryptedContent.length <= MAX_ENCRYPTED_CONTENT_CHARS
448
+ value.encryptedContent.length <= MAX_ENCRYPTED_CONTENT_CHARS &&
449
+ (value.usage === undefined || isCodexCompactionUsage(value.usage))
368
450
  );
369
451
  }
370
452
 
371
- function estimateConservativeTokens(value: unknown): number {
372
- // One UTF-8 byte per token is deliberately conservative without a provider tokenizer.
373
- return new TextEncoder().encode(JSON.stringify(value) ?? "").byteLength;
453
+ export function getCodexAuthKind(
454
+ modelRegistry: unknown,
455
+ model: Model<any>,
456
+ ): CodexAuthKind {
457
+ const isUsingOAuth = isRecord(modelRegistry) ? modelRegistry.isUsingOAuth : undefined;
458
+ if (typeof isUsingOAuth !== "function") return "unknown";
459
+ try {
460
+ return isUsingOAuth.call(modelRegistry, model) ? "oauth" : "api-key";
461
+ } catch {
462
+ return "unknown";
463
+ }
464
+ }
465
+
466
+ function isCodexCompactionUsage(value: unknown): value is CodexCompactionUsage {
467
+ return isRecord(value) && Object.values(value).every(
468
+ (entry) => typeof entry === "number" && Number.isFinite(entry) && entry >= 0,
469
+ );
470
+ }
471
+
472
+ function buildCompactionRequest(
473
+ requestPayload: Record<string, unknown> | undefined,
474
+ input: readonly unknown[],
475
+ instructions: string,
476
+ tools: readonly unknown[],
477
+ ): Record<string, unknown> {
478
+ const payload: Record<string, unknown> = requestPayload ? { ...requestPayload } : {};
479
+ payload.instructions = instructions;
480
+ payload.input = input;
481
+ if (tools.length > 0 || requestPayload && "tools" in requestPayload) payload.tools = tools;
482
+ return payload;
483
+ }
484
+
485
+ function trimToolOutput(value: unknown): unknown | undefined {
486
+ if (!isRecord(value)) return undefined;
487
+ if (
488
+ (value.type === "function_call_output" ||
489
+ value.type === "custom_tool_call_output" ||
490
+ value.type === "local_shell_call_output" ||
491
+ value.type === "computer_call_output") &&
492
+ value.output !== undefined
493
+ ) {
494
+ return { ...value, output: TRUNCATED_TOOL_OUTPUT };
495
+ }
496
+ if (value.type === "tool_search_output" && Array.isArray(value.tools)) {
497
+ return { ...value, tools: [] };
498
+ }
499
+ return undefined;
500
+ }
501
+
502
+ function estimateConservativeBytes(value: unknown): number {
503
+ try {
504
+ const json = JSON.stringify(value) ?? "";
505
+ return new TextEncoder().encode(json).byteLength;
506
+ } catch {
507
+ return Number.POSITIVE_INFINITY;
508
+ }
509
+ }
510
+
511
+ function formatFileOperations(fileOps: { read: Set<string>; edited: Set<string> }): string {
512
+ const read = [...fileOps.read].filter((path): path is string => typeof path === "string").sort();
513
+ const edited = [...fileOps.edited].filter((path): path is string => typeof path === "string").sort();
514
+ if (read.length === 0 && edited.length === 0) return "";
515
+ return [
516
+ "File operations in discarded history:",
517
+ read.length > 0 ? `- Read: ${read.join(", ")}` : "",
518
+ edited.length > 0 ? `- Modified: ${edited.join(", ")}` : "",
519
+ ]
520
+ .filter(Boolean)
521
+ .join("\n");
522
+ }
523
+
524
+ function isThinkingLevel(value: string | undefined): value is ThinkingLevel {
525
+ return value === "off" || value === "minimal" || value === "low" || value === "medium" ||
526
+ value === "high" || value === "xhigh" || value === "max";
374
527
  }
375
528
 
376
529
  function limitText(text: string, maxChars: number): string {