@narumitw/pi-codex-compact 0.51.3 → 0.52.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.
@@ -18,7 +18,7 @@ import {
18
18
  latestCheckpoint,
19
19
  projectCheckpointContext,
20
20
  } from "./checkpoint.js";
21
- import { usesCodexResponsesApi } from "./model-api.js";
21
+ import { resolveCompactionRoute, usesResponsesCompactionApi } from "./model-api.js";
22
22
  import { hasCheckpointMarker, rewriteCheckpointMarker } from "./protocol.js";
23
23
  import { requestRemoteCompaction } from "./remote.js";
24
24
  import {
@@ -27,6 +27,7 @@ import {
27
27
  type CodexCompactSettingsState,
28
28
  createCodexCompactSettingsRuntime,
29
29
  } from "./settings.js";
30
+ import { terminalText } from "./terminal.js";
30
31
 
31
32
  const STATUS_KEY = "codex-compact";
32
33
 
@@ -37,8 +38,10 @@ function activeCheckpoint(ctx: ExtensionContext) {
37
38
  function isCheckpointCompatible(
38
39
  details: CodexCheckpointDetails,
39
40
  model: Model<Api> | undefined,
40
- ): model is Model<"openai-codex-responses"> {
41
- return usesCodexResponsesApi(model) && model.id === details.modelId;
41
+ ): boolean {
42
+ return (
43
+ usesResponsesCompactionApi(model) && model.api === details.api && model.id === details.modelId
44
+ );
42
45
  }
43
46
 
44
47
  function keptMessages(event: SessionBeforeCompactEvent): AgentMessage[] {
@@ -54,27 +57,31 @@ function keptMessages(event: SessionBeforeCompactEvent): AgentMessage[] {
54
57
  }
55
58
 
56
59
  function activeTools(pi: ExtensionAPI): Tool[] {
57
- const enabled = new Set(pi.getActiveTools());
58
- return pi
59
- .getAllTools()
60
- .filter((tool) => enabled.has(tool.name))
61
- .map((tool) => ({
62
- name: tool.name,
63
- description: tool.description,
64
- parameters: tool.parameters,
65
- }));
60
+ const available = new Map(pi.getAllTools().map((tool) => [tool.name, tool]));
61
+ return pi.getActiveTools().flatMap((name) => {
62
+ const tool = available.get(name);
63
+ return tool
64
+ ? [
65
+ {
66
+ name: tool.name,
67
+ description: tool.description,
68
+ parameters: tool.parameters,
69
+ },
70
+ ]
71
+ : [];
72
+ });
66
73
  }
67
74
 
68
75
  function projectedCurrentMessages(
69
76
  event: SessionBeforeCompactEvent,
70
- model: Model<"openai-codex-responses">,
77
+ model: Model<Api>,
71
78
  ): { messages: AgentMessage[]; prior?: CodexCheckpointDetails } {
72
79
  const leafId = event.branchEntries.at(-1)?.id ?? null;
73
80
  const session = buildSessionContext(event.branchEntries, leafId);
74
81
  const prior = latestCheckpoint(event.branchEntries);
75
82
  if (!prior) return { messages: session.messages };
76
- if (prior.details.modelId !== model.id) {
77
- throw new Error("The active opaque checkpoint belongs to a different Codex model");
83
+ if (prior.details.api !== model.api || prior.details.modelId !== model.id) {
84
+ throw new Error("The active opaque checkpoint belongs to a different Responses model");
78
85
  }
79
86
  const projected = projectCheckpointContext(session.messages, prior.details, prior.entry.summary);
80
87
  if (!projected) {
@@ -89,8 +96,8 @@ function notifyFailure(
89
96
  settings: CodexCompactSettings,
90
97
  ): void {
91
98
  if (!ctx.hasUI || !settings.notifyOnFallback) return;
92
- const message = error instanceof Error ? error.message : String(error);
93
- ctx.ui.notify(`Codex remote compaction failed; using Pi compaction. ${message}`, "warning");
99
+ const message = terminalText(error instanceof Error ? error.message : String(error));
100
+ ctx.ui.notify(`Responses compaction failed; using Pi compaction. ${message}`, "warning");
94
101
  }
95
102
 
96
103
  function sessionStillOwned(ctx: ExtensionContext, sessionId: string, signal: AbortSignal): boolean {
@@ -102,18 +109,25 @@ async function compactRemotely(
102
109
  event: SessionBeforeCompactEvent,
103
110
  ctx: ExtensionContext,
104
111
  settings: CodexCompactSettings,
112
+ ownerSignal: AbortSignal,
105
113
  fetch?: typeof globalThis.fetch,
106
114
  ) {
107
115
  const model = ctx.model;
108
- if (!settings.enabled || !usesCodexResponsesApi(model)) return undefined;
116
+ const route = resolveCompactionRoute(model, settings);
117
+ if (route.kind === "native" || !usesResponsesCompactionApi(model)) return undefined;
118
+ const signal = AbortSignal.any([event.signal, ownerSignal]);
119
+ if (signal.aborted) return { cancel: true };
109
120
  const sessionId = ctx.sessionManager.getSessionId();
110
- ctx.ui.setStatus(STATUS_KEY, "Codex remote compaction…");
121
+ ctx.ui.setStatus(
122
+ STATUS_KEY,
123
+ route.protocol === "remote-v2" ? "Responses Remote V2…" : "Responses Compact API…",
124
+ );
111
125
  try {
112
126
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
113
- if (!sessionStillOwned(ctx, sessionId, event.signal)) return { cancel: true };
127
+ if (!sessionStillOwned(ctx, sessionId, signal)) return { cancel: true };
114
128
  if (!auth.ok) throw new Error(auth.error);
115
129
  const provider = ctx.modelRegistry.getProvider(model.provider);
116
- if (!provider) throw new Error("The active Codex Responses provider is unavailable");
130
+ if (!provider) throw new Error("The active Responses provider is unavailable");
117
131
  const current = projectedCurrentMessages(event, model);
118
132
  const context: Context = {
119
133
  systemPrompt: ctx.getSystemPrompt(),
@@ -124,10 +138,11 @@ async function compactRemotely(
124
138
  provider,
125
139
  model,
126
140
  context,
141
+ protocol: route.protocol,
127
142
  apiKey: auth.apiKey,
128
143
  headers: auth.headers,
129
144
  env: auth.env,
130
- signal: event.signal,
145
+ signal,
131
146
  priorCheckpoint: current.prior
132
147
  ? {
133
148
  marker: checkpointMarker(current.prior.checkpointId),
@@ -138,13 +153,17 @@ async function compactRemotely(
138
153
  maxRetries: settings.maxRetries,
139
154
  fetch,
140
155
  });
141
- if (!sessionStillOwned(ctx, sessionId, event.signal)) return { cancel: true };
142
- const replacementHistory = buildReplacementHistory(response.promptInput, response.item, {
143
- tokenBudget: settings.replacementTokenBudget,
144
- });
156
+ if (!sessionStillOwned(ctx, sessionId, signal)) return { cancel: true };
157
+ const replacementHistory = buildReplacementHistory(
158
+ response.compactedOutput?.slice(0, -1) ?? response.promptInput,
159
+ response.item,
160
+ { tokenBudget: settings.replacementTokenBudget },
161
+ );
145
162
  const details = createCheckpointDetails({
146
163
  provider: model.provider,
164
+ api: route.api,
147
165
  modelId: model.id,
166
+ protocol: route.protocol,
148
167
  replacementHistory,
149
168
  keptMessages: keptMessages(event),
150
169
  });
@@ -158,7 +177,7 @@ async function compactRemotely(
158
177
  },
159
178
  };
160
179
  } catch (error) {
161
- if (event.signal.aborted || ctx.sessionManager.getSessionId() !== sessionId) {
180
+ if (signal.aborted || ctx.sessionManager.getSessionId() !== sessionId) {
162
181
  return { cancel: true };
163
182
  }
164
183
  notifyFailure(ctx, error, settings);
@@ -178,8 +197,9 @@ export function createCodexCompactExtension(
178
197
  let generation = 0;
179
198
 
180
199
  pi.registerCommand("codex-compact", {
181
- description: "Compact now or configure Codex Remote Compaction V2",
182
- handler: async (_args, ctx) => {
200
+ description: "Compact now or configure Responses compaction",
201
+ handler: async (args, ctx) => {
202
+ if (args.trim()) throw new Error("Usage: /codex-compact");
183
203
  const ownerGeneration = generation;
184
204
  const controller = sessionController;
185
205
  const { showCodexCompactMenu } = await import("./settings-menu.js");
@@ -205,7 +225,7 @@ export function createCodexCompactExtension(
205
225
  if (sessionController.signal.aborted || ownerGeneration !== generation) return;
206
226
  if (ctx.hasUI) {
207
227
  ctx.ui.notify(
208
- `Could not load pi-codex-compact.json; using defaults. ${error instanceof Error ? error.message : String(error)}`,
228
+ `Could not load pi-codex-compact.json; using defaults. ${terminalText(error instanceof Error ? error.message : String(error))}`,
209
229
  "warning",
210
230
  );
211
231
  }
@@ -220,14 +240,21 @@ export function createCodexCompactExtension(
220
240
  }
221
241
  if (ctx.hasUI && state.kind === "invalid") {
222
242
  ctx.ui.notify(
223
- `Invalid pi-codex-compact.json; using defaults without overwriting it. ${state.issue}`,
243
+ `Invalid pi-codex-compact.json; using defaults without overwriting it. ${terminalText(state.issue ?? "unknown validation error")}`,
224
244
  "warning",
225
245
  );
226
246
  }
227
247
  });
228
248
 
229
249
  pi.on("session_before_compact", (event, ctx) =>
230
- compactRemotely(pi, event, ctx, settingsRuntime.get().settings, options.fetch),
250
+ compactRemotely(
251
+ pi,
252
+ event,
253
+ ctx,
254
+ settingsRuntime.get().settings,
255
+ sessionController.signal,
256
+ options.fetch,
257
+ ),
231
258
  );
232
259
 
233
260
  pi.on("context", (event, ctx) => {
@@ -260,7 +287,7 @@ export function createCodexCompactExtension(
260
287
  providerWarnings.add(key);
261
288
  if (ctx.hasUI) {
262
289
  ctx.ui.notify(
263
- "The active Codex checkpoint cannot replay on this model; Pi will expose only its fallback marker and retained recent messages.",
290
+ "The active Responses checkpoint cannot replay on this model; Pi will expose only its fallback marker and retained recent messages.",
264
291
  "warning",
265
292
  );
266
293
  }
package/src/model-api.ts CHANGED
@@ -1,8 +1,48 @@
1
1
  import type { Api, Model } from "@earendil-works/pi-ai";
2
2
  import { hasApi } from "@earendil-works/pi-ai";
3
3
 
4
- export function usesCodexResponsesApi(
4
+ export const RESPONSES_COMPACTION_APIS = [
5
+ "openai-codex-responses",
6
+ "openai-responses",
7
+ "azure-openai-responses",
8
+ ] as const;
9
+
10
+ export type ResponsesCompactionApi = (typeof RESPONSES_COMPACTION_APIS)[number];
11
+ export type RemoteCompactionProtocol = "remote-v2" | "responses-compact";
12
+ export type RemoteCompactionProtocolSetting = "auto" | RemoteCompactionProtocol;
13
+
14
+ export type CompactionRoute =
15
+ | { kind: "remote"; protocol: RemoteCompactionProtocol; api: ResponsesCompactionApi }
16
+ | { kind: "native"; reason: string };
17
+
18
+ export function usesResponsesCompactionApi(
19
+ model: Model<Api> | undefined,
20
+ ): model is Model<ResponsesCompactionApi> {
21
+ return model !== undefined && RESPONSES_COMPACTION_APIS.some((api) => hasApi(model, api));
22
+ }
23
+
24
+ export function resolveCompactionRouteForApi(
25
+ api: Api | undefined,
26
+ options: { enabled: boolean; protocol: RemoteCompactionProtocolSetting },
27
+ ): CompactionRoute {
28
+ if (!options.enabled) return { kind: "native", reason: "remote compaction is disabled" };
29
+ if (!api) return { kind: "native", reason: "no active model" };
30
+ if (!RESPONSES_COMPACTION_APIS.includes(api as ResponsesCompactionApi)) {
31
+ return { kind: "native", reason: `API ${api} does not support Responses compaction` };
32
+ }
33
+ const supportedApi = api as ResponsesCompactionApi;
34
+ const protocol =
35
+ options.protocol === "auto"
36
+ ? supportedApi === "openai-codex-responses"
37
+ ? "remote-v2"
38
+ : "responses-compact"
39
+ : options.protocol;
40
+ return { kind: "remote", protocol, api: supportedApi };
41
+ }
42
+
43
+ export function resolveCompactionRoute(
5
44
  model: Model<Api> | undefined,
6
- ): model is Model<"openai-codex-responses"> {
7
- return model !== undefined && hasApi(model, "openai-codex-responses");
45
+ options: { enabled: boolean; protocol: RemoteCompactionProtocolSetting },
46
+ ): CompactionRoute {
47
+ return resolveCompactionRouteForApi(model?.api, options);
8
48
  }
package/src/protocol.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export const MAX_SSE_BYTES = 8 * 1024 * 1024;
2
+ export const MAX_COMPACT_JSON_BYTES = 8 * 1024 * 1024;
2
3
  export const MAX_COMPACTION_ITEM_BYTES = 2 * 1024 * 1024;
3
4
 
4
5
  export type JsonObject = Record<string, unknown>;
@@ -47,6 +48,12 @@ export interface CollectedCompaction {
47
48
  completedResponse?: JsonObject;
48
49
  }
49
50
 
51
+ export interface CollectedCompactResponse {
52
+ item: JsonObject;
53
+ output: JsonObject[];
54
+ response: JsonObject;
55
+ }
56
+
50
57
  function compactionItemsFromEvent(event: JsonObject): unknown[] {
51
58
  const items: unknown[] = [];
52
59
  if (event.type === "response.output_item.done" && isObject(event.item)) {
@@ -156,6 +163,147 @@ export async function collectCompactionSse(
156
163
  return { item: [...items.values()][0], completedResponse };
157
164
  }
158
165
 
166
+ function isRetainedCompactContent(value: unknown): value is JsonObject {
167
+ if (!isObject(value)) return false;
168
+ if (value.type === "input_text") return typeof value.text === "string";
169
+ if (value.type !== "input_image") return false;
170
+ if (
171
+ value.detail !== undefined &&
172
+ value.detail !== null &&
173
+ value.detail !== "auto" &&
174
+ value.detail !== "low" &&
175
+ value.detail !== "high" &&
176
+ value.detail !== "original"
177
+ ) {
178
+ return false;
179
+ }
180
+ if (
181
+ (value.file_id !== undefined && value.file_id !== null && typeof value.file_id !== "string") ||
182
+ (value.image_url !== undefined &&
183
+ value.image_url !== null &&
184
+ typeof value.image_url !== "string")
185
+ ) {
186
+ return false;
187
+ }
188
+ return (
189
+ (typeof value.file_id === "string" && value.file_id.length > 0) ||
190
+ (typeof value.image_url === "string" && value.image_url.length > 0)
191
+ );
192
+ }
193
+
194
+ function isRetainedCompactMessage(value: unknown): value is JsonObject {
195
+ return (
196
+ isObject(value) &&
197
+ value.role === "user" &&
198
+ (value.type === undefined || value.type === "message") &&
199
+ Array.isArray(value.content) &&
200
+ value.content.length > 0 &&
201
+ value.content.every(isRetainedCompactContent)
202
+ );
203
+ }
204
+
205
+ export function validateCompactedResponse(
206
+ value: unknown,
207
+ options: { maxBytes?: number; maxItemBytes?: number } = {},
208
+ ): CollectedCompactResponse {
209
+ if (!isObject(value) || !Array.isArray(value.output)) {
210
+ throw new CodexCompactionProtocolError("Responses Compact returned an invalid response object");
211
+ }
212
+ const maxBytes = options.maxBytes ?? MAX_COMPACT_JSON_BYTES;
213
+ const maxItemBytes = options.maxItemBytes ?? MAX_COMPACTION_ITEM_BYTES;
214
+ if (byteLength(value) > maxBytes) {
215
+ throw new CodexCompactionProtocolError("Responses Compact response exceeded the size limit");
216
+ }
217
+ if (value.output.length === 0) {
218
+ throw new CodexCompactionProtocolError("Responses Compact returned no output items");
219
+ }
220
+ const output = value.output.map((item) => {
221
+ if (!isObject(item)) {
222
+ throw new CodexCompactionProtocolError("Responses Compact returned a non-object output item");
223
+ }
224
+ if (byteLength(item) > maxItemBytes) {
225
+ throw new CodexCompactionProtocolError(
226
+ "Responses Compact output item exceeded the size limit",
227
+ );
228
+ }
229
+ return structuredClone(item);
230
+ });
231
+ const compactionItems = output.filter((item) => item.type === "compaction");
232
+ if (compactionItems.length !== 1 || output.at(-1)?.type !== "compaction") {
233
+ throw new CodexCompactionProtocolError(
234
+ "Responses Compact must return retained messages followed by one compaction item",
235
+ );
236
+ }
237
+ for (const item of output.slice(0, -1)) {
238
+ if (!isRetainedCompactMessage(item)) {
239
+ throw new CodexCompactionProtocolError(
240
+ "Responses Compact returned an unsupported retained output item",
241
+ );
242
+ }
243
+ }
244
+ const item = validateCompactionItem(output.at(-1), maxItemBytes);
245
+ return { item, output: [...output.slice(0, -1), item], response: structuredClone(value) };
246
+ }
247
+
248
+ export async function collectCompactResponse(
249
+ response: Response,
250
+ options: { signal?: AbortSignal; maxBytes?: number; maxItemBytes?: number } = {},
251
+ ): Promise<CollectedCompactResponse> {
252
+ const maxBytes = options.maxBytes ?? MAX_COMPACT_JSON_BYTES;
253
+ const declaredLength = Number(response.headers.get("content-length"));
254
+ if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
255
+ const error = new CodexCompactionProtocolError(
256
+ "Responses Compact response exceeded the size limit",
257
+ );
258
+ await response.body?.cancel(error).catch(() => undefined);
259
+ throw error;
260
+ }
261
+ if (!response.body) {
262
+ throw new CodexCompactionProtocolError("Responses Compact response did not contain a body");
263
+ }
264
+ const reader = response.body.getReader();
265
+ const chunks: Uint8Array[] = [];
266
+ let bytes = 0;
267
+ const onAbort = () => {
268
+ void reader.cancel(new DOMException("Compaction aborted", "AbortError")).catch(() => undefined);
269
+ };
270
+ options.signal?.addEventListener("abort", onAbort, { once: true });
271
+ try {
272
+ while (true) {
273
+ if (options.signal?.aborted) throw new DOMException("Compaction aborted", "AbortError");
274
+ const { done, value } = await reader.read();
275
+ if (done) break;
276
+ bytes += value.byteLength;
277
+ if (bytes > maxBytes) {
278
+ throw new CodexCompactionProtocolError(
279
+ "Responses Compact response exceeded the size limit",
280
+ );
281
+ }
282
+ chunks.push(value);
283
+ }
284
+ if (options.signal?.aborted) throw new DOMException("Compaction aborted", "AbortError");
285
+ } catch (error) {
286
+ await reader.cancel(error).catch(() => undefined);
287
+ throw error;
288
+ } finally {
289
+ options.signal?.removeEventListener("abort", onAbort);
290
+ reader.releaseLock();
291
+ }
292
+ const body = new Uint8Array(bytes);
293
+ let offset = 0;
294
+ for (const chunk of chunks) {
295
+ body.set(chunk, offset);
296
+ offset += chunk.byteLength;
297
+ }
298
+ let parsed: unknown;
299
+ try {
300
+ parsed = JSON.parse(new TextDecoder().decode(body));
301
+ } catch {
302
+ throw new CodexCompactionProtocolError("Responses Compact returned malformed JSON");
303
+ }
304
+ return validateCompactedResponse(parsed, options);
305
+ }
306
+
159
307
  function markerTextFromItem(item: unknown): string | undefined {
160
308
  if (!isObject(item) || item.role !== "user" || !Array.isArray(item.content)) return undefined;
161
309
  if (item.content.length !== 1) return undefined;
@@ -205,14 +353,24 @@ export function appendCompactionTrigger(payload: unknown): JsonObject {
205
353
  return { ...payload, input: [...payload.input, { type: "compaction_trigger" }] };
206
354
  }
207
355
 
356
+ export function expandRemoteCompactionPayload(
357
+ payload: unknown,
358
+ checkpoint?: { marker: string; replacementHistory: readonly unknown[] },
359
+ ): JsonObject {
360
+ if (checkpoint) {
361
+ return rewriteCheckpointMarker(payload, checkpoint.marker, checkpoint.replacementHistory);
362
+ }
363
+ if (!isObject(payload) || !Array.isArray(payload.input)) {
364
+ throw new CodexCompactionProtocolError("Responses payload is missing an input array");
365
+ }
366
+ return structuredClone(payload);
367
+ }
368
+
208
369
  export function prepareRemoteCompactionPayload(
209
370
  payload: unknown,
210
371
  checkpoint?: { marker: string; replacementHistory: readonly unknown[] },
211
372
  ): JsonObject {
212
- const expanded = checkpoint
213
- ? rewriteCheckpointMarker(payload, checkpoint.marker, checkpoint.replacementHistory)
214
- : payload;
215
- return appendCompactionTrigger(expanded);
373
+ return appendCompactionTrigger(expandRemoteCompactionPayload(payload, checkpoint));
216
374
  }
217
375
 
218
376
  export function hasCheckpointMarker(payload: unknown, marker: string): boolean {