@lll9p/pi-better-compaction 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,7 @@ import type {
6
6
  SessionBeforeCompactEvent,
7
7
  } from "@earendil-works/pi-coding-agent";
8
8
  import { executeNativeCompaction } from "./compact-client";
9
+ import { executeV2Compaction } from "./compact-client-v2";
9
10
  import { loadExtensionConfig } from "./config";
10
11
  import { writeDebugArtifact } from "./debug";
11
12
  import { resolveLatestNativeCompactionEntry } from "./details-store";
@@ -15,6 +16,7 @@ import {
15
16
  serializeLiveTailToResponsesInput,
16
17
  } from "./payload-rewrite";
17
18
  import { getCompactionRequestExtras, rememberRequestContext } from "./request-context-cache";
19
+ import { buildRetainedMessages } from "./retained-messages";
18
20
  import {
19
21
  isResponsesCompatiblePayload,
20
22
  resolveNativeCompactionEnvironment,
@@ -26,6 +28,8 @@ import {
26
28
  createNativeCompactionResult,
27
29
  EXTENSION_ID,
28
30
  isNativeCompactionDetails,
31
+ NATIVE_COMPACTION_STRATEGY,
32
+ NATIVE_COMPACTION_STRATEGY_V2,
29
33
  type ExtensionConfig,
30
34
  type NativeCompactionDetails,
31
35
  type NativeCompactionRequestMeta,
@@ -90,7 +94,7 @@ function buildCompactionInstructions(systemPrompt: string, customInstructions?:
90
94
  return `${systemPrompt}\n\nAdditional user guidance for this manual /compact request:\n${guidance}`;
91
95
  }
92
96
 
93
- async function runResponsesNativeCompact(
97
+ async function runResponsesV1Compact(
94
98
  event: SessionBeforeCompactEvent,
95
99
  ctx: ExtensionContext,
96
100
  config: ExtensionConfig,
@@ -135,7 +139,7 @@ async function runResponsesNativeCompact(
135
139
  writeDebugArtifact(
136
140
  "compaction-event",
137
141
  {
138
- event: "session_before_compact.responses-compact-skip",
142
+ event: "session_before_compact.v1-compact-skip",
139
143
  reason: latestNativeCompaction.reason,
140
144
  provider: runtime.provider,
141
145
  api: runtime.api,
@@ -169,7 +173,7 @@ async function runResponsesNativeCompact(
169
173
  writeDebugArtifact(
170
174
  "compaction-event",
171
175
  {
172
- event: "session_before_compact.responses-compact-failure",
176
+ event: "session_before_compact.v1-compact-failure",
173
177
  reason: compactResult.reason,
174
178
  status: compactResult.status,
175
179
  errorMessage: compactResult.errorMessage,
@@ -196,7 +200,7 @@ async function runResponsesNativeCompact(
196
200
  writeDebugArtifact(
197
201
  "compaction-event",
198
202
  {
199
- event: "session_before_compact.invalid-native-details",
203
+ event: "session_before_compact.v1-invalid-native-details",
200
204
  reason: error instanceof Error ? error.message : String(error),
201
205
  provider: runtime.provider,
202
206
  api: runtime.api,
@@ -219,7 +223,7 @@ async function runResponsesNativeCompact(
219
223
  writeDebugArtifact(
220
224
  "compaction-event",
221
225
  {
222
- event: "session_before_compact.responses-compact-success",
226
+ event: "session_before_compact.v1-compact-success",
223
227
  provider: runtime.provider,
224
228
  api: runtime.api,
225
229
  model: runtime.model,
@@ -238,6 +242,164 @@ async function runResponsesNativeCompact(
238
242
  return { outcome: "success", compaction };
239
243
  }
240
244
 
245
+ /**
246
+ * V2 compaction: stream a Responses request with compaction_trigger appended.
247
+ * On success, returns retained messages + encrypted compaction blob.
248
+ */
249
+ async function runResponsesV2Compact(
250
+ event: SessionBeforeCompactEvent,
251
+ ctx: ExtensionContext,
252
+ config: ExtensionConfig,
253
+ runtime: NativeCompactionRuntime,
254
+ ): Promise<ResponsesCompactOutcome> {
255
+ const instructions = buildCompactionInstructions(ctx.getSystemPrompt(), event.customInstructions);
256
+ const branchEntries = ctx.sessionManager.getBranch();
257
+ const latestNativeCompaction = resolveLatestNativeCompactionEntry(branchEntries, {
258
+ provider: runtime.provider,
259
+ api: runtime.api,
260
+ model: runtime.model,
261
+ baseUrl: runtime.baseUrl,
262
+ });
263
+
264
+ let requestSource: "session-context" | "non-native-session-context" | "latest-native-replay";
265
+ let request: NativeCompactionRequestBody;
266
+ if (latestNativeCompaction.ok) {
267
+ const liveTailEntries = branchEntries.slice(latestNativeCompaction.index + 1);
268
+ requestSource = "latest-native-replay";
269
+ const input: ResponsesInputItem[] = [
270
+ ...(cloneOpaqueWindow(latestNativeCompaction.entry.details.compactedWindow) as ResponsesInputItem[]),
271
+ ...serializeLiveTailToResponsesInput({ model: runtime.currentModel, entries: liveTailEntries }),
272
+ ];
273
+ request = {
274
+ model: runtime.currentModel.id,
275
+ input,
276
+ instructions,
277
+ };
278
+ } else if (
279
+ latestNativeCompaction.reason === "no-compaction" ||
280
+ (latestNativeCompaction.reason === "latest-compaction-not-native" &&
281
+ config.allowCompactionContinuityBreak)
282
+ ) {
283
+ requestSource =
284
+ latestNativeCompaction.reason === "no-compaction" ? "session-context" : "non-native-session-context";
285
+ request = serializeMessagesToCompactRequest({
286
+ model: runtime.currentModel,
287
+ messages: ctx.sessionManager.buildSessionContext().messages,
288
+ instructions,
289
+ });
290
+ } else {
291
+ writeDebugArtifact(
292
+ "compaction-event",
293
+ {
294
+ event: "session_before_compact.v2-compact-skip",
295
+ reason: latestNativeCompaction.reason,
296
+ provider: runtime.provider,
297
+ api: runtime.api,
298
+ model: runtime.model,
299
+ baseUrl: runtime.baseUrl,
300
+ latestCompactionIndex: latestNativeCompaction.latestCompactionIndex,
301
+ latestCompactionIdentity: getCompactionIdentityDebugInfo(latestNativeCompaction.latestCompaction),
302
+ },
303
+ config,
304
+ ctx,
305
+ );
306
+ return { outcome: "failed" };
307
+ }
308
+
309
+ const extras = getCompactionRequestExtras(runtime.model, getSessionId(ctx));
310
+ if (extras) {
311
+ request = { ...request, ...extras };
312
+ }
313
+
314
+ const v2Result = await executeV2Compaction({
315
+ runtime,
316
+ request,
317
+ signal: event.signal,
318
+ settings: config,
319
+ context: ctx,
320
+ });
321
+
322
+ if (!v2Result.ok) {
323
+ writeDebugArtifact(
324
+ "compaction-event",
325
+ {
326
+ event: "session_before_compact.v2-compact-failure",
327
+ reason: v2Result.reason,
328
+ status: v2Result.status,
329
+ errorMessage: v2Result.errorMessage,
330
+ },
331
+ config,
332
+ ctx,
333
+ );
334
+ return v2Result.reason === "aborted" ? { outcome: "aborted" } : { outcome: "failed" };
335
+ }
336
+
337
+ // Build compacted window: retained messages + compaction blob.
338
+ const retainedMessages = buildRetainedMessages(request.input);
339
+ const compactedWindow = [...retainedMessages, v2Result.compactionItem];
340
+
341
+ let details: NativeCompactionDetails;
342
+ try {
343
+ details = createNativeCompactionDetails(
344
+ {
345
+ provider: runtime.provider,
346
+ api: runtime.api,
347
+ model: runtime.model,
348
+ baseUrl: runtime.baseUrl,
349
+ compactedWindow,
350
+ compactResponseId: v2Result.responseId,
351
+ createdAt: v2Result.createdAt,
352
+ requestMeta: buildCompactionRequestMeta(event),
353
+ },
354
+ NATIVE_COMPACTION_STRATEGY_V2,
355
+ );
356
+ } catch (error) {
357
+ writeDebugArtifact(
358
+ "compaction-event",
359
+ {
360
+ event: "session_before_compact.v2-invalid-native-details",
361
+ reason: error instanceof Error ? error.message : String(error),
362
+ provider: runtime.provider,
363
+ api: runtime.api,
364
+ model: runtime.model,
365
+ baseUrl: runtime.baseUrl,
366
+ },
367
+ config,
368
+ ctx,
369
+ );
370
+ return { outcome: "failed" };
371
+ }
372
+
373
+ // V2 blob is encrypted; no summary text can be extracted.
374
+ const compaction = createNativeCompactionResult({
375
+ firstKeptEntryId: event.preparation.firstKeptEntryId,
376
+ tokensBefore: event.preparation.tokensBefore,
377
+ details,
378
+ });
379
+
380
+ writeDebugArtifact(
381
+ "compaction-event",
382
+ {
383
+ event: "session_before_compact.v2-compact-success",
384
+ provider: runtime.provider,
385
+ api: runtime.api,
386
+ model: runtime.model,
387
+ requestSource,
388
+ requestInputItems: request.input.length,
389
+ requestExtras: extras ? Object.keys(extras) : [],
390
+ compactResponseId: v2Result.responseId,
391
+ retainedMessageCount: retainedMessages.length,
392
+ compactedItems: compactedWindow.length,
393
+ usage: v2Result.usage,
394
+ firstKeptEntryId: event.preparation.firstKeptEntryId,
395
+ },
396
+ config,
397
+ ctx,
398
+ );
399
+
400
+ return { outcome: "success", compaction };
401
+ }
402
+
241
403
  async function handleSessionBeforeCompact(event: SessionBeforeCompactEvent, ctx: ExtensionContext) {
242
404
  const { config } = loadExtensionConfig();
243
405
  if (!config.enabled) {
@@ -271,7 +433,14 @@ async function handleSessionBeforeCompact(event: SessionBeforeCompactEvent, ctx:
271
433
  responsesCompactApis: config.responsesCompactApis,
272
434
  });
273
435
  if (resolution.ok) {
274
- const responsesOutcome = await runResponsesNativeCompact(event, ctx, config, resolution.runtime);
436
+ let responsesOutcome: ResponsesCompactOutcome;
437
+
438
+ if (config.compactionVersion === "v2") {
439
+ responsesOutcome = await runResponsesV2Compact(event, ctx, config, resolution.runtime);
440
+ } else {
441
+ responsesOutcome = await runResponsesV1Compact(event, ctx, config, resolution.runtime);
442
+ }
443
+
275
444
  if (responsesOutcome.outcome === "success") {
276
445
  return { compaction: responsesOutcome.compaction };
277
446
  }
@@ -296,7 +465,7 @@ async function handleSessionBeforeCompact(event: SessionBeforeCompactEvent, ctx:
296
465
  }
297
466
 
298
467
  // Branch 2: run pi's native compaction method with the configured model.
299
- const fallback = await runNativeFallbackCompaction({ ctx, event, config });
468
+ const fallback = await runNativeFallbackCompaction({ ctx, event, config, sessionId: getSessionId(ctx) });
300
469
  if (fallback.ok) {
301
470
  if (ctx.hasUI) {
302
471
  ctx.ui.notify(
@@ -309,6 +478,7 @@ async function handleSessionBeforeCompact(event: SessionBeforeCompactEvent, ctx:
309
478
  {
310
479
  event: "session_before_compact.fallback-success",
311
480
  model: fallback.model,
481
+ usage: fallback.usage,
312
482
  },
313
483
  config,
314
484
  ctx,
@@ -501,4 +671,23 @@ export default function (pi: ExtensionAPI) {
501
671
 
502
672
  pi.on("session_before_compact", handleSessionBeforeCompact);
503
673
  pi.on("before_provider_request", handleBeforeProviderRequest);
674
+
675
+ pi.on("session_compact_failed", (event, ctx) => {
676
+ const { config } = loadExtensionConfig();
677
+ if (!config.enabled) return;
678
+
679
+ writeDebugArtifact(
680
+ "compaction-event",
681
+ {
682
+ event: "session_compact_failed",
683
+ reason: event.reason,
684
+ errorMessage: event.errorMessage,
685
+ aborted: event.aborted,
686
+ willRetry: event.willRetry,
687
+ fromExtension: event.fromExtension,
688
+ },
689
+ config,
690
+ ctx,
691
+ );
692
+ });
504
693
  }
@@ -26,6 +26,7 @@ export type NativeFallbackResult =
26
26
  ok: true;
27
27
  result: CompactionResult;
28
28
  model: { provider: string; id: string };
29
+ usage?: CompactionResult["usage"];
29
30
  }
30
31
  | {
31
32
  ok: false;
@@ -38,9 +39,21 @@ export type NativeFallbackResult =
38
39
  export type NativeCompactFn = typeof compact;
39
40
 
40
41
  type ResolvedAuth =
41
- | { ok: true; apiKey?: string; headers?: Record<string, string>; env?: Record<string, string> }
42
+ | { ok: true; apiKey?: string; headers?: Record<string, string | null>; env?: Record<string, string> }
42
43
  | { ok: false; error: string };
43
44
 
45
+ /** Strip null-valued entries so downstream consumers receive a clean Record<string, string>. */
46
+ function filterNullHeaders(headers: Record<string, string | null> | undefined): Record<string, string> | undefined {
47
+ if (!headers) return undefined;
48
+ const filtered: Record<string, string> = {};
49
+ for (const [key, value] of Object.entries(headers)) {
50
+ if (value !== null) {
51
+ filtered[key] = value;
52
+ }
53
+ }
54
+ return Object.keys(filtered).length > 0 ? filtered : undefined;
55
+ }
56
+
44
57
  /** Parse "provider/model-id" (model ids may themselves contain slashes). */
45
58
  export function parseModelSpec(spec: string): ParsedModelSpec | undefined {
46
59
  const trimmed = spec.trim();
@@ -82,6 +95,7 @@ export async function runNativeFallbackCompaction(args: {
82
95
  event: SessionBeforeCompactEvent;
83
96
  config: ExtensionConfig;
84
97
  compactFn?: NativeCompactFn;
98
+ sessionId?: string;
85
99
  }): Promise<NativeFallbackResult> {
86
100
  const { ctx, event, config } = args;
87
101
  const compactFn = args.compactFn ?? compact;
@@ -120,12 +134,15 @@ export async function runNativeFallbackCompaction(args: {
120
134
  event.preparation,
121
135
  model,
122
136
  auth.apiKey,
123
- auth.headers,
137
+ filterNullHeaders(auth.headers),
124
138
  event.customInstructions,
125
139
  event.signal,
126
140
  config.compactionThinkingLevel,
127
- undefined,
141
+ undefined, // streamFn
128
142
  auth.env,
143
+ undefined, // retry (use pi defaults)
144
+ undefined, // callbacks
145
+ args.sessionId,
129
146
  );
130
147
 
131
148
  if (event.signal.aborted) {
@@ -139,6 +156,7 @@ export async function runNativeFallbackCompaction(args: {
139
156
  ok: true,
140
157
  result,
141
158
  model: { provider: model.provider, id: model.id },
159
+ usage: result.usage,
142
160
  };
143
161
  } catch (error) {
144
162
  if (event.signal.aborted || isAbortError(error)) {
@@ -0,0 +1,98 @@
1
+ /**
2
+ * V2 compaction retained-message filtering.
3
+ *
4
+ * After the API returns an encrypted compaction blob, V2 keeps a window of recent
5
+ * user/developer/system messages alongside the blob so the model retains explicit
6
+ * user instructions and context anchors. This module mirrors the filtering and
7
+ * budget logic from codex-rs `compact_remote_v2.rs`.
8
+ */
9
+
10
+ /** Token budget for retained messages (matches codex-rs RETAINED_MESSAGE_TOKEN_BUDGET). */
11
+ export const RETAINED_MESSAGE_TOKEN_BUDGET = 65_536;
12
+
13
+ /** Messages larger than this are excluded even if they are of a retained role. */
14
+ const MAX_SINGLE_ITEM_TOKENS = 10_000;
15
+
16
+ /** Rough chars-per-token ratio for budget estimation. */
17
+ const CHARS_PER_TOKEN = 4;
18
+
19
+ function isRecord(value: unknown): value is Record<string, unknown> {
20
+ return !!value && typeof value === "object" && !Array.isArray(value);
21
+ }
22
+
23
+ /**
24
+ * Estimate the token count of an opaque input item by serializing to JSON and
25
+ * dividing by the chars-per-token ratio. This is intentionally rough — codex-rs
26
+ * uses a real tokenizer but we don't have one in the extension runtime.
27
+ */
28
+ export function estimateItemTokens(item: unknown): number {
29
+ try {
30
+ const length = JSON.stringify(item).length;
31
+ return Math.ceil(length / CHARS_PER_TOKEN);
32
+ } catch {
33
+ return 0;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Whether an input item should be retained alongside the compaction blob.
39
+ *
40
+ * Retained roles (matching codex-rs `is_retained_for_remote_compaction_v2`):
41
+ * - `user` messages
42
+ * - `developer` messages
43
+ * - `system` messages
44
+ *
45
+ * Everything else (assistant, function_call, function_call_output, reasoning,
46
+ * compaction, etc.) is excluded.
47
+ */
48
+ export function isRetainedItem(item: unknown): boolean {
49
+ if (!isRecord(item)) {
50
+ return false;
51
+ }
52
+
53
+ const role = item.role;
54
+ if (typeof role === "string") {
55
+ return role === "user" || role === "developer" || role === "system";
56
+ }
57
+
58
+ return false;
59
+ }
60
+
61
+ /**
62
+ * From a list of Responses API input items, select the most recent retained
63
+ * messages that fit within the token budget. Items are selected newest-first
64
+ * (reverse order) and returned in their original chronological order.
65
+ *
66
+ * Oversized individual items (> MAX_SINGLE_ITEM_TOKENS) are skipped.
67
+ */
68
+ export function buildRetainedMessages(
69
+ input: readonly unknown[],
70
+ budget: number = RETAINED_MESSAGE_TOKEN_BUDGET,
71
+ ): unknown[] {
72
+ // Filter to retained roles first.
73
+ const retained: Array<{ index: number; item: unknown; tokens: number }> = [];
74
+ for (let i = 0; i < input.length; i++) {
75
+ const item = input[i];
76
+ if (!isRetainedItem(item)) continue;
77
+
78
+ const tokens = estimateItemTokens(item);
79
+ if (tokens > MAX_SINGLE_ITEM_TOKENS) continue;
80
+
81
+ retained.push({ index: i, item, tokens });
82
+ }
83
+
84
+ // Select from newest to oldest within the budget.
85
+ let remaining = budget;
86
+ const selected: typeof retained = [];
87
+
88
+ for (let i = retained.length - 1; i >= 0; i--) {
89
+ const entry = retained[i]!;
90
+ if (entry.tokens > remaining) break;
91
+ remaining -= entry.tokens;
92
+ selected.push(entry);
93
+ }
94
+
95
+ // Return in original chronological order.
96
+ selected.reverse();
97
+ return selected.map((entry) => structuredClone(entry.item));
98
+ }
package/src/runtime.ts CHANGED
@@ -4,6 +4,8 @@ import { RESPONSES_COMPACT_CAPABLE_APIS } from "./types";
4
4
 
5
5
  const OPENAI_COMPACT_PATH = "responses/compact";
6
6
  const CODEX_COMPACT_PATH = "codex/responses/compact";
7
+ const OPENAI_RESPONSES_PATH = "responses";
8
+ const CODEX_RESPONSES_PATH = "codex/responses";
7
9
 
8
10
  type ResponsesCompactApi = (typeof RESPONSES_COMPACT_CAPABLE_APIS)[number];
9
11
 
@@ -40,6 +42,7 @@ export type NativeCompactionRuntime = {
40
42
  headers?: Record<string, string>;
41
43
  compactPath: string;
42
44
  compactUrl: string;
45
+ responsesUrl: string;
43
46
  payload?: ResponsesCompatibleRequestPayload;
44
47
  currentModel: RuntimeModel;
45
48
  };
@@ -74,6 +77,29 @@ export function normalizeBaseUrl(baseUrl: string | undefined | null): string | u
74
77
  return normalized ? normalized : undefined;
75
78
  }
76
79
 
80
+ function buildOpenAIResponsesUrl(baseUrl: string): string {
81
+ const normalized = normalizeBaseUrl(baseUrl) ?? baseUrl;
82
+ if (normalized.endsWith("/responses")) {
83
+ return normalized;
84
+ }
85
+ return `${normalized}/${OPENAI_RESPONSES_PATH}`;
86
+ }
87
+
88
+ function buildCodexResponsesUrl(baseUrl: string): string {
89
+ const normalized = normalizeBaseUrl(baseUrl) ?? baseUrl;
90
+ if (normalized.endsWith("/codex/responses")) {
91
+ return normalized;
92
+ }
93
+ if (normalized.endsWith("/codex")) {
94
+ return `${normalized}/responses`;
95
+ }
96
+ return `${normalized}/${CODEX_RESPONSES_PATH}`;
97
+ }
98
+
99
+ export function buildResponsesUrl(baseUrl: string, api: ResponsesCompactApi): string {
100
+ return api === "openai-codex-responses" ? buildCodexResponsesUrl(baseUrl) : buildOpenAIResponsesUrl(baseUrl);
101
+ }
102
+
77
103
  function buildOpenAICompactUrl(baseUrl: string): string {
78
104
  const normalized = normalizeBaseUrl(baseUrl) ?? baseUrl;
79
105
  if (normalized.endsWith("/responses")) {
@@ -101,13 +127,25 @@ export function buildCompactPath(api: ResponsesCompactApi): string {
101
127
  return api === "openai-codex-responses" ? CODEX_COMPACT_PATH : OPENAI_COMPACT_PATH;
102
128
  }
103
129
 
130
+ /** Strip null-valued entries so downstream consumers receive a clean Record<string, string>. */
131
+ function filterNullHeaders(headers: Record<string, string | null> | undefined): Record<string, string> | undefined {
132
+ if (!headers) return undefined;
133
+ const filtered: Record<string, string> = {};
134
+ for (const [key, value] of Object.entries(headers)) {
135
+ if (value !== null) {
136
+ filtered[key] = value;
137
+ }
138
+ }
139
+ return Object.keys(filtered).length > 0 ? filtered : undefined;
140
+ }
141
+
104
142
  async function resolveRequestAuth(
105
143
  ctx: ExtensionContext,
106
144
  model: RuntimeModel,
107
145
  ): Promise<{ apiKey?: string; headers?: Record<string, string> }> {
108
146
  const modelRegistry = ctx.modelRegistry as {
109
147
  getApiKeyAndHeaders?: (currentModel: RuntimeModel) => Promise<
110
- | { ok: true; apiKey?: string; headers?: Record<string, string> }
148
+ | { ok: true; apiKey?: string; headers?: Record<string, string | null> }
111
149
  | { ok: false; error: string }
112
150
  >;
113
151
  };
@@ -117,7 +155,7 @@ async function resolveRequestAuth(
117
155
  }
118
156
 
119
157
  const auth = await modelRegistry.getApiKeyAndHeaders(model);
120
- return auth.ok ? { apiKey: auth.apiKey, headers: auth.headers } : {};
158
+ return auth.ok ? { apiKey: auth.apiKey, headers: filterNullHeaders(auth.headers) } : {};
121
159
  }
122
160
 
123
161
  export function isSupportedApi(api: string): api is ResponsesCompactApi {
@@ -234,6 +272,7 @@ export async function resolveNativeCompactionEnvironment(
234
272
  headers,
235
273
  compactPath: buildCompactPath(descriptor.api),
236
274
  compactUrl: buildCompactUrl(descriptor.baseUrl, descriptor.api),
275
+ responsesUrl: buildResponsesUrl(descriptor.baseUrl, descriptor.api),
237
276
  payload: requestPayload,
238
277
  currentModel,
239
278
  },
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Shared HTTP header construction for V1 and V2 compaction clients.
3
+ *
4
+ * Extracts the header building logic that was previously private in
5
+ * compact-client.ts so both compact-client.ts and compact-client-v2.ts
6
+ * can share it without duplication.
7
+ */
8
+
9
+ import type { NativeCompactionRuntime } from "./runtime";
10
+
11
+ const JSON_CONTENT_TYPE = "application/json";
12
+
13
+ function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
14
+ const parts = token.split(".");
15
+ if (parts.length !== 3) {
16
+ return undefined;
17
+ }
18
+
19
+ try {
20
+ const payloadText = Buffer.from(parts[1]!, "base64url").toString("utf8");
21
+ const payload = JSON.parse(payloadText);
22
+ return payload && typeof payload === "object" && !Array.isArray(payload)
23
+ ? (payload as Record<string, unknown>)
24
+ : undefined;
25
+ } catch {
26
+ return undefined;
27
+ }
28
+ }
29
+
30
+ function isRecord(value: unknown): value is Record<string, unknown> {
31
+ return !!value && typeof value === "object" && !Array.isArray(value);
32
+ }
33
+
34
+ function extractCodexAccountId(token: string): string | undefined {
35
+ const payload = decodeJwtPayload(token);
36
+ const authClaims = payload?.["https://api.openai.com/auth"];
37
+ if (!isRecord(authClaims)) {
38
+ return undefined;
39
+ }
40
+
41
+ const accountId = authClaims.chatgpt_account_id;
42
+ return typeof accountId === "string" && accountId.trim().length > 0 ? accountId.trim() : undefined;
43
+ }
44
+
45
+ function buildCodexUserAgent(): string {
46
+ const platform = typeof process !== "undefined" ? process.platform : "browser";
47
+ const arch = typeof process !== "undefined" ? process.arch : "unknown";
48
+ return `pi (${platform}; ${arch})`;
49
+ }
50
+
51
+ /**
52
+ * Build HTTP headers for a compaction request from the resolved runtime.
53
+ *
54
+ * Handles model-level headers, extension-resolved headers, authorization,
55
+ * and Codex-specific headers (account ID, originator, user-agent, beta flag).
56
+ *
57
+ * @param accept - The Accept header value. Defaults to `application/json`.
58
+ */
59
+ export function toHeaders(
60
+ runtime: NativeCompactionRuntime,
61
+ accept: string = JSON_CONTENT_TYPE,
62
+ ): Record<string, string> {
63
+ const headers = new Headers();
64
+ // Model-level headers may contain null values (ProviderHeaders); null means "unset".
65
+ for (const [key, value] of Object.entries(runtime.currentModel.headers ?? {})) {
66
+ if (value != null) {
67
+ headers.set(key, String(value));
68
+ }
69
+ }
70
+ // Extension-resolved headers (already filtered by resolveRequestAuth, but defensive).
71
+ for (const [key, value] of Object.entries(runtime.headers ?? {})) {
72
+ if (value == null) {
73
+ headers.delete(key);
74
+ } else {
75
+ headers.set(key, value);
76
+ }
77
+ }
78
+ headers.set("accept", accept);
79
+ headers.set("content-type", JSON_CONTENT_TYPE);
80
+ if (!headers.has("authorization")) {
81
+ headers.set("authorization", `Bearer ${runtime.apiKey}`);
82
+ }
83
+
84
+ if (runtime.api === "openai-codex-responses") {
85
+ const accountId = extractCodexAccountId(runtime.apiKey);
86
+ if (accountId) {
87
+ headers.set("chatgpt-account-id", accountId);
88
+ }
89
+ headers.set("originator", "pi");
90
+ headers.set("user-agent", buildCodexUserAgent());
91
+ headers.set("openai-beta", "responses=experimental");
92
+ }
93
+
94
+ return Object.fromEntries(headers.entries());
95
+ }
96
+
97
+ /** Check whether an error represents an intentional abort (AbortController / AbortSignal). */
98
+ export function isAbortError(error: unknown): boolean {
99
+ return (
100
+ (error instanceof DOMException && error.name === "AbortError") ||
101
+ (error instanceof Error && (error.name === "AbortError" || error.name === "ABORT_ERR"))
102
+ );
103
+ }