@nijaru/pi-provider-compaction 0.0.3 → 0.0.4
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 +55 -41
- package/dist/index.js +991 -591
- package/index.ts +279 -456
- package/package.json +9 -8
- package/protocol.ts +50 -12
- package/provider.ts +85 -0
- package/replay.ts +140 -0
package/index.ts
CHANGED
|
@@ -1,39 +1,15 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes, randomUUID } from "node:crypto";
|
|
1
2
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import type { Context, Model, Tool, Usage } from "@earendil-works/pi-ai";
|
|
5
|
-
import type {
|
|
6
|
-
ExtensionAPI,
|
|
7
|
-
ExtensionContext,
|
|
8
|
-
SessionBeforeCompactEvent,
|
|
9
|
-
SessionEntry,
|
|
10
|
-
} from "@earendil-works/pi-coding-agent";
|
|
11
|
-
import {
|
|
12
|
-
compact,
|
|
13
|
-
convertToLlm,
|
|
14
|
-
sessionEntryToContextMessages,
|
|
15
|
-
} from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { normalizeContext, type Model, type Provider, type ProviderHeaders, type ProviderStreamOptions, type SimpleStreamOptions, type TranscriptContext, type Usage } from "@earendil-works/pi-ai";
|
|
4
|
+
import { compact, convertToLlm, type ExtensionAPI, type ExtensionContext, type SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
16
5
|
import { resolveCompactionModelPolicy } from "./policy";
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
requestProviderCompaction,
|
|
21
|
-
resolveNativeProtocol,
|
|
22
|
-
supportsResponsesApi,
|
|
23
|
-
type NativeProtocol,
|
|
24
|
-
type ResponseItem,
|
|
25
|
-
} from "./protocol";
|
|
6
|
+
import { installObserver, type ObserveRequest } from "./provider";
|
|
7
|
+
import { capturePreparedRequest, equal, requestPolicy, selectCoveredPrefix, substituteSummary, summarySlot, type PreparedRequest } from "./replay";
|
|
8
|
+
import { isObject, requestProviderCompaction, resolveNativeProtocol, supportsResponsesApi, validateCompactedOutput, type NativeProtocol, type ResponseItem } from "./protocol";
|
|
26
9
|
|
|
27
10
|
export const NATIVE_COMPACTION_TYPE = "pi-provider-compaction/openai-responses";
|
|
28
|
-
export const NATIVE_COMPACTION_VERSION =
|
|
29
|
-
|
|
30
|
-
export const NATIVE_COMPACTION_SUMMARY =
|
|
31
|
-
"This context was compacted using the provider's native OpenAI Responses state.";
|
|
32
|
-
|
|
33
|
-
const TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]);
|
|
34
|
-
|
|
35
|
-
type JsonObject = Record<string, unknown>;
|
|
36
|
-
type FetchImplementation = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
11
|
+
export const NATIVE_COMPACTION_VERSION = 3;
|
|
12
|
+
export const NATIVE_COMPACTION_SUMMARY = "This context was compacted using the provider's native OpenAI Responses state.";
|
|
37
13
|
|
|
38
14
|
export interface NativeIdentity {
|
|
39
15
|
endpoint: string;
|
|
@@ -43,469 +19,316 @@ export interface NativeIdentity {
|
|
|
43
19
|
azureApiVersion?: string;
|
|
44
20
|
azureDeployment?: string;
|
|
45
21
|
}
|
|
46
|
-
|
|
47
22
|
export interface NativeCompactionDetails {
|
|
48
23
|
type: typeof NATIVE_COMPACTION_TYPE;
|
|
49
|
-
version: 1 |
|
|
24
|
+
version: 1 | 2 | 3;
|
|
50
25
|
provider: string;
|
|
51
26
|
api: string;
|
|
52
27
|
model: string;
|
|
53
28
|
protocol: NativeProtocol;
|
|
54
29
|
identity?: NativeIdentity;
|
|
55
30
|
output: ResponseItem[];
|
|
56
|
-
/** Native protocol usage. `usage` is a legacy alias accepted for v1 compatibility. */
|
|
57
31
|
nativeUsage?: Usage;
|
|
58
|
-
usage?: Usage;
|
|
59
32
|
portableUsage?: Usage;
|
|
33
|
+
usage?: Usage;
|
|
60
34
|
readFiles?: string[];
|
|
61
35
|
modifiedFiles?: string[];
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function readUsage(value: unknown): Usage | undefined {
|
|
73
|
-
if (!isObject(value) || !isObject(value.cost)) return undefined;
|
|
74
|
-
for (const key of ["input", "output", "cacheRead", "cacheWrite", "totalTokens"] as const) {
|
|
75
|
-
if (typeof value[key] !== "number" || !Number.isFinite(value[key])) return undefined;
|
|
76
|
-
}
|
|
77
|
-
for (const key of ["input", "output", "cacheRead", "cacheWrite", "total"] as const) {
|
|
78
|
-
if (typeof value.cost[key] !== "number" || !Number.isFinite(value.cost[key])) return undefined;
|
|
79
|
-
}
|
|
80
|
-
return value as unknown as Usage;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function readIdentity(value: unknown): NativeIdentity | undefined {
|
|
84
|
-
if (!isObject(value) || typeof value.endpoint !== "string" || !value.endpoint) return undefined;
|
|
85
|
-
for (const key of ["organization", "project", "accountId", "azureApiVersion", "azureDeployment"] as const) {
|
|
86
|
-
if (value[key] !== undefined && typeof value[key] !== "string") return undefined;
|
|
87
|
-
}
|
|
88
|
-
return {
|
|
89
|
-
endpoint: value.endpoint,
|
|
90
|
-
organization: value.organization as string | undefined,
|
|
91
|
-
project: value.project as string | undefined,
|
|
92
|
-
accountId: value.accountId as string | undefined,
|
|
93
|
-
azureApiVersion: value.azureApiVersion as string | undefined,
|
|
94
|
-
azureDeployment: value.azureDeployment as string | undefined,
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export function readNativeCompactionDetails(value: unknown): NativeCompactionDetails | undefined {
|
|
99
|
-
if (!isObject(value) || value.type !== NATIVE_COMPACTION_TYPE) return undefined;
|
|
100
|
-
if (value.version !== 1 && value.version !== NATIVE_COMPACTION_VERSION) return undefined;
|
|
101
|
-
if (typeof value.provider !== "string" || typeof value.api !== "string" || typeof value.model !== "string") return undefined;
|
|
102
|
-
if (!Array.isArray(value.output) || !value.output.every(isObject) || !value.output.some(isCompactionItem)) return undefined;
|
|
103
|
-
const protocol = value.version === 1
|
|
104
|
-
? "responses-compact"
|
|
105
|
-
: value.protocol === "responses-compact" || value.protocol === "remote-v2"
|
|
106
|
-
? value.protocol
|
|
107
|
-
: undefined;
|
|
108
|
-
if (!protocol) return undefined;
|
|
109
|
-
const strings = (candidate: unknown): string[] | undefined =>
|
|
110
|
-
candidate === undefined ? undefined : Array.isArray(candidate) && candidate.every((item) => typeof item === "string") ? [...candidate] : undefined;
|
|
111
|
-
return {
|
|
112
|
-
type: NATIVE_COMPACTION_TYPE,
|
|
113
|
-
version: value.version,
|
|
114
|
-
provider: value.provider,
|
|
115
|
-
api: value.api,
|
|
116
|
-
model: value.model,
|
|
117
|
-
protocol,
|
|
118
|
-
identity: value.version === 1 ? undefined : readIdentity(value.identity),
|
|
119
|
-
output: structuredClone(value.output) as ResponseItem[],
|
|
120
|
-
nativeUsage: readUsage(value.nativeUsage ?? value.usage),
|
|
121
|
-
usage: readUsage(value.usage),
|
|
122
|
-
portableUsage: readUsage(value.portableUsage),
|
|
123
|
-
readFiles: strings(value.readFiles),
|
|
124
|
-
modifiedFiles: strings(value.modifiedFiles),
|
|
36
|
+
coverage?: {
|
|
37
|
+
kind: "summarized-prefix";
|
|
38
|
+
firstKeptEntryId: string;
|
|
39
|
+
sourceLeafId: string;
|
|
40
|
+
sourceHash: string;
|
|
41
|
+
policyHash: string;
|
|
42
|
+
inputHash: string;
|
|
43
|
+
runtimeId: string;
|
|
125
44
|
};
|
|
126
45
|
}
|
|
127
46
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
return left.endpoint === right.endpoint && left.organization === right.organization && left.project === right.project && left.accountId === right.accountId && left.azureApiVersion === right.azureApiVersion && left.azureDeployment === right.azureDeployment;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
export function findLatestNativeCompaction(
|
|
135
|
-
entries: readonly SessionEntry[],
|
|
136
|
-
model: Model<any> | undefined,
|
|
137
|
-
identity?: NativeIdentity,
|
|
138
|
-
): NativeCompactionState | undefined {
|
|
139
|
-
if (!supportsNativeCompaction(model)) return undefined;
|
|
140
|
-
for (let index = entries.length - 1; index >= 0; index--) {
|
|
141
|
-
const entry = entries[index];
|
|
142
|
-
if (entry.type !== "compaction") continue;
|
|
143
|
-
const details = readNativeCompactionDetails(entry.details);
|
|
144
|
-
if (!details) return undefined;
|
|
145
|
-
if (details.provider !== model.provider || details.api !== model.api || details.model !== model.id) return undefined;
|
|
146
|
-
if (!identityEqual(details.identity, identity)) return undefined;
|
|
147
|
-
return { ...details, entryId: entry.id };
|
|
148
|
-
}
|
|
149
|
-
return undefined;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function responseItemsFromMessages(model: Model<any>, messages: AgentMessage[]): ResponseItem[] {
|
|
153
|
-
const input = convertResponsesMessages(
|
|
154
|
-
model,
|
|
155
|
-
{ messages: convertToLlm(messages), tools: [] },
|
|
156
|
-
TOOL_CALL_PROVIDERS,
|
|
157
|
-
{ includeSystemPrompt: false },
|
|
158
|
-
);
|
|
159
|
-
return input.filter(isObject) as unknown as ResponseItem[];
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function responseItemsFromEntries(model: Model<any>, entries: readonly SessionEntry[]): ResponseItem[] {
|
|
163
|
-
return responseItemsFromMessages(model, entries.flatMap((entry) => sessionEntryToContextMessages(entry)));
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function nativeRequestInput(
|
|
167
|
-
model: Model<any>,
|
|
168
|
-
branchEntries: readonly SessionEntry[],
|
|
169
|
-
activeEntries: readonly SessionEntry[],
|
|
170
|
-
previous: NativeCompactionState | undefined,
|
|
171
|
-
): ResponseItem[] {
|
|
172
|
-
if (previous) {
|
|
173
|
-
const previousIndex = branchEntries.findIndex((entry) => entry.id === previous.entryId);
|
|
174
|
-
if (previousIndex >= 0) {
|
|
175
|
-
return [...structuredClone(previous.output), ...responseItemsFromEntries(model, branchEntries.slice(previousIndex + 1))];
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
return responseItemsFromEntries(model, activeEntries);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
function authHeaders(apiKey: string | undefined, headers: Record<string, string | null> | undefined): Record<string, string> {
|
|
182
|
-
const result: Record<string, string> = { "content-type": "application/json" };
|
|
183
|
-
let hasAuthorization = false;
|
|
184
|
-
for (const [name, value] of Object.entries(headers ?? {})) {
|
|
185
|
-
if (value === null) continue;
|
|
186
|
-
result[name] = value;
|
|
187
|
-
if (name.toLowerCase() === "authorization" && value.trim()) hasAuthorization = true;
|
|
188
|
-
}
|
|
189
|
-
if (!hasAuthorization && apiKey) result.authorization = `Bearer ${apiKey}`;
|
|
190
|
-
return result;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
export interface CompactRequestOptions {
|
|
194
|
-
model: Model<any>;
|
|
195
|
-
baseUrl: string;
|
|
196
|
-
apiKey?: string;
|
|
197
|
-
headers?: Record<string, string | null>;
|
|
198
|
-
input: ResponseItem[];
|
|
199
|
-
instructions?: string;
|
|
200
|
-
signal?: AbortSignal;
|
|
201
|
-
fetchImpl?: FetchImplementation;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
export async function compactOpenAIResponses(options: CompactRequestOptions): Promise<NativeCompactionDetails> {
|
|
205
|
-
const endpoint = `${options.baseUrl.replace(/\/+$/, "")}/responses/compact`;
|
|
206
|
-
const body: JsonObject = { model: options.model.id, input: options.input };
|
|
207
|
-
if (options.instructions) body.instructions = options.instructions;
|
|
208
|
-
const response = await (options.fetchImpl ?? fetch)(endpoint, {
|
|
209
|
-
method: "POST",
|
|
210
|
-
headers: authHeaders(options.apiKey, options.headers),
|
|
211
|
-
body: JSON.stringify(body),
|
|
212
|
-
signal: options.signal,
|
|
213
|
-
});
|
|
214
|
-
if (!response.ok) {
|
|
215
|
-
const detail = await response.text().catch(() => "");
|
|
216
|
-
throw new Error(`OpenAI Responses compact request failed (${response.status}${detail ? `: ${detail.slice(0, 400)}` : ""})`);
|
|
217
|
-
}
|
|
218
|
-
const result: unknown = await response.json();
|
|
219
|
-
if (!isObject(result) || !Array.isArray(result.output) || !result.output.every(isObject) || !result.output.some(isCompactionItem)) {
|
|
220
|
-
throw new Error("OpenAI Responses compact response did not contain opaque compaction state");
|
|
221
|
-
}
|
|
222
|
-
const usage = compactUsage(options.model, result);
|
|
223
|
-
return {
|
|
224
|
-
type: NATIVE_COMPACTION_TYPE,
|
|
225
|
-
version: NATIVE_COMPACTION_VERSION,
|
|
226
|
-
provider: options.model.provider,
|
|
227
|
-
api: options.model.api,
|
|
228
|
-
model: options.model.id,
|
|
229
|
-
protocol: "responses-compact",
|
|
230
|
-
output: structuredClone(result.output) as ResponseItem[],
|
|
231
|
-
nativeUsage: usage,
|
|
232
|
-
usage,
|
|
233
|
-
};
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
function compactUsage(model: Model<any>, result: JsonObject): Usage | undefined {
|
|
237
|
-
const usage = result.usage;
|
|
238
|
-
if (!isObject(usage)) return undefined;
|
|
239
|
-
const cached = nestedNumber(usage.input_tokens_details, "cached_tokens");
|
|
240
|
-
const cacheWrite = nestedNumber(usage.input_tokens_details, "cache_write_tokens");
|
|
241
|
-
const mapped: Usage = {
|
|
242
|
-
input: Math.max(0, readNumber(usage.input_tokens) - cached - cacheWrite),
|
|
243
|
-
output: readNumber(usage.output_tokens),
|
|
244
|
-
cacheRead: cached,
|
|
245
|
-
cacheWrite,
|
|
246
|
-
reasoning: nestedNumber(usage.output_tokens_details, "reasoning_tokens"),
|
|
247
|
-
totalTokens: readNumber(usage.total_tokens),
|
|
248
|
-
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
249
|
-
};
|
|
250
|
-
try { calculateCost(model, mapped); } catch {}
|
|
251
|
-
return mapped;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function readNumber(value: unknown): number {
|
|
255
|
-
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
function nestedNumber(value: unknown, key: string): number {
|
|
259
|
-
return isObject(value) ? readNumber(value[key]) : 0;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
function stableStringify(value: unknown): string {
|
|
263
|
-
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
264
|
-
if (isObject(value)) {
|
|
265
|
-
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
|
|
266
|
-
}
|
|
267
|
-
return JSON.stringify(value) ?? "null";
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
function itemMatches(left: ResponseItem, right: ResponseItem): boolean {
|
|
271
|
-
const leftId = typeof left.id === "string" ? left.id : undefined;
|
|
272
|
-
const rightId = typeof right.id === "string" ? right.id : undefined;
|
|
273
|
-
if (leftId && rightId) return leftId === rightId;
|
|
274
|
-
const leftCallId = typeof left.call_id === "string" ? left.call_id : undefined;
|
|
275
|
-
const rightCallId = typeof right.call_id === "string" ? right.call_id : undefined;
|
|
276
|
-
if (leftCallId && rightCallId) return leftCallId === rightCallId && left.type === right.type;
|
|
277
|
-
if (left.type === "message" && right.type === "message") {
|
|
278
|
-
return left.role === right.role && stableStringify(left.content) === stableStringify(right.content);
|
|
279
|
-
}
|
|
280
|
-
return stableStringify(left) === stableStringify(right);
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
function findLastSubsequence(items: ResponseItem[], sequence: ResponseItem[]): number {
|
|
284
|
-
if (sequence.length === 0) return -1;
|
|
285
|
-
for (let start = items.length - sequence.length; start >= 0; start--) {
|
|
286
|
-
if (sequence.every((item, offset) => itemMatches(items[start + offset], item))) return start;
|
|
287
|
-
}
|
|
288
|
-
return -1;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
export function rewriteResponsesPayload(
|
|
292
|
-
payload: unknown,
|
|
293
|
-
details: NativeCompactionDetails,
|
|
294
|
-
postCompactionItems: ResponseItem[],
|
|
295
|
-
): unknown {
|
|
296
|
-
if (!isObject(payload) || !Array.isArray(payload.input)) return payload;
|
|
297
|
-
const currentInput = payload.input.filter(isObject) as ResponseItem[];
|
|
298
|
-
if (currentInput.length !== payload.input.length) return payload;
|
|
299
|
-
const postStart = findLastSubsequence(currentInput, postCompactionItems);
|
|
300
|
-
if (postStart >= 0) return { ...payload, input: [...structuredClone(details.output), ...currentInput.slice(postStart)] };
|
|
301
|
-
const retainedOutput = details.output.filter((item) => item.type !== "compaction");
|
|
302
|
-
const retainedStart = findLastSubsequence(currentInput, retainedOutput);
|
|
303
|
-
if (retainedStart >= 0) {
|
|
304
|
-
return { ...payload, input: [...structuredClone(details.output), ...currentInput.slice(retainedStart + retainedOutput.length)] };
|
|
305
|
-
}
|
|
306
|
-
return { ...payload, input: [...structuredClone(details.output), ...structuredClone(postCompactionItems)] };
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
function genericCompactionSelected(
|
|
310
|
-
pi: Pick<ExtensionAPI, "getFlag">,
|
|
311
|
-
ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted" | "sessionManager">,
|
|
312
|
-
): boolean {
|
|
313
|
-
return resolveCompactionModelPolicy(pi, ctx).hasSelectors;
|
|
314
|
-
}
|
|
47
|
+
export const supportsNativeCompaction = supportsResponsesApi;
|
|
48
|
+
const digest = (value: unknown) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
49
|
+
const transcript = (messages: AgentMessage[]): TranscriptContext => normalizeContext({ messages: convertToLlm(messages) });
|
|
315
50
|
|
|
316
|
-
|
|
317
|
-
|
|
51
|
+
/** Legacy state is inspectable, but its whole-window coverage is never replayed as v3. */
|
|
52
|
+
export function readNativeCompactionDetails(value: unknown): NativeCompactionDetails | undefined {
|
|
53
|
+
if (!isObject(value) || value.type !== NATIVE_COMPACTION_TYPE || (value.version !== 1 && value.version !== 2 && value.version !== 3)) return;
|
|
54
|
+
if (typeof value.provider !== "string" || typeof value.api !== "string" || typeof value.model !== "string") return;
|
|
55
|
+
const protocol = value.version === 1 ? "responses-compact" : value.protocol;
|
|
56
|
+
if (protocol !== "responses-compact" && protocol !== "remote-v2") return;
|
|
318
57
|
try {
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
function headerValue(headers: Record<string, string | null> | undefined, name: string): string | undefined {
|
|
331
|
-
const match = Object.entries(headers ?? {}).find(([key]) => key.toLowerCase() === name.toLowerCase());
|
|
332
|
-
return typeof match?.[1] === "string" && match[1] ? match[1] : undefined;
|
|
58
|
+
const output = validateCompactedOutput(value);
|
|
59
|
+
if (value.version === 3) {
|
|
60
|
+
const coverage = value.coverage;
|
|
61
|
+
if (!isObject(coverage) || coverage.kind !== "summarized-prefix") return;
|
|
62
|
+
for (const field of ["firstKeptEntryId", "sourceLeafId", "sourceHash", "policyHash", "inputHash", "runtimeId"]) {
|
|
63
|
+
if (typeof coverage[field] !== "string" || !coverage[field]) return;
|
|
64
|
+
}
|
|
65
|
+
if (!isObject(value.identity) || typeof value.identity.endpoint !== "string") return;
|
|
66
|
+
}
|
|
67
|
+
return { ...structuredClone(value), protocol, output } as unknown as NativeCompactionDetails;
|
|
68
|
+
} catch { return; }
|
|
333
69
|
}
|
|
334
70
|
|
|
335
|
-
function
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
try {
|
|
340
|
-
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
|
341
|
-
if (!isObject(payload)) return undefined;
|
|
342
|
-
const auth = payload["https://api.openai.com/auth"];
|
|
343
|
-
return isObject(auth) && typeof auth.chatgpt_account_id === "string" ? auth.chatgpt_account_id : undefined;
|
|
344
|
-
} catch {
|
|
345
|
-
return undefined;
|
|
71
|
+
function latestCompaction(branch: readonly SessionEntry[]) {
|
|
72
|
+
for (let index = branch.length - 1; index >= 0; index--) {
|
|
73
|
+
const entry = branch[index]!;
|
|
74
|
+
if (entry.type === "compaction") return entry;
|
|
346
75
|
}
|
|
76
|
+
return undefined;
|
|
347
77
|
}
|
|
348
78
|
|
|
349
|
-
function
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
-
for (const entry of raw.split(",")) {
|
|
353
|
-
const [candidate, deployment] = entry.split("=", 2).map((part) => part?.trim());
|
|
354
|
-
if (candidate === modelId && deployment) return deployment;
|
|
355
|
-
}
|
|
356
|
-
return modelId;
|
|
79
|
+
function header(headers: ProviderHeaders | undefined, name: string): string | undefined {
|
|
80
|
+
const value = Object.entries(headers ?? {}).find(([key]) => key.toLowerCase() === name)?.[1];
|
|
81
|
+
return typeof value === "string" && value ? value : undefined;
|
|
357
82
|
}
|
|
358
|
-
|
|
359
|
-
function routeIdentity(
|
|
360
|
-
model: Model<any>,
|
|
361
|
-
auth: { apiKey?: string; headers?: Record<string, string | null>; baseUrl?: string; env?: Record<string, string> },
|
|
362
|
-
): NativeIdentity {
|
|
83
|
+
function identity(model: Model<any>, options: ProviderStreamOptions | SimpleStreamOptions): NativeIdentity {
|
|
363
84
|
const azure = model.api === "azure-openai-responses";
|
|
85
|
+
const azureBase = options.env?.AZURE_OPENAI_BASE_URL || (options.env?.AZURE_OPENAI_RESOURCE_NAME ? `https://${options.env.AZURE_OPENAI_RESOURCE_NAME}.openai.azure.com/openai/v1` : undefined);
|
|
86
|
+
const url = new URL(azure ? azureBase ?? model.baseUrl : model.baseUrl);
|
|
87
|
+
url.username = ""; url.password = ""; url.search = ""; url.hash = "";
|
|
88
|
+
let accountId = header(options.headers, "chatgpt-account-id");
|
|
89
|
+
if (!accountId && model.api === "openai-codex-responses" && options.apiKey) {
|
|
90
|
+
try {
|
|
91
|
+
const token = JSON.parse(Buffer.from(options.apiKey.split(".")[1]!, "base64url").toString("utf8"));
|
|
92
|
+
const id = token["https://api.openai.com/auth"]?.chatgpt_account_id;
|
|
93
|
+
if (typeof id === "string") accountId = id;
|
|
94
|
+
} catch { /* A malformed credential is left to the provider, never persisted. */ }
|
|
95
|
+
}
|
|
96
|
+
const map = options.env?.AZURE_OPENAI_DEPLOYMENT_NAME_MAP?.split(",").map((entry) => entry.split("=").map((part) => part.trim()));
|
|
364
97
|
return {
|
|
365
|
-
endpoint:
|
|
366
|
-
organization:
|
|
367
|
-
project:
|
|
368
|
-
accountId
|
|
369
|
-
azureApiVersion: azure ?
|
|
370
|
-
azureDeployment: azure ?
|
|
98
|
+
endpoint: url.toString().replace(/\/+$/, ""),
|
|
99
|
+
organization: header(options.headers, "openai-organization"),
|
|
100
|
+
project: header(options.headers, "openai-project"),
|
|
101
|
+
accountId,
|
|
102
|
+
azureApiVersion: azure ? options.env?.AZURE_OPENAI_API_VERSION ?? "v1" : undefined,
|
|
103
|
+
azureDeployment: azure ? map?.find(([id]) => id === model.id)?.[1] ?? model.id : undefined,
|
|
371
104
|
};
|
|
372
105
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
106
|
+
function combineUsage(first: Usage | undefined, second: Usage | undefined): Usage | undefined {
|
|
107
|
+
if (!first) return second;
|
|
108
|
+
if (!second) return first;
|
|
109
|
+
const result = structuredClone(first);
|
|
110
|
+
for (const key of ["input", "output", "cacheRead", "cacheWrite", "totalTokens"] as const) result[key] += second[key];
|
|
111
|
+
for (const key of ["reasoning", "cacheWrite1h"] as const) if (first[key] !== undefined || second[key] !== undefined) result[key] = (first[key] ?? 0) + (second[key] ?? 0);
|
|
112
|
+
for (const key of ["input", "output", "cacheRead", "cacheWrite", "total"] as const) result.cost[key] += second.cost[key];
|
|
113
|
+
return result;
|
|
380
114
|
}
|
|
381
115
|
|
|
382
|
-
function
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
tools: activeTools(pi),
|
|
388
|
-
};
|
|
116
|
+
function supportedRouteOptions(model: Model<any>, options: ProviderStreamOptions | SimpleStreamOptions): boolean {
|
|
117
|
+
if (model.api !== "azure-openai-responses") return true;
|
|
118
|
+
const extra = options as ProviderStreamOptions;
|
|
119
|
+
if (["azureBaseUrl", "azureResourceName", "azureApiVersion", "azureDeploymentName"].some((key) => extra[key] !== undefined)) return false;
|
|
120
|
+
return !["AZURE_OPENAI_BASE_URL", "AZURE_OPENAI_RESOURCE_NAME", "AZURE_OPENAI_API_VERSION", "AZURE_OPENAI_DEPLOYMENT_NAME_MAP"].some((key) => process.env[key] !== undefined && options.env?.[key] === undefined);
|
|
389
121
|
}
|
|
390
122
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
123
|
+
interface Captured {
|
|
124
|
+
prepared: PreparedRequest;
|
|
125
|
+
model: Model<any>;
|
|
126
|
+
provider: Provider;
|
|
127
|
+
options: ProviderStreamOptions | SimpleStreamOptions;
|
|
128
|
+
branchLength: number;
|
|
129
|
+
branchHash: string;
|
|
130
|
+
routeHash: string;
|
|
131
|
+
authHash: string;
|
|
132
|
+
policyHash: string;
|
|
133
|
+
sessionId: string;
|
|
134
|
+
}
|
|
135
|
+
interface Active {
|
|
136
|
+
entryId: string;
|
|
137
|
+
detailsHash: string;
|
|
138
|
+
summary: string;
|
|
139
|
+
branchLength: number;
|
|
140
|
+
branchHash: string;
|
|
141
|
+
routeHash: string;
|
|
142
|
+
policyHash: string;
|
|
394
143
|
}
|
|
395
144
|
|
|
396
|
-
function
|
|
397
|
-
|
|
398
|
-
if
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
145
|
+
export default function (pi: ExtensionAPI): void {
|
|
146
|
+
// Evidence is deliberately process/session-local. A reload cannot prove unchanged
|
|
147
|
+
// hook/privacy policy, even if the visible summary text happens to match.
|
|
148
|
+
const secret = randomBytes(32);
|
|
149
|
+
const routeDigest = (value: unknown) => createHmac("sha256", secret).update(JSON.stringify(value)).digest("hex");
|
|
150
|
+
let runtimeId = randomUUID();
|
|
151
|
+
let ctx: ExtensionContext | undefined;
|
|
152
|
+
let snapshot: Captured | undefined;
|
|
153
|
+
let active: Active | undefined;
|
|
154
|
+
let pending: { details: NativeCompactionDetails; capture: Captured; summary: string } | undefined;
|
|
155
|
+
let uninstall: (() => void) | undefined;
|
|
156
|
+
let installedProvider: string | undefined;
|
|
157
|
+
let generation = 0;
|
|
158
|
+
let captureStatus = "no request observed";
|
|
159
|
+
let compactionStatus = "not attempted in this runtime";
|
|
160
|
+
const captureRejected = (reason: string) => { captureStatus = reason; };
|
|
161
|
+
const fallback = (reason: string) => { compactionStatus = `portable: ${reason}`; };
|
|
162
|
+
pi.registerCommand("provider-compaction-status", {
|
|
163
|
+
description: "Show content-free native compaction eligibility and last attempt",
|
|
164
|
+
handler: async (_args, context) => {
|
|
165
|
+
context.ui.notify(`Provider compaction\nObserver: ${installedProvider ? "installed" : "unavailable for current registration"}\nCapture: ${captureStatus}\nLast attempt: ${compactionStatus}`, "info");
|
|
413
166
|
},
|
|
414
|
-
};
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
async function nativeAndPortableCompaction(
|
|
418
|
-
pi: ExtensionAPI,
|
|
419
|
-
event: SessionBeforeCompactEvent,
|
|
420
|
-
ctx: ExtensionContext,
|
|
421
|
-
) {
|
|
422
|
-
const model = ctx.model;
|
|
423
|
-
const protocol = resolveNativeProtocol(model);
|
|
424
|
-
if (!model || !protocol || genericCompactionSelected(pi, ctx)) return undefined;
|
|
425
|
-
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
426
|
-
if (!auth.ok) return undefined;
|
|
427
|
-
const provider = ctx.modelRegistry.getProvider(model.provider);
|
|
428
|
-
if (!provider) return undefined;
|
|
429
|
-
const identity = routeIdentity(model, auth);
|
|
430
|
-
const branchEntries = event.branchEntries;
|
|
431
|
-
const activeEntries = ctx.sessionManager.buildContextEntries();
|
|
432
|
-
const previous = findLatestNativeCompaction(branchEntries, model, identity);
|
|
433
|
-
const input = nativeRequestInput(model, branchEntries, activeEntries, previous);
|
|
434
|
-
const native = await requestProviderCompaction({
|
|
435
|
-
provider,
|
|
436
|
-
model,
|
|
437
|
-
context: providerContext(pi, ctx, activeEntries),
|
|
438
|
-
input,
|
|
439
|
-
protocol,
|
|
440
|
-
apiKey: auth.apiKey,
|
|
441
|
-
headers: auth.headers,
|
|
442
|
-
env: auth.env,
|
|
443
|
-
signal: event.signal,
|
|
444
167
|
});
|
|
445
|
-
const
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
168
|
+
const invalidate = () => { generation++; runtimeId = randomUUID(); snapshot = undefined; active = undefined; pending = undefined; captureStatus = "invalidated by lifecycle/history/policy change"; };
|
|
169
|
+
const generic = (context: ExtensionContext) => resolveCompactionModelPolicy(pi, context).hasSelectors;
|
|
170
|
+
const route = (model: Model<any>, options: ProviderStreamOptions | SimpleStreamOptions) => routeDigest({ model, apiKey: options.apiKey, headers: options.headers, env: options.env });
|
|
171
|
+
const unchanged = (branch: readonly SessionEntry[], length: number, hash: string) => branch.length >= length && digest(branch.slice(0, length)) === hash && !branch.slice(length).some((entry) => entry.type === "context_edit");
|
|
172
|
+
|
|
173
|
+
const observe: ObserveRequest = (model, context, options) => {
|
|
174
|
+
const owner = ctx;
|
|
175
|
+
if (!owner || options.sessionId !== owner.sessionManager.getSessionId()) return;
|
|
176
|
+
if (!supportsNativeCompaction(model)) { captureRejected("unsupported API"); return; }
|
|
177
|
+
if (!options.onPayload) { captureRejected("no payload callback"); return; }
|
|
178
|
+
if (generic(owner)) { captureRejected("generic compaction model takes precedence"); return; }
|
|
179
|
+
// The Azure adapter also accepts API-specific overrides and process-env
|
|
180
|
+
// fallbacks. Those must not silently select a route absent from our evidence.
|
|
181
|
+
if (!supportedRouteOptions(model, options)) { captureRejected("unsupported route overrides"); return; }
|
|
182
|
+
const ticket = generation;
|
|
183
|
+
const branch = owner.sessionManager.getBranch();
|
|
184
|
+
const branchHash = digest(branch);
|
|
185
|
+
const canonical = transcript(owner.sessionManager.buildSessionProjection().messages);
|
|
186
|
+
const current = () => generation === ticket && ctx === owner && !options.signal?.aborted && supportedRouteOptions(model, options) && digest(owner.sessionManager.getBranch()) === branchHash;
|
|
187
|
+
return async (payload) => {
|
|
188
|
+
if (!current()) return payload;
|
|
189
|
+
snapshot = undefined;
|
|
190
|
+
const prepared = capturePreparedRequest(model, context, canonical, payload, captureRejected);
|
|
191
|
+
if (!prepared) { active = undefined; return payload; }
|
|
192
|
+
const auth = await owner.modelRegistry.getApiKeyAndHeaders(model);
|
|
193
|
+
if (!current()) return payload;
|
|
194
|
+
// A registry lookup after payload hooks must describe the credentials used by
|
|
195
|
+
// THIS request, not a newly rotated account. Header/env rewrites without an
|
|
196
|
+
// exact auth correspondence are deliberately ineligible.
|
|
197
|
+
const normalizedHeaders = (headers: ProviderHeaders | undefined) => Object.entries(headers ?? {}).map(([name, value]) => [name.toLowerCase(), value]).sort(([a], [b]) => String(a).localeCompare(String(b)));
|
|
198
|
+
if (!auth.ok || generic(owner) || auth.apiKey !== options.apiKey || (auth.baseUrl ?? model.baseUrl) !== model.baseUrl || !equal(normalizedHeaders(auth.headers), normalizedHeaders(options.headers)) || !equal(auth.env ?? {}, options.env ?? {})) {
|
|
199
|
+
active = undefined;
|
|
200
|
+
captureRejected("resolved auth/route differs from prepared request, or generic policy changed");
|
|
201
|
+
return payload;
|
|
202
|
+
}
|
|
203
|
+
const provider = owner.modelRegistry.getProvider(model.provider);
|
|
204
|
+
if (!provider) { captureRejected("provider unavailable"); return payload; }
|
|
205
|
+
const routeHash = route(model, options);
|
|
206
|
+
const policyHash = digest(requestPolicy(model, prepared));
|
|
207
|
+
const latest = latestCompaction(branch);
|
|
208
|
+
let result = payload;
|
|
209
|
+
if (active && latest?.id === active.entryId && latest.summary === active.summary && digest(latest.details) === active.detailsHash && unchanged(branch, active.branchLength, active.branchHash) && active.routeHash === routeHash && active.policyHash === policyHash) {
|
|
210
|
+
const details = readNativeCompactionDetails(latest.details);
|
|
211
|
+
const projection = owner.sessionManager.buildSessionProjection();
|
|
212
|
+
const contribution = projection.entries.find((entry) => entry.sourceEntry.id === latest.id);
|
|
213
|
+
const prefix = contribution ? transcript(contribution.messages) : undefined;
|
|
214
|
+
const slot = prefix ? summarySlot(model, prepared, prefix) : undefined;
|
|
215
|
+
if (details?.version === 3 && details.coverage?.runtimeId === runtimeId && details.coverage.firstKeptEntryId === latest.firstKeptEntryId && slot !== undefined) {
|
|
216
|
+
const replaced = substituteSummary(payload, slot, prepared.portableInput[slot]!, details.output);
|
|
217
|
+
if (replaced) {
|
|
218
|
+
result = replaced;
|
|
219
|
+
prepared.payload = structuredClone(replaced);
|
|
220
|
+
prepared.replacement = { index: slot, count: details.output.length };
|
|
221
|
+
} else active = undefined;
|
|
222
|
+
} else active = undefined;
|
|
223
|
+
} else active = undefined;
|
|
224
|
+
captureStatus = "eligible prepared request captured";
|
|
225
|
+
snapshot = {
|
|
226
|
+
prepared, model: structuredClone(model), provider,
|
|
227
|
+
options: { apiKey: options.apiKey, headers: structuredClone(options.headers), env: structuredClone(options.env), fetch: options.fetch },
|
|
228
|
+
branchLength: branch.length, branchHash, routeHash, authHash: routeDigest(auth), policyHash,
|
|
229
|
+
sessionId: owner.sessionManager.getSessionId(),
|
|
230
|
+
};
|
|
231
|
+
return result;
|
|
232
|
+
};
|
|
473
233
|
};
|
|
474
|
-
return {
|
|
475
|
-
compaction: {
|
|
476
|
-
...portable,
|
|
477
|
-
usage: combineUsage(native.usage, portable.usage),
|
|
478
|
-
details,
|
|
479
|
-
},
|
|
480
|
-
};
|
|
481
|
-
}
|
|
482
234
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
if (
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
const
|
|
495
|
-
|
|
496
|
-
const rewritten = rewriteResponsesPayload(event.payload, latest, postItems);
|
|
497
|
-
if (rewritten === event.payload || !isObject(rewritten)) return rewritten;
|
|
498
|
-
const systemPrompt = ctx.getSystemPrompt();
|
|
499
|
-
return systemPrompt ? { ...rewritten, instructions: systemPrompt } : rewritten;
|
|
235
|
+
const install = (context: ExtensionContext) => {
|
|
236
|
+
ctx = context;
|
|
237
|
+
if (installedProvider === context.model?.provider) return;
|
|
238
|
+
uninstall?.(); uninstall = undefined; installedProvider = undefined;
|
|
239
|
+
if (!supportsNativeCompaction(context.model)) return;
|
|
240
|
+
uninstall = installObserver(pi, context, observe);
|
|
241
|
+
if (uninstall) installedProvider = context.model.provider;
|
|
242
|
+
captureStatus = uninstall ? "awaiting prepared request" : "unsupported provider registration";
|
|
243
|
+
};
|
|
244
|
+
pi.on("session_start", (_event, context) => {
|
|
245
|
+
invalidate(); install(context);
|
|
246
|
+
const latest = latestCompaction(context.sessionManager.getBranch());
|
|
247
|
+
if (latest?.summary === NATIVE_COMPACTION_SUMMARY) context.ui.notify("Legacy native compaction has no portable summary. Native replay is disabled; recover from earlier history or start a new session.", "warning");
|
|
500
248
|
});
|
|
501
|
-
|
|
502
|
-
pi.on("
|
|
249
|
+
pi.on("before_agent_start", (_event, context) => { install(context); });
|
|
250
|
+
pi.on("model_select", (_event, context) => { invalidate(); install(context); });
|
|
251
|
+
pi.on("session_tree", () => { invalidate(); });
|
|
252
|
+
pi.on("session_shutdown", () => { invalidate(); uninstall?.(); uninstall = undefined; installedProvider = undefined; ctx = undefined; });
|
|
253
|
+
// Privacy-policy owners must invalidate before changing a policy that can affect
|
|
254
|
+
// hidden covered history. Arbitrary dynamic privacy compositions are unsupported.
|
|
255
|
+
const removeInvalidation = pi.events.on("pi-provider-compaction:invalidate", invalidate);
|
|
256
|
+
pi.on("session_shutdown", () => { if (typeof removeInvalidation === "function") removeInvalidation(); });
|
|
257
|
+
|
|
258
|
+
pi.on("session_before_compact", async (event, context) => {
|
|
259
|
+
const capture = snapshot;
|
|
260
|
+
snapshot = undefined; pending = undefined;
|
|
261
|
+
if (!capture) { fallback(`no eligible snapshot (${captureStatus})`); return; }
|
|
262
|
+
if (!context.model || generic(context) || event.signal.aborted) { fallback("missing model, generic model override, or aborted attempt"); return; }
|
|
263
|
+
const protocol = resolveNativeProtocol(context.model);
|
|
264
|
+
if (!protocol || context.sessionManager.getSessionId() !== capture.sessionId) { fallback("unsupported protocol or changed session"); return; }
|
|
265
|
+
const ticket = generation;
|
|
266
|
+
const branch = context.sessionManager.getBranch();
|
|
267
|
+
const branchHash = digest(branch);
|
|
268
|
+
if (!equal(branch, event.branchEntries) || !unchanged(branch, capture.branchLength, capture.branchHash)) { fallback("history changed since capture"); return; }
|
|
269
|
+
const projection = context.sessionManager.buildSessionProjection();
|
|
270
|
+
const boundary = projection.entries.findIndex((entry) => entry.sourceEntry.id === event.preparation.firstKeptEntryId);
|
|
271
|
+
if (boundary < 0) { fallback("retained boundary missing from projection"); return; }
|
|
272
|
+
const prefix = transcript(projection.entries.slice(0, boundary).flatMap((entry) => entry.messages));
|
|
273
|
+
const input = selectCoveredPrefix(capture.model, capture.prepared, prefix, fallback);
|
|
274
|
+
if (!input) return;
|
|
275
|
+
const current = () => ticket === generation && !event.signal.aborted && supportedRouteOptions(capture.model, capture.options) && !generic(context) && context.modelRegistry.getProvider(capture.model.provider) === capture.provider && digest(context.sessionManager.getBranch()) === branchHash;
|
|
276
|
+
const auth = await context.modelRegistry.getApiKeyAndHeaders(context.model);
|
|
277
|
+
if (!current()) return { cancel: true };
|
|
278
|
+
if (!auth.ok || routeDigest(auth) !== capture.authHash) { fallback("auth changed since capture"); return; }
|
|
279
|
+
compactionStatus = "generating portable summary";
|
|
280
|
+
let portable: Awaited<ReturnType<typeof compact>>;
|
|
503
281
|
try {
|
|
504
|
-
|
|
505
|
-
} catch
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
return undefined;
|
|
282
|
+
portable = await compact(event.preparation, capture.model, auth.apiKey, auth.headers as Record<string, string> | undefined, event.customInstructions, event.signal, context.thinkingLevel, capture.provider.streamSimple.bind(capture.provider), auth.env, undefined, undefined, capture.sessionId);
|
|
283
|
+
} catch {
|
|
284
|
+
// No result exists to return; let Pi own its ordinary failure/fallback behavior.
|
|
285
|
+
return !current() ? { cancel: true } : undefined;
|
|
509
286
|
}
|
|
287
|
+
if (!current() || !portable.summary.trim() || portable.summary === NATIVE_COMPACTION_SUMMARY) return { cancel: true };
|
|
288
|
+
try {
|
|
289
|
+
const freshAuth = await context.modelRegistry.getApiKeyAndHeaders(context.model);
|
|
290
|
+
if (!current()) return { cancel: true };
|
|
291
|
+
if (!freshAuth.ok || routeDigest(freshAuth) !== capture.authHash) { fallback("auth changed after portable generation"); return { compaction: portable }; }
|
|
292
|
+
compactionStatus = "requesting native checkpoint";
|
|
293
|
+
const native = await requestProviderCompaction({
|
|
294
|
+
provider: capture.provider, model: capture.model, context: capture.prepared.context,
|
|
295
|
+
input, preparedPayload: capture.prepared.payload, protocol,
|
|
296
|
+
apiKey: capture.options.apiKey, headers: capture.options.headers, env: capture.options.env,
|
|
297
|
+
signal: event.signal, fetch: capture.options.fetch,
|
|
298
|
+
});
|
|
299
|
+
if (!current()) return { cancel: true };
|
|
300
|
+
const finalAuth = await context.modelRegistry.getApiKeyAndHeaders(context.model);
|
|
301
|
+
if (!current()) return { cancel: true };
|
|
302
|
+
if (!finalAuth.ok || routeDigest(finalAuth) !== capture.authHash) { fallback("auth changed after native generation"); return { compaction: portable }; }
|
|
303
|
+
const details: NativeCompactionDetails = {
|
|
304
|
+
...(isObject(portable.details) ? portable.details : {}),
|
|
305
|
+
type: NATIVE_COMPACTION_TYPE, version: 3, provider: capture.model.provider, api: capture.model.api, model: capture.model.id,
|
|
306
|
+
protocol, identity: identity(capture.model, capture.options), output: native.output,
|
|
307
|
+
nativeUsage: native.usage, portableUsage: portable.usage,
|
|
308
|
+
coverage: { kind: "summarized-prefix", firstKeptEntryId: portable.firstKeptEntryId, sourceLeafId: branch.at(-1)!.id, sourceHash: branchHash, policyHash: capture.policyHash, inputHash: digest(input), runtimeId },
|
|
309
|
+
};
|
|
310
|
+
pending = { details, capture, summary: portable.summary };
|
|
311
|
+
return { compaction: { ...portable, usage: combineUsage(native.usage, portable.usage), details } };
|
|
312
|
+
} catch {
|
|
313
|
+
if (!current()) return { cancel: true };
|
|
314
|
+
// Do not expose exception text: provider errors may contain request data.
|
|
315
|
+
fallback("native acquisition or validation failed");
|
|
316
|
+
// The portable request already succeeded. Never pay for a duplicate summary.
|
|
317
|
+
return { compaction: portable };
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
pi.on("session_compact", (event, context) => {
|
|
321
|
+
if (readNativeCompactionDetails(event.compactionEntry.details)?.version === 3) compactionStatus = "native checkpoint committed";
|
|
322
|
+
else if (!compactionStatus.startsWith("portable:")) fallback("another handler supplied the committed result");
|
|
323
|
+
// Only static diagnostics are persisted, never request/auth data or error text.
|
|
324
|
+
if (compactionStatus.startsWith("portable:")) pi.appendEntry("pi-provider-compaction:diagnostic", { status: compactionStatus });
|
|
325
|
+
active = undefined; snapshot = undefined;
|
|
326
|
+
const candidate = pending; pending = undefined;
|
|
327
|
+
if (!candidate || !equal(event.compactionEntry.details, candidate.details) || event.compactionEntry.summary !== candidate.summary) return;
|
|
328
|
+
const branch = context.sessionManager.getBranch();
|
|
329
|
+
const coverage = candidate.details.coverage;
|
|
330
|
+
if (!coverage || event.compactionEntry.firstKeptEntryId !== coverage.firstKeptEntryId || event.compactionEntry.parentId !== coverage.sourceLeafId || branch.at(-1)?.id !== event.compactionEntry.id || digest(branch.slice(0, -1)) !== coverage.sourceHash) return;
|
|
331
|
+
active = { entryId: event.compactionEntry.id, detailsHash: digest(candidate.details), summary: candidate.summary, branchLength: branch.length, branchHash: digest(branch), routeHash: candidate.capture.routeHash, policyHash: candidate.capture.policyHash };
|
|
510
332
|
});
|
|
333
|
+
pi.on("session_compact_failed", () => { pending = undefined; snapshot = undefined; compactionStatus = "compaction failed or cancelled"; });
|
|
511
334
|
}
|