pi-openai-toolkit 0.20.4 → 0.20.5

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/README.md CHANGED
@@ -54,9 +54,9 @@ Set `defaults.context.mode` or an individual model's `context.mode`:
54
54
 
55
55
  **Remote windows** require the native `openai-codex` provider with `openai-codex-responses`, or an `openai-responses` gateway implementing the protocol. Gateways require `compatibility.transport: "codex-gateway"` on the exact model entry. This setting does not add the capability to an ordinary compatible endpoint.
56
56
 
57
- Change `mode` in the example above to `remote-windows`. Eligible sessions expose `new_context`, `get_context_remaining`, `history`, and `notes`. Manual `/compact [instructions]` starts a checkpoint handoff: write notes successfully, then switch windows. Models without window capability retain the compaction flow; a failed window activation does not silently switch to a summary.
57
+ Change `mode` in the example above to `remote-windows`. Eligible sessions expose `new_context`, `get_context_remaining`, `history`, and `notes`. Manual `/compact [instructions]` starts a checkpoint handoff: write notes successfully, then switch windows. Models without window capability use only the current local window projection: retired Remote Context history is not sent to the model or to a configured compaction producer. A no-summary local boundary preserves the persisted Remote Window / Context Session, so switching back to a supported model reuses the same window instead of opening a duplicate one. The legacy `leaveManagedMode` setting is retained for config compatibility but no longer triggers bulk close-out compaction.
58
58
 
