@nijaru/pi-provider-compaction 0.0.4 → 0.0.6

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
@@ -1,14 +1,14 @@
1
1
  # pi-provider-compaction
2
2
 
3
- Experimental provider-native compaction for [Pi](https://github.com/earendil-works/pi), with a meaningful portable summary saved alongside each native checkpoint. Requires Pi 0.87.0.
3
+ Experimental provider-native compaction for [Pi](https://github.com/earendil-works/pi), with a meaningful portable summary saved alongside each native checkpoint. Minimum runtime: Pi 0.87.0. Development checks target Pi 1.2.0.
4
4
 
5
- **The unpublished redesign is not production-validated. Do not enable it globally yet.** Real runner/adapter fixtures and a Codex live smoke test pass; broader backend and deployment validation remains incomplete.
5
+ **This experimental design is not production-validated. Do not enable it globally yet.** Real runner/adapter fixtures and a Codex live smoke test pass; broader backend and deployment validation remains incomplete.
6
6
 
7
7
  [pi-compactor](https://github.com/nijaru/pi-compactor) owns compaction timing and continuation. This extension owns native request capture, prefix coverage, persistence and replay. It does not change `/fast` or account reporting.
8
8
 
9
9
  ## Supported integration
10
10
 
11
- Pi 0.87.0 has replacement provider registration, not composable middleware. This extension only installs request observation where it can preserve the existing provider contract:
11
+ Pi has replacement provider registration, not composable middleware. This extension only installs request observation where it can preserve the existing provider contract:
12
12
 
13
13
  - **Registered native base providers:** both streaming methods are decorated; authentication, catalogs, filtering, refresh and deferred methods stay with the original provider. `models.json` remains above that base.
14
14
  - **Existing static legacy `streamSimple` registrations:** the original callback and configuration are retained. This includes `pi-fast-mode`'s Codex registration. Legacy dynamic catalogs and OAuth model-projection callbacks are excluded.
@@ -38,7 +38,7 @@ Request-only suffix additions, deletions, redactions and reordering can survive
38
38
 
39
39
  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.
40
40
 
41
- Native eligibility is deliberately **session-local and short-lived**. Restart, reload, 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.
41
+ 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.
42
42
 
43
43
  Dynamic privacy policies that can change what previously covered history is allowed to reveal are **not transparently supported**. A cooperating policy owner must emit `pi-provider-compaction:invalidate` before such a change; otherwise keep this extension disabled. Matching visible summary text cannot establish that hidden native state obeys a new policy.
44
44
 
@@ -60,7 +60,7 @@ An explicit generic model takes precedence, matching `pi-compactor`:
60
60
  2. trusted `.pi/compaction-policy.json`;
61
61
  3. agent `compaction-policy.json`.
62
62
 
63
- An empty `models` list is not an override.
63
+ An empty `models` list is not an override. When `pi-compactor` is loaded, its effective selection takes precedence, including flags injected by an SDK host rather than passed on the command line.
64
64
 
65
65
  ## Local development
66
66
 
@@ -71,9 +71,9 @@ bun run build
71
71
  pi --extension ./dist/index.js
72
72
  ```
73
73
 
74
- The manifest loads the built `dist/index.js`; source edits alone do not update it. Keep global installation disabled while testing. Publishing and global activation are separate approval steps.
74
+ 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.
75
75
 
76
- Tests include real Pi 0.87.0 runner/adapter fixtures for all three protocols, repeated prefix compaction, transformed suffix preservation, unchanged fallback, grammar tools, strict defaults, tool IDs, images and prompt updates. The optional installed-fast-mode fixture (`PI_FAST_MODE_FIXTURE_PATH`) also checks actual `pi-fast-mode` composition across `/reload`; run it with an isolated `PI_CODING_AGENT_DIR` containing an active, persistent fast-mode configuration.
76
+ Tests include real Pi 1.2.0 runner/adapter fixtures for all three protocols, repeated prefix compaction, transformed suffix preservation, unchanged fallback, grammar tools, strict defaults, tool IDs, images and prompt updates. Set `PI_COMPACTOR_FIXTURE_PATH` to a `pi-compactor` entrypoint to check SDK flag precedence in both extension orders. 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.
77
77
 
78
78
  Authenticated protocol smoke tests are separate and send three small requests:
79
79
 
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // index.ts
2
2
  import { createHash, createHmac, randomBytes, randomUUID } from "node:crypto";
3
- import { normalizeContext as normalizeContext2 } from "@earendil-works/pi-ai";
3
+ import { normalizeContext as normalizeContext2, registerSessionResourceCleanup } from "@earendil-works/pi-ai";
4
4
  import { compact, convertToLlm } from "@earendil-works/pi-coding-agent";
5
5
 
6
6
  // policy.ts
@@ -119,18 +119,21 @@ function readCompactionModelFlagFromArgv(argv = process.argv.slice(2)) {
119
119
  return value;
120
120
  }
121
121
  function resolveCompactionModelPolicy(pi, ctx, argv = process.argv.slice(2)) {
122
+ let effective;
123
+ pi.events.emit("pi-compactor:resolve-model-policy", {
124
+ context: ctx,
125
+ respond: (hasSelectors) => {
126
+ if (typeof hasSelectors === "boolean")
127
+ effective = hasSelectors;
128
+ }
129
+ });
130
+ if (effective !== undefined)
131
+ return effective ? { hasSelectors: true, source: "compactor" } : { hasSelectors: false };
122
132
  const argvRaw = readCompactionModelFlagFromArgv(argv);
123
133
  if (typeof argvRaw === "string" && argvRaw.trim()) {
124
134
  const selector = normalizeSelector(argvRaw);
125
135
  return selector ? { hasSelectors: true, source: "flag" } : { hasSelectors: false };
126
136
  }
127
- try {
128
- const rawFlag = pi.getFlag(COMPACTION_MODEL_FLAG);
129
- if (typeof rawFlag === "string" && rawFlag.trim()) {
130
- const selector = normalizeSelector(rawFlag);
131
- return selector ? { hasSelectors: true, source: "flag" } : { hasSelectors: false };
132
- }
133
- } catch {}
134
137
  const agentDir = activeAgentDir(ctx);
135
138
  const candidates = [];
136
139
  if (isProjectPolicyTrusted(ctx, agentDir)) {
@@ -147,8 +150,14 @@ function resolveCompactionModelPolicy(pi, ctx, argv = process.argv.slice(2)) {
147
150
  }
148
151
 
149
152
  // provider.ts
153
+ var STREAM_OBSERVER = Symbol.for("@nijaru/pi-provider-compaction/stream-observer");
150
154
  function optionsWithObserver(model, context, options, observe) {
151
- const handler = observe(model, context, options ?? {});
155
+ let handler;
156
+ try {
157
+ handler = observe(model, context, options ?? {});
158
+ } catch {
159
+ return options;
160
+ }
152
161
  if (!handler)
153
162
  return options;
154
163
  return {
@@ -191,15 +200,22 @@ function installObserver(pi, ctx, observe) {
191
200
  const id = model.provider;
192
201
  const native = registry.getRegisteredNativeProvider(id);
193
202
  if (native) {
203
+ const isInstalled = () => registry.getRegisteredNativeProvider(id) === wrapper;
194
204
  const guarded = (...args) => {
205
+ if (!isInstalled())
206
+ return;
195
207
  const handler = observe(...args);
196
- return handler ? (payload) => registry.getRegisteredNativeProvider(id) === wrapper ? handler(payload) : payload : undefined;
208
+ return handler ? (payload) => isInstalled() ? handler(payload) : payload : undefined;
197
209
  };
198
210
  const wrapper = decorateProvider(native, guarded);
199
211
  pi.registerProvider(wrapper);
200
- return () => {
201
- if (registry.getRegisteredNativeProvider(id) === wrapper)
202
- pi.registerProvider(native);
212
+ return {
213
+ provider: id,
214
+ isInstalled,
215
+ remove() {
216
+ if (isInstalled())
217
+ registry.registerProvider(native);
218
+ }
203
219
  };
204
220
  }
205
221
  const original = registry.getRegisteredProviderConfig(id);
@@ -208,27 +224,24 @@ function installObserver(pi, ctx, observe) {
208
224
  if (original.api !== model.api)
209
225
  return;
210
226
  const delegate = original.streamSimple.bind(original);
227
+ const isInstalled = () => registry.getRegisteredProviderConfig(id)?.streamSimple === wrapper && !registry.getRegisteredNativeProvider(id);
211
228
  const guarded = (...args) => {
229
+ if (!isInstalled())
230
+ return;
212
231
  const handler = observe(...args);
213
- return handler ? (payload) => registry.getRegisteredProviderConfig(id)?.streamSimple === wrapper ? handler(payload) : payload : undefined;
232
+ return handler ? (payload) => isInstalled() ? handler(payload) : payload : undefined;
214
233
  };
215
234
  const wrapper = (requestModel, context, options) => delegate(requestModel, context, optionsWithObserver(requestModel, context, options, guarded));
216
- pi.registerProvider(id, { ...original, api: original?.api ?? model.api, streamSimple: wrapper });
217
- return () => {
218
- const current = registry.getRegisteredProviderConfig(id);
219
- if (current?.streamSimple !== wrapper || registry.getRegisteredNativeProvider(id))
235
+ const remove = () => {
236
+ if (!isInstalled())
220
237
  return;
221
- const restored = { ...current };
222
- if (original?.streamSimple)
223
- restored.streamSimple = original.streamSimple;
224
- else
225
- delete restored.streamSimple;
226
- if (!original?.api && restored.api === model.api)
227
- delete restored.api;
228
- pi.unregisterProvider(id);
229
- if (Object.keys(restored).length)
230
- pi.registerProvider(id, restored);
238
+ const restored = { ...registry.getRegisteredProviderConfig(id), streamSimple: original.streamSimple };
239
+ registry.unregisterProvider(id);
240
+ registry.registerProvider(id, restored);
231
241
  };
242
+ Object.defineProperty(wrapper, STREAM_OBSERVER, { value: { delegate: original.streamSimple, release: remove } });
243
+ pi.registerProvider(id, { ...original, streamSimple: wrapper });
244
+ return { provider: id, isInstalled, remove };
232
245
  }
233
246
 
234
247
  // replay.ts
@@ -273,7 +286,7 @@ function schemaAllowsNull(schema) {
273
286
  return true;
274
287
  return Array.isArray(schema.anyOf) && schema.anyOf.some((variant) => schemaAllowsNull(variant));
275
288
  }
276
- function makeJsonSchemaNodeStrict(schema) {
289
+ function makeJsonSchemaNodeStrict(schema, isUnsupportedKeyword) {
277
290
  if (!isJsonSchemaObject(schema)) {
278
291
  throw new UnsupportedStrictJsonSchemaError("boolean schemas are unsupported");
279
292
  }
@@ -282,6 +295,13 @@ function makeJsonSchemaNodeStrict(schema) {
282
295
  throw new UnsupportedStrictJsonSchemaError(`${key} schemas are unsupported`);
283
296
  }
284
297
  }
298
+ if (isUnsupportedKeyword) {
299
+ for (const [key, value] of Object.entries(schema)) {
300
+ if (isUnsupportedKeyword(key, value)) {
301
+ throw new UnsupportedStrictJsonSchemaError(`${key}: ${JSON.stringify(value)} is unsupported`);
302
+ }
303
+ }
304
+ }
285
305
  if (schema.anyOf !== undefined) {
286
306
  if (!Array.isArray(schema.anyOf) || schema.anyOf.length === 0) {
287
307
  throw new UnsupportedStrictJsonSchemaError("anyOf must contain at least one schema");
@@ -290,14 +310,14 @@ function makeJsonSchemaNodeStrict(schema) {
290
310
  if (isStructuredSchema(variant)) {
291
311
  throw new UnsupportedStrictJsonSchemaError("object and array unions are unsupported");
292
312
  }
293
- makeJsonSchemaNodeStrict(variant);
313
+ makeJsonSchemaNodeStrict(variant, isUnsupportedKeyword);
294
314
  }
295
315
  }
296
316
  if (schema.items !== undefined) {
297
317
  if (Array.isArray(schema.items)) {
298
318
  throw new UnsupportedStrictJsonSchemaError("tuple schemas are unsupported");
299
319
  }
300
- makeJsonSchemaNodeStrict(schema.items);
320
+ makeJsonSchemaNodeStrict(schema.items, isUnsupportedKeyword);
301
321
  }
302
322
  const isObjectSchema = schema.type === "object";
303
323
  if (schema.properties !== undefined && !isObjectSchema) {
@@ -321,7 +341,7 @@ function makeJsonSchemaNodeStrict(schema) {
321
341
  throw new UnsupportedStrictJsonSchemaError("required contains an unknown property");
322
342
  }
323
343
  for (const [key, property] of Object.entries(properties)) {
324
- makeJsonSchemaNodeStrict(property);
344
+ makeJsonSchemaNodeStrict(property, isUnsupportedKeyword);
325
345
  if (!required.has(key) && !schemaAllowsNull(property)) {
326
346
  properties[key] = { anyOf: [property, { type: "null" }] };
327
347
  }
@@ -329,12 +349,12 @@ function makeJsonSchemaNodeStrict(schema) {
329
349
  schema.required = propertyNames;
330
350
  schema.additionalProperties = false;
331
351
  }
332
- function makeStrictJsonSchema(schema) {
352
+ function makeStrictJsonSchema(schema, isUnsupportedKeyword) {
333
353
  const cloned = structuredClone(schema);
334
354
  if (!isJsonSchemaObject(cloned)) {
335
355
  throw new UnsupportedStrictJsonSchemaError("root schema must have type object");
336
356
  }
337
- makeJsonSchemaNodeStrict(cloned);
357
+ makeJsonSchemaNodeStrict(cloned, isUnsupportedKeyword);
338
358
  if (cloned.type !== "object") {
339
359
  throw new UnsupportedStrictJsonSchemaError("root schema must have type object");
340
360
  }
@@ -367,13 +387,13 @@ function inferGrammarInputProperty(tool) {
367
387
  }
368
388
  return inputProperty;
369
389
  }
370
- function resolveJsonSchemaStrictSampling(tool, supportsStrictMode) {
390
+ function resolveJsonSchemaStrictSampling(tool, supportsStrictMode, isUnsupportedKeyword) {
371
391
  const config = tool.constrainedSampling;
372
392
  if (!config || config.type !== "json_schema")
373
393
  return;
374
394
  if (supportsStrictMode) {
375
395
  try {
376
- makeStrictJsonSchema(tool.parameters);
396
+ makeStrictJsonSchema(tool.parameters, isUnsupportedKeyword);
377
397
  return true;
378
398
  } catch (error) {
379
399
  if (!(error instanceof UnsupportedStrictJsonSchemaError))
@@ -899,7 +919,8 @@ function convertResponsesMessages(model, context, allowedToolCallProviders, opti
899
919
  const [callId, itemIdRaw] = toolCall.id.split("|");
900
920
  const customInputProperty = options?.grammarToolInputProperties?.get(toolCall.name);
901
921
  let itemId = itemIdRaw;
902
- if (isDifferentModel && itemId?.startsWith("fc_") || customInputProperty === undefined && !itemId?.startsWith("fc_")) {
922
+ const itemIdPrefix = customInputProperty === undefined ? "fc_" : "ctc_";
923
+ if (isDifferentModel || !itemId?.startsWith(itemIdPrefix)) {
903
924
  itemId = undefined;
904
925
  }
905
926
  if (customInputProperty !== undefined) {
@@ -1436,6 +1457,7 @@ async function requestProviderCompaction(request) {
1436
1457
 
1437
1458
  // replay.ts
1438
1459
  var TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]);
1460
+ var AZURE_TOOL_CALL_PROVIDERS = new Set([...TOOL_CALL_PROVIDERS, "azure"]);
1439
1461
  var MAX_SNAPSHOT_BYTES = 8 * 1024 * 1024;
1440
1462
  var SNAPSHOT_TTL_MS = 5 * 60000;
1441
1463
  function equal(left, right) {
@@ -1447,7 +1469,7 @@ function serializeInput(model, context, declarations = context) {
1447
1469
  const codex = model.api === "openai-codex-responses";
1448
1470
  const normalized = resolveTranscript2(context, flag("supportsMidConvoSystemMessages"));
1449
1471
  const tools = resolveTranscript2(declarations, flag("supportsMidConvoSystemMessages"));
1450
- return convertResponsesMessages(model, normalized, model.api === "azure-openai-responses" ? new Set([...TOOL_CALL_PROVIDERS, "azure-openai-responses"]) : TOOL_CALL_PROVIDERS, {
1472
+ return convertResponsesMessages(model, normalized, model.api === "azure-openai-responses" ? AZURE_TOOL_CALL_PROVIDERS : TOOL_CALL_PROVIDERS, {
1451
1473
  includeSystemPrompt: !codex,
1452
1474
  grammarToolInputProperties: createGrammarToolInputProperties(getDeclaredTools(tools.messages), flag("supportsOpenAIGrammarTools")),
1453
1475
  supportsMidConvoSystemMessages: flag("supportsMidConvoSystemMessages"),
@@ -1683,8 +1705,8 @@ function pi_provider_compaction_default(pi) {
1683
1705
  let snapshot;
1684
1706
  let active;
1685
1707
  let pending;
1686
- let uninstall;
1687
- let installedProvider;
1708
+ let observer;
1709
+ let unregisterCleanup;
1688
1710
  let generation = 0;
1689
1711
  let captureStatus = "no request observed";
1690
1712
  let compactionStatus = "not attempted in this runtime";
@@ -1698,7 +1720,7 @@ function pi_provider_compaction_default(pi) {
1698
1720
  description: "Show content-free native compaction eligibility and last attempt",
1699
1721
  handler: async (_args, context) => {
1700
1722
  context.ui.notify(`Provider compaction
1701
- Observer: ${installedProvider ? "installed" : "unavailable for current registration"}
1723
+ Observer: ${observer?.isInstalled() ? "installed" : "unavailable for current registration"}
1702
1724
  Capture: ${captureStatus}
1703
1725
  Last attempt: ${compactionStatus}`, "info");
1704
1726
  }
@@ -1711,12 +1733,30 @@ Last attempt: ${compactionStatus}`, "info");
1711
1733
  pending = undefined;
1712
1734
  captureStatus = "invalidated by lifecycle/history/policy change";
1713
1735
  };
1736
+ const retire = () => {
1737
+ invalidate();
1738
+ const installed = observer;
1739
+ observer = undefined;
1740
+ ctx = undefined;
1741
+ unregisterCleanup?.();
1742
+ unregisterCleanup = undefined;
1743
+ installed?.remove();
1744
+ };
1714
1745
  const generic = (context) => resolveCompactionModelPolicy(pi, context).hasSelectors;
1715
1746
  const route = (model, options) => routeDigest({ model, apiKey: options.apiKey, headers: options.headers, env: options.env });
1716
1747
  const unchanged = (branch, length, hash) => branch.length >= length && digest(branch.slice(0, length)) === hash && !branch.slice(length).some((entry) => entry.type === "context_edit");
1717
1748
  const observe = (model, context, options) => {
1718
1749
  const owner = ctx;
1719
- if (!owner || options.sessionId !== owner.sessionManager.getSessionId())
1750
+ if (!owner)
1751
+ return;
1752
+ let manager;
1753
+ try {
1754
+ manager = owner.sessionManager;
1755
+ } catch {
1756
+ retire();
1757
+ return;
1758
+ }
1759
+ if (options.sessionId !== manager.getSessionId())
1720
1760
  return;
1721
1761
  if (!supportsNativeCompaction(model)) {
1722
1762
  captureRejected("unsupported API");
@@ -1735,10 +1775,19 @@ Last attempt: ${compactionStatus}`, "info");
1735
1775
  return;
1736
1776
  }
1737
1777
  const ticket = generation;
1738
- const branch = owner.sessionManager.getBranch();
1778
+ const branch = manager.getBranch();
1739
1779
  const branchHash = digest(branch);
1740
- const canonical = transcript(owner.sessionManager.buildSessionProjection().messages);
1741
- const current = () => generation === ticket && ctx === owner && !options.signal?.aborted && supportedRouteOptions(model, options) && digest(owner.sessionManager.getBranch()) === branchHash;
1780
+ const canonical = transcript(manager.buildSessionProjection().messages);
1781
+ const current = () => {
1782
+ if (generation !== ticket || ctx !== owner || options.signal?.aborted || !supportedRouteOptions(model, options))
1783
+ return false;
1784
+ try {
1785
+ return digest(owner.sessionManager.getBranch()) === branchHash;
1786
+ } catch {
1787
+ retire();
1788
+ return false;
1789
+ }
1790
+ };
1742
1791
  return async (payload) => {
1743
1792
  if (!current())
1744
1793
  return payload;
@@ -1802,21 +1851,26 @@ Last attempt: ${compactionStatus}`, "info");
1802
1851
  };
1803
1852
  const install = (context) => {
1804
1853
  ctx = context;
1805
- if (installedProvider === context.model?.provider)
1854
+ if (observer?.provider === context.model?.provider && observer?.isInstalled())
1806
1855
  return;
1807
- uninstall?.();
1808
- uninstall = undefined;
1809
- installedProvider = undefined;
1856
+ if (observer) {
1857
+ invalidate();
1858
+ observer.remove();
1859
+ observer = undefined;
1860
+ }
1810
1861
  if (!supportsNativeCompaction(context.model))
1811
1862
  return;
1812
- uninstall = installObserver(pi, context, observe);
1813
- if (uninstall)
1814
- installedProvider = context.model.provider;
1815
- captureStatus = uninstall ? "awaiting prepared request" : "unsupported provider registration";
1863
+ observer = installObserver(pi, context, observe);
1864
+ captureStatus = observer ? "awaiting prepared request" : "unsupported provider registration";
1816
1865
  };
1817
1866
  pi.on("session_start", (_event, context) => {
1818
- invalidate();
1867
+ retire();
1819
1868
  install(context);
1869
+ const sessionId = context.sessionManager.getSessionId();
1870
+ unregisterCleanup = registerSessionResourceCleanup((disposedId) => {
1871
+ if (disposedId === undefined || disposedId === sessionId)
1872
+ retire();
1873
+ });
1820
1874
  const latest = latestCompaction(context.sessionManager.getBranch());
1821
1875
  if (latest?.summary === NATIVE_COMPACTION_SUMMARY)
1822
1876
  context.ui.notify("Legacy native compaction has no portable summary. Native replay is disabled; recover from earlier history or start a new session.", "warning");
@@ -1831,13 +1885,7 @@ Last attempt: ${compactionStatus}`, "info");
1831
1885
  pi.on("session_tree", () => {
1832
1886
  invalidate();
1833
1887
  });
1834
- pi.on("session_shutdown", () => {
1835
- invalidate();
1836
- uninstall?.();
1837
- uninstall = undefined;
1838
- installedProvider = undefined;
1839
- ctx = undefined;
1840
- });
1888
+ pi.on("session_shutdown", retire);
1841
1889
  const removeInvalidation = pi.events.on("pi-provider-compaction:invalidate", invalidate);
1842
1890
  pi.on("session_shutdown", () => {
1843
1891
  if (typeof removeInvalidation === "function")
package/index.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { createHash, createHmac, randomBytes, randomUUID } from "node:crypto";
2
2
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
3
- import { normalizeContext, type Model, type Provider, type ProviderHeaders, type ProviderStreamOptions, type SimpleStreamOptions, type TranscriptContext, type Usage } from "@earendil-works/pi-ai";
3
+ import { normalizeContext, registerSessionResourceCleanup, type Model, type Provider, type ProviderHeaders, type ProviderStreamOptions, type SimpleStreamOptions, type TranscriptContext, type Usage } from "@earendil-works/pi-ai";
4
4
  import { compact, convertToLlm, type ExtensionAPI, type ExtensionContext, type SessionEntry } from "@earendil-works/pi-coding-agent";
5
5
  import { resolveCompactionModelPolicy } from "./policy";
6
- import { installObserver, type ObserveRequest } from "./provider";
6
+ import { installObserver, type InstalledObserver, type ObserveRequest } from "./provider";
7
7
  import { capturePreparedRequest, equal, requestPolicy, selectCoveredPrefix, substituteSummary, summarySlot, type PreparedRequest } from "./replay";
8
8
  import { isObject, requestProviderCompaction, resolveNativeProtocol, supportsResponsesApi, validateCompactedOutput, type NativeProtocol, type ResponseItem } from "./protocol";
9
9
 
@@ -152,8 +152,8 @@ export default function (pi: ExtensionAPI): void {
152
152
  let snapshot: Captured | undefined;
153
153
  let active: Active | undefined;
154
154
  let pending: { details: NativeCompactionDetails; capture: Captured; summary: string } | undefined;
155
- let uninstall: (() => void) | undefined;
156
- let installedProvider: string | undefined;
155
+ let observer: InstalledObserver | undefined;
156
+ let unregisterCleanup: (() => void) | undefined;
157
157
  let generation = 0;
158
158
  let captureStatus = "no request observed";
159
159
  let compactionStatus = "not attempted in this runtime";
@@ -162,17 +162,33 @@ export default function (pi: ExtensionAPI): void {
162
162
  pi.registerCommand("provider-compaction-status", {
163
163
  description: "Show content-free native compaction eligibility and last attempt",
164
164
  handler: async (_args, context) => {
165
- context.ui.notify(`Provider compaction\nObserver: ${installedProvider ? "installed" : "unavailable for current registration"}\nCapture: ${captureStatus}\nLast attempt: ${compactionStatus}`, "info");
165
+ context.ui.notify(`Provider compaction\nObserver: ${observer?.isInstalled() ? "installed" : "unavailable for current registration"}\nCapture: ${captureStatus}\nLast attempt: ${compactionStatus}`, "info");
166
166
  },
167
167
  });
168
168
  const invalidate = () => { generation++; runtimeId = randomUUID(); snapshot = undefined; active = undefined; pending = undefined; captureStatus = "invalidated by lifecycle/history/policy change"; };
169
+ const retire = () => {
170
+ invalidate();
171
+ const installed = observer;
172
+ observer = undefined; ctx = undefined;
173
+ unregisterCleanup?.(); unregisterCleanup = undefined;
174
+ installed?.remove();
175
+ };
169
176
  const generic = (context: ExtensionContext) => resolveCompactionModelPolicy(pi, context).hasSelectors;
170
177
  const route = (model: Model<any>, options: ProviderStreamOptions | SimpleStreamOptions) => routeDigest({ model, apiKey: options.apiKey, headers: options.headers, env: options.env });
171
178
  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");
172
179
 
173
180
  const observe: ObserveRequest = (model, context, options) => {
174
181
  const owner = ctx;
175
- if (!owner || options.sessionId !== owner.sessionManager.getSessionId()) return;
182
+ if (!owner) return;
183
+ let manager: ExtensionContext["sessionManager"];
184
+ try { manager = owner.sessionManager; }
185
+ catch {
186
+ // Bare SDK dispose() invalidates contexts without session_shutdown.
187
+ // Retire the owned registration before dispatching another session.
188
+ retire();
189
+ return;
190
+ }
191
+ if (options.sessionId !== manager.getSessionId()) return;
176
192
  if (!supportsNativeCompaction(model)) { captureRejected("unsupported API"); return; }
177
193
  if (!options.onPayload) { captureRejected("no payload callback"); return; }
178
194
  if (generic(owner)) { captureRejected("generic compaction model takes precedence"); return; }
@@ -180,10 +196,14 @@ export default function (pi: ExtensionAPI): void {
180
196
  // fallbacks. Those must not silently select a route absent from our evidence.
181
197
  if (!supportedRouteOptions(model, options)) { captureRejected("unsupported route overrides"); return; }
182
198
  const ticket = generation;
183
- const branch = owner.sessionManager.getBranch();
199
+ const branch = manager.getBranch();
184
200
  const branchHash = digest(branch);
185
- const canonical = transcript(owner.sessionManager.buildSessionProjection().messages);
186
- const current = () => generation === ticket && ctx === owner && !options.signal?.aborted && supportedRouteOptions(model, options) && digest(owner.sessionManager.getBranch()) === branchHash;
201
+ const canonical = transcript(manager.buildSessionProjection().messages);
202
+ const current = () => {
203
+ if (generation !== ticket || ctx !== owner || options.signal?.aborted || !supportedRouteOptions(model, options)) return false;
204
+ try { return digest(owner.sessionManager.getBranch()) === branchHash; }
205
+ catch { retire(); return false; }
206
+ };
187
207
  return async (payload) => {
188
208
  if (!current()) return payload;
189
209
  snapshot = undefined;
@@ -234,22 +254,27 @@ export default function (pi: ExtensionAPI): void {
234
254
 
235
255
  const install = (context: ExtensionContext) => {
236
256
  ctx = context;
237
- if (installedProvider === context.model?.provider) return;
238
- uninstall?.(); uninstall = undefined; installedProvider = undefined;
257
+ if (observer?.provider === context.model?.provider && observer?.isInstalled()) return;
258
+ if (observer) { invalidate(); observer.remove(); observer = undefined; }
239
259
  if (!supportsNativeCompaction(context.model)) return;
240
- uninstall = installObserver(pi, context, observe);
241
- if (uninstall) installedProvider = context.model.provider;
242
- captureStatus = uninstall ? "awaiting prepared request" : "unsupported provider registration";
260
+ observer = installObserver(pi, context, observe);
261
+ captureStatus = observer ? "awaiting prepared request" : "unsupported provider registration";
243
262
  };
244
263
  pi.on("session_start", (_event, context) => {
245
- invalidate(); install(context);
264
+ retire(); install(context);
265
+ const sessionId = context.sessionManager.getSessionId();
266
+ // AgentSession.dispose() cleans session resources even without emitting
267
+ // session_shutdown. The captured registry keeps owned cleanup usable.
268
+ unregisterCleanup = registerSessionResourceCleanup((disposedId) => {
269
+ if (disposedId === undefined || disposedId === sessionId) retire();
270
+ });
246
271
  const latest = latestCompaction(context.sessionManager.getBranch());
247
272
  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");
248
273
  });
249
274
  pi.on("before_agent_start", (_event, context) => { install(context); });
250
275
  pi.on("model_select", (_event, context) => { invalidate(); install(context); });
251
276
  pi.on("session_tree", () => { invalidate(); });
252
- pi.on("session_shutdown", () => { invalidate(); uninstall?.(); uninstall = undefined; installedProvider = undefined; ctx = undefined; });
277
+ pi.on("session_shutdown", retire);
253
278
  // Privacy-policy owners must invalidate before changing a policy that can affect
254
279
  // hidden covered history. Arbitrary dynamic privacy compositions are unsupported.
255
280
  const removeInvalidation = pi.events.on("pi-provider-compaction:invalidate", invalidate);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nijaru/pi-provider-compaction",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "type": "module",
5
5
  "description": "Provider-native context compaction adapters for Pi",
6
6
  "keywords": [
@@ -18,7 +18,7 @@
18
18
  "author": "Nick Naru <nick@nijaru.com>",
19
19
  "repository": {
20
20
  "type": "git",
21
- "url": "https://github.com/nijaru/pi-provider-compaction.git"
21
+ "url": "git+https://github.com/nijaru/pi-provider-compaction.git"
22
22
  },
23
23
  "pi": {
24
24
  "extensions": [
@@ -31,7 +31,7 @@
31
31
  "test": "bun test",
32
32
  "lint": "oxlint build.ts index.ts protocol.ts policy.ts provider.ts replay.ts tests/",
33
33
  "typecheck": "tsc --noEmit",
34
- "check": "bun test && bun run lint && tsc --noEmit"
34
+ "check": "bun run build && bun test && bun run lint && tsc --noEmit"
35
35
  },
36
36
  "files": [
37
37
  "dist",
@@ -44,14 +44,15 @@
44
44
  "LICENSE"
45
45
  ],
46
46
  "devDependencies": {
47
- "@earendil-works/pi-ai": "0.87.0",
48
- "@earendil-works/pi-agent-core": "0.87.0",
49
- "@earendil-works/pi-coding-agent": "0.87.0",
47
+ "@earendil-works/pi-ai": "1.2.0",
48
+ "@earendil-works/pi-agent-core": "1.2.0",
49
+ "@earendil-works/pi-coding-agent": "1.2.0",
50
50
  "@types/bun": "latest",
51
51
  "oxlint": "^1.81.0",
52
52
  "typescript": "^6.0.3"
53
53
  },
54
54
  "peerDependencies": {
55
+ "@earendil-works/pi-ai": "*",
55
56
  "@earendil-works/pi-coding-agent": "*"
56
57
  }
57
58
  }
package/policy.ts CHANGED
@@ -1,31 +1,9 @@
1
1
  /**
2
- * Generic-compaction-model precedence resolution shared with pi-compactor.
3
- *
4
- * When pi-compactor's generic compaction model is configured — via the
5
- * `compaction-model` flag or a `compaction-policy.json` file — the explicit
6
- * generic model takes precedence over provider-native compaction: this
7
- * extension must not request a native compaction and must not replay a
8
- * previously persisted native window.
9
- *
10
- * Pi scopes `getFlag` to flags the calling extension registered, and Pi
11
- * rejects two extensions registering the same flag name. This extension
12
- * therefore never registers `compaction-model` (owned by pi-compactor) and
13
- * reads the shared CLI value from `process.argv` instead. The policy-file
14
- * fallback mirrors pi-compactor exactly: project policy requires project
15
- * trust; the agent-dir policy does not; a valid file with an empty `models`
16
- * list disables the generic model. This module must stay in sync with
17
- * `readModelSelectors` in pi-compactor's policy.ts (see
18
- * tests/policy-parity.test.ts, which checks the two against each other when
19
- * the sibling checkout is present).
20
- *
21
- * Flag-source note (verified against Pi 0.85.1): extension flag values are
22
- * populated solely from CLI unknown flags (parseArgs in cli/args.js feeds
23
- * extensionFlagValues in main.js, applied by applyExtensionFlagValues in
24
- * agent-session-services.js). There is no config-file flag source, so reading
25
- * process.argv with Pi's exact parsing rules observes every flag value the
26
- * host can deliver. A programmatic SDK host injecting extensionFlagValues
27
- * without argv would not be seen here; flag scoping provides no shared read
28
- * API for that path.
2
+ * An explicit generic model suppresses both native acquisition and replay.
3
+ * Ask pi-compactor for its effective policy first: only the flag's owner can
4
+ * read SDK-injected extensionFlagValues. The event reply is synchronous.
5
+ * Without a companion reply, argv and policy files retain standalone support;
6
+ * their trust, bounds, and empty-policy rules mirror pi-compactor's resolver.
29
7
  */
30
8
 
31
9
  import {
@@ -49,7 +27,7 @@ type PolicyContext = Pick<ExtensionContext, "cwd" | "isProjectTrusted"> & {
49
27
  sessionManager?: Pick<ExtensionContext["sessionManager"], "getSessionDir">;
50
28
  };
51
29
 
52
- export type CompactionModelSource = "flag" | "project-policy" | "agent-policy";
30
+ export type CompactionModelSource = "compactor" | "flag" | "project-policy" | "agent-policy";
53
31
 
54
32
  export interface CompactionModelPolicy {
55
33
  /** True when a generic compaction model takes precedence over native compaction. */
@@ -172,27 +150,24 @@ export function readCompactionModelFlagFromArgv(argv: readonly string[] = proces
172
150
 
173
151
  /** Resolve whether pi-compactor's generic compaction model takes precedence. */
174
152
  export function resolveCompactionModelPolicy(
175
- pi: Pick<ExtensionAPI, "getFlag">,
153
+ pi: { events: Pick<ExtensionAPI["events"], "emit"> },
176
154
  ctx: PolicyContext,
177
155
  argv: readonly string[] = process.argv.slice(2),
178
156
  ): CompactionModelPolicy {
157
+ let effective: boolean | undefined;
158
+ pi.events.emit("pi-compactor:resolve-model-policy", {
159
+ context: ctx,
160
+ respond: (hasSelectors: boolean) => { if (typeof hasSelectors === "boolean") effective = hasSelectors; },
161
+ });
162
+ if (effective !== undefined) return effective ? { hasSelectors: true, source: "compactor" } : { hasSelectors: false };
163
+
179
164
  const argvRaw = readCompactionModelFlagFromArgv(argv);
180
165
  if (typeof argvRaw === "string" && argvRaw.trim()) {
181
166
  const selector = normalizeSelector(argvRaw);
182
167
  return selector ? { hasSelectors: true, source: "flag" } : { hasSelectors: false };
183
168
  }
184
- // A bare `--compaction-model` (true) or whitespace value falls through like
185
- // pi-compactor: Pi reports "requires a value" and the policy files still apply.
169
+ // A bare or whitespace flag leaves policy files eligible, like pi-compactor.
186
170
  // An oversized selector above already returned hasSelectors:false.
187
- try {
188
- const rawFlag = pi.getFlag(COMPACTION_MODEL_FLAG);
189
- if (typeof rawFlag === "string" && rawFlag.trim()) {
190
- const selector = normalizeSelector(rawFlag);
191
- return selector ? { hasSelectors: true, source: "flag" } : { hasSelectors: false };
192
- }
193
- } catch {
194
- // getFlag can throw on a stale context; fall through to policy files.
195
- }
196
171
 
197
172
  const agentDir = activeAgentDir(ctx);
198
173
  const candidates: Array<{ path: string; source: CompactionModelSource }> = [];
package/provider.ts CHANGED
@@ -3,8 +3,20 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
3
3
 
4
4
  export type ObserveRequest = (model: Model<any>, context: TranscriptContext, options: StreamOptions) => ((payload: unknown) => unknown | Promise<unknown>) | undefined;
5
5
 
6
+ export interface InstalledObserver {
7
+ provider: string;
8
+ isInstalled(): boolean;
9
+ remove(): void;
10
+ }
11
+
12
+ // Explicit cooperation with pi-fast-mode, not permission to replace arbitrary
13
+ // streams: this observer delegates unchanged and can release only its own slot.
14
+ export const STREAM_OBSERVER = Symbol.for("@nijaru/pi-provider-compaction/stream-observer");
15
+
6
16
  function optionsWithObserver<T extends StreamOptions>(model: Model<any>, context: TranscriptContext, options: T | undefined, observe: ObserveRequest): T | undefined {
7
- const handler = observe(model, context, options ?? {});
17
+ let handler: ReturnType<ObserveRequest>;
18
+ try { handler = observe(model, context, options ?? {}); }
19
+ catch { return options; } // An expired or failing optional observer must not veto dispatch.
8
20
  if (!handler) return options;
9
21
  return {
10
22
  ...options,
@@ -40,21 +52,27 @@ export function decorateProvider(base: Provider, observe: ObserveRequest): Provi
40
52
  * delegate their original callback (including /fast). Never register an effective
41
53
  * composed provider as a native base: that loses model headers and freezes config.
42
54
  */
43
- export function installObserver(pi: ExtensionAPI, ctx: ExtensionContext, observe: ObserveRequest): (() => void) | undefined {
55
+ export function installObserver(pi: ExtensionAPI, ctx: ExtensionContext, observe: ObserveRequest): InstalledObserver | undefined {
44
56
  const model = ctx.model;
45
57
  if (!model) return;
46
58
  const registry = ctx.modelRegistry;
47
59
  const id = model.provider;
48
60
  const native = registry.getRegisteredNativeProvider(id);
49
61
  if (native) {
62
+ const isInstalled = () => registry.getRegisteredNativeProvider(id) === wrapper;
50
63
  const guarded: ObserveRequest = (...args) => {
64
+ if (!isInstalled()) return;
51
65
  const handler = observe(...args);
52
- return handler ? (payload) => registry.getRegisteredNativeProvider(id) === wrapper ? handler(payload) : payload : undefined;
66
+ return handler ? (payload) => isInstalled() ? handler(payload) : payload : undefined;
53
67
  };
54
68
  const wrapper = decorateProvider(native, guarded);
55
69
  pi.registerProvider(wrapper);
56
- return () => {
57
- if (registry.getRegisteredNativeProvider(id) === wrapper) pi.registerProvider(native);
70
+ return {
71
+ provider: id, isInstalled,
72
+ remove() {
73
+ // Keep cleanup usable after bare SDK disposal invalidates the extension API.
74
+ if (isInstalled()) registry.registerProvider(native);
75
+ },
58
76
  };
59
77
  }
60
78
  const original = registry.getRegisteredProviderConfig(id);
@@ -65,21 +83,21 @@ export function installObserver(pi: ExtensionAPI, ctx: ExtensionContext, observe
65
83
  // An injected legacy streamSimple overlay would also intercept full stream()
66
84
  // calls and change reasoning semantics. Only decorate an existing stream owner.
67
85
  const delegate = original.streamSimple.bind(original);
86
+ const isInstalled = () => registry.getRegisteredProviderConfig(id)?.streamSimple === wrapper && !registry.getRegisteredNativeProvider(id);
68
87
  const guarded: ObserveRequest = (...args) => {
88
+ if (!isInstalled()) return;
69
89
  const handler = observe(...args);
70
- return handler ? (payload) => registry.getRegisteredProviderConfig(id)?.streamSimple === wrapper ? handler(payload) : payload : undefined;
90
+ return handler ? (payload) => isInstalled() ? handler(payload) : payload : undefined;
71
91
  };
72
92
  const wrapper: Provider["streamSimple"] = (requestModel, context, options) => delegate(requestModel, context, optionsWithObserver(requestModel, context, options, guarded));
73
- pi.registerProvider(id, { ...original, api: original?.api ?? model.api, streamSimple: wrapper });
74
- return () => {
75
- const current = registry.getRegisteredProviderConfig(id);
76
- if (current?.streamSimple !== wrapper || registry.getRegisteredNativeProvider(id)) return;
93
+ const remove = () => {
94
+ if (!isInstalled()) return;
77
95
  // registerProvider merges undefined properties: remove our layer before restore.
78
- const restored = { ...current };
79
- if (original?.streamSimple) restored.streamSimple = original.streamSimple;
80
- else delete restored.streamSimple;
81
- if (!original?.api && restored.api === model.api) delete restored.api;
82
- pi.unregisterProvider(id);
83
- if (Object.keys(restored).length) pi.registerProvider(id, restored);
96
+ const restored = { ...registry.getRegisteredProviderConfig(id)!, streamSimple: original.streamSimple };
97
+ registry.unregisterProvider(id);
98
+ registry.registerProvider(id, restored);
84
99
  };
100
+ Object.defineProperty(wrapper, STREAM_OBSERVER, { value: { delegate: original.streamSimple, release: remove } });
101
+ pi.registerProvider(id, { ...original, streamSimple: wrapper });
102
+ return { provider: id, isInstalled, remove };
85
103
  }
package/replay.ts CHANGED
@@ -5,6 +5,7 @@ import { convertResponsesMessages } from "@earendil-works/pi-ai/api/openai-respo
5
5
  import { isObject, type JsonObject, type ResponseItem } from "./protocol";
6
6
 
7
7
  const TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]);
8
+ const AZURE_TOOL_CALL_PROVIDERS = new Set([...TOOL_CALL_PROVIDERS, "azure"]);
8
9
  export const MAX_SNAPSHOT_BYTES = 8 * 1024 * 1024;
9
10
  export const SNAPSHOT_TTL_MS = 5 * 60_000;
10
11
 
@@ -19,7 +20,7 @@ export function serializeInput(model: Model<any>, context: TranscriptContext, de
19
20
  const codex = model.api === "openai-codex-responses";
20
21
  const normalized = resolveTranscript(context, flag("supportsMidConvoSystemMessages"));
21
22
  const tools = resolveTranscript(declarations, flag("supportsMidConvoSystemMessages"));
22
- return convertResponsesMessages(model, normalized, model.api === "azure-openai-responses" ? new Set([...TOOL_CALL_PROVIDERS, "azure-openai-responses"]) : TOOL_CALL_PROVIDERS, {
23
+ return convertResponsesMessages(model, normalized, model.api === "azure-openai-responses" ? AZURE_TOOL_CALL_PROVIDERS : TOOL_CALL_PROVIDERS, {
23
24
  includeSystemPrompt: !codex,
24
25
  grammarToolInputProperties: createGrammarToolInputProperties(getDeclaredTools(tools.messages), flag("supportsOpenAIGrammarTools")),
25
26
  supportsMidConvoSystemMessages: flag("supportsMidConvoSystemMessages"),