pi-openai-toolkit 0.14.11 → 0.14.13

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.
@@ -1,570 +1,693 @@
1
- import { randomUUID } from "node:crypto";
2
- import type { AgentMessage } from "@earendil-works/pi-agent-core";
3
- import type { ProviderHeaders } from "@earendil-works/pi-ai";
4
- import type {
5
- CompactionResult,
6
- ExtensionAPI,
7
- ExtensionContext,
8
- SessionBeforeCompactEvent,
9
- SessionEntry,
10
- } from "@earendil-works/pi-coding-agent";
11
- import { ContextWindowBudget, type ContextRemaining } from "./window-budget";
12
- import {
13
- rewriteEncryptedToolOutputs,
14
- rewriteWindowHeaders,
15
- rewriteWindowPayload,
16
- } from "./window-request";
17
- import {
18
- CONTEXT_WINDOW_COMPACTION_STRATEGY,
19
- CONTEXT_WINDOW_COMPACTION_SUMMARY,
20
- isContextWindowBoundary,
21
- renderContextWindowMessage,
22
- sendContextWindowMessage,
23
- } from "./messages";
24
- import {
25
- CODEX_CONTEXT_WINDOW_MESSAGE_TYPE,
26
- CONTEXT_MANAGEMENT_PROTOCOL,
27
- type CodexContextManagementMessageDetails,
28
- type ContextWindowCompactionDetails,
29
- type ContextWindowIdentity,
30
- isCodexContextManagementMessageDetails,
31
- isNonEmptyString,
32
- isRecord,
33
- isContextWindowCompactionDetails,
34
- type NotesCheckpointReceipt,
35
- } from "./types";
36
- import {
37
- encodeEncryptedOutputForContext,
38
- isSuccessfulHistoryNotesToolResult,
39
- loadHistoryNotesThreadHint,
40
- } from "./history-notes";
41
- import { rewriteContextNamespaceTools } from "./namespace-tools";
42
-
43
- interface StartContextWindowOptions {
44
- triggerTurn: boolean;
45
- signal?: AbortSignal;
46
- trimPreviousWindow: boolean;
47
- }
48
-
49
- type ThreadHintLoader = (
50
- ctx: ExtensionContext,
51
- signal?: AbortSignal,
52
- ) => Promise<string | undefined>;
53
-
54
- type WindowBoundaryEntry = Extract<SessionEntry, { type: "custom_message" }> & {
55
- details: CodexContextManagementMessageDetails;
56
- };
57
-
58
- /**
59
- * In-process duplicate guard for one scheduled rollover. It is anchored to the
60
- * session that scheduled it and to the exact target window id, so it can never
61
- * be satisfied by an older or foreign marker. It is not checkpoint evidence:
62
- * only a persisted notes result unlocks the gate.
63
- */
64
- type PendingRollover = {
65
- sessionId?: string;
66
- targetWindowId: string;
67
- };
68
-
69
- export class CodexContextWindowManager {
70
- private identity: ContextWindowIdentity | undefined;
71
- private sessionId: string | undefined;
72
- private restoredMarkerId: string | undefined;
73
- private readonly budget = new ContextWindowBudget();
74
- private pendingRollover: PendingRollover | undefined;
75
- private trimPendingWindowId: string | undefined;
76
- private readonly loadThreadHint: ThreadHintLoader;
77
-
78
- constructor(loadThreadHint?: ThreadHintLoader) {
79
- this.loadThreadHint = loadThreadHint ?? ((ctx, signal) => loadHistoryNotesThreadHint(ctx, signal));
80
- }
81
-
82
- reset(): void {
83
- this.resetWindowState();
84
- this.pendingRollover = undefined;
85
- }
86
-
87
- currentIdentity(): ContextWindowIdentity | undefined {
88
- return this.identity ? { ...this.identity } : undefined;
89
- }
90
-
91
- private resetWindowState(): void {
92
- this.identity = undefined;
93
- this.sessionId = undefined;
94
- this.restoredMarkerId = undefined;
95
- this.budget.reset();
96
- this.trimPendingWindowId = undefined;
97
- }
98
-
99
- restore(entries: readonly SessionEntry[], sessionId?: string): void {
100
- // A rebuild of derived state must not drop an outstanding rollover guard;
101
- // only a changed session or an observed target marker retires it below.
102
- this.resetWindowState();
103
- this.sessionId = sessionId;
104
- for (const entry of entries) {
105
- if (entry.type === "compaction") {
106
- this.recordCompaction(entry.details);
107
- continue;
108
- }
109
- if (entry.type !== "custom_message" || entry.customType !== CODEX_CONTEXT_WINDOW_MESSAGE_TYPE) continue;
110
- if (!couldBelongToSession(entry.details, sessionId)) continue;
111
- if (!isCodexContextManagementMessageDetails(entry.details)) {
112
- throw new Error("Malformed persisted Codex context-window message");
113
- }
114
- if (!matchesSession(entry.details.sessionId, sessionId)) continue;
115
- const details = entry.details.contextManagement;
116
- this.restoredMarkerId = entry.id;
117
- if (details.kind === "window") {
118
- this.identity = identityFromDetails(entry.details);
119
- this.trimPendingWindowId = details.trimPreviousWindow ? details.currentWindowId : undefined;
120
- }
121
- this.budget.restore(details.kind, details.currentWindowId);
122
- }
123
- this.retireSatisfiedOrStalePending(entries, sessionId);
124
- }
125
-
126
- /** Rebuild state when Pi navigates to a different session/branch. */
127
- synchronize(ctx: Pick<ExtensionContext, "sessionManager">): void {
128
- const entries = ctx.sessionManager.getBranch();
129
- const sessionId = ctx.sessionManager.getSessionId();
130
- this.retireSatisfiedOrStalePending(entries, sessionId);
131
- const latestMarkerId = findLatestContextMarkerId(entries, sessionId);
132
- if (this.sessionId !== sessionId || this.restoredMarkerId !== latestMarkerId) {
133
- this.restore(entries, sessionId);
134
- }
135
- }
136
-
137
- /**
138
- * Drop a pending rollover only for durable reasons: the target window marker
139
- * is now persisted in this session, or the session changed. A queued marker
140
- * visible in an in-memory message list is not durable evidence.
141
- */
142
- private retireSatisfiedOrStalePending(
143
- entries: readonly SessionEntry[],
144
- sessionId: string | undefined,
145
- ): void {
146
- const pending = this.pendingRollover;
147
- if (!pending) return;
148
- if (pending.sessionId !== undefined && pending.sessionId !== sessionId) {
149
- this.pendingRollover = undefined;
150
- return;
151
- }
152
- if (hasPersistedWindowBoundary(entries, sessionId, pending.targetWindowId)) {
153
- this.pendingRollover = undefined;
154
- }
155
- }
156
-
157
- ensureInitialized(pi: ExtensionAPI, ctx: ExtensionContext, active: boolean): void {
158
- if (!active) return;
159
- this.synchronize(ctx);
160
- if (this.identity) return;
161
- const windowId = randomUUID();
162
- this.sendWindowMessage(
163
- pi,
164
- ctx,
165
- { firstWindowId: windowId, currentWindowId: windowId, windowNumber: 0 },
166
- { triggerTurn: false, trimPreviousWindow: false },
167
- );
168
- }
169
-
170
- project(
171
- messages: readonly AgentMessage[],
172
- mode: "off" | "remote",
173
- ): AgentMessage[] {
174
- if (mode === "off") {
175
- return messages.filter(
176
- (message) => message.role !== "custom" || message.customType !== CODEX_CONTEXT_WINDOW_MESSAGE_TYPE,
177
- );
178
- }
179
- let boundaryIndex = -1;
180
- for (let index = 0; index < messages.length; index += 1) {
181
- const message = messages[index]!;
182
- if (
183
- message.role === "custom" &&
184
- message.customType === CODEX_CONTEXT_WINDOW_MESSAGE_TYPE
185
- ) {
186
- if (!couldBelongToSession(message.details, this.sessionId)) continue;
187
- if (!isCodexContextManagementMessageDetails(message.details)) {
188
- throw new Error("Malformed persisted Codex context-window message");
189
- }
190
- if (!matchesSession(message.details.sessionId, this.sessionId)) continue;
191
- }
192
- if (!isContextWindowBoundary(message)) continue;
193
- boundaryIndex = index;
194
- this.identity = identityFromDetails(message.details);
195
- }
196
- if (boundaryIndex < 0) {
197
- this.identity = undefined;
198
- this.restoredMarkerId = undefined;
199
- this.budget.reset();
200
- this.trimPendingWindowId = undefined;
201
- }
202
- // Projection observes the request view, never durable session state. A
203
- // queued rollover marker must not clear the duplicate guard here.
204
- const projected = boundaryIndex < 0 ? [...messages] : [...messages.slice(boundaryIndex)];
205
- return mode === "remote" ? projectEncryptedToolResults(projected) : projected;
206
- }
207
-
208
- async startNewWindow(
209
- pi: ExtensionAPI,
210
- ctx: ExtensionContext,
211
- options: StartContextWindowOptions,
212
- ): Promise<boolean> {
213
- this.synchronize(ctx);
214
- // A same-session rollover whose target marker is not persisted yet is
215
- // still in flight: report it instead of scheduling a second window.
216
- if (this.hasPendingRollover(ctx)) return false;
217
- if (options.signal?.aborted) throw new Error("Remote context rollover was aborted");
218
- const current = this.identity;
219
- const liveSessionId = ctx.sessionManager.getSessionId();
220
- const currentWindowId = randomUUID();
221
- const next: ContextWindowIdentity = current
222
- ? {
223
- firstWindowId: current.firstWindowId,
224
- currentWindowId,
225
- previousWindowId: current.currentWindowId,
226
- windowNumber: current.windowNumber + 1,
227
- }
228
- : { firstWindowId: currentWindowId, currentWindowId, windowNumber: 0 };
229
- this.pendingRollover = { sessionId: liveSessionId, targetWindowId: currentWindowId };
230
- try {
231
- const checkpoint = current
232
- ? findLatestNotesCheckpointSinceBoundary(ctx.sessionManager.getBranch(), liveSessionId)
233
- : undefined;
234
- let threadHint: string | undefined;
235
- if (current) {
236
- try {
237
- threadHint = await this.loadThreadHint(ctx, options.signal);
238
- } catch (error) {
239
- if (options.signal?.aborted) throw error;
240
- }
241
- }
242
- if (options.signal?.aborted) throw new Error("Remote context rollover was aborted");
243
- this.sendWindowMessage(pi, ctx, next, options, threadHint, checkpoint);
244
- return true;
245
- } catch (error) {
246
- this.pendingRollover = undefined;
247
- throw error;
248
- }
249
- }
250
-
251
- recordBudget(
252
- pi: ExtensionAPI,
253
- ctx: ExtensionContext,
254
- active: boolean,
255
- contextReminderThresholdPercent: number,
256
- contextTokens?: number,
257
- ): void {
258
- if (!active || !this.identity || contextReminderThresholdPercent <= 0) return;
259
- // Right after a rollover, Pi's usage anchor still reports the previous
260
- // window's last request until the new window's first request completes.
261
- // Budget decisions taken in that gap act on stale numbers: they burn the
262
- // once-per-window reminder on a false alarm seconds after a successful
263
- // rollover, leaving the window silent for the rest of its life.
264
- if (!hasAssistantUsageSinceWindowBoundary(ctx.sessionManager.getBranch(), this.sessionId)) return;
265
- const reminder = this.budget.record(ctx, this.identity, contextTokens, contextReminderThresholdPercent);
266
- if (!reminder) return;
267
- sendContextWindowMessage(
268
- pi,
269
- reminder.content,
270
- reminder.kind,
271
- this.identity,
272
- { triggerTurn: reminder.kind === "fallback", sessionId: ctx.sessionManager.getSessionId() },
273
- );
274
- }
275
-
276
- remaining(ctx: ExtensionContext, contextTokens?: number): ContextRemaining {
277
- return this.budget.remaining(ctx, this.identity, contextTokens);
278
- }
279
-
280
- /**
281
- * True when a notes checkpoint succeeded in the current window (after the
282
- * latest boundary). The scan always uses the live session id: a cached id can
283
- * belong to a session Pi already navigated away from, which would filter the
284
- * current window's marker and result out as foreign.
285
- */
286
- hasNotesCheckpointSinceBoundary(
287
- ctx: Pick<ExtensionContext, "sessionManager">,
288
- ): boolean {
289
- return findNotesCheckpointSinceBoundary(
290
- ctx.sessionManager.getBranch(),
291
- ctx.sessionManager.getSessionId(),
292
- );
293
- }
294
-
295
- /**
296
- * True while a rollover scheduled for the current session is awaiting its
297
- * persisted target marker. Call `synchronize()` first so a marker that has
298
- * already been persisted or a session switch retires the guard.
299
- */
300
- hasPendingRollover(ctx: Pick<ExtensionContext, "sessionManager">): boolean {
301
- const pending = this.pendingRollover;
302
- if (!pending) return false;
303
- return pending.sessionId === undefined || pending.sessionId === ctx.sessionManager.getSessionId();
304
- }
305
-
306
- prepareCompaction(
307
- event: SessionBeforeCompactEvent,
308
- ): { cancel: true } | { compaction: CompactionResult<ContextWindowCompactionDetails> } {
309
- // Only the compaction that consumes a scheduled rollover may write a
310
- // boundary. Every other path — threshold, manual /compact, overflow —
311
- // stays cancelled: a boundary written without a pending trim does not
312
- // shrink anything, becomes Pi's latest-compaction anchor, and blinds
313
- // getContextUsage (null tokens) until the next assistant usage lands —
314
- // which is exactly how the exhausted-window fallback can go silent.
315
- const boundary = findLatestWindowBoundaryEntry(event.branchEntries, this.sessionId);
316
- if (!boundary || boundary.details.contextManagement.currentWindowId !== this.trimPendingWindowId) {
317
- return { cancel: true };
318
- }
319
- const compaction = this.createCompaction(event);
320
- // Consume the trim synchronously. Pi does not re-fire session_compact
321
- // for hook-written entries, so relying on that event leaks the pending
322
- // id and lets every later compaction pass this gate again, writing an
323
- // endless run of no-op boundaries for the same window.
324
- this.trimPendingWindowId = undefined;
325
- return { compaction };
326
- }
327
-
328
- recordCompaction(details: unknown): void {
329
- if (!isContextWindowCompactionDetails(details)) return;
330
- if (details.windowId === this.trimPendingWindowId) this.trimPendingWindowId = undefined;
331
- }
332
-
333
- createCompaction(event: SessionBeforeCompactEvent): CompactionResult<ContextWindowCompactionDetails> {
334
- const boundary = findLatestWindowBoundaryEntry(event.branchEntries, this.sessionId);
335
- return {
336
- summary: CONTEXT_WINDOW_COMPACTION_SUMMARY,
337
- firstKeptEntryId: boundary?.id ?? event.preparation.firstKeptEntryId,
338
- tokensBefore: event.preparation.tokensBefore,
339
- details: {
340
- protocol: CONTEXT_MANAGEMENT_PROTOCOL,
341
- strategy: CONTEXT_WINDOW_COMPACTION_STRATEGY,
342
- ...(this.identity ? { windowId: this.identity.currentWindowId } : {}),
343
- },
344
- };
345
- }
346
-
347
- rewritePayload(payload: unknown, ctx: ExtensionContext): unknown {
348
- const withMetadata = rewriteWindowPayload(payload, ctx, this.identity);
349
- return rewriteContextNamespaceTools(withMetadata, { encrypted: true });
350
- }
351
-
352
- rewriteHeaders(headers: ProviderHeaders, ctx: ExtensionContext): void {
353
- rewriteWindowHeaders(headers, ctx, this.identity);
354
- }
355
-
356
- private sendWindowMessage(
357
- pi: ExtensionAPI,
358
- ctx: ExtensionContext,
359
- identity: ContextWindowIdentity,
360
- options: StartContextWindowOptions,
361
- threadHint?: string,
362
- checkpoint?: NotesCheckpointReceipt,
363
- ): void {
364
- sendContextWindowMessage(
365
- pi,
366
- renderContextWindowMessage(identity, threadHint, checkpoint),
367
- "window",
368
- identity,
369
- { triggerTurn: options.triggerTurn, sessionId: ctx.sessionManager.getSessionId() },
370
- options.trimPreviousWindow,
371
- );
372
- this.identity = identity;
373
- this.sessionId = ctx.sessionManager.getSessionId();
374
- this.restoredMarkerId = undefined;
375
- this.trimPendingWindowId = options.trimPreviousWindow ? identity.currentWindowId : undefined;
376
- // sendMessage() acceptance is not persistence. The pending rollover stays
377
- // armed until synchronize() observes this target window in the persisted
378
- // branch (or the session changes).
379
- }
380
- }
381
-
382
- function identityFromDetails(details: CodexContextManagementMessageDetails): ContextWindowIdentity {
383
- const context = details.contextManagement;
384
- return {
385
- firstWindowId: context.firstWindowId,
386
- currentWindowId: context.currentWindowId,
387
- ...(context.previousWindowId ? { previousWindowId: context.previousWindowId } : {}),
388
- windowNumber: context.windowNumber,
389
- };
390
- }
391
-
392
- const NOTES_CHECKPOINT_ACTIONS: ReadonlySet<string> = new Set(["append_to_file", "write_file"]);
393
-
394
- /**
395
- * Whether the branch contains a successful assistant usage after the latest
396
- * window boundary. Before that first usage lands, any context measurement is
397
- * still anchored to the previous window's last request.
398
- */
399
- export function hasAssistantUsageSinceWindowBoundary(
400
- entries: readonly SessionEntry[],
401
- sessionId?: string,
402
- ): boolean {
403
- const boundaryIndex = findLatestWindowBoundaryIndex(entries, sessionId);
404
- if (boundaryIndex < 0) return false;
405
- for (let index = boundaryIndex + 1; index < entries.length; index += 1) {
406
- const entry = entries[index]!;
407
- if (entry.type !== "message") continue;
408
- const message = entry.message;
409
- if (message.role !== "assistant") continue;
410
- const candidate = message as unknown as {
411
- stopReason?: string;
412
- usage?: { totalTokens?: number; input?: number; output?: number; cacheRead?: number; cacheWrite?: number };
413
- };
414
- if (candidate.stopReason === "aborted" || candidate.stopReason === "error") continue;
415
- const usage = candidate.usage;
416
- if (!usage) continue;
417
- const tokens = usage.totalTokens ?? (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
418
- if (tokens > 0) return true;
419
- }
420
- return false;
421
- }
422
-
423
- /**
424
- * Return the most recent successful notes write after the latest window
425
- * boundary. The receipt is derived from persisted session entries so it
426
- * survives restarts and can be used for an explicit rollover handoff.
427
- */
428
- export function findLatestNotesCheckpointSinceBoundary(
429
- entries: readonly SessionEntry[],
430
- sessionId?: string,
431
- ): NotesCheckpointReceipt | undefined {
432
- const boundaryIndex = findLatestWindowBoundaryIndex(entries, sessionId);
433
- if (boundaryIndex < 0) return undefined;
434
- const checkpointCalls = new Map<string, NotesCheckpointReceipt>();
435
- let latest: NotesCheckpointReceipt | undefined;
436
- for (let index = boundaryIndex + 1; index < entries.length; index += 1) {
437
- const entry = entries[index]!;
438
- if (entry.type !== "message") continue;
439
- const message = entry.message;
440
- if (message.role === "assistant") {
441
- const parts = Array.isArray(message.content) ? message.content : [];
442
- for (const part of parts) {
443
- if (!isRecord(part) || part.type !== "toolCall" || part.name !== "notes") continue;
444
- const args = isRecord(part.arguments) ? part.arguments : undefined;
445
- const action = typeof args?.action === "string" ? args.action : "";
446
- const path = isNonEmptyString(args?.path) ? args.path : undefined;
447
- if (isNonEmptyString(part.id) && path !== undefined && NOTES_CHECKPOINT_ACTIONS.has(action)) {
448
- checkpointCalls.set(part.id, { path, toolCallId: part.id });
449
- }
450
- }
451
- continue;
452
- }
453
- if (message.role !== "toolResult" || message.toolName !== "notes") continue;
454
- const receipt = checkpointCalls.get(message.toolCallId);
455
- if (!receipt) continue;
456
- checkpointCalls.delete(message.toolCallId);
457
- if (message.isError !== false || !isSuccessfulHistoryNotesToolResult(message.details)) continue;
458
- latest = receipt;
459
- }
460
- return latest;
461
- }
462
-
463
- /**
464
- * Whether the branch contains a successful notes append/write result after the
465
- * latest window boundary. Reads and failed writes never count as checkpoints.
466
- */
467
- export function findNotesCheckpointSinceBoundary(
468
- entries: readonly SessionEntry[],
469
- sessionId?: string,
470
- ): boolean {
471
- return findLatestNotesCheckpointSinceBoundary(entries, sessionId) !== undefined;
472
- }
473
-
474
- function findLatestWindowBoundaryIndex(
475
- entries: readonly SessionEntry[],
476
- sessionId?: string,
477
- ): number {
478
- for (let index = entries.length - 1; index >= 0; index -= 1) {
479
- const entry = entries[index]!;
480
- if (entry.type !== "custom_message" || entry.customType !== CODEX_CONTEXT_WINDOW_MESSAGE_TYPE) continue;
481
- if (!couldBelongToSession(entry.details, sessionId)) continue;
482
- if (!isCodexContextManagementMessageDetails(entry.details)) {
483
- throw new Error("Malformed persisted Codex context-window message");
484
- }
485
- if (!matchesSession(entry.details.sessionId, sessionId)) continue;
486
- if (entry.details.contextManagement.kind === "window") return index;
487
- }
488
- return -1;
489
- }
490
-
491
- export function findLatestWindowBoundaryEntry(
492
- entries: readonly SessionEntry[],
493
- sessionId?: string,
494
- ): WindowBoundaryEntry | undefined {
495
- for (let index = entries.length - 1; index >= 0; index -= 1) {
496
- const entry = entries[index]!;
497
- if (entry.type !== "custom_message" || entry.customType !== CODEX_CONTEXT_WINDOW_MESSAGE_TYPE) continue;
498
- if (!couldBelongToSession(entry.details, sessionId)) continue;
499
- if (!isCodexContextManagementMessageDetails(entry.details)) {
500
- throw new Error("Malformed persisted Codex context-window message");
501
- }
502
- if (!matchesSession(entry.details.sessionId, sessionId)) continue;
503
- if (entry.details.contextManagement.kind === "window") return entry as WindowBoundaryEntry;
504
- }
505
- return undefined;
506
- }
507
-
508
- /**
509
- * Whether this exact window boundary is already persisted for the session.
510
- * Only durable entries count; a queued/visible marker does not.
511
- */
512
- function hasPersistedWindowBoundary(
513
- entries: readonly SessionEntry[],
514
- sessionId: string | undefined,
515
- windowId: string,
516
- ): boolean {
517
- for (const entry of entries) {
518
- if (entry.type !== "custom_message" || entry.customType !== CODEX_CONTEXT_WINDOW_MESSAGE_TYPE) continue;
519
- if (!couldBelongToSession(entry.details, sessionId)) continue;
520
- if (!isCodexContextManagementMessageDetails(entry.details)) continue;
521
- if (!matchesSession(entry.details.sessionId, sessionId)) continue;
522
- const details = entry.details.contextManagement;
523
- if (details.kind === "window" && details.currentWindowId === windowId) return true;
524
- }
525
- return false;
526
- }
527
-
528
- function findLatestContextMarkerId(
529
- entries: readonly SessionEntry[],
530
- sessionId?: string,
531
- ): string | undefined {
532
- for (let index = entries.length - 1; index >= 0; index -= 1) {
533
- const entry = entries[index]!;
534
- if (entry.type !== "custom_message" || entry.customType !== CODEX_CONTEXT_WINDOW_MESSAGE_TYPE) continue;
535
- if (!couldBelongToSession(entry.details, sessionId)) continue;
536
- if (!isCodexContextManagementMessageDetails(entry.details)) {
537
- throw new Error("Malformed persisted Codex context-window message");
538
- }
539
- if (matchesSession(entry.details.sessionId, sessionId)) return entry.id;
540
- }
541
- return undefined;
542
- }
543
-
544
- function couldBelongToSession(details: unknown, sessionId: string | undefined): boolean {
545
- if (sessionId === undefined || !isRecord(details)) return true;
546
- const markerSessionId = details.sessionId;
547
- return !isNonEmptyString(markerSessionId) || markerSessionId === sessionId;
548
- }
549
-
550
- function matchesSession(markerSessionId: string | undefined, sessionId: string | undefined): boolean {
551
- return sessionId === undefined || markerSessionId === sessionId;
552
- }
553
-
554
- function projectEncryptedToolResults(messages: readonly AgentMessage[]): AgentMessage[] {
555
- let changed = false;
556
- const projected = messages.map((message) => {
557
- if (message.role !== "toolResult" || !isRecord(message.details)) return message;
558
- const historyNotes = message.details.codexHistoryNotes;
559
- if (!isRecord(historyNotes) || typeof historyNotes.encrypted_output !== "string") return message;
560
- changed = true;
561
- return {
562
- ...message,
563
- content: [
564
- { type: "text" as const, text: encodeEncryptedOutputForContext(historyNotes.encrypted_output) },
565
- ...message.content.filter((item) => item.type === "image"),
566
- ],
567
- };
568
- });
569
- return changed ? projected : [...messages];
570
- }
1
+ import { randomUUID } from "node:crypto";
2
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
3
+ import type { ProviderHeaders } from "@earendil-works/pi-ai";
4
+ import type {
5
+ CompactionResult,
6
+ ExtensionAPI,
7
+ ExtensionContext,
8
+ SessionBeforeCompactEvent,
9
+ SessionEntry,
10
+ } from "@earendil-works/pi-coding-agent";
11
+ import { ContextWindowBudget, type ContextRemaining } from "./window-budget";
12
+ import type { WindowBulkReport } from "./window-bulk";
13
+ import {
14
+ rewriteEncryptedToolOutputs,
15
+ rewriteWindowHeaders,
16
+ rewriteWindowPayload,
17
+ } from "./window-request";
18
+ import {
19
+ CONTEXT_WINDOW_COMPACTION_STRATEGY,
20
+ CONTEXT_WINDOW_COMPACTION_SUMMARY,
21
+ isContextWindowBoundary,
22
+ preserveSystemHead,
23
+ renderContextWindowMessage,
24
+ sendContextWindowMessage,
25
+ } from "./messages";
26
+ import {
27
+ CODEX_CONTEXT_WINDOW_MESSAGE_TYPE,
28
+ CONTEXT_MANAGEMENT_PROTOCOL,
29
+ type CodexContextManagementMessageDetails,
30
+ type ContextWindowCompactionDetails,
31
+ type ContextWindowIdentity,
32
+ isCodexContextManagementMessageDetails,
33
+ isNonEmptyString,
34
+ isRecord,
35
+ isContextWindowCompactionDetails,
36
+ type NotesCheckpointReceipt,
37
+ } from "./types";
38
+ import {
39
+ encodeEncryptedOutputForContext,
40
+ isSuccessfulHistoryNotesToolResult,
41
+ loadHistoryNotesThreadHint,
42
+ } from "./history-notes";
43
+ import { rewriteContextNamespaceTools } from "./namespace-tools";
44
+
45
+ interface StartContextWindowOptions {
46
+ triggerTurn: boolean;
47
+ signal?: AbortSignal;
48
+ trimPreviousWindow: boolean;
49
+ }
50
+
51
+ type ThreadHintLoader = (
52
+ ctx: ExtensionContext,
53
+ signal?: AbortSignal,
54
+ ) => Promise<string | undefined>;
55
+
56
+ type WindowBoundaryEntry = Extract<SessionEntry, { type: "custom_message" }> & {
57
+ details: CodexContextManagementMessageDetails;
58
+ };
59
+
60
+ /**
61
+ * In-process duplicate guard for one scheduled rollover. It is anchored to the
62
+ * session that scheduled it and to the exact target window id, so it can never
63
+ * be satisfied by an older or foreign marker. It is not checkpoint evidence:
64
+ * only a persisted notes result unlocks the gate.
65
+ */
66
+ type PendingRollover = {
67
+ sessionId?: string;
68
+ targetWindowId: string;
69
+ };
70
+
71
+ export class CodexContextWindowManager {
72
+ private identity: ContextWindowIdentity | undefined;
73
+ private sessionId: string | undefined;
74
+ private restoredMarkerId: string | undefined;
75
+ private readonly budget = new ContextWindowBudget();
76
+ private pendingRollover: PendingRollover | undefined;
77
+ private trimPendingWindowId: string | undefined;
78
+ private readonly projectionDiagnostics = new Set<string>();
79
+ private lastKnownSystemHead: AgentMessage | undefined;
80
+ private bulkReport: WindowBulkReport | undefined;
81
+ private bulkSurfacedKey: string | undefined;
82
+ private readonly loadThreadHint: ThreadHintLoader;
83
+
84
+ constructor(loadThreadHint?: ThreadHintLoader) {
85
+ this.loadThreadHint = loadThreadHint ?? ((ctx, signal) => loadHistoryNotesThreadHint(ctx, signal));
86
+ }
87
+
88
+ reset(): void {
89
+ this.resetWindowState();
90
+ this.pendingRollover = undefined;
91
+ this.projectionDiagnostics.clear();
92
+ this.lastKnownSystemHead = undefined;
93
+ this.bulkReport = undefined;
94
+ this.bulkSurfacedKey = undefined;
95
+ }
96
+
97
+ currentIdentity(): ContextWindowIdentity | undefined {
98
+ return this.identity ? { ...this.identity } : undefined;
99
+ }
100
+ /**
101
+ * Whether a rollover trim is queued for the next eligible compaction. A turn-end bulk
102
+ * close-out is only worth a model call while that trim is still outstanding.
103
+ */
104
+ hasPendingTrim(): boolean {
105
+ return this.trimPendingWindowId !== undefined;
106
+ }
107
+
108
+ /** Remember the latest measurement for this window; see `takeBulkCliff`. */
109
+ noteWindowBulk(report: WindowBulkReport | undefined, modelKey: string): void {
110
+ if (report) this.bulkReport = report;
111
+ else if (this.bulkKey(modelKey) === undefined) this.bulkReport = undefined;
112
+ }
113
+
114
+ /**
115
+ * Drain the pending bulk-cliff notice for this window and model. The decision what to
116
+ * do about it (warn, or compact before the next request) belongs to the runtime; this
117
+ * only guarantees the same window and model is never surfaced twice.
118
+ */
119
+ takeBulkCliff(modelKey: string): WindowBulkReport | undefined {
120
+ const report = this.bulkReport;
121
+ if (!report || !report.expanded) return undefined;
122
+ const key = this.bulkKey(modelKey);
123
+ if (key === undefined || this.bulkSurfacedKey === key) return undefined;
124
+ this.bulkSurfacedKey = key;
125
+ return report;
126
+ }
127
+
128
+ private bulkKey(modelKey: string): string | undefined {
129
+ // The report's own window id is authoritative: this notice exists precisely for the
130
+ // models that never open the window lifecycle, where `identity` stays undefined.
131
+ const windowId = this.bulkReport?.windowId ?? this.identity?.currentWindowId;
132
+ return windowId ? `${windowId}|${modelKey}` : undefined;
133
+ }
134
+
135
+
136
+
137
+ private resetWindowState(): void {
138
+ this.identity = undefined;
139
+ this.sessionId = undefined;
140
+ this.restoredMarkerId = undefined;
141
+ this.budget.reset();
142
+ this.trimPendingWindowId = undefined;
143
+ }
144
+
145
+ restore(entries: readonly SessionEntry[], sessionId?: string): void {
146
+ // A rebuild of derived state must not drop an outstanding rollover guard;
147
+ // only a changed session or an observed target marker retires it below.
148
+ this.resetWindowState();
149
+ this.sessionId = sessionId;
150
+ for (const entry of entries) {
151
+ if (entry.type === "compaction") {
152
+ this.recordCompaction(entry.details);
153
+ continue;
154
+ }
155
+ if (entry.type !== "custom_message" || entry.customType !== CODEX_CONTEXT_WINDOW_MESSAGE_TYPE) continue;
156
+ if (!couldBelongToSession(entry.details, sessionId)) continue;
157
+ if (!isCodexContextManagementMessageDetails(entry.details)) {
158
+ throw new Error("Malformed persisted Codex context-window message");
159
+ }
160
+ if (!matchesSession(entry.details.sessionId, sessionId)) continue;
161
+ const details = entry.details.contextManagement;
162
+ this.restoredMarkerId = entry.id;
163
+ if (details.kind === "window") {
164
+ this.identity = identityFromDetails(entry.details);
165
+ this.trimPendingWindowId = details.trimPreviousWindow ? details.currentWindowId : undefined;
166
+ }
167
+ this.budget.restore(details.kind, details.currentWindowId);
168
+ }
169
+ this.retireSatisfiedOrStalePending(entries, sessionId);
170
+ }
171
+
172
+ /** Rebuild state when Pi navigates to a different session/branch. */
173
+ synchronize(ctx: Pick<ExtensionContext, "sessionManager">): void {
174
+ const entries = ctx.sessionManager.getBranch();
175
+ const sessionId = ctx.sessionManager.getSessionId();
176
+ this.retireSatisfiedOrStalePending(entries, sessionId);
177
+ const latestMarkerId = findLatestContextMarkerId(entries, sessionId);
178
+ if (this.sessionId !== sessionId || this.restoredMarkerId !== latestMarkerId) {
179
+ this.restore(entries, sessionId);
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Drop a pending rollover only for durable reasons: the target window marker
185
+ * is now persisted in this session, or the session changed. A queued marker
186
+ * visible in an in-memory message list is not durable evidence.
187
+ */
188
+ private retireSatisfiedOrStalePending(
189
+ entries: readonly SessionEntry[],
190
+ sessionId: string | undefined,
191
+ ): void {
192
+ const pending = this.pendingRollover;
193
+ if (!pending) return;
194
+ if (pending.sessionId !== undefined && pending.sessionId !== sessionId) {
195
+ this.pendingRollover = undefined;
196
+ return;
197
+ }
198
+ if (hasPersistedWindowBoundary(entries, sessionId, pending.targetWindowId)) {
199
+ this.pendingRollover = undefined;
200
+ }
201
+ }
202
+
203
+ ensureInitialized(pi: ExtensionAPI, ctx: ExtensionContext, active: boolean): void {
204
+ if (!active) return;
205
+ this.synchronize(ctx);
206
+ if (this.identity) return;
207
+ const windowId = randomUUID();
208
+ this.sendWindowMessage(
209
+ pi,
210
+ ctx,
211
+ { firstWindowId: windowId, currentWindowId: windowId, windowNumber: 0 },
212
+ { triggerTurn: false, trimPreviousWindow: false },
213
+ );
214
+ }
215
+
216
+ project(
217
+ messages: readonly AgentMessage[],
218
+ mode: "off" | "remote",
219
+ ): AgentMessage[] {
220
+ if (mode === "off") {
221
+ return messages.filter(
222
+ (message) => message.role !== "custom" || message.customType !== CODEX_CONTEXT_WINDOW_MESSAGE_TYPE,
223
+ );
224
+ }
225
+ let boundaryIndex = -1;
226
+ for (let index = 0; index < messages.length; index += 1) {
227
+ const message = messages[index]!;
228
+ if (
229
+ message.role === "custom" &&
230
+ message.customType === CODEX_CONTEXT_WINDOW_MESSAGE_TYPE
231
+ ) {
232
+ if (!couldBelongToSession(message.details, this.sessionId)) continue;
233
+ if (!isCodexContextManagementMessageDetails(message.details)) {
234
+ throw new Error("Malformed persisted Codex context-window message");
235
+ }
236
+ if (!matchesSession(message.details.sessionId, this.sessionId)) continue;
237
+ }
238
+ if (!isContextWindowBoundary(message)) continue;
239
+ boundaryIndex = index;
240
+ this.identity = identityFromDetails(message.details);
241
+ }
242
+ if (boundaryIndex < 0) {
243
+ this.identity = undefined;
244
+ this.restoredMarkerId = undefined;
245
+ this.budget.reset();
246
+ this.trimPendingWindowId = undefined;
247
+ }
248
+ // Projection observes the request view, never durable session state. A
249
+ // queued rollover marker must not clear the duplicate guard here.
250
+ const trimmed = boundaryIndex < 0 ? [...messages] : this.trimToWindow(messages, boundaryIndex);
251
+ return mode === "remote" ? projectEncryptedToolResults(trimmed) : trimmed;
252
+ }
253
+
254
+ /**
255
+ * Trim everything before the window boundary while keeping Pi's prompt/tool head.
256
+ *
257
+ * When the head cannot be preserved the trim is refused and the full message list is
258
+ * sent, because a window without tools silently degrades into a chat the model can
259
+ * only narrate. Each refusal is reported once per window and reason.
260
+ */
261
+ private trimToWindow(messages: readonly AgentMessage[], boundaryIndex: number): AgentMessage[] {
262
+ const repair = preserveSystemHead({ messages, boundaryIndex, fallbackHead: this.lastKnownSystemHead });
263
+ if (repair.head) this.lastKnownSystemHead = repair.head;
264
+ if (repair.shrinkRejected) {
265
+ this.recordProjectionDiagnostic(repair.lostToolNames);
266
+ }
267
+ return [...repair.messages];
268
+ }
269
+
270
+ private recordProjectionDiagnostic(lostToolNames: readonly string[]): void {
271
+ const key = `${this.identity?.currentWindowId ?? "unknown"}|${[...lostToolNames].sort().join(",")}`;
272
+ this.projectionDiagnostics.add(key);
273
+ }
274
+
275
+ /**
276
+ * Drain the pending `tool-loadout-shrink` reports. Returns one entry per window and
277
+ * lost-tool set, so a caller can surface the incident without flooding the UI.
278
+ */
279
+ takeProjectionDiagnostics(): string[] {
280
+ if (this.projectionDiagnostics.size === 0) return [];
281
+ const pending = [...this.projectionDiagnostics];
282
+ this.projectionDiagnostics.clear();
283
+ return pending;
284
+ }
285
+
286
+ async startNewWindow(
287
+ pi: ExtensionAPI,
288
+ ctx: ExtensionContext,
289
+ options: StartContextWindowOptions,
290
+ ): Promise<boolean> {
291
+ this.synchronize(ctx);
292
+ // A same-session rollover whose target marker is not persisted yet is
293
+ // still in flight: report it instead of scheduling a second window.
294
+ if (this.hasPendingRollover(ctx)) return false;
295
+ if (options.signal?.aborted) throw new Error("Remote context rollover was aborted");
296
+ const current = this.identity;
297
+ const liveSessionId = ctx.sessionManager.getSessionId();
298
+ const currentWindowId = randomUUID();
299
+ const next: ContextWindowIdentity = current
300
+ ? {
301
+ firstWindowId: current.firstWindowId,
302
+ currentWindowId,
303
+ previousWindowId: current.currentWindowId,
304
+ windowNumber: current.windowNumber + 1,
305
+ }
306
+ : { firstWindowId: currentWindowId, currentWindowId, windowNumber: 0 };
307
+ this.pendingRollover = { sessionId: liveSessionId, targetWindowId: currentWindowId };
308
+ try {
309
+ const checkpoint = current
310
+ ? findLatestNotesCheckpointSinceBoundary(ctx.sessionManager.getBranch(), liveSessionId)
311
+ : undefined;
312
+ let threadHint: string | undefined;
313
+ if (current) {
314
+ try {
315
+ threadHint = await this.loadThreadHint(ctx, options.signal);
316
+ } catch (error) {
317
+ if (options.signal?.aborted) throw error;
318
+ }
319
+ }
320
+ if (options.signal?.aborted) throw new Error("Remote context rollover was aborted");
321
+ this.sendWindowMessage(pi, ctx, next, options, threadHint, checkpoint);
322
+ return true;
323
+ } catch (error) {
324
+ this.pendingRollover = undefined;
325
+ throw error;
326
+ }
327
+ }
328
+
329
+ recordBudget(
330
+ pi: ExtensionAPI,
331
+ ctx: ExtensionContext,
332
+ active: boolean,
333
+ contextReminderThresholdPercent: number,
334
+ contextTokens?: number,
335
+ ): void {
336
+ if (!active || !this.identity || contextReminderThresholdPercent <= 0) return;
337
+ // Right after a rollover, Pi's usage anchor still reports the previous
338
+ // window's last request until the new window's first request completes.
339
+ // Budget decisions taken in that gap act on stale numbers: they burn the
340
+ // once-per-window reminder on a false alarm seconds after a successful
341
+ // rollover, leaving the window silent for the rest of its life.
342
+ if (!hasAssistantUsageSinceWindowBoundary(ctx.sessionManager.getBranch(), this.sessionId)) return;
343
+ const reminder = this.budget.record(ctx, this.identity, contextTokens, contextReminderThresholdPercent);
344
+ if (!reminder) return;
345
+ sendContextWindowMessage(
346
+ pi,
347
+ reminder.content,
348
+ reminder.kind,
349
+ this.identity,
350
+ { triggerTurn: reminder.kind === "fallback", sessionId: ctx.sessionManager.getSessionId() },
351
+ );
352
+ }
353
+
354
+ remaining(ctx: ExtensionContext, contextTokens?: number): ContextRemaining {
355
+ return this.budget.remaining(ctx, this.identity, contextTokens);
356
+ }
357
+
358
+ /**
359
+ * True when a notes checkpoint succeeded in the current window (after the
360
+ * latest boundary). The scan always uses the live session id: a cached id can
361
+ * belong to a session Pi already navigated away from, which would filter the
362
+ * current window's marker and result out as foreign.
363
+ */
364
+ hasNotesCheckpointSinceBoundary(
365
+ ctx: Pick<ExtensionContext, "sessionManager">,
366
+ ): boolean {
367
+ return findNotesCheckpointSinceBoundary(
368
+ ctx.sessionManager.getBranch(),
369
+ ctx.sessionManager.getSessionId(),
370
+ );
371
+ }
372
+
373
+ /**
374
+ * True while a rollover scheduled for the current session is awaiting its
375
+ * persisted target marker. Call `synchronize()` first so a marker that has
376
+ * already been persisted or a session switch retires the guard.
377
+ */
378
+ hasPendingRollover(ctx: Pick<ExtensionContext, "sessionManager">): boolean {
379
+ const pending = this.pendingRollover;
380
+ if (!pending) return false;
381
+ return pending.sessionId === undefined || pending.sessionId === ctx.sessionManager.getSessionId();
382
+ }
383
+
384
+ /**
385
+ * Whether a rollover may be scheduled from the current window.
386
+ *
387
+ * A window that was entered by a context switch has to do real work before it switches
388
+ * again: without this guard a model can checkpoint and roll over on its very first turn,
389
+ * which is how long sessions start looping between notes and new_context.
390
+ */
391
+ canRolloverFromCurrentWindow(ctx: ExtensionContext): boolean {
392
+ if (!this.identity || this.identity.windowNumber === 0) return true;
393
+ // An exhausted window must always be able to escape: the budget fallback tells the
394
+ // model to checkpoint and switch, and refusing that would strand the session. An
395
+ // unmeasurable window keeps the guard, since nothing there is pushing for a switch.
396
+ const { remainingTokens } = this.remaining(ctx);
397
+ if (remainingTokens !== undefined && remainingTokens <= 0) return true;
398
+ return hasSubstantiveToolResultSinceBoundary(ctx.sessionManager.getBranch(), this.sessionId);
399
+ }
400
+
401
+ prepareCompaction(
402
+ event: SessionBeforeCompactEvent,
403
+ ): { cancel: true } | { compaction: CompactionResult<ContextWindowCompactionDetails> } {
404
+ // Only the compaction that consumes a scheduled rollover may write a
405
+ // boundary. Every other path — threshold, manual /compact, overflow —
406
+ // stays cancelled: a boundary written without a pending trim does not
407
+ // shrink anything, becomes Pi's latest-compaction anchor, and blinds
408
+ // getContextUsage (null tokens) until the next assistant usage lands —
409
+ // which is exactly how the exhausted-window fallback can go silent.
410
+ const boundary = findLatestWindowBoundaryEntry(event.branchEntries, this.sessionId);
411
+ if (!boundary || boundary.details.contextManagement.currentWindowId !== this.trimPendingWindowId) {
412
+ return { cancel: true };
413
+ }
414
+ const compaction = this.createCompaction(event);
415
+ // Consume the trim synchronously. Pi does not re-fire session_compact
416
+ // for hook-written entries, so relying on that event leaks the pending
417
+ // id and lets every later compaction pass this gate again, writing an
418
+ // endless run of no-op boundaries for the same window.
419
+ this.trimPendingWindowId = undefined;
420
+ return { compaction };
421
+ }
422
+
423
+ recordCompaction(details: unknown): void {
424
+ if (!isContextWindowCompactionDetails(details)) return;
425
+ if (details.windowId === this.trimPendingWindowId) this.trimPendingWindowId = undefined;
426
+ }
427
+
428
+ createCompaction(event: SessionBeforeCompactEvent): CompactionResult<ContextWindowCompactionDetails> {
429
+ const boundary = findLatestWindowBoundaryEntry(event.branchEntries, this.sessionId);
430
+ return {
431
+ summary: CONTEXT_WINDOW_COMPACTION_SUMMARY,
432
+ firstKeptEntryId: boundary?.id ?? event.preparation.firstKeptEntryId,
433
+ tokensBefore: event.preparation.tokensBefore,
434
+ details: {
435
+ protocol: CONTEXT_MANAGEMENT_PROTOCOL,
436
+ strategy: CONTEXT_WINDOW_COMPACTION_STRATEGY,
437
+ ...(this.identity ? { windowId: this.identity.currentWindowId } : {}),
438
+ },
439
+ };
440
+ }
441
+
442
+ rewritePayload(payload: unknown, ctx: ExtensionContext): unknown {
443
+ const withMetadata = rewriteWindowPayload(payload, ctx, this.identity);
444
+ return rewriteContextNamespaceTools(withMetadata, { encrypted: true });
445
+ }
446
+
447
+ rewriteHeaders(headers: ProviderHeaders, ctx: ExtensionContext): void {
448
+ rewriteWindowHeaders(headers, ctx, this.identity);
449
+ }
450
+
451
+ private sendWindowMessage(
452
+ pi: ExtensionAPI,
453
+ ctx: ExtensionContext,
454
+ identity: ContextWindowIdentity,
455
+ options: StartContextWindowOptions,
456
+ threadHint?: string,
457
+ checkpoint?: NotesCheckpointReceipt,
458
+ ): void {
459
+ sendContextWindowMessage(
460
+ pi,
461
+ renderContextWindowMessage(identity, threadHint, checkpoint),
462
+ "window",
463
+ identity,
464
+ { triggerTurn: options.triggerTurn, sessionId: ctx.sessionManager.getSessionId() },
465
+ options.trimPreviousWindow,
466
+ );
467
+ this.identity = identity;
468
+ this.sessionId = ctx.sessionManager.getSessionId();
469
+ this.restoredMarkerId = undefined;
470
+ this.trimPendingWindowId = options.trimPreviousWindow ? identity.currentWindowId : undefined;
471
+ // sendMessage() acceptance is not persistence. The pending rollover stays
472
+ // armed until synchronize() observes this target window in the persisted
473
+ // branch (or the session changes).
474
+ }
475
+ }
476
+
477
+ function identityFromDetails(details: CodexContextManagementMessageDetails): ContextWindowIdentity {
478
+ const context = details.contextManagement;
479
+ return {
480
+ firstWindowId: context.firstWindowId,
481
+ currentWindowId: context.currentWindowId,
482
+ ...(context.previousWindowId ? { previousWindowId: context.previousWindowId } : {}),
483
+ windowNumber: context.windowNumber,
484
+ };
485
+ }
486
+
487
+ const NOTES_CHECKPOINT_ACTIONS: ReadonlySet<string> = new Set(["append_to_file", "write_file"]);
488
+
489
+ /** Tools that only move context around; they never count as work done in a window. */
490
+ const CONTEXT_MANAGEMENT_TOOL_NAMES: ReadonlySet<string> = new Set([
491
+ "new_context",
492
+ "get_context_remaining",
493
+ "history",
494
+ "notes",
495
+ ]);
496
+
497
+ /**
498
+ * Whether the current window produced a tool result from anything other than the
499
+ * context-handoff tools. Derived from persisted entries so it survives restarts.
500
+ */
501
+ export function hasSubstantiveToolResultSinceBoundary(
502
+ entries: readonly SessionEntry[],
503
+ sessionId?: string,
504
+ ): boolean {
505
+ const boundaryIndex = findLatestWindowBoundaryIndex(entries, sessionId);
506
+ if (boundaryIndex < 0) return true;
507
+ for (let index = boundaryIndex + 1; index < entries.length; index += 1) {
508
+ const entry = entries[index]!;
509
+ if (entry.type !== "message") continue;
510
+ const message = entry.message as unknown as { role?: string; toolName?: string };
511
+ if (message.role !== "toolResult" || typeof message.toolName !== "string") continue;
512
+ if (!CONTEXT_MANAGEMENT_TOOL_NAMES.has(message.toolName)) return true;
513
+ }
514
+ return false;
515
+ }
516
+
517
+ /**
518
+ * Whether the branch contains a successful assistant usage after the latest
519
+ * window boundary. Before that first usage lands, any context measurement is
520
+ * still anchored to the previous window's last request.
521
+ */
522
+ export function hasAssistantUsageSinceWindowBoundary(
523
+ entries: readonly SessionEntry[],
524
+ sessionId?: string,
525
+ ): boolean {
526
+ const boundaryIndex = findLatestWindowBoundaryIndex(entries, sessionId);
527
+ if (boundaryIndex < 0) return false;
528
+ for (let index = boundaryIndex + 1; index < entries.length; index += 1) {
529
+ const entry = entries[index]!;
530
+ if (entry.type !== "message") continue;
531
+ const message = entry.message;
532
+ if (message.role !== "assistant") continue;
533
+ const candidate = message as unknown as {
534
+ stopReason?: string;
535
+ usage?: { totalTokens?: number; input?: number; output?: number; cacheRead?: number; cacheWrite?: number };
536
+ };
537
+ if (candidate.stopReason === "aborted" || candidate.stopReason === "error") continue;
538
+ const usage = candidate.usage;
539
+ if (!usage) continue;
540
+ const tokens = usage.totalTokens ?? (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
541
+ if (tokens > 0) return true;
542
+ }
543
+ return false;
544
+ }
545
+
546
+ /**
547
+ * Return the most recent successful notes write after the latest window
548
+ * boundary. The receipt is derived from persisted session entries so it
549
+ * survives restarts and can be used for an explicit rollover handoff.
550
+ */
551
+ export function findLatestNotesCheckpointSinceBoundary(
552
+ entries: readonly SessionEntry[],
553
+ sessionId?: string,
554
+ ): NotesCheckpointReceipt | undefined {
555
+ const boundaryIndex = findLatestWindowBoundaryIndex(entries, sessionId);
556
+ if (boundaryIndex < 0) return undefined;
557
+ const checkpointCalls = new Map<string, NotesCheckpointReceipt>();
558
+ let latest: NotesCheckpointReceipt | undefined;
559
+ for (let index = boundaryIndex + 1; index < entries.length; index += 1) {
560
+ const entry = entries[index]!;
561
+ if (entry.type !== "message") continue;
562
+ const message = entry.message;
563
+ if (message.role === "assistant") {
564
+ const parts = Array.isArray(message.content) ? message.content : [];
565
+ for (const part of parts) {
566
+ if (!isRecord(part) || part.type !== "toolCall" || part.name !== "notes") continue;
567
+ const args = isRecord(part.arguments) ? part.arguments : undefined;
568
+ const action = typeof args?.action === "string" ? args.action : "";
569
+ const path = isNonEmptyString(args?.path) ? args.path : undefined;
570
+ if (isNonEmptyString(part.id) && path !== undefined && NOTES_CHECKPOINT_ACTIONS.has(action)) {
571
+ checkpointCalls.set(part.id, { path, toolCallId: part.id });
572
+ }
573
+ }
574
+ continue;
575
+ }
576
+ if (message.role !== "toolResult" || message.toolName !== "notes") continue;
577
+ const receipt = checkpointCalls.get(message.toolCallId);
578
+ if (!receipt) continue;
579
+ checkpointCalls.delete(message.toolCallId);
580
+ if (message.isError !== false || !isSuccessfulHistoryNotesToolResult(message.details)) continue;
581
+ latest = receipt;
582
+ }
583
+ return latest;
584
+ }
585
+
586
+ /**
587
+ * Whether the branch contains a successful notes append/write result after the
588
+ * latest window boundary. Reads and failed writes never count as checkpoints.
589
+ */
590
+ export function findNotesCheckpointSinceBoundary(
591
+ entries: readonly SessionEntry[],
592
+ sessionId?: string,
593
+ ): boolean {
594
+ return findLatestNotesCheckpointSinceBoundary(entries, sessionId) !== undefined;
595
+ }
596
+
597
+ function findLatestWindowBoundaryIndex(
598
+ entries: readonly SessionEntry[],
599
+ sessionId?: string,
600
+ ): number {
601
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
602
+ const entry = entries[index]!;
603
+ if (entry.type !== "custom_message" || entry.customType !== CODEX_CONTEXT_WINDOW_MESSAGE_TYPE) continue;
604
+ if (!couldBelongToSession(entry.details, sessionId)) continue;
605
+ if (!isCodexContextManagementMessageDetails(entry.details)) {
606
+ throw new Error("Malformed persisted Codex context-window message");
607
+ }
608
+ if (!matchesSession(entry.details.sessionId, sessionId)) continue;
609
+ if (entry.details.contextManagement.kind === "window") return index;
610
+ }
611
+ return -1;
612
+ }
613
+
614
+ export function findLatestWindowBoundaryEntry(
615
+ entries: readonly SessionEntry[],
616
+ sessionId?: string,
617
+ ): WindowBoundaryEntry | undefined {
618
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
619
+ const entry = entries[index]!;
620
+ if (entry.type !== "custom_message" || entry.customType !== CODEX_CONTEXT_WINDOW_MESSAGE_TYPE) continue;
621
+ if (!couldBelongToSession(entry.details, sessionId)) continue;
622
+ if (!isCodexContextManagementMessageDetails(entry.details)) {
623
+ throw new Error("Malformed persisted Codex context-window message");
624
+ }
625
+ if (!matchesSession(entry.details.sessionId, sessionId)) continue;
626
+ if (entry.details.contextManagement.kind === "window") return entry as WindowBoundaryEntry;
627
+ }
628
+ return undefined;
629
+ }
630
+
631
+ /**
632
+ * Whether this exact window boundary is already persisted for the session.
633
+ * Only durable entries count; a queued/visible marker does not.
634
+ */
635
+ function hasPersistedWindowBoundary(
636
+ entries: readonly SessionEntry[],
637
+ sessionId: string | undefined,
638
+ windowId: string,
639
+ ): boolean {
640
+ for (const entry of entries) {
641
+ if (entry.type !== "custom_message" || entry.customType !== CODEX_CONTEXT_WINDOW_MESSAGE_TYPE) continue;
642
+ if (!couldBelongToSession(entry.details, sessionId)) continue;
643
+ if (!isCodexContextManagementMessageDetails(entry.details)) continue;
644
+ if (!matchesSession(entry.details.sessionId, sessionId)) continue;
645
+ const details = entry.details.contextManagement;
646
+ if (details.kind === "window" && details.currentWindowId === windowId) return true;
647
+ }
648
+ return false;
649
+ }
650
+
651
+ function findLatestContextMarkerId(
652
+ entries: readonly SessionEntry[],
653
+ sessionId?: string,
654
+ ): string | undefined {
655
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
656
+ const entry = entries[index]!;
657
+ if (entry.type !== "custom_message" || entry.customType !== CODEX_CONTEXT_WINDOW_MESSAGE_TYPE) continue;
658
+ if (!couldBelongToSession(entry.details, sessionId)) continue;
659
+ if (!isCodexContextManagementMessageDetails(entry.details)) {
660
+ throw new Error("Malformed persisted Codex context-window message");
661
+ }
662
+ if (matchesSession(entry.details.sessionId, sessionId)) return entry.id;
663
+ }
664
+ return undefined;
665
+ }
666
+
667
+ function couldBelongToSession(details: unknown, sessionId: string | undefined): boolean {
668
+ if (sessionId === undefined || !isRecord(details)) return true;
669
+ const markerSessionId = details.sessionId;
670
+ return !isNonEmptyString(markerSessionId) || markerSessionId === sessionId;
671
+ }
672
+
673
+ function matchesSession(markerSessionId: string | undefined, sessionId: string | undefined): boolean {
674
+ return sessionId === undefined || markerSessionId === sessionId;
675
+ }
676
+
677
+ function projectEncryptedToolResults(messages: readonly AgentMessage[]): AgentMessage[] {
678
+ let changed = false;
679
+ const projected = messages.map((message) => {
680
+ if (message.role !== "toolResult" || !isRecord(message.details)) return message;
681
+ const historyNotes = message.details.codexHistoryNotes;
682
+ if (!isRecord(historyNotes) || typeof historyNotes.encrypted_output !== "string") return message;
683
+ changed = true;
684
+ return {
685
+ ...message,
686
+ content: [
687
+ { type: "text" as const, text: encodeEncryptedOutputForContext(historyNotes.encrypted_output) },
688
+ ...message.content.filter((item) => item.type === "image"),
689
+ ],
690
+ };
691
+ });
692
+ return changed ? projected : [...messages];
693
+ }