pi-codex-compaction 0.1.0 → 0.1.2

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,32 @@ This project follows the spirit of [Keep a Changelog](https://keepachangelog.com
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.2] - 2026-09-11
10
+
11
+ ### Fixed
12
+
13
+ - Preserve pi-codex-tools grammar metadata and custom-tool history during remote compaction.
14
+ - Honor pi-fast on direct compaction requests through a public event-bus contract.
15
+ - Include cache writes and standard Pi compaction usage in session totals.
16
+ - Restrict endpoint paths and ports; merge beta features and honor null auth headers.
17
+ - Avoid I/O after pre-cancellation, bound total request time, and close completed SSE streams promptly.
18
+
19
+ ## [0.1.1] - 2026-08-06
20
+
21
+ ### Fixed
22
+
23
+ - Let `enableInstallTelemetry: false` override an enabled `PI_TELEMETRY` environment flag.
24
+ - Keep the normal Codex Responses request envelope while excluding Pi's retained user input from compaction history.
25
+ - Avoid reusing opaque checkpoints across model, endpoint, account, or authentication-mode changes.
26
+ - Avoid reusing opaque checkpoints when the authentication mode cannot be verified.
27
+ - Reject version 1 opaque checkpoints from existing sessions and use readable fallback context until a new checkpoint is created.
28
+ - Fall back to Pi's standard compactor when custom compaction instructions are supplied.
29
+
30
+ ### Added
31
+
32
+ - Incremental bounded SSE parsing, transient request retries, response idle timeouts, provider usage capture, and file-operation fallback metadata.
33
+ - Trusted-origin and redirect protections for direct Codex compaction requests.
34
+
9
35
  ## [0.1.0] - 2026-08-02
10
36
 
11
37
  ### Added
package/README.md CHANGED
@@ -6,11 +6,14 @@ 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
- 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.
16
+ The current Codex catalog includes `gpt-6-astra`, `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. Use Pi 0.85.1 or later.
14
17
 
15
18
  ## Installation
16
19
 
@@ -32,14 +35,53 @@ 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
 
46
+ ### Other Codex extensions
47
+
48
+ With `pi-fast` installed, direct compaction requests use the current Fast toggle.
49
+ With `pi-codex-tools` installed, `apply_patch` keeps its raw grammar definition,
50
+ custom-tool calls, and custom-tool results during compaction. Neither package is
51
+ required. No package reads a private Pi tool registry.
52
+ With `pi-openai-reasoning` installed, verified Astra requests keep the original
53
+ request effort and receive the current effort as a configuration update.
54
+ Failed compaction does not change saved reasoning state.
55
+
56
+ Pi 0.85.1 does not expose grammar metadata in `getAllTools()`. Two synchronous,
57
+ versioned `pi.events` contracts let cooperating extensions supply it:
58
+
59
+ - `pi-codex-compaction:tools:v1`: `{ model, tools }`, before provider serialization.
60
+ A tool owner can attach its own `constrainedSampling` metadata.
61
+ - `pi-codex-compaction:request:v1`: `{ ctx, messages, payload }`, after input
62
+ assembly and before size checks. A listener can replace `payload`. This event
63
+ is not the general `before_provider_request` chain and does not carry auth.
64
+
65
+ Other extensions' private request changes are not applied automatically.
66
+ Unknown third-party grammar metadata needs cooperation through the tools event.
67
+ The checkpoint entry includes standard Pi `usage`, including cache reads and
68
+ cache writes, as well as the original bounded token counters in `details`.
69
+ Costs follow Pi's catalog estimates. They are not a ChatGPT subscription bill.
70
+
71
+ ### Reference and smoke test
72
+
73
+ Behavior was checked against `openai/codex` commit
74
+ `654b0a77d0d2f81aa21f61caf7af4be88fe550bb` (2026-09-11), notably
75
+ `core/src/compact_remote_v2{,_attempt}.rs`. No Codex code was copied.
76
+ RemoteCompactionV2 is a changing Codex protocol, not the public `/responses/compact`
77
+ API. Async tools and mid-turn steering require upstream Pi support.
78
+
79
+ For a small live test, load this package and select `openai-codex/gpt-6-astra`.
80
+ Send two short messages, run `/compact`, then ask about the first message.
81
+ Repeat with `/fast on` and `pi-codex-tools` loaded. Check that compaction succeeds,
82
+ the continuation retains context, and session usage includes compaction tokens.
83
+ Use only a temporary file if you test `apply_patch`. Do not generate images.
84
+
43
85
  ```bash
44
86
  npm install
45
87
  npm run -w packages/pi-codex-compaction check
package/SECURITY.md CHANGED
@@ -19,10 +19,20 @@ 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
 
28
+ Direct requests allow only the official `/backend-api/codex/responses` endpoint
29
+ on the default HTTPS port, without query strings or fragments. Total request
30
+ time is limited to five minutes, including retries. Completed streams are closed
31
+ without waiting for a server disconnect. A pre-aborted request does no network I/O.
32
+ Null auth headers remove matching model headers; beta features are merged.
33
+
34
+ Cooperating local extensions can inspect and transform compaction inputs through
35
+ the documented event bus before size checks. These events contain no credentials.
36
+ They have the same trust level as other installed Pi extensions.
37
+
28
38
  See [CONTRIBUTING.md](./CONTRIBUTING.md) for development and validation instructions.
@@ -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
 
