pi-openai-toolkit 0.20.7 → 0.21.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.
Files changed (55) hide show
  1. package/LICENSE +21 -21
  2. package/NOTICE +83 -83
  3. package/README.md +8 -26
  4. package/README.zh.md +8 -26
  5. package/config.schema.json +278 -366
  6. package/docs/configuration.md +294 -310
  7. package/docs/internals.md +166 -175
  8. package/package.json +3 -13
  9. package/src/compact-client.ts +412 -389
  10. package/src/compaction-errors.ts +96 -0
  11. package/src/compaction-projection.ts +35 -35
  12. package/src/config/legacy.ts +519 -717
  13. package/src/config/migration.ts +103 -100
  14. package/src/config/notifications.ts +36 -28
  15. package/src/config/policy.ts +10 -18
  16. package/src/config/v2.ts +1167 -1380
  17. package/src/config.ts +310 -319
  18. package/src/context-management/codex-provider.ts +270 -270
  19. package/src/context-management/history-notes.ts +357 -357
  20. package/src/context-management/manual-compact.ts +0 -11
  21. package/src/context-management/tools.ts +371 -371
  22. package/src/context-management/types.ts +168 -168
  23. package/src/context-management/window-manager.ts +801 -801
  24. package/src/debug.ts +238 -237
  25. package/src/deferred-tool-carryover.ts +282 -282
  26. package/src/details-store.ts +151 -151
  27. package/src/extension-runtime.ts +1662 -1623
  28. package/src/image-generation/extension.ts +189 -189
  29. package/src/image-generation/protocol.ts +579 -579
  30. package/src/image-generation/types.ts +300 -300
  31. package/src/native-fallback.ts +4 -132
  32. package/src/payload-rewrite.ts +515 -518
  33. package/src/pi-context-hook.ts +117 -117
  34. package/src/remote-v2-client.ts +811 -814
  35. package/src/request-context-cache.ts +155 -155
  36. package/src/responses-headers.ts +180 -180
  37. package/src/runtime.ts +865 -832
  38. package/src/serializer.ts +882 -882
  39. package/src/supported-environment.ts +27 -27
  40. package/src/types.ts +565 -648
  41. package/src/web-search/alpha-client.ts +876 -876
  42. package/src/web-search/extension.ts +530 -530
  43. package/src/web-search/payload.ts +299 -299
  44. package/src/web-search/prompt.ts +60 -60
  45. package/src/web-search/types.ts +166 -166
  46. package/tsconfig.check.json +50 -54
  47. package/src/auto-mode/classifier.ts +0 -161
  48. package/src/auto-mode/extension.ts +0 -691
  49. package/src/auto-mode/model-selection-guard.ts +0 -52
  50. package/src/auto-mode/policy.ts +0 -146
  51. package/src/auto-mode/prompt.ts +0 -279
  52. package/src/auto-mode/reviewer.ts +0 -325
  53. package/src/auto-mode/tool-review-tui.ts +0 -211
  54. package/src/auto-mode/transcript.ts +0 -135
  55. package/src/auto-mode/types.ts +0 -137
