@criogaid/pi-codex-compaction 0.3.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.
@@ -0,0 +1,93 @@
1
+ // Own Codex image byte estimates and the adapter to Pi's image decoder.
2
+ // Decode serially; cancellation is checked between decodes because Pi exposes no decode abort hook.
3
+ import { createHash } from "node:crypto";
4
+ import { resizeImage } from "@earendil-works/pi-coding-agent";
5
+ import { isInputImage, type JsonObject } from "./protocol.js";
6
+ import { approximateBytesForTokens } from "./text-budget.js";
7
+
8
+ export const RESIZED_IMAGE_BYTES_ESTIMATE = 7_373;
9
+ const ORIGINAL_IMAGE_PATCH_SIZE_PX = 32;
10
+ const ORIGINAL_IMAGE_MAX_PATCHES = 10_000;
11
+ const ORIGINAL_IMAGE_ESTIMATE_CACHE_SIZE = 32;
12
+ const DIMENSION_PROBE_LIMIT = Number.MAX_SAFE_INTEGER;
13
+
14
+ /** Byte estimates for input images; original-detail inline images are decoded ahead of lookup. */
15
+ export interface ImageEstimates {
16
+ bytes(part: JsonObject): number;
17
+ }
18
+
19
+ async function inlineOriginalBytes(url: string): Promise<number | undefined> {
20
+ const comma = url.indexOf(",");
21
+ if (comma < 0 || !/^data:image\//i.test(url)) return undefined;
22
+ const [mimeType, ...parameters] = url.slice("data:".length, comma).split(";");
23
+ if (!parameters.some((parameter) => parameter.toLowerCase() === "base64")) return undefined;
24
+ const payload = url.slice(comma + 1);
25
+ const bytes = Buffer.from(payload, "base64");
26
+ // Rust's standard base64 decoder rejects noncanonical padding and trailing bits.
27
+ if (bytes.toString("base64") !== payload) return undefined;
28
+ const decoded = await resizeImage(bytes, mimeType, {
29
+ maxWidth: DIMENSION_PROBE_LIMIT, maxHeight: DIMENSION_PROBE_LIMIT, maxBytes: DIMENSION_PROBE_LIMIT,
30
+ });
31
+ if (!decoded) return undefined;
32
+ return approximateBytesForTokens(Math.min(ORIGINAL_IMAGE_MAX_PATCHES,
33
+ Math.ceil(decoded.originalWidth / ORIGINAL_IMAGE_PATCH_SIZE_PX) *
34
+ Math.ceil(decoded.originalHeight / ORIGINAL_IMAGE_PATCH_SIZE_PX)));
35
+ }
36
+
37
+ // Like Codex, cache decode results, including failures, across requests in a SHA-1 keyed LRU.
38
+ const originalEstimateCache = new Map<string, number | undefined>();
39
+
40
+ async function cachedOriginalBytes(url: string): Promise<number | undefined> {
41
+ const key = createHash("sha1").update(url).digest("hex");
42
+ if (originalEstimateCache.has(key)) {
43
+ const cached = originalEstimateCache.get(key);
44
+ originalEstimateCache.delete(key);
45
+ originalEstimateCache.set(key, cached);
46
+ return cached;
47
+ }
48
+ const bytes = await inlineOriginalBytes(url);
49
+ originalEstimateCache.set(key, bytes);
50
+ if (originalEstimateCache.size > ORIGINAL_IMAGE_ESTIMATE_CACHE_SIZE) {
51
+ const oldest = originalEstimateCache.keys().next().value;
52
+ if (oldest !== undefined) originalEstimateCache.delete(oldest);
53
+ }
54
+ return bytes;
55
+ }
56
+
57
+ function contentParts(item: JsonObject): readonly unknown[] {
58
+ if (Array.isArray(item.content)) return item.content;
59
+ return (item.type === "function_call_output" || item.type === "custom_tool_call_output") &&
60
+ Array.isArray(item.output) ? item.output : [];
61
+ }
62
+
63
+ function originalInlineUrl(part: JsonObject): string | undefined {
64
+ return part.detail === "original" && typeof part.file_id !== "string" && typeof part.image_url === "string"
65
+ ? part.image_url : undefined;
66
+ }
67
+
68
+ /** Decode original-detail inline images in message content and tool outputs; lookups survive cloning. */
69
+ export async function estimateImages(input: readonly JsonObject[], signal: AbortSignal): Promise<ImageEstimates> {
70
+ const originals = new Map<string, number>();
71
+ for (const item of input) {
72
+ for (const part of contentParts(item)) {
73
+ signal.throwIfAborted();
74
+ if (!isInputImage(part)) continue;
75
+ const url = originalInlineUrl(part);
76
+ if (url === undefined || originals.has(url)) continue;
77
+ originals.set(url, await cachedOriginalBytes(url) ?? RESIZED_IMAGE_BYTES_ESTIMATE);
78
+ }
79
+ }
80
+ signal.throwIfAborted();
81
+ return {
82
+ bytes(part) {
83
+ if (part.detail === "original" && typeof part.file_id === "string") {
84
+ return approximateBytesForTokens(ORIGINAL_IMAGE_MAX_PATCHES);
85
+ }
86
+ const url = originalInlineUrl(part);
87
+ if (url === undefined) return RESIZED_IMAGE_BYTES_ESTIMATE;
88
+ const bytes = originals.get(url);
89
+ if (bytes === undefined) throw new Error("Input image is missing its byte estimate");
90
+ return bytes;
91
+ },
92
+ };
93
+ }
package/src/index.ts ADDED
@@ -0,0 +1,370 @@
1
+ // Own Pi lifecycle integration, active-session ownership, checkpoint replay hooks, and compaction orchestration.
2
+ import { resolve } from "node:path";
3
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
4
+ import type { Tool } from "@earendil-works/pi-ai";
5
+ import {
6
+ buildSessionContext,
7
+ getAgentDir,
8
+ type ExtensionAPI,
9
+ type ExtensionContext,
10
+ type SessionBeforeCompactEvent,
11
+ } from "@earendil-works/pi-coding-agent";
12
+ import { Text } from "@earendil-works/pi-tui";
13
+ import { prepareRetention, userItemOrigins } from "./retention-input.js";
14
+ import { buildReplacementHistory } from "./retention.js";
15
+ import {
16
+ capableModel,
17
+ sameBackend,
18
+ sameProvider,
19
+ type CapableModel,
20
+ type ProviderIdentity,
21
+ } from "./capability.js";
22
+ import {
23
+ type CodexCheckpointDetails,
24
+ checkpointMarker,
25
+ createCheckpointDetails,
26
+ fallbackSummary,
27
+ keptMessages,
28
+ latestCheckpoint,
29
+ parseCheckpointDetails,
30
+ projectCheckpointContext,
31
+ projectCheckpointRequest,
32
+ } from "./checkpoint.js";
33
+ import { hasCheckpointMarker, type JsonObject, REMOTE_COMPACTION_PROTOCOL, rewriteCheckpointMarker } from "./protocol.js";
34
+ import { requestRemoteCompaction } from "./remote.js";
35
+ import { requestFallbackCompaction } from "./fallback.js";
36
+ import { COMPACTION_SETTINGS_RELATIVE_PATH, loadCompactionSettings, type CompactionConfiguration } from "./fallback-settings.js";
37
+ import { registerCompactionCommand } from "./fallback-command.js";
38
+ import { compactionRequest, RequestSnapshotTracker, type RequestSnapshots } from "./request-snapshot.js";
39
+
40
+ const STATUS_KEY = "codex-compaction";
41
+ const COMPLETION_ENTRY_TYPE = "pi-codex-compaction-completed";
42
+
43
+ interface CompletionEntryData {
44
+ message: string;
45
+ protocol: typeof REMOTE_COMPACTION_PROTOCOL;
46
+ checkpointId: string;
47
+ }
48
+
49
+ function activeCheckpoint(ctx: ExtensionContext) {
50
+ return latestCheckpoint(ctx.sessionManager.getBranch());
51
+ }
52
+
53
+ async function compatibleIdentity(
54
+ details: CodexCheckpointDetails,
55
+ ctx: ExtensionContext,
56
+ model = ctx.model,
57
+ ): Promise<CapableModel | undefined> {
58
+ if (!model || !sameProvider(details, model)) return undefined;
59
+ // Resolve only endpoint identity here; Pi still owns authorization and request dispatch.
60
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
61
+ if (!auth.ok) return undefined;
62
+ const supported = capableModel(model, auth.baseUrl);
63
+ return supported && sameBackend(details, supported.identity) ? supported : undefined;
64
+ }
65
+
66
+ function activeTools(pi: ExtensionAPI): Tool[] {
67
+ const enabled = new Set(pi.getActiveTools());
68
+ return pi
69
+ .getAllTools()
70
+ .filter((tool) => enabled.has(tool.name))
71
+ .map((tool) => ({
72
+ name: tool.name,
73
+ description: tool.description,
74
+ parameters: tool.parameters,
75
+ }));
76
+ }
77
+
78
+ function canonicalMessages(ctx: ExtensionContext): AgentMessage[] {
79
+ const branch = ctx.sessionManager.getBranch();
80
+ return buildSessionContext(branch, branch.at(-1)?.id ?? null).messages;
81
+ }
82
+
83
+ function projectedCurrentMessages(
84
+ event: SessionBeforeCompactEvent,
85
+ identity: ProviderIdentity,
86
+ ): { messages: AgentMessage[]; prior?: CodexCheckpointDetails } {
87
+ const leafId = event.branchEntries.at(-1)?.id ?? null;
88
+ const session = buildSessionContext(event.branchEntries, leafId);
89
+ const prior = latestCheckpoint(event.branchEntries)?.details;
90
+ if (!prior) return { messages: session.messages };
91
+ if (!sameProvider(prior, identity)) {
92
+ throw new Error("The active opaque checkpoint belongs to a different provider backend");
93
+ }
94
+ const projected = projectCheckpointRequest(session.messages, prior);
95
+ if (!projected) throw new Error("The previous opaque checkpoint could not be projected safely");
96
+ return { messages: projected, prior };
97
+ }
98
+
99
+ async function replayCheckpoint(payload: unknown, ctx: ExtensionContext): Promise<JsonObject | undefined> {
100
+ const checkpoint = activeCheckpoint(ctx);
101
+ if (!checkpoint) return undefined;
102
+ const marker = checkpointMarker(checkpoint.details.checkpointId);
103
+ if (!hasCheckpointMarker(payload, marker)) return undefined;
104
+ if (!await compatibleIdentity(checkpoint.details, ctx)) {
105
+ throw new Error("The active opaque checkpoint no longer matches the resolved provider endpoint");
106
+ }
107
+ return rewriteCheckpointMarker(payload, marker, checkpoint.details.replacementHistory);
108
+ }
109
+
110
+ function errorMessage(error: unknown): string {
111
+ return error instanceof Error ? error.message : String(error);
112
+ }
113
+
114
+ function notifyFailure(ctx: ExtensionContext, error: unknown): void {
115
+ if (!ctx.hasUI) return;
116
+ ctx.ui.notify(`Codex remote compaction failed; using Pi compaction. ${errorMessage(error)}`, "warning");
117
+ }
118
+
119
+ function sessionStillOwned(ctx: ExtensionContext, sessionId: string, signal: AbortSignal): boolean {
120
+ return !signal.aborted && ctx.sessionManager.getSessionId() === sessionId;
121
+ }
122
+
123
+ async function compactFallback(
124
+ pi: ExtensionAPI,
125
+ event: SessionBeforeCompactEvent,
126
+ ctx: ExtensionContext,
127
+ sessionId: string,
128
+ configuration: CompactionConfiguration,
129
+ remoteError?: unknown,
130
+ ) {
131
+ let announced = false;
132
+ try {
133
+ if (!sessionStillOwned(ctx, sessionId, event.signal)) return { cancel: true };
134
+ const result = await requestFallbackCompaction({
135
+ configuration: configuration.fallback,
136
+ modelRegistry: ctx.modelRegistry,
137
+ preparation: event.preparation,
138
+ settings: pi.getSettings(),
139
+ customInstructions: event.customInstructions,
140
+ signal: event.signal,
141
+ sessionId,
142
+ onPrepared: ({ model, thinkingLevel }) => {
143
+ if (!sessionStillOwned(ctx, sessionId, event.signal)) throw new Error("Compaction session ownership changed");
144
+ if (announced) return;
145
+ announced = true;
146
+ ctx.ui.setStatus(STATUS_KEY, "Pi fallback compaction...");
147
+ if (ctx.hasUI) {
148
+ if (remoteError !== undefined) {
149
+ ctx.ui.notify(`Codex remote compaction failed; using the configured fallback model. ${errorMessage(remoteError)}`, "warning");
150
+ }
151
+ ctx.ui.notify(`Using Pi text compaction with ${model.provider}/${model.id} (${thinkingLevel}).`, "info");
152
+ }
153
+ },
154
+ });
155
+ if (!sessionStillOwned(ctx, sessionId, event.signal)) return { cancel: true };
156
+ if (!result && remoteError !== undefined) notifyFailure(ctx, remoteError);
157
+ return result;
158
+ } catch (error) {
159
+ if (sessionStillOwned(ctx, sessionId, event.signal) && ctx.hasUI) {
160
+ ctx.ui.notify(`Configured fallback compaction failed; compaction stopped. ${errorMessage(error)}`, "error");
161
+ }
162
+ // Returning undefined would make Pi send the same context to the active chat model.
163
+ return { cancel: true };
164
+ } finally {
165
+ if (ctx.sessionManager.getSessionId() === sessionId) ctx.ui.setStatus(STATUS_KEY, undefined);
166
+ }
167
+ }
168
+
169
+ async function compactRemotely(
170
+ pi: ExtensionAPI,
171
+ event: SessionBeforeCompactEvent,
172
+ ctx: ExtensionContext,
173
+ compactionSettingsPath: string,
174
+ fetch?: typeof globalThis.fetch,
175
+ snapshots: RequestSnapshots = {},
176
+ ) {
177
+ const supported = capableModel(ctx.model);
178
+ const sessionId = ctx.sessionManager.getSessionId();
179
+ if (!sessionStillOwned(ctx, sessionId, event.signal)) return { cancel: true };
180
+ let configuration: CompactionConfiguration;
181
+ try {
182
+ configuration = (await loadCompactionSettings(compactionSettingsPath)).configuration;
183
+ } catch (error) {
184
+ if (sessionStillOwned(ctx, sessionId, event.signal) && ctx.hasUI) {
185
+ ctx.ui.notify(`Could not read compaction settings; compaction stopped. ${errorMessage(error)}`, "error");
186
+ }
187
+ return { cancel: true };
188
+ }
189
+ if (!sessionStillOwned(ctx, sessionId, event.signal)) return { cancel: true };
190
+ if (!configuration.remoteCompactionEnabled || !supported) return compactFallback(pi, event, ctx, sessionId, configuration);
191
+ const reasoning = pi.getThinkingLevel();
192
+ const settings = pi.getSettings();
193
+ let announced = false;
194
+ ctx.ui.setStatus(STATUS_KEY, "Codex remote compaction...");
195
+ try {
196
+ if (!sessionStillOwned(ctx, sessionId, event.signal)) return { cancel: true };
197
+ if (event.customInstructions?.trim() && ctx.hasUI) {
198
+ ctx.ui.notify("Codex Remote Compaction V2 does not accept custom instructions; they are ignored.", "warning");
199
+ }
200
+ const current = projectedCurrentMessages(event, supported.identity);
201
+ const request = compactionRequest(snapshots, sessionId, supported, current.messages, {
202
+ blockImages: settings.images?.blockImages ?? false,
203
+ systemPrompt: () => ctx.getSystemPrompt(),
204
+ tools: () => activeTools(pi),
205
+ });
206
+ const response = await requestRemoteCompaction({
207
+ modelRegistry: ctx.modelRegistry,
208
+ model: supported.model,
209
+ context: request.context,
210
+ userItemOrigins: userItemOrigins(request.messages),
211
+ reasoning,
212
+ sessionId,
213
+ thinkingBudgets: settings.thinkingBudgets,
214
+ maxRetries: settings.retry?.provider?.maxRetries,
215
+ maxRetryDelayMs: settings.retry?.provider?.maxRetryDelayMs,
216
+ signal: event.signal,
217
+ onPrepared: () => {
218
+ if (!sessionStillOwned(ctx, sessionId, event.signal)) throw new Error("Compaction session ownership changed");
219
+ // Provider retries prepare the payload again; announce the compaction once.
220
+ if (announced) return;
221
+ announced = true;
222
+ if (ctx.hasUI) ctx.ui.notify(
223
+ `Starting Codex Remote Compaction V2 for ${supported.identity.provider}/${supported.identity.modelId}.`,
224
+ "info",
225
+ );
226
+ },
227
+ priorCheckpoint: current.prior
228
+ ? {
229
+ identity: current.prior,
230
+ marker: checkpointMarker(current.prior.checkpointId),
231
+ replacementHistory: current.prior.replacementHistory,
232
+ }
233
+ : undefined,
234
+ fetch,
235
+ });
236
+ if (!sessionStillOwned(ctx, sessionId, event.signal)) return { cancel: true };
237
+ const retention = await prepareRetention(response.promptInput, event.signal, {
238
+ images: response.images,
239
+ contextual: response.contextual,
240
+ });
241
+ if (!sessionStillOwned(ctx, sessionId, event.signal)) return { cancel: true };
242
+ const replacementHistory = buildReplacementHistory(retention, response.item);
243
+ const details = createCheckpointDetails({
244
+ identity: response.identity,
245
+ replacementHistory,
246
+ keptMessages: keptMessages(event.branchEntries, event.preparation.firstKeptEntryId),
247
+ });
248
+ return {
249
+ compaction: {
250
+ summary: fallbackSummary(details.checkpointId),
251
+ firstKeptEntryId: event.preparation.firstKeptEntryId,
252
+ tokensBefore: event.preparation.tokensBefore,
253
+ usage: response.usage,
254
+ details,
255
+ },
256
+ };
257
+ } catch (error) {
258
+ if (event.signal.aborted || ctx.sessionManager.getSessionId() !== sessionId) {
259
+ return { cancel: true };
260
+ }
261
+ return await compactFallback(pi, event, ctx, sessionId, configuration, error);
262
+ } finally {
263
+ if (ctx.sessionManager.getSessionId() === sessionId) {
264
+ ctx.ui.setStatus(STATUS_KEY, undefined);
265
+ }
266
+ }
267
+ }
268
+
269
+ export function createCodexCompactionExtension(
270
+ options: { fetch?: typeof globalThis.fetch } = {},
271
+ ): (pi: ExtensionAPI) => void {
272
+ return (pi) => {
273
+ const compactionSettingsPath = resolve(getAgentDir(), COMPACTION_SETTINGS_RELATIVE_PATH);
274
+ registerCompactionCommand(pi, compactionSettingsPath);
275
+ const warnings = new Set<string>();
276
+ const snapshots = new RequestSnapshotTracker();
277
+
278
+ pi.registerEntryRenderer<CompletionEntryData>(
279
+ COMPLETION_ENTRY_TYPE,
280
+ (entry, _options, theme) => {
281
+ const message = entry.data?.message;
282
+ return typeof message === "string"
283
+ ? new Text(theme.fg("success", message), 1, 0)
284
+ : undefined;
285
+ },
286
+ );
287
+
288
+ pi.on("session_start", () => {
289
+ warnings.clear();
290
+ snapshots.reset();
291
+ });
292
+
293
+ pi.on("session_before_compact", (event, ctx) =>
294
+ compactRemotely(pi, event, ctx, compactionSettingsPath, options.fetch, snapshots.current()),
295
+ );
296
+
297
+ pi.on("session_compact", (event) => {
298
+ if (!event.fromExtension) return;
299
+ const details = parseCheckpointDetails(event.compactionEntry.details);
300
+ if (!details) return;
301
+ pi.appendEntry<CompletionEntryData>(COMPLETION_ENTRY_TYPE, {
302
+ message: `Codex Remote Compaction V2 completed for ${details.provider}/${details.modelId}.`,
303
+ protocol: REMOTE_COMPACTION_PROTOCOL,
304
+ checkpointId: details.checkpointId,
305
+ });
306
+ });
307
+
308
+ pi.on("context", async (event, ctx) => {
309
+ snapshots.recordContext(ctx.sessionManager.getSessionId(), capableModel(ctx.model), event.messages);
310
+ const checkpoint = activeCheckpoint(ctx);
311
+ if (!checkpoint || !await compatibleIdentity(checkpoint.details, ctx)) return undefined;
312
+ const messages = projectCheckpointContext(event.messages, checkpoint.details);
313
+ if (messages) return { messages };
314
+ const key = `${ctx.sessionManager.getSessionId()}:${checkpoint.details.checkpointId}:projection`;
315
+ if (!warnings.has(key)) {
316
+ warnings.add(key);
317
+ if (ctx.hasUI) {
318
+ ctx.ui.notify(
319
+ "The active Codex checkpoint no longer matches the retained messages, so its opaque history is not replayed.",
320
+ "warning",
321
+ );
322
+ }
323
+ }
324
+ return undefined;
325
+ });
326
+
327
+ pi.on("context_with_system", (event, ctx) => {
328
+ snapshots.recordProjectedRequest(
329
+ ctx.sessionManager.getSessionId(),
330
+ capableModel(ctx.model),
331
+ () => canonicalMessages(ctx),
332
+ () => activeCheckpoint(ctx)?.details,
333
+ event.messages,
334
+ );
335
+ });
336
+
337
+ pi.on("before_provider_request", async (event, ctx) => {
338
+ const payload = await replayCheckpoint(event.payload, ctx);
339
+ snapshots.recordProviderRequest(
340
+ ctx.sessionManager.getSessionId(),
341
+ capableModel(ctx.model),
342
+ () => canonicalMessages(ctx),
343
+ () => ctx.getSystemPrompt(),
344
+ );
345
+ return payload;
346
+ });
347
+
348
+ pi.on("model_select", async (event, ctx) => {
349
+ const checkpoint = activeCheckpoint(ctx);
350
+ if (!checkpoint || await compatibleIdentity(checkpoint.details, ctx, event.model)) return;
351
+ const key = `${ctx.sessionManager.getSessionId()}:${event.model.provider}:${event.model.id}`;
352
+ if (warnings.has(key)) return;
353
+ warnings.add(key);
354
+ if (ctx.hasUI) {
355
+ ctx.ui.notify(
356
+ "The active Codex checkpoint cannot replay on this provider backend; only its fallback marker and retained recent messages remain available.",
357
+ "warning",
358
+ );
359
+ }
360
+ });
361
+
362
+ pi.on("session_shutdown", (_event, ctx) => {
363
+ warnings.clear();
364
+ snapshots.reset();
365
+ ctx.ui.setStatus(STATUS_KEY, undefined);
366
+ });
367
+ };
368
+ }
369
+
370
+ export default createCodexCompactionExtension();
@@ -0,0 +1,155 @@
1
+ // Own Responses V2 item validation and checkpoint payload transformations.
2
+ export const REMOTE_COMPACTION_PROTOCOL = "remote-compaction-v2" as const;
3
+
4
+ export type JsonObject = Record<string, unknown>;
5
+
6
+ export class CodexCompactionProtocolError extends Error {
7
+ constructor(message: string) {
8
+ super(message);
9
+ this.name = "CodexCompactionProtocolError";
10
+ }
11
+ }
12
+
13
+ export function isObject(value: unknown): value is JsonObject {
14
+ return typeof value === "object" && value !== null && !Array.isArray(value);
15
+ }
16
+
17
+ export function isInputImage(value: unknown): value is JsonObject {
18
+ return isObject(value) && value.type === "input_image";
19
+ }
20
+
21
+ function isCompactionType(type: unknown): boolean {
22
+ return type === "compaction" || type === "compaction_summary";
23
+ }
24
+
25
+ export function validateCompactionItem(
26
+ value: unknown,
27
+ ): JsonObject {
28
+ if (
29
+ !isObject(value) ||
30
+ !isCompactionType(value.type) ||
31
+ typeof value.encrypted_content !== "string" ||
32
+ !value.encrypted_content
33
+ ) {
34
+ throw new CodexCompactionProtocolError(
35
+ "Remote response did not contain a valid compaction item",
36
+ );
37
+ }
38
+ return { ...structuredClone(value), type: "compaction" };
39
+ }
40
+
41
+ export interface CompactionCollector {
42
+ observe(event: unknown): void;
43
+ finish(): JsonObject;
44
+ }
45
+
46
+ /** Count output-item completion events, as Codex does; response.output is not another source. */
47
+ export function createCompactionCollector(): CompactionCollector {
48
+ let count = 0;
49
+ let item: JsonObject | undefined;
50
+ let completed = false;
51
+ return {
52
+ observe(event) {
53
+ if (!isObject(event)) return;
54
+ // A provider may restart its response when falling back from WebSocket to HTTP.
55
+ if (event.type === "response.created") {
56
+ count = 0;
57
+ item = undefined;
58
+ completed = false;
59
+ }
60
+ if (event.type === "response.output_item.done" && isObject(event.item) &&
61
+ isCompactionType(event.item.type)) {
62
+ count += 1;
63
+ item = validateCompactionItem(event.item);
64
+ }
65
+ if (event.type === "response.completed") completed = true;
66
+ },
67
+ finish() {
68
+ if (!completed) {
69
+ throw new CodexCompactionProtocolError(
70
+ "Remote compaction stream ended without response.completed",
71
+ );
72
+ }
73
+ if (count !== 1 || !item) {
74
+ throw new CodexCompactionProtocolError(
75
+ `Remote compaction returned ${count} compaction output events; expected exactly one`,
76
+ );
77
+ }
78
+ return item;
79
+ },
80
+ };
81
+ }
82
+
83
+ function markerText(item: unknown): string | undefined {
84
+ if (
85
+ !isObject(item) ||
86
+ item.role !== "user" ||
87
+ !Array.isArray(item.content) ||
88
+ item.content.length !== 1
89
+ ) {
90
+ return undefined;
91
+ }
92
+ const content = item.content[0];
93
+ return isObject(content) &&
94
+ content.type === "input_text" &&
95
+ typeof content.text === "string"
96
+ ? content.text
97
+ : undefined;
98
+ }
99
+
100
+ export function rewriteCheckpointMarker(
101
+ payload: unknown,
102
+ marker: string,
103
+ replacementHistory: readonly unknown[],
104
+ ): JsonObject {
105
+ if (!isObject(payload) || !Array.isArray(payload.input)) {
106
+ throw new CodexCompactionProtocolError("Codex payload is missing an input array");
107
+ }
108
+ const matches = payload.input
109
+ .map((item, index) => (markerText(item) === marker ? index : -1))
110
+ .filter((index) => index >= 0);
111
+ if (matches.length !== 1) {
112
+ throw new CodexCompactionProtocolError(
113
+ `Provider payload contained ${matches.length} checkpoint markers; expected exactly one`,
114
+ );
115
+ }
116
+ const index = matches[0];
117
+ return {
118
+ ...payload,
119
+ input: [
120
+ ...payload.input.slice(0, index),
121
+ ...structuredClone(replacementHistory),
122
+ ...payload.input.slice(index + 1),
123
+ ],
124
+ };
125
+ }
126
+
127
+ export function appendCompactionTrigger(payload: unknown): JsonObject {
128
+ if (!isObject(payload) || !Array.isArray(payload.input)) {
129
+ throw new CodexCompactionProtocolError("Codex payload is missing an input array");
130
+ }
131
+ if (payload.input.some((item) => isObject(item) && item.type === "compaction_trigger")) {
132
+ throw new CodexCompactionProtocolError("Provider payload already contains a compaction trigger");
133
+ }
134
+ return { ...payload, input: [...payload.input, { type: "compaction_trigger" }] };
135
+ }
136
+
137
+ /** Replay the checkpoint, let the caller adapt the history, then append the V2 trigger. */
138
+ export function prepareRemoteCompactionPayload(
139
+ payload: unknown,
140
+ checkpoint?: { marker: string; replacementHistory: readonly unknown[] },
141
+ adaptHistory?: (history: JsonObject) => JsonObject,
142
+ ): JsonObject {
143
+ const history = checkpoint
144
+ ? rewriteCheckpointMarker(payload, checkpoint.marker, checkpoint.replacementHistory)
145
+ : payload;
146
+ return appendCompactionTrigger(adaptHistory && isObject(history) ? adaptHistory(history) : history);
147
+ }
148
+
149
+ export function hasCheckpointMarker(payload: unknown, marker: string): boolean {
150
+ return (
151
+ isObject(payload) &&
152
+ Array.isArray(payload.input) &&
153
+ payload.input.some((item) => markerText(item) === marker)
154
+ );
155
+ }