@nijaru/pi-provider-compaction 0.0.9 → 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/README.md CHANGED
@@ -14,7 +14,9 @@ Pi has replacement provider registration, not composable middleware. This extens
14
14
  - **Existing static legacy `streamSimple` registrations:** the original callback and configuration are retained. This includes `pi-fast-mode`'s Codex registration.
15
15
  - **Built-in and composed providers:** the existing provider object's full and simple streaming methods are leased separately, without re-registering the provider or rebuilding auth/catalog configuration. Each original method retains its receiver. Cleanup restores only still-owned methods; replacing the provider revokes observation until it is reacquired.
16
16
 
17
- Sessions sharing a model runtime share observation dispatchers, not their captured histories. Releasing one session's lease leaves the others active; the final lease restores the original provider.
17
+ Sessions sharing a model runtime share observation dispatchers, not their captured histories. Leases are scoped to the provider/API dispatch path, including models.json APIs that bypass a native base. Releasing one session's lease leaves the others active; the final lease restores the original provider.
18
+
19
+ With the current `pi-peak-hours` companion, the same dispatcher applies request-scoped pricing before terminal usage reaches consumers or cache evidence. This covers foreground responses, warming, portable/native compaction and branch summaries without stacking competing provider wrappers. The SDK's header-record identity establishes ownership for summaries with independent routing IDs; no header content is read, copied or changed. Unknown ownership stays unadjusted. Prices remain estimates, not invoices.
18
20
 
19
21
  For an eligible request, protocol selection is automatic:
20
22
 
@@ -63,7 +65,7 @@ Request-only suffix additions, deletions, redactions and reordering can survive
63
65
 
64
66
  ## Safety and persistence
65
67
 
66
- Version 3 stores the portable summary, native output, usage, route identity and validated prefix metadata in one compaction entry. Both generation requests contribute to usage accounting. Raw credentials, complete auth headers and request snapshots are never persisted.
68
+ Version 3 stores the portable summary, native output, usage, route identity and validated prefix metadata in one compaction entry. Both generation requests contribute to usage accounting, including reported native usage when checkpoint validation fails. Raw credentials, complete auth headers and request snapshots are never persisted.
67
69
 
68
70
  Native eligibility is deliberately **session-local and short-lived**. Restart, reload, SDK disposal, resume, fork, tree navigation, model changes, covered-history edits, and relevant prompt/tool/route changes revoke it. A credential refresh may conservatively revoke it too. Persisted native metadata alone is not authority to replay hidden content under a new runtime's privacy policy.
69
71
 
@@ -75,7 +77,7 @@ Legacy v1/v2 whole-history checkpoints remain inspectable but are never reinterp
75
77
 
76
78
  ## Diagnose portable fallback
77
79
 
78
- Run `/provider-compaction-status` to see observer/capture eligibility, estimated cache lifetime, the last selected path, elapsed time, reported input/cache/output tokens, and catalog-priced cost. Optional outcome notifications show measured usage after a committed compaction; hypothetical savings are not presented as measured. Failed/cancelled expiry attempts remain visible in runtime status; if no checkpoint is saved, their usage cannot be attached to compaction totals through Pi's extension API. Portable fallback also appends a `pi-provider-compaction:diagnostic` session entry containing only a fixed reason string. It is not included in model context. Diagnostics never include request content, credentials, or backend error text.
80
+ Run `/provider-compaction-status` to see observer/capture eligibility, estimated cache lifetime, the last selected path, elapsed time, reported input/cache/output tokens, and estimated cost (including available companion corrections). Optional outcome notifications show measured usage after a committed compaction; hypothetical savings are not presented as measured. Failed/cancelled expiry attempts remain visible in runtime status; if no checkpoint is saved, their usage cannot be attached to compaction totals through Pi's extension API. Portable fallback also appends a `pi-provider-compaction:diagnostic` session entry containing only a fixed reason string. It is not included in model context. Diagnostics never include request content, credentials, or backend error text.
79
81
 
80
82
  These diagnostics require the extension to be loaded when the attempt happens; they cannot explain earlier compactions retroactively. A committed native checkpoint does not by itself prove that a later request replayed it.
81
83
 
@@ -100,7 +102,7 @@ pi --extension ./dist/index.js
100
102
 
101
103
  The manifest loads the built `dist/index.js`, including a bundled stateless Responses serializer validator; source or host dependency changes require a rebuild. `bun run check` rebuilds first and tests the artifact against Pi's actual host serializer, including function-call history replayed as grammar tools. Keep global installation disabled while testing. Publishing and global activation are separate approval steps.
102
104
 
103
- Tests include real Pi 1.4.0 runner/adapter fixtures for all three protocols, repeated prefix compaction, transformed suffix preservation, unchanged fallback, grammar tools, strict defaults, tool IDs, images, prompt updates, cross-model thinking and mid-conversation effort changes. Set `PI_COMPACTOR_FIXTURE_PATH` to a `pi-compactor` entrypoint to check SDK flag precedence and real idle-expiry transactions in both extension orders. Cached-summary fixtures also exercise built-in DeepSeek/Xiaomi dispatch, Anthropic Messages, late tool output, truncated-output fallback and measured usage. The optional installed-fast-mode fixture (`PI_FAST_MODE_FIXTURE_PATH`) checks actual `pi-fast-mode` composition across `/reload`; it creates an isolated fast-mode configuration.
105
+ Tests include real Pi 1.4.0 runner/adapter fixtures for all three protocols, repeated prefix compaction, transformed suffix preservation, unchanged fallback, grammar tools, strict defaults, tool IDs, images, prompt updates, cross-model thinking and mid-conversation effort changes. Set `PI_COMPACTOR_FIXTURE_PATH` to a `pi-compactor` entrypoint to check SDK flag precedence and real idle-expiry transactions in both extension orders. Cached-summary fixtures also exercise built-in DeepSeek/Xiaomi dispatch, Anthropic Messages, late tool output, truncated-output fallback and measured usage. The optional installed-fast-mode fixture (`PI_FAST_MODE_FIXTURE_PATH`) checks actual `pi-fast-mode` composition across `/reload`; it creates an isolated fast-mode configuration. `PI_PEAK_HOURS_FIXTURE_PATH` adds request-wide pricing checks in both extension orders, including warming, branch summaries, mixed-API dispatch and composition with `/fast`.
104
106
 
105
107
  Authenticated cached-summary smoke tests use synthetic history, including a value beyond the ordinary tool-result excerpt, and check continuation:
106
108
 
package/dist/index.js CHANGED
@@ -16,7 +16,6 @@ var COMPACTION_MODEL_FLAG = "compaction-model";
16
16
  var PROJECT_POLICY = "compaction-policy.json";
17
17
  var AGENT_DIR_OVERRIDE = "PI_COMPACTOR_AGENT_DIR";
18
18
  var MAX_POLICY_BYTES = 64 * 1024;
19
- var MAX_MODEL_SELECTORS = 8;
20
19
  var MAX_SELECTOR_LENGTH = 512;