59
- Remote windows use alpha endpoints that may change upstream. When switching models, also watch for retired-window history that has not yet been trimmed. See the [window lifecycle](docs/internals.md#rollover-lifecycle).
59
+ Remote windows use alpha endpoints that may change upstream. When switching models, the remote window remains persisted and reusable; see the [window lifecycle](docs/internals.md#rollover-lifecycle).
60
60
 
61
61
  ## Search, images, and tool review
62
62
 
package/README.zh.md CHANGED
@@ -54,9 +54,9 @@ Toolkit 只读取一个全局配置文件,即使扩展安装在项目内也一
54
54
 
55
55
  **远程窗口**需要原生 `openai-codex` 提供商的 `openai-codex-responses` API,或支持相应协议的 `openai-responses` 网关。网关必须在精确模型下设置 `compatibility.transport: "codex-gateway"`;这一设置不会让普通兼容端点自动获得该能力。
56
56
 
57
- 将上例的 `mode` 改为 `remote-windows` 后,符合条件的会话会提供 `new_context`、`get_context_remaining`、`history` 和 `notes`。手动 `/compact [instructions]` 会发起检查点交接:先成功写入 notes,再切换窗口。不具备窗口能力的模型仍走压缩流程;窗口激活失败不会静默改用摘要。
57
+ 将上例的 `mode` 改为 `remote-windows` 后,符合条件的会话会提供 `new_context`、`get_context_remaining`、`history` 和 `notes`。手动 `/compact [instructions]` 会发起检查点交接:先成功写入 notes,再切换窗口。不具备窗口能力的模型只使用当前本地窗口投影:已经由 Remote Context 托管的旧窗口历史不会发送给该模型,也不会发送给配置的 compaction 模型。扩展会用一个不生成摘要的本地边界保留已持久化的 Remote Window / Context Session;切回支持窗口的模型时会复用同一个窗口,不会重复创建。旧配置中的 `leaveManagedMode` 仍会解析,但不再触发批量 close-out 压缩。
58
58
 
59
- 远程窗口依赖 alpha 接口,上游可能随时调整。切换模型前也要留意旧窗口历史仍未清理的情况,详见[窗口生命周期](docs/internals.md#rollover-lifecycle)。
59
+ 远程窗口依赖 alpha 接口,上游可能随时调整。切换模型时远程窗口状态仍会持久化并可复用,详见[窗口生命周期](docs/internals.md#rollover-lifecycle)。
60
60
 
61
61
  ## 搜索、生图与工具审查
62
62
 
@@ -65,10 +65,10 @@ Place `context` under `defaults` or an exact `models` entry.
65
65
  | `nativeFallback.enabled` | `true` | Enable the existing native-method fallback tier. |
66
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. |
67
67
  | `nativeFallback.thinkingLevel` | `"off"` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max`; actual model support still applies. |
68
- | `remoteWindows.leaveManagedMode` | `"warn"` | `"compact"` also requests a close-out when retired history exceeds 80% of the next model's window and compaction can finish. Print mode remains warning-only; turn-end close-out requires a queued trim. |
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. |
70
70
 
71
- Native Codex eligibility derives from the actual Pi provider/API. Gateways additionally need the exact transport opt-in below. `remote-windows` retains the compaction chain for models outside window capability. A configured window model whose activation fails does not silently receive a summary fallback. Normal `new_context` keeps its persisted-notes, duplicate and cooldown gates. Synthetic Remote V2 producer/consumer identity and checkpoint provenance are unchanged. See [Toolkit internals](internals.md).
71
+ Native Codex eligibility derives from the actual Pi provider/API. Gateways additionally need the exact transport opt-in below. `remote-windows` keeps the Remote Window state persisted when a model without window capability is selected: that model receives only the current local window, while retired/managed Remote Context history is excluded from live requests and compaction producers. A no-summary boundary preserves the marker for later reuse. A configured window model whose activation fails does not silently receive a summary fallback. Normal `new_context` keeps its persisted-notes, duplicate and cooldown gates. Synthetic Remote V2 producer/consumer identity and checkpoint provenance are unchanged. See [Toolkit internals](internals.md).
72
72
 
73
73
  In active `remote-windows`, `/compact [instructions]` starts checkpoint duty rather than producing a summary. The selected model must authenticate to the same native backend/account or gateway credential/affinity domain, fit the projected window plus its output reserve, and retain the required permitted context tools. Invalid or unsuitable explicit targets are refused without switching to a fallback model. If Auto Mode is engaged, the target must also be eligible for its gate. The entire handoff uses the initiating context-policy snapshot; no config or global Pi defaults are rewritten.
74
74
 
package/docs/internals.md CHANGED
@@ -94,9 +94,9 @@ State invariants the lifecycle code must keep honest:
94
94
  - 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
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
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 is a projection-level act: the session branch keeps every retired window until a compaction consumes the scheduled trim. `evaluateWindowBulk()` measures that gap and compares it with the model about to receive it, `decideBulkCliffAction()` maps the report onto the `leaveManagedMode` policy (`warn` default, `compact`), and `decideBulkCloseOut()` only allows the automatic compaction where a compaction can actually finish: never in print mode (the process exits with the turn and would kill the summary), and at turn end only while a rollover trim is queued, because the window policy cancels every other compaction. Each window and model is surfaced once, and every decision writes a `window-bulk.<action>` compaction artifact with its trigger.
98
- - The close-out runs from `model_select` and `agent_settled`, never from `before_agent_start`: `ExtensionContext.compact()` aborts the running turn, and a hook inside turn processing is exactly the busy lane it must not fight.
99
- - A configured native-fallback summary model is checked against the size of the request Pi is about to send it (`estimateSummarizationRequest`). A narrower summary model than the active one - 272k summarizing a 400k session - returns `model-window-too-small` before authentication or the model call, so the current model gets the first attempt instead of a terminated stream.
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.
100
100
 
101
101
  ---
102
102
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-openai-toolkit",
3
- "version": "0.20.4",
3
+ "version": "0.20.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -8,6 +8,12 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core";
8
8
  import type { LeaveManagedModePolicy } from "../types";
9
9
  import { isContextWindowBoundary } from "./messages";
10
10
 
11
+ /**
12
+ * Legacy pure diagnostics retained for config/test compatibility. Runtime model switching no
13
+ * longer calls this module: non-window consumers use the current-local projection instead of
14
+ * measuring or compacting the durable Remote Context branch.
15
+ */
16
+
11
17
  /**
12
18
  * Share of the target model's context window above which a projection is considered
13
19
  * over budget. Estimates are conservative (`estimateTokens` is chars/4 and overestimates),
@@ -2,6 +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
6
  calculateContextTokens,
6
7
  estimateTokens,
7
8
  type CompactionResult,
@@ -11,7 +12,6 @@ import {
11
12
  type SessionEntry,
12
13
  } from "@earendil-works/pi-coding-agent";
13
14
  import { ContextWindowBudget, type ContextRemaining } from "./window-budget";
14
- import type { WindowBulkReport } from "./window-bulk";
15
15
  import {
16
16
  rewriteEncryptedToolOutputs,
17
17
  rewriteWindowHeaders,
@@ -86,8 +86,6 @@ export class CodexContextWindowManager {
86
86
  private trimPendingWindowId: string | undefined;
87
87
  private readonly projectionDiagnostics = new Set<string>();
88
88
  private lastKnownSystemHead: AgentMessage | undefined;
89
- private bulkReport: WindowBulkReport | undefined;
90
- private bulkSurfacedKey: string | undefined;
91
89
  /**
92
90
  * Pi's getContextUsage() measures the durable transcript. Remote windows are
93
91
  * projected only by this extension, so remember the last provider-visible
@@ -105,50 +103,16 @@ export class CodexContextWindowManager {
105
103
  this.pendingRollover = undefined;
106
104
  this.projectionDiagnostics.clear();
107
105
  this.lastKnownSystemHead = undefined;
108
- this.bulkReport = undefined;
109
- this.bulkSurfacedKey = undefined;
110
106
  }
111
107
 
112
108
  currentIdentity(): ContextWindowIdentity | undefined {
113
109
  return this.identity ? { ...this.identity } : undefined;
114
110
  }
115
- /**
116
- * Whether a rollover trim is queued for the next eligible compaction. A turn-end bulk
117
- * close-out is only worth a model call while that trim is still outstanding.
118
- */
111
+ /** Whether a rollover trim awaits a matching persisted compaction. */
119
112
  hasPendingTrim(): boolean {
120
113
  return this.trimPendingWindowId !== undefined;
121
114
  }
122
115
 
123
- /** Remember the latest measurement for this window; see `takeBulkCliff`. */
124
- noteWindowBulk(report: WindowBulkReport | undefined, modelKey: string): void {
125
- if (report) this.bulkReport = report;
126
- else if (this.bulkKey(modelKey) === undefined) this.bulkReport = undefined;
127
- }
128
-
129
- /**
130
- * Drain the pending bulk-cliff notice for this window and model. The decision what to
131
- * do about it (warn, or compact before the next request) belongs to the runtime; this
132
- * only guarantees the same window and model is never surfaced twice.
133
- */
134
- takeBulkCliff(modelKey: string): WindowBulkReport | undefined {
135
- const report = this.bulkReport;
136
- if (!report || !report.expanded) return undefined;
137
- const key = this.bulkKey(modelKey);
138
- if (key === undefined || this.bulkSurfacedKey === key) return undefined;
139
- this.bulkSurfacedKey = key;
140
- return report;
141
- }
142
-
143
- private bulkKey(modelKey: string): string | undefined {
144
- // The report's own window id is authoritative: this notice exists precisely for the
145
- // models that never open the window lifecycle, where `identity` stays undefined.
146
- const windowId = this.bulkReport?.windowId ?? this.identity?.currentWindowId;
147
- return windowId ? `${windowId}|${modelKey}` : undefined;
148
- }
149
-
150
-
151
-
152
116
  private resetWindowState(): void {
153
117
  this.identity = undefined;
154
118
  this.sessionId = undefined;
@@ -246,7 +210,7 @@ export class CodexContextWindowManager {
246
210
 
247
211
  project(
248
212
  messages: readonly AgentMessage[],
249
- mode: "off" | "remote",
213
+ mode: "off" | "local" | "remote",
250
214
  ): AgentMessage[] {
251
215
  if (mode === "off") {
252
216
  this.projectedContextTokens = undefined;
@@ -268,20 +232,30 @@ export class CodexContextWindowManager {
268
232
  if (!matchesSession(message.details.sessionId, this.sessionId)) continue;
269
233
  }
270
234
  if (!isContextWindowBoundary(message)) continue;
235
+ // Local consumers follow the synchronized persisted identity, not a
236
+ // queued marker from the request view. They never own the Remote session.
237
+ if (mode === "local" && message.details.contextManagement.currentWindowId !== this.identity?.currentWindowId) continue;
271
238
  boundaryIndex = index;
272
- this.identity = identityFromDetails(message.details);
239
+ if (mode === "remote") this.identity = identityFromDetails(message.details);
273
240
  }
274
241
  if (boundaryIndex < 0) {
275
- this.identity = undefined;
276
- this.restoredMarkerId = undefined;
277
- this.budget.reset();
278
- this.trimPendingWindowId = undefined;
242
+ if (mode === "local" && this.identity) {
243
+ throw new Error("Persisted Codex context-window boundary missing from local projection");
244
+ }
245
+ if (mode === "remote") {
246
+ this.identity = undefined;
247
+ this.restoredMarkerId = undefined;
248
+ this.budget.reset();
249
+ this.trimPendingWindowId = undefined;
250
+ }
279
251
  this.projectedContextTokens = undefined;
280
252
  }
281
253
  // Projection observes the request view, never durable session state. A
282
254
  // queued rollover marker must not clear the duplicate guard here.
283
- const trimmed = boundaryIndex < 0 ? [...messages] : this.trimToWindow(messages, boundaryIndex);
284
- const projected = mode === "remote" ? projectEncryptedToolResults(trimmed) : trimmed;
255
+ const trimmed = boundaryIndex < 0 ? [...messages] : this.trimToWindow(messages, boundaryIndex, mode === "local");
256
+ const projected = mode === "remote" ? projectEncryptedToolResults(trimmed) : trimmed.filter(
257
+ (message) => message.role !== "custom" || message.customType !== CODEX_CONTEXT_WINDOW_MESSAGE_TYPE,
258
+ );
285
259
  this.projectedContextTokens = mode === "remote" && this.identity
286
260
  ? estimateProjectedWindowTokens(projected)
287
261
  : undefined;
@@ -295,10 +269,13 @@ export class CodexContextWindowManager {
295
269
  * sent, because a window without tools silently degrades into a chat the model can
296
270
  * only narrate. Each refusal is reported once per window and reason.
297
271
  */
298
- private trimToWindow(messages: readonly AgentMessage[], boundaryIndex: number): AgentMessage[] {
272
+ private trimToWindow(messages: readonly AgentMessage[], boundaryIndex: number, local = false): AgentMessage[] {
299
273
  const repair = preserveSystemHead({ messages, boundaryIndex, fallbackHead: this.lastKnownSystemHead });
300
274
  if (repair.head) this.lastKnownSystemHead = repair.head;
301
275
  if (repair.shrinkRejected) {
276
+ // Remote requests retain the existing tool-preservation fallback. A
277
+ // local request must never recover tools by leaking retired windows.
278
+ if (local) throw new Error("Cannot preserve the system/tool head in local context-window projection");
302
279
  this.recordProjectionDiagnostic(repair.lostToolNames);
303
280
  }
304
281
  return [...repair.messages];
@@ -461,6 +438,33 @@ export class CodexContextWindowManager {
461
438
  return { compaction };
462
439
  }
463
440
 
441
+ /**
442
+ * Detach a local consumer at the durable Remote boundary, without summarizing
443
+ * any Remote-managed history. Unlike a scheduled rollover trim, this needs
444
+ * no pending trim and remains retryable until Pi persists the result.
445
+ */
446
+ prepareDetachedCompaction(
447
+ 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);
452
+ // Pi's preparation/tokensBefore describes the durable branch, which includes
453
+ // Remote-managed history. Detached local accounting must use the same current
454
+ // window projection that the non-window provider sees instead.
455
+ return {
456
+ compaction: {
457
+ ...compaction,
458
+ tokensBefore: this.estimateLocalContextTokens(event.branchEntries),
459
+ },
460
+ };
461
+ }
462
+
463
+ private estimateLocalContextTokens(entries: readonly SessionEntry[]): number {
464
+ const messages = buildSessionContext(entries as never[]).messages as unknown as AgentMessage[];
465
+ return this.project(messages, "local").reduce((total, message) => total + estimateTokens(message as never), 0);
466
+ }
467
+
464
468
  recordCompaction(details: unknown): void {
465
469
  if (!isContextWindowCompactionDetails(details)) return;
466
470
  if (this.trimPendingWindowId !== undefined && details.windowId === this.trimPendingWindowId) {
@@ -20,12 +20,6 @@ import { routeContextNamespaceToolMessage } from "./context-management/namespace
20
20
  import { loadHistoryNotesThreadHint } from "./context-management/history-notes";
21
21
  import { CodexContextWindowManager } from "./context-management/window-manager";
22
22
  import { ManagedManualCompact } from "./context-management/manual-compact";
23
- import {
24
- BULK_CLIFF_RATIO,
25
- decideBulkCliffAction,
26
- decideBulkCloseOut,
27
- evaluateWindowBulk,
28
- } from "./context-management/window-bulk";
29
23
  import {
30
24
  registerContextManagementTools,
31
25
  type ContextToolRegistrationState,
@@ -752,14 +746,22 @@ async function handleSessionBeforeCompact(
752
746
  return { cancel: true };
753
747
  }
754
748
 
755
- // Remote Context management owns this eligible Codex session. It persists a
756
- // no-summary boundary while active; an unavailable runtime uses the configured
757
- // Remote V2 compactor instead of silently returning to Pi's active-model path.
749
+ // Capability changes do not retire the persisted Remote session. Resolve its
750
+ // branch boundary before deciding whether any compactor may see this input.
751
+ if (config.contextManagement === "remote") {
752
+ try {
753
+ dependencies.contextWindows.synchronize(ctx);
754
+ } catch {
755
+ notifyRemoteContextFailure(ctx, "malformed-window-state");
756
+ return { cancel: true };
757
+ }
758
+ }
759
+
760
+ // Active and inactive covered Codex consumers retain their existing routing.
758
761
  const contextModel = await resolveContextCapabilityModel(ctx);
759
762
  if (isCodexContextModel(contextModel, config)) {
760
763
  if (await remoteContextActive(ctx, config, contextModel)) {
761
764
  try {
762
- dependencies.contextWindows.synchronize(ctx);
763
765
  if (event.reason === "manual" && dependencies.manualCompact && !dependencies.manualCompact.isMaintenance) {
764
766
  await dependencies.manualCompact.request(event, ctx, resolved);
765
767
  return { cancel: true };
@@ -786,6 +788,18 @@ async function handleSessionBeforeCompact(
786
788
  );
787
789
  }
788
790
 
791
+ if (config.contextManagement === "remote") {
792
+ try {
793
+ const detached = dependencies.contextWindows.prepareDetachedCompaction(event);
794
+ // A local consumer cannot summarize history owned by a Remote window,
795
+ // even through a configured Luna producer or Pi's default compactor.
796
+ if (detached) return detached;
797
+ } catch {
798
+ notifyRemoteContextFailure(ctx, "malformed-window-state");
799
+ return { cancel: true };
800
+ }
801
+ }
802
+
789
803
  // Resolve producer protocol policy from this operation's document, not a later disk read.
790
804
  if (resolved.format === "v2" && config.remoteCompactModel) {
791
805
  const producer = parseModelSpec(config.remoteCompactModel);
@@ -941,81 +955,6 @@ async function handleSessionBeforeCompact(
941
955
  return undefined;
942
956
  }
943
957
 
944
- /**
945
- * Surface (and optionally close) the gap between the durable transcript and the remote window.
946
- *
947
- * Remote context management retires previous windows in the request projection, so a covered
948
- * model never notices that the branch still carries every one of them. Switching to a model
949
- * without remote context - or resuming such a session - hands that whole pile to the provider
950
- * at once, and the first manual `/compact` then has to summarize it. Runs once per agent turn,
951
- * never per request, and only once per window and model so the notice cannot become a nag.
952
- */
953
- async function handleWindowBulk(
954
- ctx: ExtensionContext,
955
- contextWindows: CodexContextWindowManager,
956
- loadConfig: typeof loadToolkitConfig,
957
- trigger: "model-switch" | "turn-end",
958
- resolved = contextOperation(loadConfig, ctx),
959
- beginMaintenance?: () => () => void,
960
- ): Promise<void> {
961
- requireContextPolicy(resolved, ctx);
962
- const compaction = resolved.config.compaction;
963
- if (!compaction.enabled || compaction.contextManagement !== "remote") return;
964
- if (!ctx.model) return;
965
-
966
- const modelKey = `${ctx.model.provider}/${ctx.model.id}`;
967
- const report = evaluateWindowBulk(ctx);
968
- if (!report) return;
969
- contextWindows.noteWindowBulk(report, modelKey);
970
- const cliff = contextWindows.takeBulkCliff(modelKey);
971
- if (!cliff) return;
972
- const action = decideBulkCloseOut({
973
- action: decideBulkCliffAction(cliff, compaction.leaveManagedMode),
974
- mode: ctx.mode,
975
- trigger,
976
- hasPendingTrim: contextWindows.hasPendingTrim(),
977
- });
978
- if (action === "ignore") return;
979
-
980
- writeDebugArtifact(
981
- "compaction-event",
982
- {
983
- event: `window-bulk.${action}`,
984
- trigger,
985
- policy: compaction.leaveManagedMode,
986
- model: modelKey,
987
- unmanagedTokens: cliff.unmanagedTokens,
988
- managedTokens: cliff.managedTokens,
989
- targetContextWindow: cliff.targetContextWindow,
990
- overBudget: cliff.overBudget,
991
- },
992
- compaction,
993
- ctx,
994
- );
995
-
996
- const gap = `${cliff.unmanagedTokens.toLocaleString()} tokens of retired windows are still in this session's transcript, while remote context management has been sending ${cliff.managedTokens.toLocaleString()}`;
997
- if (action === "warn") {
998
- notifyWarning(
999
- ctx,
1000
- `${gap} to ${modelKey}${cliff.overBudget ? `, past ${Math.round(BULK_CLIFF_RATIO * 100)}% of its window` : ""}. Checkpoint with notes and run /compact (or new_context) before continuing on a model without remote context, or set compaction.leaveManagedMode="compact" to do it automatically.`,
1001
- );
1002
- return;
1003
- }
1004
-
1005
- notifyWarning(ctx, `${gap} to ${modelKey}, past ${Math.round(BULK_CLIFF_RATIO * 100)}% of its window; compacting the retired windows first.`);
1006
- if (typeof ctx.compact !== "function") return;
1007
- const done = beginMaintenance?.();
1008
- try {
1009
- ctx.compact({
1010
- onComplete: () => done?.(),
1011
- onError: (error: Error) => {
1012
- done?.();
1013
- notifyWarning(ctx, `automatic boundary compaction failed: ${error.message}`);
1014
- },
1015
- });
1016
- } catch (error) { done?.(); throw error; }
1017
- }
1018
-
1019
958
  async function handleContextInternal(
1020
959
  event: ContextWithSystemEvent,
1021
960
  ctx: ExtensionContext,
@@ -1035,6 +974,7 @@ async function handleContextInternal(
1035
974
  : { messages: visibleMessages };
1036
975
  }
1037
976
 
977
+ let localMessages: AgentMessage[] | undefined;
1038
978
  if (config.contextManagement === "remote") {
1039
979
  const contextModel = await resolveContextCapabilityModel(ctx);
1040
980
  try {
@@ -1095,22 +1035,40 @@ async function handleContextInternal(
1095
1035
  ? undefined
1096
1036
  : { messages: visibleMessages };
1097
1037
  }
1038
+
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 };
1048
+ }
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
+ }
1098
1055
  }
1099
1056
 
1100
- // Inactive/ineligible Remote mode must not expose internal window markers to a
1101
- // gateway or another provider. The existing compaction replay path remains the
1102
- // owner for this safe fallback.
1103
- const visibleMessages = contextWindows.project(event.messages, "off");
1057
+ // With no Remote boundary, the ordinary checkpoint replay path remains the
1058
+ // owner. Inactive covered Codex consumers were handled separately above.
1059
+ 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 };
1104
1062
  const replayEvent = visibleMessages === event.messages ? event : { ...event, messages: visibleMessages };
1105
1063
  const resolution = await resolveNativeCompactionEnvironment(ctx, {
1106
1064
  enabled: config.enabled,
1107
1065
  responsesApis: config.responsesApis,
1108
1066
  codexGatewayModels: compactGatewayModels(resolved),
1109
1067
  });
1110
- if (!resolution.ok) return undefined;
1068
+ if (!resolution.ok) return visibleResult;
1111
1069
  const branchEntries = ctx.sessionManager.getBranch();
1112
1070
  const latest = resolveLatestNativeCompactionEntry(branchEntries, { baseUrl: resolution.runtime.baseUrl });
1113
- if (!latest.ok) return undefined;
1071
+ if (!latest.ok) return visibleResult;
1114
1072
  const result = removeNativeCompactionRetainedMessages({
1115
1073
  messages: replayEvent.messages,
1116
1074
  branchEntries,
@@ -1124,7 +1082,7 @@ async function handleContextInternal(
1124
1082
  ctx.abort();
1125
1083
  return undefined;
1126
1084
  }
1127
- return result.messages === replayEvent.messages ? undefined : { messages: result.messages };
1085
+ return result.messages === replayEvent.messages ? visibleResult : { messages: result.messages };
1128
1086
  }
1129
1087
 
1130
1088
  /**
@@ -1203,6 +1161,10 @@ async function handleBeforeProviderRequest(
1203
1161
  return undefined;
1204
1162
  }
1205
1163
 
1164
+ // Do not reintroduce a pre-window opaque checkpoint after local projection.
1165
+ // Request affinity/encrypted-output rewriting is reserved for active Remote.
1166
+ if (config.contextManagement === "remote" && contextWindows.currentIdentity()) return undefined;
1167
+
1206
1168
  const resolution = await resolveNativeCompactionEnvironment(
1207
1169
  ctx,
1208
1170
  {
@@ -1518,10 +1480,9 @@ export default function registerCompactionExtension(
1518
1480
  tools.reset();
1519
1481
  });
1520
1482
  pi.on("agent_settled", async (_event, ctx) => {
1521
- // Idle: no retry, compaction, or queued continuation is left running, so this is the
1522
- // only safe place to spend a model call on the user's behalf.
1523
- if (await manualCompact.settled(ctx)) return;
1524
- await handleWindowBulk(ctx, contextWindows, dependencies.loadConfig, "turn-end", undefined, () => manualCompact.beginMaintenance());
1483
+ // Only the owned manual handoff restores/continues here; Pi owns normal
1484
+ // compaction scheduling. Never compact retired windows on model changes.
1485
+ await manualCompact.settled(ctx);
1525
1486
  });
1526
1487
  pi.on("model_select", async (event, ctx) => {
1527
1488
  manualCompact.modelSelected(event.model, ctx);
@@ -1532,9 +1493,6 @@ export default function registerCompactionExtension(
1532
1493
  // syncTools() activates and initializes the window when the model is covered.
1533
1494
  const resolved = contextOperation(dependencies.loadConfig, ctx, event.model);
1534
1495
  await syncTools(ctx, event.model, { notifyWindowFailure: true }, resolved);
1535
- // The switch itself is the moment the durable transcript changes consumer, and the
1536
- // session is idle here, so the close-out must happen now rather than mid-turn.
1537
- if (!manualCompact.busy) await handleWindowBulk(ctx, contextWindows, dependencies.loadConfig, "model-switch", resolved, () => manualCompact.beginMaintenance());
1538
1496
  });
1539
1497
  pi.on("before_agent_start", async (_event, ctx) => {
1540
1498
  await syncTools(ctx, ctx.model, { notifyWindowFailure: true });
package/src/types.ts CHANGED
@@ -79,7 +79,10 @@ export type NativeFallbackConfig = {
79
79
  thinkingLevel: ThinkingLevel;
80
80
  };
81
81
 
82
- /** How the toolkit reacts when leaving remote context management would re-expand the transcript. */
82
+ /**
83
+ * Legacy compatibility setting. Remote Context history is no longer bulk-compacted when
84
+ * selecting a non-window model; local projection and a no-summary boundary own that handoff.
85
+ */
83
86
  export type LeaveManagedModePolicy = "warn" | "compact";
84
87
 
85
88
  export type DebugConfig = {
@@ -94,8 +97,8 @@ export type DebugConfig = {
94
97
  export type CompactionConfig = {
95
98
  enabled: boolean;
96
99
  /**
97
- * What to do when the session is about to hand a model the whole durable transcript
98
- * instead of its remote window: warn, or compact the retired windows away first.
100
+ * Legacy compatibility setting. Non-window consumers never receive retired Remote Context
101
+ * history, regardless of this value; retained config files continue to parse it.
99
102
  */
100
103
  leaveManagedMode: LeaveManagedModePolicy;
101
104
  /** Optional Codex Remote Context management. Disabled by default for compatibility. */