@@ -21,9 +23,20 @@ export default function piCodexCompaction(pi: ExtensionAPI): void {
21
23
  try {
22
24
  const compaction = await createRemoteCompaction(event, ctx, () => {
23
25
  const active = new Set(pi.getActiveTools());
24
- return pi.getAllTools()
25
- .filter((tool) => active.has(tool.name))
26
- .map(({ name, description, parameters }) => ({ name, description, parameters }));
26
+ const data = {
27
+ model: ctx.model,
28
+ tools: pi.getAllTools().filter((tool) => active.has(tool.name)),
29
+ };
30
+ // Pi's public ToolInfo omits constrainedSampling. Tool owners can supply
31
+ // it here without exposing executors or inspecting private registries.
32
+ pi.events?.emit("pi-codex-compaction:tools:v1", data);
33
+ return data.tools;
34
+ }, pi.getThinkingLevel?.(), (payload, messages) => {
35
+ const data = { payload, messages, ctx };
36
+ // Synchronous bus contract: apply pure request transforms before bounds
37
+ // and before network I/O. Never include auth in this event.
38
+ pi.events?.emit("pi-codex-compaction:request:v1", data);
39
+ return data.payload;
27
40
  });
28
41
  return compaction ? { compaction } : undefined;
29
42
  } catch {
@@ -47,6 +60,23 @@ export default function piCodexCompaction(pi: ExtensionAPI): void {
47
60
  if (!supportsRemoteCompaction(ctx.model)) return;
48
61
  const details = findActiveRemoteCompaction(ctx.sessionManager.buildContextEntries());
49
62
  if (!details) return;
50
- return applyRemoteCompactionMarker(event.payload, details);
63
+
64
+ return (async () => {
65
+ const model = ctx.model;
66
+ if (!model) return undefined;
67
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
68
+ if (!auth.ok || !auth.apiKey) return undefined;
69
+
70
+ let accountFingerprint: string;
71
+ try {
72
+ accountFingerprint = getCodexAccountFingerprint(auth.apiKey);
73
+ } catch {
74
+ return undefined;
75
+ }
76
+ if (!isRemoteCompactionCompatible(details, model, accountFingerprint, getCodexAuthKind(ctx.modelRegistry, model))) {
77
+ return undefined;
78
+ }
79
+ return applyRemoteCompactionMarker(event.payload, details);
80
+ })();
51
81
  });
52
82
  }
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.2",
4
4
  "description": "Use OpenAI Codex RemoteCompactionV2 for supported Pi sessions.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -52,10 +52,10 @@
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",
58
- "tsx": "^4.23.1",
55
+ "@earendil-works/pi-ai": "^0.85.1",
56
+ "@earendil-works/pi-coding-agent": "^0.85.1",
57
+ "@types/node": "^26.2.0",
58
+ "tsx": "^4.23.5",
59
59
  "typescript": "^7.0.2"
60
60
  },
61
61
  "publishConfig": {
package/src/codex-wire.ts CHANGED
@@ -1,44 +1,113 @@
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 REQUEST_TOTAL_TIMEOUT_MS = 300_000;
9
+ const MAX_RETRIES = 2;
10
+ const RETRY_BASE_DELAY_MS = 200;
11
+ const MAX_RETRY_DELAY_MS = 30_000;
4
12
  const ACCOUNT_ID_CLAIM = "https://api.openai.com/auth";
13
+ const TRUSTED_CODEX_HOST = "chatgpt.com";
5
14
  export const BETA_FEATURE = "remote_compaction_v2";
6
15
 
7
16
  export interface CodexCompactionRequest {
8
17
  model: {
9
18
  id: string;
10
19
  baseUrl: string;
11
- headers?: Record<string, string>;
20
+ headers?: Record<string, string | null>;
12
21
  };
13
22
  apiKey: string;
14
- authHeaders?: Record<string, string>;
23
+ authHeaders?: Record<string, string | null>;
15
24
  sessionId?: string;
16
25
  body: Record<string, unknown>;
17
26
  signal?: AbortSignal;
18
27
  }
19
28
 
29
+ export interface CodexCompactionUsage {
30
+ inputTokens?: number;
31
+ outputTokens?: number;
32
+ totalTokens?: number;
33
+ cachedInputTokens?: number;
34
+ cacheWriteInputTokens?: number;
35
+ reasoningTokens?: number;
36
+ }
37
+
38
+ export interface CodexCompactionResult {
39
+ encryptedContent: string;
40
+ usage?: CodexCompactionUsage;
41
+ serviceTier?: "default" | "priority" | "flex";
42
+ }
43
+
44
+ /**
45
+ * Keep the original string-returning API for callers that only need the
46
+ * checkpoint. The extension uses requestRemoteCompactionWithUsage below.
47
+ */
20
48
  export async function requestRemoteCompaction(request: CodexCompactionRequest): Promise<string> {
49
+ return (await requestRemoteCompactionWithUsage(request)).encryptedContent;
50
+ }
51
+
52
+ export async function requestRemoteCompactionWithUsage(
53
+ request: CodexCompactionRequest,
54
+ ): Promise<CodexCompactionResult> {
55
+ request.signal?.throwIfAborted();
56
+ const endpoint = resolveCodexResponsesUrl(request.model.baseUrl);
21
57
  const accountId = extractAccountId(request.apiKey);
22
58
  const headers = buildHeaders(request, accountId);
23
- const { signal, cleanup } = requestSignal(request.signal);
59
+ const body = JSON.stringify(request.body);
60
+ if (new TextEncoder().encode(body).byteLength > MAX_REQUEST_BYTES) {
61
+ throw new Error("Codex compaction request exceeded the size limit");
62
+ }
24
63
 
64
+ const requestState = requestSignal(request.signal);
25
65
  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
- }
66
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
67
+ requestState.signal.throwIfAborted();
68
+ requestState.startAttempt();
69
+
70
+ try {
71
+ const response = await fetch(endpoint, {
72
+ method: "POST",
73
+ headers,
74
+ body,
75
+ redirect: "error",
76
+ signal: requestState.signal,
77
+ });
78
+ requestState.headersReceived();
37
79
 
38
- return parseCompactionSse(await readBoundedBody(response));
80
+ if (!response.ok) {
81
+ await response.body?.cancel().catch(() => undefined);
82
+ const error = new CodexCompactionError(
83
+ `Codex compaction request returned HTTP ${response.status}`,
84
+ isRetryableStatus(response.status),
85
+ );
86
+ if (!error.retryable || attempt >= MAX_RETRIES) throw error;
87
+ await waitForRetry(response, attempt, requestState.signal);
88
+ continue;
89
+ }
90
+
91
+ try {
92
+ return await readCompactionSse(response, requestState.signal, requestState.touch);
93
+ } catch (error) {
94
+ if (!isRetryableError(error) || attempt >= MAX_RETRIES || requestState.signal.aborted) {
95
+ throw error;
96
+ }
97
+ await waitForRetry(undefined, attempt, requestState.signal);
98
+ }
99
+ } catch (error) {
100
+ if (requestState.signal.aborted || !isRetryableError(error) || attempt >= MAX_RETRIES) {
101
+ throw error;
102
+ }
103
+ await waitForRetry(undefined, attempt, requestState.signal);
104
+ }
105
+ }
39
106
  } finally {
40
- cleanup();
107
+ requestState.cleanup();
41
108
  }
