pi-openai-toolkit 0.20.6 → 0.20.8

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.
@@ -63,7 +63,7 @@ Place `context` under `defaults` or an exact `models` entry.
63
63
  | `remoteCompaction.allowContinuityBreak` | `false` | Allow restarting from Pi context after a foreign compaction entry. It does not make malformed opaque checkpoints replayable. |
64
64
  | `remoteCompaction.apis` | `["openai-responses", "openai-codex-responses", "azure-openai-responses"]` | May narrow this set; `[]` permits no remote-compaction API. Azure uses Pi's resolved `AZURE_OPENAI_*` environment values for the `/openai/v1` Responses path, deployment mapping, and `api-version`; unsupported entries are errors. |
65
65
  | `nativeFallback.enabled` | `true` | Enable the existing native-method fallback tier. |
66
- | `nativeFallback.model` | `null` | Optional summary-model reference on the remote-ineligible path. After a remote attempt fails, the existing producer-first fallback order is retained. |
66
+ | `nativeFallback.model` | `null` | Summary-model reference on the remote-ineligible path. Character-count estimates do not bypass it; resolution, auth or summary failure cancels compaction without retrying the active model. After a remote attempt fails, the existing producer-first selection is retained. Disabled/unset/same-model selections keep Pi's default path. |
67
67
  | `nativeFallback.thinkingLevel` | `"off"` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max`; actual model support still applies. |
68
68
  | `remoteWindows.leaveManagedMode` | `"warn"` | Legacy compatibility field. Both values preserve the same behavior: non-window models receive only the current local window, and no bulk close-out compaction is started for retired Remote Context history. |
69
69
  | `remoteWindows.reminderThresholdPercent` | `5` | Integer 0-100. `0` disables both the once-per-window reminder and exhausted-window fallback. |
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,13 +91,13 @@ 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.
99
- - 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.
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`.
100
+ - Native text summarization delegates prompt construction, tool-result truncation, split-turn requests and output limits to Pi's `compact()`. Toolkit does not use character-count estimates to reject the configured summary model. If its resolution, authentication or summary generation fails, compaction is cancelled with the error and context is preserved; the active model is not an implicit retry target. Disabled/unset/same-model selections retain Pi's default path. Remote-window projection still excludes retired managed history before any producer call.
100
101
 
101
102
  ---
102
103
 
