@nijaru/pi-provider-compaction 0.0.9 → 0.0.11

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
@@ -4,7 +4,8 @@ import { normalizeContext, registerSessionResourceCleanup, type Model, type Prov
4
4
  import { compact, convertToLlm, type ExtensionAPI, type ExtensionContext, type SessionEntry } from "@earendil-works/pi-coding-agent";
5
5
  import { resolveCompactionModelPolicy } from "./policy";
6
6
  import { cacheConfig, cachedSummary, captureCachedRequest, finishCachedRequest, requestCacheState, sameCachedPrompt, supportsCachedSummary, withFileTracking, type CachedRequest } from "./cached-summary";
7
- import { installObserver, type InstalledObserver, type ObserveRequest } from "./provider";
7
+ import { bindUsageHeaders, installObserver, type InstalledObserver, type ObserveRequest } from "./provider";
8
+ import { serveUsageObservers } from "./usage-observer";
8
9
  import { capturePreparedRequest, equal, requestPolicy, selectCoveredPrefix, substituteSummary, summarySlot, type PreparedRequest } from "./replay";
9
10
  import { isObject, NativeCompactionValidationError, requestProviderCompaction, resolveNativeProtocol, supportsResponsesApi, validateCompactedOutput, type NativeProtocol, type ResponseItem } from "./protocol";
10
11
 
@@ -161,9 +162,10 @@ export default function (pi: ExtensionAPI): void {
161
162
  const routeDigest = (value: unknown) => createHmac("sha256", secret).update(JSON.stringify(value)).digest("hex");
162
163
  let runtimeId = randomUUID();
163
164
  let ctx: ExtensionContext | undefined;
165
+ let usageObservers = serveUsageObservers(pi, () => ctx);
164
166
  let snapshot: Captured | undefined;
165
167
  let cache: CachedRequest | undefined;
166
- let summarizing = false;
168
+ let compactionRequest = false;
167
169
  let expiryAttempt: ExpiryAttempt | undefined;
168
170
  let lastOutcome: { path: string; elapsedMs: number; usage?: Usage; reason?: string } | undefined;
169
171
  let active: Active | undefined;
@@ -172,22 +174,11 @@ export default function (pi: ExtensionAPI): void {
172
174
  let unregisterCleanup: (() => void) | undefined;
173
175
  let generation = 0;
174
176
  let requestSequence = 0;
175
- let captureStatus = "no request observed";
176
- let compactionStatus = "not attempted in this runtime";
177
177
  let fallbackReason: string | undefined;
178
- const captureRejected = (reason: string) => { captureStatus = reason; };
179
- const fallback = (reason: string) => { fallbackReason = reason; compactionStatus = `portable: ${reason}`; };
180
- pi.registerCommand("provider-compaction-status", {
181
- description: "Show cache/native eligibility and measured compaction usage",
182
- handler: async (_args, context) => {
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");
187
- },
188
- });
189
- const invalidate = () => { generation++; runtimeId = randomUUID(); snapshot = undefined; cache = undefined; active = undefined; pending = undefined; expiryAttempt = undefined; captureStatus = "invalidated by lifecycle/history/policy change"; };
178
+ const fallback = (reason: string) => { fallbackReason = reason; };
179
+ const invalidate = () => { generation++; runtimeId = randomUUID(); snapshot = undefined; cache = undefined; active = undefined; pending = undefined; expiryAttempt = undefined; };
190
180
  const retire = () => {
181
+ usageObservers.clear();
191
182
  invalidate();
192
183
  const installed = observer;
193
184
  observer = undefined; ctx = undefined;
@@ -195,6 +186,12 @@ export default function (pi: ExtensionAPI): void {
195
186
  installed?.remove();
196
187
  lastOutcome = undefined;
197
188
  };
189
+ pi.on("before_provider_headers", (event, context) => {
190
+ bindUsageHeaders(event.headers, context.sessionManager.getSessionId());
191
+ // Lazy API loading or catalog refresh can recompose effective providers
192
+ // between auxiliary calls that have no before_agent_start boundary.
193
+ usageObservers.ready(context);
194
+ });
198
195
  const generic = (context: ExtensionContext) => resolveCompactionModelPolicy(pi, context).hasSelectors;
199
196
  const route = (model: Model<any>, options: ProviderStreamOptions | SimpleStreamOptions) => routeDigest({ model, apiKey: options.apiKey, headers: options.headers, env: options.env });
200
197
  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");
@@ -250,18 +247,12 @@ export default function (pi: ExtensionAPI): void {
250
247
  return;
251
248
  }
252
249
  if (options.sessionId !== manager.getSessionId()) 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
- }
261
- if (generic(owner)) { captureRejected("generic compaction model takes precedence"); return; }
250
+ if (compactionRequest) return;
251
+ if (!supportsNativeCompaction(model) && !supportsCachedSummary(model)) return;
252
+ if (!options.onPayload || generic(owner)) return;
262
253
  // The Azure adapter also accepts API-specific overrides and process-env
263
254
  // fallbacks. Those must not silently select a route absent from our evidence.
264
- if (!supportedRouteOptions(model, options)) { captureRejected("unsupported route overrides"); return; }
255
+ if (!supportedRouteOptions(model, options)) return;
265
256
  const ticket = generation;
266
257
  const startedAt = Date.now();
267
258
  const sequence = ++requestSequence;
@@ -287,11 +278,10 @@ export default function (pi: ExtensionAPI): void {
287
278
  const normalizedHeaders = (headers: ProviderHeaders | undefined) => Object.entries(headers ?? {}).map(([name, value]) => [name.toLowerCase(), value]).sort(([a], [b]) => String(a).localeCompare(String(b)));
288
279
  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 ?? {})) {
289
280
  active = undefined; cache = undefined;
290
- captureRejected("resolved auth/route differs from prepared request, or generic policy changed");
291
281
  return payload;
292
282
  }
293
283
  const provider = owner.modelRegistry.getProvider(model.provider);
294
- if (!provider) { captureRejected("provider unavailable"); return payload; }
284
+ if (!provider) return payload;
295
285
  const routeHash = route(model, options);
296
286
  if (config.cachedSummaries && supportsCachedSummary(model)) {
297
287
  const warming = options.maxTokens === 1 && cache && cache.authHash === routeDigest(auth) && sameCachedPrompt(cache.payload, payload) && equal(cache.context, context);
@@ -301,8 +291,8 @@ export default function (pi: ExtensionAPI): void {
301
291
  }, payload);
302
292
  if (!warming) cache = undefined;
303
293
  }
304
- if (!supportsNativeCompaction(model)) { captureStatus = candidate ? "eligible cached request captured" : "cached request ineligible"; return payload; }
305
- const prepared = capturePreparedRequest(model, context, canonical, payload, captureRejected);
294
+ if (!supportsNativeCompaction(model)) return payload;
295
+ const prepared = capturePreparedRequest(model, context, canonical, payload);
306
296
  if (!prepared) { active = undefined; return payload; }
307
297
  const policyHash = digest(requestPolicy(model, prepared));
308
298
  const latest = latestCompaction(branch);
@@ -325,7 +315,6 @@ export default function (pi: ExtensionAPI): void {
325
315
  } else active = undefined;
326
316
  } else active = undefined;
