pi-openai-toolkit 0.20.5 → 0.20.7

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/docs/internals.md CHANGED
@@ -73,11 +73,12 @@ sequenceDiagram
73
73
  Model->>Toolkit: new_context
74
74
  Toolkit->>Toolkit: verify successful notes write in this window
75
75
  Toolkit-->>Model: checkpoint receipt with the successful note path
76
- Toolkit-->>Pi: window marker appended, previous window trim scheduled
76
+ Toolkit-->>Pi: new window marker persisted
77
77
  Note over Model: Context switch is complete; read the receipt once, then resume the active task
78
78
  Model->>Backend: next request with the new x-codex-window-id
79
79
  Pi->>Toolkit: session_before_compact
80
- Toolkit-->>Pi: no-summary boundary consuming the scheduled trim
80
+ Toolkit->>Toolkit: resolve boundary against Pi's effective context
81
+ Toolkit-->>Pi: safe no-summary boundary, or cancel obsolete cleanup
81
82
  ```
82
83
 
83
84
  State invariants the lifecycle code must keep honest:
@@ -90,12 +91,12 @@ State invariants the lifecycle code must keep honest:
90
91
  - When even that rebuild would lose declared tools, the trim is refused and the full message list is sent: a window that still carries the previous turns is usable, a window with no tools silently degrades into a chat the model can only narrate. Each refusal is reported once per window and lost-tool set through `takeProjectionDiagnostics()`, which writes a `context.projection.tool_loadout_shrink` compaction artifact and warns in the UI.
91
92
  - The manager also remembers the head it last anchored. When a boundary compaction retires the transcript entry that declared the tools, the branch itself no longer contains a system message, and the remembered head is re-anchored instead of sending a tool-less window. The cache is request-time only, refreshed by every projection, and cleared by `reset()`.
92
93
  - `new_context` has no force escape hatch: a persisted successful notes checkpoint in the current window is always required before rollover. The model must wait for the notes result to be persisted before retrying.
93
- - After a marker is accepted for sending, the manager keeps a session/window-anchored pending guard until that exact target marker is persisted or the session changes. A duplicate `new_context` during this persistence gap returns `started: false` and sends no second marker; projected/in-memory messages do not retire the guard.
94
+ - After a marker is accepted for sending, the manager keeps a session/window-anchored pending guard until that exact target marker is persisted or the session changes. A duplicate `new_context` during this persistence gap returns `started: false` and sends no second marker; projected/in-memory messages do not retire the guard. The exact queued target can already trim the current Remote request and select its metadata, but this preview is recomputed per request and never authorizes maintenance compaction before persistence.
94
95
  - Budget checks are skipped until the current window has produced its own assistant usage. Acting on the previous window's usage anchor would burn the once-per-window reminder on a false alarm.
95
- - Preparing a boundary never consumes its scheduled trim. The manager reconciles the latest persisted compaction and branch state, so only a durable matching boundary consumes it. Missing/late callbacks, aborted preparation and navigation across branches sharing a marker remain retryable. Automatic threshold/overflow and toolkit-owned manual maintenance only trim a matching scheduled window; a user manual `/compact` follows the managed handoff below.
96
- - A window boundary is persisted as a `codex-context-window` custom message. `session_start` replays boundaries from the branch and rebuilds identity after forks.
97
- - Retiring windows remains a projection-level operation in the persisted branch, but a non-window consumer never receives that retired history. `context_with_system` trims to the latest persisted Remote Context boundary, preserves the current system/tool head, and removes the internal window marker before the local provider request. The legacy `leaveManagedMode` setting is retained only so old config files remain valid; it no longer warns or starts a bulk close-out from `model_select` or `agent_settled`.
98
- - If a non-window consumer reaches `session_before_compact` while a Remote Context boundary exists, the extension returns a no-summary compaction anchored at that boundary. No Remote V2/native compaction producer receives the Remote window, Pi retains the marker and current-window tail, and switching back to a supported model reuses the persisted window identity. A session with no Remote boundary keeps the ordinary compaction path.
96
+ - Pi's canonical session projection is the source of effective history. Toolkit may further restrict that projection, but never reconstruct omitted or summarized messages from the raw branch. Boundary resolution uses projected source entries and the active branch's latest compaction; an older marker retained inside a newer compaction does not authorize dropping that compaction's summary.
97
+ - Window identity and cleanup eligibility are separate. A persisted `codex-context-window` message restores identity after reload/fork, but its historical `trimPreviousWindow` flag is not an outstanding command. A newer native summary or opaque checkpoint supersedes old cleanup without erasing window identity. Missing or edited boundary evidence is never repaired by loading raw history.
98
+ - Maintenance compaction and local-consumer detachment use the same effective-boundary rules. Only a safe current boundary can produce a no-summary compaction; obsolete cleanup must not move the retained start backwards or replace a newer summary/checkpoint. Preparation alone changes no durable state, and cancellation, retry and branch navigation are resolved from current evidence rather than callback acknowledgments. User manual `/compact` still follows the managed handoff below.
99
+ - A non-window consumer receives only the effective current context, with the system/tool head preserved and internal window markers removed. When detachment is needed, no compaction producer receives retired Remote history. Once a later ordinary compaction owns effective context, the historical window does not suppress its summary or valid opaque replay. A genuinely new window can retire that checkpoint. Disabling opaque checkpoint generation through `responsesApis` does not disable required replay of an inherited checkpoint; an unsupported transport, authentication failure, or endpoint mismatch aborts the request instead of exposing its placeholder. The legacy `leaveManagedMode` setting remains compatible but does not start a bulk close-out from `model_select` or `agent_settled`.
99
100
  - The configured native-fallback summary model is still checked against the physical request size (`estimateSummarizationRequest`). The guard remains a provider-safety invariant; the Remote-window fix avoids the oversized request by excluding managed history before any compaction producer can be called, rather than removing the guard or chunking the request.
100
101
 
101
102
  ---
@@ -120,7 +121,7 @@ The underlying native compact promise still rejects its intentional cancellation
120
121
 
121
122
  ### Remote Compaction v2 wire contract
122
123
 
123
- v2 is what an uncovered session gets. Any model that Remote Context declines, such as a gateway without an exact transport opt-in, can still compact through v2 when its API appears in `context.remoteCompaction.apis`. The two strategies never compete for one session.
124
+ v2 is what an uncovered session gets. Any model that Remote Context declines, such as a gateway without an exact transport opt-in, can still compact through v2 when its API appears in `context.remoteCompaction.apis`. Compaction generation follows the active strategy. Across mode changes, an already-persisted checkpoint remains authoritative until a safe newer window or compaction replaces it; a historical window identity alone must not suppress checkpoint replay.
124
125
 
125
126
  A `compaction_trigger` item appended to the live streaming request yields one output item of `type: "compaction"` with non-empty `encrypted_content`, stored in `CompactionEntry.details.compactedWindow`. On later requests the opaque checkpoint is replayed ahead of live turns, with no text summary. Replay fails closed: if the summary anchor cannot be located, the request is aborted with a notification and a content-free failure artifact. The sentinel-only payload is never sent.
126
127
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-openai-toolkit",
3
- "version": "0.20.5",
3
+ "version": "0.20.7",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
3
3
  import type { ProviderHeaders } from "@earendil-works/pi-ai";
4
4
  import {
5
- buildSessionContext,
5
+ buildSessionProjection,
6
6
  calculateContextTokens,
7
7
  estimateTokens,
8
8
  type CompactionResult,
@@ -43,6 +43,7 @@ import {
43
43
  loadHistoryNotesThreadHint,
44
44
  } from "./history-notes";
45
45
  import { rewriteContextNamespaceTools } from "./namespace-tools";
46
+ import { hasInvalidatedCompactionContext } from "../payload-rewrite";
46
47
 
47
48
  interface StartContextWindowOptions {
48
49
  triggerTurn: boolean;
@@ -73,6 +74,8 @@ type WindowBoundaryEntry = Extract<SessionEntry, { type: "custom_message" }> & {
73
74
  type PendingRollover = {
74
75
  sessionId?: string;
75
76
  targetWindowId: string;
77
+ /** The current request already contains this marker; never persistence proof. */
78
+ projected?: boolean;
76
79
  };
77
80
 
78
81
  export class CodexContextWindowManager {
@@ -80,10 +83,9 @@ export class CodexContextWindowManager {
80
83
  private sessionId: string | undefined;
81
84
  private restoredMarkerId: string | undefined;
82
85
  private restoredCompactionId: string | undefined;
83
- private branchStateInvalidated = false;
86
+ private boundary: WindowBoundaryResolution = { kind: "none" };
84
87
  private readonly budget = new ContextWindowBudget();
85
88
  private pendingRollover: PendingRollover | undefined;
86
- private trimPendingWindowId: string | undefined;
87
89
  private readonly projectionDiagnostics = new Set<string>();
88
90
  private lastKnownSystemHead: AgentMessage | undefined;
89
91
  /**
@@ -108,9 +110,21 @@ export class CodexContextWindowManager {
108
110
  currentIdentity(): ContextWindowIdentity | undefined {
109
111
  return this.identity ? { ...this.identity } : undefined;
110
112
  }
111
- /** Whether a rollover trim awaits a matching persisted compaction. */
113
+ /** Derived eligibility, never a replayed marker command. */
112
114
  hasPendingTrim(): boolean {
113
- return this.trimPendingWindowId !== undefined;
115
+ return this.boundary.kind === "effective" && !this.boundary.cleaned &&
116
+ this.boundary.entry.details.contextManagement.trimPreviousWindow === true;
117
+ }
118
+
119
+ /** Historical identity does not own a newer native/opaque checkpoint. */
120
+ hasEffectiveWindow(): boolean {
121
+ if (!this.identity || this.boundary.kind === "ambiguous") return false;
122
+ return this.boundary.kind !== "superseded" ||
123
+ (this.pendingRollover?.projected === true && this.pendingRollover.targetWindowId === this.identity.currentWindowId);
124
+ }
125
+
126
+ hasSupersededWindow(): boolean {
127
+ return this.boundary.kind === "superseded";
114
128
  }
115
129
 
116
130
  private resetWindowState(): void {
@@ -118,9 +132,8 @@ export class CodexContextWindowManager {
118
132
  this.sessionId = undefined;
119
133
  this.restoredMarkerId = undefined;
120
134
  this.restoredCompactionId = undefined;
121
- this.branchStateInvalidated = false;
135
+ this.boundary = { kind: "none" };
122
136
  this.budget.reset();
123
- this.trimPendingWindowId = undefined;
124
137
  this.projectedContextTokens = undefined;
125
138
  }
126
139
 
@@ -132,7 +145,6 @@ export class CodexContextWindowManager {
132
145
  for (const entry of entries) {
133
146
  if (entry.type === "compaction") {
134
147
  this.restoredCompactionId = entry.id;
135
- this.recordCompaction(entry.details);
136
148
  continue;
137
149
  }
138
150
  if (entry.type !== "custom_message" || entry.customType !== CODEX_CONTEXT_WINDOW_MESSAGE_TYPE) continue;
@@ -145,13 +157,10 @@ export class CodexContextWindowManager {
145
157
  this.restoredMarkerId = entry.id;
146
158
  if (details.kind === "window") {
147
159
  this.identity = identityFromDetails(entry.details);
148
- this.trimPendingWindowId = details.trimPreviousWindow ? details.currentWindowId : undefined;
149
160
  }
150
161
  this.budget.restore(details.kind, details.currentWindowId);
151
162
  }
152
- // recordCompaction may invalidate the cache while replaying entries; the
153
- // completed replay already reconciled those acknowledgments with this branch.
154
- this.branchStateInvalidated = false;
163
+ this.boundary = resolveWindowBoundary(entries, sessionId);
155
164
  this.retireSatisfiedOrStalePending(entries, sessionId);
156
165
  }
157
166
 
@@ -170,9 +179,11 @@ export class CodexContextWindowManager {
170
179
  }
171
180
  // Hook-provided compactions may have no success callback. The persisted
172
181
  // branch also detects navigation before/after a commit with the same marker.
173
- if (this.branchStateInvalidated || this.sessionId !== sessionId || this.restoredMarkerId !== latestMarkerId || this.restoredCompactionId !== latestCompactionId) {
182
+ if (this.sessionId !== sessionId || this.restoredMarkerId !== latestMarkerId || this.restoredCompactionId !== latestCompactionId) {
174
183
  this.restore(entries, sessionId);
175
184
  }
185
+ // Edits and sibling navigation may leave marker/compaction IDs unchanged.
186
+ this.boundary = resolveWindowBoundary(entries, sessionId);
176
187
  }
177
188
 
178
189
  /**
@@ -211,13 +222,22 @@ export class CodexContextWindowManager {
211
222
  project(
212
223
  messages: readonly AgentMessage[],
213
224
  mode: "off" | "local" | "remote",
225
+ /** Delay encoding until verified retained copies have been removed. */
226
+ encodeEncryptedOutputs = true,
214
227
  ): AgentMessage[] {
228
+ // Recompute the request-only preview on every projection. Observing a
229
+ // queued marker must not authorize maintenance or retire the duplicate guard.
230
+ if (this.pendingRollover) this.pendingRollover.projected = false;
215
231
  if (mode === "off") {
216
232
  this.projectedContextTokens = undefined;
217
233
  return messages.filter(
218
234
  (message) => message.role !== "custom" || message.customType !== CODEX_CONTEXT_WINDOW_MESSAGE_TYPE,
219
235
  );
220
236
  }
237
+ if (this.boundary.kind === "ambiguous") {
238
+ throw new Error("Persisted Codex context-window boundary missing or edited in canonical projection");
239
+ }
240
+ if (this.boundary.kind === "superseded") this.identity = identityFromDetails(this.boundary.entry.details);
221
241
  let boundaryIndex = -1;
222
242
  for (let index = 0; index < messages.length; index += 1) {
223
243
  const message = messages[index]!;
@@ -232,28 +252,24 @@ export class CodexContextWindowManager {
232
252
  if (!matchesSession(message.details.sessionId, this.sessionId)) continue;
233
253
  }
234
254
  if (!isContextWindowBoundary(message)) continue;
255
+ const queuedRollover = mode === "remote" && this.pendingRollover?.targetWindowId === message.details.contextManagement.currentWindowId;
256
+ if (this.boundary.kind === "superseded" && !queuedRollover) continue;
235
257
  // Local consumers follow the synchronized persisted identity, not a
236
258
  // queued marker from the request view. They never own the Remote session.
237
259
  if (mode === "local" && message.details.contextManagement.currentWindowId !== this.identity?.currentWindowId) continue;
238
260
  boundaryIndex = index;
239
- if (mode === "remote") this.identity = identityFromDetails(message.details);
240
- }
241
- if (boundaryIndex < 0) {
242
- if (mode === "local" && this.identity) {
243
- throw new Error("Persisted Codex context-window boundary missing from local projection");
244
- }
245
261
  if (mode === "remote") {
246
- this.identity = undefined;
247
- this.restoredMarkerId = undefined;
248
- this.budget.reset();
249
- this.trimPendingWindowId = undefined;
262
+ this.identity = identityFromDetails(message.details);
263
+ if (this.pendingRollover) this.pendingRollover.projected = queuedRollover;
250
264
  }
251
- this.projectedContextTokens = undefined;
265
+ }
266
+ if (boundaryIndex < 0 && this.boundary.kind === "effective") {
267
+ throw new Error("Persisted Codex context-window boundary missing from effective projection");
252
268
  }
253
269
  // Projection observes the request view, never durable session state. A
254
270
  // queued rollover marker must not clear the duplicate guard here.
255
271
  const trimmed = boundaryIndex < 0 ? [...messages] : this.trimToWindow(messages, boundaryIndex, mode === "local");
256
- const projected = mode === "remote" ? projectEncryptedToolResults(trimmed) : trimmed.filter(
272
+ const projected = mode === "remote" ? (encodeEncryptedOutputs ? projectEncryptedToolResults(trimmed) : trimmed) : trimmed.filter(
257
273
  (message) => message.role !== "custom" || message.customType !== CODEX_CONTEXT_WINDOW_MESSAGE_TYPE,
258
274
  );
259
275
  this.projectedContextTokens = mode === "remote" && this.identity
@@ -421,21 +437,12 @@ export class CodexContextWindowManager {
421
437
  prepareCompaction(
422
438
  event: SessionBeforeCompactEvent,
423
439
  ): { cancel: true } | { compaction: CompactionResult<ContextWindowCompactionDetails> } {
424
- // Only the compaction that consumes a scheduled rollover may write a
425
- // boundary. Every other path — threshold, manual /compact, overflow —
426
- // stays cancelled: a boundary written without a pending trim does not
427
- // shrink anything, becomes Pi's latest-compaction anchor, and blinds
428
- // getContextUsage (null tokens) until the next assistant usage lands —
429
- // which is exactly how the exhausted-window fallback can go silent.
430
- const boundary = findLatestWindowBoundaryEntry(event.branchEntries, this.sessionId);
431
- if (!boundary || boundary.details.contextManagement.currentWindowId !== this.trimPendingWindowId) {
440
+ const boundary = resolveWindowBoundary(event.branchEntries, this.sessionId);
441
+ if (boundary.kind !== "effective" || boundary.cleaned || !boundary.entry.details.contextManagement.trimPreviousWindow) {
432
442
  return { cancel: true };
433
443
  }
434
- const compaction = this.createCompaction(event);
435
- // Preparation is only a proposal: Pi may abort before appending it.
436
- // synchronize() consumes the trim after observing a durable compaction;
437
- // recordCompaction() also handles hosts that emit a success callback.
438
- return { compaction };
444
+ // An unpersisted/cancelled proposal changes no eligibility.
445
+ return { compaction: this.createCompaction(event, boundary.entry) };
439
446
  }
440
447
 
441
448
  /**
@@ -445,10 +452,12 @@ export class CodexContextWindowManager {
445
452
  */
446
453
  prepareDetachedCompaction(
447
454
  event: SessionBeforeCompactEvent,
448
- ): { compaction: CompactionResult<ContextWindowCompactionDetails> } | undefined {
449
- const boundary = findLatestWindowBoundaryEntry(event.branchEntries, this.sessionId);
450
- if (!boundary) return undefined;
451
- const compaction = this.createCompaction(event);
455
+ ): { cancel: true } | { compaction: CompactionResult<ContextWindowCompactionDetails> } | undefined {
456
+ const boundary = resolveWindowBoundary(event.branchEntries, this.sessionId);
457
+ if (boundary.kind === "ambiguous") throw new Error("Persisted Codex context-window boundary missing or edited");
458
+ if (boundary.kind !== "effective") return undefined;
459
+ if (boundary.cleaned) return { cancel: true };
460
+ const compaction = this.createCompaction(event, boundary.entry);
452
461
  // Pi's preparation/tokensBefore describes the durable branch, which includes
453
462
  // Remote-managed history. Detached local accounting must use the same current
454
463
  // window projection that the non-window provider sees instead.
@@ -461,30 +470,19 @@ export class CodexContextWindowManager {
461
470
  }
462
471
 
463
472
  private estimateLocalContextTokens(entries: readonly SessionEntry[]): number {
464
- const messages = buildSessionContext(entries as never[]).messages as unknown as AgentMessage[];
473
+ const messages = buildSessionProjection([...entries]).messages;
465
474
  return this.project(messages, "local").reduce((total, message) => total + estimateTokens(message as never), 0);
466
475
  }
467
476
 
468
- recordCompaction(details: unknown): void {
469
- if (!isContextWindowCompactionDetails(details)) return;
470
- if (this.trimPendingWindowId !== undefined && details.windowId === this.trimPendingWindowId) {
471
- this.trimPendingWindowId = undefined;
472
- // The callback supplies no branch identity. Force durable reconciliation
473
- // even if navigation returns to the cached pre-commit branch before sync.
474
- this.branchStateInvalidated = true;
475
- }
476
- }
477
-
478
- createCompaction(event: SessionBeforeCompactEvent): CompactionResult<ContextWindowCompactionDetails> {
479
- const boundary = findLatestWindowBoundaryEntry(event.branchEntries, this.sessionId);
477
+ private createCompaction(event: SessionBeforeCompactEvent, boundary: WindowBoundaryEntry): CompactionResult<ContextWindowCompactionDetails> {
480
478
  return {
481
479
  summary: CONTEXT_WINDOW_COMPACTION_SUMMARY,
482
- firstKeptEntryId: boundary?.id ?? event.preparation.firstKeptEntryId,
480
+ firstKeptEntryId: boundary.id,
483
481
  tokensBefore: event.preparation.tokensBefore,
484
482
  details: {
485
483
  protocol: CONTEXT_MANAGEMENT_PROTOCOL,
486
484
  strategy: CONTEXT_WINDOW_COMPACTION_STRATEGY,
487
- ...(this.identity ? { windowId: this.identity.currentWindowId } : {}),
485
+ windowId: boundary.details.contextManagement.currentWindowId,
488
486
  },
489
487
  };
490
488
  }
@@ -517,13 +515,41 @@ export class CodexContextWindowManager {
517
515
  this.identity = identity;
518
516
  this.sessionId = ctx.sessionManager.getSessionId();
519
517
  this.restoredMarkerId = undefined;
520
- this.trimPendingWindowId = options.trimPreviousWindow ? identity.currentWindowId : undefined;
521
518
  // sendMessage() acceptance is not persistence. The pending rollover stays
522
519
  // armed until synchronize() observes this target window in the persisted
523
520
  // branch (or the session changes).
524
521
  }
525
522
  }
526
523
 
524
+ type WindowBoundaryResolution =
525
+ | { kind: "none" | "ambiguous" }
526
+ | { kind: "superseded"; entry: WindowBoundaryEntry }
527
+ | { kind: "effective"; entry: WindowBoundaryEntry; cleaned: boolean };
528
+
529
+ /** Pi's projection owns context; raw chronology only establishes window ownership. */
530
+ function resolveWindowBoundary(entries: readonly SessionEntry[], sessionId?: string): WindowBoundaryResolution {
531
+ const boundary = findLatestWindowBoundaryEntry(entries, sessionId);
532
+ if (!boundary) return { kind: "none" };
533
+ const projection = buildSessionProjection([...entries]);
534
+ const latest = projection.entries.find((entry) => entry.sourceEntry.type === "compaction")?.sourceEntry;
535
+ const boundaryIndex = entries.findIndex((entry) => entry.id === boundary.id);
536
+ const laterCompaction = latest?.type === "compaction" && entries.findIndex((entry) => entry.id === latest.id) > boundaryIndex
537
+ ? latest : undefined;
538
+ if (laterCompaction && hasInvalidatedCompactionContext([...entries], laterCompaction.id)) return { kind: "ambiguous" };
539
+ const marker = projection.entries.find((entry) => entry.sourceEntry.id === boundary.id);
540
+ // A surviving edit can replace content without changing custom details.
541
+ const edited = entries.some((entry) => entry.type === "context_edit" && entry.targetId === boundary.id &&
542
+ projection.entries.some((projected) => projected.sourceEntry.id === entry.id));
543
+ if (edited) return { kind: "ambiguous" };
544
+ const cleaned = laterCompaction !== undefined &&
545
+ isContextWindowCompactionDetails(laterCompaction.details) &&
546
+ laterCompaction.details.windowId === boundary.details.contextManagement.currentWindowId &&
547
+ laterCompaction.firstKeptEntryId === boundary.id;
548
+ if (laterCompaction && !cleaned) return { kind: "superseded", entry: boundary };
549
+ if (!marker?.messages.some(isContextWindowBoundary)) return { kind: "ambiguous" };
550
+ return { kind: "effective", entry: boundary, cleaned };
551
+ }
552
+
527
553
  function identityFromDetails(details: CodexContextManagementMessageDetails): ContextWindowIdentity {
528
554
  const context = details.contextManagement;
529
555
  return {
@@ -882,7 +882,9 @@ async function handleSessionBeforeCompact(
882
882
  config,
883
883
  ctx,
884
884
  );
885
- if (config.remoteCompactModel) {
885
+ const expectedConsumerCapabilityMiss =
886
+ resolution.reason === "unsupported-api" && resolution.modelSpec === undefined;
887
+ if (config.remoteCompactModel && !expectedConsumerCapabilityMiss) {
886
888
  notifyWarning(
887
889
  ctx,
888
890
  `remote compaction model "${config.remoteCompactModel}" unusable (${resolution.reason}); using the native fallback chain`,
@@ -955,6 +957,19 @@ async function handleSessionBeforeCompact(
955
957
  return undefined;
956
958
  }
957
959
 
960
+ function rejectInheritedCheckpointReplay(
961
+ ctx: ExtensionContext,
962
+ config: CompactionConfig,
963
+ compactionEntryId: string,
964
+ reason: string,
965
+ ): undefined {
966
+ writeReplayFailureArtifact({ reason, compactionEntryId }, config, ctx);
967
+ reportPiContextHookFailure(ctx, `replay-failed:${reason}`);
968
+ if (ctx.hasUI) ctx.ui.notify(`${COMPACTION_EXTENSION_ID}: inherited checkpoint replay failed (${reason}); request aborted`, "error");
969
+ ctx.abort();
970
+ return undefined;
971
+ }
972
+
958
973
  async function handleContextInternal(
959
974
  event: ContextWithSystemEvent,
960
975
  ctx: ExtensionContext,
@@ -975,6 +990,7 @@ async function handleContextInternal(
975
990
  }
976
991
 
977
992
  let localMessages: AgentMessage[] | undefined;
993
+ let remoteReplayProjection = false;
978
994
  if (config.contextManagement === "remote") {
979
995
  const contextModel = await resolveContextCapabilityModel(ctx);
980
996
  try {
@@ -992,7 +1008,11 @@ async function handleContextInternal(
992
1008
  // Measure the provider-visible current window, not Pi's durable transcript.
993
1009
  // The latter still contains retired remote windows and can be inflated by
994
1010
  // an upstream error even though this request only carries the live window.
995
- const projected = contextWindows.project(event.messages, "remote");
1011
+ let projected = contextWindows.project(event.messages, "remote", false);
1012
+ // Projection may observe the exact queued rollover before Pi persists
1013
+ // it. Decide replay ownership from this request, not the old branch.
1014
+ const ownsWindow = contextWindows.hasEffectiveWindow() || !contextWindows.currentIdentity();
1015
+ if (ownsWindow) projected = contextWindows.project(projected, "remote");
996
1016
  contextWindows.recordBudget(
997
1017
  pi,
998
1018
  ctx,
@@ -1000,9 +1020,13 @@ async function handleContextInternal(
1000
1020
  config.contextReminderThresholdPercent,
1001
1021
  );
1002
1022
  reportProjectionDiagnostics(contextWindows, config, ctx);
1003
- return projected.length === event.messages.length && projected.every((message, index) => message === event.messages[index])
1004
- ? undefined
1005
- : { messages: projected };
1023
+ if (ownsWindow) {
1024
+ return projected.length === event.messages.length && projected.every((message, index) => message === event.messages[index])
1025
+ ? undefined
1026
+ : { messages: projected };
1027
+ }
1028
+ localMessages = projected;
1029
+ remoteReplayProjection = true;
1006
1030
  } catch (error) {
1007
1031
  notifyRemoteContextFailure(ctx, "malformed-window-state");
1008
1032
  reportPiContextHookFailure(ctx, "malformed-window-state");
@@ -1011,11 +1035,10 @@ async function handleContextInternal(
1011
1035
  }
1012
1036
  }
1013
1037
 
1014
- // An eligible Codex Remote session is never allowed to fall through to the
1015
- // older compaction-replay path. An explicit host exclusion is the one
1016
- // intentional downgrade: strip internal markers and let the ordinary request
1017
- // proceed without Remote Context affinity.
1018
- if (isCodexContextModel(contextModel, config)) {
1038
+ // An unavailable covered runtime must not silently become an ordinary
1039
+ // replay consumer. Explicit host exclusion remains the one intentional
1040
+ // downgrade; active Remote can replay a newer inherited checkpoint.
1041
+ if (!remoteActive && isCodexContextModel(contextModel, config)) {
1019
1042
  const registrationState = getContextToolRegistrationState();
1020
1043
  if (isExcludedGatewayContext(contextModel, config, registrationState)) {
1021
1044
  const visibleMessages = contextWindows.project(event.messages, "off");
@@ -1036,39 +1059,58 @@ async function handleContextInternal(
1036
1059
  : { messages: visibleMessages };
1037
1060
  }
1038
1061
 
1039
- try {
1040
- const projected = contextWindows.project(event.messages, "local");
1041
- localMessages = projected;
1042
- if (contextWindows.currentIdentity()) {
1043
- // This boundary owns earlier history, including any older Remote V2
1044
- // checkpoint. A local request must not replay that history back in.
1045
- return projected.length === event.messages.length && projected.every((message, index) => message === event.messages[index])
1046
- ? undefined
1047
- : { messages: projected };
1062
+ if (!remoteActive) {
1063
+ try {
1064
+ const projected = contextWindows.project(event.messages, "local");
1065
+ localMessages = projected;
1066
+ if (contextWindows.hasEffectiveWindow()) {
1067
+ // This boundary owns earlier history, including any older Remote V2
1068
+ // checkpoint. A local request must not replay that history back in.
1069
+ return projected.length === event.messages.length && projected.every((message, index) => message === event.messages[index])
1070
+ ? undefined
1071
+ : { messages: projected };
1072
+ }
1073
+ } catch (error) {
1074
+ notifyRemoteContextFailure(ctx, "malformed-window-state");
1075
+ reportPiContextHookFailure(ctx, error instanceof Error ? error.message : String(error));
1076
+ ctx.abort();
1077
+ return undefined;
1048
1078
  }
1049
- } catch (error) {
1050
- notifyRemoteContextFailure(ctx, "malformed-window-state");
1051
- reportPiContextHookFailure(ctx, error instanceof Error ? error.message : String(error));
1052
- ctx.abort();
1053
- return undefined;
1054
1079
  }
1055
1080
  }
1056
1081
 
1057
- // With no Remote boundary, the ordinary checkpoint replay path remains the
1058
- // owner. Inactive covered Codex consumers were handled separately above.
1082
+ // Without an effective Remote boundary, the latest checkpoint owns replay.
1083
+ // This also covers a newer checkpoint superseding a historical window.
1059
1084
  const visibleMessages = localMessages ?? contextWindows.project(event.messages, "off");
1060
- const visibleResult = visibleMessages.length === event.messages.length && visibleMessages.every((message, index) => message === event.messages[index])
1061
- ? undefined : { messages: visibleMessages };
1085
+ const finishProjection = (messages: AgentMessage[]) => {
1086
+ // Compare canonical retained copies before Remote encrypted-output encoding.
1087
+ const projected = remoteReplayProjection ? contextWindows.project(messages, "remote") : messages;
1088
+ return projected.length === event.messages.length && projected.every((message, index) => message === event.messages[index])
1089
+ ? undefined : { messages: projected };
1090
+ };
1062
1091
  const replayEvent = visibleMessages === event.messages ? event : { ...event, messages: visibleMessages };
1092
+ const branchEntries = ctx.sessionManager.getBranch();
1093
+ const inherited = config.contextManagement === "remote" && contextWindows.hasSupersededWindow()
1094
+ ? resolveLatestNativeCompactionEntry(branchEntries) : undefined;
1095
+ const inheritedCheckpoint = inherited?.ok ? inherited.entry : undefined;
1063
1096
  const resolution = await resolveNativeCompactionEnvironment(ctx, {
1064
1097
  enabled: config.enabled,
1065
- responsesApis: config.responsesApis,
1098
+ // Disabling checkpoint generation cannot turn an inherited opaque
1099
+ // checkpoint into a usable text summary. Replay checks transport support.
1100
+ responsesApis: inheritedCheckpoint ? undefined : config.responsesApis,
1066
1101
  codexGatewayModels: compactGatewayModels(resolved),
1067
1102
  });
1068
- if (!resolution.ok) return visibleResult;
1069
- const branchEntries = ctx.sessionManager.getBranch();
1103
+ if (!resolution.ok) {
1104
+ return inheritedCheckpoint
1105
+ ? rejectInheritedCheckpointReplay(ctx, config, inheritedCheckpoint.id, resolution.reason)
1106
+ : finishProjection(visibleMessages);
1107
+ }
1070
1108
  const latest = resolveLatestNativeCompactionEntry(branchEntries, { baseUrl: resolution.runtime.baseUrl });
1071
- if (!latest.ok) return visibleResult;
1109
+ if (!latest.ok) {
1110
+ return inheritedCheckpoint
1111
+ ? rejectInheritedCheckpointReplay(ctx, config, inheritedCheckpoint.id, latest.reason)
1112
+ : finishProjection(visibleMessages);
1113
+ }
1072
1114
  const result = removeNativeCompactionRetainedMessages({
1073
1115
  messages: replayEvent.messages,
1074
1116
  branchEntries,
@@ -1082,7 +1124,7 @@ async function handleContextInternal(
1082
1124
  ctx.abort();
1083
1125
  return undefined;
1084
1126
  }
1085
- return result.messages === replayEvent.messages ? visibleResult : { messages: result.messages };
1127
+ return finishProjection(result.messages);
1086
1128
  }
1087
1129
 
1088
1130
  /**
@@ -1135,16 +1177,20 @@ async function handleBeforeProviderRequest(
1135
1177
  return undefined;
1136
1178
  }
1137
1179
  }
1138
- if (config.contextManagement === "remote" && await remoteContextActive(ctx, config, contextModel)) {
1180
+ const remoteActive = config.contextManagement === "remote" && await remoteContextActive(ctx, config, contextModel);
1181
+ let remotePayload: unknown;
1182
+ if (remoteActive) {
1139
1183
  try {
1140
- return contextWindows.rewritePayload(event.payload, ctx);
1184
+ remotePayload = contextWindows.rewritePayload(event.payload, ctx);
1185
+ if (contextWindows.hasEffectiveWindow() || !contextWindows.currentIdentity()) return remotePayload;
1186
+ event = { ...event, payload: remotePayload };
1141
1187
  } catch {
1142
1188
  notifyRemoteContextFailure(ctx, "malformed-request-state");
1143
1189
  ctx.abort();
1144
1190
  return undefined;
1145
1191
  }
1146
1192
  }
1147
- if (isCodexContextModel(contextModel, config)) {
1193
+ if (!remoteActive && isCodexContextModel(contextModel, config)) {
1148
1194
  const registrationState = getContextToolRegistrationState();
1149
1195
  // A readable empty registry is an intentional host exclusion. Do not
1150
1196
  // rewrite gateway identity or abort an ordinary child-session request.
@@ -1163,18 +1209,23 @@ async function handleBeforeProviderRequest(
1163
1209
 
1164
1210
  // Do not reintroduce a pre-window opaque checkpoint after local projection.
1165
1211
  // Request affinity/encrypted-output rewriting is reserved for active Remote.
1166
- if (config.contextManagement === "remote" && contextWindows.currentIdentity()) return undefined;
1212
+ if (config.contextManagement === "remote" && contextWindows.hasEffectiveWindow()) return undefined;
1167
1213
 
1214
+ const branchEntries = ctx.sessionManager.getBranch();
1215
+ const inherited = config.contextManagement === "remote" && contextWindows.hasSupersededWindow()
1216
+ ? resolveLatestNativeCompactionEntry(branchEntries) : undefined;
1217
+ const inheritedCheckpoint = inherited?.ok ? inherited.entry : undefined;
1168
1218
  const resolution = await resolveNativeCompactionEnvironment(
1169
1219
  ctx,
1170
1220
  {
1171
1221
  enabled: config.enabled,
1172
- responsesApis: config.responsesApis,
1222
+ responsesApis: inheritedCheckpoint ? undefined : config.responsesApis,
1173
1223
  codexGatewayModels: compactGatewayModels(resolved),
1174
1224
  },
1175
1225
  event.payload,
1176
1226
  );
1177
1227
  if (resolution.ok === false) {
1228
+ if (inheritedCheckpoint) return rejectInheritedCheckpointReplay(ctx, config, inheritedCheckpoint.id, resolution.reason);
1178
1229
  writeDebugArtifact(
1179
1230
  "provider-request",
1180
1231
  {
@@ -1191,7 +1242,7 @@ async function handleBeforeProviderRequest(
1191
1242
  config,
1192
1243
  ctx,
1193
1244
  );
1194
- return undefined;
1245
+ return remotePayload;
1195
1246
  }
1196
1247
 
1197
1248
  const runtime = resolution.runtime;
@@ -1219,11 +1270,11 @@ async function handleBeforeProviderRequest(
1219
1270
  },
1220
1271
  );
1221
1272
 
1222
- const branchEntries = ctx.sessionManager.getBranch();
1223
1273
  const latestNativeCompaction = resolveLatestNativeCompactionEntry(branchEntries, {
1224
1274
  baseUrl: runtime.baseUrl,
1225
1275
  });
1226
1276
  if (!latestNativeCompaction.ok) {
1277
+ if (inheritedCheckpoint) return rejectInheritedCheckpointReplay(ctx, config, inheritedCheckpoint.id, latestNativeCompaction.reason);
1227
1278
  writeDebugArtifact(
1228
1279
  "provider-request",
1229
1280
  {
@@ -1241,7 +1292,7 @@ async function handleBeforeProviderRequest(
1241
1292
  config,
1242
1293
  ctx,
1243
1294
  );
1244
- return undefined;
1295
+ return remotePayload;
1245
1296
  }
1246
1297
 
1247
1298
  const latestNativeCompactionEntry = latestNativeCompaction.entry;
@@ -1469,7 +1520,7 @@ export default function registerCompactionExtension(
1469
1520
  return handleContext(event, ctx, pi, dependencies.loadConfig, contextWindows, remoteContextActive, getContextToolRegistrationState);
1470
1521
  });
1471
1522
  pi.on("session_before_compact", (event, ctx) => handleSessionBeforeCompact(event, ctx, dependencies, remoteContextActive, getContextToolRegistrationState));
1472
- pi.on("session_compact", (event, _ctx) => contextWindows.recordCompaction(event.compactionEntry.details));
1523
+ pi.on("session_compact", (_event, ctx) => contextWindows.synchronize(ctx));
1473
1524
  pi.on("session_compact_failed", async (event, ctx) => {
1474
1525
  if (event.reason === "manual" && !manualCompact.isMaintenance) await manualCompact.compactFailed(ctx);
1475
1526
  });
@@ -1,7 +1,8 @@
1
1
  import {
2
2
  compact,
3
+ convertToLlm,
3
4
  DEFAULT_COMPACTION_SETTINGS,
4
- estimateTokens,
5
+ serializeConversation,
5
6
  type CompactionResult,
6
7
  type ExtensionContext,
7
8
  type SessionBeforeCompactEvent,
@@ -59,16 +60,10 @@ function toErrorMessage(error: unknown): string {
59
60
  return error instanceof Error ? error.message : String(error);
60
61
  }
61
62
 
62
- /**
63
- * Output budget Pi gives the summary itself: `generateSummary` caps `maxTokens` at
64
- * `min(floor(0.8 * reserveTokens), model.maxTokens)`.
65
- */
66
- const SUMMARIZATION_OUTPUT_RESERVE_TOKENS = Math.floor(
67
- DEFAULT_COMPACTION_SETTINGS.reserveTokens * 0.8,
68
- );
69
-
70
- /** Summarization instructions plus the `<conversation>` framing around the transcript. */
63
+ /** Summarization instructions plus the framing around the serialized transcript. */
71
64
  const SUMMARIZATION_TEMPLATE_TOKENS = 1024;
65
+ const HISTORY_OUTPUT_RESERVE_RATIO = 0.8;
66
+ const TURN_PREFIX_OUTPUT_RESERVE_RATIO = 0.5;
72
67
 
73
68
  export type SummarizationRequestSize = {
74
69
  inputTokens: number;
@@ -76,33 +71,93 @@ export type SummarizationRequestSize = {
76
71
  totalTokens: number;
77
72
  };
78
73
 
74
+ function estimateTextTokens(text: string | undefined): number {
75
+ return text ? Math.ceil(String(text).length / 4) : 0;
76
+ }
77
+
78
+ function estimateSerializedConversationTokens(
79
+ messages: SessionBeforeCompactEvent["preparation"]["messagesToSummarize"],
80
+ ): number {
81
+ const serialized = serializeConversation(convertToLlm(messages));
82
+ return estimateTextTokens(serialized);
83
+ }
84
+
85
+ function getEffectiveReserveTokens(preparation: SessionBeforeCompactEvent["preparation"]): number {
86
+ const reserveTokens = preparation.settings?.reserveTokens;
87
+ return typeof reserveTokens === "number" && Number.isFinite(reserveTokens) && reserveTokens >= 0
88
+ ? reserveTokens
89
+ : DEFAULT_COMPACTION_SETTINGS.reserveTokens;
90
+ }
91
+
92
+ function getOutputReserveTokens(
93
+ reserveTokens: number,
94
+ model: { maxTokens?: number },
95
+ ratio: number,
96
+ ): number {
97
+ const outputTokens = Math.floor(ratio * reserveTokens);
98
+ return typeof model.maxTokens === "number" && model.maxTokens > 0
99
+ ? Math.min(outputTokens, model.maxTokens)
100
+ : outputTokens;
101
+ }
102
+
103
+ function estimateSummaryRequest(
104
+ messages: SessionBeforeCompactEvent["preparation"]["messagesToSummarize"],
105
+ outputTokens: number,
106
+ previousSummary?: string,
107
+ customInstructions?: string,
108
+ ): SummarizationRequestSize {
109
+ const inputTokens =
110
+ SUMMARIZATION_TEMPLATE_TOKENS
111
+ + estimateSerializedConversationTokens(messages)
112
+ + estimateTextTokens(previousSummary)
113
+ + estimateTextTokens(customInstructions);
114
+ return { inputTokens, outputTokens, totalTokens: inputTokens + outputTokens };
115
+ }
116
+
79
117
  /**
80
- * Size of the request Pi's `compact()` is about to send, measured the same way Pi
81
- * measures context (the exported `estimateTokens`, a conservative chars/4).
118
+ * Size of the individual request(s) Pi's `compact()` is about to send.
82
119
  *
83
- * A session driven by a wide-window model (400k) produces a preparation that a narrower
84
- * summary model (272k) cannot accept at all: the provider terminates the stream and the
85
- * first manual `/compact` fails. Callers must not attempt that request; the estimate is
86
- * deliberately an under-declaration of certainty, so a missing `contextWindow` skips the
87
- * check instead of guessing.
120
+ * Pi first converts messages with `convertToLlm()` and then applies
121
+ * `serializeConversation()`, which truncates tool results before they enter the
122
+ * summarization prompt. Split-turn compaction sends history and turn-prefix
123
+ * summaries as separate requests, so this returns the largest individual request
124
+ * rather than adding their inputs together. The fixed allowance remains
125
+ * deliberately conservative for Pi's system prompt, tags, and instructions.
126
+ *
127
+ * A session driven by a wide-window model (400k) can produce a preparation that a
128
+ * narrower summary model (272k) cannot accept. Callers must not attempt that request;
129
+ * a missing `contextWindow` skips the check instead of guessing.
88
130
  */
89
131
  export function estimateSummarizationRequest(
90
132
  preparation: SessionBeforeCompactEvent["preparation"],
91
133
  model: { contextWindow?: number; maxTokens?: number },
134
+ customInstructions?: string,
92
135
  ): SummarizationRequestSize {
93
- const conversation = [
94
- ...(preparation.messagesToSummarize ?? []),
95
- ...(preparation.turnPrefixMessages ?? []),
96
- ];
97
- let inputTokens = SUMMARIZATION_TEMPLATE_TOKENS;
98
- for (const message of conversation) inputTokens += estimateTokens(message as never);
99
- if (preparation.previousSummary) {
100
- inputTokens += Math.ceil(String(preparation.previousSummary).length / 4);
136
+ const reserveTokens = getEffectiveReserveTokens(preparation);
137
+ const historyMessages = preparation.messagesToSummarize ?? [];
138
+ const turnPrefixMessages = preparation.turnPrefixMessages ?? [];
139
+ const historyOutputTokens = getOutputReserveTokens(reserveTokens, model, HISTORY_OUTPUT_RESERVE_RATIO);
140
+ const historyRequest = estimateSummaryRequest(
141
+ historyMessages,
142
+ historyOutputTokens,
143
+ preparation.previousSummary,
144
+ customInstructions,
145
+ );
146
+
147
+ if (!preparation.isSplitTurn || turnPrefixMessages.length === 0) {
148
+ return historyRequest;
101
149
  }
102
- const outputTokens = model.maxTokens && model.maxTokens > 0
103
- ? Math.min(SUMMARIZATION_OUTPUT_RESERVE_TOKENS, model.maxTokens)
104
- : SUMMARIZATION_OUTPUT_RESERVE_TOKENS;
105
- return { inputTokens, outputTokens, totalTokens: inputTokens + outputTokens };
150
+
151
+ const requests = [
152
+ ...(historyMessages.length > 0 ? [historyRequest] : []),
153
+ estimateSummaryRequest(
154
+ turnPrefixMessages,
155
+ getOutputReserveTokens(reserveTokens, model, TURN_PREFIX_OUTPUT_RESERVE_RATIO),
156
+ ),
157
+ ];
158
+ return requests.reduce((largest, request) =>
159
+ request.totalTokens > largest.totalTokens ? request : largest,
160
+ );
106
161
  }
107
162
 
108
163
  /** Whether the summary model can accept this request at all; unknown windows always pass. */
@@ -163,7 +218,11 @@ export async function runNativeFallbackCompaction(args: {
163
218
  return { ok: false, reason: "same-as-current-model", modelSpec: spec };
164
219
  }
165
220
 
166
- const size = estimateSummarizationRequest(event.preparation, model as { contextWindow?: number; maxTokens?: number });
221
+ const size = estimateSummarizationRequest(
222
+ event.preparation,
223
+ model as { contextWindow?: number; maxTokens?: number },
224
+ event.customInstructions,
225
+ );
167
226
  if (!fitsSummarizationRequest(size, model as { contextWindow?: number })) {
168
227
  return {
169
228
  ok: false,