@narumitw/pi-codex-compact 0.51.3 → 0.53.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.
package/src/protocol.ts CHANGED
@@ -1,224 +1,363 @@
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>;
5
6
 
6
7
  export class CodexCompactionProtocolError extends Error {
7
- constructor(message: string) {
8
- super(message);
9
- this.name = "CodexCompactionProtocolError";
10
- }
8
+ constructor(message: string) {
9
+ super(message);
10
+ this.name = "CodexCompactionProtocolError";
11
+ }
11
12
  }
12
13
 
13
14
  function isObject(value: unknown): value is JsonObject {
14
- return typeof value === "object" && value !== null && !Array.isArray(value);
15
+ return typeof value === "object" && value !== null && !Array.isArray(value);
15
16
  }
16
17
 
17
18
  function byteLength(value: unknown): number {
18
- return Buffer.byteLength(JSON.stringify(value), "utf8");
19
+ return Buffer.byteLength(JSON.stringify(value), "utf8");
19
20
  }
20
21
 
21
22
  export function isCompactionItem(value: unknown): value is JsonObject {
22
- return (
23
- isObject(value) &&
24
- value.type === "compaction" &&
25
- typeof value.encrypted_content === "string" &&
26
- value.encrypted_content.length > 0
27
- );
23
+ return (
24
+ isObject(value) &&
25
+ value.type === "compaction" &&
26
+ typeof value.encrypted_content === "string" &&
27
+ value.encrypted_content.length > 0
28
+ );
28
29
  }
29
30
 
