@nijaru/pi-provider-compaction 0.0.8 → 0.0.10

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,7 +3,9 @@ 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 { installObserver, type InstalledObserver, type ObserveRequest } from "./provider";
6
+ import { cacheConfig, cachedSummary, captureCachedRequest, finishCachedRequest, requestCacheState, sameCachedPrompt, supportsCachedSummary, withFileTracking, type CachedRequest } from "./cached-summary";
7
+ import { bindUsageHeaders, installObserver, type InstalledObserver, type ObserveRequest } from "./provider";
8
+ import { serveUsageObservers } from "./usage-observer";
7
9
  import { capturePreparedRequest, equal, requestPolicy, selectCoveredPrefix, substituteSummary, summarySlot, type PreparedRequest } from "./replay";
8
10
  import { isObject, NativeCompactionValidationError, requestProviderCompaction, resolveNativeProtocol, supportsResponsesApi, validateCompactedOutput, type NativeProtocol, type ResponseItem } from "./protocol";
9
11
 
@@ -143,41 +145,108 @@ interface Active {
143
145
  payloadModel?: string;
144
146
  }
145
147
 
148
+ interface ExpiryAttempt {
149
+ sessionId: string;
150
+ cycle: string;
151
+ isCurrent(): boolean;
152
+ signal?: AbortSignal;
153
+ }
154
+
146
155
  export default function (pi: ExtensionAPI): void {
147
156
  // Evidence is deliberately process/session-local. A reload cannot prove unchanged
148
157
  // hook/privacy policy, even if the visible summary text happens to match.
158
+ // Older hosts in the native adapter's supported range lack getSettings.
159
+ // Keep their existing native path; cache preferences require that host API.
160
+ const configuration = () => typeof pi.getSettings === "function" ? cacheConfig(pi.getSettings()) : { cachedSummaries: false, outcomeNotifications: false, cacheRetention: undefined };
149
161
  const secret = randomBytes(32);
150
162
  const routeDigest = (value: unknown) => createHmac("sha256", secret).update(JSON.stringify(value)).digest("hex");
151
163
  let runtimeId = randomUUID();
152
164
  let ctx: ExtensionContext | undefined;
165
+ let usageObservers = serveUsageObservers(pi, () => ctx);
153
166
  let snapshot: Captured | undefined;
167
+ let cache: CachedRequest | undefined;
168
+ let compactionRequest = false;
169
+ let expiryAttempt: ExpiryAttempt | undefined;
170
+ let lastOutcome: { path: string; elapsedMs: number; usage?: Usage; reason?: string } | undefined;
154
171
  let active: Active | undefined;
155
172
  let pending: { details: NativeCompactionDetails; capture: Captured; summary: string } | undefined;
156
173
  let observer: InstalledObserver | undefined;
157
174
  let unregisterCleanup: (() => void) | undefined;
158
175
  let generation = 0;
176
+ let requestSequence = 0;
159
177
  let captureStatus = "no request observed";
160
178
  let compactionStatus = "not attempted in this runtime";
179
+ let fallbackReason: string | undefined;
161
180
  const captureRejected = (reason: string) => { captureStatus = reason; };
162
- const fallback = (reason: string) => { compactionStatus = `portable: ${reason}`; };
181
+ const fallback = (reason: string) => { fallbackReason = reason; compactionStatus = `portable: ${reason}`; };
163
182
  pi.registerCommand("provider-compaction-status", {
164
- description: "Show content-free native compaction eligibility and last attempt",
183
+ description: "Show cache/native eligibility and measured compaction usage",
165
184
  handler: async (_args, context) => {
166
- context.ui.notify(`Provider compaction\nObserver: ${observer?.isInstalled() ? "installed" : "unavailable for current registration"}\nCapture: ${captureStatus}\nLast attempt: ${compactionStatus}`, "info");
185
+ const state = cacheState(context);
186
+ const remaining = state.expiresAt === undefined ? "unknown lifetime" : `estimated ${Math.max(0, Math.ceil((state.expiresAt - Date.now()) / 1000))}s remaining (model declaration; best effort)`;
187
+ const usage = lastOutcome?.usage;
188
+ 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}; estimated cost $${usage.cost.total.toFixed(6)}` : "; usage unavailable"}${lastOutcome.reason ? `; ${lastOutcome.reason}` : ""}` : ""}`, "info");
167
189
  },
168
190
  });
169
- const invalidate = () => { generation++; runtimeId = randomUUID(); snapshot = undefined; active = undefined; pending = undefined; captureStatus = "invalidated by lifecycle/history/policy change"; };
191
+ const invalidate = () => { generation++; runtimeId = randomUUID(); snapshot = undefined; cache = undefined; active = undefined; pending = undefined; expiryAttempt = undefined; captureStatus = "invalidated by lifecycle/history/policy change"; };
170
192
  const retire = () => {
193
+ usageObservers.clear();
171
194
  invalidate();
172
195
  const installed = observer;
173
196
  observer = undefined; ctx = undefined;
174
197
  unregisterCleanup?.(); unregisterCleanup = undefined;
175
198
  installed?.remove();
199
+ lastOutcome = undefined;
176
200
  };
201
+ pi.on("before_provider_headers", (event, context) => {
202
+ bindUsageHeaders(event.headers, context.sessionManager.getSessionId());
203
+ // Lazy API loading or catalog refresh can recompose effective providers
204
+ // between auxiliary calls that have no before_agent_start boundary.
205
+ usageObservers.ready(context);
206
+ });
177
207
  const generic = (context: ExtensionContext) => resolveCompactionModelPolicy(pi, context).hasSelectors;
178
208
  const route = (model: Model<any>, options: ProviderStreamOptions | SimpleStreamOptions) => routeDigest({ model, apiKey: options.apiKey, headers: options.headers, env: options.env });
179
209
  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
210
 
211
+ const cacheState = (context: ExtensionContext) => {
212
+ let reason: string | undefined;
213
+ if (!configuration().cachedSummaries) reason = "cached summaries disabled";
214
+ else if (generic(context)) reason = "explicit compaction model takes precedence";
215
+ else if (cache && !observer?.isInstalled()) reason = "request observer no longer owned";
216
+ 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";
217
+ return requestCacheState(cache, reason);
218
+ };
219
+ const removeCacheState = pi.events.on("pi-provider-compaction:cache-state", (request) => {
220
+ if (!isObject(request) || !isObject(request.context) || typeof request.respond !== "function") return;
221
+ try { request.respond(cacheState(request.context as unknown as ExtensionContext)); } catch { /* Stale requester. */ }
222
+ });
223
+ const removeExpiry = pi.events.on("pi-provider-compaction:compact-before-expiry", (request) => {
224
+ if (!isObject(request) || !isObject(request.context) || typeof request.respond !== "function" || typeof request.run !== "function" || typeof request.isCurrent !== "function") return;
225
+ const context = request.context as unknown as ExtensionContext;
226
+ const run = request.run as () => Promise<boolean>;
227
+ const authorized = request.isCurrent as () => boolean;
228
+ const execute = async () => {
229
+ const state = cacheState(context);
230
+ if (expiryAttempt || !state.available || state.cycle !== request.cycle || state.expiresAt === undefined || Date.now() >= state.expiresAt || !context.isIdle() || context.hasPendingMessages() || !authorized()) return false;
231
+ const ticket = generation;
232
+ const attempt: ExpiryAttempt = {
233
+ sessionId: context.sessionManager.getSessionId(), cycle: state.cycle,
234
+ isCurrent: () => {
235
+ try {
236
+ return expiryAttempt === attempt && generation === ticket &&
237
+ context.sessionManager.getSessionId() === attempt.sessionId &&
238
+ !context.hasPendingMessages() && authorized();
239
+ } catch { return false; }
240
+ },
241
+ };
242
+ expiryAttempt = attempt;
243
+ try { return await run(); }
244
+ finally { if (expiryAttempt === attempt) expiryAttempt = undefined; }
245
+ };
246
+ request.respond(execute());
247
+ });
248
+ pi.on("session_shutdown", () => { removeCacheState(); removeExpiry(); });
249
+
181
250
  const observe: ObserveRequest = (model, context, options) => {
182
251
  const owner = ctx;
183
252
  if (!owner) return;
@@ -190,7 +259,8 @@ export default function (pi: ExtensionAPI): void {
190
259
  return;
191
260
  }
192
261
  if (options.sessionId !== manager.getSessionId()) return;
193
- if (!supportsNativeCompaction(model)) { captureRejected("unsupported API"); return; }
262
+ if (compactionRequest) return;
263
+ if (!supportsNativeCompaction(model) && !supportsCachedSummary(model)) { captureRejected("unsupported API"); return; }
194
264
  if (!options.onPayload) {
195
265
  // The portable-summary request re-enters this observer without a payload
196
266
  // callback. Do not overwrite the main request's capture diagnostic.
@@ -202,19 +272,22 @@ export default function (pi: ExtensionAPI): void {
202
272
  // fallbacks. Those must not silently select a route absent from our evidence.
203
273
  if (!supportedRouteOptions(model, options)) { captureRejected("unsupported route overrides"); return; }
204
274
  const ticket = generation;
275
+ const startedAt = Date.now();
276
+ const sequence = ++requestSequence;
277
+ const config = configuration();
278
+ const effectiveOptions = { ...options, ...(config.cacheRetention === undefined ? {} : { cacheRetention: config.cacheRetention }) };
279
+ let candidate: CachedRequest | undefined;
205
280
  const branch = manager.getBranch();
206
281
  const branchHash = digest(branch);
207
282
  const canonical = transcript(manager.buildSessionProjection().messages);
208
283
  const current = () => {
209
- if (generation !== ticket || ctx !== owner || options.signal?.aborted || !supportedRouteOptions(model, options)) return false;
284
+ if (sequence !== requestSequence || generation !== ticket || ctx !== owner || options.signal?.aborted || !supportedRouteOptions(model, options)) return false;
210
285
  try { return unchanged(owner.sessionManager.getBranch(), branch.length, branchHash); }
211
286
  catch { retire(); return false; }
212
287
  };
213
- return async (payload) => {
288
+ const handler = async (payload: unknown) => {
214
289
  if (!current()) return payload;
215
290
  snapshot = undefined;
216
- const prepared = capturePreparedRequest(model, context, canonical, payload, captureRejected);
217
- if (!prepared) { active = undefined; return payload; }
218
291
  const auth = await owner.modelRegistry.getApiKeyAndHeaders(model);
219
292
  if (!current()) return payload;
220
293
  // A registry lookup after payload hooks must describe the credentials used by
@@ -222,13 +295,24 @@ export default function (pi: ExtensionAPI): void {
222
295
  // exact auth correspondence are deliberately ineligible.
223
296
  const normalizedHeaders = (headers: ProviderHeaders | undefined) => Object.entries(headers ?? {}).map(([name, value]) => [name.toLowerCase(), value]).sort(([a], [b]) => String(a).localeCompare(String(b)));
224
297
  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;
298
+ active = undefined; cache = undefined;
226
299
  captureRejected("resolved auth/route differs from prepared request, or generic policy changed");
227
300
  return payload;
228
301
  }
229
302
  const provider = owner.modelRegistry.getProvider(model.provider);
230
303
  if (!provider) { captureRejected("provider unavailable"); return payload; }
231
304
  const routeHash = route(model, options);
305
+ if (config.cachedSummaries && supportsCachedSummary(model)) {
306
+ const warming = options.maxTokens === 1 && cache && cache.authHash === routeDigest(auth) && sameCachedPrompt(cache.payload, payload) && equal(cache.context, context);
307
+ candidate = warming ? { ...cache!, startedAt, cycle: randomUUID(), usage: undefined, expiresAt: undefined } : captureCachedRequest({
308
+ model, provider, context, canonical, options: effectiveOptions, startedAt,
309
+ sessionId: manager.getSessionId(), branchLength: branch.length, branchHash, authHash: routeDigest(auth),
310
+ }, payload);
311
+ if (!warming) cache = undefined;
312
+ }
313
+ if (!supportsNativeCompaction(model)) { captureStatus = candidate ? "eligible cached request captured" : "cached request ineligible"; return payload; }
314
+ const prepared = capturePreparedRequest(model, context, canonical, payload, captureRejected);
315
+ if (!prepared) { active = undefined; return payload; }
232
316
  const policyHash = digest(requestPolicy(model, prepared));
233
317
  const latest = latestCompaction(branch);
234
318
  const payloadModel = isObject(payload) && typeof payload.model === "string" ? payload.model : undefined;
@@ -245,6 +329,8 @@ export default function (pi: ExtensionAPI): void {
245
329
  result = replaced;
246
330
  prepared.payload = structuredClone(replaced);
247
331
  prepared.replacement = { index: slot, count: details.output.length };
332
+ // The actual request contains opaque state, not the captured readable prefix.
333
+ candidate = undefined; cache = undefined;
248
334
  } else active = undefined;
249
335
  } else active = undefined;
250
336
  } else active = undefined;
@@ -257,85 +343,155 @@ export default function (pi: ExtensionAPI): void {
257
343
  };
258
344
  return result;
259
345
  };
346
+ return Object.assign(handler, {
347
+ cacheRetention: config.cacheRetention,
348
+ complete: (message: Parameters<typeof finishCachedRequest>[1]) => {
349
+ if (!candidate || sequence !== requestSequence || !current()) return;
350
+ finishCachedRequest(candidate, message);
351
+ if (candidate.usage) cache = candidate;
352
+ },
353
+ });
260
354
  };
261
355
 
262
356
  const install = (context: ExtensionContext) => {
263
357
  ctx = context;
264
- if (observer?.provider === context.model?.provider && observer?.isInstalled()) return;
358
+ if (observer?.provider === context.model?.provider && observer?.api === context.model?.api && observer?.isInstalled()) return;
265
359
  if (observer) { invalidate(); observer.remove(); observer = undefined; }
266
- if (!supportsNativeCompaction(context.model)) return;
360
+ if (!supportsNativeCompaction(context.model) && !supportsCachedSummary(context.model)) return;
267
361
  observer = installObserver(pi, context, observe);
268
362
  captureStatus = observer ? "awaiting prepared request" : "unsupported provider registration";
269
363
  };
270
364
  pi.on("session_start", (_event, context) => {
271
- retire(); install(context);
365
+ retire();
366
+ usageObservers.dispose();
367
+ usageObservers = serveUsageObservers(pi, () => ctx);
368
+ install(context);
369
+ usageObservers.ready(context);
272
370
  const sessionId = context.sessionManager.getSessionId();
273
371
  // AgentSession.dispose() cleans session resources even without emitting
274
372
  // session_shutdown. The captured registry keeps owned cleanup usable.
275
373
  unregisterCleanup = registerSessionResourceCleanup((disposedId) => {
276
- if (disposedId === undefined || disposedId === sessionId) retire();
374
+ if (disposedId === undefined || disposedId === sessionId) { retire(); usageObservers.dispose(); }
277
375
  });
278
376
  const latest = latestCompaction(context.sessionManager.getBranch());
279
377
  if (latest?.summary === NATIVE_COMPACTION_SUMMARY) context.ui.notify("Legacy native compaction has no portable summary. Native replay is disabled; recover from earlier history or start a new session.", "warning");
280
378
  });
281
- pi.on("before_agent_start", (_event, context) => { install(context); });
282
- pi.on("model_select", (_event, context) => { invalidate(); install(context); });
379
+ pi.on("before_agent_start", (_event, context) => { install(context); usageObservers.ready(context); });
380
+ pi.on("model_select", (_event, context) => { invalidate(); install(context); usageObservers.ready(context); });
283
381
  pi.on("session_tree", () => { invalidate(); });
284
- pi.on("session_shutdown", retire);
382
+ pi.on("session_shutdown", () => { retire(); usageObservers.dispose(); });
285
383
  // Privacy-policy owners must invalidate before changing a policy that can affect
286
384
  // hidden covered history. Arbitrary dynamic privacy compositions are unsupported.
287
385
  const removeInvalidation = pi.events.on("pi-provider-compaction:invalidate", invalidate);
288
386
  pi.on("session_shutdown", () => { if (typeof removeInvalidation === "function") removeInvalidation(); });
289
387
 
290
388
  pi.on("session_before_compact", async (event, context) => {
389
+ const began = Date.now();
291
390
  compactionStatus = "preparing compaction";
391
+ fallbackReason = undefined;
392
+ const attempt = expiryAttempt;
292
393
  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; }
394
+ const cached = cacheState(context).available ? cache : undefined;
395
+ snapshot = undefined; cache = undefined; pending = undefined;
396
+ const cancelled = (usage?: Usage, reason = "compaction ownership or input changed") => {
397
+ lastOutcome = { path: attempt ? "expiry cancelled" : "cancelled", elapsedMs: Date.now() - began, usage, reason };
398
+ return { cancel: true as const };
399
+ };
400
+ const reject = (reason: string) => {
401
+ fallback(reason);
402
+ return attempt ? cancelled(undefined, reason) : undefined;
403
+ };
404
+ // The provider may run before the companion's handler. Share its live
405
+ // ownership/input guard instead of paying for work that a later handler cancels.
406
+ if (attempt) {
407
+ if (event.reason !== "manual" || !attempt.isCurrent() || !cached || cached.cycle !== attempt.cycle || attempt.signal && attempt.signal !== event.signal) return reject("idle transaction no longer authorized");
408
+ attempt.signal = event.signal;
409
+ }
410
+ if ((!capture && !cached) || !context.model || generic(context) || event.signal.aborted) return reject("no eligible snapshot, explicit model override, or aborted attempt");
298
411
  const ticket = generation;
299
412
  const branch = context.sessionManager.getBranch();
300
413
  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);
414
+ const source = cached ?? capture!;
415
+ if (context.sessionManager.getSessionId() !== source.sessionId || !equal(branch, event.branchEntries) || !unchanged(branch, source.branchLength, source.branchHash)) return reject("history/session changed since capture");
416
+ const current = () => ticket === generation && !event.signal.aborted && (!attempt || attempt.isCurrent()) && supportedRouteOptions(source.model, source.options) && !generic(context) &&
417
+ context.model?.id === source.model.id && context.model?.provider === source.model.provider && context.model?.api === source.model.api &&
418
+ observer?.isInstalled() === true && context.modelRegistry.getProvider(source.model.provider) === source.provider && unchanged(context.sessionManager.getBranch(), branch.length, branchHash);
309
419
  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;
420
+ if (!current()) return cancelled();
421
+ if (!auth.ok || routeDigest(auth) !== source.authHash) return reject("auth changed since capture");
422
+ const protocol = capture ? resolveNativeProtocol(context.model) : undefined;
423
+ let input: ResponseItem[] | undefined;
424
+ if (capture && protocol && unchanged(branch, capture.branchLength, capture.branchHash) && source.authHash === capture.authHash) {
425
+ const projection = context.sessionManager.buildSessionProjection();
426
+ const boundary = projection.entries.findIndex((entry) => entry.sourceEntry.id === event.preparation.firstKeptEntryId);
427
+ if (boundary >= 0) input = selectCoveredPrefix(capture.model, capture.prepared, transcript(projection.entries.slice(0, boundary).flatMap((entry) => entry.messages)), fallback);
428
+ }
429
+ let portable: Awaited<ReturnType<typeof compact>> | undefined;
430
+ let attemptedUsage: Usage | undefined;
431
+ let summaryPath = "standard";
432
+ let summaryReason: string | undefined;
433
+ if (cached) {
434
+ compactionStatus = "generating cached portable summary";
435
+ compactionRequest = true;
436
+ try {
437
+ const result = await cachedSummary(cached, context, event.preparation, event.customInstructions, event.signal, current);
438
+ portable = result.result;
439
+ attemptedUsage = result.usage;
440
+ summaryReason = result.reason;
441
+ if (portable) summaryPath = "cached";
442
+ } finally { compactionRequest = false; }
443
+ if (!current()) return cancelled(attemptedUsage);
444
+ }
445
+ if (!portable) {
446
+ if (attempt) return cancelled(attemptedUsage, summaryReason ?? "warm cache unavailable");
447
+ // Without native coverage or a dispatched cached attempt, Pi can own fallback.
448
+ if (!input && !attemptedUsage) return reject(summaryReason ?? fallbackReason ?? "native coverage unavailable");
449
+ compactionStatus = "generating portable summary";
450
+ try {
451
+ 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);
452
+ portable = withFileTracking(portable, event.preparation);
453
+ portable.usage = combineUsage(attemptedUsage, portable.usage);
454
+ } catch {
455
+ if (!current()) return cancelled(attemptedUsage);
456
+ return reject("portable summary generation failed");
457
+ }
458
+ }
459
+ if (!current() || !portable.summary.trim() || portable.summary === NATIVE_COMPACTION_SUMMARY) return cancelled(portable.usage);
460
+ const outcome = (path: string, result: typeof portable, reason?: string) => {
461
+ lastOutcome = { path, elapsedMs: Date.now() - began, usage: result!.usage, reason };
462
+ result!.details = { ...(isObject(result!.details) ? result!.details : {}), providerCompaction: lastOutcome };
463
+ return { compaction: result! };
464
+ };
465
+ if (!capture || !protocol || !input) {
466
+ compactionStatus = `${summaryPath} portable summary prepared`;
467
+ return outcome(summaryPath, portable, summaryReason);
321
468
  }
322
- if (!current() || !portable.summary.trim() || portable.summary === NATIVE_COMPACTION_SUMMARY) return { cancel: true };
323
469
  try {
324
470
  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 }; }
471
+ if (!current()) return cancelled(portable.usage);
472
+ if (!freshAuth.ok || routeDigest(freshAuth) !== capture.authHash) { fallback("auth changed after portable generation"); return outcome(summaryPath, portable, "native auth changed"); }
327
473
  compactionStatus = "requesting native checkpoint";
328
- const native = await requestProviderCompaction({
329
- provider: capture.provider, model: capture.model, context: capture.prepared.context,
330
- input, preparedPayload: capture.prepared.payload, protocol,
331
- apiKey: capture.options.apiKey, headers: capture.options.headers, env: capture.options.env,
332
- signal: event.signal, fetch: capture.options.fetch,
333
- ...(capture.options.serviceTier === undefined ? {} : { serviceTier: capture.options.serviceTier }),
334
- });
335
- if (!current()) return { cancel: true };
474
+ let native: Awaited<ReturnType<typeof requestProviderCompaction>>;
475
+ compactionRequest = true;
476
+ try {
477
+ native = await requestProviderCompaction({
478
+ provider: capture.provider, model: capture.model, context: capture.prepared.context,
479
+ input, preparedPayload: capture.prepared.payload, protocol,
480
+ apiKey: capture.options.apiKey, headers: capture.options.headers, env: capture.options.env,
481
+ signal: event.signal, isCurrent: current, fetch: capture.options.fetch, sessionId: source.sessionId,
482
+ ...(capture.options.serviceTier === undefined ? {} : { serviceTier: capture.options.serviceTier }),
483
+ });
484
+ } finally { compactionRequest = false; }
485
+ if (!current()) return cancelled(combineUsage(native.usage, portable.usage));
336
486
  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 }; }
487
+ if (!current()) return cancelled(combineUsage(native.usage, portable.usage));
488
+ if (!finalAuth.ok || routeDigest(finalAuth) !== capture.authHash) {
489
+ // The checkpoint is no longer safe to commit, but both requests
490
+ // completed and their known usage still belongs in the session.
491
+ portable.usage = combineUsage(native.usage, portable.usage);
492
+ fallback("auth changed after native generation");
493
+ return outcome(summaryPath, portable, "native auth changed");
494
+ }
339
495
  const details: NativeCompactionDetails = {
340
496
  ...(isObject(portable.details) ? portable.details : {}),
341
497
  type: NATIVE_COMPACTION_TYPE, version: 3, provider: capture.model.provider, api: capture.model.api, model: capture.model.id,
@@ -343,28 +499,29 @@ export default function (pi: ExtensionAPI): void {
343
499
  nativeUsage: native.usage, portableUsage: portable.usage,
344
500
  coverage: { kind: "summarized-prefix", firstKeptEntryId: portable.firstKeptEntryId, sourceLeafId: branch.at(-1)!.id, sourceHash: branchHash, policyHash: capture.policyHash, inputHash: digest(input), runtimeId },
345
501
  };
346
- pending = { details, capture, summary: portable.summary };
347
- return { compaction: { ...portable, usage: combineUsage(native.usage, portable.usage), details } };
502
+ const result = outcome(`${summaryPath} + native`, { ...portable, usage: combineUsage(native.usage, portable.usage), details }, summaryReason);
503
+ pending = { details: result.compaction.details as NativeCompactionDetails, capture, summary: portable.summary };
504
+ return result;
348
505
  } catch (error) {
349
- if (!current()) return { cancel: true };
350
- // Do not expose exception text: provider errors may contain request data.
506
+ if (!current()) return cancelled(error instanceof NativeCompactionValidationError ? combineUsage(error.usage, portable.usage) : portable.usage);
351
507
  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
- };
508
+ if (error instanceof NativeCompactionValidationError && error.usage !== undefined) portable.usage = combineUsage(error.usage, portable.usage);
509
+ return outcome(summaryPath, portable, "native acquisition or validation failed");
360
510
  }
361
511
  });
362
512
  pi.on("session_compact", (event, context) => {
513
+ const recorded = isObject(event.compactionEntry.details) ? event.compactionEntry.details.providerCompaction : undefined;
514
+ const owned = isObject(recorded) && equal(recorded, lastOutcome);
363
515
  if (readNativeCompactionDetails(event.compactionEntry.details)?.version === 3) compactionStatus = "native checkpoint committed";
364
- else if (!compactionStatus.startsWith("portable:")) fallback("another handler supplied the committed result");
516
+ else if (fallbackReason) compactionStatus = `portable: ${fallbackReason}`;
517
+ else if (owned) compactionStatus = `${lastOutcome!.path} portable summary committed`;
518
+ else fallback("another handler supplied the committed result");
519
+ if (owned) {
520
+ 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}; estimated cost $${lastOutcome.usage.cost.total.toFixed(6)}` : ""}`, "info");
521
+ } else lastOutcome = { path: "Pi/other handler", elapsedMs: 0, usage: event.compactionEntry.usage, reason: compactionStatus };
365
522
  // 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;
523
+ if (fallbackReason) pi.appendEntry("pi-provider-compaction:diagnostic", { status: `portable: ${fallbackReason}` });
524
+ active = undefined; snapshot = undefined; cache = undefined;
368
525
  const candidate = pending; pending = undefined;
369
526
  if (!candidate || !equal(event.compactionEntry.details, candidate.details) || event.compactionEntry.summary !== candidate.summary) return;
370
527
  const branch = context.sessionManager.getBranch();
@@ -381,5 +538,5 @@ export default function (pi: ExtensionAPI): void {
381
538
  ...(typeof candidate.capture.prepared.payload.model === "string" ? { payloadModel: candidate.capture.prepared.payload.model } : {}),
382
539
  };
383
540
  });
384
- pi.on("session_compact_failed", () => { pending = undefined; snapshot = undefined; compactionStatus = "compaction failed or cancelled"; });
541
+ pi.on("session_compact_failed", () => { pending = undefined; snapshot = undefined; cache = undefined; compactionStatus = "compaction failed or cancelled"; });
385
542
  }
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.10",
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 usage-observer.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
  },
@@ -39,14 +39,16 @@
39
39
  "protocol.ts",
40
40
  "policy.ts",
41
41
  "provider.ts",
42
+ "usage-observer.ts",
42
43
  "replay.ts",
44
+ "cached-summary.ts",
43
45
  "README.md",
44
46
  "LICENSE"
45
47
  ],
46
48
  "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",
49
+ "@earendil-works/pi-ai": "1.4.0",
50
+ "@earendil-works/pi-agent-core": "1.4.0",
51
+ "@earendil-works/pi-coding-agent": "1.4.0",
50
52
  "@types/bun": "latest",
51
53
  "oxlint": "^1.81.0",
52
54
  "typescript": "^6.0.3"
package/policy.ts CHANGED
@@ -20,7 +20,6 @@ export const COMPACTION_MODEL_FLAG = "compaction-model";
20
20
  const PROJECT_POLICY = "compaction-policy.json";
21
21
  const AGENT_DIR_OVERRIDE = "PI_COMPACTOR_AGENT_DIR";
22
22
  const MAX_POLICY_BYTES = 64 * 1024;
23
- const MAX_MODEL_SELECTORS = 8;
24
23
  const MAX_SELECTOR_LENGTH = 512;
25
24
 
26
25
  type PolicyContext = Pick<ExtensionContext, "cwd" | "isProjectTrusted"> & {
@@ -43,21 +42,15 @@ function normalizeSelector(value: unknown): string | undefined {
43
42
  return selector;
44
43
  }
45
44
 
46
- function parsePolicySelectors(value: unknown): string[] | undefined {
45
+ function policyHasSelectors(value: unknown): boolean | undefined {
47
46
  if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
48
47
  if (!("models" in value) || !Array.isArray(value.models)) return undefined;
49
-
50
- const selectors: string[] = [];
51
- for (const model of value.models) {
52
- const selector = normalizeSelector(model);
53
- if (selector === undefined || selectors.includes(selector)) continue;
54
- selectors.push(selector);
55
- if (selectors.length === MAX_MODEL_SELECTORS) break;
56
- }
57
- return selectors;
48
+ // This extension only decides precedence. The companion owns the actual
49
+ // selector list, including deduplication and its model-attempt limit.
50
+ return value.models.some(model => normalizeSelector(model) !== undefined);
58
51
  }
59
52
 
60
- function readPolicySelectors(configPath: string): string[] | undefined {
53
+ function readPolicySelection(configPath: string): boolean | undefined {
61
54
  let fd: number | undefined;
62
55
  try {
63
56
  fd = openSync(configPath, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK);
@@ -74,7 +67,7 @@ function readPolicySelectors(configPath: string): string[] | undefined {
74
67
  bytesRead += count;
75
68
  }
76
69
  if (bytesRead > MAX_POLICY_BYTES) return undefined;
77
- return parsePolicySelectors(JSON.parse(buffer.subarray(0, bytesRead).toString("utf8")) as unknown);
70
+ return policyHasSelectors(JSON.parse(buffer.subarray(0, bytesRead).toString("utf8")) as unknown);
78
71
  } catch {
79
72
  // A malformed or unreadable policy must not prevent native compaction.
80
73
  return undefined;
@@ -177,11 +170,11 @@ export function resolveCompactionModelPolicy(
177
170
  candidates.push({ path: join(agentDir, PROJECT_POLICY), source: "agent-policy" });
178
171
 
179
172
  for (const candidate of candidates) {
180
- const selectors = readPolicySelectors(candidate.path);
173
+ const hasSelectors = readPolicySelection(candidate.path);
181
174
  // A readable policy stops the search: an empty models list is an
182
175
  // explicit choice to use no generic compaction model.
183
- if (selectors !== undefined) {
184
- return selectors.length > 0 ? { hasSelectors: true, source: candidate.source } : { hasSelectors: false };
176
+ if (hasSelectors !== undefined) {
177
+ return hasSelectors ? { hasSelectors: true, source: candidate.source } : { hasSelectors: false };
185
178
  }
186
179
  }
187
180
  return { hasSelectors: false };