21
20
  function normalizeSelector(value) {
22
21
  if (typeof value !== "string")
@@ -26,23 +25,14 @@ function normalizeSelector(value) {
26
25
  return;
27
26
  return selector;
28
27
  }
29
- function parsePolicySelectors(value) {
28
+ function policyHasSelectors(value) {
30
29
  if (typeof value !== "object" || value === null || Array.isArray(value))
31
30
  return;
32
31
  if (!("models" in value) || !Array.isArray(value.models))
33
32
  return;
34
- const selectors = [];
35
- for (const model of value.models) {
36
- const selector = normalizeSelector(model);
37
- if (selector === undefined || selectors.includes(selector))
38
- continue;
39
- selectors.push(selector);
40
- if (selectors.length === MAX_MODEL_SELECTORS)
41
- break;
42
- }
43
- return selectors;
33
+ return value.models.some((model) => normalizeSelector(model) !== undefined);
44
34
  }
45
- function readPolicySelectors(configPath) {
35
+ function readPolicySelection(configPath) {
46
36
  let fd;
47
37
  try {
48
38
  fd = openSync(configPath, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK);
@@ -59,7 +49,7 @@ function readPolicySelectors(configPath) {
59
49
  }
60
50
  if (bytesRead > MAX_POLICY_BYTES)
61
51
  return;
62
- return parsePolicySelectors(JSON.parse(buffer.subarray(0, bytesRead).toString("utf8")));
52
+ return policyHasSelectors(JSON.parse(buffer.subarray(0, bytesRead).toString("utf8")));
63
53
  } catch {
64
54
  return;
65
55
  } finally {
@@ -141,9 +131,9 @@ function resolveCompactionModelPolicy(pi, ctx, argv = process.argv.slice(2)) {
141
131
  }
142
132
  candidates.push({ path: join(agentDir, PROJECT_POLICY), source: "agent-policy" });
143
133
  for (const candidate of candidates) {
144
- const selectors = readPolicySelectors(candidate.path);
145
- if (selectors !== undefined) {
146
- return selectors.length > 0 ? { hasSelectors: true, source: candidate.source } : { hasSelectors: false };
134
+ const hasSelectors = readPolicySelection(candidate.path);
135
+ if (hasSelectors !== undefined) {
136
+ return hasSelectors ? { hasSelectors: true, source: candidate.source } : { hasSelectors: false };
147
137
  }
148
138
  }
149
139
  return { hasSelectors: false };
@@ -1170,7 +1160,7 @@ async function collectProviderUsage(stream, signal) {
1170
1160
  if (signal.aborted)
1171
1161
  throw new DOMException("Compaction aborted", "AbortError");
1172
1162
  if (event.type === "error")
1173
- throw new Error(event.error.errorMessage ?? "Native compaction provider request failed");
1163
+ throw new NativeCompactionValidationError("Native compaction provider stream failed", event.error.usage);
1174
1164
  if (event.type === "done")
1175
1165
  usage = event.message.usage;
1176
1166
  }
@@ -1216,14 +1206,15 @@ async function requestResponsesCompact(request) {
1216
1206
  if (!response.ok)
1217
1207
  return response;
1218
1208
  const parsed = await readJsonBounded(response, request.signal);
1209
+ if (!isObject(parsed))
1210
+ throw new Error("Responses Compact returned a non-object response");
1211
+ const completion = syntheticCompletion(parsed, preparedPayload);
1219
1212
  try {
1220
- if (!isObject(parsed))
1221
- throw new Error("Responses Compact returned a non-object response");
1222
- compacted = { response: parsed, output: validateCompactedOutput(parsed) };
1213
+ compacted = validateCompactedOutput(parsed);
1223
1214
  } catch (error) {
1224
- throw new NativeCompactionValidationError(error instanceof Error ? error.message : String(error));
1215
+ bridgeError = new NativeCompactionValidationError(error instanceof Error ? error.message : String(error));
1225
1216
  }
1226
- return syntheticCompletion(parsed, preparedPayload);
1217
+ return completion;
1227
1218
  } catch (error) {
1228
1219
  bridgeError = error;
1229
1220
  return errorResponse(error);
@@ -1238,6 +1229,7 @@ async function requestResponsesCompact(request) {
1238
1229
  transport: "sse",
1239
1230
  cacheRetention: "none",
1240
1231
  maxRetries: 0,
1232
+ sessionId: request.sessionId,
1241
1233
  fetch: bridgeFetch,
1242
1234
  onPayload: (payload) => {
1243
1235
  assertCurrent(request);
@@ -1251,7 +1243,7 @@ async function requestResponsesCompact(request) {
1251
1243
  });
1252
1244
  const usage = await collectProviderUsage(stream, request.signal).catch((error) => {
1253
1245
  if (bridgeError)
1254
- throw bridgeError;
1246
+ throw new NativeCompactionValidationError("Native compaction transport or validation failed", error instanceof NativeCompactionValidationError ? error.usage : undefined);
1255
1247
  throw error;
1256
1248
  });
1257
1249
  if (bridgeError instanceof NativeCompactionValidationError)
@@ -1260,7 +1252,7 @@ async function requestResponsesCompact(request) {
1260
1252
  throw bridgeError;
1261
1253
  if (!compacted || dispatches !== 1)
1262
1254
  throw new Error("Responses Compact did not complete exactly one request");
1263
- return { output: compacted.output, usage };
1255
+ return { output: compacted, usage };
1264
1256
  }
1265
1257
  async function collectCompactionSse(stream, signal) {
1266
1258
  const reader = stream.getReader();
@@ -1405,6 +1397,7 @@ async function requestRemoteV2(request) {
1405
1397
  transport: "sse",
1406
1398
  cacheRetention: "none",
1407
1399
  maxRetries: 0,
1400
+ sessionId: request.sessionId,
1408
1401
  fetch: inspectedFetch,
1409
1402
  onPayload: (payload) => {
1410
1403
  assertCurrent(request);
@@ -1419,10 +1412,10 @@ async function requestRemoteV2(request) {
1419
1412
  try {
1420
1413
  const usage = await collectProviderUsage(stream, request.signal);
1421
1414
  if (!sentInput || inspections.length !== 1)
1422
- throw new Error(`Remote V2 observed ${inspections.length} successful provider responses; expected exactly one`);
1415
+ throw new NativeCompactionValidationError(`Remote V2 observed ${inspections.length} successful provider responses; expected exactly one`, usage);
1423
1416
  const inspection = await inspections[0];
1424
1417
  if ("error" in inspection)
1425
- throw inspection.error;
1418
+ throw new NativeCompactionValidationError("Remote V2 checkpoint validation failed", usage);
1426
1419
  return { output: buildRemoteV2History(sentInput, inspection.result.item), usage };
1427
1420
  } finally {
1428
1421
  controller.abort();
@@ -1825,17 +1818,26 @@ async function cachedSummary(request, context, preparation, instructions, signal
1825
1818
  }
1826
1819
 
1827
1820
  // provider.ts
1821
+ import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
1822
+ var USAGE_OWNERS = Symbol.for("@nijaru/pi-provider-compaction/usage-owners");
1823
+ var usageGlobal = globalThis;
1824
+ var usageOwners = usageGlobal[USAGE_OWNERS] ??= new WeakMap;
1825
+ function bindUsageHeaders(headers, sessionId) {
1826
+ usageOwners.set(headers, sessionId);
1827
+ }
1828
1828
  var STREAM_OBSERVER = Symbol.for("@nijaru/pi-provider-compaction/stream-observer");
1829
1829
  function observedStream(model, context, options, observe, dispatch) {
1830
+ const headerOwner = options?.headers ? usageOwners.get(options.headers) : undefined;
1831
+ const scope = { sessionId: headerOwner ?? options?.sessionId };
1830
1832
  let handler;
1831
1833
  try {
1832
- handler = observe(model, context, options ?? {});
1834
+ handler = observe(model, context, options ?? {}, scope);
1833
1835
  } catch {
1834
1836
  return dispatch(options);
1835
1837
  }
1836
1838
  if (!handler)
1837
1839
  return dispatch(options);
1838
- const stream = dispatch({
1840
+ const run = () => dispatch({
1839
1841
  ...options,
1840
1842
  ...handler.cacheRetention === undefined ? {} : { cacheRetention: handler.cacheRetention },
1841
1843
  onPayload: async (payload, requestModel) => {
@@ -1848,14 +1850,64 @@ function observedStream(model, context, options, observe, dispatch) {
1848
1850
  }
1849
1851
  }
1850
1852
  });
1853
+ const stream = run();
1854
+ if (handler.priceUsage)
1855
+ return pricedStream(stream, model, handler);
1851
1856
  if (handler.complete)
1852
1857
  stream.result().then(handler.complete).catch(() => {});
1853
1858
  return stream;
1854
1859
  }
1860
+ function pricedStream(stream, model, handler) {
1861
+ const out = createAssistantMessageEventStream();
1862
+ (async () => {
1863
+ let final;
1864
+ let partial;
1865
+ try {
1866
+ for await (const event of stream) {
1867
+ if ("partial" in event)
1868
+ partial = event.partial;
1869
+ if (event.type === "done" || event.type === "error") {
1870
+ final = event.type === "done" ? event.message : event.error;
1871
+ try {
1872
+ handler.priceUsage?.(final);
1873
+ } catch {}
1874
+ try {
1875
+ handler.complete?.(final);
1876
+ } catch {}
1877
+ }
1878
+ out.push(event);
1879
+ }
1880
+ if (final) {
1881
+ out.end(final);
1882
+ return;
1883
+ }
1884
+ } catch {}
1885
+ const message = {
1886
+ ...partial ?? {
1887
+ role: "assistant",
1888
+ api: model.api,
1889
+ provider: model.provider,
1890
+ model: model.id,
1891
+ content: [],
1892
+ timestamp: Date.now(),
1893
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }
1894
+ },
1895
+ stopReason: "error",
1896
+ errorMessage: "Provider stream ended before a terminal event"
1897
+ };
1898
+ try {
1899
+ handler.priceUsage?.(message);
1900
+ } catch {}
1901
+ out.push({ type: "error", reason: "error", error: message });
1902
+ out.end(message);
1903
+ })();
1904
+ return out;
1905
+ }
1855
1906
  function observerLeases(provider, owned, restore) {
1856
1907
  const leases = new Map;
1857
1908
  let closed = false;
1858
- const active = (token) => !closed && leases.get(token)?.current() === true && owned();
1909
+ const live = (token) => !closed && leases.has(token);
1910
+ const active = (token) => live(token) && leases.get(token)?.current() === true && owned();
1859
1911
  const release = () => {
1860
1912
  if (closed)
1861
1913
  return;
@@ -1874,6 +1926,10 @@ function observerLeases(provider, owned, restore) {
1874
1926
  continue;
1875
1927
  handlers.push(Object.assign((payload) => active(token) ? handler(payload) : payload, {
1876
1928
  cacheRetention: handler.cacheRetention,
1929
+ priceUsage: handler.priceUsage ? (message) => {
1930
+ if (live(token))
1931
+ handler.priceUsage(message);
1932
+ } : undefined,
1877
1933
  complete: handler.complete ? (message) => {
1878
1934
  if (active(token))
1879
1935
  handler.complete(message);
@@ -1894,6 +1950,13 @@ function observerLeases(provider, owned, restore) {
1894
1950
  return payload;
1895
1951
  }, {
1896
1952
  cacheRetention: [...handlers].reverse().find((handler) => handler.cacheRetention !== undefined)?.cacheRetention,
1953
+ priceUsage: handlers.some((handler) => handler.priceUsage) ? (message) => {
1954
+ for (const handler of handlers) {
1955
+ try {
1956
+ handler.priceUsage?.(message);
1957
+ } catch {}
1958
+ }
1959
+ } : undefined,
1897
1960
  complete: handlers.some((handler) => handler.complete) ? (message) => {
1898
1961
  for (const handler of handlers) {
1899
1962
  try {
@@ -1918,7 +1981,7 @@ function observerLeases(provider, owned, restore) {
1918
1981
  }
1919
1982
  };
1920
1983
  }
1921
- var LEASE_TABLES = Symbol.for("@nijaru/pi-provider-compaction/observer-leases-v1");
1984
+ var LEASE_TABLES = Symbol.for("@nijaru/pi-provider-compaction/observer-leases-v2");
1922
1985
  var shared = globalThis;
1923
1986
  var { effectiveLeases, effectiveRestores, nativeLeases, legacyLeases } = shared[LEASE_TABLES] ??= {
1924
1987
  effectiveLeases: new WeakMap,
@@ -1994,13 +2057,21 @@ function installObserver(pi, ctx, observe) {
1994
2057
  return;
1995
2058
  const registry = ctx.modelRegistry;
1996
2059
  const id = model.provider;
2060
+ const observeApi = (requestModel, ...args) => requestModel.api === model.api ? observe(requestModel, ...args) : undefined;
2061
+ const tagged = (lease) => lease ? Object.assign(lease, { api: model.api }) : undefined;
2062
+ const effective = () => {
2063
+ const base = registry.getProvider(id);
2064
+ if (!base?.getModels().some((entry) => entry.api === model.api))
2065
+ return;
2066
+ return tagged(leaseEffectiveProvider(base, () => registry.getProvider(id), observeApi));
2067
+ };
1997
2068
  const native = registry.getRegisteredNativeProvider(id);
1998
2069
  if (native) {
1999
2070
  if (typeof native.getModels !== "function" || !native.getModels().some((entry) => entry.api === model.api))
2000
- return;
2071
+ return effective();
2001
2072
  const existing = nativeLeases.get(native);
2002
2073
  if (existing)
2003
- return existing.add(observe);
2074
+ return tagged(existing.add(observeApi));
2004
2075
  const leases = observerLeases(id, () => registry.getRegisteredNativeProvider(id) === wrapper, () => {
2005
2076
  if (registry.getRegisteredNativeProvider(id) === wrapper)
2006
2077
  registry.registerProvider(native);
@@ -2009,15 +2080,15 @@ function installObserver(pi, ctx, observe) {
2009
2080
  const wrapper = decorateProvider(native, leases.observe);
2010
2081
  nativeLeases.set(wrapper, leases);
2011
2082
  pi.registerProvider(wrapper);
2012
- return leases.add(observe);
2083
+ return tagged(leases.add(observeApi));
2013
2084
  }
2014
2085
  const original = registry.getRegisteredProviderConfig(id);
2015
2086
  if (!original?.streamSimple || original.refreshModels || original.oauth?.modifyModels || original.api !== model.api) {
2016
- return leaseEffectiveProvider(registry.getProvider(id), () => registry.getProvider(id), observe);
2087
+ return effective();
2017
2088
  }
2018
2089
  const existing = legacyLeases.get(original.streamSimple);
2019
2090
  if (existing)
2020
- return existing.add(observe);
2091
+ return tagged(existing.add(observeApi));
2021
2092
  const delegate = original.streamSimple.bind(original);
2022
2093
  const owned = () => registry.getRegisteredProviderConfig(id)?.streamSimple === wrapper && !registry.getRegisteredNativeProvider(id);
2023
2094
  const leases = observerLeases(id, owned, () => {
@@ -2032,7 +2103,62 @@ function installObserver(pi, ctx, observe) {
2032
2103
  Object.defineProperty(wrapper, STREAM_OBSERVER, { value: { delegate: original.streamSimple, release: leases.release } });
2033
2104
  legacyLeases.set(wrapper, leases);
2034
2105
  pi.registerProvider(id, { ...original, streamSimple: wrapper });
2035
- return leases.add(observe);
2106
+ return tagged(leases.add(observeApi));
2107
+ }
2108
+
2109
+ // usage-observer.ts
2110
+ var REQUEST = "pi-provider-compaction:observe-usage";
2111
+ var READY = "pi-provider-compaction:usage-observer-ready";
2112
+ function serveUsageObservers(pi, current) {
2113
+ const leases = new Set;
2114
+ const remove = pi.events.on(REQUEST, (value) => {
2115
+ const request = value;
2116
+ const owner = current();
2117
+ if (!owner || !request || request.handled || !request.context || !request.model || typeof request.priceFor !== "function" || typeof request.respond !== "function")
2118
+ return;
2119
+ try {
2120
+ if (request.context.modelRegistry !== owner.modelRegistry || request.context.sessionManager.getSessionId() !== owner.sessionManager.getSessionId())
2121
+ return;
2122
+ request.handled = true;
2123
+ const sessionId = owner.sessionManager.getSessionId();
2124
+ const installed = installObserver(pi, { model: request.model, modelRegistry: owner.modelRegistry }, (model, _context, _options, scope) => {
2125
+ const price = request.priceFor({ provider: model.provider, id: model.id });
2126
+ return Object.assign((payload) => payload, { priceUsage: (message) => {
2127
+ if (scope.sessionId === sessionId)
2128
+ price(message.timestamp, message.usage);
2129
+ } });
2130
+ });
2131
+ if (!installed) {
2132
+ request.respond(undefined);
2133
+ return;
2134
+ }
2135
+ const lease = {
2136
+ provider: installed.provider,
2137
+ api: installed.api,
2138
+ isInstalled: () => installed.isInstalled(),
2139
+ remove() {
2140
+ leases.delete(lease);
2141
+ installed.remove();
2142
+ }
2143
+ };
2144
+ leases.add(lease);
2145
+ request.respond(lease);
2146
+ } catch {}
2147
+ });
2148
+ return {
2149
+ ready(context) {
2150
+ pi.events.emit(READY, { context });
2151
+ },
2152
+ clear() {
2153
+ for (const lease of leases)
2154
+ lease.remove();
2155
+ },
2156
+ dispose() {
2157
+ remove();
2158
+ for (const lease of leases)
2159
+ lease.remove();
2160
+ }
2161
+ };
2036
2162
  }
2037
2163
 
2038
2164
  // index.ts
@@ -2136,9 +2262,10 @@ function pi_provider_compaction_default(pi) {
2136
2262
  const routeDigest = (value) => createHmac("sha256", secret).update(JSON.stringify(value)).digest("hex");
2137
2263
  let runtimeId = randomUUID2();
2138
2264
  let ctx;
2265
+ let usageObservers = serveUsageObservers(pi, () => ctx);
2139
2266
  let snapshot;
2140
2267
  let cache;
2141
- let summarizing = false;
2268
+ let compactionRequest = false;
2142
2269
  let expiryAttempt;
2143
2270
  let lastOutcome;
2144
2271
  let active;
@@ -2168,7 +2295,7 @@ Observer: ${observer?.isInstalled() ? "installed" : "unavailable for current reg
2168
2295
  Capture: ${captureStatus}
2169
2296
  Cache: ${state.reason}; ${remaining}
2170
2297
  Last attempt: ${compactionStatus}${lastOutcome ? `
2171
- Outcome: ${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");
2298
+ Outcome: ${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");
2172
2299
  }
2173
2300
  });
2174
2301
  const invalidate = () => {
@@ -2182,6 +2309,7 @@ Outcome: ${lastOutcome.path}, ${lastOutcome.elapsedMs}ms${usage ? `; input ${usa
2182
2309
  captureStatus = "invalidated by lifecycle/history/policy change";
2183
2310
  };
2184
2311
  const retire = () => {
2312
+ usageObservers.clear();
2185
2313
  invalidate();
2186
2314
  const installed = observer;
2187
2315
  observer = undefined;
@@ -2191,6 +2319,10 @@ Outcome: ${lastOutcome.path}, ${lastOutcome.elapsedMs}ms${usage ? `; input ${usa
2191
2319
  installed?.remove();
2192
2320
  lastOutcome = undefined;
2193
2321
  };
2322
+ pi.on("before_provider_headers", (event, context) => {
2323
+ bindUsageHeaders(event.headers, context.sessionManager.getSessionId());
2324
+ usageObservers.ready(context);
2325
+ });
2194
2326
  const generic = (context) => resolveCompactionModelPolicy(pi, context).hasSelectors;
2195
2327
  const route = (model, options) => routeDigest({ model, apiKey: options.apiKey, headers: options.headers, env: options.env });
2196
2328
  const unchanged = (branch, length, hash) => branch.length >= length && digest(branch.slice(0, length)) === hash && !branch.slice(length).some((entry) => entry.type === "context_edit");
@@ -2262,7 +2394,7 @@ Outcome: ${lastOutcome.path}, ${lastOutcome.elapsedMs}ms${usage ? `; input ${usa
2262
2394
  }
2263
2395
  if (options.sessionId !== manager.getSessionId())
2264
2396
  return;
2265
- if (summarizing)
2397
+ if (compactionRequest)
2266
2398
  return;
2267
2399
  if (!supportsNativeCompaction(model) && !supportsCachedSummary(model)) {
2268
2400
  captureRejected("unsupported API");
@@ -2398,7 +2530,7 @@ Outcome: ${lastOutcome.path}, ${lastOutcome.elapsedMs}ms${usage ? `; input ${usa
2398
2530
  };
2399
2531
  const install = (context) => {
2400
2532
  ctx = context;
2401
- if (observer?.provider === context.model?.provider && observer?.isInstalled())
2533
+ if (observer?.provider === context.model?.provider && observer?.api === context.model?.api && observer?.isInstalled())
2402
2534
  return;
2403
2535
  if (observer) {
2404
2536
  invalidate();
@@ -2412,11 +2544,16 @@ Outcome: ${lastOutcome.path}, ${lastOutcome.elapsedMs}ms${usage ? `; input ${usa
2412
2544
  };
2413
2545
  pi.on("session_start", (_event, context) => {
2414
2546
  retire();
2547
+ usageObservers.dispose();
2548
+ usageObservers = serveUsageObservers(pi, () => ctx);
2415
2549
  install(context);
2550
+ usageObservers.ready(context);
2416
2551
  const sessionId = context.sessionManager.getSessionId();
2417
2552
  unregisterCleanup = registerSessionResourceCleanup((disposedId) => {
2418
- if (disposedId === undefined || disposedId === sessionId)
2553
+ if (disposedId === undefined || disposedId === sessionId) {
2419
2554
  retire();
2555
+ usageObservers.dispose();
2556
+ }
2420
2557
  });
2421
2558
  const latest = latestCompaction(context.sessionManager.getBranch());
2422
2559
  if (latest?.summary === NATIVE_COMPACTION_SUMMARY)
@@ -2424,15 +2561,20 @@ Outcome: ${lastOutcome.path}, ${lastOutcome.elapsedMs}ms${usage ? `; input ${usa
2424
2561
  });
2425
2562
  pi.on("before_agent_start", (_event, context) => {
2426
2563
  install(context);
2564
+ usageObservers.ready(context);
2427
2565
  });
2428
2566
  pi.on("model_select", (_event, context) => {
2429
2567
  invalidate();
2430
2568
  install(context);
2569
+ usageObservers.ready(context);
2431
2570
  });
2432
2571
  pi.on("session_tree", () => {
2433
2572
  invalidate();
2434
2573
  });
2435
- pi.on("session_shutdown", retire);
2574
+ pi.on("session_shutdown", () => {
2575
+ retire();
2576
+ usageObservers.dispose();
2577
+ });
2436
2578
  const removeInvalidation = pi.events.on("pi-provider-compaction:invalidate", invalidate);
2437
2579
  pi.on("session_shutdown", () => {
2438
2580
  if (typeof removeInvalidation === "function")
@@ -2489,7 +2631,7 @@ Outcome: ${lastOutcome.path}, ${lastOutcome.elapsedMs}ms${usage ? `; input ${usa
2489
2631
  let summaryReason;
2490
2632
  if (cached) {
2491
2633
  compactionStatus = "generating cached portable summary";
2492
- summarizing = true;
2634
+ compactionRequest = true;
2493
2635
  try {
2494
2636
  const result = await cachedSummary(cached, context, event.preparation, event.customInstructions, event.signal, current);
2495
2637
  portable = result.result;
@@ -2498,7 +2640,7 @@ Outcome: ${lastOutcome.path}, ${lastOutcome.elapsedMs}ms${usage ? `; input ${usa
2498
2640
  if (portable)
2499
2641
  summaryPath = "cached";
2500
2642
  } finally {
2501
- summarizing = false;
2643
+ compactionRequest = false;
2502
2644
  }
2503
2645
  if (!current())
2504
2646
  return cancelled(attemptedUsage);
@@ -2539,27 +2681,35 @@ Outcome: ${lastOutcome.path}, ${lastOutcome.elapsedMs}ms${usage ? `; input ${usa
2539
2681
  return outcome(summaryPath, portable, "native auth changed");
2540
2682
  }
2541
2683
  compactionStatus = "requesting native checkpoint";
2542
- const native = await requestProviderCompaction({
2543
- provider: capture.provider,
2544
- model: capture.model,
2545
- context: capture.prepared.context,
2546
- input,
2547
- preparedPayload: capture.prepared.payload,
2548
- protocol,
2549
- apiKey: capture.options.apiKey,
2550
- headers: capture.options.headers,
2551
- env: capture.options.env,
2552
- signal: event.signal,
2553
- isCurrent: current,
2554
- fetch: capture.options.fetch,
2555
- ...capture.options.serviceTier === undefined ? {} : { serviceTier: capture.options.serviceTier }
2556
- });
2684
+ let native;
2685
+ compactionRequest = true;
2686
+ try {
2687
+ native = await requestProviderCompaction({
2688
+ provider: capture.provider,
2689
+ model: capture.model,
2690
+ context: capture.prepared.context,
2691
+ input,
2692
+ preparedPayload: capture.prepared.payload,
2693
+ protocol,
2694
+ apiKey: capture.options.apiKey,
2695
+ headers: capture.options.headers,
2696
+ env: capture.options.env,
2697
+ signal: event.signal,
2698
+ isCurrent: current,
2699
+ fetch: capture.options.fetch,
2700
+ sessionId: source.sessionId,
2701
+ ...capture.options.serviceTier === undefined ? {} : { serviceTier: capture.options.serviceTier }
2702
+ });
2703
+ } finally {
2704
+ compactionRequest = false;
2705
+ }
2557
2706
  if (!current())
2558
2707
  return cancelled(combineUsage(native.usage, portable.usage));
2559
2708
  const finalAuth = await context.modelRegistry.getApiKeyAndHeaders(context.model);
2560
2709
  if (!current())
2561
2710
  return cancelled(combineUsage(native.usage, portable.usage));
2562
2711
  if (!finalAuth.ok || routeDigest(finalAuth) !== capture.authHash) {
2712
+ portable.usage = combineUsage(native.usage, portable.usage);
2563
2713
  fallback("auth changed after native generation");
2564
2714
  return outcome(summaryPath, portable, "native auth changed");
2565
2715
  }
@@ -2602,7 +2752,7 @@ Outcome: ${lastOutcome.path}, ${lastOutcome.elapsedMs}ms${usage ? `; input ${usa
2602
2752
  fallback("another handler supplied the committed result");
2603
2753
  if (owned) {
2604
2754
  if (configuration().outcomeNotifications && lastOutcome)
2605
- 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");
2755
+ 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");
2606
2756
  } else
2607
2757
  lastOutcome = { path: "Pi/other handler", elapsedMs: 0, usage: event.compactionEntry.usage, reason: compactionStatus };
2608
2758
  if (fallbackReason)
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;
@@ -183,11 +185,12 @@ export default function (pi: ExtensionAPI): void {
183
185
  const state = cacheState(context);
184
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)`;
185
187
  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");
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");
187
189
  },
188
190
  });
189
191
  const invalidate = () => { generation++; runtimeId = randomUUID(); snapshot = undefined; cache = undefined; active = undefined; pending = undefined; expiryAttempt = undefined; captureStatus = "invalidated by lifecycle/history/policy change"; };
190
192
  const retire = () => {
193
+ usageObservers.clear();
191
194
  invalidate();
192
195
  const installed = observer;
193
196
  observer = undefined; ctx = undefined;
@@ -195,6 +198,12 @@ export default function (pi: ExtensionAPI): void {
195
198
  installed?.remove();
196
199
  lastOutcome = undefined;
197
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
+ });
198
207
  const generic = (context: ExtensionContext) => resolveCompactionModelPolicy(pi, context).hasSelectors;
199
208
  const route = (model: Model<any>, options: ProviderStreamOptions | SimpleStreamOptions) => routeDigest({ model, apiKey: options.apiKey, headers: options.headers, env: options.env });
200
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");
@@ -250,7 +259,7 @@ export default function (pi: ExtensionAPI): void {
250
259
  return;
251
260
  }
252
261
  if (options.sessionId !== manager.getSessionId()) return;
253
- if (summarizing) return;
262
+ if (compactionRequest) return;
254
263
  if (!supportsNativeCompaction(model) && !supportsCachedSummary(model)) { captureRejected("unsupported API"); return; }
255
264
  if (!options.onPayload) {
256
265
  // The portable-summary request re-enters this observer without a payload
@@ -346,27 +355,31 @@ export default function (pi: ExtensionAPI): void {
346
355
 
347
356
  const install = (context: ExtensionContext) => {
348
357
  ctx = context;
349
- if (observer?.provider === context.model?.provider && observer?.isInstalled()) return;
358
+ if (observer?.provider === context.model?.provider && observer?.api === context.model?.api && observer?.isInstalled()) return;
350
359
  if (observer) { invalidate(); observer.remove(); observer = undefined; }
351
360
  if (!supportsNativeCompaction(context.model) && !supportsCachedSummary(context.model)) return;
352
361
  observer = installObserver(pi, context, observe);
353
362
  captureStatus = observer ? "awaiting prepared request" : "unsupported provider registration";
354
363
  };
355
364
  pi.on("session_start", (_event, context) => {
356
- retire(); install(context);
365
+ retire();
366
+ usageObservers.dispose();
367
+ usageObservers = serveUsageObservers(pi, () => ctx);
368
+ install(context);
369
+ usageObservers.ready(context);
357
370
  const sessionId = context.sessionManager.getSessionId();
358
371
  // AgentSession.dispose() cleans session resources even without emitting
359
372
  // session_shutdown. The captured registry keeps owned cleanup usable.
360
373
  unregisterCleanup = registerSessionResourceCleanup((disposedId) => {
361
- if (disposedId === undefined || disposedId === sessionId) retire();
374
+ if (disposedId === undefined || disposedId === sessionId) { retire(); usageObservers.dispose(); }
362
375
  });
363
376
  const latest = latestCompaction(context.sessionManager.getBranch());
364
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");
365
378
  });
366
- pi.on("before_agent_start", (_event, context) => { install(context); });
367
- 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); });
368
381
  pi.on("session_tree", () => { invalidate(); });
369
- pi.on("session_shutdown", retire);
382
+ pi.on("session_shutdown", () => { retire(); usageObservers.dispose(); });
370
383
  // Privacy-policy owners must invalidate before changing a policy that can affect
371
384
  // hidden covered history. Arbitrary dynamic privacy compositions are unsupported.
372
385
  const removeInvalidation = pi.events.on("pi-provider-compaction:invalidate", invalidate);
@@ -419,14 +432,14 @@ export default function (pi: ExtensionAPI): void {
419
432
  let summaryReason: string | undefined;
420
433
  if (cached) {
421
434
  compactionStatus = "generating cached portable summary";
422
- summarizing = true;
435
+ compactionRequest = true;
423
436
  try {
424
437
  const result = await cachedSummary(cached, context, event.preparation, event.customInstructions, event.signal, current);
425
438
  portable = result.result;
426
439
  attemptedUsage = result.usage;
427
440
  summaryReason = result.reason;
428
441
  if (portable) summaryPath = "cached";
429
- } finally { summarizing = false; }
442
+ } finally { compactionRequest = false; }
430
443
  if (!current()) return cancelled(attemptedUsage);
431
444
  }
432
445
  if (!portable) {
@@ -458,17 +471,27 @@ export default function (pi: ExtensionAPI): void {
458
471
  if (!current()) return cancelled(portable.usage);
459
472
  if (!freshAuth.ok || routeDigest(freshAuth) !== capture.authHash) { fallback("auth changed after portable generation"); return outcome(summaryPath, portable, "native auth changed"); }
460
473
  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
- });
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; }
468
485
  if (!current()) return cancelled(combineUsage(native.usage, portable.usage));
469
486
  const finalAuth = await context.modelRegistry.getApiKeyAndHeaders(context.model);
470
487
  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"); }
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
+ }
472
495
  const details: NativeCompactionDetails = {
473
496
  ...(isObject(portable.details) ? portable.details : {}),
474
497
  type: NATIVE_COMPACTION_TYPE, version: 3, provider: capture.model.provider, api: capture.model.api, model: capture.model.id,
@@ -494,7 +517,7 @@ export default function (pi: ExtensionAPI): void {
494
517
  else if (owned) compactionStatus = `${lastOutcome!.path} portable summary committed`;
495
518
  else fallback("another handler supplied the committed result");
496
519
  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");
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");
498
521
  } else lastOutcome = { path: "Pi/other handler", elapsedMs: 0, usage: event.compactionEntry.usage, reason: compactionStatus };
499
522
  // Only static diagnostics are persisted, never request/auth data or error text.
500
523
  if (fallbackReason) pi.appendEntry("pi-provider-compaction:diagnostic", { status: `portable: ${fallbackReason}` });
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.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 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();
package/provider.ts CHANGED
@@ -1,14 +1,27 @@
1
- import type { AssistantMessage, AssistantMessageEventStream, CacheRetention, Provider, StreamOptions, Model, TranscriptContext } from "@earendil-works/pi-ai";
1
+ import { createAssistantMessageEventStream, type AssistantMessage, type AssistantMessageEventStream, type CacheRetention, type Provider, type StreamOptions, type Model, type TranscriptContext } from "@earendil-works/pi-ai";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ interface UsageScope { sessionId?: string; }
4
+ const USAGE_OWNERS = Symbol.for("@nijaru/pi-provider-compaction/usage-owners");
5
+ const usageGlobal = globalThis as typeof globalThis & { [USAGE_OWNERS]?: WeakMap<object, string> };
6
+ const usageOwners = usageGlobal[USAGE_OWNERS] ??= new WeakMap<object, string>();
7
+
8
+ /** Associate the SDK-owned header record without reading or changing any headers. */
9
+ export function bindUsageHeaders(headers: object, sessionId: string): void {
10
+ usageOwners.set(headers, sessionId);
11
+ }
3
12
 
4
13
  export type PayloadObserver = ((payload: unknown) => unknown | Promise<unknown>) & {
5
14
  complete?: (message: AssistantMessage) => void;
15
+ /** Adjust only usage before any completion observer or consumer sees it. */
16
+ priceUsage?: (message: AssistantMessage) => void;
6
17
  cacheRetention?: CacheRetention;
7
18
  };
8
- export type ObserveRequest = (model: Model<any>, context: TranscriptContext, options: StreamOptions) => PayloadObserver | undefined;
19
+ export type ObserveRequest = (model: Model<any>, context: TranscriptContext, options: StreamOptions, scope: UsageScope) => PayloadObserver | undefined;
9
20
 
10
21
  export interface InstalledObserver {
11
22
  provider: string;
23
+ /** installObserver leases one dispatch API; lower-level leases can be unscoped. */
24
+ api?: string;
12
25
  isInstalled(): boolean;
13
26
  remove(): void;
14
27
  }
@@ -18,11 +31,13 @@ export interface InstalledObserver {
18
31
  export const STREAM_OBSERVER = Symbol.for("@nijaru/pi-provider-compaction/stream-observer");
19
32
 
20
33
  function observedStream<T extends StreamOptions>(model: Model<any>, context: TranscriptContext, options: T | undefined, observe: ObserveRequest, dispatch: (options: T | undefined) => AssistantMessageEventStream): AssistantMessageEventStream {
34
+ const headerOwner = options?.headers ? usageOwners.get(options.headers) : undefined;
35
+ const scope: UsageScope = { sessionId: headerOwner ?? options?.sessionId };
21
36
  let handler: ReturnType<ObserveRequest>;
22
- try { handler = observe(model, context, options ?? {}); }
37
+ try { handler = observe(model, context, options ?? {}, scope); }
23
38
  catch { return dispatch(options); } // An expired optional observer must not veto dispatch.
24
39
  if (!handler) return dispatch(options);
25
- const stream = dispatch({
40
+ const run = () => dispatch({
26
41
  ...options,
27
42
  ...(handler.cacheRetention === undefined ? {} : { cacheRetention: handler.cacheRetention }),
28
43
  onPayload: async (payload: unknown, requestModel: Model<any>) => {
@@ -32,16 +47,52 @@ function observedStream<T extends StreamOptions>(model: Model<any>, context: Tra
32
47
  try { return await handler(prepared); } catch { return prepared; }
33
48
  },
34
49
  } as T);
35
- // result() observes completion without consuming the public event iterator.
50
+ const stream = run();
51
+ if (handler.priceUsage) return pricedStream(stream, model, handler);
52
+ // Pure capture remains transparent, including the original stream identity.
36
53
  if (handler.complete) void stream.result().then(handler.complete).catch(() => {});
37
54
  return stream;
38
55
  }
39
56
 
57
+ /** Pricing must precede both terminal-event delivery and result() resolution. */
58
+ function pricedStream(stream: AssistantMessageEventStream, model: Model<any>, handler: PayloadObserver): AssistantMessageEventStream {
59
+ const out = createAssistantMessageEventStream();
60
+ void (async () => {
61
+ let final: AssistantMessage | undefined;
62
+ let partial: AssistantMessage | undefined;
63
+ try {
64
+ for await (const event of stream) {
65
+ if ("partial" in event) partial = event.partial;
66
+ if (event.type === "done" || event.type === "error") {
67
+ final = event.type === "done" ? event.message : event.error;
68
+ try { handler.priceUsage?.(final); } catch { /* Optional estimates never veto completion. */ }
69
+ try { handler.complete?.(final); } catch { /* Capture failure never vetoes completion. */ }
70
+ }
71
+ out.push(event);
72
+ }
73
+ if (final) { out.end(final); return; }
74
+ } catch { /* Malformed plugin streams still need a terminal outcome. */ }
75
+ const message: AssistantMessage = {
76
+ ...(partial ?? {
77
+ role: "assistant", api: model.api, provider: model.provider, model: model.id,
78
+ content: [], timestamp: Date.now(),
79
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
80
+ }),
81
+ stopReason: "error", errorMessage: "Provider stream ended before a terminal event",
82
+ };
83
+ try { handler.priceUsage?.(message); } catch { /* Preserve the known usage on pricing failure. */ }
84
+ out.push({ type: "error", reason: "error", error: message });
85
+ out.end(message);
86
+ })();
87
+ return out;
88
+ }
89
+
40
90
  /** One dispatch layer, with session-local selection and no released lease history. */
41
91
  function observerLeases(provider: string, owned: () => boolean, restore: () => void) {
42
92
  const leases = new Map<symbol, { observe: ObserveRequest; current: () => boolean }>();
43
93
  let closed = false;
44
- const active = (token: symbol) => !closed && leases.get(token)?.current() === true && owned();
94
+ const live = (token: symbol) => !closed && leases.has(token);
95
+ const active = (token: symbol) => live(token) && leases.get(token)?.current() === true && owned();
45
96
  const release = () => {
46
97
  if (closed) return;
47
98
  closed = true;
@@ -57,6 +108,10 @@ function observerLeases(provider: string, owned: () => boolean, restore: () => v
57
108
  if (!handler) continue;
58
109
  handlers.push(Object.assign((payload: unknown) => active(token) ? handler(payload) : payload, {
59
110
  cacheRetention: handler.cacheRetention,
111
+ // A catalog/API recomposition may detach a provider after dispatch.
112
+ // Its in-flight bill still uses the sampled policy; explicit lease
113
+ // removal/disposal, unlike recomposition, revokes that callback.
114
+ priceUsage: handler.priceUsage ? (message: AssistantMessage) => { if (live(token)) handler.priceUsage!(message); } : undefined,
60
115
  complete: handler.complete ? (message: AssistantMessage) => { if (active(token)) handler.complete!(message); } : undefined,
61
116
  }));
62
117
  } catch { /* A failed optional observer must not disable the other sessions. */ }
@@ -72,6 +127,11 @@ function observerLeases(provider: string, owned: () => boolean, restore: () => v
72
127
  return payload;
73
128
  }, {
74
129
  cacheRetention: [...handlers].reverse().find((handler) => handler.cacheRetention !== undefined)?.cacheRetention,
130
+ priceUsage: handlers.some((handler) => handler.priceUsage) ? (message: AssistantMessage) => {
131
+ for (const handler of handlers) {
132
+ try { handler.priceUsage?.(message); } catch { /* Isolate independent pricing owners. */ }
133
+ }
134
+ } : undefined,
75
135
  complete: handlers.some((handler) => handler.complete) ? (message: AssistantMessage) => {
76
136
  for (const handler of handlers) {
77
137
  try { handler.complete?.(message); } catch { /* Isolate optional completion observers too. */ }
@@ -95,7 +155,7 @@ function observerLeases(provider: string, owned: () => boolean, restore: () => v
95
155
  type ObserverLeases = ReturnType<typeof observerLeases>;
96
156
  // Pi's uncached module loader can instantiate this extension separately for
97
157
  // sessions sharing one model runtime. Weak ownership must span those instances.
98
- const LEASE_TABLES = Symbol.for("@nijaru/pi-provider-compaction/observer-leases-v1");
158
+ const LEASE_TABLES = Symbol.for("@nijaru/pi-provider-compaction/observer-leases-v2");
99
159
  interface LeaseTables {
100
160
  effectiveLeases: WeakMap<Provider, { owned(): boolean; leases: ObserverLeases }>;
101
161
  effectiveRestores: WeakMap<Function, { base: Provider; restore(): void }>;
@@ -168,20 +228,27 @@ export function leaseEffectiveProvider(base: Provider | undefined, current: () =
168
228
  * delegate their original callback (including /fast). Never register an effective
169
229
  * composed provider as a native base: that loses model headers and freezes config.
170
230
  */
171
- export function installObserver(pi: ExtensionAPI, ctx: ExtensionContext, observe: ObserveRequest): InstalledObserver | undefined {
231
+ export function installObserver(pi: ExtensionAPI, ctx: Pick<ExtensionContext, "model" | "modelRegistry">, observe: ObserveRequest): InstalledObserver | undefined {
172
232
  const model = ctx.model;
173
233
  if (!model) return;
174
234
  const registry = ctx.modelRegistry;
175
235
  const id = model.provider;
236
+ const observeApi: ObserveRequest = (requestModel, ...args) => requestModel.api === model.api ? observe(requestModel, ...args) : undefined;
237
+ const tagged = (lease: InstalledObserver | undefined) => lease ? Object.assign(lease, { api: model.api }) : undefined;
238
+ const effective = () => {
239
+ const base = registry.getProvider(id);
240
+ if (!base?.getModels().some((entry) => entry.api === model.api)) return;
241
+ return tagged(leaseEffectiveProvider(base, () => registry.getProvider(id), observeApi));
242
+ };
176
243
  const native = registry.getRegisteredNativeProvider(id);
177
244
  if (native) {
178
245
  // Pi composes a native base only when its catalog serves the active API.
179
246
  // Otherwise the request goes to the global API provider and bypasses this
180
247
  // wrapper; claiming an observer there would report native compaction as
181
248
  // installed while never capturing anything.
182
- if (typeof native.getModels !== "function" || !native.getModels().some((entry) => entry.api === model.api)) return;
249
+ if (typeof native.getModels !== "function" || !native.getModels().some((entry) => entry.api === model.api)) return effective();
183
250
  const existing = nativeLeases.get(native);
184
- if (existing) return existing.add(observe);
251
+ if (existing) return tagged(existing.add(observeApi));
185
252
  const leases = observerLeases(id, () => registry.getRegisteredNativeProvider(id) === wrapper, () => {
186
253
  // Keep cleanup usable after bare SDK disposal invalidates the extension API.
187
254
  if (registry.getRegisteredNativeProvider(id) === wrapper) registry.registerProvider(native);
@@ -190,7 +257,7 @@ export function installObserver(pi: ExtensionAPI, ctx: ExtensionContext, observe
190
257
  const wrapper = decorateProvider(native, leases.observe);
191
258
  nativeLeases.set(wrapper, leases);
192
259
  pi.registerProvider(wrapper);
193
- return leases.add(observe);
260
+ return tagged(leases.add(observeApi));
194
261
  }
195
262
  const original = registry.getRegisteredProviderConfig(id);
196
263
  // Refresh callbacks own mutable catalog state inside the composer. Replacing
@@ -198,12 +265,12 @@ export function installObserver(pi: ExtensionAPI, ctx: ExtensionContext, observe
198
265
  if (!original?.streamSimple || original.refreshModels || original.oauth?.modifyModels || original.api !== model.api) {
199
266
  // Lease the effective object's methods, NOT its registry slot. Re-registering
200
267
  // a composed provider freezes catalogs and loses overlay/header semantics.
201
- return leaseEffectiveProvider(registry.getProvider(id), () => registry.getProvider(id), observe);
268
+ return effective();
202
269
  }
203
270
  // An injected legacy streamSimple overlay would also intercept full stream()
204
271
  // calls and change reasoning semantics. Only decorate an existing stream owner.
205
272
  const existing = legacyLeases.get(original.streamSimple);
206
- if (existing) return existing.add(observe);
273
+ if (existing) return tagged(existing.add(observeApi));
207
274
  const delegate = original.streamSimple.bind(original);
208
275
  const owned = () => registry.getRegisteredProviderConfig(id)?.streamSimple === wrapper && !registry.getRegisteredNativeProvider(id);
209
276
  const leases = observerLeases(id, owned, () => {
@@ -219,5 +286,5 @@ export function installObserver(pi: ExtensionAPI, ctx: ExtensionContext, observe
219
286
  Object.defineProperty(wrapper, STREAM_OBSERVER, { value: { delegate: original.streamSimple, release: leases.release } });
220
287
  legacyLeases.set(wrapper, leases);
221
288
  pi.registerProvider(id, { ...original, streamSimple: wrapper });
222
- return leases.add(observe);
289
+ return tagged(leases.add(observeApi));
223
290
  }
@@ -0,0 +1,52 @@
1
+ import type { Usage } from "@earendil-works/pi-ai";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { installObserver, type InstalledObserver } from "./provider";
4
+
5
+ const REQUEST = "pi-provider-compaction:observe-usage";
6
+ const READY = "pi-provider-compaction:usage-observer-ready";
7
+ type ModelKey = { provider: string; id: string };
8
+ type PriceUsage = (timestamp: number, usage: Usage) => void;
9
+ interface UsageRequest {
10
+ context: ExtensionContext;
11
+ model: NonNullable<ExtensionContext["model"]>;
12
+ priceFor(model: ModelKey): PriceUsage;
13
+ respond(observer: InstalledObserver | undefined): void;
14
+ handled?: boolean;
15
+ }
16
+
17
+ /** Share the existing stream owner, rather than stacking competing pricing wrappers. */
18
+ export function serveUsageObservers(pi: ExtensionAPI, current: () => ExtensionContext | undefined) {
19
+ const leases = new Set<InstalledObserver>();
20
+ const remove = pi.events.on(REQUEST, (value) => {
21
+ const request = value as Partial<UsageRequest> | undefined;
22
+ const owner = current();
23
+ if (!owner || !request || request.handled || !request.context || !request.model || typeof request.priceFor !== "function" || typeof request.respond !== "function") return;
24
+ try {
25
+ if (request.context.modelRegistry !== owner.modelRegistry || request.context.sessionManager.getSessionId() !== owner.sessionManager.getSessionId()) return;
26
+ request.handled = true;
27
+ const sessionId = owner.sessionManager.getSessionId();
28
+ const installed = installObserver(pi, { model: request.model, modelRegistry: owner.modelRegistry }, (model, _context, _options, scope) => {
29
+ // The SDK header event identifies the owner even when a branch
30
+ // summary has an independent routing id. Sample policy at dispatch.
31
+ const price = request.priceFor!({ provider: model.provider, id: model.id });
32
+ return Object.assign((payload: unknown) => payload, { priceUsage: (message: { timestamp: number; usage: Usage }) => {
33
+ if (scope.sessionId === sessionId) price(message.timestamp, message.usage);
34
+ } });
35
+ });
36
+ if (!installed) { request.respond(undefined); return; }
37
+ const lease: InstalledObserver = {
38
+ provider: installed.provider,
39
+ api: installed.api,
40
+ isInstalled: () => installed.isInstalled(),
41
+ remove() { leases.delete(lease); installed.remove(); },
42
+ };
43
+ leases.add(lease);
44
+ request.respond(lease);
45
+ } catch { /* Optional cost estimates must not veto a provider request. */ }
46
+ });
47
+ return {
48
+ ready(context: ExtensionContext) { pi.events.emit(READY, { context }); },
49
+ clear() { for (const lease of leases) lease.remove(); },
50
+ dispose() { remove(); for (const lease of leases) lease.remove(); },
51
+ };
52
+ }