30
- export function validateCompactionItem(
31
- value: unknown,
32
- maxBytes = MAX_COMPACTION_ITEM_BYTES,
33
- ): JsonObject {
34
- if (!isCompactionItem(value)) {
35
- throw new CodexCompactionProtocolError(
36
- "Remote response did not contain a valid compaction item",
37
- );
38
- }
39
- if (byteLength(value) > maxBytes) {
40
- throw new CodexCompactionProtocolError("Remote compaction item exceeded the size limit");
41
- }
42
- return structuredClone(value);
31
+ export function validateCompactionItem(value: unknown, maxBytes = MAX_COMPACTION_ITEM_BYTES): JsonObject {
32
+ if (!isCompactionItem(value)) {
33
+ throw new CodexCompactionProtocolError("Remote response did not contain a valid compaction item");
34
+ }
35
+ if (byteLength(value) > maxBytes) {
36
+ throw new CodexCompactionProtocolError("Remote compaction item exceeded the size limit");
37
+ }
38
+ return structuredClone(value);
43
39
  }
44
40
 
45
41
  export interface CollectedCompaction {
46
- item: JsonObject;
47
- completedResponse?: JsonObject;
42
+ item: JsonObject;
43
+ completedResponse?: JsonObject;
44
+ }
45
+
46
+ export interface CollectedCompactResponse {
47
+ item: JsonObject;
48
+ output: JsonObject[];
49
+ response: JsonObject;
48
50
  }
49
51
 
50
52
  function compactionItemsFromEvent(event: JsonObject): unknown[] {
51
- const items: unknown[] = [];
52
- if (event.type === "response.output_item.done" && isObject(event.item)) {
53
- items.push(event.item);
54
- }
55
- if (event.type === "response.completed" && isObject(event.response)) {
56
- const output = event.response.output;
57
- if (Array.isArray(output)) items.push(...output);
58
- }
59
- return items.filter((item) => isObject(item) && item.type === "compaction");
53
+ const items: unknown[] = [];
54
+ if (event.type === "response.output_item.done" && isObject(event.item)) {
55
+ items.push(event.item);
56
+ }
57
+ if (event.type === "response.completed" && isObject(event.response)) {
58
+ const output = event.response.output;
59
+ if (Array.isArray(output)) items.push(...output);
60
+ }
61
+ return items.filter((item) => isObject(item) && item.type === "compaction");
60
62
  }
61
63
 
62
64
  export async function collectCompactionSse(
63
- stream: ReadableStream<Uint8Array>,
64
- options: {
65
- signal?: AbortSignal;
66
- maxBytes?: number;
67
- maxItemBytes?: number;
68
- } = {},
65
+ stream: ReadableStream<Uint8Array>,
66
+ options: {
67
+ signal?: AbortSignal;
68
+ maxBytes?: number;
69
+ maxItemBytes?: number;
70
+ } = {},
69
71
  ): Promise<CollectedCompaction> {
70
- const maxBytes = options.maxBytes ?? MAX_SSE_BYTES;
71
- const reader = stream.getReader();
72
- const onAbort = () => {
73
- void reader.cancel(new DOMException("Compaction aborted", "AbortError")).catch(() => undefined);
74
- };
75
- options.signal?.addEventListener("abort", onAbort, { once: true });
76
- const decoder = new TextDecoder();
77
- let bytes = 0;
78
- let pending = "";
79
- let dataLines: string[] = [];
80
- let completedResponse: JsonObject | undefined;
81
- const items = new Map<string, JsonObject>();
82
-
83
- const checkAbort = () => {
84
- if (options.signal?.aborted) throw new DOMException("Compaction aborted", "AbortError");
85
- };
86
- const dispatch = () => {
87
- if (dataLines.length === 0) return;
88
- const data = dataLines.join("\n");
89
- dataLines = [];
90
- if (data === "[DONE]") return;
91
- let parsed: unknown;
92
- try {
93
- parsed = JSON.parse(data);
94
- } catch {
95
- throw new CodexCompactionProtocolError("Remote compaction returned malformed SSE JSON");
96
- }
97
- if (!isObject(parsed)) return;
98
- if (parsed.type === "response.completed") {
99
- completedResponse = isObject(parsed.response) ? parsed.response : {};
100
- }
101
- for (const candidate of compactionItemsFromEvent(parsed)) {
102
- const item = validateCompactionItem(candidate, options.maxItemBytes);
103
- items.set(JSON.stringify(item), item);
104
- }
105
- };
106
- const processLine = (line: string) => {
107
- if (line === "") {
108
- dispatch();
109
- return;
110
- }
111
- if (line.startsWith(":")) return;
112
- if (line === "data") dataLines.push("");
113
- else if (line.startsWith("data:")) dataLines.push(line.slice(5).replace(/^ /, ""));
114
- };
115
-
116
- try {
117
- while (true) {
118
- checkAbort();
119
- const { done, value } = await reader.read();
120
- if (done) break;
121
- bytes += value.byteLength;
122
- if (bytes > maxBytes) {
123
- throw new CodexCompactionProtocolError("Remote compaction stream exceeded the size limit");
124
- }
125
- pending += decoder.decode(value, { stream: true });
126
- let newline = pending.indexOf("\n");
127
- while (newline !== -1) {
128
- const rawLine = pending.slice(0, newline);
129
- pending = pending.slice(newline + 1);
130
- processLine(rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine);
131
- newline = pending.indexOf("\n");
132
- }
133
- }
134
- pending += decoder.decode();
135
- if (pending.length > 0) processLine(pending.endsWith("\r") ? pending.slice(0, -1) : pending);
136
- dispatch();
137
- checkAbort();
138
- } catch (error) {
139
- await reader.cancel(error).catch(() => undefined);
140
- throw error;
141
- } finally {
142
- options.signal?.removeEventListener("abort", onAbort);
143
- reader.releaseLock();
144
- }
145
-
146
- if (!completedResponse) {
147
- throw new CodexCompactionProtocolError(
148
- "Remote compaction stream ended without response.completed",
149
- );
150
- }
151
- if (items.size !== 1) {
152
- throw new CodexCompactionProtocolError(
153
- `Remote compaction returned ${items.size} distinct compaction items; expected exactly one`,
154
- );
155
- }
156
- return { item: [...items.values()][0], completedResponse };
72
+ const maxBytes = options.maxBytes ?? MAX_SSE_BYTES;
73
+ const reader = stream.getReader();
74
+ const onAbort = () => {
75
+ void reader.cancel(new DOMException("Compaction aborted", "AbortError")).catch(() => undefined);
76
+ };
77
+ options.signal?.addEventListener("abort", onAbort, { once: true });
78
+ const decoder = new TextDecoder();
79
+ let bytes = 0;
80
+ let pending = "";
81
+ let dataLines: string[] = [];
82
+ let completedResponse: JsonObject | undefined;
83
+ const items = new Map<string, JsonObject>();
84
+
85
+ const checkAbort = () => {
86
+ if (options.signal?.aborted) throw new DOMException("Compaction aborted", "AbortError");
87
+ };
88
+ const dispatch = () => {
89
+ if (dataLines.length === 0) return;
90
+ const data = dataLines.join("\n");
91
+ dataLines = [];
92
+ if (data === "[DONE]") return;
93
+ let parsed: unknown;
94
+ try {
95
+ parsed = JSON.parse(data);
96
+ } catch {
97
+ throw new CodexCompactionProtocolError("Remote compaction returned malformed SSE JSON");
98
+ }
99
+ if (!isObject(parsed)) return;
100
+ if (parsed.type === "response.completed") {
101
+ completedResponse = isObject(parsed.response) ? parsed.response : {};
102
+ }
103
+ for (const candidate of compactionItemsFromEvent(parsed)) {
104
+ const item = validateCompactionItem(candidate, options.maxItemBytes);
105
+ items.set(JSON.stringify(item), item);
106
+ }
107
+ };
108
+ const processLine = (line: string) => {
109
+ if (line === "") {
110
+ dispatch();
111
+ return;
112
+ }
113
+ if (line.startsWith(":")) return;
114
+ if (line === "data") dataLines.push("");
115
+ else if (line.startsWith("data:")) dataLines.push(line.slice(5).replace(/^ /, ""));
116
+ };
117
+
118
+ try {
119
+ while (true) {
120
+ checkAbort();
121
+ const { done, value } = await reader.read();
122
+ if (done) break;
123
+ bytes += value.byteLength;
124
+ if (bytes > maxBytes) {
125
+ throw new CodexCompactionProtocolError("Remote compaction stream exceeded the size limit");
126
+ }
127
+ pending += decoder.decode(value, { stream: true });
128
+ let newline = pending.indexOf("\n");
129
+ while (newline !== -1) {
130
+ const rawLine = pending.slice(0, newline);
131
+ pending = pending.slice(newline + 1);
132
+ processLine(rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine);
133
+ newline = pending.indexOf("\n");
134
+ }
135
+ }
136
+ pending += decoder.decode();
137
+ if (pending.length > 0) processLine(pending.endsWith("\r") ? pending.slice(0, -1) : pending);
138
+ dispatch();
139
+ checkAbort();
140
+ } catch (error) {
141
+ await reader.cancel(error).catch(() => undefined);
142
+ throw error;
143
+ } finally {
144
+ options.signal?.removeEventListener("abort", onAbort);
145
+ reader.releaseLock();
146
+ }
147
+
148
+ if (!completedResponse) {
149
+ throw new CodexCompactionProtocolError("Remote compaction stream ended without response.completed");
150
+ }
151
+ if (items.size !== 1) {
152
+ throw new CodexCompactionProtocolError(
153
+ `Remote compaction returned ${items.size} distinct compaction items; expected exactly one`,
154
+ );
155
+ }
156
+ return { item: [...items.values()][0], completedResponse };
157
+ }
158
+
159
+ function isRetainedCompactContent(value: unknown): value is JsonObject {
160
+ if (!isObject(value)) return false;
161
+ if (value.type === "input_text") return typeof value.text === "string";
162
+ if (value.type !== "input_image") return false;
163
+ if (
164
+ value.detail !== undefined &&
165
+ value.detail !== null &&
166
+ value.detail !== "auto" &&
167
+ value.detail !== "low" &&
168
+ value.detail !== "high" &&
169
+ value.detail !== "original"
170
+ ) {
171
+ return false;
172
+ }
173
+ if (
174
+ (value.file_id !== undefined && value.file_id !== null && typeof value.file_id !== "string") ||
175
+ (value.image_url !== undefined && value.image_url !== null && typeof value.image_url !== "string")
176
+ ) {
177
+ return false;
178
+ }
179
+ return (
180
+ (typeof value.file_id === "string" && value.file_id.length > 0) ||
181
+ (typeof value.image_url === "string" && value.image_url.length > 0)
182
+ );
183
+ }
184
+
185
+ function isRetainedCompactMessage(value: unknown): value is JsonObject {
186
+ return (
187
+ isObject(value) &&
188
+ value.role === "user" &&
189
+ (value.type === undefined || value.type === "message") &&
190
+ Array.isArray(value.content) &&
191
+ value.content.length > 0 &&
192
+ value.content.every(isRetainedCompactContent)
193
+ );
194
+ }
195
+
196
+ export function validateCompactedResponse(
197
+ value: unknown,
198
+ options: { maxBytes?: number; maxItemBytes?: number } = {},
199
+ ): CollectedCompactResponse {
200
+ if (!isObject(value) || !Array.isArray(value.output)) {
201
+ throw new CodexCompactionProtocolError("Responses Compact returned an invalid response object");
202
+ }
203
+ const maxBytes = options.maxBytes ?? MAX_COMPACT_JSON_BYTES;
204
+ const maxItemBytes = options.maxItemBytes ?? MAX_COMPACTION_ITEM_BYTES;
205
+ if (byteLength(value) > maxBytes) {
206
+ throw new CodexCompactionProtocolError("Responses Compact response exceeded the size limit");
207
+ }
208
+ if (value.output.length === 0) {
209
+ throw new CodexCompactionProtocolError("Responses Compact returned no output items");
210
+ }
211
+ const output = value.output.map((item) => {
212
+ if (!isObject(item)) {
213
+ throw new CodexCompactionProtocolError("Responses Compact returned a non-object output item");
214
+ }
215
+ if (byteLength(item) > maxItemBytes) {
216
+ throw new CodexCompactionProtocolError("Responses Compact output item exceeded the size limit");
217
+ }
218
+ return structuredClone(item);
219
+ });
220
+ const compactionItems = output.filter((item) => item.type === "compaction");
221
+ if (compactionItems.length !== 1 || output.at(-1)?.type !== "compaction") {
222
+ throw new CodexCompactionProtocolError(
223
+ "Responses Compact must return retained messages followed by one compaction item",
224
+ );
225
+ }
226
+ for (const item of output.slice(0, -1)) {
227
+ if (!isRetainedCompactMessage(item)) {
228
+ throw new CodexCompactionProtocolError("Responses Compact returned an unsupported retained output item");
229
+ }
230
+ }
231
+ const item = validateCompactionItem(output.at(-1), maxItemBytes);
232
+ return { item, output: [...output.slice(0, -1), item], response: structuredClone(value) };
233
+ }
234
+
235
+ export async function collectCompactResponse(
236
+ response: Response,
237
+ options: { signal?: AbortSignal; maxBytes?: number; maxItemBytes?: number } = {},
238
+ ): Promise<CollectedCompactResponse> {
239
+ const maxBytes = options.maxBytes ?? MAX_COMPACT_JSON_BYTES;
240
+ const declaredLength = Number(response.headers.get("content-length"));
241
+ if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
242
+ const error = new CodexCompactionProtocolError("Responses Compact response exceeded the size limit");
243
+ await response.body?.cancel(error).catch(() => undefined);
244
+ throw error;
245
+ }
246
+ if (!response.body) {
247
+ throw new CodexCompactionProtocolError("Responses Compact response did not contain a body");
248
+ }
249
+ const reader = response.body.getReader();
250
+ const chunks: Uint8Array[] = [];
251
+ let bytes = 0;
252
+ const onAbort = () => {
253
+ void reader.cancel(new DOMException("Compaction aborted", "AbortError")).catch(() => undefined);
254
+ };
255
+ options.signal?.addEventListener("abort", onAbort, { once: true });
256
+ try {
257
+ while (true) {
258
+ if (options.signal?.aborted) throw new DOMException("Compaction aborted", "AbortError");
259
+ const { done, value } = await reader.read();
260
+ if (done) break;
261
+ bytes += value.byteLength;
262
+ if (bytes > maxBytes) {
263
+ throw new CodexCompactionProtocolError("Responses Compact response exceeded the size limit");
264
+ }
265
+ chunks.push(value);
266
+ }
267
+ if (options.signal?.aborted) throw new DOMException("Compaction aborted", "AbortError");
268
+ } catch (error) {
269
+ await reader.cancel(error).catch(() => undefined);
270
+ throw error;
271
+ } finally {
272
+ options.signal?.removeEventListener("abort", onAbort);
273
+ reader.releaseLock();
274
+ }
275
+ const body = new Uint8Array(bytes);
276
+ let offset = 0;
277
+ for (const chunk of chunks) {
278
+ body.set(chunk, offset);
279
+ offset += chunk.byteLength;
280
+ }
281
+ let parsed: unknown;
282
+ try {
283
+ parsed = JSON.parse(new TextDecoder().decode(body));
284
+ } catch {
285
+ throw new CodexCompactionProtocolError("Responses Compact returned malformed JSON");
286
+ }
287
+ return validateCompactedResponse(parsed, options);
157
288
  }
