pi-codex-compaction 0.1.1 → 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,16 @@ 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
+
9
19
  ## [0.1.1] - 2026-08-06
10
20
 
11
21
  ### Fixed
package/README.md CHANGED
@@ -13,7 +13,7 @@ Keep long Pi sessions usable on OpenAI Codex models by replacing Pi's local summ
13
13
  - Falls back to standard Pi compaction on failure or when custom compaction instructions are requested.
14
14
  - Keeps a bounded readable transcript excerpt so switching models or providers remains usable.
15
15
 
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.
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.
17
17
 
18
18
  ## Installation
19
19
 
@@ -43,6 +43,45 @@ If the remote request fails, is cancelled, or returns an unexpected response, Pi
43
43
 
44
44
  ## Development
45
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
+
46
85
  ```bash
47
86
  npm install
48
87
  npm run -w packages/pi-codex-compaction check
package/SECURITY.md CHANGED
@@ -25,4 +25,14 @@ The extension bounds request, compaction input, and response size, validates the
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.
@@ -23,10 +23,21 @@ export default function piCodexCompaction(pi: ExtensionAPI): void {
23
23
  try {
24
24
  const compaction = await createRemoteCompaction(event, ctx, () => {
25
25
  const active = new Set(pi.getActiveTools());
26
- return pi.getAllTools()
27
- .filter((tool) => active.has(tool.name))
28
- .map(({ name, description, parameters }) => ({ name, description, parameters }));
29
- }, pi.getThinkingLevel?.());
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;
40
+ });
30
41
  return compaction ? { compaction } : undefined;
31
42
  } catch {
32
43
  if (!event.signal.aborted && ctx.hasUI) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-codex-compaction",
3
- "version": "0.1.1",
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.82.1",
56
- "@earendil-works/pi-coding-agent": "^0.82.1",
57
- "@types/node": "^26.1.2",
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
@@ -5,6 +5,7 @@ const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
5
5
  const MAX_ENCRYPTED_CONTENT_CHARS = 2_000_000;
6
6
  const REQUEST_HEADER_TIMEOUT_MS = 30_000;
7
7
  const REQUEST_IDLE_TIMEOUT_MS = 120_000;
8
+ const REQUEST_TOTAL_TIMEOUT_MS = 300_000;
8
9
  const MAX_RETRIES = 2;
9
10
  const RETRY_BASE_DELAY_MS = 200;
10
11
  const MAX_RETRY_DELAY_MS = 30_000;
@@ -16,10 +17,10 @@ export interface CodexCompactionRequest {
16
17
  model: {
17
18
  id: string;
18
19
  baseUrl: string;
19
- headers?: Record<string, string>;
20
+ headers?: Record<string, string | null>;
20
21
  };
21
22
  apiKey: string;
22
- authHeaders?: Record<string, string>;
23
+ authHeaders?: Record<string, string | null>;
23
24
  sessionId?: string;
24
25
  body: Record<string, unknown>;
25
26
  signal?: AbortSignal;
@@ -30,12 +31,14 @@ export interface CodexCompactionUsage {
30
31
  outputTokens?: number;
31
32
  totalTokens?: number;
32
33
  cachedInputTokens?: number;
34
+ cacheWriteInputTokens?: number;
33
35
  reasoningTokens?: number;
34
36
  }
35
37
 
36
38
  export interface CodexCompactionResult {
37
39
  encryptedContent: string;
38
40
  usage?: CodexCompactionUsage;
41
+ serviceTier?: "default" | "priority" | "flex";
39
42
  }
40
43
 
41
44
  /**
@@ -49,6 +52,7 @@ export async function requestRemoteCompaction(request: CodexCompactionRequest):
49
52
  export async function requestRemoteCompactionWithUsage(
50
53
  request: CodexCompactionRequest,
51
54
  ): Promise<CodexCompactionResult> {
55
+ request.signal?.throwIfAborted();
52
56
  const endpoint = resolveCodexResponsesUrl(request.model.baseUrl);
53
57
  const accountId = extractAccountId(request.apiKey);
54
58
  const headers = buildHeaders(request, accountId);
@@ -60,6 +64,7 @@ export async function requestRemoteCompactionWithUsage(
60
64
  const requestState = requestSignal(request.signal);
61
65
  try {
62
66
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
67
+ requestState.signal.throwIfAborted();
63
68
  requestState.startAttempt();
64
69
 
65
70
  try {
@@ -116,8 +121,14 @@ export function resolveCodexResponsesUrl(baseUrl: string): string {
116
121
  if (url.username || url.password) {
117
122
  throw new Error("Codex compaction endpoint must not contain URL credentials");
118
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
+ }
119
127
 
120
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
+ }
121
132
  if (path.endsWith("/codex/responses")) {
122
133
  return url.toString();
123
134
  }
@@ -140,14 +151,20 @@ export function parseCompactionSseResult(sse: string): CodexCompactionResult {
140
151
 
141
152
  function buildHeaders(request: CodexCompactionRequest, accountId: string): Headers {
142
153
  const headers = new Headers();
143
- for (const [name, value] of Object.entries(request.model.headers ?? {})) headers.set(name, value);
144
- 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
+ }
145
161
 
146
162
  headers.set("Authorization", `Bearer ${request.apiKey}`);
147
163
  headers.set("chatgpt-account-id", accountId);
148
164
  headers.set("originator", "pi");
149
165
  headers.set("OpenAI-Beta", "responses=experimental");
150
- 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(","));
151
168
  headers.set("accept", "text/event-stream");
152
169
  headers.set("content-type", "application/json");
153
170
 
@@ -208,6 +225,7 @@ async function readCompactionSse(
208
225
  throw new CodexCompactionError("Codex compaction response exceeded the size limit", false);
209
226
  }
210
227
  parser.push(decoder.decode(value, { stream: true }));
228
+ if (parser.isComplete) return parser.finish();
211
229
  }
212
230
  parser.push(decoder.decode());
213
231
  return parser.finish();
@@ -227,6 +245,11 @@ class CompactionSseParser {
227
245
  private status: string | undefined;
228
246
  private compactionItems: Array<string | undefined> = [];
229
247
  private usage: CodexCompactionUsage | undefined;
248
+ private serviceTier: CodexCompactionResult["serviceTier"];
249
+
250
+ get isComplete(): boolean {
251
+ return this.completed;
252
+ }
230
253
 
231
254
  push(chunk: string): void {
232
255
  this.buffer += chunk;
@@ -262,6 +285,7 @@ class CompactionSseParser {
262
285
  return {
263
286
  encryptedContent,
264
287
  ...(this.usage ? { usage: this.usage } : {}),
288
+ ...(this.serviceTier ? { serviceTier: this.serviceTier } : {}),
265
289
  };
266
290
  }
267
291
 
@@ -294,6 +318,8 @@ class CompactionSseParser {
294
318
  const response = isRecord(event.response) ? event.response : undefined;
295
319
  this.status = typeof response?.status === "string" ? response.status : undefined;
296
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;
297
323
 
298
324
  // Codex emits output_item.done before response.completed. Retain this
299
325
  // fallback for compact Responses implementations that only return the
@@ -331,12 +357,14 @@ function parseUsage(value: unknown): CodexCompactionUsage | undefined {
331
357
  const inputDetails = isRecord(value.input_tokens_details) ? value.input_tokens_details : undefined;
332
358
  const outputDetails = isRecord(value.output_tokens_details) ? value.output_tokens_details : undefined;
333
359
  const cachedInputTokens = finiteNonNegativeNumber(inputDetails?.cached_tokens);
360
+ const cacheWriteInputTokens = finiteNonNegativeNumber(inputDetails?.cache_write_tokens);
334
361
  const reasoningTokens = finiteNonNegativeNumber(outputDetails?.reasoning_tokens);
335
362
  if (
336
363
  inputTokens === undefined &&
337
364
  outputTokens === undefined &&
338
365
  totalTokens === undefined &&
339
366
  cachedInputTokens === undefined &&
367
+ cacheWriteInputTokens === undefined &&
340
368
  reasoningTokens === undefined
341
369
  ) {
342
370
  return undefined;
@@ -346,6 +374,7 @@ function parseUsage(value: unknown): CodexCompactionUsage | undefined {
346
374
  ...(outputTokens !== undefined ? { outputTokens } : {}),
347
375
  ...(totalTokens !== undefined ? { totalTokens } : {}),
348
376
  ...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}),
377
+ ...(cacheWriteInputTokens !== undefined ? { cacheWriteInputTokens } : {}),
349
378
  ...(reasoningTokens !== undefined ? { reasoningTokens } : {}),
350
379
  };
351
380
  }
@@ -428,6 +457,10 @@ interface RequestSignal {
428
457
 
429
458
  function requestSignal(signal: AbortSignal | undefined): RequestSignal {
430
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
+ );
431
464
  let headerTimeout: ReturnType<typeof setTimeout> | undefined;
432
465
  let idleTimeout: ReturnType<typeof setTimeout> | undefined;
433
466
 
@@ -468,6 +501,7 @@ function requestSignal(signal: AbortSignal | undefined): RequestSignal {
468
501
  },
469
502
  touch,
470
503
  cleanup: () => {
504
+ clearTimeout(totalTimeout);
471
505
  clearTimers();
472
506
  signal?.removeEventListener("abort", onAbort);
473
507
  },
@@ -1,5 +1,5 @@
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";
@@ -95,11 +95,13 @@ export async function createRemoteCompaction(
95
95
  ctx: ExtensionContext,
96
96
  getTools: () => readonly ToolInfoLike[],
97
97
  thinkingLevel?: string,
98
+ prepareRequest?: (payload: Record<string, unknown>, messages: readonly AgentMessage[]) => Record<string, unknown>,
98
99
  ): Promise<{
99
100
  summary: string;
100
101
  firstKeptEntryId: string;
101
102
  tokensBefore: number;
102
103
  details: RemoteCompactionDetails;
104
+ usage?: Usage;
103
105
  } | undefined> {
104
106
  // Pi's custom focus is part of the standard summarizer contract. The
105
107
  // Responses compaction envelope has no documented equivalent, so do not
@@ -139,16 +141,20 @@ export async function createRemoteCompaction(
139
141
  const providerTools = Array.isArray(providerPayload.tools) ? providerPayload.tools : [];
140
142
 
141
143
  const input = appendCompactionItems(providerInput, event.preparation, compatiblePrevious);
142
- const requestBody = {
144
+ const requestBody = prepareRequest?.({
143
145
  ...providerPayload,
146
+ input,
147
+ }, messages) ?? { ...providerPayload, input };
148
+ Object.assign(requestBody, {
144
149
  model: model.id,
145
150
  store: false,
146
151
  stream: true,
147
- };
152
+ });
153
+ if (!Array.isArray(requestBody.input)) return undefined;
148
154
  const boundedInput = boundCompactionInput(
149
- input,
155
+ requestBody.input,
150
156
  instructions,
151
- providerTools,
157
+ Array.isArray(requestBody.tools) ? requestBody.tools : providerTools,
152
158
  model.contextWindow,
153
159
  requestBody,
154
160
  );
@@ -171,6 +177,11 @@ export async function createRemoteCompaction(
171
177
  summary: `${REMOTE_SUMMARY_MARKER}\n\n${fallback}`,
172
178
  firstKeptEntryId: event.preparation.firstKeptEntryId,
173
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
+ ) } : {}),
174
185
  details: {
175
186
  kind: REMOTE_COMPACTION_KIND,
176
187
  version: 2,
@@ -247,7 +258,7 @@ async function captureCodexPayload(
247
258
  messages: ReturnType<typeof convertToLlm>,
248
259
  tools: readonly ToolInfoLike[],
249
260
  apiKey: string,
250
- authHeaders: Record<string, string> | undefined,
261
+ authHeaders: Record<string, string | null> | undefined,
251
262
  sessionId: string,
252
263
  signal: AbortSignal,
253
264
  thinkingLevel: string | undefined,
@@ -534,7 +545,27 @@ function limitText(text: string, maxChars: number): string {
534
545
  return `${text.slice(0, head)}${omission}${text.slice(text.length - (available - head))}`;
535
546
  }
536
547
 
537
- 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
+ }
538
569
 
539
570
  function isRecord(value: unknown): value is Record<string, unknown> {
540
571
  return typeof value === "object" && value !== null && !Array.isArray(value);