@@ -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.6",
3
+ "version": "0.20.8",
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 {
@@ -926,8 +926,6 @@ async function handleSessionBeforeCompact(
926
926
  reason: fallback.reason,
927
927
  modelSpec: fallback.modelSpec,
928
928
  errorMessage: fallback.errorMessage,
929
- ...(fallback.estimatedTokens === undefined ? {} : { estimatedTokens: fallback.estimatedTokens }),
930
- ...(fallback.contextWindow === undefined ? {} : { contextWindow: fallback.contextWindow }),
931
929
  },
932
930
  config,
933
931
  ctx,
@@ -938,25 +936,33 @@ async function handleSessionBeforeCompact(
938
936
  fallback.reason === "disabled" ||
939
937
  fallback.reason === "no-model-configured" ||
940
938
  fallback.reason === "same-as-current-model";
941
- if (fallback.reason === "model-window-too-small") {
942
- // The configured summary model is narrower than the request Pi would send for it.
943
- // Skipping is the success path here: pi's current model gets one chance instead of
944
- // the provider terminating the oversized summary.
939
+ if (!intentionalSkip) {
940
+ // An explicit summary model is authoritative: the active model may be
941
+ // unable to summarize at all. Preserve context on failure, never reroute.
945
942
  notifyWarning(
946
943
  ctx,
947
- `compaction summary model "${fallback.modelSpec}" cannot fit ~${fallback.estimatedTokens} tokens in its ${fallback.contextWindow} window; compacting with the current model instead`,
948
- );
949
- } else if (!intentionalSkip) {
950
- notifyWarning(
951
- ctx,
952
- `compaction model "${fallback.modelSpec}" unusable (${fallback.reason}${fallback.errorMessage ? `: ${fallback.errorMessage}` : ""}); using pi's default compaction`,
944
+ `compaction model "${fallback.modelSpec ?? fallbackModelSpec}" failed (${fallback.reason}${fallback.errorMessage ? `: ${fallback.errorMessage}` : ""}); compaction cancelled, context preserved; not switching to the current model`,
953
945
  );
946
+ return { cancel: true };
954
947
  }
955
948
 
956
949
  // Branch 3: pi's default native compaction with the current model.
957
950
  return undefined;
958
951
  }
959
952
 
953
+ function rejectInheritedCheckpointReplay(
954
+ ctx: ExtensionContext,
955
+ config: CompactionConfig,
956
+ compactionEntryId: string,
957
+ reason: string,
958
+ ): undefined {
959
+ writeReplayFailureArtifact({ reason, compactionEntryId }, config, ctx);
960
+ reportPiContextHookFailure(ctx, `replay-failed:${reason}`);
961
+ if (ctx.hasUI) ctx.ui.notify(`${COMPACTION_EXTENSION_ID}: inherited checkpoint replay failed (${reason}); request aborted`, "error");
962
+ ctx.abort();
963
+ return undefined;
964
+ }
965
+
960
966
  async function handleContextInternal(
961
967
  event: ContextWithSystemEvent,
962
968
  ctx: ExtensionContext,
@@ -977,6 +983,7 @@ async function handleContextInternal(
977
983
  }
978
984
 
979
985
  let localMessages: AgentMessage[] | undefined;
986
+ let remoteReplayProjection = false;
980
987
  if (config.contextManagement === "remote") {
981
988
  const contextModel = await resolveContextCapabilityModel(ctx);
982
989
  try {
@@ -994,7 +1001,11 @@ async function handleContextInternal(
994
1001
  // Measure the provider-visible current window, not Pi's durable transcript.
995
1002
  // The latter still contains retired remote windows and can be inflated by
996
1003
  // an upstream error even though this request only carries the live window.
997
- const projected = contextWindows.project(event.messages, "remote");
1004
+ let projected = contextWindows.project(event.messages, "remote", false);
1005
+ // Projection may observe the exact queued rollover before Pi persists
1006
+ // it. Decide replay ownership from this request, not the old branch.
1007
+ const ownsWindow = contextWindows.hasEffectiveWindow() || !contextWindows.currentIdentity();
1008
+ if (ownsWindow) projected = contextWindows.project(projected, "remote");
998
1009
  contextWindows.recordBudget(
999
1010
  pi,
1000
1011
  ctx,
@@ -1002,9 +1013,13 @@ async function handleContextInternal(
1002
1013
  config.contextReminderThresholdPercent,
1003
1014
  );
1004
1015
  reportProjectionDiagnostics(contextWindows, config, ctx);
1005
- return projected.length === event.messages.length && projected.every((message, index) => message === event.messages[index])
1006
- ? undefined
1007
- : { messages: projected };
1016
+ if (ownsWindow) {
1017
+ return projected.length === event.messages.length && projected.every((message, index) => message === event.messages[index])
1018
+ ? undefined
1019
+ : { messages: projected };
1020
+ }
1021
+ localMessages = projected;
1022
+ remoteReplayProjection = true;
1008
1023
  } catch (error) {
1009
1024
  notifyRemoteContextFailure(ctx, "malformed-window-state");
1010
1025
  reportPiContextHookFailure(ctx, "malformed-window-state");
@@ -1013,11 +1028,10 @@ async function handleContextInternal(
1013
1028
  }
1014
1029
  }
1015
1030
 
1016
- // An eligible Codex Remote session is never allowed to fall through to the
1017
- // older compaction-replay path. An explicit host exclusion is the one
1018
- // intentional downgrade: strip internal markers and let the ordinary request
1019
- // proceed without Remote Context affinity.
1020
- if (isCodexContextModel(contextModel, config)) {
1031
+ // An unavailable covered runtime must not silently become an ordinary
1032
+ // replay consumer. Explicit host exclusion remains the one intentional
1033
+ // downgrade; active Remote can replay a newer inherited checkpoint.
1034
+ if (!remoteActive && isCodexContextModel(contextModel, config)) {
1021
1035
  const registrationState = getContextToolRegistrationState();
1022
1036
  if (isExcludedGatewayContext(contextModel, config, registrationState)) {
1023
1037
  const visibleMessages = contextWindows.project(event.messages, "off");
@@ -1038,39 +1052,58 @@ async function handleContextInternal(
1038
1052
  : { messages: visibleMessages };
1039
1053
  }
1040
1054
 
1041
- try {
1042
- const projected = contextWindows.project(event.messages, "local");
1043
- localMessages = projected;
1044
- if (contextWindows.currentIdentity()) {
1045
- // This boundary owns earlier history, including any older Remote V2
1046
- // checkpoint. A local request must not replay that history back in.
1047
- return projected.length === event.messages.length && projected.every((message, index) => message === event.messages[index])
1048
- ? undefined
1049
- : { messages: projected };
1055
+ if (!remoteActive) {
1056
+ try {
1057
+ const projected = contextWindows.project(event.messages, "local");
1058
+ localMessages = projected;
1059
+ if (contextWindows.hasEffectiveWindow()) {
1060
+ // This boundary owns earlier history, including any older Remote V2
1061
+ // checkpoint. A local request must not replay that history back in.
1062
+ return projected.length === event.messages.length && projected.every((message, index) => message === event.messages[index])
1063
+ ? undefined
1064
+ : { messages: projected };
1065
+ }
1066
+ } catch (error) {
1067
+ notifyRemoteContextFailure(ctx, "malformed-window-state");
1068
+ reportPiContextHookFailure(ctx, error instanceof Error ? error.message : String(error));
1069
+ ctx.abort();
1070
+ return undefined;
1050
1071
  }
1051
- } catch (error) {
1052
- notifyRemoteContextFailure(ctx, "malformed-window-state");
1053
- reportPiContextHookFailure(ctx, error instanceof Error ? error.message : String(error));
1054
- ctx.abort();
1055
- return undefined;
1056
1072
  }
1057
1073
  }
1058
1074
 
1059
- // With no Remote boundary, the ordinary checkpoint replay path remains the
1060
- // owner. Inactive covered Codex consumers were handled separately above.
1075
+ // Without an effective Remote boundary, the latest checkpoint owns replay.
1076
+ // This also covers a newer checkpoint superseding a historical window.
1061
1077
  const visibleMessages = localMessages ?? contextWindows.project(event.messages, "off");
1062
- const visibleResult = visibleMessages.length === event.messages.length && visibleMessages.every((message, index) => message === event.messages[index])
1063
- ? undefined : { messages: visibleMessages };
1078
+ const finishProjection = (messages: AgentMessage[]) => {
1079
+ // Compare canonical retained copies before Remote encrypted-output encoding.
1080
+ const projected = remoteReplayProjection ? contextWindows.project(messages, "remote") : messages;
1081
+ return projected.length === event.messages.length && projected.every((message, index) => message === event.messages[index])
1082
+ ? undefined : { messages: projected };
1083
+ };
1064
1084
  const replayEvent = visibleMessages === event.messages ? event : { ...event, messages: visibleMessages };
1085
+ const branchEntries = ctx.sessionManager.getBranch();
1086
+ const inherited = config.contextManagement === "remote" && contextWindows.hasSupersededWindow()
1087
+ ? resolveLatestNativeCompactionEntry(branchEntries) : undefined;
1088
+ const inheritedCheckpoint = inherited?.ok ? inherited.entry : undefined;
1065
1089
  const resolution = await resolveNativeCompactionEnvironment(ctx, {
1066
1090
  enabled: config.enabled,
1067
- responsesApis: config.responsesApis,
1091
+ // Disabling checkpoint generation cannot turn an inherited opaque
1092
+ // checkpoint into a usable text summary. Replay checks transport support.
1093
+ responsesApis: inheritedCheckpoint ? undefined : config.responsesApis,
1068
1094
  codexGatewayModels: compactGatewayModels(resolved),
1069
1095
  });
1070
- if (!resolution.ok) return visibleResult;
1071
- const branchEntries = ctx.sessionManager.getBranch();
1096
+ if (!resolution.ok) {
1097
+ return inheritedCheckpoint
1098
+ ? rejectInheritedCheckpointReplay(ctx, config, inheritedCheckpoint.id, resolution.reason)
1099
+ : finishProjection(visibleMessages);
1100
+ }
1072
1101
  const latest = resolveLatestNativeCompactionEntry(branchEntries, { baseUrl: resolution.runtime.baseUrl });
1073
- if (!latest.ok) return visibleResult;
1102
+ if (!latest.ok) {
1103
+ return inheritedCheckpoint
1104
+ ? rejectInheritedCheckpointReplay(ctx, config, inheritedCheckpoint.id, latest.reason)
1105
+ : finishProjection(visibleMessages);
1106
+ }
1074
1107
  const result = removeNativeCompactionRetainedMessages({
1075
1108
  messages: replayEvent.messages,
1076
1109
  branchEntries,
@@ -1084,7 +1117,7 @@ async function handleContextInternal(
1084
1117
  ctx.abort();
1085
1118
  return undefined;
1086
1119
  }
1087
- return result.messages === replayEvent.messages ? visibleResult : { messages: result.messages };
1120
+ return finishProjection(result.messages);
1088
1121
  }
1089
1122
 
1090
1123
  /**
@@ -1137,16 +1170,20 @@ async function handleBeforeProviderRequest(
1137
1170
  return undefined;
1138
1171
  }
1139
1172
  }
1140
- if (config.contextManagement === "remote" && await remoteContextActive(ctx, config, contextModel)) {
1173
+ const remoteActive = config.contextManagement === "remote" && await remoteContextActive(ctx, config, contextModel);
1174
+ let remotePayload: unknown;
1175
+ if (remoteActive) {
1141
1176
  try {
1142
- return contextWindows.rewritePayload(event.payload, ctx);
1177
+ remotePayload = contextWindows.rewritePayload(event.payload, ctx);
1178
+ if (contextWindows.hasEffectiveWindow() || !contextWindows.currentIdentity()) return remotePayload;
1179
+ event = { ...event, payload: remotePayload };
1143
1180
  } catch {
1144
1181
  notifyRemoteContextFailure(ctx, "malformed-request-state");
1145
1182
  ctx.abort();
1146
1183
  return undefined;
1147
1184
  }
1148
1185
  }
1149
- if (isCodexContextModel(contextModel, config)) {
1186
+ if (!remoteActive && isCodexContextModel(contextModel, config)) {
1150
1187
  const registrationState = getContextToolRegistrationState();
1151
1188
  // A readable empty registry is an intentional host exclusion. Do not
1152
1189
  // rewrite gateway identity or abort an ordinary child-session request.
@@ -1165,18 +1202,23 @@ async function handleBeforeProviderRequest(
1165
1202
 
1166
1203
  // Do not reintroduce a pre-window opaque checkpoint after local projection.
1167
1204
  // Request affinity/encrypted-output rewriting is reserved for active Remote.
1168
- if (config.contextManagement === "remote" && contextWindows.currentIdentity()) return undefined;
1205
+ if (config.contextManagement === "remote" && contextWindows.hasEffectiveWindow()) return undefined;
1169
1206
 
1207
+ const branchEntries = ctx.sessionManager.getBranch();
1208
+ const inherited = config.contextManagement === "remote" && contextWindows.hasSupersededWindow()
1209
+ ? resolveLatestNativeCompactionEntry(branchEntries) : undefined;
1210
+ const inheritedCheckpoint = inherited?.ok ? inherited.entry : undefined;
1170
1211
  const resolution = await resolveNativeCompactionEnvironment(
1171
1212
  ctx,
1172
1213
  {
1173
1214
  enabled: config.enabled,
1174
- responsesApis: config.responsesApis,
1215
+ responsesApis: inheritedCheckpoint ? undefined : config.responsesApis,
1175
1216
  codexGatewayModels: compactGatewayModels(resolved),
1176
1217
  },
1177
1218
  event.payload,
1178
1219
  );
1179
1220
  if (resolution.ok === false) {
1221
+ if (inheritedCheckpoint) return rejectInheritedCheckpointReplay(ctx, config, inheritedCheckpoint.id, resolution.reason);
1180
1222
  writeDebugArtifact(
1181
1223
  "provider-request",
1182
1224
  {
@@ -1193,7 +1235,7 @@ async function handleBeforeProviderRequest(
1193
1235
  config,
1194
1236
  ctx,
1195
1237
  );
1196
- return undefined;
1238
+ return remotePayload;
1197
1239
  }
1198
1240
 
1199
1241
  const runtime = resolution.runtime;
@@ -1221,11 +1263,11 @@ async function handleBeforeProviderRequest(
1221
1263
  },
1222
1264
  );
1223
1265
 
1224
- const branchEntries = ctx.sessionManager.getBranch();
1225
1266
  const latestNativeCompaction = resolveLatestNativeCompactionEntry(branchEntries, {
1226
1267
  baseUrl: runtime.baseUrl,
1227
1268
  });
1228
1269
  if (!latestNativeCompaction.ok) {
1270
+ if (inheritedCheckpoint) return rejectInheritedCheckpointReplay(ctx, config, inheritedCheckpoint.id, latestNativeCompaction.reason);
1229
1271
  writeDebugArtifact(
1230
1272
  "provider-request",
1231
1273
  {
@@ -1243,7 +1285,7 @@ async function handleBeforeProviderRequest(
1243
1285
  config,
1244
1286
  ctx,
1245
1287
  );
1246
- return undefined;
1288
+ return remotePayload;
1247
1289
  }
1248
1290
 
1249
1291
  const latestNativeCompactionEntry = latestNativeCompaction.entry;
@@ -1471,7 +1513,7 @@ export default function registerCompactionExtension(
1471
1513
  return handleContext(event, ctx, pi, dependencies.loadConfig, contextWindows, remoteContextActive, getContextToolRegistrationState);
1472
1514
  });
1473
1515
  pi.on("session_before_compact", (event, ctx) => handleSessionBeforeCompact(event, ctx, dependencies, remoteContextActive, getContextToolRegistrationState));
1474
- pi.on("session_compact", (event, _ctx) => contextWindows.recordCompaction(event.compactionEntry.details));
1516
+ pi.on("session_compact", (_event, ctx) => contextWindows.synchronize(ctx));
1475
1517
  pi.on("session_compact_failed", async (event, ctx) => {
1476
1518
  if (event.reason === "manual" && !manualCompact.isMaintenance) await manualCompact.compactFailed(ctx);
1477
1519
  });
@@ -1,8 +1,5 @@
1
1
  import {
2
2
  compact,
3
- convertToLlm,
4
- DEFAULT_COMPACTION_SETTINGS,
5
- serializeConversation,
6
3
  type CompactionResult,
7
4
  type ExtensionContext,
8
5
  type SessionBeforeCompactEvent,
@@ -23,9 +20,7 @@ export type NativeFallbackFailureReason =
23
20
  | "auth-failed"
24
21
  | "aborted"
25
22
  | "empty-summary"
26
- | "compact-failed"
27
- /** The configured summary model cannot fit the request it would have to send. */
28
- | "model-window-too-small";
23
+ | "compact-failed";
29
24
 
30
25
  export type NativeFallbackResult =
31
26
  | {
@@ -38,8 +33,6 @@ export type NativeFallbackResult =
38
33
  reason: NativeFallbackFailureReason;
39
34
  modelSpec?: string;
40
35
  errorMessage?: string;
41
- estimatedTokens?: number;
42
- contextWindow?: number;
43
36
  };
44
37
 
45
38
  /** pi's exported native compact(); injectable for tests. */
@@ -60,116 +53,6 @@ function toErrorMessage(error: unknown): string {
60
53
  return error instanceof Error ? error.message : String(error);
61
54
  }
62
55
 
63
- /** Summarization instructions plus the framing around the serialized transcript. */
64
- const SUMMARIZATION_TEMPLATE_TOKENS = 1024;
65
- const HISTORY_OUTPUT_RESERVE_RATIO = 0.8;
66
- const TURN_PREFIX_OUTPUT_RESERVE_RATIO = 0.5;
67
-
68
- export type SummarizationRequestSize = {
69
- inputTokens: number;
70
- outputTokens: number;
71
- totalTokens: number;
72
- };
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
-
117
- /**
118
- * Size of the individual request(s) Pi's `compact()` is about to send.
119
- *
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.
130
- */
131
- export function estimateSummarizationRequest(
132
- preparation: SessionBeforeCompactEvent["preparation"],
133
- model: { contextWindow?: number; maxTokens?: number },
134
- customInstructions?: string,
135
- ): SummarizationRequestSize {
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;
149
- }
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
- );
161
- }
162
-
163
- /** Whether the summary model can accept this request at all; unknown windows always pass. */
164
- export function fitsSummarizationRequest(
165
- size: SummarizationRequestSize,
166
- model: { contextWindow?: number },
167
- ): boolean {
168
- const contextWindow = model.contextWindow;
169
- if (typeof contextWindow !== "number" || contextWindow <= 0) return true;
170
- return size.totalTokens <= contextWindow;
171
- }
172
-
173
56
  /**
174
57
  * Run pi's native compaction method with a caller-resolved summary model.
175
58
  *
@@ -218,20 +101,9 @@ export async function runNativeFallbackCompaction(args: {
218
101
  return { ok: false, reason: "same-as-current-model", modelSpec: spec };
219
102
  }
220
103
 
221
- const size = estimateSummarizationRequest(
222
- event.preparation,
223
- model as { contextWindow?: number; maxTokens?: number },
224
- event.customInstructions,
225
- );
226
- if (!fitsSummarizationRequest(size, model as { contextWindow?: number })) {
227
- return {
228
- ok: false,
229
- reason: "model-window-too-small",
230
- modelSpec: spec,
231
- estimatedTokens: size.totalTokens,
232
- contextWindow: (model as { contextWindow?: number }).contextWindow,
233
- };
234
- }
104
+ // Pi owns prompt construction, tool-result truncation and split-turn requests.
105
+ // A chars/4 estimate is not target-model token accounting and must not veto
106
+ // the user's summary model. Surface actual provider failures to the caller.
235
107
 
236
108
  let auth: ResolvedAuth;
237
109
  try {