@nijaru/pi-provider-compaction 0.0.8 → 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 +751 -190
- package/index.ts +192 -58
- package/package.json +6 -5
- package/protocol.ts +11 -1
- package/provider.ts +152 -37
package/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ 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
9
|
import { isObject, NativeCompactionValidationError, requestProviderCompaction, resolveNativeProtocol, supportsResponsesApi, validateCompactedOutput, type NativeProtocol, type ResponseItem } from "./protocol";
|
|
@@ -143,41 +144,100 @@ interface Active {
|
|
|
143
144
|
payloadModel?: string;
|
|
144
145
|
}
|
|
145
146
|
|
|
147
|
+
interface ExpiryAttempt {
|
|
148
|
+
sessionId: string;
|
|
149
|
+
cycle: string;
|
|
150
|
+
isCurrent(): boolean;
|
|
151
|
+
signal?: AbortSignal;
|
|
152
|
+
}
|
|
153
|
+
|
|
146
154
|
export default function (pi: ExtensionAPI): void {
|
|
147
155
|
// Evidence is deliberately process/session-local. A reload cannot prove unchanged
|
|
148
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 };
|
|
149
160
|
const secret = randomBytes(32);
|
|
150
161
|
const routeDigest = (value: unknown) => createHmac("sha256", secret).update(JSON.stringify(value)).digest("hex");
|
|
151
162
|
let runtimeId = randomUUID();
|
|
152
163
|
let ctx: ExtensionContext | undefined;
|
|
153
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;
|
|
154
169
|
let active: Active | undefined;
|
|
155
170
|
let pending: { details: NativeCompactionDetails; capture: Captured; summary: string } | undefined;
|
|
156
171
|
let observer: InstalledObserver | undefined;
|
|
157
172
|
let unregisterCleanup: (() => void) | undefined;
|
|
158
173
|
let generation = 0;
|
|
174
|
+
let requestSequence = 0;
|
|
159
175
|
let captureStatus = "no request observed";
|
|
160
176
|
let compactionStatus = "not attempted in this runtime";
|
|
177
|
+
let fallbackReason: string | undefined;
|
|
161
178
|
const captureRejected = (reason: string) => { captureStatus = reason; };
|
|
162
|
-
const fallback = (reason: string) => { compactionStatus = `portable: ${reason}`; };
|
|
179
|
+
const fallback = (reason: string) => { fallbackReason = reason; compactionStatus = `portable: ${reason}`; };
|
|
163
180
|
pi.registerCommand("provider-compaction-status", {
|
|
164
|
-
description: "Show
|
|
181
|
+
description: "Show cache/native eligibility and measured compaction usage",
|
|
165
182
|
handler: async (_args, context) => {
|
|
166
|
-
|
|
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");
|
|
167
187
|
},
|
|
168
188
|
});
|
|
169
|
-
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"; };
|
|
170
190
|
const retire = () => {
|
|
171
191
|
invalidate();
|
|
172
192
|
const installed = observer;
|
|
173
193
|
observer = undefined; ctx = undefined;
|
|
174
194
|
unregisterCleanup?.(); unregisterCleanup = undefined;
|
|
175
195
|
installed?.remove();
|
|
196
|
+
lastOutcome = undefined;
|
|
176
197
|
};
|
|
177
198
|
const generic = (context: ExtensionContext) => resolveCompactionModelPolicy(pi, context).hasSelectors;
|
|
178
199
|
const route = (model: Model<any>, options: ProviderStreamOptions | SimpleStreamOptions) => routeDigest({ model, apiKey: options.apiKey, headers: options.headers, env: options.env });
|
|
179
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");
|
|
180
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
|
+
|
|
181
241
|
const observe: ObserveRequest = (model, context, options) => {
|
|
182
242
|
const owner = ctx;
|
|
183
243
|
if (!owner) return;
|
|
@@ -190,7 +250,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
190
250
|
return;
|
|
191
251
|
}
|
|
192
252
|
if (options.sessionId !== manager.getSessionId()) return;
|
|
193
|
-
if (
|
|
253
|
+
if (summarizing) return;
|
|
254
|
+
if (!supportsNativeCompaction(model) && !supportsCachedSummary(model)) { captureRejected("unsupported API"); return; }
|
|
194
255
|
if (!options.onPayload) {
|
|
195
256
|
// The portable-summary request re-enters this observer without a payload
|
|
196
257
|
// callback. Do not overwrite the main request's capture diagnostic.
|
|
@@ -202,19 +263,22 @@ export default function (pi: ExtensionAPI): void {
|
|
|
202
263
|
// fallbacks. Those must not silently select a route absent from our evidence.
|
|
203
264
|
if (!supportedRouteOptions(model, options)) { captureRejected("unsupported route overrides"); return; }
|
|
204
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;
|
|
205
271
|
const branch = manager.getBranch();
|
|
206
272
|
const branchHash = digest(branch);
|
|
207
273
|
const canonical = transcript(manager.buildSessionProjection().messages);
|
|
208
274
|
const current = () => {
|
|
209
|
-
if (generation !== ticket || ctx !== owner || options.signal?.aborted || !supportedRouteOptions(model, options)) return false;
|
|
275
|
+
if (sequence !== requestSequence || generation !== ticket || ctx !== owner || options.signal?.aborted || !supportedRouteOptions(model, options)) return false;
|
|
210
276
|
try { return unchanged(owner.sessionManager.getBranch(), branch.length, branchHash); }
|
|
211
277
|
catch { retire(); return false; }
|
|
212
278
|
};
|
|
213
|
-
|
|
279
|
+
const handler = async (payload: unknown) => {
|
|
214
280
|
if (!current()) return payload;
|
|
215
281
|
snapshot = undefined;
|
|
216
|
-
const prepared = capturePreparedRequest(model, context, canonical, payload, captureRejected);
|
|
217
|
-
if (!prepared) { active = undefined; return payload; }
|
|
218
282
|
const auth = await owner.modelRegistry.getApiKeyAndHeaders(model);
|
|
219
283
|
if (!current()) return payload;
|
|
220
284
|
// A registry lookup after payload hooks must describe the credentials used by
|
|
@@ -222,13 +286,24 @@ export default function (pi: ExtensionAPI): void {
|
|
|
222
286
|
// exact auth correspondence are deliberately ineligible.
|
|
223
287
|
const normalizedHeaders = (headers: ProviderHeaders | undefined) => Object.entries(headers ?? {}).map(([name, value]) => [name.toLowerCase(), value]).sort(([a], [b]) => String(a).localeCompare(String(b)));
|
|
224
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 ?? {})) {
|
|
225
|
-
active = undefined;
|
|
289
|
+
active = undefined; cache = undefined;
|
|
226
290
|
captureRejected("resolved auth/route differs from prepared request, or generic policy changed");
|
|
227
291
|
return payload;
|
|
228
292
|
}
|
|
229
293
|
const provider = owner.modelRegistry.getProvider(model.provider);
|
|
230
294
|
if (!provider) { captureRejected("provider unavailable"); return payload; }
|
|
231
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; }
|
|
232
307
|
const policyHash = digest(requestPolicy(model, prepared));
|
|
233
308
|
const latest = latestCompaction(branch);
|
|
234
309
|
const payloadModel = isObject(payload) && typeof payload.model === "string" ? payload.model : undefined;
|
|
@@ -245,6 +320,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
245
320
|
result = replaced;
|
|
246
321
|
prepared.payload = structuredClone(replaced);
|
|
247
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;
|
|
248
325
|
} else active = undefined;
|
|
249
326
|
} else active = undefined;
|
|
250
327
|
} else active = undefined;
|
|
@@ -257,13 +334,21 @@ export default function (pi: ExtensionAPI): void {
|
|
|
257
334
|
};
|
|
258
335
|
return result;
|
|
259
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
|
+
});
|
|
260
345
|
};
|
|
261
346
|
|
|
262
347
|
const install = (context: ExtensionContext) => {
|
|
263
348
|
ctx = context;
|
|
264
349
|
if (observer?.provider === context.model?.provider && observer?.isInstalled()) return;
|
|
265
350
|
if (observer) { invalidate(); observer.remove(); observer = undefined; }
|
|
266
|
-
if (!supportsNativeCompaction(context.model)) return;
|
|
351
|
+
if (!supportsNativeCompaction(context.model) && !supportsCachedSummary(context.model)) return;
|
|
267
352
|
observer = installObserver(pi, context, observe);
|
|
268
353
|
captureStatus = observer ? "awaiting prepared request" : "unsupported provider registration";
|
|
269
354
|
};
|
|
@@ -288,54 +373,102 @@ export default function (pi: ExtensionAPI): void {
|
|
|
288
373
|
pi.on("session_shutdown", () => { if (typeof removeInvalidation === "function") removeInvalidation(); });
|
|
289
374
|
|
|
290
375
|
pi.on("session_before_compact", async (event, context) => {
|
|
376
|
+
const began = Date.now();
|
|
291
377
|
compactionStatus = "preparing compaction";
|
|
378
|
+
fallbackReason = undefined;
|
|
379
|
+
const attempt = expiryAttempt;
|
|
292
380
|
const capture = snapshot;
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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");
|
|
298
398
|
const ticket = generation;
|
|
299
399
|
const branch = context.sessionManager.getBranch();
|
|
300
400
|
const branchHash = digest(branch);
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
const input = selectCoveredPrefix(capture.model, capture.prepared, prefix, fallback);
|
|
307
|
-
if (!input) return;
|
|
308
|
-
const current = () => ticket === generation && !event.signal.aborted && supportedRouteOptions(capture.model, capture.options) && !generic(context) && context.modelRegistry.getProvider(capture.model.provider) === capture.provider && unchanged(context.sessionManager.getBranch(), branch.length, 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);
|
|
309
406
|
const auth = await context.modelRegistry.getApiKeyAndHeaders(context.model);
|
|
310
|
-
if (!current()) return
|
|
311
|
-
if (!auth.ok || routeDigest(auth) !==
|
|
312
|
-
|
|
313
|
-
let
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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);
|
|
321
455
|
}
|
|
322
|
-
if (!current() || !portable.summary.trim() || portable.summary === NATIVE_COMPACTION_SUMMARY) return { cancel: true };
|
|
323
456
|
try {
|
|
324
457
|
const freshAuth = await context.modelRegistry.getApiKeyAndHeaders(context.model);
|
|
325
|
-
if (!current()) return
|
|
326
|
-
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"); }
|
|
327
460
|
compactionStatus = "requesting native checkpoint";
|
|
328
461
|
const native = await requestProviderCompaction({
|
|
329
462
|
provider: capture.provider, model: capture.model, context: capture.prepared.context,
|
|
330
463
|
input, preparedPayload: capture.prepared.payload, protocol,
|
|
331
464
|
apiKey: capture.options.apiKey, headers: capture.options.headers, env: capture.options.env,
|
|
332
|
-
signal: event.signal, fetch: capture.options.fetch,
|
|
465
|
+
signal: event.signal, isCurrent: current, fetch: capture.options.fetch,
|
|
333
466
|
...(capture.options.serviceTier === undefined ? {} : { serviceTier: capture.options.serviceTier }),
|
|
334
467
|
});
|
|
335
|
-
if (!current()) return
|
|
468
|
+
if (!current()) return cancelled(combineUsage(native.usage, portable.usage));
|
|
336
469
|
const finalAuth = await context.modelRegistry.getApiKeyAndHeaders(context.model);
|
|
337
|
-
if (!current()) return
|
|
338
|
-
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"); }
|
|
339
472
|
const details: NativeCompactionDetails = {
|
|
340
473
|
...(isObject(portable.details) ? portable.details : {}),
|
|
341
474
|
type: NATIVE_COMPACTION_TYPE, version: 3, provider: capture.model.provider, api: capture.model.api, model: capture.model.id,
|
|
@@ -343,28 +476,29 @@ export default function (pi: ExtensionAPI): void {
|
|
|
343
476
|
nativeUsage: native.usage, portableUsage: portable.usage,
|
|
344
477
|
coverage: { kind: "summarized-prefix", firstKeptEntryId: portable.firstKeptEntryId, sourceLeafId: branch.at(-1)!.id, sourceHash: branchHash, policyHash: capture.policyHash, inputHash: digest(input), runtimeId },
|
|
345
478
|
};
|
|
346
|
-
|
|
347
|
-
|
|
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;
|
|
348
482
|
} catch (error) {
|
|
349
|
-
if (!current()) return
|
|
350
|
-
// Do not expose exception text: provider errors may contain request data.
|
|
483
|
+
if (!current()) return cancelled(error instanceof NativeCompactionValidationError ? combineUsage(error.usage, portable.usage) : portable.usage);
|
|
351
484
|
fallback("native acquisition or validation failed");
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
// an unusable window.
|
|
355
|
-
return {
|
|
356
|
-
compaction: error instanceof NativeCompactionValidationError && error.usage !== undefined
|
|
357
|
-
? { ...portable, usage: combineUsage(error.usage, portable.usage) }
|
|
358
|
-
: portable,
|
|
359
|
-
};
|
|
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");
|
|
360
487
|
}
|
|
361
488
|
});
|
|
362
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);
|
|
363
492
|
if (readNativeCompactionDetails(event.compactionEntry.details)?.version === 3) compactionStatus = "native checkpoint committed";
|
|
364
|
-
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 };
|
|
365
499
|
// Only static diagnostics are persisted, never request/auth data or error text.
|
|
366
|
-
if (
|
|
367
|
-
active = undefined; snapshot = undefined;
|
|
500
|
+
if (fallbackReason) pi.appendEntry("pi-provider-compaction:diagnostic", { status: `portable: ${fallbackReason}` });
|
|
501
|
+
active = undefined; snapshot = undefined; cache = undefined;
|
|
368
502
|
const candidate = pending; pending = undefined;
|
|
369
503
|
if (!candidate || !equal(event.compactionEntry.details, candidate.details) || event.compactionEntry.summary !== candidate.summary) return;
|
|
370
504
|
const branch = context.sessionManager.getBranch();
|
|
@@ -381,5 +515,5 @@ export default function (pi: ExtensionAPI): void {
|
|
|
381
515
|
...(typeof candidate.capture.prepared.payload.model === "string" ? { payloadModel: candidate.capture.prepared.payload.model } : {}),
|
|
382
516
|
};
|
|
383
517
|
});
|
|
384
|
-
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"; });
|
|
385
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
|
@@ -228,9 +228,15 @@ interface TransportRequest {
|
|
|
228
228
|
/** Cost-relevant request option captured with the original request. */
|
|
229
229
|
serviceTier?: string;
|
|
230
230
|
signal: AbortSignal;
|
|
231
|
+
/** Revalidate request ownership after asynchronous provider preparation. */
|
|
232
|
+
isCurrent?: () => boolean;
|
|
231
233
|
fetch?: typeof globalThis.fetch;
|
|
232
234
|
}
|
|
233
235
|
|
|
236
|
+
function assertCurrent(request: TransportRequest): void {
|
|
237
|
+
if (request.signal.aborted || request.isCurrent?.() === false) throw new DOMException("Compaction invalidated", "AbortError");
|
|
238
|
+
}
|
|
239
|
+
|
|
234
240
|
/** The provider answered and was billed, but its window cannot be committed. */
|
|
235
241
|
export class NativeCompactionValidationError extends Error {
|
|
236
242
|
constructor(message: string, readonly usage?: Usage) {
|
|
@@ -251,7 +257,7 @@ async function requestResponsesCompact(request: TransportRequest): Promise<Trans
|
|
|
251
257
|
let dispatches = 0;
|
|
252
258
|
const baseFetch = request.fetch ?? globalThis.fetch;
|
|
253
259
|
const bridgeFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
254
|
-
|
|
260
|
+
assertCurrent(request);
|
|
255
261
|
dispatches += 1;
|
|
256
262
|
if (dispatches !== 1 || !preparedPayload) {
|
|
257
263
|
bridgeError = new Error("Responses Compact provider dispatched an unexpected additional request");
|
|
@@ -292,6 +298,7 @@ async function requestResponsesCompact(request: TransportRequest): Promise<Trans
|
|
|
292
298
|
maxRetries: 0,
|
|
293
299
|
fetch: bridgeFetch,
|
|
294
300
|
onPayload: (payload: unknown) => {
|
|
301
|
+
assertCurrent(request);
|
|
295
302
|
if (!isObject(payload)) throw new Error("Responses provider exposed a non-object payload");
|
|
296
303
|
if (request.preparedPayload && request.preparedPayload.model !== payload.model) throw new Error("Native route model changed since capture");
|
|
297
304
|
preparedPayload = { ...structuredClone(request.preparedPayload ?? payload), input: structuredClone(request.input) };
|
|
@@ -408,6 +415,7 @@ async function requestRemoteV2(request: TransportRequest): Promise<TransportResp
|
|
|
408
415
|
const signal = AbortSignal.any([request.signal, controller.signal]);
|
|
409
416
|
const inspections: Promise<{ result: CollectedSse } | { error: unknown }>[] = [];
|
|
410
417
|
const inspectedFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
418
|
+
assertCurrent(request);
|
|
411
419
|
const response = await baseFetch(input, init);
|
|
412
420
|
if (!response.ok || !response.body) return response;
|
|
413
421
|
const [providerBody, inspectionBody] = response.body.tee();
|
|
@@ -432,6 +440,7 @@ async function requestRemoteV2(request: TransportRequest): Promise<TransportResp
|
|
|
432
440
|
maxRetries: 0,
|
|
433
441
|
fetch: inspectedFetch,
|
|
434
442
|
onPayload: (payload: unknown) => {
|
|
443
|
+
assertCurrent(request);
|
|
435
444
|
if (!isObject(payload)) throw new Error("Codex provider exposed a non-object payload");
|
|
436
445
|
sentInput = structuredClone(request.input);
|
|
437
446
|
if (request.preparedPayload && request.preparedPayload.model !== payload.model) throw new Error("Native route model changed since capture");
|
|
@@ -452,6 +461,7 @@ async function requestRemoteV2(request: TransportRequest): Promise<TransportResp
|
|
|
452
461
|
export async function requestProviderCompaction(
|
|
453
462
|
request: TransportRequest & { protocol: NativeProtocol },
|
|
454
463
|
): Promise<TransportResponse> {
|
|
464
|
+
assertCurrent(request);
|
|
455
465
|
const result = await (request.protocol === "remote-v2" ? requestRemoteV2(request) : requestResponsesCompact(request));
|
|
456
466
|
try {
|
|
457
467
|
return { ...result, output: validateCompactedOutput(result) };
|