109
+
110
+ throw new Error("Codex compaction request failed");
42
111
  }
43
112
 
44
113
  export function resolveCodexResponsesUrl(baseUrl: string): string {
@@ -46,11 +115,20 @@ export function resolveCodexResponsesUrl(baseUrl: string): string {
46
115
  if (url.protocol !== "https:") {
47
116
  throw new Error("Codex compaction requires an HTTPS endpoint");
48
117
  }
118
+ if (url.hostname.toLowerCase() !== TRUSTED_CODEX_HOST) {
119
+ throw new Error("Codex compaction endpoint is not a trusted Codex origin");
120
+ }
49
121
  if (url.username || url.password) {
50
122
  throw new Error("Codex compaction endpoint must not contain URL credentials");
51
123
  }
124
+ if (url.port || url.search || url.hash) {
125
+ throw new Error("Codex compaction endpoint must use the default port without query or fragment");
126
+ }
52
127
 
53
128
  const path = url.pathname.replace(/\/+$/, "");
129
+ if (!["/backend-api", "/backend-api/codex", "/backend-api/codex/responses"].includes(path)) {
130
+ throw new Error("Codex compaction endpoint must use the Codex Responses path");
131
+ }
54
132
  if (path.endsWith("/codex/responses")) {
55
133
  return url.toString();
56
134
  }
@@ -59,60 +137,34 @@ export function resolveCodexResponsesUrl(baseUrl: string): string {
59
137
  }
60
138
 
61
139
  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
- }
140
+ return parseCompactionSseResult(sse).encryptedContent;
141
+ }
96
142
 
