pi-openai-toolkit 0.6.0 → 0.7.1

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.
@@ -7,6 +7,7 @@ import type {
7
7
  SessionMessageEntry,
8
8
  } from "@earendil-works/pi-coding-agent";
9
9
  import type { ResponsesCompatibleRequestPayload } from "./runtime";
10
+ import { rewritePayloadWithDeferredToolCarryover } from "./deferred-tool-carryover";
10
11
  import type { NativeCompactionEntry } from "./types";
11
12
  import {
12
13
  compareResponsesInputParity,
@@ -16,12 +17,6 @@ import {
16
17
  type ResponsesInputMessageItem,
17
18
  } from "./serializer";
18
19
 
19
- export type FreshAuthoritativePreamble = {
20
- instructions?: string;
21
- leadingInput: ResponsesInputMessageItem[];
22
- trailingInput: ResponsesInputMessageItem[];
23
- };
24
-
25
20
  export type SerializedReplaySlice = {
26
21
  entries: SessionEntry[];
27
22
  messages: AgentMessage[];
@@ -51,10 +46,10 @@ export type NativeReplayPayloadRewrite = {
51
46
  export type NativeReplayPayloadRewriteFailureReason =
52
47
  | "compaction-boundary-not-found"
53
48
  | "first-kept-entry-not-found"
54
- | "unsupported-instructions"
55
49
  | "invalid-compacted-window"
56
50
  | "unexpected-compaction-after-boundary"
57
- | "expected-pi-replay-mismatch";
51
+ | "expected-pi-replay-mismatch"
52
+ | "compaction-summary-not-found";
58
53
 
59
54
  export type NativeReplayPayloadRewriteFailure = {
60
55
  ok: false;
@@ -267,44 +262,50 @@ function toReplayAgentMessage(entry: SessionEntry): AgentMessage | undefined {
267
262
  return undefined;
268
263
  }
269
264
 
270
- function isPromptEnvelopeItem(item: unknown): item is ResponsesInputMessageItem {
271
- return isResponsesInputMessageItem(item) && isPreambleRole(item.role);
272
- }
273
-
274
- export function extractFreshAuthoritativePreamble(
275
- payload: ResponsesCompatibleRequestPayload,
276
- ): FreshAuthoritativePreamble | undefined {
277
- if (payload.instructions !== undefined && typeof payload.instructions !== "string") {
278
- return undefined;
265
+ function extractUserItemText(item: Record<string, unknown>): unknown {
266
+ const { content } = item;
267
+ if (typeof content === "string") {
268
+ return content;
279
269
  }
280
270
 
281
- // Developer/system items in Pi's Responses payload are prompt-level instructions,
282
- // not transcript entries from session history. Preserve them in the same leading
283
- // or trailing position that Pi authored so provider-added suffix prompts like
284
- // GPT-5's trailing developer "# Juice: 0 !important" survive replay unchanged.
285
- let leadingBoundary = 0;
286
- while (leadingBoundary < payload.input.length && isPromptEnvelopeItem(payload.input[leadingBoundary])) {
287
- leadingBoundary += 1;
271
+ if (Array.isArray(content)) {
272
+ const first = content[0];
273
+ if (isRecord(first) && first.type === "input_text" && typeof first.text === "string") {
274
+ return first.text;
275
+ }
288
276
  }
289
277
 
290
- let trailingBoundary = payload.input.length;
291
- while (trailingBoundary > leadingBoundary && isPromptEnvelopeItem(payload.input[trailingBoundary - 1])) {
292
- trailingBoundary -= 1;
293
- }
278
+ return undefined;
279
+ }
294
280
 
295
- for (let index = leadingBoundary; index < trailingBoundary; index++) {
296
- if (isPromptEnvelopeItem(payload.input[index])) {
297
- return undefined;
281
+ /**
282
+ * Locate the Pi-authored compaction summary item. The marker text is unique to
283
+ * this compaction entry, so a content match on the user item is a reliable
284
+ * single anchor; nothing else in the payload needs to be prefixed or counted.
285
+ */
286
+ function findCompactionSummaryIndex(input: readonly unknown[], summaryMarker: string): number {
287
+ return input.findIndex((item) => {
288
+ if (!isRecord(item) || item.role !== "user") {
289
+ return false;
298
290
  }
299
- }
300
291
 
301
- return {
302
- ...(typeof payload.instructions === "string" ? { instructions: payload.instructions } : {}),
303
- leadingInput: payload.input.slice(0, leadingBoundary).map((item) => cloneResponsesInputMessageItem(item as ResponsesInputMessageItem)),
304
- trailingInput: payload.input
305
- .slice(trailingBoundary)
306
- .map((item) => cloneResponsesInputMessageItem(item as ResponsesInputMessageItem)),
307
- };
292
+ const text = extractUserItemText(item);
293
+ return typeof text === "string" && text.includes("<summary>") && text.includes(summaryMarker);
294
+ });
295
+ }
296
+
297
+ /** Count consecutive provider-authored developer/system items at the end of the input. */
298
+ function countTrailingPreambleItems(items: readonly unknown[]): number {
299
+ let count = 0;
300
+ for (let index = items.length - 1; index >= 0; index--) {
301
+ const item = items[index];
302
+ if (isResponsesInputMessageItem(item) && isPreambleRole(item.role)) {
303
+ count += 1;
304
+ continue;
305
+ }
306
+ break;
307
+ }
308
+ return count;
308
309
  }
309
310
 
310
311
  function collectReplayMessages(entries: readonly SessionEntry[]): AgentMessage[] {
@@ -407,14 +408,6 @@ function buildNativeReplaySegmentsInternal<TApi extends Api>(args: {
407
408
  };
408
409
  }
409
410
 
410
- const freshPreamble = extractFreshAuthoritativePreamble(args.payload);
411
- if (!freshPreamble) {
412
- return {
413
- ok: false,
414
- reason: "unsupported-instructions",
415
- };
416
- }
417
-
418
411
  const newerCompactionEntry = args.branchEntries
419
412
  .slice(boundaryIndex + 1)
420
413
  .some((entry) => entry.type === "compaction");
@@ -446,19 +439,38 @@ function buildNativeReplaySegmentsInternal<TApi extends Api>(args: {
446
439
  const preCompactionKeptMessages = collectReplayMessages(preCompactionEntries);
447
440
  const postCompactionTailMessages = collectReplayMessages(postCompactionEntries);
448
441
  const compactionSummaryMessage = createCompactionSummaryAgentMessage(args.compactionEntry);
449
- const serializedPiHistoryInput = serializeMessagesToResponsesInput(args.model, [
450
- compactionSummaryMessage,
451
- ...preCompactionKeptMessages,
452
- ...postCompactionTailMessages,
453
- ]);
454
- const originalPiReplayInput: ResponsesInputItem[] = [
455
- ...freshPreamble.leadingInput,
456
- ...serializedPiHistoryInput,
457
- ...freshPreamble.trailingInput,
458
- ];
442
+ const keptWindowSerialized = serializeMessagesToResponsesInput(args.model, preCompactionKeptMessages);
459
443
 
460
- if (!areEquivalentValues(args.payload.input, originalPiReplayInput)) {
461
- const parity = compareResponsesInputParity(args.payload.input, originalPiReplayInput);
444
+ // Pi-authored compaction summary item. Everything outside the replaced region
445
+ // is preserved verbatim, so this content anchor is the only location probe
446
+ // needed; the marker text is unique to this compaction entry.
447
+ const summaryIndex = findCompactionSummaryIndex(args.payload.input, args.compactionEntry.summary);
448
+ if (summaryIndex < 0) {
449
+ return {
450
+ ok: false,
451
+ reason: "compaction-summary-not-found",
452
+ };
453
+ }
454
+
455
+ const keptStart = summaryIndex + 1;
456
+ const keptEnd = keptStart + keptWindowSerialized.length;
457
+ const actualKeptWindow = cloneResponsesInputSlice(args.payload.input.slice(keptStart, keptEnd));
458
+ if (!actualKeptWindow) {
459
+ return {
460
+ ok: false,
461
+ reason: "expected-pi-replay-mismatch",
462
+ };
463
+ }
464
+
465
+ // The only hard verification: the region immediately after the sentinel must be
466
+ // exactly the kept pre-compaction window derived from session entries. Content
467
+ // after this region is preserved as-is, so transient messages injected by other
468
+ // extensions (which have no session entries) no longer break replay.
469
+ if (!areEquivalentValues(actualKeptWindow, keptWindowSerialized)) {
470
+ const parity = compareResponsesInputParity(
471
+ args.payload.input.slice(keptStart, keptEnd),
472
+ keptWindowSerialized,
473
+ );
462
474
  return {
463
475
  ok: false,
464
476
  reason: "expected-pi-replay-mismatch",
@@ -470,70 +482,77 @@ function buildNativeReplaySegmentsInternal<TApi extends Api>(args: {
470
482
  };
471
483
  }
472
484
 
473
- const freshPreambleCount = freshPreamble.leadingInput.length;
474
- const trailingPreambleCount = freshPreamble.trailingInput.length;
475
- const compactionSummaryCount = serializeMessagesToResponsesInput(args.model, [compactionSummaryMessage]).length;
476
- const preCompactionKeptCount = serializeMessagesToResponsesInput(args.model, preCompactionKeptMessages).length;
477
- const tailStartIndex = freshPreambleCount + compactionSummaryCount + preCompactionKeptCount;
478
- const tailEndIndex = args.payload.input.length - trailingPreambleCount;
479
- const actualCompactionSummary = cloneResponsesInputSlice(
480
- args.payload.input.slice(freshPreambleCount, freshPreambleCount + compactionSummaryCount),
481
- );
482
- const actualPreCompactionKeptWindow = cloneResponsesInputSlice(
483
- args.payload.input.slice(
484
- freshPreambleCount + compactionSummaryCount,
485
- freshPreambleCount + compactionSummaryCount + preCompactionKeptCount,
486
- ),
487
- );
488
- const actualPostCompactionTail = cloneResponsesInputSlice(args.payload.input.slice(tailStartIndex, tailEndIndex));
489
- if (!actualCompactionSummary || !actualPreCompactionKeptWindow || !actualPostCompactionTail) {
485
+ const actualCompactionSummary = cloneResponsesInputSlice([args.payload.input[summaryIndex]]);
486
+ const actualPostCompactionTail = cloneResponsesInputSlice(args.payload.input.slice(keptEnd));
487
+ if (!actualCompactionSummary || !actualPostCompactionTail) {
490
488
  return {
491
489
  ok: false,
492
490
  reason: "expected-pi-replay-mismatch",
493
491
  };
494
492
  }
495
493
 
496
- const preCompactionKeptWindow = createReplaySlice(
497
- preCompactionEntries,
498
- preCompactionKeptMessages,
499
- actualPreCompactionKeptWindow,
500
- );
501
- const postCompactionTail = createReplaySlice(
502
- postCompactionEntries,
503
- postCompactionTailMessages,
504
- actualPostCompactionTail,
505
- );
494
+ // Keep provider-authored trailing developer/system prompts in place.
495
+ const trailingCount = countTrailingPreambleItems(actualPostCompactionTail);
496
+ const trailingPreamble = actualPostCompactionTail.slice(actualPostCompactionTail.length - trailingCount);
497
+ const liveTail = actualPostCompactionTail.slice(0, actualPostCompactionTail.length - trailingCount);
498
+
499
+ const leadingInput = cloneResponsesInputSlice(args.payload.input.slice(0, summaryIndex));
500
+ if (!leadingInput) {
501
+ return {
502
+ ok: false,
503
+ reason: "expected-pi-replay-mismatch",
504
+ };
505
+ }
506
+
507
+ const baseRewrittenPayload: ResponsesCompatibleRequestPayload = {
508
+ ...args.payload,
509
+ input: [
510
+ ...leadingInput,
511
+ ...compactedWindow,
512
+ ...liveTail,
513
+ ...trailingPreamble,
514
+ ],
515
+ };
516
+ const carryoverRewrite = rewritePayloadWithDeferredToolCarryover({
517
+ payload: baseRewrittenPayload,
518
+ carryover: details.deferredToolCarryover,
519
+ compactionEntryId: args.compactionEntry.id,
520
+ checkpointEndIndex: leadingInput.length + compactedWindow.length,
521
+ compat: args.model.compat as
522
+ | { supportsAdditionalTools?: boolean; supportsToolSearch?: boolean }
523
+ | undefined,
524
+ });
525
+
526
+ const sentinelSerialized = serializeMessagesToResponsesInput(args.model, [compactionSummaryMessage]);
527
+ const serializedTailInput = serializeMessagesToResponsesInput(args.model, postCompactionTailMessages);
528
+ const originalPiReplayInput: ResponsesInputItem[] = [
529
+ ...leadingInput,
530
+ ...sentinelSerialized,
531
+ ...keptWindowSerialized,
532
+ ...serializedTailInput,
533
+ ...trailingPreamble,
534
+ ];
506
535
 
507
536
  return {
508
537
  ok: true,
509
538
  segments: {
510
539
  boundaryIndex,
511
540
  firstKeptEntryIndex,
512
- instructions: freshPreamble.instructions,
513
- freshPreamble: freshPreamble.leadingInput,
514
- trailingPreamble: freshPreamble.trailingInput,
541
+ instructions: typeof args.payload.instructions === "string" ? args.payload.instructions : undefined,
542
+ freshPreamble: leadingInput as ResponsesInputMessageItem[],
543
+ trailingPreamble: trailingPreamble as ResponsesInputMessageItem[],
515
544
  compactionSummary: actualCompactionSummary,
516
- preCompactionKeptWindow,
545
+ preCompactionKeptWindow: createReplaySlice(
546
+ preCompactionEntries,
547
+ preCompactionKeptMessages,
548
+ actualKeptWindow,
549
+ ),
517
550
  compactedWindow,
518
- postCompactionTail,
551
+ postCompactionTail: createReplaySlice(postCompactionEntries, postCompactionTailMessages, liveTail),
519
552
  originalPiReplayInput,
520
- replayInput: [
521
- ...freshPreamble.leadingInput,
522
- ...compactedWindow,
523
- ...actualPostCompactionTail,
524
- ...freshPreamble.trailingInput,
525
- ],
526
- },
527
- rewrittenPayload: {
528
- ...args.payload,
529
- ...(freshPreamble.instructions !== undefined ? { instructions: freshPreamble.instructions } : {}),
530
- input: [
531
- ...freshPreamble.leadingInput,
532
- ...compactedWindow,
533
- ...actualPostCompactionTail,
534
- ...freshPreamble.trailingInput,
535
- ],
553
+ replayInput: carryoverRewrite.payload.input,
536
554
  },
555
+ rewrittenPayload: carryoverRewrite.payload,
537
556
  };
538
557
  }
539
558
 
package/src/types.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
2
- import type { CompactionEntry, CompactionResult, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { CompactionEntry, CompactionResult, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
3
3
 
4
4
  export const TOOLKIT_ID = "pi-openai-toolkit";
5
5
  export const COMPACTION_EXTENSION_ID = `${TOOLKIT_ID}:compaction`;
@@ -36,6 +36,15 @@ export type DebugArtifactKind =
36
36
  | "compaction-event"
37
37
  | "lifecycle";
38
38
 
39
+ export type CompactionContinuationMode = "inline" | "followUp" | "off";
40
+
41
+ export type AutoCompactionConfig = {
42
+ enabled: boolean;
43
+ continuation: CompactionContinuationMode;
44
+ unsupportedFallback: "followUp" | "off";
45
+ reserveTokens?: number;
46
+ };
47
+
39
48
  export type CompactionConfig = {
40
49
  enabled: boolean;
41
50
  /**
@@ -55,6 +64,8 @@ export type CompactionConfig = {
55
64
  model?: string;
56
65
  /** Thinking level passed to pi's native compact() when the fallback model runs. */
57
66
  thinkingLevel: ThinkingLevel;
67
+ /** Threshold-triggered compaction and same-tool-loop continuation policy. */
68
+ autoCompaction: AutoCompactionConfig;
58
69
  /** Subset of RESPONSES_COMPACT_CAPABLE_APIS that should use remote compaction. */
59
70
  responsesApis: string[];
60
71
  notifyOnLoad: boolean;
@@ -135,10 +146,21 @@ export type NativeCompactionIdentity = {
135
146
  baseUrl: string;
136
147
  };
137
148
 
149
+ export const CACHE_STACK_ACTIVATION_ENTRY_TYPE = "pi-cache-stack.activation-state.v1" as const;
150
+
151
+ export type DeferredToolCarryoverV1 = {
152
+ version: 1;
153
+ source: typeof CACHE_STACK_ACTIVATION_ENTRY_TYPE;
154
+ toolNames: string[];
155
+ catalogHash?: string;
156
+ };
157
+
138
158
  export type NativeCompactionDetails = NativeCompactionIdentity & {
139
159
  strategy: NativeCompactionStrategy;
140
160
  /** Actual producer of the opaque checkpoint; absent on legacy same-model entries. */
141
161
  compactionModel?: NativeCompactionIdentity;
162
+ /** Cache-stack activation state captured at this opaque checkpoint. */
163
+ deferredToolCarryover?: DeferredToolCarryoverV1;
142
164
  compactedWindow: unknown[];
143
165
  compactResponseId?: string;
144
166
  createdAt: string;
@@ -149,6 +171,7 @@ export type NativeCompactionEntry = CompactionEntry<NativeCompactionDetails>;
149
171
 
150
172
  export type CreateNativeCompactionDetailsInput = NativeCompactionIdentity & {
151
173
  compactionModel?: NativeCompactionIdentity;
174
+ deferredToolCarryover?: DeferredToolCarryoverV1;
152
175
  compactedWindow: unknown[];
153
176
  compactResponseId?: string;
154
177
  createdAt?: string;
@@ -262,6 +285,64 @@ export function isNativeCompactionIdentity(value: unknown): value is NativeCompa
262
285
  );
263
286
  }
264
287
 
288
+ export function isDeferredToolCarryover(value: unknown): value is DeferredToolCarryoverV1 {
289
+ if (!isRecord(value) || value.version !== 1 || value.source !== CACHE_STACK_ACTIVATION_ENTRY_TYPE) {
290
+ return false;
291
+ }
292
+
293
+ return (
294
+ Array.isArray(value.toolNames) &&
295
+ value.toolNames.every((name) => isNonEmptyString(name)) &&
296
+ (value.catalogHash === undefined || typeof value.catalogHash === "string")
297
+ );
298
+ }
299
+
300
+ function normalizeDeferredToolCarryover(value: DeferredToolCarryoverV1): DeferredToolCarryoverV1 {
301
+ if (!isDeferredToolCarryover(value)) {
302
+ throw new Error("Invalid deferred tool carryover");
303
+ }
304
+
305
+ return {
306
+ version: 1,
307
+ source: CACHE_STACK_ACTIVATION_ENTRY_TYPE,
308
+ toolNames: [...new Set(value.toolNames.map((name) => name.trim()).filter(Boolean))].sort(),
309
+ ...(typeof value.catalogHash === "string" ? { catalogHash: value.catalogHash } : {}),
310
+ };
311
+ }
312
+
313
+ function decodeCacheStackActivationState(value: unknown): DeferredToolCarryoverV1 | undefined {
314
+ if (!isRecord(value) || value.version !== 1 || typeof value.catalogHash !== "string") {
315
+ return undefined;
316
+ }
317
+ if (!Array.isArray(value.activatedTools) || !value.activatedTools.every((name) => typeof name === "string")) {
318
+ return undefined;
319
+ }
320
+
321
+ return normalizeDeferredToolCarryover({
322
+ version: 1,
323
+ source: CACHE_STACK_ACTIVATION_ENTRY_TYPE,
324
+ toolNames: value.activatedTools,
325
+ catalogHash: value.catalogHash,
326
+ });
327
+ }
328
+
329
+ /** Read only the latest valid cache-stack activation snapshot on this branch. */
330
+ export function getLatestDeferredToolCarryover(
331
+ entries: readonly SessionEntry[],
332
+ ): DeferredToolCarryoverV1 | undefined {
333
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
334
+ const entry = entries[index];
335
+ if (entry?.type !== "custom" || entry.customType !== CACHE_STACK_ACTIVATION_ENTRY_TYPE) {
336
+ continue;
337
+ }
338
+
339
+ const decoded = decodeCacheStackActivationState(entry.data);
340
+ if (decoded) return decoded;
341
+ }
342
+
343
+ return undefined;
344
+ }
345
+
265
346
  export function isNativeCompactionDetails(value: unknown): value is NativeCompactionDetails {
266
347
  if (!isRecord(value) || !isNativeCompactionIdentity(value)) {
267
348
  return false;
@@ -272,6 +353,7 @@ export function isNativeCompactionDetails(value: unknown): value is NativeCompac
272
353
  (candidate.strategy === LEGACY_NATIVE_COMPACTION_STRATEGY ||
273
354
  candidate.strategy === REMOTE_V2_COMPACTION_STRATEGY) &&
274
355
  (candidate.compactionModel === undefined || isNativeCompactionIdentity(candidate.compactionModel)) &&
356
+ (candidate.deferredToolCarryover === undefined || isDeferredToolCarryover(candidate.deferredToolCarryover)) &&
275
357
  Array.isArray(candidate.compactedWindow) &&
276
358
  candidate.compactedWindow.every(isCompactedWindowItem) &&
277
359
  isNonEmptyString(candidate.createdAt) &&
@@ -299,6 +381,9 @@ export function createNativeCompactionDetails(input: CreateNativeCompactionDetai
299
381
  baseUrl: normalizeString(input.compactionModel.baseUrl),
300
382
  }
301
383
  : undefined,
384
+ deferredToolCarryover: input.deferredToolCarryover
385
+ ? normalizeDeferredToolCarryover(input.deferredToolCarryover)
386
+ : undefined,
302
387
  compactedWindow: input.compactedWindow.map((item) => cloneStructuredValue(item)),
303
388
  compactResponseId: isNonEmptyString(input.compactResponseId) ? normalizeString(input.compactResponseId) : undefined,
304
389
  createdAt: isNonEmptyString(input.createdAt) ? normalizeString(input.createdAt) : new Date().toISOString(),
@@ -325,12 +410,20 @@ export function createNativeCompactionResult(
325
410
  };
326
411
  }
327
412
 
413
+ export const DEFAULT_AUTO_COMPACTION_CONFIG: AutoCompactionConfig = {
414
+ enabled: true,
415
+ continuation: "inline",
416
+ unsupportedFallback: "followUp",
417
+ reserveTokens: undefined,
418
+ };
419
+
328
420
  export const DEFAULT_COMPACTION_CONFIG: CompactionConfig = {
329
421
  enabled: true,
330
422
  allowCompactionContinuityBreak: false,
331
423
  remoteCompactModel: undefined,
332
424
  model: undefined,
333
425
  thinkingLevel: "off",
426
+ autoCompaction: { ...DEFAULT_AUTO_COMPACTION_CONFIG },
334
427
  responsesApis: [...RESPONSES_COMPACT_CAPABLE_APIS],
335
428
  notifyOnLoad: false,
336
429
  debug: false,
@@ -18,6 +18,8 @@
18
18
  "src/config.ts",
19
19
  "src/debug.ts",
20
20
  "src/details-store.ts",
21
+ "src/deferred-tool-carryover.ts",
22
+ "src/inline-compaction.ts",
21
23
  "src/extension-runtime.ts",
22
24
  "src/native-fallback.ts",
23
25
  "src/payload-rewrite.ts",