pi-openai-toolkit 0.23.0 → 0.23.2
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 +6 -2
- package/README.zh.md +6 -2
- package/docs/configuration.md +14 -6
- package/docs/internals.md +17 -14
- package/package.json +3 -1
- package/src/context-management/manual-compact.ts +23 -375
- package/src/context-management/responses-tools.ts +238 -0
- package/src/context-management/scoped-compaction.ts +89 -0
- package/src/context-management/window-manager.ts +8 -1
- package/src/extension-runtime.ts +113 -180
- package/src/native-fallback.ts +32 -29
|
@@ -1,20 +1,16 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { isDeepStrictEqual } from "node:util";
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
3
2
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
4
|
-
import
|
|
5
|
-
import { resolveToolkitConfig, type ResolvedToolkitConfig } from "../config";
|
|
3
|
+
import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
6
4
|
import { parseModelSpec } from "../runtime";
|
|
7
|
-
import { THINKING_LEVELS
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import { isNonEmptyString, isRecord, type CodexContextProvider, type ContextWindowIdentity } from "./types";
|
|
5
|
+
import { THINKING_LEVELS } from "../types";
|
|
6
|
+
import { findLatestNotesCheckpointSinceBoundary, findLatestWindowBoundaryEntry } from "./window-manager";
|
|
7
|
+
import { isNonEmptyString, isRecord } from "./types";
|
|
11
8
|
import type { ContextRolloverHandoff } from "./tools";
|
|
12
9
|
|
|
13
10
|
export const MANUAL_COMPACT_ENTRY_TYPE = "pi-openai-toolkit:manual-compact";
|
|
14
11
|
const PHASES = ["requested", "switching", "checkpointing", "rollover-scheduled", "restoring", "restore-needed", "completed", "failed", "cancelled", "superseded"] as const;
|
|
15
12
|
type Phase = typeof PHASES[number];
|
|
16
13
|
const TERMINAL = new Set<Phase>(["completed", "failed", "cancelled", "superseded"]);
|
|
17
|
-
const REQUIRED_TOOLS = ["notes", "history", "new_context", "get_context_remaining"];
|
|
18
14
|
|
|
19
15
|
/** Non-LLM recovery state. No auth, model object, transcript or note text belongs here. */
|
|
20
16
|
export type ManualCompactRecord = {
|
|
@@ -41,27 +37,6 @@ export function decodeManualCompactRecord(value: unknown): ManualCompactRecord |
|
|
|
41
37
|
return value as ManualCompactRecord;
|
|
42
38
|
}
|
|
43
39
|
|
|
44
|
-
/** Same gateway credential/affinity domain, or the same native backend/account. Never log this comparison. */
|
|
45
|
-
export function sameContextScope(left: CodexContextProvider, right: CodexContextProvider): boolean {
|
|
46
|
-
if (left.kind !== right.kind || left.baseUrl !== right.baseUrl || left.provider !== right.provider) return false;
|
|
47
|
-
if (left.kind === "native-codex" && right.kind === "native-codex") return left.accountId === right.accountId;
|
|
48
|
-
return left.kind === "codex-gateway" && right.kind === "codex-gateway" && left.apiKey === right.apiKey
|
|
49
|
-
&& JSON.stringify(Object.entries(left.headers).sort()) === JSON.stringify(Object.entries(right.headers).sort());
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
async function preflightDeadline<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
53
|
-
let onAbort!: () => void;
|
|
54
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
55
|
-
const cancelled = new Promise<never>((_resolve, reject) => {
|
|
56
|
-
onAbort = () => reject(new Error("cancelled before switching models"));
|
|
57
|
-
if (signal.aborted) onAbort();
|
|
58
|
-
else signal.addEventListener("abort", onAbort, { once: true });
|
|
59
|
-
timer = setTimeout(() => reject(new Error("checkpoint-model authentication preflight timed out")), 10_000);
|
|
60
|
-
});
|
|
61
|
-
try { return await Promise.race([work, cancelled]); }
|
|
62
|
-
finally { clearTimeout(timer); signal.removeEventListener("abort", onAbort); }
|
|
63
|
-
}
|
|
64
|
-
|
|
65
40
|
function modelKey(model: ExtensionContext["model"]): string | undefined {
|
|
66
41
|
return model && `${model.provider}/${model.id}`;
|
|
67
42
|
}
|
|
@@ -80,203 +55,19 @@ function freshCheckpoint(entries: readonly SessionEntry[], record: ManualCompact
|
|
|
80
55
|
return findLatestNotesCheckpointSinceBoundary(entries, record.sessionId, afterId);
|
|
81
56
|
}
|
|
82
57
|
|
|
83
|
-
|
|
84
|
-
return createHash("sha256").update(JSON.stringify(entries)).digest("hex");
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export type RemoteManualCompactOperation = {
|
|
88
|
-
generation: number;
|
|
89
|
-
sessionId: string;
|
|
90
|
-
sessionFile: string | undefined;
|
|
91
|
-
anchorId: string;
|
|
92
|
-
branchLength: number;
|
|
93
|
-
branchFingerprint: string;
|
|
94
|
-
windowId: string;
|
|
95
|
-
model: string;
|
|
96
|
-
api: string;
|
|
97
|
-
baseUrl: string;
|
|
98
|
-
thinking: ThinkingLevel;
|
|
99
|
-
controller: AbortController;
|
|
100
|
-
signal: AbortSignal;
|
|
101
|
-
removeAbortListeners: () => void;
|
|
102
|
-
policy: ResolvedToolkitConfig;
|
|
103
|
-
candidate?: CompactionResult<NativeCompactionDetails>;
|
|
104
|
-
persistedId?: string;
|
|
105
|
-
deadline?: number;
|
|
106
|
-
};
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* One transient Remote V2 operation. It never selects a model, writes recovery
|
|
110
|
-
* records, takes host queues, or resumes from reload. Legacy duty recovery below
|
|
111
|
-
* remains independent.
|
|
112
|
-
*/
|
|
113
|
-
export class RemoteManualCompact {
|
|
114
|
-
private generation = 0;
|
|
115
|
-
private operation: RemoteManualCompactOperation | undefined;
|
|
116
|
-
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
117
|
-
|
|
118
|
-
constructor(private readonly pi: ExtensionAPI, private readonly windows: CodexContextWindowManager,
|
|
119
|
-
private readonly idleTimeoutMs = 10_000) {}
|
|
120
|
-
|
|
121
|
-
get snapshot() { return this.operation?.policy.snapshot; }
|
|
122
|
-
|
|
123
|
-
invalidate(): void {
|
|
124
|
-
++this.generation;
|
|
125
|
-
clearTimeout(this.timer);
|
|
126
|
-
this.timer = undefined;
|
|
127
|
-
const operation = this.operation;
|
|
128
|
-
this.operation = undefined;
|
|
129
|
-
operation?.removeAbortListeners();
|
|
130
|
-
operation?.controller.abort();
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
cancel(operation: RemoteManualCompactOperation | undefined): void {
|
|
134
|
-
if (operation && this.operation === operation) this.invalidate();
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
begin(event: SessionBeforeCompactEvent, ctx: ExtensionContext, policy: ResolvedToolkitConfig): RemoteManualCompactOperation | undefined {
|
|
138
|
-
this.invalidate();
|
|
139
|
-
const model = ctx.model;
|
|
140
|
-
const anchorId = ctx.sessionManager.getLeafId();
|
|
141
|
-
const windowId = this.windows.currentIdentity()?.currentWindowId;
|
|
142
|
-
if (!model || !anchorId || !windowId || event.signal.aborted) return undefined;
|
|
143
|
-
const branch = ctx.sessionManager.getBranch();
|
|
144
|
-
if (!isDeepStrictEqual(event.branchEntries, branch)) return undefined;
|
|
145
|
-
const controller = new AbortController();
|
|
146
|
-
const onAbort = () => { if (this.operation === operation) this.invalidate(); };
|
|
147
|
-
// Manual compact has already aborted the preceding agent run. Its old,
|
|
148
|
-
// aborted ctx.signal is not cancellation of this newly initiated operation.
|
|
149
|
-
const signals = [event.signal, ...(ctx.signal && !ctx.signal.aborted && ctx.signal !== event.signal ? [ctx.signal] : [])];
|
|
150
|
-
const operation: RemoteManualCompactOperation = {
|
|
151
|
-
generation: this.generation, sessionId: ctx.sessionManager.getSessionId(),
|
|
152
|
-
sessionFile: ctx.sessionManager.getSessionFile(), anchorId,
|
|
153
|
-
branchLength: branch.length, branchFingerprint: branchFingerprint(branch), windowId,
|
|
154
|
-
model: modelKey(model)!, api: model.api, baseUrl: model.baseUrl, thinking: this.pi.getThinkingLevel(),
|
|
155
|
-
controller, signal: controller.signal, policy,
|
|
156
|
-
removeAbortListeners: () => { for (const signal of signals) signal.removeEventListener("abort", onAbort); },
|
|
157
|
-
};
|
|
158
|
-
this.operation = operation;
|
|
159
|
-
for (const signal of signals) signal.addEventListener("abort", onAbort, { once: true });
|
|
160
|
-
return operation;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
private owns(operation: RemoteManualCompactOperation, ctx: ExtensionContext): boolean {
|
|
164
|
-
if (this.operation !== operation || operation.generation !== this.generation || operation.signal.aborted
|
|
165
|
-
|| ctx.sessionManager.getSessionId() !== operation.sessionId || ctx.sessionManager.getSessionFile() !== operation.sessionFile
|
|
166
|
-
|| modelKey(ctx.model) !== operation.model || ctx.model?.api !== operation.api || ctx.model?.baseUrl !== operation.baseUrl
|
|
167
|
-
|| this.pi.getThinkingLevel() !== operation.thinking || ctx.hasPendingMessages()) return false;
|
|
168
|
-
this.windows.synchronize(ctx);
|
|
169
|
-
return this.windows.currentIdentity()?.currentWindowId === operation.windowId && !this.windows.hasPendingRollover(ctx);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
current(operation: RemoteManualCompactOperation, ctx: ExtensionContext): boolean {
|
|
173
|
-
try {
|
|
174
|
-
const branch = ctx.sessionManager.getBranch();
|
|
175
|
-
return this.owns(operation, ctx) && ctx.sessionManager.getLeafId() === operation.anchorId
|
|
176
|
-
&& branch.length === operation.branchLength && branchFingerprint(branch) === operation.branchFingerprint;
|
|
177
|
-
} catch { return false; }
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
arm(
|
|
181
|
-
operation: RemoteManualCompactOperation,
|
|
182
|
-
ctx: ExtensionContext,
|
|
183
|
-
candidate: CompactionResult<NativeCompactionDetails>,
|
|
184
|
-
): { readonly cancel: boolean; compaction: CompactionResult<NativeCompactionDetails> } | undefined {
|
|
185
|
-
if (!this.current(operation, ctx)) return undefined;
|
|
186
|
-
operation.candidate = structuredClone(candidate);
|
|
187
|
-
const owner = this;
|
|
188
|
-
return {
|
|
189
|
-
compaction: candidate,
|
|
190
|
-
// Pi reads this public result again after later before-compact observers.
|
|
191
|
-
// Revalidate at consumption, not just at return: newer work must revoke
|
|
192
|
-
// the candidate before persistence, without ctx.abort() draining TUI queues.
|
|
193
|
-
get cancel() { return !owner.current(operation, ctx); },
|
|
194
|
-
};
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
private durableCandidate(operation: RemoteManualCompactOperation, ctx: ExtensionContext): string | undefined {
|
|
198
|
-
if (!this.owns(operation, ctx) || !operation.candidate) return undefined;
|
|
199
|
-
const branch = ctx.sessionManager.getBranch();
|
|
200
|
-
// The event's ID can refer to an OLD equal-summary checkpoint in Pi 1.0.
|
|
201
|
-
// Only the new actual branch child and its complete candidate are proof.
|
|
202
|
-
if (branch.length !== operation.branchLength + 1
|
|
203
|
-
|| branchFingerprint(branch.slice(0, operation.branchLength)) !== operation.branchFingerprint) return undefined;
|
|
204
|
-
const entry = branch[operation.branchLength];
|
|
205
|
-
const candidate = operation.candidate;
|
|
206
|
-
if (!entry || entry.type !== "compaction" || entry.parentId !== operation.anchorId
|
|
207
|
-
|| entry.id === operation.anchorId || ctx.sessionManager.getLeafId() !== entry.id || entry.fromHook !== true
|
|
208
|
-
|| entry.summary !== candidate.summary || entry.firstKeptEntryId !== candidate.firstKeptEntryId
|
|
209
|
-
|| entry.tokensBefore !== candidate.tokensBefore
|
|
210
|
-
// Persistence omits undefined optional fields; compare the actual JSON
|
|
211
|
-
// data, not object identity, summary text, or provider response ID alone.
|
|
212
|
-
|| !isDeepStrictEqual(JSON.parse(JSON.stringify(entry.details)), JSON.parse(JSON.stringify(candidate.details)))) return undefined;
|
|
213
|
-
return entry.id;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
private requestManualResume(ctx: ExtensionContext, message = "Remote /compact saved its checkpoint; resume manually."): void {
|
|
217
|
-
try { if (ctx.hasUI) ctx.ui.notify(message, "warning"); } catch { /* stale UI must not escape a deferred callback */ }
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/** Observer only: never await idle or submit a prompt inside session_compact. */
|
|
221
|
-
persisted(ctx: ExtensionContext): void {
|
|
222
|
-
const operation = this.operation;
|
|
223
|
-
if (!operation?.candidate || operation.persistedId) return;
|
|
224
|
-
try { operation.persistedId = this.durableCandidate(operation, ctx); } catch { /* fail closed */ }
|
|
225
|
-
if (!operation.persistedId) {
|
|
226
|
-
this.invalidate();
|
|
227
|
-
this.requestManualResume(ctx, "Remote /compact could not verify its exact saved checkpoint; resume manually.");
|
|
228
|
-
return;
|
|
229
|
-
}
|
|
230
|
-
operation.deadline = Date.now() + this.idleTimeoutMs;
|
|
231
|
-
this.schedule(operation, ctx, 0);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
private schedule(operation: RemoteManualCompactOperation, ctx: ExtensionContext, delay: number): void {
|
|
235
|
-
this.timer = setTimeout(() => {
|
|
236
|
-
this.timer = undefined;
|
|
237
|
-
if (this.operation !== operation) return;
|
|
238
|
-
try {
|
|
239
|
-
if (this.durableCandidate(operation, ctx) !== operation.persistedId) { this.invalidate(); return; }
|
|
240
|
-
if (Date.now() >= operation.deadline!) {
|
|
241
|
-
this.invalidate();
|
|
242
|
-
this.requestManualResume(ctx, "Remote /compact saved its checkpoint; automatic continuation could not safely reach idle. Resume manually.");
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
if (!ctx.isIdle()) { this.schedule(operation, ctx, 25); return; }
|
|
246
|
-
// No await between final public ownership/queue/idle proof and send.
|
|
247
|
-
// Disarm first, so our own input/run events cannot invalidate a newer operation.
|
|
248
|
-
this.invalidate();
|
|
249
|
-
this.pi.sendUserMessage("Context compressed. Resume the active user task.");
|
|
250
|
-
} catch {
|
|
251
|
-
this.invalidate();
|
|
252
|
-
this.requestManualResume(ctx);
|
|
253
|
-
}
|
|
254
|
-
}, delay);
|
|
255
|
-
this.timer.unref?.();
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/** Owns only legacy managed handoffs, not normal window rollovers or Remote V2. */
|
|
58
|
+
/** Restore already-persisted legacy selections only; never start duty or inference. */
|
|
260
59
|
export class ManagedManualCompact implements ContextRolloverHandoff {
|
|
261
60
|
private record: ManualCompactRecord | undefined;
|
|
262
61
|
private generation = 0;
|
|
263
|
-
private sourcePolicy: ResolvedToolkitConfig | undefined;
|
|
264
|
-
private instructions = "";
|
|
265
|
-
private attemptSignal: AbortSignal | undefined;
|
|
266
62
|
private selecting: string | undefined;
|
|
267
63
|
private selectionPromise: Promise<boolean> | undefined;
|
|
268
64
|
private expectedThinking: ThinkingLevel | undefined;
|
|
269
65
|
private newerSelection: { model: NonNullable<ExtensionContext["model"]>; thinking: ThinkingLevel } | undefined;
|
|
270
|
-
private deliveryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
271
|
-
private delivered = false;
|
|
272
|
-
private resumeAllowed = true;
|
|
273
|
-
private checkpointStopped = false;
|
|
274
66
|
private maintenance = 0;
|
|
275
67
|
|
|
276
|
-
constructor(private readonly pi: ExtensionAPI
|
|
68
|
+
constructor(private readonly pi: ExtensionAPI) {}
|
|
277
69
|
|
|
278
70
|
get busy(): boolean { return !!this.record && !TERMINAL.has(this.record.phase); }
|
|
279
|
-
get snapshot() { return this.busy ? this.sourcePolicy?.snapshot : undefined; }
|
|
280
71
|
get isMaintenance(): boolean { return this.maintenance > 0; }
|
|
281
72
|
beginMaintenance(): () => void {
|
|
282
73
|
this.maintenance++;
|
|
@@ -294,11 +85,6 @@ export class ManagedManualCompact implements ContextRolloverHandoff {
|
|
|
294
85
|
this.record = { ...this.record, phase };
|
|
295
86
|
this.pi.appendEntry(MANUAL_COMPACT_ENTRY_TYPE, this.record);
|
|
296
87
|
}
|
|
297
|
-
private clearActivity(): void {
|
|
298
|
-
clearTimeout(this.deliveryTimer);
|
|
299
|
-
this.deliveryTimer = undefined;
|
|
300
|
-
this.sourcePolicy = undefined;
|
|
301
|
-
}
|
|
302
88
|
private belongs(ctx: ExtensionContext, record = this.record): boolean {
|
|
303
89
|
if (!record || ctx.sessionManager.getSessionId() !== record.sessionId) return false;
|
|
304
90
|
const branch = ctx.sessionManager.getBranch();
|
|
@@ -307,144 +93,16 @@ export class ManagedManualCompact implements ContextRolloverHandoff {
|
|
|
307
93
|
private current(ctx: ExtensionContext, generation: number): boolean {
|
|
308
94
|
return generation === this.generation && this.busy && this.belongs(ctx);
|
|
309
95
|
}
|
|
310
|
-
private toolsAvailable(): boolean {
|
|
311
|
-
const active = new Set(this.pi.getActiveTools());
|
|
312
|
-
const published = new Set(this.pi.getAllTools().map((tool) => tool.name));
|
|
313
|
-
return REQUIRED_TOOLS.every((tool) => active.has(tool) && published.has(tool));
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
async request(event: SessionBeforeCompactEvent, ctx: ExtensionContext, source: ResolvedToolkitConfig): Promise<void> {
|
|
317
|
-
if (this.busy) { this.notify(ctx, "a handoff is already active; no second window was scheduled."); return; }
|
|
318
|
-
this.windows.synchronize(ctx);
|
|
319
|
-
const boundary = findLatestWindowBoundaryEntry(ctx.sessionManager.getBranch(), ctx.sessionManager.getSessionId());
|
|
320
|
-
if (!ctx.model || !boundary || event.signal.aborted || !this.toolsAvailable()) {
|
|
321
|
-
this.notify(ctx, "requires a live managed window and all four permitted context tools.", true); return;
|
|
322
|
-
}
|
|
323
|
-
if (this.windows.hasPendingRollover(ctx) || !this.windows.canRolloverFromCurrentWindow(ctx)) {
|
|
324
|
-
this.notify(ctx, "this window is already switching or has not produced substantive tool activity since its last switch.", true); return;
|
|
325
|
-
}
|
|
326
|
-
const targetKey = source.config.compaction.remoteCompactModel ?? modelKey(ctx.model)!;
|
|
327
|
-
const spec = parseModelSpec(targetKey);
|
|
328
|
-
const target = spec && ctx.modelRegistry.find(spec.provider, spec.modelId);
|
|
329
|
-
if (!target) { this.notify(ctx, `configured checkpoint model ${targetKey} is unavailable.`, true); return; }
|
|
330
|
-
const targetPolicy = resolveToolkitConfig(source.snapshot, target);
|
|
331
|
-
if (targetPolicy.invalidFeatures.some((feature) => ["context", "compatibility", "diagnostics"].includes(feature))
|
|
332
|
-
|| !targetPolicy.config.compaction.enabled || targetPolicy.config.compaction.contextManagement !== "remote") {
|
|
333
|
-
this.notify(ctx, "the checkpoint model must have valid Remote Context policy.", true); return;
|
|
334
|
-
}
|
|
335
|
-
const projected = this.windows.project(buildSessionContext(ctx.sessionManager.getBranch()).messages, "remote");
|
|
336
|
-
const tokens = projected.reduce((total, message) => total + estimateTokens(message), 0);
|
|
337
|
-
const guidanceTokens = Math.ceil((event.customInstructions?.length ?? 0) / 4);
|
|
338
|
-
if (!Number.isFinite(target.contextWindow) || !Number.isFinite(target.maxTokens) || target.maxTokens <= 0
|
|
339
|
-
|| tokens + target.maxTokens + guidanceTokens + 2048 >= target.contextWindow) {
|
|
340
|
-
this.notify(ctx, "the checkpoint model cannot fit this window plus checkpoint output; no history was truncated.", true); return;
|
|
341
|
-
}
|
|
342
|
-
this.newerSelection = undefined;
|
|
343
|
-
this.delivered = false;
|
|
344
|
-
this.resumeAllowed = true;
|
|
345
|
-
this.checkpointStopped = false;
|
|
346
|
-
this.sourcePolicy = source;
|
|
347
|
-
this.attemptSignal = event.signal;
|
|
348
|
-
this.instructions = event.customInstructions?.trim() ?? "";
|
|
349
|
-
this.record = {
|
|
350
|
-
version: 1, operationId: randomUUID(), sessionId: ctx.sessionManager.getSessionId(),
|
|
351
|
-
sourceWindowId: boundary.details.contextManagement.currentWindowId,
|
|
352
|
-
anchorId: ctx.sessionManager.getLeafId() ?? boundary.id,
|
|
353
|
-
originalModel: modelKey(ctx.model)!, targetModel: targetKey,
|
|
354
|
-
originalThinking: this.pi.getThinkingLevel(), phase: "requested",
|
|
355
|
-
};
|
|
356
|
-
this.write("requested");
|
|
357
|
-
const generation = ++this.generation;
|
|
358
|
-
try {
|
|
359
|
-
const [originalProvider, targetProvider] = await preflightDeadline(Promise.all([
|
|
360
|
-
resolveCodexContextProvider(ctx, ctx.model, source.gatewayModelKeys),
|
|
361
|
-
resolveCodexContextProvider(ctx, target, targetPolicy.gatewayModelKeys),
|
|
362
|
-
]), event.signal);
|
|
363
|
-
if (!this.current(ctx, generation)) return;
|
|
364
|
-
if (event.signal.aborted) throw new Error("cancelled before switching models");
|
|
365
|
-
if (!originalProvider.ok || !targetProvider.ok || !sameContextScope(originalProvider.provider, targetProvider.provider)) {
|
|
366
|
-
throw new Error("checkpoint model must authenticate to the same context backend/account");
|
|
367
|
-
}
|
|
368
|
-
this.notify(ctx, `checkpoint handoff requested (${this.record!.originalModel} → ${targetKey}); native compaction is intentionally cancelled.`);
|
|
369
|
-
} catch (error) {
|
|
370
|
-
if (!this.current(ctx, generation)) return;
|
|
371
|
-
this.write(event.signal.aborted ? "cancelled" : "failed"); this.clearActivity();
|
|
372
|
-
this.notify(ctx, error instanceof Error ? error.message : "preflight failed", true);
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
/** Called only after the native manual operation has exposed idle state. */
|
|
377
|
-
async compactFailed(ctx: ExtensionContext): Promise<void> {
|
|
378
|
-
if (this.record?.phase !== "requested") return;
|
|
379
|
-
if (!ctx.isIdle() || this.attemptSignal?.aborted) {
|
|
380
|
-
this.write("cancelled"); this.clearActivity(); this.notify(ctx, "cancelled before checkpoint duty began.", true); return;
|
|
381
|
-
}
|
|
382
|
-
const generation = this.generation;
|
|
383
|
-
// Fence a second terminal callback while public selection awaits auth.
|
|
384
|
-
this.write("switching");
|
|
385
|
-
try {
|
|
386
|
-
if (!this.belongs(ctx) || !this.toolsAvailable()) throw new Error("session or context-tool availability changed");
|
|
387
|
-
if (!(await this.select(this.record.targetModel, ctx))) throw new Error("checkpoint model selection failed");
|
|
388
|
-
if (!this.current(ctx, generation)) { await this.honorNewerSelection(ctx); return; }
|
|
389
|
-
if (this.attemptSignal?.aborted) throw new Error("cancelled during model selection");
|
|
390
|
-
if (!this.toolsAvailable()) throw new Error("required context tools became unavailable");
|
|
391
|
-
this.write("checkpointing");
|
|
392
|
-
const prompt = `[Managed /compact ${this.record.operationId}]\n`
|
|
393
|
-
+ "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. "
|
|
394
|
-
+ "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. "
|
|
395
|
-
+ "After scheduling the window, stop; the extension will restore the original model before resuming. No conversation summary carries over."
|
|
396
|
-
+ (this.instructions ? `\nAdditional user guidance for this checkpoint: ${this.instructions}` : "");
|
|
397
|
-
this.deliveryTimer = setTimeout(() => {
|
|
398
|
-
if (this.current(ctx, generation) && !this.delivered && ctx.isIdle()) void this.finish(ctx, false, false);
|
|
399
|
-
}, this.deliveryTimeoutMs);
|
|
400
|
-
this.deliveryTimer.unref?.();
|
|
401
|
-
this.pi.sendUserMessage(prompt);
|
|
402
|
-
} catch {
|
|
403
|
-
if (this.current(ctx, generation)) await this.finish(ctx, false, false);
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
96
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
this.delivered = true;
|
|
412
|
-
clearTimeout(this.deliveryTimer);
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
prepare(ctx: ExtensionContext): { triggerTurn: false; beforeSchedule: (identity: ContextWindowIdentity) => void } | undefined {
|
|
416
|
-
if (!this.busy) return undefined;
|
|
417
|
-
const record = this.record!;
|
|
418
|
-
if (!this.belongs(ctx) || record.phase !== "checkpointing" || !this.delivered
|
|
419
|
-
|| modelKey(ctx.model) !== record.targetModel || this.windows.currentIdentity()?.currentWindowId !== record.sourceWindowId) {
|
|
420
|
-
throw new Error("Managed /compact is not ready for a rollover; do not retry while restoration is pending.");
|
|
421
|
-
}
|
|
422
|
-
if (!freshCheckpoint(ctx.sessionManager.getBranch(), record)) {
|
|
423
|
-
throw new Error("Managed /compact requires a new persisted successful notes write/append after this operation and the latest delivered user message.");
|
|
424
|
-
}
|
|
425
|
-
const generation = this.generation;
|
|
426
|
-
return {
|
|
427
|
-
triggerTurn: false,
|
|
428
|
-
beforeSchedule: (identity) => {
|
|
429
|
-
const boundary = findLatestWindowBoundaryEntry(ctx.sessionManager.getBranch(), record.sessionId);
|
|
430
|
-
if (!this.current(ctx, generation) || modelKey(ctx.model) !== record.targetModel
|
|
431
|
-
|| boundary?.details.contextManagement.currentWindowId !== record.sourceWindowId
|
|
432
|
-
|| !freshCheckpoint(ctx.sessionManager.getBranch(), record)) throw new Error("Managed /compact was interrupted before rollover.");
|
|
433
|
-
this.record = { ...record, targetWindowId: identity.currentWindowId };
|
|
434
|
-
this.write("rollover-scheduled");
|
|
435
|
-
},
|
|
436
|
-
};
|
|
97
|
+
/** Legacy recovery may block a rollover, but never initiate a handoff. */
|
|
98
|
+
prepare(_ctx: ExtensionContext): undefined {
|
|
99
|
+
if (this.busy) throw new Error("Legacy /compact selection recovery is pending; wait for restoration.");
|
|
100
|
+
return undefined;
|
|
437
101
|
}
|
|
438
102
|
|
|
439
103
|
/** Abort without waiting: a queued/mixed continuation must not send the captured temporary model. */
|
|
440
104
|
turnEnded(ctx: ExtensionContext): void {
|
|
441
|
-
if (this.busy
|
|
442
|
-
if (!this.checkpointStopped) {
|
|
443
|
-
this.resumeAllowed = !ctx.signal?.aborted;
|
|
444
|
-
this.checkpointStopped = true;
|
|
445
|
-
}
|
|
446
|
-
ctx.abort();
|
|
447
|
-
}
|
|
105
|
+
if (this.busy) ctx.abort();
|
|
448
106
|
}
|
|
449
107
|
guardRequest(ctx: ExtensionContext): void {
|
|
450
108
|
if (ctx.signal?.aborted) return;
|
|
@@ -453,15 +111,12 @@ export class ManagedManualCompact implements ContextRolloverHandoff {
|
|
|
453
111
|
throw new Error("Managed /compact is preserving your newer model selection; this provider request was cancelled.");
|
|
454
112
|
}
|
|
455
113
|
if (!this.busy) return;
|
|
456
|
-
if (this.record?.phase === "checkpointing" && this.belongs(ctx) && this.toolsAvailable() && modelKey(ctx.model) === this.record.targetModel
|
|
457
|
-
&& this.windows.currentIdentity()?.currentWindowId === this.record.sourceWindowId) return;
|
|
458
114
|
ctx.abort();
|
|
459
115
|
throw new Error("Managed /compact is restoring the original model; this provider request was cancelled.");
|
|
460
116
|
}
|
|
461
|
-
guardTool(
|
|
117
|
+
guardTool(_name: string): { block: true; terminate: true; reason: string } | undefined {
|
|
462
118
|
if (!this.busy && !this.selectionPromise) return;
|
|
463
|
-
|
|
464
|
-
return { block: true, terminate: true, reason: "Managed /compact owns this checkpoint-only turn. Wait for the original-model continuation before ordinary work." };
|
|
119
|
+
return { block: true, terminate: true, reason: "Legacy /compact selection recovery is pending. Wait for restoration before ordinary work." };
|
|
465
120
|
}
|
|
466
121
|
|
|
467
122
|
modelSelected(model: NonNullable<ExtensionContext["model"]>, ctx: ExtensionContext): void {
|
|
@@ -469,7 +124,6 @@ export class ManagedManualCompact implements ContextRolloverHandoff {
|
|
|
469
124
|
this.newerSelection = { model, thinking: this.pi.getThinkingLevel() };
|
|
470
125
|
++this.generation;
|
|
471
126
|
if (this.belongs(ctx)) this.write("superseded");
|
|
472
|
-
this.clearActivity();
|
|
473
127
|
if (!ctx.isIdle()) ctx.abort();
|
|
474
128
|
this.notify(ctx, "stopped; your newer model selection takes priority.");
|
|
475
129
|
}
|
|
@@ -521,23 +175,23 @@ export class ManagedManualCompact implements ContextRolloverHandoff {
|
|
|
521
175
|
|
|
522
176
|
async settled(ctx: ExtensionContext): Promise<boolean> {
|
|
523
177
|
if (!this.busy) return false;
|
|
524
|
-
if (this.record?.phase === "
|
|
525
|
-
await this.finish(ctx,
|
|
178
|
+
if (this.record?.phase === "restore-needed" || this.record?.phase === "restoring") return true;
|
|
179
|
+
await this.finish(ctx, true);
|
|
526
180
|
return true;
|
|
527
181
|
}
|
|
528
182
|
|
|
529
|
-
private async finish(ctx: ExtensionContext,
|
|
183
|
+
private async finish(ctx: ExtensionContext, inspectMarker: boolean): Promise<void> {
|
|
530
184
|
const record = this.record;
|
|
531
185
|
if (!record || !ctx.isIdle()) return;
|
|
532
186
|
const generation = this.generation;
|
|
533
|
-
if (!this.belongs(ctx)) { this.
|
|
187
|
+
if (!this.belongs(ctx)) { this.record = undefined; ++this.generation; return; }
|
|
534
188
|
const branch = ctx.sessionManager.getBranch();
|
|
535
189
|
const marker = findLatestWindowBoundaryEntry(branch, record.sessionId);
|
|
536
190
|
const switched = inspectMarker && !!record.targetWindowId && marker?.details.contextManagement.currentWindowId === record.targetWindowId
|
|
537
191
|
&& marker.details.contextManagement.previousWindowId === record.sourceWindowId;
|
|
538
192
|
const receipt = switched ? freshCheckpoint(branch.slice(0, branch.indexOf(marker!)), record) : undefined;
|
|
539
193
|
if (modelKey(ctx.model) !== record.targetModel && modelKey(ctx.model) !== record.originalModel) {
|
|
540
|
-
this.write("superseded");
|
|
194
|
+
this.write("superseded"); return;
|
|
541
195
|
}
|
|
542
196
|
this.write("restoring");
|
|
543
197
|
try {
|
|
@@ -552,11 +206,7 @@ export class ManagedManualCompact implements ContextRolloverHandoff {
|
|
|
552
206
|
return;
|
|
553
207
|
}
|
|
554
208
|
this.write(switched && receipt ? "completed" : "failed");
|
|
555
|
-
this.
|
|
556
|
-
this.notify(ctx, switched && receipt ? `new window persisted; restored ${record.originalModel}.` : "checkpoint handoff did not complete; original model restored.", !receipt);
|
|
557
|
-
if (mayResume && receipt) {
|
|
558
|
-
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.`);
|
|
559
|
-
}
|
|
209
|
+
this.notify(ctx, switched && receipt ? `new window persisted; restored ${record.originalModel}.` : "legacy checkpoint duty stopped; original model restored.", !receipt);
|
|
560
210
|
}
|
|
561
211
|
|
|
562
212
|
/** Reload/navigation recovers selection only. It never retries inference or writes notes. */
|
|
@@ -564,7 +214,6 @@ export class ManagedManualCompact implements ContextRolloverHandoff {
|
|
|
564
214
|
const previous = this.busy ? this.record : undefined;
|
|
565
215
|
++this.generation;
|
|
566
216
|
await this.selectionPromise?.catch(() => false);
|
|
567
|
-
this.clearActivity();
|
|
568
217
|
if (previous && ctx.isIdle() && previous.sessionId === ctx.sessionManager.getSessionId()
|
|
569
218
|
&& modelKey(ctx.model) === previous.targetModel && !this.belongs(ctx, previous)) {
|
|
570
219
|
// Tree navigation can leave the owned temporary selection on a sibling
|
|
@@ -573,7 +222,7 @@ export class ManagedManualCompact implements ContextRolloverHandoff {
|
|
|
573
222
|
if (anchorId) {
|
|
574
223
|
this.record = { ...previous, operationId: randomUUID(), anchorId, targetWindowId: undefined };
|
|
575
224
|
this.write("restoring");
|
|
576
|
-
await this.finish(ctx, false
|
|
225
|
+
await this.finish(ctx, false);
|
|
577
226
|
return;
|
|
578
227
|
}
|
|
579
228
|
}
|
|
@@ -587,13 +236,12 @@ export class ManagedManualCompact implements ContextRolloverHandoff {
|
|
|
587
236
|
// A newer explicit model change on the branch wins over the saved operation.
|
|
588
237
|
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;
|
|
589
238
|
this.record = record;
|
|
590
|
-
await this.finish(ctx,
|
|
239
|
+
await this.finish(ctx, true);
|
|
591
240
|
}
|
|
592
241
|
async shutdown(ctx: ExtensionContext): Promise<void> {
|
|
593
242
|
++this.generation;
|
|
594
243
|
await this.selectionPromise?.catch(() => false);
|
|
595
|
-
if (this.busy) await this.finish(ctx,
|
|
596
|
-
this.clearActivity();
|
|
244
|
+
if (this.busy) await this.finish(ctx, true);
|
|
597
245
|
this.record = undefined;
|
|
598
246
|
}
|
|
599
247
|
}
|