@nijaru/pi-provider-compaction 0.0.5 → 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
@@ -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
 
@@ -73,7 +73,7 @@ pi --extension ./dist/index.js
73
73
 
74
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 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. 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
@@ -1692,8 +1705,8 @@ function pi_provider_compaction_default(pi) {
1692
1705
  let snapshot;
1693
1706
  let active;
1694
1707
  let pending;
1695
- let uninstall;
1696
- let installedProvider;
1708
+ let observer;
1709
+ let unregisterCleanup;
1697
1710
  let generation = 0;
1698
1711
  let captureStatus = "no request observed";
1699
1712
  let compactionStatus = "not attempted in this runtime";
@@ -1707,7 +1720,7 @@ function pi_provider_compaction_default(pi) {
1707
1720
  description: "Show content-free native compaction eligibility and last attempt",
1708
1721
  handler: async (_args, context) => {
1709
1722
  context.ui.notify(`Provider compaction
1710
- Observer: ${installedProvider ? "installed" : "unavailable for current registration"}
1723
+ Observer: ${observer?.isInstalled() ? "installed" : "unavailable for current registration"}
1711
1724
  Capture: ${captureStatus}
1712
1725
  Last attempt: ${compactionStatus}`, "info");
1713
1726
  }
@@ -1720,12 +1733,30 @@ Last attempt: ${compactionStatus}`, "info");
1720
1733
  pending = undefined;
1721
1734
  captureStatus = "invalidated by lifecycle/history/policy change";
1722
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
+ };
1723
1745
  const generic = (context) => resolveCompactionModelPolicy(pi, context).hasSelectors;
1724
1746
  const route = (model, options) => routeDigest({ model, apiKey: options.apiKey, headers: options.headers, env: options.env });
1725
1747
  const unchanged = (branch, length, hash) => branch.length >= length && digest(branch.slice(0, length)) === hash && !branch.slice(length).some((entry) => entry.type === "context_edit");
1726
1748
  const observe = (model, context, options) => {
1727
1749
  const owner = ctx;
1728
- 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())
1729
1760
  return;
1730
1761
  if (!supportsNativeCompaction(model)) {
1731
1762
  captureRejected("unsupported API");
@@ -1744,10 +1775,19 @@ Last attempt: ${compactionStatus}`, "info");
1744
1775
  return;
1745
1776
  }
1746
1777
  const ticket = generation;
1747
- const branch = owner.sessionManager.getBranch();
1778
+ const branch = manager.getBranch();
1748
1779
  const branchHash = digest(branch);
1749
- const canonical = transcript(owner.sessionManager.buildSessionProjection().messages);
1750
- 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
+ };
1751
1791
  return async (payload) => {
1752
1792
  if (!current())
1753
1793
  return payload;
@@ -1811,21 +1851,26 @@ Last attempt: ${compactionStatus}`, "info");
1811
1851
  };
1812
1852
  const install = (context) => {
1813
1853
  ctx = context;
1814
- if (installedProvider === context.model?.provider)
1854
+ if (observer?.provider === context.model?.provider && observer?.isInstalled())
1815
1855
  return;
1816
- uninstall?.();
1817
- uninstall = undefined;
1818
- installedProvider = undefined;
1856
+ if (observer) {
1857
+ invalidate();
1858
+ observer.remove();
1859
+ observer = undefined;
1860
+ }
1819
1861
  if (!supportsNativeCompaction(context.model))
1820
1862
  return;
1821
- uninstall = installObserver(pi, context, observe);
1822
- if (uninstall)
1823
- installedProvider = context.model.provider;
1824
- captureStatus = uninstall ? "awaiting prepared request" : "unsupported provider registration";
1863
+ observer = installObserver(pi, context, observe);
1864
+ captureStatus = observer ? "awaiting prepared request" : "unsupported provider registration";
1825
1865
  };
1826
1866
  pi.on("session_start", (_event, context) => {
1827
- invalidate();
1867
+ retire();
1828
1868
  install(context);
1869
+ const sessionId = context.sessionManager.getSessionId();
1870
+ unregisterCleanup = registerSessionResourceCleanup((disposedId) => {
1871
+ if (disposedId === undefined || disposedId === sessionId)
1872
+ retire();
1873
+ });
1829
1874
  const latest = latestCompaction(context.sessionManager.getBranch());
1830
1875
  if (latest?.summary === NATIVE_COMPACTION_SUMMARY)
1831
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");
@@ -1840,13 +1885,7 @@ Last attempt: ${compactionStatus}`, "info");
1840
1885
  pi.on("session_tree", () => {
1841
1886
  invalidate();
1842
1887
  });
1843
- pi.on("session_shutdown", () => {
1844
- invalidate();
1845
- uninstall?.();
1846
- uninstall = undefined;
1847
- installedProvider = undefined;
1848
- ctx = undefined;
1849
- });
1888
+ pi.on("session_shutdown", retire);
1850
1889
  const removeInvalidation = pi.events.on("pi-provider-compaction:invalidate", invalidate);
1851
1890
  pi.on("session_shutdown", () => {
1852
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.5",
3
+ "version": "0.0.6",
4
4
  "type": "module",
5
5
  "description": "Provider-native context compaction adapters for Pi",
6
6
  "keywords": [
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.test.ts, which always checks the safety contracts and also
19
- * compares both implementations when 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
  }