@lll9p/pi-better-compaction 0.2.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,555 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
3
+ import { compact, convertToLlm } from "@earendil-works/pi-coding-agent";
4
+ import type {
5
+ Api,
6
+ AssistantMessage,
7
+ ImageContent,
8
+ Message,
9
+ Model,
10
+ TextContent,
11
+ ThinkingContent,
12
+ ToolCall,
13
+ ToolResultMessage,
14
+ UserMessage,
15
+ } from "@earendil-works/pi-ai";
16
+ import type { ResponsesCompatibleRequestPayload } from "./runtime";
17
+
18
+ /**
19
+ * pi stopped exporting the CompactionPreparation type name in 0.80.x, but it is still
20
+ * structurally the first argument of the exported compact(). Derive it from there so we
21
+ * track pi's shape without depending on a private export.
22
+ */
23
+ type CompactionPreparation = Parameters<typeof compact>[0];
24
+
25
+ /**
26
+ * Decision for T4: keep a narrow local serializer instead of importing Pi internals.
27
+ *
28
+ * Why this is sufficient for v1:
29
+ * - we only target same-model OpenAI Responses-compatible requests
30
+ * - we only need Pi's current supported message semantics (assistant phase,
31
+ * reasoning signatures, tool call/result pairing, image blocks)
32
+ * - Pi's shared Responses converter is not publicly exported, so importing it
33
+ * would require a brittle install-path-specific wrapper
34
+ *
35
+ * The helpers below intentionally mirror Pi's same-model Responses serialization
36
+ * rules closely so later tasks can compare their output against captured
37
+ * before_provider_request payload artifacts.
38
+ */
39
+ export const COMPACTION_SERIALIZER_STRATEGY = "local-same-model-responses-serializer" as const;
40
+
41
+ export type CompactionSerializerStrategy = typeof COMPACTION_SERIALIZER_STRATEGY;
42
+ export type AssistantPhase = "commentary" | "final_answer";
43
+
44
+ type ResponsesTextInputItem = {
45
+ type: "input_text";
46
+ text: string;
47
+ };
48
+
49
+ type ResponsesImageInputItem = {
50
+ type: "input_image";
51
+ detail: "auto";
52
+ image_url: string;
53
+ };
54
+
55
+ export type ResponsesInputContentItem = ResponsesTextInputItem | ResponsesImageInputItem;
56
+
57
+ export type ResponsesInputMessageItem = {
58
+ role: "user" | "developer" | "system";
59
+ content: ResponsesInputContentItem[] | string;
60
+ };
61
+
62
+ export type ResponsesAssistantOutputItem = {
63
+ type: "message";
64
+ role: "assistant";
65
+ content: Array<{
66
+ type: "output_text";
67
+ text: string;
68
+ annotations: [];
69
+ }>;
70
+ status: "completed";
71
+ id: string;
72
+ phase?: AssistantPhase;
73
+ };
74
+
75
+ export type ResponsesFunctionCallItem = {
76
+ type: "function_call";
77
+ id?: string;
78
+ call_id: string;
79
+ name: string;
80
+ arguments: string;
81
+ };
82
+
83
+ export type ResponsesFunctionCallOutputItem = {
84
+ type: "function_call_output";
85
+ call_id: string;
86
+ output: ResponsesInputContentItem[] | string;
87
+ };
88
+
89
+ export type ResponsesReasoningItem = Record<string, unknown>;
90
+
91
+ export type ResponsesInputItem =
92
+ | ResponsesInputMessageItem
93
+ | ResponsesAssistantOutputItem
94
+ | ResponsesFunctionCallItem
95
+ | ResponsesFunctionCallOutputItem
96
+ | ResponsesReasoningItem;
97
+
98
+ export type NativeCompactionRequestBody = {
99
+ model: string;
100
+ input: ResponsesInputItem[];
101
+ instructions: string;
102
+ /**
103
+ * Optional passthrough fields mirroring the latest codex_rs CompactionInput.
104
+ * Sourced from the most recent provider request payload when available;
105
+ * undefined fields are omitted from the serialized JSON body.
106
+ */
107
+ tools?: unknown[];
108
+ parallel_tool_calls?: boolean;
109
+ reasoning?: Record<string, unknown>;
110
+ service_tier?: string;
111
+ prompt_cache_key?: string;
112
+ text?: Record<string, unknown>;
113
+ };
114
+
115
+ export type SerializeResponsesMessagesOptions = {
116
+ instructions?: string;
117
+ includeInstructionsInInput?: boolean;
118
+ };
119
+
120
+ export type ResponsesParityReport = {
121
+ ok: boolean;
122
+ actual: string[];
123
+ expected: string[];
124
+ mismatches: string[];
125
+ };
126
+
127
+ type ParsedTextSignature = {
128
+ id: string;
129
+ phase?: AssistantPhase;
130
+ };
131
+
132
+ const SYNTHETIC_TOOL_RESULT_TEXT = "No result provided";
133
+
134
+ function sanitizeSurrogates(text: string): string {
135
+ return text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "");
136
+ }
137
+
138
+ export function collectCompactionWindowMessages(preparation: CompactionPreparation): AgentMessage[] {
139
+ return [...preparation.messagesToSummarize, ...preparation.turnPrefixMessages];
140
+ }
141
+
142
+ export function serializeCompactionPreparationToRequest<TApi extends Api>(args: {
143
+ model: Model<TApi>;
144
+ preparation: CompactionPreparation;
145
+ instructions: string;
146
+ }): NativeCompactionRequestBody {
147
+ return serializeMessagesToCompactRequest({
148
+ model: args.model,
149
+ messages: collectCompactionWindowMessages(args.preparation),
150
+ instructions: args.instructions,
151
+ });
152
+ }
153
+
154
+ export function serializeMessagesToCompactRequest<TApi extends Api>(args: {
155
+ model: Model<TApi>;
156
+ messages: AgentMessage[];
157
+ instructions: string;
158
+ }): NativeCompactionRequestBody {
159
+ return {
160
+ model: args.model.id,
161
+ input: serializeMessagesToResponsesInput(args.model, args.messages),
162
+ instructions: sanitizeSurrogates(args.instructions),
163
+ };
164
+ }
165
+
166
+ export function serializeMessagesToResponsesInput<TApi extends Api>(
167
+ model: Model<TApi>,
168
+ messages: AgentMessage[],
169
+ options: SerializeResponsesMessagesOptions = {},
170
+ ): ResponsesInputItem[] {
171
+ const llmMessages = convertToLlm(messages);
172
+ const transformedMessages = transformMessagesForResponses(llmMessages);
173
+ const input: ResponsesInputItem[] = [];
174
+
175
+ if (options.includeInstructionsInInput && options.instructions) {
176
+ input.push({
177
+ role: model.reasoning ? "developer" : "system",
178
+ content: sanitizeSurrogates(options.instructions),
179
+ });
180
+ }
181
+
182
+ let messageIndex = 0;
183
+ for (const message of transformedMessages) {
184
+ if (message.role === "user") {
185
+ const item = serializeUserMessage(message, model);
186
+ if (item) {
187
+ input.push(item);
188
+ }
189
+ messageIndex++;
190
+ continue;
191
+ }
192
+
193
+ if (message.role === "assistant") {
194
+ const items = serializeAssistantMessage(message, messageIndex);
195
+ if (items.length > 0) {
196
+ input.push(...items);
197
+ }
198
+ messageIndex++;
199
+ continue;
200
+ }
201
+
202
+ input.push(serializeToolResultMessage(message, model));
203
+ messageIndex++;
204
+ }
205
+
206
+ return input;
207
+ }
208
+
209
+ export function createResponsesInputParitySignature(input: readonly unknown[]): string[] {
210
+ return input.map(describeResponsesInputItem);
211
+ }
212
+
213
+ export function compareResponsesInputParity(actual: readonly unknown[], expected: readonly unknown[]): ResponsesParityReport {
214
+ const actualSignature = createResponsesInputParitySignature(actual);
215
+ const expectedSignature = createResponsesInputParitySignature(expected);
216
+ const maxLength = Math.max(actualSignature.length, expectedSignature.length);
217
+ const mismatches: string[] = [];
218
+
219
+ for (let index = 0; index < maxLength; index++) {
220
+ const actualValue = actualSignature[index];
221
+ const expectedValue = expectedSignature[index];
222
+ if (actualValue !== expectedValue) {
223
+ mismatches.push(`index ${index}: expected ${expectedValue ?? "<missing>"}, got ${actualValue ?? "<missing>"}`);
224
+ }
225
+ }
226
+
227
+ return {
228
+ ok: mismatches.length === 0,
229
+ actual: actualSignature,
230
+ expected: expectedSignature,
231
+ mismatches,
232
+ };
233
+ }
234
+
235
+ export function compareCompactRequestToPayload(
236
+ request: NativeCompactionRequestBody,
237
+ payload: Pick<ResponsesCompatibleRequestPayload, "model" | "input" | "instructions">,
238
+ ): ResponsesParityReport {
239
+ const parity = compareResponsesInputParity(request.input, payload.input);
240
+ const mismatches = [...parity.mismatches];
241
+
242
+ if (payload.model !== request.model) {
243
+ mismatches.unshift(`model: expected ${payload.model}, got ${request.model}`);
244
+ }
245
+
246
+ if ((payload.instructions ?? "") !== request.instructions) {
247
+ mismatches.unshift("instructions: expected serialized instructions to match payload instructions");
248
+ }
249
+
250
+ return {
251
+ ok: mismatches.length === 0,
252
+ actual: parity.actual,
253
+ expected: parity.expected,
254
+ mismatches,
255
+ };
256
+ }
257
+
258
+ function transformMessagesForResponses(messages: Message[]): Message[] {
259
+ const transformed: Message[] = [];
260
+ let pendingToolCalls: ToolCall[] = [];
261
+ let existingToolResultIds = new Set<string>();
262
+
263
+ for (const message of messages) {
264
+ if (message.role === "assistant") {
265
+ if (pendingToolCalls.length > 0) {
266
+ transformed.push(...createSyntheticToolResults(pendingToolCalls, existingToolResultIds));
267
+ pendingToolCalls = [];
268
+ existingToolResultIds = new Set<string>();
269
+ }
270
+
271
+ if (message.stopReason === "error" || message.stopReason === "aborted") {
272
+ continue;
273
+ }
274
+
275
+ const normalizedContent = message.content.flatMap((block) => {
276
+ if (block.type !== "thinking") {
277
+ return [block];
278
+ }
279
+
280
+ return block.thinkingSignature ? [block] : [];
281
+ });
282
+
283
+ const normalizedAssistantMessage: AssistantMessage = {
284
+ ...message,
285
+ content: normalizedContent,
286
+ };
287
+ transformed.push(normalizedAssistantMessage);
288
+
289
+ const toolCalls = normalizedContent.filter(isToolCallBlock);
290
+ if (toolCalls.length > 0) {
291
+ pendingToolCalls = toolCalls;
292
+ existingToolResultIds = new Set<string>();
293
+ }
294
+ continue;
295
+ }
296
+
297
+ if (message.role === "toolResult") {
298
+ existingToolResultIds.add(message.toolCallId);
299
+ transformed.push(message);
300
+ continue;
301
+ }
302
+
303
+ if (pendingToolCalls.length > 0) {
304
+ transformed.push(...createSyntheticToolResults(pendingToolCalls, existingToolResultIds));
305
+ pendingToolCalls = [];
306
+ existingToolResultIds = new Set<string>();
307
+ }
308
+
309
+ transformed.push(message);
310
+ }
311
+
312
+ return transformed;
313
+ }
314
+
315
+ function createSyntheticToolResults(
316
+ pendingToolCalls: readonly ToolCall[],
317
+ existingToolResultIds: ReadonlySet<string>,
318
+ ): ToolResultMessage[] {
319
+ const syntheticResults: ToolResultMessage[] = [];
320
+
321
+ for (const toolCall of pendingToolCalls) {
322
+ if (existingToolResultIds.has(toolCall.id)) {
323
+ continue;
324
+ }
325
+
326
+ syntheticResults.push({
327
+ role: "toolResult",
328
+ toolCallId: toolCall.id,
329
+ toolName: toolCall.name,
330
+ content: [{ type: "text", text: SYNTHETIC_TOOL_RESULT_TEXT }],
331
+ isError: true,
332
+ timestamp: Date.now(),
333
+ });
334
+ }
335
+
336
+ return syntheticResults;
337
+ }
338
+
339
+ function serializeUserMessage<TApi extends Api>(
340
+ message: UserMessage,
341
+ model: Model<TApi>,
342
+ ): ResponsesInputMessageItem | undefined {
343
+ const contentItems = normalizeUserContent(message.content).flatMap((item) => serializeUserContentItem(item, model));
344
+ if (contentItems.length === 0) {
345
+ return undefined;
346
+ }
347
+
348
+ return {
349
+ role: "user",
350
+ content: contentItems,
351
+ };
352
+ }
353
+
354
+ function serializeUserContentItem<TApi extends Api>(
355
+ item: TextContent | ImageContent,
356
+ model: Model<TApi>,
357
+ ): ResponsesInputContentItem[] {
358
+ if (item.type === "text") {
359
+ return [{ type: "input_text", text: sanitizeSurrogates(item.text) }];
360
+ }
361
+
362
+ if (!model.input.includes("image")) {
363
+ return [];
364
+ }
365
+
366
+ return [
367
+ {
368
+ type: "input_image",
369
+ detail: "auto",
370
+ image_url: `data:${item.mimeType};base64,${item.data}`,
371
+ },
372
+ ];
373
+ }
374
+
375
+ function serializeAssistantMessage(message: AssistantMessage, messageIndex: number): ResponsesInputItem[] {
376
+ const items: ResponsesInputItem[] = [];
377
+
378
+ for (const block of message.content) {
379
+ if (block.type === "thinking") {
380
+ const reasoningItem = parseReasoningItem(block);
381
+ if (reasoningItem) {
382
+ items.push(reasoningItem);
383
+ }
384
+ continue;
385
+ }
386
+
387
+ if (block.type === "text") {
388
+ const signature = parseTextSignature(block.textSignature);
389
+ items.push({
390
+ type: "message",
391
+ role: "assistant",
392
+ content: [{ type: "output_text", text: sanitizeSurrogates(block.text), annotations: [] }],
393
+ status: "completed",
394
+ id: normalizeAssistantMessageId(signature?.id, messageIndex),
395
+ phase: signature?.phase,
396
+ });
397
+ continue;
398
+ }
399
+
400
+ const [callId, rawItemId] = block.id.split("|");
401
+ items.push({
402
+ type: "function_call",
403
+ id: rawItemId,
404
+ call_id: callId,
405
+ name: block.name,
406
+ arguments: JSON.stringify(block.arguments),
407
+ });
408
+ }
409
+
410
+ return items;
411
+ }
412
+
413
+ function serializeToolResultMessage<TApi extends Api>(
414
+ message: ToolResultMessage,
415
+ model: Model<TApi>,
416
+ ): ResponsesFunctionCallOutputItem {
417
+ const [callId] = message.toolCallId.split("|");
418
+ const textOutput = message.content
419
+ .filter((item): item is TextContent => item.type === "text")
420
+ .map((item) => sanitizeSurrogates(item.text))
421
+ .join("\n");
422
+ const hasImages = message.content.some((item) => item.type === "image");
423
+ const hasText = textOutput.length > 0;
424
+
425
+ if (hasImages && model.input.includes("image")) {
426
+ const output: ResponsesInputContentItem[] = [];
427
+ if (hasText) {
428
+ output.push({ type: "input_text", text: textOutput });
429
+ }
430
+ for (const item of message.content) {
431
+ if (item.type !== "image") {
432
+ continue;
433
+ }
434
+ output.push({
435
+ type: "input_image",
436
+ detail: "auto",
437
+ image_url: `data:${item.mimeType};base64,${item.data}`,
438
+ });
439
+ }
440
+ return {
441
+ type: "function_call_output",
442
+ call_id: callId,
443
+ output,
444
+ };
445
+ }
446
+
447
+ return {
448
+ type: "function_call_output",
449
+ call_id: callId,
450
+ output: hasText ? textOutput : "(see attached image)",
451
+ };
452
+ }
453
+
454
+ function normalizeUserContent(content: UserMessage["content"]): Array<TextContent | ImageContent> {
455
+ return typeof content === "string" ? [{ type: "text", text: content }] : content;
456
+ }
457
+
458
+ function parseReasoningItem(block: ThinkingContent): ResponsesReasoningItem | undefined {
459
+ if (!block.thinkingSignature) {
460
+ return undefined;
461
+ }
462
+
463
+ try {
464
+ const parsed = JSON.parse(block.thinkingSignature);
465
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
466
+ return undefined;
467
+ }
468
+ return parsed as ResponsesReasoningItem;
469
+ } catch {
470
+ return undefined;
471
+ }
472
+ }
473
+
474
+ function parseTextSignature(signature: string | undefined): ParsedTextSignature | undefined {
475
+ if (!signature) {
476
+ return undefined;
477
+ }
478
+
479
+ if (!signature.startsWith("{")) {
480
+ return { id: signature };
481
+ }
482
+
483
+ try {
484
+ const parsed = JSON.parse(signature);
485
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
486
+ return undefined;
487
+ }
488
+
489
+ const record = parsed as Record<string, unknown>;
490
+ if (record.v !== 1 || typeof record.id !== "string") {
491
+ return undefined;
492
+ }
493
+
494
+ return {
495
+ id: record.id,
496
+ phase:
497
+ record.phase === "commentary" || record.phase === "final_answer"
498
+ ? record.phase
499
+ : undefined,
500
+ };
501
+ } catch {
502
+ return undefined;
503
+ }
504
+ }
505
+
506
+ function normalizeAssistantMessageId(id: string | undefined, messageIndex: number): string {
507
+ if (!id) {
508
+ return `msg_${messageIndex}`;
509
+ }
510
+
511
+ if (id.length <= 64) {
512
+ return id;
513
+ }
514
+
515
+ return `msg_${createHash("sha1").update(id).digest("hex").slice(0, 12)}`;
516
+ }
517
+
518
+ function isToolCallBlock(block: AssistantMessage["content"][number]): block is ToolCall {
519
+ return block.type === "toolCall";
520
+ }
521
+
522
+ function describeResponsesInputItem(item: unknown): string {
523
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
524
+ return typeof item;
525
+ }
526
+
527
+ const record = item as Record<string, unknown>;
528
+ const type = typeof record.type === "string" ? record.type : undefined;
529
+ if (type === "message") {
530
+ const phase =
531
+ record.phase === "commentary" || record.phase === "final_answer"
532
+ ? `:${record.phase}`
533
+ : "";
534
+ return `message:${typeof record.role === "string" ? record.role : "unknown"}${phase}`;
535
+ }
536
+
537
+ if (type === "function_call") {
538
+ return `function_call:${typeof record.name === "string" ? record.name : "unknown"}`;
539
+ }
540
+
541
+ if (type === "function_call_output") {
542
+ return "function_call_output";
543
+ }
544
+
545
+ if (type === "reasoning") {
546
+ return "reasoning";
547
+ }
548
+
549
+ if (typeof record.role === "string") {
550
+ const content = Array.isArray(record.content) ? `[${record.content.length}]` : "";
551
+ return `input:${record.role}${content}`;
552
+ }
553
+
554
+ return type ? `item:${type}` : "object";
555
+ }
@@ -0,0 +1,16 @@
1
+ export {
2
+ buildCompactUrl,
3
+ getNativeCompactionRuntime,
4
+ getRuntimeModelDescriptor,
5
+ isResponsesCompatiblePayload,
6
+ isSupportedApi,
7
+ normalizeBaseUrl,
8
+ resolveNativeCompactionEnvironment,
9
+ type NativeCompactionEnvironmentFailure,
10
+ type NativeCompactionEnvironmentResolution,
11
+ type NativeCompactionEnvironmentSuccess,
12
+ type NativeCompactionRuntime,
13
+ type NativeCompactionSupportOptions,
14
+ type ResponsesCompatibleRequestPayload,
15
+ } from "./runtime.ts";
16
+ export { RESPONSES_COMPACT_CAPABLE_APIS } from "./types.ts";