pi-openai-toolkit 0.23.0 → 0.23.1
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/LICENSE +21 -21
- package/NOTICE +83 -83
- package/config.schema.json +278 -278
- package/docs/configuration.md +298 -298
- package/docs/internals.md +181 -181
- package/package.json +135 -134
- package/src/compact-client.ts +412 -412
- package/src/compaction-projection.ts +35 -35
- package/src/config/migration.ts +103 -103
- package/src/config.ts +310 -310
- package/src/context-management/codex-provider.ts +270 -270
- package/src/context-management/history-notes.ts +357 -357
- package/src/context-management/manual-compact.ts +599 -599
- package/src/context-management/responses-tools.ts +238 -0
- package/src/context-management/tools.ts +371 -371
- package/src/context-management/types.ts +168 -168
- package/src/context-management/window-manager.ts +905 -905
- package/src/debug.ts +238 -238
- package/src/deferred-tool-carryover.ts +282 -282
- package/src/details-store.ts +151 -151
- package/src/extension-runtime.ts +1986 -1986
- package/src/image-generation/extension.ts +189 -189
- package/src/image-generation/protocol.ts +579 -579
- package/src/image-generation/types.ts +300 -300
- package/src/payload-rewrite.ts +515 -515
- package/src/pi-context-hook.ts +117 -117
- package/src/remote-v2-client.ts +811 -811
- package/src/request-context-cache.ts +155 -155
- package/src/responses-headers.ts +180 -180
- package/src/runtime.ts +865 -865
- package/src/serializer.ts +882 -882
- package/src/supported-environment.ts +27 -27
- package/src/types.ts +565 -565
- package/src/web-search/alpha-client.ts +876 -876
- package/src/web-search/extension.ts +530 -530
- package/src/web-search/payload.ts +299 -299
- package/src/web-search/prompt.ts +60 -60
- package/src/web-search/types.ts +166 -166
- package/tsconfig.check.json +50 -50
|
@@ -1,599 +1,599 @@
|
|
|
1
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { isDeepStrictEqual } from "node:util";
|
|
3
|
-
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
4
|
-
import { buildSessionContext, estimateTokens, type CompactionResult, type ExtensionAPI, type ExtensionContext, type SessionBeforeCompactEvent, type SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import { resolveToolkitConfig, type ResolvedToolkitConfig } from "../config";
|
|
6
|
-
import { parseModelSpec } from "../runtime";
|
|
7
|
-
import { THINKING_LEVELS, type NativeCompactionDetails } from "../types";
|
|
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 REQUIRED_TOOLS = ["notes", "history", "new_context", "get_context_remaining"];
|
|
18
|
-
|
|
19
|
-
/** Non-LLM recovery state. No auth, model object, transcript or note text belongs here. */
|
|
20
|
-
export type ManualCompactRecord = {
|
|
21
|
-
version: 1;
|
|
22
|
-
operationId: string;
|
|
23
|
-
sessionId: string;
|
|
24
|
-
sourceWindowId: string;
|
|
25
|
-
anchorId: string;
|
|
26
|
-
originalModel: string;
|
|
27
|
-
targetModel: string;
|
|
28
|
-
originalThinking: ThinkingLevel;
|
|
29
|
-
phase: Phase;
|
|
30
|
-
targetWindowId?: string;
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
export function decodeManualCompactRecord(value: unknown): ManualCompactRecord | undefined {
|
|
34
|
-
if (!isRecord(value) || value.version !== 1
|
|
35
|
-
|| ![value.operationId, value.sessionId, value.sourceWindowId, value.anchorId].every(isNonEmptyString)
|
|
36
|
-
|| typeof value.originalModel !== "string" || !parseModelSpec(value.originalModel)
|
|
37
|
-
|| typeof value.targetModel !== "string" || !parseModelSpec(value.targetModel)
|
|
38
|
-
|| typeof value.originalThinking !== "string" || !(THINKING_LEVELS as readonly string[]).includes(value.originalThinking)
|
|
39
|
-
|| !PHASES.includes(value.phase as Phase)
|
|
40
|
-
|| (value.targetWindowId !== undefined && !isNonEmptyString(value.targetWindowId))) return undefined;
|
|
41
|
-
return value as ManualCompactRecord;
|
|
42
|
-
}
|
|
43
|
-
|
|
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
|
-
function modelKey(model: ExtensionContext["model"]): string | undefined {
|
|
66
|
-
return model && `${model.provider}/${model.id}`;
|
|
67
|
-
}
|
|
68
|
-
function requestEntry(entries: readonly SessionEntry[], record: ManualCompactRecord): SessionEntry | undefined {
|
|
69
|
-
return entries.find((entry) => entry.type === "custom" && entry.customType === MANUAL_COMPACT_ENTRY_TYPE
|
|
70
|
-
&& decodeManualCompactRecord(entry.data)?.operationId === record.operationId);
|
|
71
|
-
}
|
|
72
|
-
function freshCheckpoint(entries: readonly SessionEntry[], record: ManualCompactRecord) {
|
|
73
|
-
const request = requestEntry(entries, record);
|
|
74
|
-
if (!request) return undefined;
|
|
75
|
-
let afterId = request.id;
|
|
76
|
-
// Queued user input delivered after a checkpoint must also be checkpointed.
|
|
77
|
-
for (const entry of entries.slice(entries.indexOf(request) + 1)) {
|
|
78
|
-
if (entry.type === "message" && entry.message.role === "user") afterId = entry.id;
|
|
79
|
-
}
|
|
80
|
-
return findLatestNotesCheckpointSinceBoundary(entries, record.sessionId, afterId);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function branchFingerprint(entries: readonly SessionEntry[]): string {
|
|
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. */
|
|
260
|
-
export class ManagedManualCompact implements ContextRolloverHandoff {
|
|
261
|
-
private record: ManualCompactRecord | undefined;
|
|
262
|
-
private generation = 0;
|
|
263
|
-
private sourcePolicy: ResolvedToolkitConfig | undefined;
|
|
264
|
-
private instructions = "";
|
|
265
|
-
private attemptSignal: AbortSignal | undefined;
|
|
266
|
-
private selecting: string | undefined;
|
|
267
|
-
private selectionPromise: Promise<boolean> | undefined;
|
|
268
|
-
private expectedThinking: ThinkingLevel | undefined;
|
|
269
|
-
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
|
-
private maintenance = 0;
|
|
275
|
-
|
|
276
|
-
constructor(private readonly pi: ExtensionAPI, private readonly windows: CodexContextWindowManager, private readonly deliveryTimeoutMs = 10_000) {}
|
|
277
|
-
|
|
278
|
-
get busy(): boolean { return !!this.record && !TERMINAL.has(this.record.phase); }
|
|
279
|
-
get snapshot() { return this.busy ? this.sourcePolicy?.snapshot : undefined; }
|
|
280
|
-
get isMaintenance(): boolean { return this.maintenance > 0; }
|
|
281
|
-
beginMaintenance(): () => void {
|
|
282
|
-
this.maintenance++;
|
|
283
|
-
let done = false;
|
|
284
|
-
return () => { if (!done) { done = true; this.maintenance--; } };
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
private notify(ctx: ExtensionContext, text: string, error = false): void {
|
|
288
|
-
const content = `Managed /compact: ${text}`;
|
|
289
|
-
if (ctx.hasUI) ctx.ui.notify(content, error ? "warning" : "info");
|
|
290
|
-
else this.pi.sendMessage({ customType: `${MANUAL_COMPACT_ENTRY_TYPE}:status`, content, display: true }, { triggerTurn: false });
|
|
291
|
-
}
|
|
292
|
-
private write(phase: Phase): void {
|
|
293
|
-
if (!this.record) return;
|
|
294
|
-
this.record = { ...this.record, phase };
|
|
295
|
-
this.pi.appendEntry(MANUAL_COMPACT_ENTRY_TYPE, this.record);
|
|
296
|
-
}
|
|
297
|
-
private clearActivity(): void {
|
|
298
|
-
clearTimeout(this.deliveryTimer);
|
|
299
|
-
this.deliveryTimer = undefined;
|
|
300
|
-
this.sourcePolicy = undefined;
|
|
301
|
-
}
|
|
302
|
-
private belongs(ctx: ExtensionContext, record = this.record): boolean {
|
|
303
|
-
if (!record || ctx.sessionManager.getSessionId() !== record.sessionId) return false;
|
|
304
|
-
const branch = ctx.sessionManager.getBranch();
|
|
305
|
-
return branch.some((entry) => entry.id === record.anchorId) && !!requestEntry(branch, record);
|
|
306
|
-
}
|
|
307
|
-
private current(ctx: ExtensionContext, generation: number): boolean {
|
|
308
|
-
return generation === this.generation && this.busy && this.belongs(ctx);
|
|
309
|
-
}
|
|
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
|
-
|
|
407
|
-
messageStarted(message: { role: string; content?: unknown }): void {
|
|
408
|
-
if (this.record?.phase !== "checkpointing" || message.role !== "user") return;
|
|
409
|
-
const text = typeof message.content === "string" ? message.content : JSON.stringify(message.content);
|
|
410
|
-
if (!text?.includes(`[Managed /compact ${this.record.operationId}]`)) return;
|
|
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
|
-
};
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
/** Abort without waiting: a queued/mixed continuation must not send the captured temporary model. */
|
|
440
|
-
turnEnded(ctx: ExtensionContext): void {
|
|
441
|
-
if (this.busy && this.record?.phase !== "checkpointing" && this.record?.phase !== "requested") {
|
|
442
|
-
if (!this.checkpointStopped) {
|
|
443
|
-
this.resumeAllowed = !ctx.signal?.aborted;
|
|
444
|
-
this.checkpointStopped = true;
|
|
445
|
-
}
|
|
446
|
-
ctx.abort();
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
guardRequest(ctx: ExtensionContext): void {
|
|
450
|
-
if (ctx.signal?.aborted) return;
|
|
451
|
-
if (this.selectionPromise && this.newerSelection) {
|
|
452
|
-
ctx.abort();
|
|
453
|
-
throw new Error("Managed /compact is preserving your newer model selection; this provider request was cancelled.");
|
|
454
|
-
}
|
|
455
|
-
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
|
-
ctx.abort();
|
|
459
|
-
throw new Error("Managed /compact is restoring the original model; this provider request was cancelled.");
|
|
460
|
-
}
|
|
461
|
-
guardTool(name: string): { block: true; terminate: true; reason: string } | undefined {
|
|
462
|
-
if (!this.busy && !this.selectionPromise) return;
|
|
463
|
-
if (this.record?.phase === "checkpointing" && REQUIRED_TOOLS.includes(name)) return;
|
|
464
|
-
return { block: true, terminate: true, reason: "Managed /compact owns this checkpoint-only turn. Wait for the original-model continuation before ordinary work." };
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
modelSelected(model: NonNullable<ExtensionContext["model"]>, ctx: ExtensionContext): void {
|
|
468
|
-
if ((!this.busy && !this.selectionPromise) || modelKey(model) === this.selecting) return;
|
|
469
|
-
this.newerSelection = { model, thinking: this.pi.getThinkingLevel() };
|
|
470
|
-
++this.generation;
|
|
471
|
-
if (this.belongs(ctx)) this.write("superseded");
|
|
472
|
-
this.clearActivity();
|
|
473
|
-
if (!ctx.isIdle()) ctx.abort();
|
|
474
|
-
this.notify(ctx, "stopped; your newer model selection takes priority.");
|
|
475
|
-
}
|
|
476
|
-
thinkingSelected(level: ThinkingLevel, ctx: ExtensionContext): void {
|
|
477
|
-
if (!this.busy || this.selecting || level === this.expectedThinking) return;
|
|
478
|
-
if (ctx.model) this.modelSelected(ctx.model, ctx);
|
|
479
|
-
}
|
|
480
|
-
private async honorNewerSelection(ctx: ExtensionContext): Promise<void> {
|
|
481
|
-
const choice = this.newerSelection;
|
|
482
|
-
if (!choice || ctx.sessionManager.getSessionId() !== this.record?.sessionId) return;
|
|
483
|
-
const key = modelKey(choice.model)!;
|
|
484
|
-
try {
|
|
485
|
-
const selected = await this.select(key, ctx);
|
|
486
|
-
if (choice !== this.newerSelection) { await this.honorNewerSelection(ctx); return; }
|
|
487
|
-
if (!selected || modelKey(ctx.model) !== key) throw new Error("selection refused");
|
|
488
|
-
this.setThinking(choice.thinking);
|
|
489
|
-
} catch {
|
|
490
|
-
if (choice !== this.newerSelection) { await this.honorNewerSelection(ctx); return; }
|
|
491
|
-
if (!this.belongs(ctx) || !this.record) return;
|
|
492
|
-
this.record = { ...this.record, originalModel: key, originalThinking: choice.thinking, targetModel: modelKey(ctx.model) ?? this.record.targetModel };
|
|
493
|
-
this.write("restore-needed");
|
|
494
|
-
this.notify(ctx, `could not preserve your newer selection ${key}; restore its authentication and reload, or select a model explicitly.`, true);
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
private setThinking(level: ThinkingLevel): void {
|
|
498
|
-
this.expectedThinking = level;
|
|
499
|
-
this.pi.setThinkingLevel(level);
|
|
500
|
-
// Pi emits this event without awaiting earlier extension handlers. Keep
|
|
501
|
-
// the owned value so a delayed echo is not mistaken for a user selection.
|
|
502
|
-
this.expectedThinking = this.pi.getThinkingLevel();
|
|
503
|
-
}
|
|
504
|
-
private async select(key: string, ctx: ExtensionContext): Promise<boolean> {
|
|
505
|
-
if (this.selectionPromise) await this.selectionPromise.catch(() => false);
|
|
506
|
-
if (modelKey(ctx.model) === key) return true;
|
|
507
|
-
const spec = parseModelSpec(key)!;
|
|
508
|
-
const model = ctx.modelRegistry.find(spec.provider, spec.modelId);
|
|
509
|
-
if (!model) return false;
|
|
510
|
-
this.selecting = key;
|
|
511
|
-
const pending = this.pi.setModel(model);
|
|
512
|
-
this.selectionPromise = pending;
|
|
513
|
-
try {
|
|
514
|
-
const selected = await pending;
|
|
515
|
-
this.expectedThinking = this.pi.getThinkingLevel();
|
|
516
|
-
return selected;
|
|
517
|
-
} finally {
|
|
518
|
-
if (this.selectionPromise === pending) { this.selecting = undefined; this.selectionPromise = undefined; }
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
async settled(ctx: ExtensionContext): Promise<boolean> {
|
|
523
|
-
if (!this.busy) return false;
|
|
524
|
-
if (this.record?.phase === "requested" || this.record?.phase === "switching" || this.record?.phase === "restore-needed" || this.record?.phase === "restoring") return true;
|
|
525
|
-
await this.finish(ctx, this.resumeAllowed, true);
|
|
526
|
-
return true;
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
private async finish(ctx: ExtensionContext, mayResume: boolean, inspectMarker: boolean): Promise<void> {
|
|
530
|
-
const record = this.record;
|
|
531
|
-
if (!record || !ctx.isIdle()) return;
|
|
532
|
-
const generation = this.generation;
|
|
533
|
-
if (!this.belongs(ctx)) { this.clearActivity(); this.record = undefined; ++this.generation; return; }
|
|
534
|
-
const branch = ctx.sessionManager.getBranch();
|
|
535
|
-
const marker = findLatestWindowBoundaryEntry(branch, record.sessionId);
|
|
536
|
-
const switched = inspectMarker && !!record.targetWindowId && marker?.details.contextManagement.currentWindowId === record.targetWindowId
|
|
537
|
-
&& marker.details.contextManagement.previousWindowId === record.sourceWindowId;
|
|
538
|
-
const receipt = switched ? freshCheckpoint(branch.slice(0, branch.indexOf(marker!)), record) : undefined;
|
|
539
|
-
if (modelKey(ctx.model) !== record.targetModel && modelKey(ctx.model) !== record.originalModel) {
|
|
540
|
-
this.write("superseded"); this.clearActivity(); return;
|
|
541
|
-
}
|
|
542
|
-
this.write("restoring");
|
|
543
|
-
try {
|
|
544
|
-
if (!(await this.select(record.originalModel, ctx))) throw new Error("selection refused");
|
|
545
|
-
if (!this.current(ctx, generation)) { await this.honorNewerSelection(ctx); return; }
|
|
546
|
-
this.setThinking(record.originalThinking);
|
|
547
|
-
if (modelKey(ctx.model) !== record.originalModel || this.pi.getThinkingLevel() !== record.originalThinking) throw new Error("restoration did not match");
|
|
548
|
-
} catch {
|
|
549
|
-
if (!this.current(ctx, generation)) return;
|
|
550
|
-
this.write("restore-needed");
|
|
551
|
-
this.notify(ctx, `could not restore ${record.originalModel}; no continuation was started. Restore model/auth, then reload or select a model explicitly.`, true);
|
|
552
|
-
return;
|
|
553
|
-
}
|
|
554
|
-
this.write(switched && receipt ? "completed" : "failed");
|
|
555
|
-
this.clearActivity();
|
|
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
|
-
}
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
/** Reload/navigation recovers selection only. It never retries inference or writes notes. */
|
|
563
|
-
async recover(ctx: ExtensionContext): Promise<void> {
|
|
564
|
-
const previous = this.busy ? this.record : undefined;
|
|
565
|
-
++this.generation;
|
|
566
|
-
await this.selectionPromise?.catch(() => false);
|
|
567
|
-
this.clearActivity();
|
|
568
|
-
if (previous && ctx.isIdle() && previous.sessionId === ctx.sessionManager.getSessionId()
|
|
569
|
-
&& modelKey(ctx.model) === previous.targetModel && !this.belongs(ctx, previous)) {
|
|
570
|
-
// Tree navigation can leave the owned temporary selection on a sibling
|
|
571
|
-
// without its request anchor. Re-anchor recovery only; never resume work.
|
|
572
|
-
const anchorId = ctx.sessionManager.getLeafId();
|
|
573
|
-
if (anchorId) {
|
|
574
|
-
this.record = { ...previous, operationId: randomUUID(), anchorId, targetWindowId: undefined };
|
|
575
|
-
this.write("restoring");
|
|
576
|
-
await this.finish(ctx, false, false);
|
|
577
|
-
return;
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
this.record = undefined;
|
|
581
|
-
this.newerSelection = undefined;
|
|
582
|
-
const branch = ctx.sessionManager.getBranch();
|
|
583
|
-
const latest = [...branch].reverse().find((entry) => entry.type === "custom" && entry.customType === MANUAL_COMPACT_ENTRY_TYPE);
|
|
584
|
-
if (!latest || latest.type !== "custom") return;
|
|
585
|
-
const record = decodeManualCompactRecord(latest.data);
|
|
586
|
-
if (!record || TERMINAL.has(record.phase) || record.sessionId !== ctx.sessionManager.getSessionId()) return;
|
|
587
|
-
// A newer explicit model change on the branch wins over the saved operation.
|
|
588
|
-
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
|
-
this.record = record;
|
|
590
|
-
await this.finish(ctx, false, true);
|
|
591
|
-
}
|
|
592
|
-
async shutdown(ctx: ExtensionContext): Promise<void> {
|
|
593
|
-
++this.generation;
|
|
594
|
-
await this.selectionPromise?.catch(() => false);
|
|
595
|
-
if (this.busy) await this.finish(ctx, false, true);
|
|
596
|
-
this.clearActivity();
|
|
597
|
-
this.record = undefined;
|
|
598
|
-
}
|
|
599
|
-
}
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { isDeepStrictEqual } from "node:util";
|
|
3
|
+
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
4
|
+
import { buildSessionContext, estimateTokens, type CompactionResult, type ExtensionAPI, type ExtensionContext, type SessionBeforeCompactEvent, type SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { resolveToolkitConfig, type ResolvedToolkitConfig } from "../config";
|
|
6
|
+
import { parseModelSpec } from "../runtime";
|
|
7
|
+
import { THINKING_LEVELS, type NativeCompactionDetails } from "../types";
|
|
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 REQUIRED_TOOLS = ["notes", "history", "new_context", "get_context_remaining"];
|
|
18
|
+
|
|
19
|
+
/** Non-LLM recovery state. No auth, model object, transcript or note text belongs here. */
|
|
20
|
+
export type ManualCompactRecord = {
|
|
21
|
+
version: 1;
|
|
22
|
+
operationId: string;
|
|
23
|
+
sessionId: string;
|
|
24
|
+
sourceWindowId: string;
|
|
25
|
+
anchorId: string;
|
|
26
|
+
originalModel: string;
|
|
27
|
+
targetModel: string;
|
|
28
|
+
originalThinking: ThinkingLevel;
|
|
29
|
+
phase: Phase;
|
|
30
|
+
targetWindowId?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function decodeManualCompactRecord(value: unknown): ManualCompactRecord | undefined {
|
|
34
|
+
if (!isRecord(value) || value.version !== 1
|
|
35
|
+
|| ![value.operationId, value.sessionId, value.sourceWindowId, value.anchorId].every(isNonEmptyString)
|
|
36
|
+
|| typeof value.originalModel !== "string" || !parseModelSpec(value.originalModel)
|
|
37
|
+
|| typeof value.targetModel !== "string" || !parseModelSpec(value.targetModel)
|
|
38
|
+
|| typeof value.originalThinking !== "string" || !(THINKING_LEVELS as readonly string[]).includes(value.originalThinking)
|
|
39
|
+
|| !PHASES.includes(value.phase as Phase)
|
|
40
|
+
|| (value.targetWindowId !== undefined && !isNonEmptyString(value.targetWindowId))) return undefined;
|
|
41
|
+
return value as ManualCompactRecord;
|
|
42
|
+
}
|
|
43
|
+
|
|
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
|
+
function modelKey(model: ExtensionContext["model"]): string | undefined {
|
|
66
|
+
return model && `${model.provider}/${model.id}`;
|
|
67
|
+
}
|
|
68
|
+
function requestEntry(entries: readonly SessionEntry[], record: ManualCompactRecord): SessionEntry | undefined {
|
|
69
|
+
return entries.find((entry) => entry.type === "custom" && entry.customType === MANUAL_COMPACT_ENTRY_TYPE
|
|
70
|
+
&& decodeManualCompactRecord(entry.data)?.operationId === record.operationId);
|
|
71
|
+
}
|
|
72
|
+
function freshCheckpoint(entries: readonly SessionEntry[], record: ManualCompactRecord) {
|
|
73
|
+
const request = requestEntry(entries, record);
|
|
74
|
+
if (!request) return undefined;
|
|
75
|
+
let afterId = request.id;
|
|
76
|
+
// Queued user input delivered after a checkpoint must also be checkpointed.
|
|
77
|
+
for (const entry of entries.slice(entries.indexOf(request) + 1)) {
|
|
78
|
+
if (entry.type === "message" && entry.message.role === "user") afterId = entry.id;
|
|
79
|
+
}
|
|
80
|
+
return findLatestNotesCheckpointSinceBoundary(entries, record.sessionId, afterId);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function branchFingerprint(entries: readonly SessionEntry[]): string {
|
|
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. */
|
|
260
|
+
export class ManagedManualCompact implements ContextRolloverHandoff {
|
|
261
|
+
private record: ManualCompactRecord | undefined;
|
|
262
|
+
private generation = 0;
|
|
263
|
+
private sourcePolicy: ResolvedToolkitConfig | undefined;
|
|
264
|
+
private instructions = "";
|
|
265
|
+
private attemptSignal: AbortSignal | undefined;
|
|
266
|
+
private selecting: string | undefined;
|
|
267
|
+
private selectionPromise: Promise<boolean> | undefined;
|
|
268
|
+
private expectedThinking: ThinkingLevel | undefined;
|
|
269
|
+
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
|
+
private maintenance = 0;
|
|
275
|
+
|
|
276
|
+
constructor(private readonly pi: ExtensionAPI, private readonly windows: CodexContextWindowManager, private readonly deliveryTimeoutMs = 10_000) {}
|
|
277
|
+
|
|
278
|
+
get busy(): boolean { return !!this.record && !TERMINAL.has(this.record.phase); }
|
|
279
|
+
get snapshot() { return this.busy ? this.sourcePolicy?.snapshot : undefined; }
|
|
280
|
+
get isMaintenance(): boolean { return this.maintenance > 0; }
|
|
281
|
+
beginMaintenance(): () => void {
|
|
282
|
+
this.maintenance++;
|
|
283
|
+
let done = false;
|
|
284
|
+
return () => { if (!done) { done = true; this.maintenance--; } };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private notify(ctx: ExtensionContext, text: string, error = false): void {
|
|
288
|
+
const content = `Managed /compact: ${text}`;
|
|
289
|
+
if (ctx.hasUI) ctx.ui.notify(content, error ? "warning" : "info");
|
|
290
|
+
else this.pi.sendMessage({ customType: `${MANUAL_COMPACT_ENTRY_TYPE}:status`, content, display: true }, { triggerTurn: false });
|
|
291
|
+
}
|
|
292
|
+
private write(phase: Phase): void {
|
|
293
|
+
if (!this.record) return;
|
|
294
|
+
this.record = { ...this.record, phase };
|
|
295
|
+
this.pi.appendEntry(MANUAL_COMPACT_ENTRY_TYPE, this.record);
|
|
296
|
+
}
|
|
297
|
+
private clearActivity(): void {
|
|
298
|
+
clearTimeout(this.deliveryTimer);
|
|
299
|
+
this.deliveryTimer = undefined;
|
|
300
|
+
this.sourcePolicy = undefined;
|
|
301
|
+
}
|
|
302
|
+
private belongs(ctx: ExtensionContext, record = this.record): boolean {
|
|
303
|
+
if (!record || ctx.sessionManager.getSessionId() !== record.sessionId) return false;
|
|
304
|
+
const branch = ctx.sessionManager.getBranch();
|
|
305
|
+
return branch.some((entry) => entry.id === record.anchorId) && !!requestEntry(branch, record);
|
|
306
|
+
}
|
|
307
|
+
private current(ctx: ExtensionContext, generation: number): boolean {
|
|
308
|
+
return generation === this.generation && this.busy && this.belongs(ctx);
|
|
309
|
+
}
|
|
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
|
+
|
|
407
|
+
messageStarted(message: { role: string; content?: unknown }): void {
|
|
408
|
+
if (this.record?.phase !== "checkpointing" || message.role !== "user") return;
|
|
409
|
+
const text = typeof message.content === "string" ? message.content : JSON.stringify(message.content);
|
|
410
|
+
if (!text?.includes(`[Managed /compact ${this.record.operationId}]`)) return;
|
|
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
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Abort without waiting: a queued/mixed continuation must not send the captured temporary model. */
|
|
440
|
+
turnEnded(ctx: ExtensionContext): void {
|
|
441
|
+
if (this.busy && this.record?.phase !== "checkpointing" && this.record?.phase !== "requested") {
|
|
442
|
+
if (!this.checkpointStopped) {
|
|
443
|
+
this.resumeAllowed = !ctx.signal?.aborted;
|
|
444
|
+
this.checkpointStopped = true;
|
|
445
|
+
}
|
|
446
|
+
ctx.abort();
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
guardRequest(ctx: ExtensionContext): void {
|
|
450
|
+
if (ctx.signal?.aborted) return;
|
|
451
|
+
if (this.selectionPromise && this.newerSelection) {
|
|
452
|
+
ctx.abort();
|
|
453
|
+
throw new Error("Managed /compact is preserving your newer model selection; this provider request was cancelled.");
|
|
454
|
+
}
|
|
455
|
+
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
|
+
ctx.abort();
|
|
459
|
+
throw new Error("Managed /compact is restoring the original model; this provider request was cancelled.");
|
|
460
|
+
}
|
|
461
|
+
guardTool(name: string): { block: true; terminate: true; reason: string } | undefined {
|
|
462
|
+
if (!this.busy && !this.selectionPromise) return;
|
|
463
|
+
if (this.record?.phase === "checkpointing" && REQUIRED_TOOLS.includes(name)) return;
|
|
464
|
+
return { block: true, terminate: true, reason: "Managed /compact owns this checkpoint-only turn. Wait for the original-model continuation before ordinary work." };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
modelSelected(model: NonNullable<ExtensionContext["model"]>, ctx: ExtensionContext): void {
|
|
468
|
+
if ((!this.busy && !this.selectionPromise) || modelKey(model) === this.selecting) return;
|
|
469
|
+
this.newerSelection = { model, thinking: this.pi.getThinkingLevel() };
|
|
470
|
+
++this.generation;
|
|
471
|
+
if (this.belongs(ctx)) this.write("superseded");
|
|
472
|
+
this.clearActivity();
|
|
473
|
+
if (!ctx.isIdle()) ctx.abort();
|
|
474
|
+
this.notify(ctx, "stopped; your newer model selection takes priority.");
|
|
475
|
+
}
|
|
476
|
+
thinkingSelected(level: ThinkingLevel, ctx: ExtensionContext): void {
|
|
477
|
+
if (!this.busy || this.selecting || level === this.expectedThinking) return;
|
|
478
|
+
if (ctx.model) this.modelSelected(ctx.model, ctx);
|
|
479
|
+
}
|
|
480
|
+
private async honorNewerSelection(ctx: ExtensionContext): Promise<void> {
|
|
481
|
+
const choice = this.newerSelection;
|
|
482
|
+
if (!choice || ctx.sessionManager.getSessionId() !== this.record?.sessionId) return;
|
|
483
|
+
const key = modelKey(choice.model)!;
|
|
484
|
+
try {
|
|
485
|
+
const selected = await this.select(key, ctx);
|
|
486
|
+
if (choice !== this.newerSelection) { await this.honorNewerSelection(ctx); return; }
|
|
487
|
+
if (!selected || modelKey(ctx.model) !== key) throw new Error("selection refused");
|
|
488
|
+
this.setThinking(choice.thinking);
|
|
489
|
+
} catch {
|
|
490
|
+
if (choice !== this.newerSelection) { await this.honorNewerSelection(ctx); return; }
|
|
491
|
+
if (!this.belongs(ctx) || !this.record) return;
|
|
492
|
+
this.record = { ...this.record, originalModel: key, originalThinking: choice.thinking, targetModel: modelKey(ctx.model) ?? this.record.targetModel };
|
|
493
|
+
this.write("restore-needed");
|
|
494
|
+
this.notify(ctx, `could not preserve your newer selection ${key}; restore its authentication and reload, or select a model explicitly.`, true);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
private setThinking(level: ThinkingLevel): void {
|
|
498
|
+
this.expectedThinking = level;
|
|
499
|
+
this.pi.setThinkingLevel(level);
|
|
500
|
+
// Pi emits this event without awaiting earlier extension handlers. Keep
|
|
501
|
+
// the owned value so a delayed echo is not mistaken for a user selection.
|
|
502
|
+
this.expectedThinking = this.pi.getThinkingLevel();
|
|
503
|
+
}
|
|
504
|
+
private async select(key: string, ctx: ExtensionContext): Promise<boolean> {
|
|
505
|
+
if (this.selectionPromise) await this.selectionPromise.catch(() => false);
|
|
506
|
+
if (modelKey(ctx.model) === key) return true;
|
|
507
|
+
const spec = parseModelSpec(key)!;
|
|
508
|
+
const model = ctx.modelRegistry.find(spec.provider, spec.modelId);
|
|
509
|
+
if (!model) return false;
|
|
510
|
+
this.selecting = key;
|
|
511
|
+
const pending = this.pi.setModel(model);
|
|
512
|
+
this.selectionPromise = pending;
|
|
513
|
+
try {
|
|
514
|
+
const selected = await pending;
|
|
515
|
+
this.expectedThinking = this.pi.getThinkingLevel();
|
|
516
|
+
return selected;
|
|
517
|
+
} finally {
|
|
518
|
+
if (this.selectionPromise === pending) { this.selecting = undefined; this.selectionPromise = undefined; }
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async settled(ctx: ExtensionContext): Promise<boolean> {
|
|
523
|
+
if (!this.busy) return false;
|
|
524
|
+
if (this.record?.phase === "requested" || this.record?.phase === "switching" || this.record?.phase === "restore-needed" || this.record?.phase === "restoring") return true;
|
|
525
|
+
await this.finish(ctx, this.resumeAllowed, true);
|
|
526
|
+
return true;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
private async finish(ctx: ExtensionContext, mayResume: boolean, inspectMarker: boolean): Promise<void> {
|
|
530
|
+
const record = this.record;
|
|
531
|
+
if (!record || !ctx.isIdle()) return;
|
|
532
|
+
const generation = this.generation;
|
|
533
|
+
if (!this.belongs(ctx)) { this.clearActivity(); this.record = undefined; ++this.generation; return; }
|
|
534
|
+
const branch = ctx.sessionManager.getBranch();
|
|
535
|
+
const marker = findLatestWindowBoundaryEntry(branch, record.sessionId);
|
|
536
|
+
const switched = inspectMarker && !!record.targetWindowId && marker?.details.contextManagement.currentWindowId === record.targetWindowId
|
|
537
|
+
&& marker.details.contextManagement.previousWindowId === record.sourceWindowId;
|
|
538
|
+
const receipt = switched ? freshCheckpoint(branch.slice(0, branch.indexOf(marker!)), record) : undefined;
|
|
539
|
+
if (modelKey(ctx.model) !== record.targetModel && modelKey(ctx.model) !== record.originalModel) {
|
|
540
|
+
this.write("superseded"); this.clearActivity(); return;
|
|
541
|
+
}
|
|
542
|
+
this.write("restoring");
|
|
543
|
+
try {
|
|
544
|
+
if (!(await this.select(record.originalModel, ctx))) throw new Error("selection refused");
|
|
545
|
+
if (!this.current(ctx, generation)) { await this.honorNewerSelection(ctx); return; }
|
|
546
|
+
this.setThinking(record.originalThinking);
|
|
547
|
+
if (modelKey(ctx.model) !== record.originalModel || this.pi.getThinkingLevel() !== record.originalThinking) throw new Error("restoration did not match");
|
|
548
|
+
} catch {
|
|
549
|
+
if (!this.current(ctx, generation)) return;
|
|
550
|
+
this.write("restore-needed");
|
|
551
|
+
this.notify(ctx, `could not restore ${record.originalModel}; no continuation was started. Restore model/auth, then reload or select a model explicitly.`, true);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
this.write(switched && receipt ? "completed" : "failed");
|
|
555
|
+
this.clearActivity();
|
|
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
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** Reload/navigation recovers selection only. It never retries inference or writes notes. */
|
|
563
|
+
async recover(ctx: ExtensionContext): Promise<void> {
|
|
564
|
+
const previous = this.busy ? this.record : undefined;
|
|
565
|
+
++this.generation;
|
|
566
|
+
await this.selectionPromise?.catch(() => false);
|
|
567
|
+
this.clearActivity();
|
|
568
|
+
if (previous && ctx.isIdle() && previous.sessionId === ctx.sessionManager.getSessionId()
|
|
569
|
+
&& modelKey(ctx.model) === previous.targetModel && !this.belongs(ctx, previous)) {
|
|
570
|
+
// Tree navigation can leave the owned temporary selection on a sibling
|
|
571
|
+
// without its request anchor. Re-anchor recovery only; never resume work.
|
|
572
|
+
const anchorId = ctx.sessionManager.getLeafId();
|
|
573
|
+
if (anchorId) {
|
|
574
|
+
this.record = { ...previous, operationId: randomUUID(), anchorId, targetWindowId: undefined };
|
|
575
|
+
this.write("restoring");
|
|
576
|
+
await this.finish(ctx, false, false);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
this.record = undefined;
|
|
581
|
+
this.newerSelection = undefined;
|
|
582
|
+
const branch = ctx.sessionManager.getBranch();
|
|
583
|
+
const latest = [...branch].reverse().find((entry) => entry.type === "custom" && entry.customType === MANUAL_COMPACT_ENTRY_TYPE);
|
|
584
|
+
if (!latest || latest.type !== "custom") return;
|
|
585
|
+
const record = decodeManualCompactRecord(latest.data);
|
|
586
|
+
if (!record || TERMINAL.has(record.phase) || record.sessionId !== ctx.sessionManager.getSessionId()) return;
|
|
587
|
+
// A newer explicit model change on the branch wins over the saved operation.
|
|
588
|
+
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
|
+
this.record = record;
|
|
590
|
+
await this.finish(ctx, false, true);
|
|
591
|
+
}
|
|
592
|
+
async shutdown(ctx: ExtensionContext): Promise<void> {
|
|
593
|
+
++this.generation;
|
|
594
|
+
await this.selectionPromise?.catch(() => false);
|
|
595
|
+
if (this.busy) await this.finish(ctx, false, true);
|
|
596
|
+
this.clearActivity();
|
|
597
|
+
this.record = undefined;
|
|
598
|
+
}
|
|
599
|
+
}
|