158
289
 
159
290
  function markerTextFromItem(item: unknown): string | undefined {
160
- if (!isObject(item) || item.role !== "user" || !Array.isArray(item.content)) return undefined;
161
- if (item.content.length !== 1) return undefined;
162
- const content = item.content[0];
163
- if (!isObject(content) || content.type !== "input_text" || typeof content.text !== "string") {
164
- return undefined;
165
- }
166
- return content.text;
291
+ if (!isObject(item) || item.role !== "user" || !Array.isArray(item.content)) return undefined;
292
+ if (item.content.length !== 1) return undefined;
293
+ const content = item.content[0];
294
+ if (!isObject(content) || content.type !== "input_text" || typeof content.text !== "string") {
295
+ return undefined;
296
+ }
297
+ return content.text;
167
298
  }
168
299
 
169
300
  export function rewriteCheckpointMarker(
170
- payload: unknown,
171
- marker: string,
172
- replacementHistory: readonly unknown[],
301
+ payload: unknown,
302
+ marker: string,
303
+ replacementHistory: readonly unknown[],
173
304
  ): JsonObject {
174
- if (!isObject(payload) || !Array.isArray(payload.input)) {
175
- throw new CodexCompactionProtocolError("Codex Responses payload is missing an input array");
176
- }
177
- const matches = payload.input
178
- .map((item, index) => (markerTextFromItem(item) === marker ? index : -1))
179
- .filter((index) => index >= 0);
180
- if (matches.length !== 1) {
181
- throw new CodexCompactionProtocolError(
182
- `Provider payload contained ${matches.length} checkpoint markers; expected exactly one`,
183
- );
184
- }
185
- const index = matches[0];
186
- return {
187
- ...payload,
188
- input: [
189
- ...payload.input.slice(0, index),
190
- ...structuredClone(replacementHistory),
191
- ...payload.input.slice(index + 1),
192
- ],
193
- };
305
+ if (!isObject(payload) || !Array.isArray(payload.input)) {
306
+ throw new CodexCompactionProtocolError("Codex Responses payload is missing an input array");
307
+ }
308
+ const matches = payload.input
309
+ .map((item, index) => (markerTextFromItem(item) === marker ? index : -1))
310
+ .filter((index) => index >= 0);
311
+ if (matches.length !== 1) {
312
+ throw new CodexCompactionProtocolError(
313
+ `Provider payload contained ${matches.length} checkpoint markers; expected exactly one`,
314
+ );
315
+ }
316
+ const index = matches[0];
317
+ return {
318
+ ...payload,
319
+ input: [
320
+ ...payload.input.slice(0, index),
321
+ ...structuredClone(replacementHistory),
322
+ ...payload.input.slice(index + 1),
323
+ ],
324
+ };
194
325
  }
