pi-openai-toolkit 0.15.1 → 0.17.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 +60 -68
- package/README.zh.md +58 -66
- package/docs/configuration.md +9 -3
- package/docs/internals.md +31 -6
- package/package.json +11 -9
- 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/compaction-projection.ts +35 -31
- 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 +64 -30
- package/src/payload-rewrite.ts +34 -64
- package/src/pi-context-hook.ts +117 -116
- package/src/remote-v2-client.ts +108 -33
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
3
|
+
import { buildSessionContext, estimateTokens, type ExtensionAPI, type ExtensionContext, type SessionBeforeCompactEvent, type SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { resolveToolkitConfig, type ResolvedToolkitConfig } from "../config";
|
|
5
|
+
import { isAutoModeEligible } from "../auto-mode/policy";
|
|
6
|
+
import { protectAutoModeSelection } from "../auto-mode/model-selection-guard";
|
|
7
|
+
import { parseModelSpec } from "../runtime";
|
|
8
|
+
import { resolveCodexContextProvider } from "./codex-provider";
|
|
9
|
+
import { findLatestNotesCheckpointSinceBoundary, findLatestWindowBoundaryEntry, type CodexContextWindowManager } from "./window-manager";
|
|
10
|
+
import { isNonEmptyString, isRecord, type CodexContextProvider, type ContextWindowIdentity } from "./types";
|
|
11
|
+
import type { ContextRolloverHandoff } from "./tools";
|
|
12
|
+
|
|
13
|
+
export const MANUAL_COMPACT_ENTRY_TYPE = "pi-openai-toolkit:manual-compact";
|
|
14
|
+
const PHASES = ["requested", "switching", "checkpointing", "rollover-scheduled", "restoring", "restore-needed", "completed", "failed", "cancelled", "superseded"] as const;
|
|
15
|
+
type Phase = typeof PHASES[number];
|
|
16
|
+
const TERMINAL = new Set<Phase>(["completed", "failed", "cancelled", "superseded"]);
|
|
17
|
+
const THINKING = new Set<string>(["off", "minimal", "low", "medium", "high", "xhigh"]);
|
|
18
|
+
const REQUIRED_TOOLS = ["notes", "history", "new_context", "get_context_remaining"];
|
|
19
|
+
|
|
20
|
+
/** Non-LLM recovery state. No auth, model object, transcript or note text belongs here. */
|
|
21
|
+
export type ManualCompactRecord = {
|
|
22
|
+
version: 1;
|
|
23
|
+
operationId: string;
|
|
24
|
+
sessionId: string;
|
|
25
|
+
sourceWindowId: string;
|
|
26
|
+
anchorId: string;
|
|
27
|
+
originalModel: string;
|
|
28
|
+
targetModel: string;
|
|
29
|
+
originalThinking: ThinkingLevel;
|
|
30
|
+
phase: Phase;
|
|
31
|
+
targetWindowId?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function decodeManualCompactRecord(value: unknown): ManualCompactRecord | undefined {
|
|
35
|
+
if (!isRecord(value) || value.version !== 1
|
|
36
|
+
|| ![value.operationId, value.sessionId, value.sourceWindowId, value.anchorId].every(isNonEmptyString)
|
|
37
|
+
|| typeof value.originalModel !== "string" || !parseModelSpec(value.originalModel)
|
|
38
|
+
|| typeof value.targetModel !== "string" || !parseModelSpec(value.targetModel)
|
|
39
|
+
|| typeof value.originalThinking !== "string" || !THINKING.has(value.originalThinking)
|
|
40
|
+
|| !PHASES.includes(value.phase as Phase)
|
|
41
|
+
|| (value.targetWindowId !== undefined && !isNonEmptyString(value.targetWindowId))) return undefined;
|
|
42
|
+
return value as ManualCompactRecord;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Same gateway credential/affinity domain, or the same native backend/account. Never log this comparison. */
|
|
46
|
+
export function sameContextScope(left: CodexContextProvider, right: CodexContextProvider): boolean {
|
|
47
|
+
if (left.kind !== right.kind || left.baseUrl !== right.baseUrl || left.provider !== right.provider) return false;
|
|
48
|
+
if (left.kind === "native-codex" && right.kind === "native-codex") return left.accountId === right.accountId;
|
|
49
|
+
return left.kind === "codex-gateway" && right.kind === "codex-gateway" && left.apiKey === right.apiKey
|
|
50
|
+
&& JSON.stringify(Object.entries(left.headers).sort()) === JSON.stringify(Object.entries(right.headers).sort());
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function preflightDeadline<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
54
|
+
let onAbort!: () => void;
|
|
55
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
56
|
+
const cancelled = new Promise<never>((_resolve, reject) => {
|
|
57
|
+
onAbort = () => reject(new Error("cancelled before switching models"));
|
|
58
|
+
if (signal.aborted) onAbort();
|
|
59
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
60
|
+
timer = setTimeout(() => reject(new Error("checkpoint-model authentication preflight timed out")), 10_000);
|
|
61
|
+
});
|
|
62
|
+
try { return await Promise.race([work, cancelled]); }
|
|
63
|
+
finally { clearTimeout(timer); signal.removeEventListener("abort", onAbort); }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function modelKey(model: ExtensionContext["model"]): string | undefined {
|
|
67
|
+
return model && `${model.provider}/${model.id}`;
|
|
68
|
+
}
|
|
69
|
+
function requestEntry(entries: readonly SessionEntry[], record: ManualCompactRecord): SessionEntry | undefined {
|
|
70
|
+
return entries.find((entry) => entry.type === "custom" && entry.customType === MANUAL_COMPACT_ENTRY_TYPE
|
|
71
|
+
&& decodeManualCompactRecord(entry.data)?.operationId === record.operationId);
|
|
72
|
+
}
|
|
73
|
+
function freshCheckpoint(entries: readonly SessionEntry[], record: ManualCompactRecord) {
|
|
74
|
+
const request = requestEntry(entries, record);
|
|
75
|
+
if (!request) return undefined;
|
|
76
|
+
let afterId = request.id;
|
|
77
|
+
// Queued user input delivered after a checkpoint must also be checkpointed.
|
|
78
|
+
for (const entry of entries.slice(entries.indexOf(request) + 1)) {
|
|
79
|
+
if (entry.type === "message" && entry.message.role === "user") afterId = entry.id;
|
|
80
|
+
}
|
|
81
|
+
return findLatestNotesCheckpointSinceBoundary(entries, record.sessionId, afterId);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Owns only manual managed handoffs, not normal window rollovers or Remote V2. */
|
|
85
|
+
export class ManagedManualCompact implements ContextRolloverHandoff {
|
|
86
|
+
private record: ManualCompactRecord | undefined;
|
|
87
|
+
private generation = 0;
|
|
88
|
+
private sourcePolicy: ResolvedToolkitConfig | undefined;
|
|
89
|
+
private instructions = "";
|
|
90
|
+
private attemptSignal: AbortSignal | undefined;
|
|
91
|
+
private releaseApproval: (() => void) | undefined;
|
|
92
|
+
private selecting: string | undefined;
|
|
93
|
+
private selectionPromise: Promise<boolean> | undefined;
|
|
94
|
+
private expectedThinking: ThinkingLevel | undefined;
|
|
95
|
+
private newerSelection: { model: NonNullable<ExtensionContext["model"]>; thinking: ThinkingLevel } | undefined;
|
|
96
|
+
private deliveryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
97
|
+
private delivered = false;
|
|
98
|
+
private resumeAllowed = true;
|
|
99
|
+
private checkpointStopped = false;
|
|
100
|
+
private maintenance = 0;
|
|
101
|
+
|
|
102
|
+
constructor(private readonly pi: ExtensionAPI, private readonly windows: CodexContextWindowManager, private readonly deliveryTimeoutMs = 10_000) {}
|
|
103
|
+
|
|
104
|
+
get busy(): boolean { return !!this.record && !TERMINAL.has(this.record.phase); }
|
|
105
|
+
get snapshot() { return this.busy ? this.sourcePolicy?.snapshot : undefined; }
|
|
106
|
+
get isMaintenance(): boolean { return this.maintenance > 0; }
|
|
107
|
+
beginMaintenance(): () => void {
|
|
108
|
+
this.maintenance++;
|
|
109
|
+
let done = false;
|
|
110
|
+
return () => { if (!done) { done = true; this.maintenance--; } };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private notify(ctx: ExtensionContext, text: string, error = false): void {
|
|
114
|
+
const content = `Managed /compact: ${text}`;
|
|
115
|
+
if (ctx.hasUI) ctx.ui.notify(content, error ? "warning" : "info");
|
|
116
|
+
else this.pi.sendMessage({ customType: `${MANUAL_COMPACT_ENTRY_TYPE}:status`, content, display: true }, { triggerTurn: false });
|
|
117
|
+
}
|
|
118
|
+
private write(phase: Phase): void {
|
|
119
|
+
if (!this.record) return;
|
|
120
|
+
this.record = { ...this.record, phase };
|
|
121
|
+
this.pi.appendEntry(MANUAL_COMPACT_ENTRY_TYPE, this.record);
|
|
122
|
+
}
|
|
123
|
+
private clearActivity(): void {
|
|
124
|
+
clearTimeout(this.deliveryTimer);
|
|
125
|
+
this.deliveryTimer = undefined;
|
|
126
|
+
this.releaseApproval?.();
|
|
127
|
+
this.releaseApproval = undefined;
|
|
128
|
+
this.sourcePolicy = undefined;
|
|
129
|
+
}
|
|
130
|
+
private belongs(ctx: ExtensionContext, record = this.record): boolean {
|
|
131
|
+
if (!record || ctx.sessionManager.getSessionId() !== record.sessionId) return false;
|
|
132
|
+
const branch = ctx.sessionManager.getBranch();
|
|
133
|
+
return branch.some((entry) => entry.id === record.anchorId) && !!requestEntry(branch, record);
|
|
134
|
+
}
|
|
135
|
+
private current(ctx: ExtensionContext, generation: number): boolean {
|
|
136
|
+
return generation === this.generation && this.busy && this.belongs(ctx);
|
|
137
|
+
}
|
|
138
|
+
private toolsAvailable(): boolean {
|
|
139
|
+
const active = new Set(this.pi.getActiveTools());
|
|
140
|
+
const published = new Set(this.pi.getAllTools().map((tool) => tool.name));
|
|
141
|
+
return REQUIRED_TOOLS.every((tool) => active.has(tool) && published.has(tool));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async request(event: SessionBeforeCompactEvent, ctx: ExtensionContext, source: ResolvedToolkitConfig): Promise<void> {
|
|
145
|
+
if (this.busy) { this.notify(ctx, "a handoff is already active; no second window was scheduled."); return; }
|
|
146
|
+
this.windows.synchronize(ctx);
|
|
147
|
+
const boundary = findLatestWindowBoundaryEntry(ctx.sessionManager.getBranch(), ctx.sessionManager.getSessionId());
|
|
148
|
+
if (!ctx.model || !boundary || event.signal.aborted || !this.toolsAvailable()) {
|
|
149
|
+
this.notify(ctx, "requires a live managed window and all four permitted context tools.", true); return;
|
|
150
|
+
}
|
|
151
|
+
if (this.windows.hasPendingRollover(ctx) || !this.windows.canRolloverFromCurrentWindow(ctx)) {
|
|
152
|
+
this.notify(ctx, "this window is already switching or has not produced substantive tool activity since its last switch.", true); return;
|
|
153
|
+
}
|
|
154
|
+
const targetKey = source.config.compaction.remoteCompactModel ?? modelKey(ctx.model)!;
|
|
155
|
+
const spec = parseModelSpec(targetKey);
|
|
156
|
+
const target = spec && ctx.modelRegistry.find(spec.provider, spec.modelId);
|
|
157
|
+
if (!target) { this.notify(ctx, `configured checkpoint model ${targetKey} is unavailable.`, true); return; }
|
|
158
|
+
const targetPolicy = resolveToolkitConfig(source.snapshot, target);
|
|
159
|
+
if (targetPolicy.invalidFeatures.some((feature) => ["context", "compatibility", "diagnostics"].includes(feature))
|
|
160
|
+
|| !targetPolicy.config.compaction.enabled || targetPolicy.config.compaction.contextManagement !== "remote") {
|
|
161
|
+
this.notify(ctx, "the checkpoint model must have valid Remote Context policy.", true); return;
|
|
162
|
+
}
|
|
163
|
+
const projected = this.windows.project(buildSessionContext(ctx.sessionManager.getBranch()).messages, "remote");
|
|
164
|
+
const tokens = projected.reduce((total, message) => total + estimateTokens(message), 0);
|
|
165
|
+
const guidanceTokens = Math.ceil((event.customInstructions?.length ?? 0) / 4);
|
|
166
|
+
if (!Number.isFinite(target.contextWindow) || !Number.isFinite(target.maxTokens) || target.maxTokens <= 0
|
|
167
|
+
|| tokens + target.maxTokens + guidanceTokens + 2048 >= target.contextWindow) {
|
|
168
|
+
this.notify(ctx, "the checkpoint model cannot fit this window plus checkpoint output; no history was truncated.", true); return;
|
|
169
|
+
}
|
|
170
|
+
this.newerSelection = undefined;
|
|
171
|
+
this.delivered = false;
|
|
172
|
+
this.resumeAllowed = true;
|
|
173
|
+
this.checkpointStopped = false;
|
|
174
|
+
this.sourcePolicy = source;
|
|
175
|
+
this.attemptSignal = event.signal;
|
|
176
|
+
this.instructions = event.customInstructions?.trim() ?? "";
|
|
177
|
+
this.record = {
|
|
178
|
+
version: 1, operationId: randomUUID(), sessionId: ctx.sessionManager.getSessionId(),
|
|
179
|
+
sourceWindowId: boundary.details.contextManagement.currentWindowId,
|
|
180
|
+
anchorId: ctx.sessionManager.getLeafId() ?? boundary.id,
|
|
181
|
+
originalModel: modelKey(ctx.model)!, targetModel: targetKey,
|
|
182
|
+
originalThinking: this.pi.getThinkingLevel(), phase: "requested",
|
|
183
|
+
};
|
|
184
|
+
this.write("requested");
|
|
185
|
+
const generation = ++this.generation;
|
|
186
|
+
try {
|
|
187
|
+
const [originalProvider, targetProvider] = await preflightDeadline(Promise.all([
|
|
188
|
+
resolveCodexContextProvider(ctx, ctx.model, source.gatewayModelKeys),
|
|
189
|
+
resolveCodexContextProvider(ctx, target, targetPolicy.gatewayModelKeys),
|
|
190
|
+
]), event.signal);
|
|
191
|
+
if (!this.current(ctx, generation)) return;
|
|
192
|
+
if (event.signal.aborted) throw new Error("cancelled before switching models");
|
|
193
|
+
if (!originalProvider.ok || !targetProvider.ok || !sameContextScope(originalProvider.provider, targetProvider.provider)) {
|
|
194
|
+
throw new Error("checkpoint model must authenticate to the same context backend/account");
|
|
195
|
+
}
|
|
196
|
+
const protection = protectAutoModeSelection(this.pi, {
|
|
197
|
+
sessionId: this.record!.sessionId, operationId: this.record!.operationId, target,
|
|
198
|
+
eligible: !targetPolicy.invalidFeatures.includes("autoMode") && isAutoModeEligible(target, targetPolicy.config.autoMode),
|
|
199
|
+
});
|
|
200
|
+
this.releaseApproval = protection.release;
|
|
201
|
+
if (!protection.allowed) throw new Error("checkpoint model would disable the engaged approval gate; allowlist it or choose another model");
|
|
202
|
+
this.notify(ctx, `checkpoint handoff requested (${this.record!.originalModel} → ${targetKey}); native compaction is intentionally cancelled.`);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
if (!this.current(ctx, generation)) return;
|
|
205
|
+
this.write(event.signal.aborted ? "cancelled" : "failed"); this.clearActivity();
|
|
206
|
+
this.notify(ctx, error instanceof Error ? error.message : "preflight failed", true);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Called only after the native manual operation has exposed idle state. */
|
|
211
|
+
async compactFailed(ctx: ExtensionContext): Promise<void> {
|
|
212
|
+
if (this.record?.phase !== "requested") return;
|
|
213
|
+
if (!ctx.isIdle() || this.attemptSignal?.aborted) {
|
|
214
|
+
this.write("cancelled"); this.clearActivity(); this.notify(ctx, "cancelled before checkpoint duty began.", true); return;
|
|
215
|
+
}
|
|
216
|
+
const generation = this.generation;
|
|
217
|
+
// Fence a second terminal callback while public selection awaits auth.
|
|
218
|
+
this.write("switching");
|
|
219
|
+
try {
|
|
220
|
+
if (!this.belongs(ctx) || !this.toolsAvailable()) throw new Error("session or context-tool availability changed");
|
|
221
|
+
if (!(await this.select(this.record.targetModel, ctx))) throw new Error("checkpoint model selection failed");
|
|
222
|
+
if (!this.current(ctx, generation)) { await this.honorNewerSelection(ctx); return; }
|
|
223
|
+
if (this.attemptSignal?.aborted) throw new Error("cancelled during model selection");
|
|
224
|
+
if (!this.toolsAvailable()) throw new Error("required context tools became unavailable");
|
|
225
|
+
this.write("checkpointing");
|
|
226
|
+
const prompt = `[Managed /compact ${this.record.operationId}]\n`
|
|
227
|
+
+ "Checkpoint duty only. Preserve the real active user request, restrictions, decisions, progress, learnings, known history IDs/references and next steps in notes. Do not perform unrelated work or answer the task. "
|
|
228
|
+
+ "Use the existing notes tool to write or append a NEW checkpoint, wait for its successful persisted result, then call new_context exactly once in a later tool turn. An older checkpoint cannot authorize this handoff. "
|
|
229
|
+
+ "After scheduling the window, stop; the extension will restore the original model before resuming. No conversation summary carries over."
|
|
230
|
+
+ (this.instructions ? `\nAdditional user guidance for this checkpoint: ${this.instructions}` : "");
|
|
231
|
+
this.deliveryTimer = setTimeout(() => {
|
|
232
|
+
if (this.current(ctx, generation) && !this.delivered && ctx.isIdle()) void this.finish(ctx, false, false);
|
|
233
|
+
}, this.deliveryTimeoutMs);
|
|
234
|
+
this.deliveryTimer.unref?.();
|
|
235
|
+
this.pi.sendUserMessage(prompt);
|
|
236
|
+
} catch {
|
|
237
|
+
if (this.current(ctx, generation)) await this.finish(ctx, false, false);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
messageStarted(message: { role: string; content?: unknown }): void {
|
|
242
|
+
if (this.record?.phase !== "checkpointing" || message.role !== "user") return;
|
|
243
|
+
const text = typeof message.content === "string" ? message.content : JSON.stringify(message.content);
|
|
244
|
+
if (!text?.includes(`[Managed /compact ${this.record.operationId}]`)) return;
|
|
245
|
+
this.delivered = true;
|
|
246
|
+
clearTimeout(this.deliveryTimer);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
prepare(ctx: ExtensionContext): { triggerTurn: false; beforeSchedule: (identity: ContextWindowIdentity) => void } | undefined {
|
|
250
|
+
if (!this.busy) return undefined;
|
|
251
|
+
const record = this.record!;
|
|
252
|
+
if (!this.belongs(ctx) || record.phase !== "checkpointing" || !this.delivered
|
|
253
|
+
|| modelKey(ctx.model) !== record.targetModel || this.windows.currentIdentity()?.currentWindowId !== record.sourceWindowId) {
|
|
254
|
+
throw new Error("Managed /compact is not ready for a rollover; do not retry while restoration is pending.");
|
|
255
|
+
}
|
|
256
|
+
if (!freshCheckpoint(ctx.sessionManager.getBranch(), record)) {
|
|
257
|
+
throw new Error("Managed /compact requires a new persisted successful notes write/append after this operation and the latest delivered user message.");
|
|
258
|
+
}
|
|
259
|
+
const generation = this.generation;
|
|
260
|
+
return {
|
|
261
|
+
triggerTurn: false,
|
|
262
|
+
beforeSchedule: (identity) => {
|
|
263
|
+
const boundary = findLatestWindowBoundaryEntry(ctx.sessionManager.getBranch(), record.sessionId);
|
|
264
|
+
if (!this.current(ctx, generation) || modelKey(ctx.model) !== record.targetModel
|
|
265
|
+
|| boundary?.details.contextManagement.currentWindowId !== record.sourceWindowId
|
|
266
|
+
|| !freshCheckpoint(ctx.sessionManager.getBranch(), record)) throw new Error("Managed /compact was interrupted before rollover.");
|
|
267
|
+
this.record = { ...record, targetWindowId: identity.currentWindowId };
|
|
268
|
+
this.write("rollover-scheduled");
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Abort without waiting: a queued/mixed continuation must not send the captured temporary model. */
|
|
274
|
+
turnEnded(ctx: ExtensionContext): void {
|
|
275
|
+
if (this.busy && this.record?.phase !== "checkpointing" && this.record?.phase !== "requested") {
|
|
276
|
+
if (!this.checkpointStopped) {
|
|
277
|
+
this.resumeAllowed = !ctx.signal?.aborted;
|
|
278
|
+
this.checkpointStopped = true;
|
|
279
|
+
}
|
|
280
|
+
ctx.abort();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
guardRequest(ctx: ExtensionContext): void {
|
|
284
|
+
if (ctx.signal?.aborted) return;
|
|
285
|
+
if (this.selectionPromise && this.newerSelection) {
|
|
286
|
+
ctx.abort();
|
|
287
|
+
throw new Error("Managed /compact is preserving your newer model selection; this provider request was cancelled.");
|
|
288
|
+
}
|
|
289
|
+
if (!this.busy) return;
|
|
290
|
+
if (this.record?.phase === "checkpointing" && this.belongs(ctx) && this.toolsAvailable() && modelKey(ctx.model) === this.record.targetModel
|
|
291
|
+
&& this.windows.currentIdentity()?.currentWindowId === this.record.sourceWindowId) return;
|
|
292
|
+
ctx.abort();
|
|
293
|
+
throw new Error("Managed /compact is restoring the original model; this provider request was cancelled.");
|
|
294
|
+
}
|
|
295
|
+
guardTool(name: string): { block: true; terminate: true; reason: string } | undefined {
|
|
296
|
+
if (!this.busy && !this.selectionPromise) return;
|
|
297
|
+
if (this.record?.phase === "checkpointing" && REQUIRED_TOOLS.includes(name)) return;
|
|
298
|
+
return { block: true, terminate: true, reason: "Managed /compact owns this checkpoint-only turn. Wait for the original-model continuation before ordinary work." };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
modelSelected(model: NonNullable<ExtensionContext["model"]>, ctx: ExtensionContext): void {
|
|
302
|
+
if ((!this.busy && !this.selectionPromise) || modelKey(model) === this.selecting) return;
|
|
303
|
+
this.newerSelection = { model, thinking: this.pi.getThinkingLevel() };
|
|
304
|
+
++this.generation;
|
|
305
|
+
if (this.belongs(ctx)) this.write("superseded");
|
|
306
|
+
this.clearActivity();
|
|
307
|
+
if (!ctx.isIdle()) ctx.abort();
|
|
308
|
+
this.notify(ctx, "stopped; your newer model selection takes priority.");
|
|
309
|
+
}
|
|
310
|
+
thinkingSelected(level: ThinkingLevel, ctx: ExtensionContext): void {
|
|
311
|
+
if (!this.busy || this.selecting || level === this.expectedThinking) return;
|
|
312
|
+
if (ctx.model) this.modelSelected(ctx.model, ctx);
|
|
313
|
+
}
|
|
314
|
+
private async honorNewerSelection(ctx: ExtensionContext): Promise<void> {
|
|
315
|
+
const choice = this.newerSelection;
|
|
316
|
+
if (!choice || ctx.sessionManager.getSessionId() !== this.record?.sessionId) return;
|
|
317
|
+
const key = modelKey(choice.model)!;
|
|
318
|
+
try {
|
|
319
|
+
const selected = await this.select(key, ctx);
|
|
320
|
+
if (choice !== this.newerSelection) { await this.honorNewerSelection(ctx); return; }
|
|
321
|
+
if (!selected || modelKey(ctx.model) !== key) throw new Error("selection refused");
|
|
322
|
+
this.setThinking(choice.thinking);
|
|
323
|
+
} catch {
|
|
324
|
+
if (choice !== this.newerSelection) { await this.honorNewerSelection(ctx); return; }
|
|
325
|
+
if (!this.belongs(ctx) || !this.record) return;
|
|
326
|
+
this.record = { ...this.record, originalModel: key, originalThinking: choice.thinking, targetModel: modelKey(ctx.model) ?? this.record.targetModel };
|
|
327
|
+
this.write("restore-needed");
|
|
328
|
+
this.notify(ctx, `could not preserve your newer selection ${key}; restore its authentication and reload, or select a model explicitly.`, true);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
private setThinking(level: ThinkingLevel): void {
|
|
332
|
+
this.expectedThinking = level;
|
|
333
|
+
this.pi.setThinkingLevel(level);
|
|
334
|
+
// Pi emits this event without awaiting earlier extension handlers. Keep
|
|
335
|
+
// the owned value so a delayed echo is not mistaken for a user selection.
|
|
336
|
+
this.expectedThinking = this.pi.getThinkingLevel();
|
|
337
|
+
}
|
|
338
|
+
private async select(key: string, ctx: ExtensionContext): Promise<boolean> {
|
|
339
|
+
if (this.selectionPromise) await this.selectionPromise.catch(() => false);
|
|
340
|
+
if (modelKey(ctx.model) === key) return true;
|
|
341
|
+
const spec = parseModelSpec(key)!;
|
|
342
|
+
const model = ctx.modelRegistry.find(spec.provider, spec.modelId);
|
|
343
|
+
if (!model) return false;
|
|
344
|
+
this.selecting = key;
|
|
345
|
+
const pending = this.pi.setModel(model);
|
|
346
|
+
this.selectionPromise = pending;
|
|
347
|
+
try {
|
|
348
|
+
const selected = await pending;
|
|
349
|
+
this.expectedThinking = this.pi.getThinkingLevel();
|
|
350
|
+
return selected;
|
|
351
|
+
} finally {
|
|
352
|
+
if (this.selectionPromise === pending) { this.selecting = undefined; this.selectionPromise = undefined; }
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async settled(ctx: ExtensionContext): Promise<boolean> {
|
|
357
|
+
if (!this.busy) return false;
|
|
358
|
+
if (this.record?.phase === "requested" || this.record?.phase === "switching" || this.record?.phase === "restore-needed" || this.record?.phase === "restoring") return true;
|
|
359
|
+
await this.finish(ctx, this.resumeAllowed, true);
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private async finish(ctx: ExtensionContext, mayResume: boolean, inspectMarker: boolean): Promise<void> {
|
|
364
|
+
const record = this.record;
|
|
365
|
+
if (!record || !ctx.isIdle()) return;
|
|
366
|
+
const generation = this.generation;
|
|
367
|
+
if (!this.belongs(ctx)) { this.clearActivity(); this.record = undefined; ++this.generation; return; }
|
|
368
|
+
const branch = ctx.sessionManager.getBranch();
|
|
369
|
+
const marker = findLatestWindowBoundaryEntry(branch, record.sessionId);
|
|
370
|
+
const switched = inspectMarker && !!record.targetWindowId && marker?.details.contextManagement.currentWindowId === record.targetWindowId
|
|
371
|
+
&& marker.details.contextManagement.previousWindowId === record.sourceWindowId;
|
|
372
|
+
const receipt = switched ? freshCheckpoint(branch.slice(0, branch.indexOf(marker!)), record) : undefined;
|
|
373
|
+
if (modelKey(ctx.model) !== record.targetModel && modelKey(ctx.model) !== record.originalModel) {
|
|
374
|
+
this.write("superseded"); this.clearActivity(); return;
|
|
375
|
+
}
|
|
376
|
+
this.write("restoring");
|
|
377
|
+
try {
|
|
378
|
+
if (!(await this.select(record.originalModel, ctx))) throw new Error("selection refused");
|
|
379
|
+
if (!this.current(ctx, generation)) { await this.honorNewerSelection(ctx); return; }
|
|
380
|
+
this.setThinking(record.originalThinking);
|
|
381
|
+
if (modelKey(ctx.model) !== record.originalModel || this.pi.getThinkingLevel() !== record.originalThinking) throw new Error("restoration did not match");
|
|
382
|
+
} catch {
|
|
383
|
+
if (!this.current(ctx, generation)) return;
|
|
384
|
+
this.write("restore-needed");
|
|
385
|
+
this.notify(ctx, `could not restore ${record.originalModel}; no continuation was started. Restore model/auth, then reload or select a model explicitly.`, true);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
this.write(switched && receipt ? "completed" : "failed");
|
|
389
|
+
this.clearActivity();
|
|
390
|
+
this.notify(ctx, switched && receipt ? `new window persisted; restored ${record.originalModel}.` : "checkpoint handoff did not complete; original model restored.", !receipt);
|
|
391
|
+
if (mayResume && receipt) {
|
|
392
|
+
this.pi.sendUserMessage(`Managed /compact completed. Read the persisted checkpoint first with notes action "read_file", path ${JSON.stringify(receipt.path)}, then resume the active user task and any queued user requests in order. Do not checkpoint or call new_context again as part of this handoff.`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Reload/navigation recovers selection only. It never retries inference or writes notes. */
|
|
397
|
+
async recover(ctx: ExtensionContext): Promise<void> {
|
|
398
|
+
const previous = this.busy ? this.record : undefined;
|
|
399
|
+
++this.generation;
|
|
400
|
+
await this.selectionPromise?.catch(() => false);
|
|
401
|
+
this.clearActivity();
|
|
402
|
+
if (previous && ctx.isIdle() && previous.sessionId === ctx.sessionManager.getSessionId()
|
|
403
|
+
&& modelKey(ctx.model) === previous.targetModel && !this.belongs(ctx, previous)) {
|
|
404
|
+
// Tree navigation can leave the owned temporary selection on a sibling
|
|
405
|
+
// without its request anchor. Re-anchor recovery only; never resume work.
|
|
406
|
+
const anchorId = ctx.sessionManager.getLeafId();
|
|
407
|
+
if (anchorId) {
|
|
408
|
+
this.record = { ...previous, operationId: randomUUID(), anchorId, targetWindowId: undefined };
|
|
409
|
+
this.write("restoring");
|
|
410
|
+
await this.finish(ctx, false, false);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
this.record = undefined;
|
|
415
|
+
this.newerSelection = undefined;
|
|
416
|
+
const branch = ctx.sessionManager.getBranch();
|
|
417
|
+
const latest = [...branch].reverse().find((entry) => entry.type === "custom" && entry.customType === MANUAL_COMPACT_ENTRY_TYPE);
|
|
418
|
+
if (!latest || latest.type !== "custom") return;
|
|
419
|
+
const record = decodeManualCompactRecord(latest.data);
|
|
420
|
+
if (!record || TERMINAL.has(record.phase) || record.sessionId !== ctx.sessionManager.getSessionId()) return;
|
|
421
|
+
// A newer explicit model change on the branch wins over the saved operation.
|
|
422
|
+
if (branch.slice(branch.indexOf(latest) + 1).some((entry) => entry.type === "model_change" && `${entry.provider}/${entry.modelId}` !== record.targetModel && `${entry.provider}/${entry.modelId}` !== record.originalModel)) return;
|
|
423
|
+
this.record = record;
|
|
424
|
+
await this.finish(ctx, false, true);
|
|
425
|
+
}
|
|
426
|
+
async shutdown(ctx: ExtensionContext): Promise<void> {
|
|
427
|
+
++this.generation;
|
|
428
|
+
await this.selectionPromise?.catch(() => false);
|
|
429
|
+
if (this.busy) await this.finish(ctx, false, true);
|
|
430
|
+
this.clearActivity();
|
|
431
|
+
this.record = undefined;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
NOTES_ACTION_FIELDS,
|
|
9
9
|
NOTES_ENDPOINTS,
|
|
10
10
|
} from "./history-notes";
|
|
11
|
-
import type { HistoryAction, NotesAction } from "./types";
|
|
11
|
+
import type { ContextWindowIdentity, HistoryAction, NotesAction } from "./types";
|
|
12
12
|
|
|
13
13
|
const EMPTY_PARAMETERS = Type.Object({}, { additionalProperties: false });
|
|
14
14
|
|
|
@@ -98,11 +98,16 @@ export type ContextManagementTools = {
|
|
|
98
98
|
export type ContextOperationPolicy = { active: boolean; gatewayModels: readonly string[] };
|
|
99
99
|
type ContextActivity = (ctx: ExtensionContext) => Promise<boolean | ContextOperationPolicy> | boolean | ContextOperationPolicy;
|
|
100
100
|
|
|
101
|
+
export interface ContextRolloverHandoff {
|
|
102
|
+
prepare(ctx: ExtensionContext): { triggerTurn: false; beforeSchedule: (identity: ContextWindowIdentity) => void } | undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
101
105
|
export function createContextManagementTools(
|
|
102
106
|
pi: ExtensionAPI,
|
|
103
107
|
manager: CodexContextWindowManager,
|
|
104
108
|
isActive: ContextActivity,
|
|
105
109
|
getGatewayModels: () => readonly string[] = () => [],
|
|
110
|
+
handoff?: ContextRolloverHandoff,
|
|
106
111
|
): ContextManagementTools {
|
|
107
112
|
const assertActive = async (ctx: ExtensionContext): Promise<readonly string[]> => {
|
|
108
113
|
const policy = await isActive(ctx);
|
|
@@ -139,11 +144,13 @@ export function createContextManagementTools(
|
|
|
139
144
|
if (!manager.hasNotesCheckpointSinceBoundary(ctx)) {
|
|
140
145
|
throw new Error(NEW_CONTEXT_CHECKPOINT_REQUIRED_MESSAGE);
|
|
141
146
|
}
|
|
147
|
+
const scheduling = handoff?.prepare(ctx);
|
|
142
148
|
const started = await manager.startNewWindow(pi, ctx, {
|
|
143
149
|
triggerTurn: true,
|
|
144
150
|
signal,
|
|
145
151
|
trimPreviousWindow: true,
|
|
146
152
|
gatewayModels,
|
|
153
|
+
...scheduling,
|
|
147
154
|
});
|
|
148
155
|
return {
|
|
149
156
|
content: [{
|
|
@@ -153,6 +160,7 @@ export function createContextManagementTools(
|
|
|
153
160
|
: "A new context window is already scheduled.",
|
|
154
161
|
}],
|
|
155
162
|
details: { started },
|
|
163
|
+
...(started && scheduling ? { terminate: true } : {}),
|
|
156
164
|
};
|
|
157
165
|
},
|
|
158
166
|
};
|
|
@@ -337,9 +345,10 @@ export function registerContextManagementTools(
|
|
|
337
345
|
manager: CodexContextWindowManager,
|
|
338
346
|
isActive: ContextActivity,
|
|
339
347
|
getGatewayModels?: () => readonly string[],
|
|
348
|
+
handoff?: ContextRolloverHandoff,
|
|
340
349
|
): ContextManagementToolController {
|
|
341
350
|
const controller = new ContextManagementToolController(pi);
|
|
342
|
-
controller.register(createContextManagementTools(pi, manager, isActive, getGatewayModels));
|
|
351
|
+
controller.register(createContextManagementTools(pi, manager, isActive, getGatewayModels, handoff));
|
|
343
352
|
return controller;
|
|
344
353
|
}
|
|
345
354
|
|
|
@@ -48,6 +48,8 @@ interface StartContextWindowOptions {
|
|
|
48
48
|
trimPreviousWindow: boolean;
|
|
49
49
|
/** Captured with the tool activation policy, before any awaited backend work. */
|
|
50
50
|
gatewayModels?: readonly string[];
|
|
51
|
+
/** Persist an owned handoff's exact target before the marker can be queued. */
|
|
52
|
+
beforeSchedule?: (identity: ContextWindowIdentity) => void;
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
type ThreadHintLoader = (
|
|
@@ -75,6 +77,8 @@ export class CodexContextWindowManager {
|
|
|
75
77
|
private identity: ContextWindowIdentity | undefined;
|
|
76
78
|
private sessionId: string | undefined;
|
|
77
79
|
private restoredMarkerId: string | undefined;
|
|
80
|
+
private restoredCompactionId: string | undefined;
|
|
81
|
+
private branchStateInvalidated = false;
|
|
78
82
|
private readonly budget = new ContextWindowBudget();
|
|
79
83
|
private pendingRollover: PendingRollover | undefined;
|
|
80
84
|
private trimPendingWindowId: string | undefined;
|
|
@@ -141,6 +145,8 @@ export class CodexContextWindowManager {
|
|
|
141
145
|
this.identity = undefined;
|
|
142
146
|
this.sessionId = undefined;
|
|
143
147
|
this.restoredMarkerId = undefined;
|
|
148
|
+
this.restoredCompactionId = undefined;
|
|
149
|
+
this.branchStateInvalidated = false;
|
|
144
150
|
this.budget.reset();
|
|
145
151
|
this.trimPendingWindowId = undefined;
|
|
146
152
|
}
|
|
@@ -152,6 +158,7 @@ export class CodexContextWindowManager {
|
|
|
152
158
|
this.sessionId = sessionId;
|
|
153
159
|
for (const entry of entries) {
|
|
154
160
|
if (entry.type === "compaction") {
|
|
161
|
+
this.restoredCompactionId = entry.id;
|
|
155
162
|
this.recordCompaction(entry.details);
|
|
156
163
|
continue;
|
|
157
164
|
}
|
|
@@ -169,6 +176,9 @@ export class CodexContextWindowManager {
|
|
|
169
176
|
}
|
|
170
177
|
this.budget.restore(details.kind, details.currentWindowId);
|
|
171
178
|
}
|
|
179
|
+
// recordCompaction may invalidate the cache while replaying entries; the
|
|
180
|
+
// completed replay already reconciled those acknowledgments with this branch.
|
|
181
|
+
this.branchStateInvalidated = false;
|
|
172
182
|
this.retireSatisfiedOrStalePending(entries, sessionId);
|
|
173
183
|
}
|
|
174
184
|
|
|
@@ -178,7 +188,16 @@ export class CodexContextWindowManager {
|
|
|
178
188
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
179
189
|
this.retireSatisfiedOrStalePending(entries, sessionId);
|
|
180
190
|
const latestMarkerId = findLatestContextMarkerId(entries, sessionId);
|
|
181
|
-
|
|
191
|
+
let latestCompactionId: string | undefined;
|
|
192
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
193
|
+
if (entries[index]!.type === "compaction") {
|
|
194
|
+
latestCompactionId = entries[index]!.id;
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// Hook-provided compactions may have no success callback. The persisted
|
|
199
|
+
// branch also detects navigation before/after a commit with the same marker.
|
|
200
|
+
if (this.branchStateInvalidated || this.sessionId !== sessionId || this.restoredMarkerId !== latestMarkerId || this.restoredCompactionId !== latestCompactionId) {
|
|
182
201
|
this.restore(entries, sessionId);
|
|
183
202
|
}
|
|
184
203
|
}
|
|
@@ -321,6 +340,7 @@ export class CodexContextWindowManager {
|
|
|
321
340
|
}
|
|
322
341
|
}
|
|
323
342
|
if (options.signal?.aborted) throw new Error("Remote context rollover was aborted");
|
|
343
|
+
options.beforeSchedule?.(next);
|
|
324
344
|
this.sendWindowMessage(pi, ctx, next, options, threadHint, checkpoint);
|
|
325
345
|
return true;
|
|
326
346
|
} catch (error) {
|
|
@@ -415,17 +435,20 @@ export class CodexContextWindowManager {
|
|
|
415
435
|
return { cancel: true };
|
|
416
436
|
}
|
|
417
437
|
const compaction = this.createCompaction(event);
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
// endless run of no-op boundaries for the same window.
|
|
422
|
-
this.trimPendingWindowId = undefined;
|
|
438
|
+
// Preparation is only a proposal: Pi may abort before appending it.
|
|
439
|
+
// synchronize() consumes the trim after observing a durable compaction;
|
|
440
|
+
// recordCompaction() also handles hosts that emit a success callback.
|
|
423
441
|
return { compaction };
|
|
424
442
|
}
|
|
425
443
|
|
|
426
444
|
recordCompaction(details: unknown): void {
|
|
427
445
|
if (!isContextWindowCompactionDetails(details)) return;
|
|
428
|
-
if (details.windowId === this.trimPendingWindowId)
|
|
446
|
+
if (this.trimPendingWindowId !== undefined && details.windowId === this.trimPendingWindowId) {
|
|
447
|
+
this.trimPendingWindowId = undefined;
|
|
448
|
+
// The callback supplies no branch identity. Force durable reconciliation
|
|
449
|
+
// even if navigation returns to the cached pre-commit branch before sync.
|
|
450
|
+
this.branchStateInvalidated = true;
|
|
451
|
+
}
|
|
429
452
|
}
|
|
430
453
|
|
|
431
454
|
createCompaction(event: SessionBeforeCompactEvent): CompactionResult<ContextWindowCompactionDetails> {
|
|
@@ -554,9 +577,12 @@ export function hasAssistantUsageSinceWindowBoundary(
|
|
|
554
577
|
export function findLatestNotesCheckpointSinceBoundary(
|
|
555
578
|
entries: readonly SessionEntry[],
|
|
556
579
|
sessionId?: string,
|
|
580
|
+
afterEntryId?: string,
|
|
557
581
|
): NotesCheckpointReceipt | undefined {
|
|
558
|
-
const
|
|
559
|
-
|
|
582
|
+
const windowBoundaryIndex = findLatestWindowBoundaryIndex(entries, sessionId);
|
|
583
|
+
const afterIndex = afterEntryId === undefined ? -1 : entries.findIndex((entry) => entry.id === afterEntryId);
|
|
584
|
+
if (windowBoundaryIndex < 0 || (afterEntryId !== undefined && afterIndex < 0)) return undefined;
|
|
585
|
+
const boundaryIndex = Math.max(windowBoundaryIndex, afterIndex);
|
|
560
586
|
const checkpointCalls = new Map<string, NotesCheckpointReceipt>();
|
|
561
587
|
let latest: NotesCheckpointReceipt | undefined;
|
|
562
588
|
for (let index = boundaryIndex + 1; index < entries.length; index += 1) {
|