97
- if (!completed) {
98
- throw new Error("Codex compaction stream ended before response completion");
99
- }
100
- if (encryptedContents.size !== 1) {
101
- throw new Error(`Codex compaction returned ${encryptedContents.size} checkpoint items`);
143
+ export function parseCompactionSseResult(sse: string): CodexCompactionResult {
144
+ if (new TextEncoder().encode(sse).byteLength > MAX_RESPONSE_BYTES) {
145
+ throw new Error("Codex compaction response exceeded the size limit");
102
146
  }
103
- return [...encryptedContents][0];
147
+ const parser = new CompactionSseParser();
148
+ parser.push(sse);
149
+ return parser.finish();
104
150
  }
105
151
 
106
152
  function buildHeaders(request: CodexCompactionRequest, accountId: string): Headers {
107
153
  const headers = new Headers();
108
- for (const [name, value] of Object.entries(request.model.headers ?? {})) headers.set(name, value);
109
- for (const [name, value] of Object.entries(request.authHeaders ?? {})) headers.set(name, value);
154
+ for (const [name, value] of Object.entries(request.model.headers ?? {})) {
155
+ if (value !== null) headers.set(name, value);
156
+ }
157
+ for (const [name, value] of Object.entries(request.authHeaders ?? {})) {
158
+ if (value !== null) headers.set(name, value);
159
+ else headers.delete(name);
160
+ }
110
161
 
111
162
  headers.set("Authorization", `Bearer ${request.apiKey}`);
112
163
  headers.set("chatgpt-account-id", accountId);
113
164
  headers.set("originator", "pi");
114
165
  headers.set("OpenAI-Beta", "responses=experimental");
115
- headers.set("x-codex-beta-features", BETA_FEATURE);
166
+ const features = headers.get("x-codex-beta-features")?.split(",").map((part) => part.trim()).filter(Boolean) ?? [];
167
+ headers.set("x-codex-beta-features", [...new Set([...features, BETA_FEATURE])].join(","));
116
168
  headers.set("accept", "text/event-stream");
117
169
  headers.set("content-type", "application/json");
118
170
 
@@ -124,6 +176,10 @@ function buildHeaders(request: CodexCompactionRequest, accountId: string): Heade
124
176
  return headers;
125
177
  }
126
178
 
179
+ export function getCodexAccountFingerprint(token: string): string {
180
+ return `sha256:${createHash("sha256").update(extractAccountId(token)).digest("hex")}`;
181
+ }
182
+
127
183
  function extractAccountId(token: string): string {
128
184
  const segment = token.split(".")[1];
129
185
  if (!segment) throw new Error("Codex authentication token has no account claim");
@@ -146,61 +202,307 @@ function safeSessionId(value: string | undefined): string | undefined {
146
202
  return value;
147
203
  }
148
204
 
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);
205
+ async function readCompactionSse(
206
+ response: Response,
207
+ signal: AbortSignal,
208
+ touch: () => void,
209
+ ): Promise<CodexCompactionResult> {
210
+ if (!response.body) {
211
+ throw new CodexCompactionError("Codex compaction response has no body", true);
163
212
  }
164
- }
165
-
166
- async function readBoundedBody(response: Response): Promise<string> {
167
- if (!response.body) throw new Error("Codex compaction response has no body");
168
213
 
169
214
  const reader = response.body.getReader();
170
215
  const decoder = new TextDecoder();
216
+ const parser = new CompactionSseParser();
171
217
  let bytes = 0;
172
- let text = "";
173
218
  try {
174
219
  while (true) {
175
220
  const { done, value } = await reader.read();
176
221
  if (done) break;
222
+ touch();
177
223
  bytes += value.byteLength;
178
224
  if (bytes > MAX_RESPONSE_BYTES) {
179
- throw new Error("Codex compaction response exceeded the size limit");
225
+ throw new CodexCompactionError("Codex compaction response exceeded the size limit", false);
180
226
  }
181
- text += decoder.decode(value, { stream: true });
227
+ parser.push(decoder.decode(value, { stream: true }));
228
+ if (parser.isComplete) return parser.finish();
182
229
  }
183
- return text + decoder.decode();
230
+ parser.push(decoder.decode());
231
+ return parser.finish();
232
+ } catch (error) {
233
+ if (error instanceof CodexCompactionError) throw error;
234
+ if (signal.aborted) throw error;
235
+ throw new CodexCompactionError("Codex compaction response stream failed", true);
184
236
  } finally {
185
237
  await reader.cancel().catch(() => undefined);
186
238
  reader.releaseLock();
187
239
  }
188
240
  }
189
241
 
190
- function requestSignal(signal: AbortSignal | undefined): { signal: AbortSignal; cleanup: () => void } {
242
+ class CompactionSseParser {
243
+ private buffer = "";
244
+ private completed = false;
245
+ private status: string | undefined;
246
+ private compactionItems: Array<string | undefined> = [];
247
+ private usage: CodexCompactionUsage | undefined;
248
+ private serviceTier: CodexCompactionResult["serviceTier"];
249
+
250
+ get isComplete(): boolean {
251
+ return this.completed;
252
+ }
253
+
254
+ push(chunk: string): void {
255
+ this.buffer += chunk;
256
+ this.buffer = this.buffer.replace(/\r\n/g, "\n");
257
+ while (true) {
258
+ const separator = this.buffer.indexOf("\n\n");
259
+ if (separator < 0) break;
260
+ const frame = this.buffer.slice(0, separator);
261
+ this.buffer = this.buffer.slice(separator + 2);
262
+ this.processFrame(frame);
263
+ }
264
+ }
265
+
266
+ finish(): CodexCompactionResult {
267
+ if (this.buffer.trim()) this.processFrame(this.buffer);
268
+ if (!this.completed) {
269
+ throw new CodexCompactionError("Codex compaction stream ended before response completion", true);
270
+ }
271
+ if (this.status && this.status !== "completed") {
272
+ throw new CodexCompactionError("Codex compaction response failed", false);
273
+ }
274
+ if (this.compactionItems.length !== 1) {
275
+ throw new CodexCompactionError(
276
+ `Codex compaction returned ${this.compactionItems.length} checkpoint items`,
277
+ false,
278
+ );
279
+ }
280
+
281
+ const encryptedContent = this.compactionItems[0];
282
+ if (!encryptedContent) {
283
+ throw new CodexCompactionError("Codex compaction returned an invalid checkpoint", false);
284
+ }
285
+ return {
286
+ encryptedContent,
287
+ ...(this.usage ? { usage: this.usage } : {}),
288
+ ...(this.serviceTier ? { serviceTier: this.serviceTier } : {}),
289
+ };
290
+ }
291
+
292
+ private processFrame(frame: string): void {
293
+ const data = frame
294
+ .split("\n")
295
+ .filter((line) => line.startsWith("data:"))
296
+ .map((line) => line.slice(5).trim())
297
+ .join("\n")
298
+ .trim();
299
+ if (!data || data === "[DONE]") return;
300
+
301
+ let event: unknown;
302
+ try {
303
+ event = JSON.parse(data) as unknown;
304
+ } catch {
305
+ throw new CodexCompactionError("Codex compaction returned invalid SSE data", false);
306
+ }
307
+ if (!isRecord(event)) return;
308
+
309
+ if (event.type === "error" || event.type === "response.failed") {
310
+ throw new CodexCompactionError("Codex compaction response failed", false);
311
+ }
312
+
313
+ if (event.type === "response.output_item.done") {
314
+ this.collectCompactionItem(event.item);
315
+ }
316
+
317
+ if (event.type === "response.completed" || event.type === "response.done") {
318
+ const response = isRecord(event.response) ? event.response : undefined;
319
+ this.status = typeof response?.status === "string" ? response.status : undefined;
320
+ this.usage = parseUsage(response?.usage ?? event.usage);
321
+ const tier = response?.service_tier;
322
+ this.serviceTier = tier === "default" || tier === "priority" || tier === "flex" ? tier : undefined;
323
+
324
+ // Codex emits output_item.done before response.completed. Retain this
325
+ // fallback for compact Responses implementations that only return the
326
+ // completed response's output array.
327
+ if (this.compactionItems.length === 0) {
328
+ collectOutputItems(response?.output, (item) => this.collectCompactionItem(item));
329
+ }
330
+ this.completed = true;
331
+ }
332
+ }
333
+
334
+ private collectCompactionItem(value: unknown): void {
335
+ if (!isRecord(value) || value.type !== "compaction") return;
336
+ const encryptedContent = value.encrypted_content;
337
+ this.compactionItems.push(
338
+ typeof encryptedContent === "string" &&
339
+ encryptedContent.length > 0 &&
340
+ encryptedContent.length <= MAX_ENCRYPTED_CONTENT_CHARS
341
+ ? encryptedContent
342
+ : undefined,
343
+ );
344
+ }
345
+ }
346
+
347
+ function collectOutputItems(value: unknown, collect: (item: unknown) => void): void {
348
+ if (!Array.isArray(value)) return;
349
+ for (const item of value) collect(item);
350
+ }
351
+
352
+ function parseUsage(value: unknown): CodexCompactionUsage | undefined {
353
+ if (!isRecord(value)) return undefined;
354
+ const inputTokens = finiteNonNegativeNumber(value.input_tokens);
355
+ const outputTokens = finiteNonNegativeNumber(value.output_tokens);
356
+ const totalTokens = finiteNonNegativeNumber(value.total_tokens);
357
+ const inputDetails = isRecord(value.input_tokens_details) ? value.input_tokens_details : undefined;
358
+ const outputDetails = isRecord(value.output_tokens_details) ? value.output_tokens_details : undefined;
359
+ const cachedInputTokens = finiteNonNegativeNumber(inputDetails?.cached_tokens);
360
+ const cacheWriteInputTokens = finiteNonNegativeNumber(inputDetails?.cache_write_tokens);
361
+ const reasoningTokens = finiteNonNegativeNumber(outputDetails?.reasoning_tokens);
362
+ if (
363
+ inputTokens === undefined &&
364
+ outputTokens === undefined &&
365
+ totalTokens === undefined &&
366
+ cachedInputTokens === undefined &&
367
+ cacheWriteInputTokens === undefined &&
368
+ reasoningTokens === undefined
369
+ ) {
370
+ return undefined;
371
+ }
372
+ return {
373
+ ...(inputTokens !== undefined ? { inputTokens } : {}),
374
+ ...(outputTokens !== undefined ? { outputTokens } : {}),
375
+ ...(totalTokens !== undefined ? { totalTokens } : {}),
376
+ ...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}),
377
+ ...(cacheWriteInputTokens !== undefined ? { cacheWriteInputTokens } : {}),
378
+ ...(reasoningTokens !== undefined ? { reasoningTokens } : {}),
379
+ };
380
+ }
381
+
382
+ function finiteNonNegativeNumber(value: unknown): number | undefined {
383
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
384
+ }
385
+
386
+ function isRetryableStatus(status: number): boolean {
387
+ return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
388
+ }
389
+
390
+ async function waitForRetry(
391
+ response: Response | undefined,
392
+ attempt: number,
393
+ signal: AbortSignal,
394
+ ): Promise<void> {
395
+ const retryAfter = response ? parseRetryAfter(response.headers) : undefined;
396
+ const delay = Math.min(
397
+ MAX_RETRY_DELAY_MS,
398
+ retryAfter ?? RETRY_BASE_DELAY_MS * 2 ** attempt,
399
+ );
400
+ await new Promise<void>((resolve, reject) => {
401
+ let settled = false;
402
+ const onAbort = () => {
403
+ if (settled) return;
404
+ settled = true;
405
+ clearTimeout(timer);
406
+ signal.removeEventListener("abort", onAbort);
407
+ reject(signal.reason ?? new Error("Codex compaction request aborted"));
408
+ };
409
+ const timer = setTimeout(() => {
410
+ if (settled) return;
411
+ settled = true;
412
+ signal.removeEventListener("abort", onAbort);
413
+ resolve();
414
+ }, delay);
415
+ signal.addEventListener("abort", onAbort, { once: true });
416
+ if (signal.aborted) onAbort();
417
+ });
418
+ }
419
+
420
+ function parseRetryAfter(headers: Headers): number | undefined {
421
+ const milliseconds = headers.get("retry-after-ms");
422
+ if (milliseconds) {
423
+ const value = Number(milliseconds);
424
+ if (Number.isFinite(value) && value >= 0) return value;
425
+ }
426
+ const seconds = headers.get("retry-after");
427
+ if (!seconds) return undefined;
428
+ const numeric = Number(seconds);
429
+ if (Number.isFinite(numeric) && numeric >= 0) return numeric * 1_000;
430
+ const date = Date.parse(seconds);
431
+ return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined;
432
+ }
433
+
434
+ class CodexCompactionError extends Error {
435
+ constructor(
436
+ message: string,
437
+ readonly retryable: boolean,
438
+ ) {
439
+ super(message);
440
+ this.name = "CodexCompactionError";
441
+ }
442
+ }
443
+
444
+ function isRetryableError(error: unknown): boolean {
445
+ return error instanceof CodexCompactionError
446
+ ? error.retryable
447
+ : error instanceof Error;
448
+ }
449
+
450
+ interface RequestSignal {
451
+ signal: AbortSignal;
452
+ startAttempt: () => void;
453
+ headersReceived: () => void;
454
+ touch: () => void;
455
+ cleanup: () => void;
456
+ }
457
+
458
+ function requestSignal(signal: AbortSignal | undefined): RequestSignal {
191
459
  const controller = new AbortController();
460
+ const totalTimeout = setTimeout(
461
+ () => controller.abort(new Error("Codex compaction request exceeded the total time limit")),
462
+ REQUEST_TOTAL_TIMEOUT_MS,
463
+ );
464
+ let headerTimeout: ReturnType<typeof setTimeout> | undefined;
465
+ let idleTimeout: ReturnType<typeof setTimeout> | undefined;
466
+
192
467
  const onAbort = () => controller.abort(signal?.reason);
193
468
  if (signal?.aborted) {
194
469
  controller.abort(signal.reason);
195
470
  } else {
196
471
  signal?.addEventListener("abort", onAbort, { once: true });
197
472
  }
198
- const timeout = setTimeout(() => controller.abort(new Error("Codex compaction request timed out")), REQUEST_TIMEOUT_MS);
473
+
474
+ const clearTimers = () => {
475
+ if (headerTimeout) clearTimeout(headerTimeout);
476
+ if (idleTimeout) clearTimeout(idleTimeout);
477
+ headerTimeout = undefined;
478
+ idleTimeout = undefined;
479
+ };
480
+ const touch = () => {
481
+ if (idleTimeout) clearTimeout(idleTimeout);
482
+ idleTimeout = setTimeout(
483
+ () => controller.abort(new Error("Codex compaction response idle timeout")),
484
+ REQUEST_IDLE_TIMEOUT_MS,
485
+ );
486
+ };
199
487
 
200
488
  return {
201
489
  signal: controller.signal,
490
+ startAttempt: () => {
491
+ clearTimers();
492
+ headerTimeout = setTimeout(
493
+ () => controller.abort(new Error("Codex compaction request timed out waiting for response headers")),
494
+ REQUEST_HEADER_TIMEOUT_MS,
495
+ );
496
+ },
497
+ headersReceived: () => {
498
+ if (headerTimeout) clearTimeout(headerTimeout);
499
+ headerTimeout = undefined;
500
+ touch();
501
+ },
502
+ touch,
202
503
  cleanup: () => {
203
- clearTimeout(timeout);
504
+ clearTimeout(totalTimeout);
505
+ clearTimers();
204
506
  signal?.removeEventListener("abort", onAbort);
205
507
  },
206
508
  };
@@ -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 {
@@ -1,9 +1,14 @@
1
1
  import { stream as captureProviderPayload } from "@earendil-works/pi-ai/compat";
2
- import type { Model, Tool } from "@earendil-works/pi-ai";
2
+ import { calculateCost, type Model, type Tool, type Usage } 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,33 @@ export async function createRemoteCompaction(
57
94
  event: SessionBeforeCompactEvent,
58
95
  ctx: ExtensionContext,
59
96
  getTools: () => readonly ToolInfoLike[],
97
+ thinkingLevel?: string,
98
+ prepareRequest?: (payload: Record<string, unknown>, messages: readonly AgentMessage[]) => Record<string, unknown>,
60
99
  ): Promise<{
61
100
  summary: string;
62
101
  firstKeptEntryId: string;
63
102
  tokensBefore: number;
64
103
  details: RemoteCompactionDetails;
104
+ usage?: Usage;
65
105
  } | undefined> {
106
+ // Pi's custom focus is part of the standard summarizer contract. The
107
+ // Responses compaction envelope has no documented equivalent, so do not
108
+ // silently discard it.
109
+ if (event.customInstructions?.trim()) return undefined;
110
+
66
111
  const model = ctx.model as CodexModel | undefined;
67
112
  if (!model || !supportsRemoteCompaction(model)) return undefined;
68
113
 
114
+ const endpoint = resolveCodexResponsesUrl(model.baseUrl);
69
115
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model as Model<any>);
70
116
  if (!auth.ok || !auth.apiKey) return undefined;
117
+ const accountFingerprint = getCodexAccountFingerprint(auth.apiKey);
118
+ const authKind = getCodexAuthKind(ctx.modelRegistry, model as Model<any>);
71
119
 
72
120
  const previous = findActiveRemoteCompaction(ctx.sessionManager.buildContextEntries());
121
+ const compatiblePrevious = previous && isRemoteCompactionCompatible(previous, model, accountFingerprint, authKind)
122
+ ? previous
123
+ : undefined;
73
124
  const messages = [...event.preparation.messagesToSummarize, ...event.preparation.turnPrefixMessages];
74
125
  const instructions = ctx.getSystemPrompt() || "You are a helpful assistant.";
75
126
  const sessionId = ctx.sessionManager.getSessionId();
@@ -79,28 +130,43 @@ export async function createRemoteCompaction(
79
130
  convertToLlm(messages),
80
131
  getTools(),
81
132
  auth.apiKey,
133
+ auth.headers,
82
134
  sessionId,
83
135
  event.signal,
136
+ thinkingLevel,
84
137
  );
85
138
  if (!providerPayload) return undefined;
86
139
  const providerInput = providerPayload.input;
87
140
  if (!Array.isArray(providerInput)) return undefined;
88
141
  const providerTools = Array.isArray(providerPayload.tools) ? providerPayload.tools : [];
89
142
 
90
- const input = appendCompactionItems(providerInput, event.preparation, previous);
91
- const boundedInput = boundCompactionInput(input, instructions, providerTools, model.contextWindow);
143
+ const input = appendCompactionItems(providerInput, event.preparation, compatiblePrevious);
144
+ const requestBody = prepareRequest?.({
145
+ ...providerPayload,
146
+ input,
147
+ }, messages) ?? { ...providerPayload, input };
148
+ Object.assign(requestBody, {
149
+ model: model.id,
150
+ store: false,
151
+ stream: true,
152
+ });
153
+ if (!Array.isArray(requestBody.input)) return undefined;
154
+ const boundedInput = boundCompactionInput(
155
+ requestBody.input,
156
+ instructions,
157
+ Array.isArray(requestBody.tools) ? requestBody.tools : providerTools,
158
+ model.contextWindow,
159
+ requestBody,
160
+ );
92
161
  if (!boundedInput) return undefined;
93
162
 
94
- const encryptedContent = await requestRemoteCompaction({
163
+ const result = await requestRemoteCompactionWithUsage({
95
164
  model,
96
165
  apiKey: auth.apiKey,
97
166
  authHeaders: auth.headers,
98
167
  sessionId,
99
168
  body: {
100
- ...providerPayload,
101
- model: model.id,
102
- store: false,
103
- stream: true,
169
+ ...requestBody,
104
170
  input: boundedInput,
105
171
  },
106
172
  signal: event.signal,
@@ -111,12 +177,21 @@ export async function createRemoteCompaction(
111
177
  summary: `${REMOTE_SUMMARY_MARKER}\n\n${fallback}`,
112
178
  firstKeptEntryId: event.preparation.firstKeptEntryId,
113
179
  tokensBefore: event.preparation.tokensBefore,
180
+ ...(result.usage ? { usage: toPiUsage(
181
+ result.usage,
182
+ ctx.model!,
183
+ result.serviceTier && result.serviceTier !== "default" ? result.serviceTier : requestBody.service_tier,
184
+ ) } : {}),
114
185
  details: {
115
186
  kind: REMOTE_COMPACTION_KIND,
116
- version: 1,
187
+ version: 2,
117
188
  provider: "openai-codex",
118
189
  model: model.id,
119
- encryptedContent,
190
+ endpoint,
191
+ accountFingerprint,
192
+ authKind,
193
+ encryptedContent: result.encryptedContent,
194
+ ...(result.usage ? { usage: result.usage } : {}),
120
195
  },
121
196
  };
122
197
  }
@@ -135,29 +210,28 @@ export function boundCompactionInput(
135
210
  instructions: string,
136
211
  tools: readonly unknown[],
137
212
  contextWindow: number,
213
+ requestPayload?: Record<string, unknown>,
138
214
  ): unknown[] | undefined {
139
- const budget = Math.max(1, contextWindow - COMPACTION_RESPONSE_RESERVE_TOKENS);
215
+ const budget = Math.max(1, Math.floor(contextWindow - COMPACTION_RESPONSE_RESERVE_TOKENS));
216
+ // A byte-level bound is conservative when the active model's tokenizer is unavailable:
217
+ // a token can be represented by a single UTF-8 byte, but not fewer.
218
+ const budgetBytes = budget;
140
219
  const bounded = input.map((item) => item);
141
- let estimatedTokens = estimateConservativeTokens({ instructions, input: bounded, tools });
142
- const fits = () => estimatedTokens <= budget;
143
- if (fits()) return bounded;
220
+ let requestBytes = estimateConservativeBytes(
221
+ buildCompactionRequest(requestPayload, bounded, instructions, tools),
222
+ );
223
+ if (requestBytes <= budgetBytes) return bounded;
144
224
 
145
225
  // ponytail: trim tool outputs first; if structural content still exceeds the active model window,
146
226
  // let Pi's standard compaction path handle the request instead of inventing a lossy transcript rewrite.
147
227
  for (let index = bounded.length - 1; index >= 0; index--) {
148
228
  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;
229
+ const replacement = trimToolOutput(item);
156
230
  if (!replacement) continue;
157
231
 
158
- estimatedTokens += estimateConservativeTokens(replacement) - estimateConservativeTokens(item);
232
+ requestBytes += estimateConservativeBytes(replacement) - estimateConservativeBytes(item);
159
233
  bounded[index] = replacement;
160
- if (fits()) return bounded;
234
+ if (requestBytes <= budgetBytes) return bounded;
161
235
  }
162
236
 
163
237
  return undefined;
@@ -166,10 +240,12 @@ export function boundCompactionInput(
166
240
  export function buildFallbackSummary(preparation: SessionBeforeCompactEvent["preparation"], messages: AgentMessage[]): string {
167
241
  const transcript = serializeConversation(convertToLlm(messages));
168
242
  const previous = preparation.previousSummary ? `Previous checkpoint fallback:\n${preparation.previousSummary}` : "";
243
+ const fileOperations = formatFileOperations(preparation.fileOps);
169
244
  const content = [
170
245
  "This checkpoint is opaque to non-Codex providers. Use the transcript excerpt below when continuing this session.",
171
246
  previous,
172
247
  transcript ? `Discarded conversation excerpt:\n${transcript}` : "",
248
+ fileOperations,
173
249
  ]
174
250
  .filter(Boolean)
175
251
  .join("\n\n");
@@ -182,26 +258,34 @@ async function captureCodexPayload(
182
258
  messages: ReturnType<typeof convertToLlm>,
183
259
  tools: readonly ToolInfoLike[],
184
260
  apiKey: string,
261
+ authHeaders: Record<string, string | null> | undefined,
185
262
  sessionId: string,
186
263
  signal: AbortSignal,
264
+ thinkingLevel: string | undefined,
187
265
  ): Promise<Record<string, unknown> | undefined> {
188
266
  if (signal.aborted) return undefined;
189
267
 
190
268
  let payload: unknown;
191
269
  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
- }, {
270
+ const options: Record<string, unknown> = {
197
271
  apiKey,
272
+ headers: authHeaders,
198
273
  sessionId,
199
274
  signal,
200
- onPayload(next) {
275
+ onPayload(next: unknown) {
201
276
  payload = next;
202
277
  throw captureError;
203
278
  },
204
- });
279
+ };
280
+ if (model.reasoning && isThinkingLevel(thinkingLevel) && thinkingLevel !== "off") {
281
+ options.reasoningEffort = thinkingLevel;
282
+ }
283
+
284
+ const stream = captureProviderPayload(model as Model<any>, {
285
+ systemPrompt: instructions,
286
+ messages,
287
+ tools: [...tools],
288
+ }, options);
205
289
 
206
290
  await stream.result();
207
291
  return isRecord(payload) ? payload : undefined;
@@ -219,7 +303,9 @@ function appendCompactionItems(
219
303
  output.unshift({
220
304
  type: "message",
221
305
  role: "user",
222
- content: [{ type: "input_text", text: limitText(preparation.previousSummary, FALLBACK_SUMMARY_MAX_CHARS) }],
306
+ // This is bounded together with the complete request envelope below;
307
+ // the readable fallback limit is only for non-Codex continuation.
308
+ content: [{ type: "input_text", text: preparation.previousSummary }],
223
309
  status: "completed",
224
310
  });
225
311
  }
@@ -358,19 +444,97 @@ function isRemoteCompactionDetails(value: unknown): value is RemoteCompactionDet
358
444
  return (
359
445
  isRecord(value) &&
360
446
  value.kind === REMOTE_COMPACTION_KIND &&
361
- value.version === 1 &&
447
+ value.version === 2 &&
362
448
  value.provider === "openai-codex" &&
363
449
  typeof value.model === "string" &&
364
450
  value.model.length > 0 &&
451
+ value.model.length <= 256 &&
452
+ typeof value.endpoint === "string" &&
453
+ value.endpoint.length <= 2_048 &&
454
+ typeof value.accountFingerprint === "string" &&
455
+ /^sha256:[a-f0-9]{64}$/.test(value.accountFingerprint) &&
456
+ (value.authKind === "oauth" || value.authKind === "api-key" || value.authKind === "unknown") &&
365
457
  typeof value.encryptedContent === "string" &&
366
458
  value.encryptedContent.length > 0 &&
367
- value.encryptedContent.length <= MAX_ENCRYPTED_CONTENT_CHARS
459
+ value.encryptedContent.length <= MAX_ENCRYPTED_CONTENT_CHARS &&
460
+ (value.usage === undefined || isCodexCompactionUsage(value.usage))
368
461
  );
369
462
  }
370
463
 
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;
464
+ export function getCodexAuthKind(
465
+ modelRegistry: unknown,
466
+ model: Model<any>,
467
+ ): CodexAuthKind {
468
+ const isUsingOAuth = isRecord(modelRegistry) ? modelRegistry.isUsingOAuth : undefined;
469
+ if (typeof isUsingOAuth !== "function") return "unknown";
470
+ try {
471
+ return isUsingOAuth.call(modelRegistry, model) ? "oauth" : "api-key";
472
+ } catch {
473
+ return "unknown";
474
+ }
475
+ }
476
+
477
+ function isCodexCompactionUsage(value: unknown): value is CodexCompactionUsage {
478
+ return isRecord(value) && Object.values(value).every(
479
+ (entry) => typeof entry === "number" && Number.isFinite(entry) && entry >= 0,
480
+ );
481
+ }
482
+
483
+ function buildCompactionRequest(
484
+ requestPayload: Record<string, unknown> | undefined,
485
+ input: readonly unknown[],
486
+ instructions: string,
487
+ tools: readonly unknown[],
488
+ ): Record<string, unknown> {
489
+ const payload: Record<string, unknown> = requestPayload ? { ...requestPayload } : {};
490
+ payload.instructions = instructions;
491
+ payload.input = input;
492
+ if (tools.length > 0 || requestPayload && "tools" in requestPayload) payload.tools = tools;
493
+ return payload;
494
+ }
495
+
496
+ function trimToolOutput(value: unknown): unknown | undefined {
497
+ if (!isRecord(value)) return undefined;
498
+ if (
499
+ (value.type === "function_call_output" ||
500
+ value.type === "custom_tool_call_output" ||
501
+ value.type === "local_shell_call_output" ||
502
+ value.type === "computer_call_output") &&
503
+ value.output !== undefined
504
+ ) {
505
+ return { ...value, output: TRUNCATED_TOOL_OUTPUT };
506
+ }
507
+ if (value.type === "tool_search_output" && Array.isArray(value.tools)) {
508
+ return { ...value, tools: [] };
509
+ }
510
+ return undefined;
511
+ }
512
+
513
+ function estimateConservativeBytes(value: unknown): number {
514
+ try {
515
+ const json = JSON.stringify(value) ?? "";
516
+ return new TextEncoder().encode(json).byteLength;
517
+ } catch {
518
+ return Number.POSITIVE_INFINITY;
519
+ }
520
+ }
521
+
522
+ function formatFileOperations(fileOps: { read: Set<string>; edited: Set<string> }): string {
523
+ const read = [...fileOps.read].filter((path): path is string => typeof path === "string").sort();
524
+ const edited = [...fileOps.edited].filter((path): path is string => typeof path === "string").sort();
525
+ if (read.length === 0 && edited.length === 0) return "";
526
+ return [
527
+ "File operations in discarded history:",
528
+ read.length > 0 ? `- Read: ${read.join(", ")}` : "",
529
+ edited.length > 0 ? `- Modified: ${edited.join(", ")}` : "",
530
+ ]
531
+ .filter(Boolean)
532
+ .join("\n");
533
+ }
534
+
535
+ function isThinkingLevel(value: string | undefined): value is ThinkingLevel {
536
+ return value === "off" || value === "minimal" || value === "low" || value === "medium" ||
537
+ value === "high" || value === "xhigh" || value === "max";
374
538
  }
375
539
 
376
540
  function limitText(text: string, maxChars: number): string {
@@ -381,7 +545,27 @@ function limitText(text: string, maxChars: number): string {
381
545
  return `${text.slice(0, head)}${omission}${text.slice(text.length - (available - head))}`;
382
546
  }
383
547
 
384
- type ToolInfoLike = Pick<Tool, "name" | "description" | "parameters">;
548
+ type ToolInfoLike = Tool;
549
+
550
+ function toPiUsage(raw: CodexCompactionUsage, model: Model<any>, tier: unknown): Usage {
551
+ const cacheRead = raw.cachedInputTokens ?? 0;
552
+ const cacheWrite = raw.cacheWriteInputTokens ?? 0;
553
+ const output = raw.outputTokens ?? 0;
554
+ const usage: Usage = {
555
+ input: Math.max(0, (raw.inputTokens ?? 0) - cacheRead - cacheWrite),
556
+ output,
557
+ cacheRead,
558
+ cacheWrite,
559
+ ...(raw.reasoningTokens !== undefined ? { reasoning: raw.reasoningTokens } : {}),
560
+ totalTokens: raw.totalTokens ?? (raw.inputTokens ?? 0) + output,
561
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
562
+ };
563
+ calculateCost(model, usage);
564
+ // Match Pi 0.85.1's catalog estimates, not subscription credit accounting.
565
+ const multiplier = tier === "priority" ? (model.id === "gpt-5.5" ? 2.5 : 2) : tier === "flex" ? 0.5 : 1;
566
+ for (const key of ["input", "output", "cacheRead", "cacheWrite", "total"] as const) usage.cost[key] *= multiplier;
567
+ return usage;
568
+ }
385
569
 
386
570
  function isRecord(value: unknown): value is Record<string, unknown> {
387
571
  return typeof value === "object" && value !== null && !Array.isArray(value);