@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/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 content-free native compaction eligibility and last attempt",
181
+ description: "Show cache/native eligibility and measured compaction usage",
165
182
  handler: async (_args, context) => {
166
- 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");
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 (!supportsNativeCompaction(model)) { captureRejected("unsupported API"); return; }
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
- return async (payload) => {
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
- snapshot = undefined; pending = undefined;
294
- if (!capture) { fallback(`no eligible snapshot (${captureStatus})`); return; }
295
- if (!context.model || generic(context) || event.signal.aborted) { fallback("missing model, generic model override, or aborted attempt"); return; }
296
- const protocol = resolveNativeProtocol(context.model);
297
- 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");
298
398
  const ticket = generation;
299
399
  const branch = context.sessionManager.getBranch();
300
400
  const branchHash = digest(branch);
301
- if (!equal(branch, event.branchEntries) || !unchanged(branch, capture.branchLength, capture.branchHash)) { fallback("history changed since capture"); return; }
302
- const projection = context.sessionManager.buildSessionProjection();
303
- const boundary = projection.entries.findIndex((entry) => entry.sourceEntry.id === event.preparation.firstKeptEntryId);
304
- if (boundary < 0) { fallback("retained boundary missing from projection"); return; }
305
- const prefix = transcript(projection.entries.slice(0, boundary).flatMap((entry) => entry.messages));
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 { cancel: true };
311
- if (!auth.ok || routeDigest(auth) !== capture.authHash) { fallback("auth changed since capture"); return; }
312
- compactionStatus = "generating portable summary";
313
- let portable: Awaited<ReturnType<typeof compact>>;
314
- try {
315
- 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);
316
- } catch {
317
- // No result exists to return; let Pi own its ordinary failure/fallback behavior.
318
- if (!current()) return { cancel: true };
319
- fallback("portable summary generation failed");
320
- return 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);
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 { cancel: true };
326
- 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"); }
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 { cancel: true };
468
+ if (!current()) return cancelled(combineUsage(native.usage, portable.usage));
336
469
  const finalAuth = await context.modelRegistry.getApiKeyAndHeaders(context.model);
337
- if (!current()) return { cancel: true };
338
- 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"); }
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
- pending = { details, capture, summary: portable.summary };
347
- return { compaction: { ...portable, usage: combineUsage(native.usage, portable.usage), details } };
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 { cancel: true };
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
- // The portable request already succeeded. Never pay for a duplicate summary,
353
- // and keep the native call's usage when the endpoint answered but produced
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 (!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 };
365
499
  // Only static diagnostics are persisted, never request/auth data or error text.
366
- if (compactionStatus.startsWith("portable:")) pi.appendEntry("pi-provider-compaction:diagnostic", { status: compactionStatus });
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.8",
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
@@ -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
- if (request.signal.aborted) throw new DOMException("Compaction aborted", "AbortError");
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) };