pi-openai-toolkit 0.15.0 → 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,
@@ -294,10 +294,12 @@ function createWebRunTool(deps: WebRunExecutorDependencies): ToolDefinition<
294
294
  };
295
295
  }
296
296
 
297
+ type StandaloneRegistrationState = "verified" | "excluded" | "unavailable";
298
+
297
299
  function verifyStandaloneRegistration(
298
300
  pi: ExtensionAPI,
299
301
  definition: ToolDefinition<typeof STANDALONE_WEB_RUN_PARAMETERS, AlphaSearchDetails>,
300
- ): boolean {
302
+ ): StandaloneRegistrationState {
301
303
  try {
302
304
  const api = pi as ExtensionAPI & {
303
305
  getAllTools?: () => Array<{
@@ -307,14 +309,17 @@ function verifyStandaloneRegistration(
307
309
  promptGuidelines?: string[];
308
310
  }>;
309
311
  };
310
- if (typeof api.getAllTools !== "function") return false;
312
+ if (typeof api.getAllTools !== "function") return "unavailable";
311
313
  const actual = api.getAllTools().find((tool) => tool.name === WEB_RUN_TOOL_NAME);
312
- return actual !== undefined &&
313
- actual.description === definition.description &&
314
+ // Pi publishes only tools permitted by --tools/--exclude-tools/--no-tools.
315
+ // After successful registration, an absent definition means this session
316
+ // cannot use search; it is not a reason to abort ordinary model requests.
317
+ if (actual === undefined) return "excluded";
318
+ return actual.description === definition.description &&
314
319
  actual.parameters === definition.parameters &&
315
- actual.promptGuidelines === definition.promptGuidelines;
320
+ actual.promptGuidelines === definition.promptGuidelines ? "verified" : "unavailable";
316
321
  } catch {
317
- return false;
322
+ return "unavailable";
318
323
  }
319
324
  }
320
325
 
@@ -399,8 +404,8 @@ export function registerWebSearchExtension(
399
404
  standaloneRegistrationSucceeded = false;
400
405
  }
401
406
 
402
- function standaloneReady(): boolean {
403
- if (!standaloneRegistrationSucceeded || !canManageActiveTools(pi)) return false;
407
+ function standaloneRegistrationState(): StandaloneRegistrationState {
408
+ if (!standaloneRegistrationSucceeded || !canManageActiveTools(pi)) return "unavailable";
404
409
  // Recheck on every lifecycle boundary so a later extension cannot replace
405
410
  // the definition after startup and leave a stale active name executable.
406
411
  // Do not use getAllTools() during extension loading; lifecycle callbacks
@@ -408,6 +413,10 @@ export function registerWebSearchExtension(
408
413
  return verifyStandaloneRegistration(pi, standaloneTool);
409
414
  }
410
415
 
416
+ function standaloneReady(): boolean {
417
+ return standaloneRegistrationState() === "verified";
418
+ }
419
+
411
420
  function readConfig(model: WebSearchModel | undefined, ctx?: ExtensionContext): ResolvedToolkitConfig {
412
421
  const resolved = resolveToolkitConfig(loadConfig(), model);
413
422
  if (ctx) notifyConfigIssues(ctx, resolved);
@@ -459,13 +468,15 @@ export function registerWebSearchExtension(
459
468
  catch (error) { abortAndThrow(ctx, error instanceof Error ? error.message : "Invalid Web Search configuration."); }
460
469
  const { config } = resolved;
461
470
  const resolution = synchronizeResolved(ctx.model, resolved);
462
- if (resolution.route === "standalone-alpha" && resolution.available && !standaloneReady()) {
471
+ const registration = standaloneRegistrationState();
472
+ if (resolution.route === "standalone-alpha" && resolution.available && registration === "unavailable") {
463
473
  abortAndThrow(ctx, `${WEB_RUN_TOOL_NAME} is not registered by the toolkit; standalone Web Search request aborted.`);
464
474
  }
465
475
  const transformed = transformWebSearchPayload({
466
476
  model: ctx.model,
467
477
  config: config.webSearch,
468
478
  payload: event.payload,
479
+ standaloneToolExcluded: registration === "excluded",
469
480
  });
470
481
  if (transformed.fatal) {
471
482
  abortAndThrow(ctx, transformed.errorMessage ?? "Web Search route request aborted.");
@@ -49,8 +49,9 @@ function unavailableRouteTransform(
49
49
  function transformStandalonePayload(args: {
50
50
  payload: JsonObject;
51
51
  resolution: ReturnType<typeof resolveWebSearchRoute>;
52
+ standaloneToolExcluded?: boolean;
52
53
  }): WebSearchPayloadTransform {
53
- const { payload, resolution } = args;
54
+ const { payload, resolution, standaloneToolExcluded = false } = args;
54
55
  const toolsValue = payload.tools;
55
56
  if (toolsValue !== undefined && !Array.isArray(toolsValue)) {
56
57
  return {
@@ -77,6 +78,7 @@ function transformStandalonePayload(args: {
77
78
  const normalizedTools = tools.filter((tool) => {
78
79
  if (isLocalWebSearchFunction(tool) || isNativeWebSearchTool(tool)) return false;
79
80
  if (!isStandaloneWebRunFunction(tool)) return true;
81
+ if (standaloneToolExcluded) return false;
80
82
  standaloneToolCount += 1;
81
83
  return standaloneToolCount === 1;
82
84
  });
@@ -84,7 +86,7 @@ function transformStandalonePayload(args: {
84
86
  const normalizedInclude = include.filter((item) => item !== WEB_SEARCH_SOURCE_INCLUDE);
85
87
  const toolsChanged = normalizedTools.length !== tools.length;
86
88
  const includeChanged = normalizedInclude.length !== include.length;
87
- if (toolsValue !== undefined && !normalizedTools.some(isStandaloneWebRunFunction)) {
89
+ if (!standaloneToolExcluded && toolsValue !== undefined && !normalizedTools.some(isStandaloneWebRunFunction)) {
88
90
  return {
89
91
  payload,
90
92
  outcome: "unavailable-route",
@@ -159,6 +161,8 @@ export function transformWebSearchPayload(args: {
159
161
  model: WebSearchModel | undefined;
160
162
  config: WebSearchConfig;
161
163
  payload: unknown;
164
+ /** The host filtered a successfully registered web_run out of this session's tool catalog. */
165
+ standaloneToolExcluded?: boolean;
162
166
  }): WebSearchPayloadTransform {
163
167
  const { model, config, payload } = args;
164
168
  const resolution = resolveWebSearchRoute({ model, config });
@@ -195,7 +199,7 @@ export function transformWebSearchPayload(args: {
195
199
  errorMessage: "Standalone Web Search requires an object provider payload.",
196
200
  };
197
201
  }
198
- return transformStandalonePayload({ payload, resolution });
202
+ return transformStandalonePayload({ payload, resolution, standaloneToolExcluded: args.standaloneToolExcluded });
199
203
  }
200
204
 
201
205
  if (!isRecord(payload)) {