195
326
 
196
327
  export function appendCompactionTrigger(payload: unknown): JsonObject {
197
- if (!isObject(payload) || !Array.isArray(payload.input)) {
198
- throw new CodexCompactionProtocolError("Codex Responses payload is missing an input array");
199
- }
200
- if (payload.input.some((item) => isObject(item) && item.type === "compaction_trigger")) {
201
- throw new CodexCompactionProtocolError(
202
- "Provider payload already contains a compaction trigger",
203
- );
204
- }
205
- return { ...payload, input: [...payload.input, { type: "compaction_trigger" }] };
328
+ if (!isObject(payload) || !Array.isArray(payload.input)) {
329
+ throw new CodexCompactionProtocolError("Codex Responses payload is missing an input array");
330
+ }
331
+ if (payload.input.some((item) => isObject(item) && item.type === "compaction_trigger")) {
332
+ throw new CodexCompactionProtocolError("Provider payload already contains a compaction trigger");
333
+ }
334
+ return { ...payload, input: [...payload.input, { type: "compaction_trigger" }] };
335
+ }
336
+
337
+ export function expandRemoteCompactionPayload(
338
+ payload: unknown,
339
+ checkpoint?: { marker: string; replacementHistory: readonly unknown[] },
340
+ ): JsonObject {
341
+ if (checkpoint) {
342
+ return rewriteCheckpointMarker(payload, checkpoint.marker, checkpoint.replacementHistory);
343
+ }
344
+ if (!isObject(payload) || !Array.isArray(payload.input)) {
345
+ throw new CodexCompactionProtocolError("Responses payload is missing an input array");
346
+ }
347
+ return structuredClone(payload);
206
348
  }
207
349
 
208
350
  export function prepareRemoteCompactionPayload(
209
- payload: unknown,
210
- checkpoint?: { marker: string; replacementHistory: readonly unknown[] },
351
+ payload: unknown,
352
+ checkpoint?: { marker: string; replacementHistory: readonly unknown[] },
211
353
  ): JsonObject {
212
- const expanded = checkpoint
213
- ? rewriteCheckpointMarker(payload, checkpoint.marker, checkpoint.replacementHistory)
214
- : payload;
215
- return appendCompactionTrigger(expanded);
354
+ return appendCompactionTrigger(expandRemoteCompactionPayload(payload, checkpoint));
216
355
  }
217
356
 
218
357
  export function hasCheckpointMarker(payload: unknown, marker: string): boolean {
219
- return (
220
- isObject(payload) &&
221
- Array.isArray(payload.input) &&
222
- payload.input.some((item) => markerTextFromItem(item) === marker)
223
- );
358
+ return (
359
+ isObject(payload) &&
360
+ Array.isArray(payload.input) &&
361
+ payload.input.some((item) => markerTextFromItem(item) === marker)
362
+ );
224
363
  }