@@ -1,518 +1,515 @@
1
- import { buildSessionProjection, sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
2
- import type { AgentMessage } from "@earendil-works/pi-agent-core";
3
- import type { Api, Model } from "@earendil-works/pi-ai";
4
- import type { SessionEntry } from "@earendil-works/pi-coding-agent";
5
- import type { ResponsesCompatibleRequestPayload } from "./runtime";
6
- import { hasVerifiedCompactionInputProvenance } from "./compaction-projection";
7
- import { rewritePayloadWithDeferredToolCarryover } from "./deferred-tool-carryover";
8
- import type {
9
- NativeCompactionEntry,
10
- NativeCompactionInputProvenance,
11
- } from "./types";
12
- import {
13
- serializeMessagesToResponsesInput,
14
- serializeMessagesToResponsesInputWithLimits,
15
- type ResponsesInputContentItem,
16
- type ResponsesInputItem,
17
- type ResponsesInputMessageItem,
18
- } from "./serializer";
19
-
20
- export type NativeReplaySegments = {
21
- boundaryIndex: number;
22
- firstKeptEntryIndex: number;
23
- instructions?: string;
24
- leading: ResponsesInputItem[];
25
- compactionSummary: ResponsesInputItem[];
26
- compactedWindow: unknown[];
27
- post: ResponsesInputItem[];
28
- replayInput: unknown[];
29
- };
30
-
31
- export type NativeReplayPayloadRewrite = {
32
- ok: true;
33
- segments: NativeReplaySegments;
34
- rewrittenPayload: ResponsesCompatibleRequestPayload;
35
- };
36
-
37
- export type NativeReplayPayloadRewriteFailureReason =
38
- | "compaction-boundary-not-found"
39
- | "first-kept-entry-not-found"
40
- | "invalid-compacted-window"
41
- | "unexpected-compaction-after-boundary"
42
- | "compaction-summary-not-found"
43
- | "retained-context-mismatch"
44
- | "unverified-compaction-input"
45
- | "checkpoint-context-edited";
46
-
47
- export type NativeReplayPayloadRewriteFailure = {
48
- ok: false;
49
- reason: NativeReplayPayloadRewriteFailureReason;
50
- parity?: {
51
- actual: string[];
52
- expected: string[];
53
- mismatches: string[];
54
- };
55
- };
56
-
57
- export type NativeReplayPayloadRewriteResult =
58
- | NativeReplayPayloadRewrite
59
- | NativeReplayPayloadRewriteFailure;
60
-
61
- function isRecord(value: unknown): value is Record<string, unknown> {
62
- return !!value && typeof value === "object" && !Array.isArray(value);
63
- }
64
-
65
- function isResponsesInputContentItem(value: unknown): value is ResponsesInputContentItem {
66
- if (!isRecord(value) || typeof value.type !== "string") {
67
- return false;
68
- }
69
-
70
- if (value.type === "input_text") {
71
- return typeof value.text === "string";
72
- }
73
-
74
- if (value.type === "input_image") {
75
- return value.detail === "auto" && typeof value.image_url === "string";
76
- }
77
-
78
- return false;
79
- }
80
-
81
- function isResponsesInputMessageRole(value: unknown): value is ResponsesInputMessageItem["role"] {
82
- return value === "user" || value === "developer" || value === "system";
83
- }
84
-
85
- function cloneResponsesInputContentItem(item: ResponsesInputContentItem): ResponsesInputContentItem {
86
- return item.type === "input_text"
87
- ? {
88
- type: "input_text",
89
- text: item.text,
90
- }
91
- : {
92
- type: "input_image",
93
- detail: "auto",
94
- image_url: item.image_url,
95
- };
96
- }
97
-
98
- function cloneResponsesInputMessageItem(item: ResponsesInputMessageItem): ResponsesInputMessageItem {
99
- return {
100
- role: item.role,
101
- content: typeof item.content === "string" ? item.content : item.content.map(cloneResponsesInputContentItem),
102
- };
103
- }
104
-
105
- function cloneStructuredValue(value: unknown): unknown {
106
- if (
107
- value === undefined ||
108
- value === null ||
109
- typeof value === "string" ||
110
- typeof value === "number" ||
111
- typeof value === "boolean"
112
- ) {
113
- return value;
114
- }
115
-
116
- if (Array.isArray(value)) {
117
- return value.map(cloneStructuredValue);
118
- }
119
-
120
- if (isRecord(value)) {
121
- const clone: Record<string, unknown> = {};
122
- for (const [key, nested] of Object.entries(value)) {
123
- clone[key] = cloneStructuredValue(nested);
124
- }
125
- return clone;
126
- }
127
-
128
- throw new Error(`Unsupported structured value: ${typeof value}`);
129
- }
130
-
131
- function cloneOpaqueCompactedWindow(compactedWindow: readonly unknown[]): unknown[] | undefined {
132
- const cloned: unknown[] = [];
133
-
134
- for (const item of compactedWindow) {
135
- if (!isRecord(item)) {
136
- return undefined;
137
- }
138
-
139
- try {
140
- cloned.push(cloneStructuredValue(item));
141
- } catch {
142
- return undefined;
143
- }
144
- }
145
-
146
- return cloned;
147
- }
148
-
149
- function cloneResponsesInputSlice(items: readonly unknown[]): ResponsesInputItem[] | undefined {
150
- const cloned: ResponsesInputItem[] = [];
151
-
152
- for (const item of items) {
153
- try {
154
- cloned.push(cloneStructuredValue(item) as ResponsesInputItem);
155
- } catch {
156
- return undefined;
157
- }
158
- }
159
-
160
- return cloned;
161
- }
162
-
163
- function areEquivalentValues(left: unknown, right: unknown): boolean {
164
- if (Object.is(left, right)) {
165
- return true;
166
- }
167
-
168
- if (Array.isArray(left) || Array.isArray(right)) {
169
- if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) {
170
- return false;
171
- }
172
-
173
- for (let index = 0; index < left.length; index++) {
174
- if (!areEquivalentValues(left[index], right[index])) {
175
- return false;
176
- }
177
- }
178
-
179
- return true;
180
- }
181
-
182
- if (isRecord(left) || isRecord(right)) {
183
- if (!isRecord(left) || !isRecord(right)) {
184
- return false;
185
- }
186
-
187
- const leftKeys = Object.keys(left).sort();
188
- const rightKeys = Object.keys(right).sort();
189
- if (!areEquivalentValues(leftKeys, rightKeys)) {
190
- return false;
191
- }
192
-
193
- for (const key of leftKeys) {
194
- if (!areEquivalentValues(left[key], right[key])) {
195
- return false;
196
- }
197
- }
198
-
199
- return true;
200
- }
201
-
202
- return false;
203
- }
204
-
205
- function extractUserItemText(item: Record<string, unknown>): unknown {
206
- const { content } = item;
207
- if (typeof content === "string") {
208
- return content;
209
- }
210
-
211
- if (Array.isArray(content)) {
212
- const first = content[0];
213
- if (isRecord(first) && first.type === "input_text" && typeof first.text === "string") {
214
- return first.text;
215
- }
216
- }
217
-
218
- return undefined;
219
- }
220
-
221
- /**
222
- * Locate the Pi-authored compaction summary item. The marker text is unique to
223
- * this compaction entry, so a content match on the user item is a reliable
224
- * single anchor; nothing else in the payload needs to be prefixed or counted.
225
- */
226
- function findCompactionSummaryIndex(input: readonly unknown[], summaryMarker: string): number {
227
- return input.findIndex((item) => {
228
- if (!isRecord(item) || item.role !== "user") {
229
- return false;
230
- }
231
-
232
- const text = extractUserItemText(item);
233
- return typeof text === "string" && text.includes("<summary>") && text.includes(summaryMarker);
234
- });
235
- }
236
-
237
- function collectReplayMessages(entries: readonly SessionEntry[]): AgentMessage[] {
238
- return buildSessionProjection([...entries]).messages;
239
- }
240
-
241
- /** An opaque checkpoint cannot be selectively edited after it was produced. */
242
- export function hasInvalidatedCompactionContext(
243
- entries: readonly SessionEntry[],
244
- compactionEntryId: string,
245
- ): boolean {
246
- const boundary = findCompactionBoundaryIndex(entries, compactionEntryId);
247
- if (boundary === undefined) return false;
248
- const coveredIds = new Set(entries.slice(0, boundary).map((entry) => entry.id));
249
- return entries.slice(boundary + 1).some((entry) =>
250
- entry.type === "context_edit" && coveredIds.has(entry.targetId),
251
- );
252
- }
253
-
254
- function findKeptEntryIndex(
255
- entries: readonly SessionEntry[],
256
- entryId: string,
257
- boundaryIndex: number,
258
- ): number | undefined {
259
- // Pi 0.87 uses the compaction itself as the retained boundary for retain-none.
260
- if (entries[boundaryIndex]?.id === entryId) return boundaryIndex;
261
- const index = entries.findIndex((entry, candidateIndex) => candidateIndex < boundaryIndex && entry.id === entryId);
262
- return index >= 0 ? index : undefined;
263
- }
264
-
265
- export function findCompactionBoundaryIndex(
266
- entries: readonly SessionEntry[],
267
- compactionEntryId: string,
268
- ): number | undefined {
269
- const boundaryIndex = entries.findIndex((entry) => entry.id === compactionEntryId);
270
- return boundaryIndex >= 0 ? boundaryIndex : undefined;
271
- }
272
-
273
- export function findEntriesStrictlyAfterCompactionBoundary(
274
- entries: readonly SessionEntry[],
275
- compactionEntryId: string,
276
- ): SessionEntry[] | undefined {
277
- const boundaryIndex = findCompactionBoundaryIndex(entries, compactionEntryId);
278
- if (boundaryIndex === undefined) {
279
- return undefined;
280
- }
281
-
282
- return entries.slice(boundaryIndex + 1);
283
- }
284
-
285
- export function collectLiveTailMessages(entries: readonly SessionEntry[]): AgentMessage[] {
286
- return collectReplayMessages(entries);
287
- }
288
-
289
- /**
290
- * Remote V2 covers the complete pre-compaction context. Pi also retains
291
- * recent messages, so remove those verified copies before provider conversion
292
- * (where IDs/fields may change across models). Persisted session entries are
293
- * untouched. Preserve transient additions and all post-compaction messages.
294
- */
295
- export function removeNativeCompactionRetainedMessages(args: {
296
- messages: AgentMessage[];
297
- branchEntries: readonly SessionEntry[];
298
- compactionEntry: NativeCompactionEntry;
299
- expectedInputProvenance: NativeCompactionInputProvenance;
300
- }): { ok: true; messages: AgentMessage[] } | NativeReplayPayloadRewriteFailure {
301
- const boundary = findCompactionBoundaryIndex(args.branchEntries, args.compactionEntry.id);
302
- if (boundary === undefined) return { ok: false, reason: "compaction-boundary-not-found" };
303
- if (!hasVerifiedCompactionInputProvenance(args.compactionEntry.details, args.expectedInputProvenance)) {
304
- return { ok: false, reason: "unverified-compaction-input" };
305
- }
306
- if (hasInvalidatedCompactionContext(args.branchEntries, args.compactionEntry.id)) {
307
- return { ok: false, reason: "checkpoint-context-edited" };
308
- }
309
- const firstKept = findKeptEntryIndex(args.branchEntries, args.compactionEntry.firstKeptEntryId, boundary);
310
- if (firstKept === undefined) return { ok: false, reason: "first-kept-entry-not-found" };
311
- const summaryMessage = sessionEntryToContextMessages(args.compactionEntry).find(
312
- (message) => message.role === "compactionSummary",
313
- );
314
- if (!summaryMessage) return { ok: false, reason: "compaction-summary-not-found" };
315
- const summary = structuredClone(summaryMessage);
316
- const summaryIndex = args.messages.findIndex((message) => areEquivalentValues(message, summary));
317
- if (summaryIndex < 0) return { ok: false, reason: "compaction-summary-not-found" };
318
- const retainedIds = new Set(args.branchEntries.slice(firstKept, boundary).map((entry) => entry.id));
319
- const retained = buildSessionProjection([...args.branchEntries]).entries
320
- .filter((entry) => retainedIds.has(entry.sourceEntry.id))
321
- .flatMap((entry) => entry.messages)
322
- .map((message) => structuredClone(message));
323
- // Custom messages are optional context-hook entries: a hook may filter or
324
- // rewrite them before provider serialization. An exact custom copy is still
325
- // removed when present, while a missing or rewritten copy is left alone.
326
- // Required user/assistant/tool messages keep the ordered, fail-closed
327
- // matching contract.
328
- // Pi 0.86 promotes transcript system messages to the leading prompt when it
329
- // rebuilds context after compaction. They can therefore appear before the
330
- // compaction summary even when their session entry lies inside the retained
331
- // span; keep them in place instead of treating that relocation as a mismatch.
332
- const requiredRetained = retained.filter(
333
- (message) => message.role !== "custom" && (message.role as string) !== "system",
334
- );
335
- const optionalRetained = retained.filter((message) => message.role === "custom");
336
- const removed = new Set<number>();
337
- let cursor = summaryIndex + 1;
338
- for (const expected of requiredRetained) {
339
- const index = args.messages.findIndex((message, index) => index >= cursor && areEquivalentValues(message, expected));
340
- if (index >= 0) {
341
- removed.add(index);
342
- cursor = index + 1;
343
- }
344
- }
345
- // An empty required span is valid. For a non-empty span, zero matches is
346
- // ambiguous with the entire required history having been removed or changed;
347
- // fail closed instead of treating it as an already-filtered no-op. Partial
348
- // matches are equally unsafe because they can remove half a call/result batch.
349
- if (requiredRetained.length > 0 && removed.size !== requiredRetained.length) {
350
- return { ok: false, reason: "retained-context-mismatch" };
351
- }
352
-
353
- // Optional entries do not affect the strict parity decision. Search for each
354
- // exact copy independently so a reordered custom entry can still be removed,
355
- // but a provider-visible metadata/content rewrite remains untouched.
356
- for (const expected of optionalRetained) {
357
- const index = args.messages.findIndex(
358
- (message, candidateIndex) =>
359
- candidateIndex > summaryIndex && !removed.has(candidateIndex) && areEquivalentValues(message, expected),
360
- );
361
- if (index >= 0) removed.add(index);
362
- }
363
-
364
- return removed.size === 0
365
- ? { ok: true, messages: args.messages }
366
- : { ok: true, messages: args.messages.filter((_, index) => !removed.has(index)) };
367
- }
368
-
369
- export function serializeLiveTailToResponsesInput<TApi extends Api>(args: {
370
- model: Model<TApi>;
371
- entries: readonly SessionEntry[];
372
- }): ResponsesInputItem[] {
373
- return serializeMessagesToResponsesInput(args.model, collectReplayMessages(args.entries), {
374
- firstSystemMessageIsUpdate: true,
375
- });
376
- }
377
-
378
- export async function serializeLiveTailToResponsesInputWithLimits<TApi extends Api>(args: {
379
- model: Model<TApi>;
380
- entries: readonly SessionEntry[];
381
- }): Promise<ResponsesInputItem[]> {
382
- return serializeMessagesToResponsesInputWithLimits(args.model, collectReplayMessages(args.entries), {
383
- firstSystemMessageIsUpdate: true,
384
- });
385
- }
386
-
387
- function buildNativeReplaySegmentsInternal<TApi extends Api>(args: {
388
- model: Model<TApi>;
389
- payload: ResponsesCompatibleRequestPayload;
390
- branchEntries: readonly SessionEntry[];
391
- compactionEntry: NativeCompactionEntry;
392
- expectedInputProvenance: NativeCompactionInputProvenance;
393
- }): NativeReplayPayloadRewriteResult {
394
- const boundaryIndex = findCompactionBoundaryIndex(args.branchEntries, args.compactionEntry.id);
395
- if (boundaryIndex === undefined) {
396
- return {
397
- ok: false,
398
- reason: "compaction-boundary-not-found",
399
- };
400
- }
401
-
402
- if (hasInvalidatedCompactionContext(args.branchEntries, args.compactionEntry.id)) {
403
- return { ok: false, reason: "checkpoint-context-edited" };
404
- }
405
-
406
- const firstKeptEntryIndex = findKeptEntryIndex(
407
- args.branchEntries,
408
- args.compactionEntry.firstKeptEntryId,
409
- boundaryIndex,
410
- );
411
- if (firstKeptEntryIndex === undefined) {
412
- return {
413
- ok: false,
414
- reason: "first-kept-entry-not-found",
415
- };
416
- }
417
-
418
- const newerCompactionEntry = args.branchEntries
419
- .slice(boundaryIndex + 1)
420
- .some((entry) => entry.type === "compaction");
421
- if (newerCompactionEntry) {
422
- return {
423
- ok: false,
424
- reason: "unexpected-compaction-after-boundary",
425
- };
426
- }
427
-
428
- const details = args.compactionEntry.details;
429
- if (!details) {
430
- return {
431
- ok: false,
432
- reason: "invalid-compacted-window",
433
- };
434
- }
435
- if (!hasVerifiedCompactionInputProvenance(details, args.expectedInputProvenance)) {
436
- return {
437
- ok: false,
438
- reason: "unverified-compaction-input",
439
- };
440
- }
441
-
442
- const compactedWindow = cloneOpaqueCompactedWindow(details.compactedWindow);
443
- if (!compactedWindow) {
444
- return {
445
- ok: false,
446
- reason: "invalid-compacted-window",
447
- };
448
- }
449
-
450
- // The context hook has removed Pi's verified retained copies. Replace only
451
- // the summary anchor here; preserve provider fields and transient/post items.
452
- const summaryIndex = findCompactionSummaryIndex(args.payload.input, args.compactionEntry.summary);
453
- if (summaryIndex < 0) {
454
- return {
455
- ok: false,
456
- reason: "compaction-summary-not-found",
457
- };
458
- }
459
-
460
- const leadingInput = cloneResponsesInputSlice(args.payload.input.slice(0, summaryIndex));
461
- const actualCompactionSummary = cloneResponsesInputSlice([args.payload.input[summaryIndex]]);
462
- const actualPostInput = cloneResponsesInputSlice(args.payload.input.slice(summaryIndex + 1));
463
- if (!leadingInput || !actualCompactionSummary || !actualPostInput) {
464
- return {
465
- ok: false,
466
- reason: "compaction-summary-not-found",
467
- };
468
- }
469
-
470
- const baseRewrittenPayload: ResponsesCompatibleRequestPayload = {
471
- ...args.payload,
472
- input: [...leadingInput, ...compactedWindow, ...actualPostInput],
473
- };
474
- const carryoverRewrite = rewritePayloadWithDeferredToolCarryover({
475
- payload: baseRewrittenPayload,
476
- carryover: details.deferredToolCarryover,
477
- compactionEntryId: args.compactionEntry.id,
478
- checkpointEndIndex: leadingInput.length + compactedWindow.length,
479
- compat: args.model.compat as
480
- | { supportsAdditionalTools?: boolean; supportsToolSearch?: boolean }
481
- | undefined,
482
- });
483
-
484
- return {
485
- ok: true,
486
- segments: {
487
- boundaryIndex,
488
- firstKeptEntryIndex,
489
- instructions: typeof args.payload.instructions === "string" ? args.payload.instructions : undefined,
490
- leading: leadingInput,
491
- compactionSummary: actualCompactionSummary,
492
- compactedWindow,
493
- post: actualPostInput,
494
- replayInput: carryoverRewrite.payload.input,
495
- },
496
- rewrittenPayload: carryoverRewrite.payload,
497
- };
498
- }
499
-
500
- export function buildNativeReplaySegments<TApi extends Api>(args: {
501
- model: Model<TApi>;
502
- payload: ResponsesCompatibleRequestPayload;
503
- branchEntries: readonly SessionEntry[];
504
- compactionEntry: NativeCompactionEntry;
505
- expectedInputProvenance: NativeCompactionInputProvenance;
506
- }): NativeReplayPayloadRewriteResult {
507
- return buildNativeReplaySegmentsInternal(args);
508
- }
509
-
510
- export function rewriteResponsesPayloadWithNativeReplay<TApi extends Api>(args: {
511
- model: Model<TApi>;
512
- payload: ResponsesCompatibleRequestPayload;
513
- branchEntries: readonly SessionEntry[];
514
- compactionEntry: NativeCompactionEntry;
515
- expectedInputProvenance: NativeCompactionInputProvenance;
516
- }): NativeReplayPayloadRewriteResult {
517
- return buildNativeReplaySegmentsInternal(args);
518
- }
1
+ import { buildSessionProjection, sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
2
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
3
+ import type { Api, Model } from "@earendil-works/pi-ai";
4
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
5
+ import type { ResponsesCompatibleRequestPayload } from "./runtime";
6
+ import { hasVerifiedCompactionInputProvenance } from "./compaction-projection";
7
+ import { rewritePayloadWithDeferredToolCarryover } from "./deferred-tool-carryover";
8
+ import type {
9
+ NativeCompactionEntry,
10
+ NativeCompactionInputProvenance,
11
+ } from "./types";
12
+ import {
13
+ serializeMessagesToResponsesInput,
14
+ serializeMessagesToResponsesInputWithLimits,
15
+ type ResponsesInputContentItem,
16
+ type ResponsesInputItem,
17
+ type ResponsesInputMessageItem,
18
+ } from "./serializer";
19
+
20
+ export type NativeReplaySegments = {
21
+ boundaryIndex: number;
22
+ firstKeptEntryIndex: number;
23
+ instructions?: string;
24
+ leading: ResponsesInputItem[];
25
+ compactionSummary: ResponsesInputItem[];
26
+ compactedWindow: unknown[];
27
+ post: ResponsesInputItem[];
28
+ replayInput: unknown[];
29
+ };
30
+
31
+ export type NativeReplayPayloadRewrite = {
32
+ ok: true;
33
+ segments: NativeReplaySegments;
34
+ rewrittenPayload: ResponsesCompatibleRequestPayload;
35
+ };
36
+
37
+ export type NativeReplayPayloadRewriteFailureReason =
38
+ | "compaction-boundary-not-found"
39
+ | "first-kept-entry-not-found"
40
+ | "invalid-compacted-window"
41
+ | "unexpected-compaction-after-boundary"
42
+ | "compaction-summary-not-found"
43
+ | "retained-context-mismatch"
44
+ | "unverified-compaction-input"
45
+ | "checkpoint-context-edited";
46
+
47
+ export type NativeReplayPayloadRewriteFailure = {
48
+ ok: false;
49
+ reason: NativeReplayPayloadRewriteFailureReason;
50
+ parity?: {
51
+ actual: string[];
52
+ expected: string[];
53
+ mismatches: string[];
54
+ };
55
+ };
56
+
57
+ export type NativeReplayPayloadRewriteResult =
58
+ | NativeReplayPayloadRewrite
59
+ | NativeReplayPayloadRewriteFailure;
60
+
61
+ function isRecord(value: unknown): value is Record<string, unknown> {
62
+ return !!value && typeof value === "object" && !Array.isArray(value);
63
+ }
64
+
65
+ function isResponsesInputContentItem(value: unknown): value is ResponsesInputContentItem {
66
+ if (!isRecord(value) || typeof value.type !== "string") {
67
+ return false;
68
+ }
69
+
70
+ if (value.type === "input_text") {
71
+ return typeof value.text === "string";
72
+ }
73
+
74
+ if (value.type === "input_image") {
75
+ return value.detail === "auto" && typeof value.image_url === "string";
76
+ }
77
+
78
+ return false;
79
+ }
80
+
81
+ function isResponsesInputMessageRole(value: unknown): value is ResponsesInputMessageItem["role"] {
82
+ return value === "user" || value === "developer" || value === "system";
83
+ }
84
+
85
+ function cloneResponsesInputContentItem(item: ResponsesInputContentItem): ResponsesInputContentItem {
86
+ return item.type === "input_text"
87
+ ? {
88
+ type: "input_text",
89
+ text: item.text,
90
+ }
91
+ : {
92
+ type: "input_image",
93
+ detail: "auto",
94
+ image_url: item.image_url,
95
+ };
96
+ }
97
+
98
+ function cloneResponsesInputMessageItem(item: ResponsesInputMessageItem): ResponsesInputMessageItem {
99
+ return {
100
+ role: item.role,
101
+ content: typeof item.content === "string" ? item.content : item.content.map(cloneResponsesInputContentItem),
102
+ };
103
+ }
104
+
105
+ function cloneStructuredValue(value: unknown): unknown {
106
+ if (
107
+ value === undefined ||
108
+ value === null ||
109
+ typeof value === "string" ||
110
+ typeof value === "number" ||
111
+ typeof value === "boolean"
112
+ ) {
113
+ return value;
114
+ }
115
+
116
+ if (Array.isArray(value)) {
117
+ return value.map(cloneStructuredValue);
118
+ }
119
+
120
+ if (isRecord(value)) {
121
+ // Define own data properties, including provider JSON keys such as __proto__.
122
+ return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, cloneStructuredValue(nested)]));
123
+ }
124
+
125
+ throw new Error(`Unsupported structured value: ${typeof value}`);
126
+ }
127
+
128
+ function cloneOpaqueCompactedWindow(compactedWindow: readonly unknown[]): unknown[] | undefined {
129
+ const cloned: unknown[] = [];
130
+
131
+ for (const item of compactedWindow) {
132
+ if (!isRecord(item)) {
133
+ return undefined;
134
+ }
135
+
136
+ try {
137
+ cloned.push(cloneStructuredValue(item));
138
+ } catch {
139
+ return undefined;
140
+ }
141
+ }
142
+
143
+ return cloned;
144
+ }
145
+
146
+ function cloneResponsesInputSlice(items: readonly unknown[]): ResponsesInputItem[] | undefined {
147
+ const cloned: ResponsesInputItem[] = [];
148
+
149
+ for (const item of items) {
150
+ try {
151
+ cloned.push(cloneStructuredValue(item) as ResponsesInputItem);
152
+ } catch {
153
+ return undefined;
154
+ }
155
+ }
156
+
157
+ return cloned;
158
+ }
159
+
160
+ function areEquivalentValues(left: unknown, right: unknown): boolean {
161
+ if (Object.is(left, right)) {
162
+ return true;
163
+ }
164
+
165
+ if (Array.isArray(left) || Array.isArray(right)) {
166
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) {
167
+ return false;
168
+ }
169
+
170
+ for (let index = 0; index < left.length; index++) {
171
+ if (!areEquivalentValues(left[index], right[index])) {
172
+ return false;
173
+ }
174
+ }
175
+
176
+ return true;
177
+ }
178
+
179
+ if (isRecord(left) || isRecord(right)) {
180
+ if (!isRecord(left) || !isRecord(right)) {
181
+ return false;
182
+ }
183
+
184
+ const leftKeys = Object.keys(left).sort();
185
+ const rightKeys = Object.keys(right).sort();
186
+ if (!areEquivalentValues(leftKeys, rightKeys)) {
187
+ return false;
188
+ }
189
+
190
+ for (const key of leftKeys) {
191
+ if (!areEquivalentValues(left[key], right[key])) {
192
+ return false;
193
+ }
194
+ }
195
+
196
+ return true;
197
+ }
198
+
199
+ return false;
200
+ }
201
+
202
+ function extractUserItemText(item: Record<string, unknown>): unknown {
203
+ const { content } = item;
204
+ if (typeof content === "string") {
205
+ return content;
206
+ }
207
+
208
+ if (Array.isArray(content)) {
209
+ const first = content[0];
210
+ if (isRecord(first) && first.type === "input_text" && typeof first.text === "string") {
211
+ return first.text;
212
+ }
213
+ }
214
+
215
+ return undefined;
216
+ }
217
+
218
+ /**
219
+ * Locate the Pi-authored compaction summary item. The marker text is unique to
220
+ * this compaction entry, so a content match on the user item is a reliable
221
+ * single anchor; nothing else in the payload needs to be prefixed or counted.
222
+ */
223
+ function findCompactionSummaryIndex(input: readonly unknown[], summaryMarker: string): number {
224
+ return input.findIndex((item) => {
225
+ if (!isRecord(item) || item.role !== "user") {
226
+ return false;
227
+ }
228
+
229
+ const text = extractUserItemText(item);
230
+ return typeof text === "string" && text.includes("<summary>") && text.includes(summaryMarker);
231
+ });
232
+ }
233
+
234
+ function collectReplayMessages(entries: readonly SessionEntry[]): AgentMessage[] {
235
+ return buildSessionProjection([...entries]).messages;
236
+ }
237
+
238
+ /** An opaque checkpoint cannot be selectively edited after it was produced. */
239
+ export function hasInvalidatedCompactionContext(
240
+ entries: readonly SessionEntry[],
241
+ compactionEntryId: string,
242
+ ): boolean {
243
+ const boundary = findCompactionBoundaryIndex(entries, compactionEntryId);
244
+ if (boundary === undefined) return false;
245
+ const coveredIds = new Set(entries.slice(0, boundary).map((entry) => entry.id));
246
+ return entries.slice(boundary + 1).some((entry) =>
247
+ entry.type === "context_edit" && coveredIds.has(entry.targetId),
248
+ );
249
+ }
250
+
251
+ function findKeptEntryIndex(
252
+ entries: readonly SessionEntry[],
253
+ entryId: string,
254
+ boundaryIndex: number,
255
+ ): number | undefined {
256
+ // Pi 0.87 uses the compaction itself as the retained boundary for retain-none.
257
+ if (entries[boundaryIndex]?.id === entryId) return boundaryIndex;
258
+ const index = entries.findIndex((entry, candidateIndex) => candidateIndex < boundaryIndex && entry.id === entryId);
259
+ return index >= 0 ? index : undefined;
260
+ }
261
+
262
+ export function findCompactionBoundaryIndex(
263
+ entries: readonly SessionEntry[],
264
+ compactionEntryId: string,
265
+ ): number | undefined {
266
+ const boundaryIndex = entries.findIndex((entry) => entry.id === compactionEntryId);
267
+ return boundaryIndex >= 0 ? boundaryIndex : undefined;
268
+ }
269
+
270
+ export function findEntriesStrictlyAfterCompactionBoundary(
271
+ entries: readonly SessionEntry[],
272
+ compactionEntryId: string,
273
+ ): SessionEntry[] | undefined {
274
+ const boundaryIndex = findCompactionBoundaryIndex(entries, compactionEntryId);
275
+ if (boundaryIndex === undefined) {
276
+ return undefined;
277
+ }
278
+
279
+ return entries.slice(boundaryIndex + 1);
280
+ }
281
+
282
+ export function collectLiveTailMessages(entries: readonly SessionEntry[]): AgentMessage[] {
283
+ return collectReplayMessages(entries);
284
+ }
285
+
286
+ /**
287
+ * Remote V2 covers the complete pre-compaction context. Pi also retains
288
+ * recent messages, so remove those verified copies before provider conversion
289
+ * (where IDs/fields may change across models). Persisted session entries are
290
+ * untouched. Preserve transient additions and all post-compaction messages.
291
+ */
292
+ export function removeNativeCompactionRetainedMessages(args: {
293
+ messages: AgentMessage[];
294
+ branchEntries: readonly SessionEntry[];
295
+ compactionEntry: NativeCompactionEntry;
296
+ expectedInputProvenance: NativeCompactionInputProvenance;
297
+ }): { ok: true; messages: AgentMessage[] } | NativeReplayPayloadRewriteFailure {
298
+ const boundary = findCompactionBoundaryIndex(args.branchEntries, args.compactionEntry.id);
299
+ if (boundary === undefined) return { ok: false, reason: "compaction-boundary-not-found" };
300
+ if (!hasVerifiedCompactionInputProvenance(args.compactionEntry.details, args.expectedInputProvenance)) {
301
+ return { ok: false, reason: "unverified-compaction-input" };
302
+ }
303
+ if (hasInvalidatedCompactionContext(args.branchEntries, args.compactionEntry.id)) {
304
+ return { ok: false, reason: "checkpoint-context-edited" };
305
+ }
306
+ const firstKept = findKeptEntryIndex(args.branchEntries, args.compactionEntry.firstKeptEntryId, boundary);
307
+ if (firstKept === undefined) return { ok: false, reason: "first-kept-entry-not-found" };
308
+ const summaryMessage = sessionEntryToContextMessages(args.compactionEntry).find(
309
+ (message) => message.role === "compactionSummary",
310
+ );
311
+ if (!summaryMessage) return { ok: false, reason: "compaction-summary-not-found" };
312
+ const summary = structuredClone(summaryMessage);
313
+ const summaryIndex = args.messages.findIndex((message) => areEquivalentValues(message, summary));
314
+ if (summaryIndex < 0) return { ok: false, reason: "compaction-summary-not-found" };
315
+ const retainedIds = new Set(args.branchEntries.slice(firstKept, boundary).map((entry) => entry.id));
316
+ const retained = buildSessionProjection([...args.branchEntries]).entries
317
+ .filter((entry) => retainedIds.has(entry.sourceEntry.id))
318
+ .flatMap((entry) => entry.messages)
319
+ .map((message) => structuredClone(message));
320
+ // Custom messages are optional context-hook entries: a hook may filter or
321
+ // rewrite them before provider serialization. An exact custom copy is still
322
+ // removed when present, while a missing or rewritten copy is left alone.
323
+ // Required user/assistant/tool messages keep the ordered, fail-closed
324
+ // matching contract.
325
+ // Pi 0.86 promotes transcript system messages to the leading prompt when it
326
+ // rebuilds context after compaction. They can therefore appear before the
327
+ // compaction summary even when their session entry lies inside the retained
328
+ // span; keep them in place instead of treating that relocation as a mismatch.
329
+ const requiredRetained = retained.filter(
330
+ (message) => message.role !== "custom" && (message.role as string) !== "system",
331
+ );
332
+ const optionalRetained = retained.filter((message) => message.role === "custom");
333
+ const removed = new Set<number>();
334
+ let cursor = summaryIndex + 1;
335
+ for (const expected of requiredRetained) {
336
+ const index = args.messages.findIndex((message, index) => index >= cursor && areEquivalentValues(message, expected));
337
+ if (index >= 0) {
338
+ removed.add(index);
339
+ cursor = index + 1;
340
+ }
341
+ }
342
+ // An empty required span is valid. For a non-empty span, zero matches is
343
+ // ambiguous with the entire required history having been removed or changed;
344
+ // fail closed instead of treating it as an already-filtered no-op. Partial
345
+ // matches are equally unsafe because they can remove half a call/result batch.
346
+ if (requiredRetained.length > 0 && removed.size !== requiredRetained.length) {
347
+ return { ok: false, reason: "retained-context-mismatch" };
348
+ }
349
+
350
+ // Optional entries do not affect the strict parity decision. Search for each
351
+ // exact copy independently so a reordered custom entry can still be removed,
352
+ // but a provider-visible metadata/content rewrite remains untouched.
353
+ for (const expected of optionalRetained) {
354
+ const index = args.messages.findIndex(
355
+ (message, candidateIndex) =>
356
+ candidateIndex > summaryIndex && !removed.has(candidateIndex) && areEquivalentValues(message, expected),
357
+ );
358
+ if (index >= 0) removed.add(index);
359
+ }
360
+
361
+ return removed.size === 0
362
+ ? { ok: true, messages: args.messages }
363
+ : { ok: true, messages: args.messages.filter((_, index) => !removed.has(index)) };
364
+ }
365
+
366
+ export function serializeLiveTailToResponsesInput<TApi extends Api>(args: {
367
+ model: Model<TApi>;
368
+ entries: readonly SessionEntry[];
369
+ }): ResponsesInputItem[] {
370
+ return serializeMessagesToResponsesInput(args.model, collectReplayMessages(args.entries), {
371
+ firstSystemMessageIsUpdate: true,
372
+ });
373
+ }
374
+
375
+ export async function serializeLiveTailToResponsesInputWithLimits<TApi extends Api>(args: {
376
+ model: Model<TApi>;
377
+ entries: readonly SessionEntry[];
378
+ }): Promise<ResponsesInputItem[]> {
379
+ return serializeMessagesToResponsesInputWithLimits(args.model, collectReplayMessages(args.entries), {
380
+ firstSystemMessageIsUpdate: true,
381
+ });
382
+ }
383
+
384
+ function buildNativeReplaySegmentsInternal<TApi extends Api>(args: {
385
+ model: Model<TApi>;
386
+ payload: ResponsesCompatibleRequestPayload;
387
+ branchEntries: readonly SessionEntry[];
388
+ compactionEntry: NativeCompactionEntry;
389
+ expectedInputProvenance: NativeCompactionInputProvenance;
390
+ }): NativeReplayPayloadRewriteResult {
391
+ const boundaryIndex = findCompactionBoundaryIndex(args.branchEntries, args.compactionEntry.id);
392
+ if (boundaryIndex === undefined) {
393
+ return {
394
+ ok: false,
395
+ reason: "compaction-boundary-not-found",
396
+ };
397
+ }
398
+
399
+ if (hasInvalidatedCompactionContext(args.branchEntries, args.compactionEntry.id)) {
400
+ return { ok: false, reason: "checkpoint-context-edited" };
401
+ }
402
+
403
+ const firstKeptEntryIndex = findKeptEntryIndex(
404
+ args.branchEntries,
405
+ args.compactionEntry.firstKeptEntryId,
406
+ boundaryIndex,
407
+ );
408
+ if (firstKeptEntryIndex === undefined) {
409
+ return {
410
+ ok: false,
411
+ reason: "first-kept-entry-not-found",
412
+ };
413
+ }
414
+
415
+ const newerCompactionEntry = args.branchEntries
416
+ .slice(boundaryIndex + 1)
417
+ .some((entry) => entry.type === "compaction");
418
+ if (newerCompactionEntry) {
419
+ return {
420
+ ok: false,
421
+ reason: "unexpected-compaction-after-boundary",
422
+ };
423
+ }
424
+
425
+ const details = args.compactionEntry.details;
426
+ if (!details) {
427
+ return {
428
+ ok: false,
429
+ reason: "invalid-compacted-window",
430
+ };
431
+ }
432
+ if (!hasVerifiedCompactionInputProvenance(details, args.expectedInputProvenance)) {
433
+ return {
434
+ ok: false,
435
+ reason: "unverified-compaction-input",
436
+ };
437
+ }
438
+
439
+ const compactedWindow = cloneOpaqueCompactedWindow(details.compactedWindow);
440
+ if (!compactedWindow) {
441
+ return {
442
+ ok: false,
443
+ reason: "invalid-compacted-window",
444
+ };
445
+ }
446
+
447
+ // The context hook has removed Pi's verified retained copies. Replace only
448
+ // the summary anchor here; preserve provider fields and transient/post items.
449
+ const summaryIndex = findCompactionSummaryIndex(args.payload.input, args.compactionEntry.summary);
450
+ if (summaryIndex < 0) {
451
+ return {
452
+ ok: false,
453
+ reason: "compaction-summary-not-found",
454
+ };
455
+ }
456
+
457
+ const leadingInput = cloneResponsesInputSlice(args.payload.input.slice(0, summaryIndex));
458
+ const actualCompactionSummary = cloneResponsesInputSlice([args.payload.input[summaryIndex]]);
459
+ const actualPostInput = cloneResponsesInputSlice(args.payload.input.slice(summaryIndex + 1));
460
+ if (!leadingInput || !actualCompactionSummary || !actualPostInput) {
461
+ return {
462
+ ok: false,
463
+ reason: "compaction-summary-not-found",
464
+ };
465
+ }
466
+
467
+ const baseRewrittenPayload: ResponsesCompatibleRequestPayload = {
468
+ ...args.payload,
469
+ input: [...leadingInput, ...compactedWindow, ...actualPostInput],
470
+ };
471
+ const carryoverRewrite = rewritePayloadWithDeferredToolCarryover({
472
+ payload: baseRewrittenPayload,
473
+ carryover: details.deferredToolCarryover,
474
+ compactionEntryId: args.compactionEntry.id,
475
+ checkpointEndIndex: leadingInput.length + compactedWindow.length,
476
+ compat: args.model.compat as
477
+ | { supportsAdditionalTools?: boolean; supportsToolSearch?: boolean }
478
+ | undefined,
479
+ });
480
+
481
+ return {
482
+ ok: true,
483
+ segments: {
484
+ boundaryIndex,
485
+ firstKeptEntryIndex,
486
+ instructions: typeof args.payload.instructions === "string" ? args.payload.instructions : undefined,
487
+ leading: leadingInput,
488
+ compactionSummary: actualCompactionSummary,
489
+ compactedWindow,
490
+ post: actualPostInput,
491
+ replayInput: carryoverRewrite.payload.input,
492
+ },
493
+ rewrittenPayload: carryoverRewrite.payload,
494
+ };
495
+ }
496
+
497
+ export function buildNativeReplaySegments<TApi extends Api>(args: {
498
+ model: Model<TApi>;
499
+ payload: ResponsesCompatibleRequestPayload;
500
+ branchEntries: readonly SessionEntry[];
501
+ compactionEntry: NativeCompactionEntry;
502
+ expectedInputProvenance: NativeCompactionInputProvenance;
503
+ }): NativeReplayPayloadRewriteResult {
504
+ return buildNativeReplaySegmentsInternal(args);
505
+ }
506
+
507
+ export function rewriteResponsesPayloadWithNativeReplay<TApi extends Api>(args: {
508
+ model: Model<TApi>;
509
+ payload: ResponsesCompatibleRequestPayload;
510
+ branchEntries: readonly SessionEntry[];
511
+ compactionEntry: NativeCompactionEntry;
512
+ expectedInputProvenance: NativeCompactionInputProvenance;
513
+ }): NativeReplayPayloadRewriteResult {
514
+ return buildNativeReplaySegmentsInternal(args);
515
+ }