pi-openai-toolkit 0.15.1 → 0.16.0

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.
@@ -19,6 +19,7 @@ import {
19
19
  import { routeContextNamespaceToolMessage } from "./context-management/namespace-tools";
20
20
  import { loadHistoryNotesThreadHint } from "./context-management/history-notes";
21
21
  import { CodexContextWindowManager } from "./context-management/window-manager";
22
+ import { ManagedManualCompact } from "./context-management/manual-compact";
22
23
  import {
23
24
  BULK_CLIFF_RATIO,
24
25
  decideBulkCliffAction,
@@ -110,6 +111,7 @@ type CompactionDependencies = {
110
111
  remoteCompact: typeof executeRemoteV2Compaction;
111
112
  nativeFallback: typeof runNativeFallbackCompaction;
112
113
  contextWindows: CodexContextWindowManager;
114
+ manualCompact?: ManagedManualCompact;
113
115
  /** Test seam for the same ordered projection exposed by the Pi host patch. */
114
116
  projectCompactionContext?: CompactionContextProjection;
115
117
  piContextHookPatch: PiContextHookPatchResult;
@@ -599,6 +601,10 @@ async function handleSessionBeforeCompact(
599
601
  if (await remoteContextActive(ctx, config)) {
600
602
  try {
601
603
  dependencies.contextWindows.synchronize(ctx);
604
+ if (event.reason === "manual" && dependencies.manualCompact && !dependencies.manualCompact.isMaintenance) {
605
+ await dependencies.manualCompact.request(event, ctx, resolved);
606
+ return { cancel: true };
607
+ }
602
608
  return dependencies.contextWindows.prepareCompaction(event);
603
609
  } catch {
604
610
  notifyRemoteContextFailure(ctx, "malformed-window-state");
@@ -783,6 +789,7 @@ async function handleWindowBulk(
783
789
  loadConfig: typeof loadToolkitConfig,
784
790
  trigger: "model-switch" | "turn-end",
785
791
  resolved = contextOperation(loadConfig, ctx),
792
+ beginMaintenance?: () => () => void,
786
793
  ): Promise<void> {
787
794
  requireContextPolicy(resolved, ctx);
788
795
  const compaction = resolved.config.compaction;
@@ -830,11 +837,16 @@ async function handleWindowBulk(
830
837
 
831
838
  notifyWarning(ctx, `${gap} to ${modelKey}, past ${Math.round(BULK_CLIFF_RATIO * 100)}% of its window; compacting the retired windows first.`);
832
839
  if (typeof ctx.compact !== "function") return;
833
- ctx.compact({
834
- onError: (error: Error) => {
835
- notifyWarning(ctx, `automatic boundary compaction failed: ${error.message}`);
836
- },
837
- });
840
+ const done = beginMaintenance?.();
841
+ try {
842
+ ctx.compact({
843
+ onComplete: () => done?.(),
844
+ onError: (error: Error) => {
845
+ done?.();
846
+ notifyWarning(ctx, `automatic boundary compaction failed: ${error.message}`);
847
+ },
848
+ });
849
+ } catch (error) { done?.(); throw error; }
838
850
  }
839
851
 
840
852
  async function handleContextInternal(
@@ -1169,12 +1181,14 @@ export default function registerCompactionExtension(
1169
1181
  const contextWindows = overrides.contextWindows ?? new CodexContextWindowManager((ctx, signal, gatewayModels) =>
1170
1182
  loadHistoryNotesThreadHint(ctx, signal, gatewayModels)
1171
1183
  );
1184
+ const manualCompact = new ManagedManualCompact(pi, contextWindows);
1172
1185
  const dependencies: CompactionDependencies = {
1173
- loadConfig,
1174
1186
  remoteCompact: executeRemoteV2Compaction,
1175
1187
  nativeFallback: runNativeFallbackCompaction,
1176
1188
  contextWindows,
1177
1189
  ...overrides,
1190
+ manualCompact,
1191
+ loadConfig: () => manualCompact.snapshot ?? loadConfig(),
1178
1192
  piContextHookPatch: overrides.piContextHookPatch ?? piContextHookPatch,
1179
1193
  };
1180
1194
  let tools!: ReturnType<typeof registerContextManagementTools>;
@@ -1193,6 +1207,8 @@ export default function registerCompactionExtension(
1193
1207
  assertConfigValid(resolved, "context", "compatibility");
1194
1208
  return { active: await isContextRuntimeActive(ctx, resolved.config.compaction), gatewayModels: resolved.gatewayModelKeys };
1195
1209
  },
1210
+ undefined,
1211
+ manualCompact,
1196
1212
  );
1197
1213
  const remoteContextActive: RemoteContextActive = isContextRuntimeActive;
1198
1214
  const syncTools = async (
@@ -1233,6 +1249,7 @@ export default function registerCompactionExtension(
1233
1249
  pi.on("session_start", async (_event, ctx) => {
1234
1250
  const resolved = contextOperation(dependencies.loadConfig, ctx);
1235
1251
  const syncOutcome = await syncTools(ctx, ctx.model, { notifyWindowFailure: true }, resolved);
1252
+ await manualCompact.recover(ctx);
1236
1253
  const active = syncOutcome.eligible && syncOutcome.toolsSynced && syncOutcome.windowInitialized;
1237
1254
  const { source } = resolved;
1238
1255
  const warnings = resolved.issues.map((issue) => `${issue.path}: ${issue.code}`);
@@ -1291,10 +1308,17 @@ export default function registerCompactionExtension(
1291
1308
  }
1292
1309
  });
1293
1310
 
1294
- pi.on("context", (event, ctx) => handleContext(event, ctx, pi, dependencies.loadConfig, contextWindows, remoteContextActive));
1311
+ pi.on("context", (event, ctx) => {
1312
+ manualCompact.guardRequest(ctx);
1313
+ return handleContext(event, ctx, pi, dependencies.loadConfig, contextWindows, remoteContextActive);
1314
+ });
1295
1315
  pi.on("session_before_compact", (event, ctx) => handleSessionBeforeCompact(event, ctx, dependencies, remoteContextActive));
1296
1316
  pi.on("session_compact", (event, _ctx) => contextWindows.recordCompaction(event.compactionEntry.details));
1297
- pi.on("session_shutdown", () => {
1317
+ pi.on("session_compact_failed", async (event, ctx) => {
1318
+ if (event.reason === "manual" && !manualCompact.isMaintenance) await manualCompact.compactFailed(ctx);
1319
+ });
1320
+ pi.on("session_shutdown", async (_event, ctx) => {
1321
+ await manualCompact.shutdown(ctx);
1298
1322
  contextWindowReady = false;
1299
1323
  contextWindows.reset();
1300
1324
  tools.reset();
@@ -1302,9 +1326,11 @@ export default function registerCompactionExtension(
1302
1326
  pi.on("agent_settled", async (_event, ctx) => {
1303
1327
  // Idle: no retry, compaction, or queued continuation is left running, so this is the
1304
1328
  // only safe place to spend a model call on the user's behalf.
1305
- await handleWindowBulk(ctx, contextWindows, dependencies.loadConfig, "turn-end");
1329
+ if (await manualCompact.settled(ctx)) return;
1330
+ await handleWindowBulk(ctx, contextWindows, dependencies.loadConfig, "turn-end", undefined, () => manualCompact.beginMaintenance());
1306
1331
  });
1307
1332
  pi.on("model_select", async (event, ctx) => {
1333
+ manualCompact.modelSelected(event.model, ctx);
1308
1334
  // Switching into a covered model mid-session must open the window
1309
1335
  // lifecycle immediately: without an identity the request rewrite skips
1310
1336
  // window metadata, the backend never ingests those turns, and the first
@@ -1314,12 +1340,24 @@ export default function registerCompactionExtension(
1314
1340
  await syncTools(ctx, event.model, { notifyWindowFailure: true }, resolved);
1315
1341
  // The switch itself is the moment the durable transcript changes consumer, and the
1316
1342
  // session is idle here, so the close-out must happen now rather than mid-turn.
1317
- await handleWindowBulk(ctx, contextWindows, dependencies.loadConfig, "model-switch", resolved);
1343
+ if (!manualCompact.busy) await handleWindowBulk(ctx, contextWindows, dependencies.loadConfig, "model-switch", resolved, () => manualCompact.beginMaintenance());
1318
1344
  });
1319
1345
  pi.on("before_agent_start", async (_event, ctx) => {
1320
1346
  await syncTools(ctx, ctx.model, { notifyWindowFailure: true });
1321
1347
  });
1322
- pi.on("before_provider_request", (event, ctx) => handleBeforeProviderRequest(event, ctx, dependencies.loadConfig, contextWindows, remoteContextActive));
1348
+ pi.on("thinking_level_select", (event, ctx) => manualCompact.thinkingSelected(event.level, ctx));
1349
+ pi.on("message_start", (event) => manualCompact.messageStarted(event.message));
1350
+ pi.on("turn_end", (_event, ctx) => manualCompact.turnEnded(ctx));
1351
+ pi.on("tool_call", (event) => manualCompact.guardTool(event.toolName));
1352
+ pi.on("session_tree", async (_event, ctx) => {
1353
+ contextWindows.synchronize(ctx);
1354
+ await manualCompact.recover(ctx);
1355
+ await syncTools(ctx);
1356
+ });
1357
+ pi.on("before_provider_request", (event, ctx) => {
1358
+ manualCompact.guardRequest(ctx);
1359
+ return handleBeforeProviderRequest(event, ctx, dependencies.loadConfig, contextWindows, remoteContextActive);
1360
+ });
1323
1361
  pi.on("before_provider_headers", async (event, ctx) => {
1324
1362
  const resolved = contextOperation(dependencies.loadConfig, ctx);
1325
1363
  requireContextPolicy(resolved, ctx);
@@ -156,41 +156,53 @@ export function buildRemoteV2CompactionRequest(
156
156
  };
157
157
  }
158
158
 
159
- export function parseSseEvents(raw: string): ParsedSseEvent[] | undefined {
160
- const normalized = raw.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
161
- const events: ParsedSseEvent[] = [];
162
-
163
- for (const block of normalized.split(/\n\n+/)) {
164
- if (!block.trim()) continue;
165
-
166
- let event: string | undefined;
167
- const dataLines: string[] = [];
168
- for (const line of block.split("\n")) {
169
- if (line.startsWith(":")) continue;
170
- if (line.startsWith("event:")) {
171
- event = line.slice("event:".length).trim();
159
+ /** Shared line framing; yielding lazily keeps bytes after a terminal out of parsing. */
160
+ class SseEventParser {
161
+ private line = "";
162
+ private skipLf = false;
163
+ private event: string | undefined;
164
+ private dataLines: string[] = [];
165
+
166
+ *push(text: string): Generator<ParsedSseEvent> {
167
+ for (const character of text) {
168
+ if (this.skipLf) {
169
+ this.skipLf = false;
170
+ if (character === "\n") continue;
171
+ }
172
+ if (character !== "\r" && character !== "\n") {
173
+ this.line += character;
172
174
  continue;
173
175
  }
174
- if (line.startsWith("data:")) {
175
- dataLines.push(line.slice("data:".length).replace(/^ /, ""));
176
+ this.skipLf = character === "\r";
177
+ const line = this.line;
178
+ this.line = "";
179
+ if (line === "") {
180
+ const event = this.event;
181
+ const dataLines = this.dataLines;
182
+ this.event = undefined;
183
+ this.dataLines = [];
184
+ if (dataLines.length === 0) continue;
185
+ const dataText = dataLines.join("\n");
186
+ yield dataText === "[DONE]"
187
+ ? { event, dataText }
188
+ : { event, dataText, data: JSON.parse(dataText) };
189
+ } else if (line.startsWith("event:")) {
190
+ this.event = line.slice("event:".length).trim();
191
+ } else if (line === "data" || line.startsWith("data:")) {
192
+ this.dataLines.push(line.slice("data:".length).replace(/^ /, ""));
176
193
  }
177
194
  }
178
-
179
- if (dataLines.length === 0) continue;
180
- const dataText = dataLines.join("\n");
181
- if (dataText === "[DONE]") {
182
- events.push({ event, dataText });
183
- continue;
184
- }
185
-
186
- try {
187
- events.push({ event, dataText, data: JSON.parse(dataText) });
188
- } catch {
189
- return undefined;
190
- }
191
195
  }
196
+ }
192
197
 
193
- return events.length > 0 ? events : undefined;
198
+ export function parseSseEvents(raw: string): ParsedSseEvent[] | undefined {
199
+ try {
200
+ // Retain the buffered helper's historical support for a final unterminated block.
201
+ const events = [...new SseEventParser().push(`${raw}\n\n`)];
202
+ return events.length > 0 ? events : undefined;
203
+ } catch {
204
+ return undefined;
205
+ }
194
206
  }
195
207
 
196
208
  function isRemoteV2CompactionItem(value: unknown): value is RemoteV2CompactionItem {
@@ -560,6 +572,68 @@ function reconcileSseCheckpoint(events: readonly ParsedSseEvent[]): CheckpointRe
560
572
  };
561
573
  }
562
574
 
575
+ type CollectedSse = {
576
+ responseText: string;
577
+ events?: ParsedSseEvent[];
578
+ reconciliation?: CheckpointReconciliation;
579
+ };
580
+
581
+ async function collectSseCheckpoint(response: Response, signal?: AbortSignal): Promise<CollectedSse> {
582
+ const reader = response.body?.getReader();
583
+ if (!reader) {
584
+ if (signal?.aborted) throw new DOMException("Compaction aborted", "AbortError");
585
+ return { responseText: "" };
586
+ }
587
+ const decoder = new TextDecoder();
588
+ const parser = new SseEventParser();
589
+ const events: ParsedSseEvent[] = [];
590
+ const text: string[] = [];
591
+ let onAbort: (() => void) | undefined;
592
+ const aborted = new Promise<never>((_resolve, reject) => {
593
+ onAbort = () => reject(new DOMException("Compaction aborted", "AbortError"));
594
+ signal?.addEventListener("abort", onAbort, { once: true });
595
+ });
596
+ try {
597
+ while (true) {
598
+ if (signal?.aborted) throw new DOMException("Compaction aborted", "AbortError");
599
+ const chunk = await Promise.race([reader.read(), aborted]);
600
+ if (signal?.aborted) throw new DOMException("Compaction aborted", "AbortError");
601
+ const decoded = chunk.done ? decoder.decode() : decoder.decode(chunk.value, { stream: true });
602
+ text.push(decoded);
603
+ try {
604
+ for (const event of parser.push(decoded)) {
605
+ events.push(event);
606
+ const type = getEventType(event);
607
+ if (type === "response.completed" || type === "error" || type === "response.failed" || type === "response.incomplete") {
608
+ // Validate synchronously before cleanup; later cancellation cannot undo this boundary.
609
+ return { responseText: "", events, reconciliation: reconcileSseCheckpoint(events) };
610
+ }
611
+ }
612
+ } catch (error) {
613
+ if (!(error instanceof SyntaxError)) throw error;
614
+ return { responseText: text.join("") };
615
+ }
616
+ // EOF never dispatches an unfinished frame, even if its JSON happens to be complete.
617
+ if (chunk.done) return { responseText: text.join(""), events: events.length ? events : undefined };
618
+ }
619
+ } finally {
620
+ if (onAbort) signal?.removeEventListener("abort", onAbort);
621
+ // Cancellation is best-effort cleanup, not part of the completion boundary.
622
+ // Observe rejection immediately and release even if the transport never settles cancel().
623
+ try {
624
+ void reader.cancel().catch(() => undefined);
625
+ } catch {
626
+ // A custom reader may throw synchronously during cleanup.
627
+ } finally {
628
+ try {
629
+ reader.releaseLock();
630
+ } catch {
631
+ // Cleanup failures must not replace a validated checkpoint or cancellation.
632
+ }
633
+ }
634
+ }
635
+ }
636
+
563
637
  export async function executeRemoteV2Compaction(
564
638
  options: ExecuteRemoteV2CompactionOptions,
565
639
  ): Promise<RemoteV2CompactionClientResult> {
@@ -588,13 +662,13 @@ export async function executeRemoteV2Compaction(
588
662
  body: JSON.stringify(request),
589
663
  signal,
590
664
  });
591
- const responseText = await response.text();
592
665
  const responseHeaders: Record<string, string> = {};
593
666
  response.headers.forEach((value, key) => {
594
667
  responseHeaders[key] = value;
595
668
  });
596
669
 
597
670
  if (!response.ok) {
671
+ const responseText = await response.text();
598
672
  let responseJson: unknown;
599
673
  if (responseText.trim()) {
600
674
  try {
@@ -623,7 +697,9 @@ export async function executeRemoteV2Compaction(
623
697
  return failure;
624
698
  }
625
699
 
626
- if (!responseText.trim()) {
700
+ const collected = await collectSseCheckpoint(response, signal);
701
+ const { responseText, events } = collected;
702
+ if (!events && !responseText.trim()) {
627
703
  const failure: RemoteV2CompactionClientFailure = {
628
704
  ok: false,
629
705
  reason: "empty-body",
@@ -642,7 +718,6 @@ export async function executeRemoteV2Compaction(
642
718
  return failure;
643
719
  }
644
720
 
645
- const events = parseSseEvents(responseText);
646
721
  if (!events) {
647
722
  const failure: RemoteV2CompactionClientFailure = {
648
723
  ok: false,
@@ -663,7 +738,7 @@ export async function executeRemoteV2Compaction(
663
738
  return failure;
664
739
  }
665
740
 
666
- const reconciliation = reconcileSseCheckpoint(events);
741
+ const reconciliation = collected.reconciliation ?? reconcileSseCheckpoint(events);
667
742
  if (!reconciliation.ok) {
668
743
  const failure: RemoteV2CompactionClientFailure = {
669
744
  ok: false,