@nijaru/pi-provider-compaction 0.0.7 → 0.0.9
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 +51 -15
- package/cached-summary.ts +230 -0
- package/dist/index.js +798 -196
- package/index.ts +220 -60
- package/package.json +6 -5
- package/protocol.ts +35 -4
- package/provider.ts +157 -37
package/index.ts
CHANGED
|
@@ -3,9 +3,10 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
|
3
3
|
import { normalizeContext, registerSessionResourceCleanup, type Model, type Provider, type ProviderHeaders, type ProviderStreamOptions, type SimpleStreamOptions, type TranscriptContext, type Usage } from "@earendil-works/pi-ai";
|
|
4
4
|
import { compact, convertToLlm, type ExtensionAPI, type ExtensionContext, type SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { resolveCompactionModelPolicy } from "./policy";
|
|
6
|
+
import { cacheConfig, cachedSummary, captureCachedRequest, finishCachedRequest, requestCacheState, sameCachedPrompt, supportsCachedSummary, withFileTracking, type CachedRequest } from "./cached-summary";
|
|
6
7
|
import { installObserver, type InstalledObserver, type ObserveRequest } from "./provider";
|
|
7
8
|
import { capturePreparedRequest, equal, requestPolicy, selectCoveredPrefix, substituteSummary, summarySlot, type PreparedRequest } from "./replay";
|
|
8
|
-
import { isObject, requestProviderCompaction, resolveNativeProtocol, supportsResponsesApi, validateCompactedOutput, type NativeProtocol, type ResponseItem } from "./protocol";
|
|
9
|
+
import { isObject, NativeCompactionValidationError, requestProviderCompaction, resolveNativeProtocol, supportsResponsesApi, validateCompactedOutput, type NativeProtocol, type ResponseItem } from "./protocol";
|
|
9
10
|
|
|
10
11
|
export const NATIVE_COMPACTION_TYPE = "pi-provider-compaction/openai-responses";
|
|
11
12
|
export const NATIVE_COMPACTION_VERSION = 3;
|
|
@@ -124,7 +125,7 @@ interface Captured {
|
|
|
124
125
|
prepared: PreparedRequest;
|
|
125
126
|
model: Model<any>;
|
|
126
127
|
provider: Provider;
|
|
127
|
-
options: ProviderStreamOptions | SimpleStreamOptions;
|
|
128
|
+
options: (ProviderStreamOptions | SimpleStreamOptions) & { serviceTier?: string };
|
|
128
129
|
branchLength: number;
|
|
129
130
|
branchHash: string;
|
|
130
131
|
routeHash: string;
|
|
@@ -134,49 +135,109 @@ interface Captured {
|
|
|
134
135
|
}
|
|
135
136
|
interface Active {
|
|
136
137
|
entryId: string;
|
|
137
|
-
detailsHash: string;
|
|
138
138
|
summary: string;
|
|
139
139
|
branchLength: number;
|
|
140
140
|
branchHash: string;
|
|
141
141
|
routeHash: string;
|
|
142
142
|
policyHash: string;
|
|
143
|
+
/** Model field of the prepared payload; a request hook rewrite invalidates native state. */
|
|
144
|
+
payloadModel?: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
interface ExpiryAttempt {
|
|
148
|
+
sessionId: string;
|
|
149
|
+
cycle: string;
|
|
150
|
+
isCurrent(): boolean;
|
|
151
|
+
signal?: AbortSignal;
|
|
143
152
|
}
|
|
144
153
|
|
|
145
154
|
export default function (pi: ExtensionAPI): void {
|
|
146
155
|
// Evidence is deliberately process/session-local. A reload cannot prove unchanged
|
|
147
156
|
// hook/privacy policy, even if the visible summary text happens to match.
|
|
157
|
+
// Older hosts in the native adapter's supported range lack getSettings.
|
|
158
|
+
// Keep their existing native path; cache preferences require that host API.
|
|
159
|
+
const configuration = () => typeof pi.getSettings === "function" ? cacheConfig(pi.getSettings()) : { cachedSummaries: false, outcomeNotifications: false, cacheRetention: undefined };
|
|
148
160
|
const secret = randomBytes(32);
|
|
149
161
|
const routeDigest = (value: unknown) => createHmac("sha256", secret).update(JSON.stringify(value)).digest("hex");
|
|
150
162
|
let runtimeId = randomUUID();
|
|
151
163
|
let ctx: ExtensionContext | undefined;
|
|
152
164
|
let snapshot: Captured | undefined;
|
|
165
|
+
let cache: CachedRequest | undefined;
|
|
166
|
+
let summarizing = false;
|
|
167
|
+
let expiryAttempt: ExpiryAttempt | undefined;
|
|
168
|
+
let lastOutcome: { path: string; elapsedMs: number; usage?: Usage; reason?: string } | undefined;
|
|
153
169
|
let active: Active | undefined;
|
|
154
170
|
let pending: { details: NativeCompactionDetails; capture: Captured; summary: string } | undefined;
|
|
155
171
|
let observer: InstalledObserver | undefined;
|
|
156
172
|
let unregisterCleanup: (() => void) | undefined;
|
|
157
173
|
let generation = 0;
|
|
174
|
+
let requestSequence = 0;
|
|
158
175
|
let captureStatus = "no request observed";
|
|
159
176
|
let compactionStatus = "not attempted in this runtime";
|
|
177
|
+
let fallbackReason: string | undefined;
|
|
160
178
|
const captureRejected = (reason: string) => { captureStatus = reason; };
|
|
161
|
-
const fallback = (reason: string) => { compactionStatus = `portable: ${reason}`; };
|
|
179
|
+
const fallback = (reason: string) => { fallbackReason = reason; compactionStatus = `portable: ${reason}`; };
|
|
162
180
|
pi.registerCommand("provider-compaction-status", {
|
|
163
|
-
description: "Show
|
|
181
|
+
description: "Show cache/native eligibility and measured compaction usage",
|
|
164
182
|
handler: async (_args, context) => {
|
|
165
|
-
|
|
183
|
+
const state = cacheState(context);
|
|
184
|
+
const remaining = state.expiresAt === undefined ? "unknown lifetime" : `estimated ${Math.max(0, Math.ceil((state.expiresAt - Date.now()) / 1000))}s remaining (model declaration; best effort)`;
|
|
185
|
+
const usage = lastOutcome?.usage;
|
|
186
|
+
context.ui.notify(`Provider compaction\nObserver: ${observer?.isInstalled() ? "installed" : "unavailable for current registration"}\nCapture: ${captureStatus}\nCache: ${state.reason}; ${remaining}\nLast attempt: ${compactionStatus}${lastOutcome ? `\nOutcome: ${lastOutcome.path}, ${lastOutcome.elapsedMs}ms${usage ? `; input ${usage.input}, cache read ${usage.cacheRead}, write ${usage.cacheWrite}, output ${usage.output}; catalog cost $${usage.cost.total.toFixed(6)}` : "; usage unavailable"}${lastOutcome.reason ? `; ${lastOutcome.reason}` : ""}` : ""}`, "info");
|
|
166
187
|
},
|
|
167
188
|
});
|
|
168
|
-
const invalidate = () => { generation++; runtimeId = randomUUID(); snapshot = undefined; active = undefined; pending = undefined; captureStatus = "invalidated by lifecycle/history/policy change"; };
|
|
189
|
+
const invalidate = () => { generation++; runtimeId = randomUUID(); snapshot = undefined; cache = undefined; active = undefined; pending = undefined; expiryAttempt = undefined; captureStatus = "invalidated by lifecycle/history/policy change"; };
|
|
169
190
|
const retire = () => {
|
|
170
191
|
invalidate();
|
|
171
192
|
const installed = observer;
|
|
172
193
|
observer = undefined; ctx = undefined;
|
|
173
194
|
unregisterCleanup?.(); unregisterCleanup = undefined;
|
|
174
195
|
installed?.remove();
|
|
196
|
+
lastOutcome = undefined;
|
|
175
197
|
};
|
|
176
198
|
const generic = (context: ExtensionContext) => resolveCompactionModelPolicy(pi, context).hasSelectors;
|
|
177
199
|
const route = (model: Model<any>, options: ProviderStreamOptions | SimpleStreamOptions) => routeDigest({ model, apiKey: options.apiKey, headers: options.headers, env: options.env });
|
|
178
200
|
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");
|
|
179
201
|
|
|
202
|
+
const cacheState = (context: ExtensionContext) => {
|
|
203
|
+
let reason: string | undefined;
|
|
204
|
+
if (!configuration().cachedSummaries) reason = "cached summaries disabled";
|
|
205
|
+
else if (generic(context)) reason = "explicit compaction model takes precedence";
|
|
206
|
+
else if (cache && !observer?.isInstalled()) reason = "request observer no longer owned";
|
|
207
|
+
else if (cache && (cache.sessionId !== context.sessionManager.getSessionId() || context.model?.id !== cache.model.id || context.model?.provider !== cache.model.provider || context.model?.api !== cache.model.api || context.modelRegistry.getProvider(cache.model.provider) !== cache.provider || !unchanged(context.sessionManager.getBranch(), cache.branchLength, cache.branchHash))) reason = "cache snapshot no longer matches session/model/history";
|
|
208
|
+
return requestCacheState(cache, reason);
|
|
209
|
+
};
|
|
210
|
+
const removeCacheState = pi.events.on("pi-provider-compaction:cache-state", (request) => {
|
|
211
|
+
if (!isObject(request) || !isObject(request.context) || typeof request.respond !== "function") return;
|
|
212
|
+
try { request.respond(cacheState(request.context as unknown as ExtensionContext)); } catch { /* Stale requester. */ }
|
|
213
|
+
});
|
|
214
|
+
const removeExpiry = pi.events.on("pi-provider-compaction:compact-before-expiry", (request) => {
|
|
215
|
+
if (!isObject(request) || !isObject(request.context) || typeof request.respond !== "function" || typeof request.run !== "function" || typeof request.isCurrent !== "function") return;
|
|
216
|
+
const context = request.context as unknown as ExtensionContext;
|
|
217
|
+
const run = request.run as () => Promise<boolean>;
|
|
218
|
+
const authorized = request.isCurrent as () => boolean;
|
|
219
|
+
const execute = async () => {
|
|
220
|
+
const state = cacheState(context);
|
|
221
|
+
if (expiryAttempt || !state.available || state.cycle !== request.cycle || state.expiresAt === undefined || Date.now() >= state.expiresAt || !context.isIdle() || context.hasPendingMessages() || !authorized()) return false;
|
|
222
|
+
const ticket = generation;
|
|
223
|
+
const attempt: ExpiryAttempt = {
|
|
224
|
+
sessionId: context.sessionManager.getSessionId(), cycle: state.cycle,
|
|
225
|
+
isCurrent: () => {
|
|
226
|
+
try {
|
|
227
|
+
return expiryAttempt === attempt && generation === ticket &&
|
|
228
|
+
context.sessionManager.getSessionId() === attempt.sessionId &&
|
|
229
|
+
!context.hasPendingMessages() && authorized();
|
|
230
|
+
} catch { return false; }
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
expiryAttempt = attempt;
|
|
234
|
+
try { return await run(); }
|
|
235
|
+
finally { if (expiryAttempt === attempt) expiryAttempt = undefined; }
|
|
236
|
+
};
|
|
237
|
+
request.respond(execute());
|
|
238
|
+
});
|
|
239
|
+
pi.on("session_shutdown", () => { removeCacheState(); removeExpiry(); });
|
|
240
|
+
|
|
180
241
|
const observe: ObserveRequest = (model, context, options) => {
|
|
181
242
|
const owner = ctx;
|
|
182
243
|
if (!owner) return;
|
|
@@ -189,26 +250,35 @@ export default function (pi: ExtensionAPI): void {
|
|
|
189
250
|
return;
|
|
190
251
|
}
|
|
191
252
|
if (options.sessionId !== manager.getSessionId()) return;
|
|
192
|
-
if (
|
|
193
|
-
if (!
|
|
253
|
+
if (summarizing) return;
|
|
254
|
+
if (!supportsNativeCompaction(model) && !supportsCachedSummary(model)) { captureRejected("unsupported API"); return; }
|
|
255
|
+
if (!options.onPayload) {
|
|
256
|
+
// The portable-summary request re-enters this observer without a payload
|
|
257
|
+
// callback. Do not overwrite the main request's capture diagnostic.
|
|
258
|
+
if (captureStatus !== "eligible prepared request captured") captureRejected("no payload callback");
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
194
261
|
if (generic(owner)) { captureRejected("generic compaction model takes precedence"); return; }
|
|
195
262
|
// The Azure adapter also accepts API-specific overrides and process-env
|
|
196
263
|
// fallbacks. Those must not silently select a route absent from our evidence.
|
|
197
264
|
if (!supportedRouteOptions(model, options)) { captureRejected("unsupported route overrides"); return; }
|
|
198
265
|
const ticket = generation;
|
|
266
|
+
const startedAt = Date.now();
|
|
267
|
+
const sequence = ++requestSequence;
|
|
268
|
+
const config = configuration();
|
|
269
|
+
const effectiveOptions = { ...options, ...(config.cacheRetention === undefined ? {} : { cacheRetention: config.cacheRetention }) };
|
|
270
|
+
let candidate: CachedRequest | undefined;
|
|
199
271
|
const branch = manager.getBranch();
|
|
200
272
|
const branchHash = digest(branch);
|
|
201
273
|
const canonical = transcript(manager.buildSessionProjection().messages);
|
|
202
274
|
const current = () => {
|
|
203
|
-
if (generation !== ticket || ctx !== owner || options.signal?.aborted || !supportedRouteOptions(model, options)) return false;
|
|
204
|
-
try { return
|
|
275
|
+
if (sequence !== requestSequence || generation !== ticket || ctx !== owner || options.signal?.aborted || !supportedRouteOptions(model, options)) return false;
|
|
276
|
+
try { return unchanged(owner.sessionManager.getBranch(), branch.length, branchHash); }
|
|
205
277
|
catch { retire(); return false; }
|
|
206
278
|
};
|
|
207
|
-
|
|
279
|
+
const handler = async (payload: unknown) => {
|
|
208
280
|
if (!current()) return payload;
|
|
209
281
|
snapshot = undefined;
|
|
210
|
-
const prepared = capturePreparedRequest(model, context, canonical, payload, captureRejected);
|
|
211
|
-
if (!prepared) { active = undefined; return payload; }
|
|
212
282
|
const auth = await owner.modelRegistry.getApiKeyAndHeaders(model);
|
|
213
283
|
if (!current()) return payload;
|
|
214
284
|
// A registry lookup after payload hooks must describe the credentials used by
|
|
@@ -216,17 +286,29 @@ export default function (pi: ExtensionAPI): void {
|
|
|
216
286
|
// exact auth correspondence are deliberately ineligible.
|
|
217
287
|
const normalizedHeaders = (headers: ProviderHeaders | undefined) => Object.entries(headers ?? {}).map(([name, value]) => [name.toLowerCase(), value]).sort(([a], [b]) => String(a).localeCompare(String(b)));
|
|
218
288
|
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 ?? {})) {
|
|
219
|
-
active = undefined;
|
|
289
|
+
active = undefined; cache = undefined;
|
|
220
290
|
captureRejected("resolved auth/route differs from prepared request, or generic policy changed");
|
|
221
291
|
return payload;
|
|
222
292
|
}
|
|
223
293
|
const provider = owner.modelRegistry.getProvider(model.provider);
|
|
224
294
|
if (!provider) { captureRejected("provider unavailable"); return payload; }
|
|
225
295
|
const routeHash = route(model, options);
|
|
296
|
+
if (config.cachedSummaries && supportsCachedSummary(model)) {
|
|
297
|
+
const warming = options.maxTokens === 1 && cache && cache.authHash === routeDigest(auth) && sameCachedPrompt(cache.payload, payload) && equal(cache.context, context);
|
|
298
|
+
candidate = warming ? { ...cache!, startedAt, cycle: randomUUID(), usage: undefined, expiresAt: undefined } : captureCachedRequest({
|
|
299
|
+
model, provider, context, canonical, options: effectiveOptions, startedAt,
|
|
300
|
+
sessionId: manager.getSessionId(), branchLength: branch.length, branchHash, authHash: routeDigest(auth),
|
|
301
|
+
}, payload);
|
|
302
|
+
if (!warming) cache = undefined;
|
|
303
|
+
}
|
|
304
|
+
if (!supportsNativeCompaction(model)) { captureStatus = candidate ? "eligible cached request captured" : "cached request ineligible"; return payload; }
|
|
305
|
+
const prepared = capturePreparedRequest(model, context, canonical, payload, captureRejected);
|
|
306
|
+
if (!prepared) { active = undefined; return payload; }
|
|
226
307
|
const policyHash = digest(requestPolicy(model, prepared));
|
|
227
308
|
const latest = latestCompaction(branch);
|
|
309
|
+
const payloadModel = isObject(payload) && typeof payload.model === "string" ? payload.model : undefined;
|
|
228
310
|
let result = payload;
|
|
229
|
-
if (active && latest?.id === active.entryId && latest.summary === active.summary &&
|
|
311
|
+
if (active && latest?.id === active.entryId && latest.summary === active.summary && active.payloadModel === payloadModel && unchanged(branch, active.branchLength, active.branchHash) && active.routeHash === routeHash && active.policyHash === policyHash) {
|
|
230
312
|
const details = readNativeCompactionDetails(latest.details);
|
|
231
313
|
const projection = owner.sessionManager.buildSessionProjection();
|
|
232
314
|
const contribution = projection.entries.find((entry) => entry.sourceEntry.id === latest.id);
|
|
@@ -238,25 +320,35 @@ export default function (pi: ExtensionAPI): void {
|
|
|
238
320
|
result = replaced;
|
|
239
321
|
prepared.payload = structuredClone(replaced);
|
|
240
322
|
prepared.replacement = { index: slot, count: details.output.length };
|
|
323
|
+
// The actual request contains opaque state, not the captured readable prefix.
|
|
324
|
+
candidate = undefined; cache = undefined;
|
|
241
325
|
} else active = undefined;
|
|
242
326
|
} else active = undefined;
|
|
243
327
|
} else active = undefined;
|
|
244
328
|
captureStatus = "eligible prepared request captured";
|
|
245
329
|
snapshot = {
|
|
246
330
|
prepared, model: structuredClone(model), provider,
|
|
247
|
-
options: { apiKey: options.apiKey, headers: structuredClone(options.headers), env: structuredClone(options.env), fetch: options.fetch },
|
|
331
|
+
options: { apiKey: options.apiKey, headers: structuredClone(options.headers), env: structuredClone(options.env), fetch: options.fetch, ...(typeof (options as { serviceTier?: unknown }).serviceTier === "string" ? { serviceTier: (options as { serviceTier: string }).serviceTier } : {}) },
|
|
248
332
|
branchLength: branch.length, branchHash, routeHash, authHash: routeDigest(auth), policyHash,
|
|
249
333
|
sessionId: owner.sessionManager.getSessionId(),
|
|
250
334
|
};
|
|
251
335
|
return result;
|
|
252
336
|
};
|
|
337
|
+
return Object.assign(handler, {
|
|
338
|
+
cacheRetention: config.cacheRetention,
|
|
339
|
+
complete: (message: Parameters<typeof finishCachedRequest>[1]) => {
|
|
340
|
+
if (!candidate || sequence !== requestSequence || !current()) return;
|
|
341
|
+
finishCachedRequest(candidate, message);
|
|
342
|
+
if (candidate.usage) cache = candidate;
|
|
343
|
+
},
|
|
344
|
+
});
|
|
253
345
|
};
|
|
254
346
|
|
|
255
347
|
const install = (context: ExtensionContext) => {
|
|
256
348
|
ctx = context;
|
|
257
349
|
if (observer?.provider === context.model?.provider && observer?.isInstalled()) return;
|
|
258
350
|
if (observer) { invalidate(); observer.remove(); observer = undefined; }
|
|
259
|
-
if (!supportsNativeCompaction(context.model)) return;
|
|
351
|
+
if (!supportsNativeCompaction(context.model) && !supportsCachedSummary(context.model)) return;
|
|
260
352
|
observer = installObserver(pi, context, observe);
|
|
261
353
|
captureStatus = observer ? "awaiting prepared request" : "unsupported provider registration";
|
|
262
354
|
};
|
|
@@ -281,50 +373,102 @@ export default function (pi: ExtensionAPI): void {
|
|
|
281
373
|
pi.on("session_shutdown", () => { if (typeof removeInvalidation === "function") removeInvalidation(); });
|
|
282
374
|
|
|
283
375
|
pi.on("session_before_compact", async (event, context) => {
|
|
376
|
+
const began = Date.now();
|
|
377
|
+
compactionStatus = "preparing compaction";
|
|
378
|
+
fallbackReason = undefined;
|
|
379
|
+
const attempt = expiryAttempt;
|
|
284
380
|
const capture = snapshot;
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
381
|
+
const cached = cacheState(context).available ? cache : undefined;
|
|
382
|
+
snapshot = undefined; cache = undefined; pending = undefined;
|
|
383
|
+
const cancelled = (usage?: Usage, reason = "compaction ownership or input changed") => {
|
|
384
|
+
lastOutcome = { path: attempt ? "expiry cancelled" : "cancelled", elapsedMs: Date.now() - began, usage, reason };
|
|
385
|
+
return { cancel: true as const };
|
|
386
|
+
};
|
|
387
|
+
const reject = (reason: string) => {
|
|
388
|
+
fallback(reason);
|
|
389
|
+
return attempt ? cancelled(undefined, reason) : undefined;
|
|
390
|
+
};
|
|
391
|
+
// The provider may run before the companion's handler. Share its live
|
|
392
|
+
// ownership/input guard instead of paying for work that a later handler cancels.
|
|
393
|
+
if (attempt) {
|
|
394
|
+
if (event.reason !== "manual" || !attempt.isCurrent() || !cached || cached.cycle !== attempt.cycle || attempt.signal && attempt.signal !== event.signal) return reject("idle transaction no longer authorized");
|
|
395
|
+
attempt.signal = event.signal;
|
|
396
|
+
}
|
|
397
|
+
if ((!capture && !cached) || !context.model || generic(context) || event.signal.aborted) return reject("no eligible snapshot, explicit model override, or aborted attempt");
|
|
290
398
|
const ticket = generation;
|
|
291
399
|
const branch = context.sessionManager.getBranch();
|
|
292
400
|
const branchHash = digest(branch);
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
const input = selectCoveredPrefix(capture.model, capture.prepared, prefix, fallback);
|
|
299
|
-
if (!input) return;
|
|
300
|
-
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;
|
|
401
|
+
const source = cached ?? capture!;
|
|
402
|
+
if (context.sessionManager.getSessionId() !== source.sessionId || !equal(branch, event.branchEntries) || !unchanged(branch, source.branchLength, source.branchHash)) return reject("history/session changed since capture");
|
|
403
|
+
const current = () => ticket === generation && !event.signal.aborted && (!attempt || attempt.isCurrent()) && supportedRouteOptions(source.model, source.options) && !generic(context) &&
|
|
404
|
+
context.model?.id === source.model.id && context.model?.provider === source.model.provider && context.model?.api === source.model.api &&
|
|
405
|
+
observer?.isInstalled() === true && context.modelRegistry.getProvider(source.model.provider) === source.provider && unchanged(context.sessionManager.getBranch(), branch.length, branchHash);
|
|
301
406
|
const auth = await context.modelRegistry.getApiKeyAndHeaders(context.model);
|
|
302
|
-
if (!current()) return
|
|
303
|
-
if (!auth.ok || routeDigest(auth) !==
|
|
304
|
-
|
|
305
|
-
let
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
407
|
+
if (!current()) return cancelled();
|
|
408
|
+
if (!auth.ok || routeDigest(auth) !== source.authHash) return reject("auth changed since capture");
|
|
409
|
+
const protocol = capture ? resolveNativeProtocol(context.model) : undefined;
|
|
410
|
+
let input: ResponseItem[] | undefined;
|
|
411
|
+
if (capture && protocol && unchanged(branch, capture.branchLength, capture.branchHash) && source.authHash === capture.authHash) {
|
|
412
|
+
const projection = context.sessionManager.buildSessionProjection();
|
|
413
|
+
const boundary = projection.entries.findIndex((entry) => entry.sourceEntry.id === event.preparation.firstKeptEntryId);
|
|
414
|
+
if (boundary >= 0) input = selectCoveredPrefix(capture.model, capture.prepared, transcript(projection.entries.slice(0, boundary).flatMap((entry) => entry.messages)), fallback);
|
|
415
|
+
}
|
|
416
|
+
let portable: Awaited<ReturnType<typeof compact>> | undefined;
|
|
417
|
+
let attemptedUsage: Usage | undefined;
|
|
418
|
+
let summaryPath = "standard";
|
|
419
|
+
let summaryReason: string | undefined;
|
|
420
|
+
if (cached) {
|
|
421
|
+
compactionStatus = "generating cached portable summary";
|
|
422
|
+
summarizing = true;
|
|
423
|
+
try {
|
|
424
|
+
const result = await cachedSummary(cached, context, event.preparation, event.customInstructions, event.signal, current);
|
|
425
|
+
portable = result.result;
|
|
426
|
+
attemptedUsage = result.usage;
|
|
427
|
+
summaryReason = result.reason;
|
|
428
|
+
if (portable) summaryPath = "cached";
|
|
429
|
+
} finally { summarizing = false; }
|
|
430
|
+
if (!current()) return cancelled(attemptedUsage);
|
|
431
|
+
}
|
|
432
|
+
if (!portable) {
|
|
433
|
+
if (attempt) return cancelled(attemptedUsage, summaryReason ?? "warm cache unavailable");
|
|
434
|
+
// Without native coverage or a dispatched cached attempt, Pi can own fallback.
|
|
435
|
+
if (!input && !attemptedUsage) return reject(summaryReason ?? fallbackReason ?? "native coverage unavailable");
|
|
436
|
+
compactionStatus = "generating portable summary";
|
|
437
|
+
try {
|
|
438
|
+
portable = await compact(event.preparation, source.model, auth.apiKey, auth.headers as Record<string, string> | undefined, event.customInstructions, event.signal, context.thinkingLevel, source.provider.streamSimple.bind(source.provider), auth.env, undefined, undefined, source.sessionId);
|
|
439
|
+
portable = withFileTracking(portable, event.preparation);
|
|
440
|
+
portable.usage = combineUsage(attemptedUsage, portable.usage);
|
|
441
|
+
} catch {
|
|
442
|
+
if (!current()) return cancelled(attemptedUsage);
|
|
443
|
+
return reject("portable summary generation failed");
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
if (!current() || !portable.summary.trim() || portable.summary === NATIVE_COMPACTION_SUMMARY) return cancelled(portable.usage);
|
|
447
|
+
const outcome = (path: string, result: typeof portable, reason?: string) => {
|
|
448
|
+
lastOutcome = { path, elapsedMs: Date.now() - began, usage: result!.usage, reason };
|
|
449
|
+
result!.details = { ...(isObject(result!.details) ? result!.details : {}), providerCompaction: lastOutcome };
|
|
450
|
+
return { compaction: result! };
|
|
451
|
+
};
|
|
452
|
+
if (!capture || !protocol || !input) {
|
|
453
|
+
compactionStatus = `${summaryPath} portable summary prepared`;
|
|
454
|
+
return outcome(summaryPath, portable, summaryReason);
|
|
311
455
|
}
|
|
312
|
-
if (!current() || !portable.summary.trim() || portable.summary === NATIVE_COMPACTION_SUMMARY) return { cancel: true };
|
|
313
456
|
try {
|
|
314
457
|
const freshAuth = await context.modelRegistry.getApiKeyAndHeaders(context.model);
|
|
315
|
-
if (!current()) return
|
|
316
|
-
if (!freshAuth.ok || routeDigest(freshAuth) !== capture.authHash) { fallback("auth changed after portable generation"); return
|
|
458
|
+
if (!current()) return cancelled(portable.usage);
|
|
459
|
+
if (!freshAuth.ok || routeDigest(freshAuth) !== capture.authHash) { fallback("auth changed after portable generation"); return outcome(summaryPath, portable, "native auth changed"); }
|
|
317
460
|
compactionStatus = "requesting native checkpoint";
|
|
318
461
|
const native = await requestProviderCompaction({
|
|
319
462
|
provider: capture.provider, model: capture.model, context: capture.prepared.context,
|
|
320
463
|
input, preparedPayload: capture.prepared.payload, protocol,
|
|
321
464
|
apiKey: capture.options.apiKey, headers: capture.options.headers, env: capture.options.env,
|
|
322
|
-
signal: event.signal, fetch: capture.options.fetch,
|
|
465
|
+
signal: event.signal, isCurrent: current, fetch: capture.options.fetch,
|
|
466
|
+
...(capture.options.serviceTier === undefined ? {} : { serviceTier: capture.options.serviceTier }),
|
|
323
467
|
});
|
|
324
|
-
if (!current()) return
|
|
468
|
+
if (!current()) return cancelled(combineUsage(native.usage, portable.usage));
|
|
325
469
|
const finalAuth = await context.modelRegistry.getApiKeyAndHeaders(context.model);
|
|
326
|
-
if (!current()) return
|
|
327
|
-
if (!finalAuth.ok || routeDigest(finalAuth) !== capture.authHash) { fallback("auth changed after native generation"); return
|
|
470
|
+
if (!current()) return cancelled(combineUsage(native.usage, portable.usage));
|
|
471
|
+
if (!finalAuth.ok || routeDigest(finalAuth) !== capture.authHash) { fallback("auth changed after native generation"); return outcome(summaryPath, portable, "native auth changed"); }
|
|
328
472
|
const details: NativeCompactionDetails = {
|
|
329
473
|
...(isObject(portable.details) ? portable.details : {}),
|
|
330
474
|
type: NATIVE_COMPACTION_TYPE, version: 3, provider: capture.model.provider, api: capture.model.api, model: capture.model.id,
|
|
@@ -332,28 +476,44 @@ export default function (pi: ExtensionAPI): void {
|
|
|
332
476
|
nativeUsage: native.usage, portableUsage: portable.usage,
|
|
333
477
|
coverage: { kind: "summarized-prefix", firstKeptEntryId: portable.firstKeptEntryId, sourceLeafId: branch.at(-1)!.id, sourceHash: branchHash, policyHash: capture.policyHash, inputHash: digest(input), runtimeId },
|
|
334
478
|
};
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
479
|
+
const result = outcome(`${summaryPath} + native`, { ...portable, usage: combineUsage(native.usage, portable.usage), details }, summaryReason);
|
|
480
|
+
pending = { details: result.compaction.details as NativeCompactionDetails, capture, summary: portable.summary };
|
|
481
|
+
return result;
|
|
482
|
+
} catch (error) {
|
|
483
|
+
if (!current()) return cancelled(error instanceof NativeCompactionValidationError ? combineUsage(error.usage, portable.usage) : portable.usage);
|
|
340
484
|
fallback("native acquisition or validation failed");
|
|
341
|
-
|
|
342
|
-
return
|
|
485
|
+
if (error instanceof NativeCompactionValidationError && error.usage !== undefined) portable.usage = combineUsage(error.usage, portable.usage);
|
|
486
|
+
return outcome(summaryPath, portable, "native acquisition or validation failed");
|
|
343
487
|
}
|
|
344
488
|
});
|
|
345
489
|
pi.on("session_compact", (event, context) => {
|
|
490
|
+
const recorded = isObject(event.compactionEntry.details) ? event.compactionEntry.details.providerCompaction : undefined;
|
|
491
|
+
const owned = isObject(recorded) && equal(recorded, lastOutcome);
|
|
346
492
|
if (readNativeCompactionDetails(event.compactionEntry.details)?.version === 3) compactionStatus = "native checkpoint committed";
|
|
347
|
-
else if (
|
|
493
|
+
else if (fallbackReason) compactionStatus = `portable: ${fallbackReason}`;
|
|
494
|
+
else if (owned) compactionStatus = `${lastOutcome!.path} portable summary committed`;
|
|
495
|
+
else fallback("another handler supplied the committed result");
|
|
496
|
+
if (owned) {
|
|
497
|
+
if (configuration().outcomeNotifications && lastOutcome) context.ui.notify(`Compacted via ${lastOutcome.path} in ${(lastOutcome.elapsedMs / 1000).toFixed(1)}s${lastOutcome.usage ? `; cache read ${lastOutcome.usage.cacheRead}, write ${lastOutcome.usage.cacheWrite}; catalog cost $${lastOutcome.usage.cost.total.toFixed(6)}` : ""}`, "info");
|
|
498
|
+
} else lastOutcome = { path: "Pi/other handler", elapsedMs: 0, usage: event.compactionEntry.usage, reason: compactionStatus };
|
|
348
499
|
// Only static diagnostics are persisted, never request/auth data or error text.
|
|
349
|
-
if (
|
|
350
|
-
active = undefined; snapshot = undefined;
|
|
500
|
+
if (fallbackReason) pi.appendEntry("pi-provider-compaction:diagnostic", { status: `portable: ${fallbackReason}` });
|
|
501
|
+
active = undefined; snapshot = undefined; cache = undefined;
|
|
351
502
|
const candidate = pending; pending = undefined;
|
|
352
503
|
if (!candidate || !equal(event.compactionEntry.details, candidate.details) || event.compactionEntry.summary !== candidate.summary) return;
|
|
353
504
|
const branch = context.sessionManager.getBranch();
|
|
354
505
|
const coverage = candidate.details.coverage;
|
|
355
|
-
|
|
356
|
-
|
|
506
|
+
const index = branch.findIndex((entry) => entry.id === event.compactionEntry.id);
|
|
507
|
+
if (!coverage || index < 0 || event.compactionEntry.firstKeptEntryId !== coverage.firstKeptEntryId || event.compactionEntry.parentId !== coverage.sourceLeafId || digest(branch.slice(0, index)) !== coverage.sourceHash) return;
|
|
508
|
+
active = {
|
|
509
|
+
entryId: event.compactionEntry.id,
|
|
510
|
+
summary: candidate.summary,
|
|
511
|
+
branchLength: index + 1,
|
|
512
|
+
branchHash: digest(branch.slice(0, index + 1)),
|
|
513
|
+
routeHash: candidate.capture.routeHash,
|
|
514
|
+
policyHash: candidate.capture.policyHash,
|
|
515
|
+
...(typeof candidate.capture.prepared.payload.model === "string" ? { payloadModel: candidate.capture.prepared.payload.model } : {}),
|
|
516
|
+
};
|
|
357
517
|
});
|
|
358
|
-
pi.on("session_compact_failed", () => { pending = undefined; snapshot = undefined; compactionStatus = "compaction failed or cancelled"; });
|
|
518
|
+
pi.on("session_compact_failed", () => { pending = undefined; snapshot = undefined; cache = undefined; compactionStatus = "compaction failed or cancelled"; });
|
|
359
519
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nijaru/pi-provider-compaction",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Provider-native context compaction adapters for Pi",
|
|
6
6
|
"keywords": [
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"build": "bun run build.ts",
|
|
30
30
|
"prepack": "bun run build",
|
|
31
31
|
"test": "bun test",
|
|
32
|
-
"lint": "oxlint build.ts index.ts protocol.ts policy.ts provider.ts replay.ts tests/",
|
|
32
|
+
"lint": "oxlint build.ts index.ts protocol.ts policy.ts provider.ts replay.ts cached-summary.ts tests/",
|
|
33
33
|
"typecheck": "tsc --noEmit",
|
|
34
34
|
"check": "bun run build && bun test && bun run lint && tsc --noEmit"
|
|
35
35
|
},
|
|
@@ -40,13 +40,14 @@
|
|
|
40
40
|
"policy.ts",
|
|
41
41
|
"provider.ts",
|
|
42
42
|
"replay.ts",
|
|
43
|
+
"cached-summary.ts",
|
|
43
44
|
"README.md",
|
|
44
45
|
"LICENSE"
|
|
45
46
|
],
|
|
46
47
|
"devDependencies": {
|
|
47
|
-
"@earendil-works/pi-ai": "1.
|
|
48
|
-
"@earendil-works/pi-agent-core": "1.
|
|
49
|
-
"@earendil-works/pi-coding-agent": "1.
|
|
48
|
+
"@earendil-works/pi-ai": "1.4.0",
|
|
49
|
+
"@earendil-works/pi-agent-core": "1.4.0",
|
|
50
|
+
"@earendil-works/pi-coding-agent": "1.4.0",
|
|
50
51
|
"@types/bun": "latest",
|
|
51
52
|
"oxlint": "^1.81.0",
|
|
52
53
|
"typescript": "^6.0.3"
|
package/protocol.ts
CHANGED
|
@@ -225,10 +225,26 @@ interface TransportRequest {
|
|
|
225
225
|
apiKey?: string;
|
|
226
226
|
headers?: ProviderHeaders;
|
|
227
227
|
env?: Record<string, string>;
|
|
228
|
+
/** Cost-relevant request option captured with the original request. */
|
|
229
|
+
serviceTier?: string;
|
|
228
230
|
signal: AbortSignal;
|
|
231
|
+
/** Revalidate request ownership after asynchronous provider preparation. */
|
|
232
|
+
isCurrent?: () => boolean;
|
|
229
233
|
fetch?: typeof globalThis.fetch;
|
|
230
234
|
}
|
|
231
235
|
|
|
236
|
+
function assertCurrent(request: TransportRequest): void {
|
|
237
|
+
if (request.signal.aborted || request.isCurrent?.() === false) throw new DOMException("Compaction invalidated", "AbortError");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The provider answered and was billed, but its window cannot be committed. */
|
|
241
|
+
export class NativeCompactionValidationError extends Error {
|
|
242
|
+
constructor(message: string, readonly usage?: Usage) {
|
|
243
|
+
super(message);
|
|
244
|
+
this.name = "NativeCompactionValidationError";
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
232
248
|
export interface TransportResponse {
|
|
233
249
|
output: ResponseItem[];
|
|
234
250
|
usage: Usage;
|
|
@@ -241,7 +257,7 @@ async function requestResponsesCompact(request: TransportRequest): Promise<Trans
|
|
|
241
257
|
let dispatches = 0;
|
|
242
258
|
const baseFetch = request.fetch ?? globalThis.fetch;
|
|
243
259
|
const bridgeFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
244
|
-
|
|
260
|
+
assertCurrent(request);
|
|
245
261
|
dispatches += 1;
|
|
246
262
|
if (dispatches !== 1 || !preparedPayload) {
|
|
247
263
|
bridgeError = new Error("Responses Compact provider dispatched an unexpected additional request");
|
|
@@ -258,8 +274,12 @@ async function requestResponsesCompact(request: TransportRequest): Promise<Trans
|
|
|
258
274
|
});
|
|
259
275
|
if (!response.ok) return response;
|
|
260
276
|
const parsed = await readJsonBounded(response, request.signal);
|
|
261
|
-
|
|
262
|
-
|
|
277
|
+
try {
|
|
278
|
+
if (!isObject(parsed)) throw new Error("Responses Compact returned a non-object response");
|
|
279
|
+
compacted = { response: parsed, output: validateCompactedOutput(parsed) };
|
|
280
|
+
} catch (error) {
|
|
281
|
+
throw new NativeCompactionValidationError(error instanceof Error ? error.message : String(error));
|
|
282
|
+
}
|
|
263
283
|
return syntheticCompletion(parsed, preparedPayload);
|
|
264
284
|
} catch (error) {
|
|
265
285
|
bridgeError = error;
|
|
@@ -272,11 +292,13 @@ async function requestResponsesCompact(request: TransportRequest): Promise<Trans
|
|
|
272
292
|
headers: request.headers,
|
|
273
293
|
env: request.env,
|
|
274
294
|
signal: request.signal,
|
|
295
|
+
...(request.serviceTier === undefined ? {} : { serviceTier: request.serviceTier }),
|
|
275
296
|
transport: "sse",
|
|
276
297
|
cacheRetention: "none",
|
|
277
298
|
maxRetries: 0,
|
|
278
299
|
fetch: bridgeFetch,
|
|
279
300
|
onPayload: (payload: unknown) => {
|
|
301
|
+
assertCurrent(request);
|
|
280
302
|
if (!isObject(payload)) throw new Error("Responses provider exposed a non-object payload");
|
|
281
303
|
if (request.preparedPayload && request.preparedPayload.model !== payload.model) throw new Error("Native route model changed since capture");
|
|
282
304
|
preparedPayload = { ...structuredClone(request.preparedPayload ?? payload), input: structuredClone(request.input) };
|
|
@@ -288,6 +310,7 @@ async function requestResponsesCompact(request: TransportRequest): Promise<Trans
|
|
|
288
310
|
if (bridgeError) throw bridgeError;
|
|
289
311
|
throw error;
|
|
290
312
|
});
|
|
313
|
+
if (bridgeError instanceof NativeCompactionValidationError) throw new NativeCompactionValidationError(bridgeError.message, usage);
|
|
291
314
|
if (bridgeError) throw bridgeError;
|
|
292
315
|
if (!compacted || dispatches !== 1) throw new Error("Responses Compact did not complete exactly one request");
|
|
293
316
|
return { output: compacted.output, usage };
|
|
@@ -392,6 +415,7 @@ async function requestRemoteV2(request: TransportRequest): Promise<TransportResp
|
|
|
392
415
|
const signal = AbortSignal.any([request.signal, controller.signal]);
|
|
393
416
|
const inspections: Promise<{ result: CollectedSse } | { error: unknown }>[] = [];
|
|
394
417
|
const inspectedFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
418
|
+
assertCurrent(request);
|
|
395
419
|
const response = await baseFetch(input, init);
|
|
396
420
|
if (!response.ok || !response.body) return response;
|
|
397
421
|
const [providerBody, inspectionBody] = response.body.tee();
|
|
@@ -410,11 +434,13 @@ async function requestRemoteV2(request: TransportRequest): Promise<TransportResp
|
|
|
410
434
|
headers: request.headers,
|
|
411
435
|
env: request.env,
|
|
412
436
|
signal: request.signal,
|
|
437
|
+
...(request.serviceTier === undefined ? {} : { serviceTier: request.serviceTier }),
|
|
413
438
|
transport: "sse",
|
|
414
439
|
cacheRetention: "none",
|
|
415
440
|
maxRetries: 0,
|
|
416
441
|
fetch: inspectedFetch,
|
|
417
442
|
onPayload: (payload: unknown) => {
|
|
443
|
+
assertCurrent(request);
|
|
418
444
|
if (!isObject(payload)) throw new Error("Codex provider exposed a non-object payload");
|
|
419
445
|
sentInput = structuredClone(request.input);
|
|
420
446
|
if (request.preparedPayload && request.preparedPayload.model !== payload.model) throw new Error("Native route model changed since capture");
|
|
@@ -435,6 +461,11 @@ async function requestRemoteV2(request: TransportRequest): Promise<TransportResp
|
|
|
435
461
|
export async function requestProviderCompaction(
|
|
436
462
|
request: TransportRequest & { protocol: NativeProtocol },
|
|
437
463
|
): Promise<TransportResponse> {
|
|
464
|
+
assertCurrent(request);
|
|
438
465
|
const result = await (request.protocol === "remote-v2" ? requestRemoteV2(request) : requestResponsesCompact(request));
|
|
439
|
-
|
|
466
|
+
try {
|
|
467
|
+
return { ...result, output: validateCompactedOutput(result) };
|
|
468
|
+
} catch (error) {
|
|
469
|
+
throw new NativeCompactionValidationError(error instanceof Error ? error.message : String(error), result.usage);
|
|
470
|
+
}
|
|
440
471
|
}
|