@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/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 content-free native compaction eligibility and last attempt",
181
+ description: "Show cache/native eligibility and measured compaction usage",
164
182
  handler: async (_args, context) => {
165
- context.ui.notify(`Provider compaction\nObserver: ${observer?.isInstalled() ? "installed" : "unavailable for current registration"}\nCapture: ${captureStatus}\nLast attempt: ${compactionStatus}`, "info");
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 (!supportsNativeCompaction(model)) { captureRejected("unsupported API"); return; }
193
- if (!options.onPayload) { captureRejected("no payload callback"); return; }
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 digest(owner.sessionManager.getBranch()) === branchHash; }
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
- return async (payload) => {
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 && digest(latest.details) === active.detailsHash && unchanged(branch, active.branchLength, active.branchHash) && active.routeHash === routeHash && active.policyHash === policyHash) {
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
- snapshot = undefined; pending = undefined;
286
- if (!capture) { fallback(`no eligible snapshot (${captureStatus})`); return; }
287
- if (!context.model || generic(context) || event.signal.aborted) { fallback("missing model, generic model override, or aborted attempt"); return; }
288
- const protocol = resolveNativeProtocol(context.model);
289
- if (!protocol || context.sessionManager.getSessionId() !== capture.sessionId) { fallback("unsupported protocol or changed session"); return; }
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
- if (!equal(branch, event.branchEntries) || !unchanged(branch, capture.branchLength, capture.branchHash)) { fallback("history changed since capture"); return; }
294
- const projection = context.sessionManager.buildSessionProjection();
295
- const boundary = projection.entries.findIndex((entry) => entry.sourceEntry.id === event.preparation.firstKeptEntryId);
296
- if (boundary < 0) { fallback("retained boundary missing from projection"); return; }
297
- const prefix = transcript(projection.entries.slice(0, boundary).flatMap((entry) => entry.messages));
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 { cancel: true };
303
- if (!auth.ok || routeDigest(auth) !== capture.authHash) { fallback("auth changed since capture"); return; }
304
- compactionStatus = "generating portable summary";
305
- let portable: Awaited<ReturnType<typeof compact>>;
306
- try {
307
- 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);
308
- } catch {
309
- // No result exists to return; let Pi own its ordinary failure/fallback behavior.
310
- return !current() ? { cancel: true } : undefined;
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 { cancel: true };
316
- if (!freshAuth.ok || routeDigest(freshAuth) !== capture.authHash) { fallback("auth changed after portable generation"); return { compaction: portable }; }
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 { cancel: true };
468
+ if (!current()) return cancelled(combineUsage(native.usage, portable.usage));
325
469
  const finalAuth = await context.modelRegistry.getApiKeyAndHeaders(context.model);
326
- if (!current()) return { cancel: true };
327
- if (!finalAuth.ok || routeDigest(finalAuth) !== capture.authHash) { fallback("auth changed after native generation"); return { compaction: portable }; }
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
- pending = { details, capture, summary: portable.summary };
336
- return { compaction: { ...portable, usage: combineUsage(native.usage, portable.usage), details } };
337
- } catch {
338
- if (!current()) return { cancel: true };
339
- // Do not expose exception text: provider errors may contain request data.
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
- // The portable request already succeeded. Never pay for a duplicate summary.
342
- return { compaction: portable };
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 (!compactionStatus.startsWith("portable:")) fallback("another handler supplied the committed result");
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 (compactionStatus.startsWith("portable:")) pi.appendEntry("pi-provider-compaction:diagnostic", { status: compactionStatus });
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
- 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;
356
- 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 };
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.7",
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.3.0",
48
- "@earendil-works/pi-agent-core": "1.3.0",
49
- "@earendil-works/pi-coding-agent": "1.3.0",
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
- if (request.signal.aborted) throw new DOMException("Compaction aborted", "AbortError");
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
- if (!isObject(parsed)) throw new Error("Responses Compact returned a non-object response");
262
- compacted = { response: parsed, output: validateCompactedOutput(parsed) };
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
- return { ...result, output: validateCompactedOutput(result) };
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
  }