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.
- package/README.md +24 -2
- package/README.zh.md +24 -2
- package/docs/configuration.md +9 -3
- package/docs/internals.md +25 -2
- package/package.json +3 -1
- package/src/auto-mode/classifier.ts +161 -165
- package/src/auto-mode/extension.ts +691 -661
- package/src/auto-mode/model-selection-guard.ts +52 -0
- package/src/auto-mode/reviewer.ts +325 -335
- package/src/auto-mode/transcript.ts +135 -133
- package/src/context-management/manual-compact.ts +433 -0
- package/src/context-management/tools.ts +11 -2
- package/src/context-management/window-manager.ts +35 -9
- package/src/extension-runtime.ts +49 -11
- package/src/remote-v2-client.ts +108 -33
package/src/extension-runtime.ts
CHANGED
|
@@ -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
|
-
|
|
834
|
-
|
|
835
|
-
|
|
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) =>
|
|
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("
|
|
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
|
|
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("
|
|
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);
|
package/src/remote-v2-client.ts
CHANGED
|
@@ -156,41 +156,53 @@ export function buildRemoteV2CompactionRequest(
|
|
|
156
156
|
};
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|