327
317
  } else active = undefined;
328
- captureStatus = "eligible prepared request captured";
329
318
  snapshot = {
330
319
  prepared, model: structuredClone(model), provider,
331
320
  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 } : {}) },
@@ -346,42 +335,51 @@ export default function (pi: ExtensionAPI): void {
346
335
 
347
336
  const install = (context: ExtensionContext) => {
348
337
  ctx = context;
349
- if (observer?.provider === context.model?.provider && observer?.isInstalled()) return;
338
+ if (observer?.provider === context.model?.provider && observer?.api === context.model?.api && observer?.isInstalled()) return;
350
339
  if (observer) { invalidate(); observer.remove(); observer = undefined; }
351
340
  if (!supportsNativeCompaction(context.model) && !supportsCachedSummary(context.model)) return;
352
341
  observer = installObserver(pi, context, observe);
353
- captureStatus = observer ? "awaiting prepared request" : "unsupported provider registration";
354
342
  };
355
343
  pi.on("session_start", (_event, context) => {
356
- retire(); install(context);
344
+ retire();
345
+ usageObservers.dispose();
346
+ usageObservers = serveUsageObservers(pi, () => ctx);
347
+ install(context);
348
+ usageObservers.ready(context);
357
349
  const sessionId = context.sessionManager.getSessionId();
358
350
  // AgentSession.dispose() cleans session resources even without emitting
359
351
  // session_shutdown. The captured registry keeps owned cleanup usable.
360
352
  unregisterCleanup = registerSessionResourceCleanup((disposedId) => {
361
- if (disposedId === undefined || disposedId === sessionId) retire();
353
+ if (disposedId === undefined || disposedId === sessionId) { retire(); usageObservers.dispose(); }
362
354
  });
363
355
  const latest = latestCompaction(context.sessionManager.getBranch());
364
356
  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");
365
357
  });
366
- pi.on("before_agent_start", (_event, context) => { install(context); });
367
- pi.on("model_select", (_event, context) => { invalidate(); install(context); });
358
+ pi.on("before_agent_start", (_event, context) => { install(context); usageObservers.ready(context); });
359
+ pi.on("model_select", (_event, context) => { invalidate(); install(context); usageObservers.ready(context); });
368
360
  pi.on("session_tree", () => { invalidate(); });
369
- pi.on("session_shutdown", retire);
361
+ pi.on("session_shutdown", () => { retire(); usageObservers.dispose(); });
370
362
  // Privacy-policy owners must invalidate before changing a policy that can affect
371
363
  // hidden covered history. Arbitrary dynamic privacy compositions are unsupported.
372
364
  const removeInvalidation = pi.events.on("pi-provider-compaction:invalidate", invalidate);
373
365
  pi.on("session_shutdown", () => { if (typeof removeInvalidation === "function") removeInvalidation(); });
374
366
 
375
367
  pi.on("session_before_compact", async (event, context) => {
376
- const began = Date.now();
377
- compactionStatus = "preparing compaction";
368
+ const began = Date.now(), ticket = generation;
369
+ const sessionId = context.sessionManager.getSessionId();
378
370
  fallbackReason = undefined;
379
371
  const attempt = expiryAttempt;
380
372
  const capture = snapshot;
381
373
  const cached = cacheState(context).available ? cache : undefined;
382
374
  snapshot = undefined; cache = undefined; pending = undefined;
383
375
  const cancelled = (usage?: Usage, reason = "compaction ownership or input changed") => {
384
- lastOutcome = { path: attempt ? "expiry cancelled" : "cancelled", elapsedMs: Date.now() - began, usage, reason };
376
+ // A cancelled attempt has no checkpoint to carry its known bill. Retain
377
+ // that usage in the existing session diagnostic, not a command-only cache.
378
+ if (usage && ticket === generation && ctx) {
379
+ try {
380
+ if (ctx.sessionManager.getSessionId() === sessionId) pi.appendEntry("pi-provider-compaction:diagnostic", { status: `${attempt ? "expiry cancelled" : "cancelled"}: ${reason}`, usage });
381
+ } catch { /* A disposed session cannot accept a diagnostic. */ }
382
+ }
385
383
  return { cancel: true as const };
386
384
  };
387
385
  const reject = (reason: string) => {
@@ -395,7 +393,6 @@ export default function (pi: ExtensionAPI): void {
395
393
  attempt.signal = event.signal;
396
394
  }
397
395
  if ((!capture && !cached) || !context.model || generic(context) || event.signal.aborted) return reject("no eligible snapshot, explicit model override, or aborted attempt");
398
- const ticket = generation;
399
396
  const branch = context.sessionManager.getBranch();
400
397
  const branchHash = digest(branch);
401
398
  const source = cached ?? capture!;
@@ -418,22 +415,20 @@ export default function (pi: ExtensionAPI): void {
418
415
  let summaryPath = "standard";
419
416
  let summaryReason: string | undefined;
420
417
  if (cached) {
421
- compactionStatus = "generating cached portable summary";
422
- summarizing = true;
418
+ compactionRequest = true;
423
419
  try {
424
420
  const result = await cachedSummary(cached, context, event.preparation, event.customInstructions, event.signal, current);
425
421
  portable = result.result;
426
422
  attemptedUsage = result.usage;
427
423
  summaryReason = result.reason;
428
424
  if (portable) summaryPath = "cached";
429
- } finally { summarizing = false; }
425
+ } finally { compactionRequest = false; }
430
426
  if (!current()) return cancelled(attemptedUsage);
431
427
  }
432
428
  if (!portable) {
433
429
  if (attempt) return cancelled(attemptedUsage, summaryReason ?? "warm cache unavailable");
434
430
  // Without native coverage or a dispatched cached attempt, Pi can own fallback.
435
431
  if (!input && !attemptedUsage) return reject(summaryReason ?? fallbackReason ?? "native coverage unavailable");
436
- compactionStatus = "generating portable summary";
437
432
  try {
438
433
  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
434
  portable = withFileTracking(portable, event.preparation);
@@ -450,25 +445,33 @@ export default function (pi: ExtensionAPI): void {
450
445
  return { compaction: result! };
451
446
  };
452
447
  if (!capture || !protocol || !input) {
453
- compactionStatus = `${summaryPath} portable summary prepared`;
454
448
  return outcome(summaryPath, portable, summaryReason);
455
449
  }
456
450
  try {
457
451
  const freshAuth = await context.modelRegistry.getApiKeyAndHeaders(context.model);
458
452
  if (!current()) return cancelled(portable.usage);
459
453
  if (!freshAuth.ok || routeDigest(freshAuth) !== capture.authHash) { fallback("auth changed after portable generation"); return outcome(summaryPath, portable, "native auth changed"); }
460
- compactionStatus = "requesting native checkpoint";
461
- const native = await requestProviderCompaction({
462
- provider: capture.provider, model: capture.model, context: capture.prepared.context,
463
- input, preparedPayload: capture.prepared.payload, protocol,
464
- apiKey: capture.options.apiKey, headers: capture.options.headers, env: capture.options.env,
465
- signal: event.signal, isCurrent: current, fetch: capture.options.fetch,
466
- ...(capture.options.serviceTier === undefined ? {} : { serviceTier: capture.options.serviceTier }),
467
- });
454
+ let native: Awaited<ReturnType<typeof requestProviderCompaction>>;
455
+ compactionRequest = true;
456
+ try {
457
+ native = await requestProviderCompaction({
458
+ provider: capture.provider, model: capture.model, context: capture.prepared.context,
459
+ input, preparedPayload: capture.prepared.payload, protocol,
460
+ apiKey: capture.options.apiKey, headers: capture.options.headers, env: capture.options.env,
461
+ signal: event.signal, isCurrent: current, fetch: capture.options.fetch, sessionId: source.sessionId,
462
+ ...(capture.options.serviceTier === undefined ? {} : { serviceTier: capture.options.serviceTier }),
463
+ });
464
+ } finally { compactionRequest = false; }
468
465
  if (!current()) return cancelled(combineUsage(native.usage, portable.usage));
469
466
  const finalAuth = await context.modelRegistry.getApiKeyAndHeaders(context.model);
470
467
  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"); }
468
+ if (!finalAuth.ok || routeDigest(finalAuth) !== capture.authHash) {
469
+ // The checkpoint is no longer safe to commit, but both requests
470
+ // completed and their known usage still belongs in the session.
471
+ portable.usage = combineUsage(native.usage, portable.usage);
472
+ fallback("auth changed after native generation");
473
+ return outcome(summaryPath, portable, "native auth changed");
474
+ }
472
475
  const details: NativeCompactionDetails = {
473
476
  ...(isObject(portable.details) ? portable.details : {}),
474
477
  type: NATIVE_COMPACTION_TYPE, version: 3, provider: capture.model.provider, api: capture.model.api, model: capture.model.id,
@@ -489,13 +492,10 @@ export default function (pi: ExtensionAPI): void {
489
492
  pi.on("session_compact", (event, context) => {
490
493
  const recorded = isObject(event.compactionEntry.details) ? event.compactionEntry.details.providerCompaction : undefined;
491
494
  const owned = isObject(recorded) && equal(recorded, lastOutcome);
492
- if (readNativeCompactionDetails(event.compactionEntry.details)?.version === 3) compactionStatus = "native checkpoint committed";
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");
495
+ if (!owned && !fallbackReason && readNativeCompactionDetails(event.compactionEntry.details)?.version !== 3) fallback("another handler supplied the committed result");
496
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 };
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}; estimated cost $${lastOutcome.usage.cost.total.toFixed(6)}` : ""}`, "info");
498
+ }
499
499
  // Only static diagnostics are persisted, never request/auth data or error text.
500
500
  if (fallbackReason) pi.appendEntry("pi-provider-compaction:diagnostic", { status: `portable: ${fallbackReason}` });
501
501
  active = undefined; snapshot = undefined; cache = undefined;
@@ -515,5 +515,5 @@ export default function (pi: ExtensionAPI): void {
515
515
  ...(typeof candidate.capture.prepared.payload.model === "string" ? { payloadModel: candidate.capture.prepared.payload.model } : {}),
516
516
  };
517
517
  });
518
- pi.on("session_compact_failed", () => { pending = undefined; snapshot = undefined; cache = undefined; compactionStatus = "compaction failed or cancelled"; });
518
+ pi.on("session_compact_failed", () => { pending = undefined; snapshot = undefined; cache = undefined; });
519
519
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nijaru/pi-provider-compaction",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
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 cached-summary.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,6 +39,7 @@
39
39
  "protocol.ts",
40
40
  "policy.ts",
41
41
  "provider.ts",
42
+ "usage-observer.ts",
42
43
  "replay.ts",
43
44
  "cached-summary.ts",
44
45
  "README.md",
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 };
package/protocol.ts CHANGED
@@ -208,7 +208,7 @@ async function collectProviderUsage(stream: AssistantMessageEventStream, signal:
208
208
  let usage: Usage | undefined;
209
209
  for await (const event of stream) {
210
210
  if (signal.aborted) throw new DOMException("Compaction aborted", "AbortError");
211
- if (event.type === "error") throw new Error(event.error.errorMessage ?? "Native compaction provider request failed");
211
+ if (event.type === "error") throw new NativeCompactionValidationError("Native compaction provider stream failed", event.error.usage);
212
212
  if (event.type === "done") usage = event.message.usage;
213
213
  }
214
214
  if (!usage) throw new Error("Native compaction provider stream ended without usage");
@@ -227,6 +227,7 @@ interface TransportRequest {
227
227
  env?: Record<string, string>;
228
228
  /** Cost-relevant request option captured with the original request. */
229
229
  serviceTier?: string;
230
+ sessionId?: string;
230
231
  signal: AbortSignal;
231
232
  /** Revalidate request ownership after asynchronous provider preparation. */
232
233
  isCurrent?: () => boolean;
@@ -252,7 +253,7 @@ export interface TransportResponse {
252
253
 
253
254
  async function requestResponsesCompact(request: TransportRequest): Promise<TransportResponse> {
254
255
  let preparedPayload: JsonObject | undefined;
255
- let compacted: { response: JsonObject; output: ResponseItem[] } | undefined;
256
+ let compacted: ResponseItem[] | undefined;
256
257
  let bridgeError: unknown;
257
258
  let dispatches = 0;
258
259
  const baseFetch = request.fetch ?? globalThis.fetch;
@@ -274,13 +275,13 @@ async function requestResponsesCompact(request: TransportRequest): Promise<Trans
274
275
  });
275
276
  if (!response.ok) return response;
276
277
  const parsed = await readJsonBounded(response, request.signal);
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
- }
283
- return syntheticCompletion(parsed, preparedPayload);
278
+ if (!isObject(parsed)) throw new Error("Responses Compact returned a non-object response");
279
+ // Let the SDK normalize and price reported usage even when the returned
280
+ // checkpoint is invalid. A synthetic HTTP error would discard that bill.
281
+ const completion = syntheticCompletion(parsed, preparedPayload);
282
+ try { compacted = validateCompactedOutput(parsed); }
283
+ catch (error) { bridgeError = new NativeCompactionValidationError(error instanceof Error ? error.message : String(error)); }
284
+ return completion;
284
285
  } catch (error) {
285
286
  bridgeError = error;
286
287
  return errorResponse(error);
@@ -296,6 +297,7 @@ async function requestResponsesCompact(request: TransportRequest): Promise<Trans
296
297
  transport: "sse",
297
298
  cacheRetention: "none",
298
299
  maxRetries: 0,
300
+ sessionId: request.sessionId,
299
301
  fetch: bridgeFetch,
300
302
  onPayload: (payload: unknown) => {
301
303
  assertCurrent(request);
@@ -307,13 +309,13 @@ async function requestResponsesCompact(request: TransportRequest): Promise<Trans
307
309
  });
308
310
 
309
311
  const usage = await collectProviderUsage(stream, request.signal).catch((error) => {
310
- if (bridgeError) throw bridgeError;
312
+ if (bridgeError) throw new NativeCompactionValidationError("Native compaction transport or validation failed", error instanceof NativeCompactionValidationError ? error.usage : undefined);
311
313
  throw error;
312
314
  });
313
315
  if (bridgeError instanceof NativeCompactionValidationError) throw new NativeCompactionValidationError(bridgeError.message, usage);
314
316
  if (bridgeError) throw bridgeError;
315
317
  if (!compacted || dispatches !== 1) throw new Error("Responses Compact did not complete exactly one request");
316
- return { output: compacted.output, usage };
318
+ return { output: compacted, usage };
317
319
  }
318
320
 
319
321
  interface CollectedSse {
@@ -438,6 +440,7 @@ async function requestRemoteV2(request: TransportRequest): Promise<TransportResp
438
440
  transport: "sse",
439
441
  cacheRetention: "none",
440
442
  maxRetries: 0,
443
+ sessionId: request.sessionId,
441
444
  fetch: inspectedFetch,
442
445
  onPayload: (payload: unknown) => {
443
446
  assertCurrent(request);
@@ -449,9 +452,9 @@ async function requestRemoteV2(request: TransportRequest): Promise<TransportResp
449
452
  });
450
453
  try {
451
454
  const usage = await collectProviderUsage(stream, request.signal);
452
- if (!sentInput || inspections.length !== 1) throw new Error(`Remote V2 observed ${inspections.length} successful provider responses; expected exactly one`);
455
+ if (!sentInput || inspections.length !== 1) throw new NativeCompactionValidationError(`Remote V2 observed ${inspections.length} successful provider responses; expected exactly one`, usage);
453
456
  const inspection = await inspections[0]!;
454
- if ("error" in inspection) throw inspection.error;
457
+ if ("error" in inspection) throw new NativeCompactionValidationError("Remote V2 checkpoint validation failed", usage);
455
458
  return { output: buildRemoteV2History(sentInput, inspection.result.item), usage };
456
459
  } finally {
457
460
  controller.abort();