@nijaru/pi-provider-compaction 0.0.5 → 0.0.7

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,6 +1,6 @@
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. Minimum runtime: Pi 0.87.0. Development checks target Pi 1.2.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.3.0.
4
4
 
5
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
 
@@ -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.3.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 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
@@ -624,19 +637,13 @@ function transformMessages(messages, model, normalizeToolCallId) {
624
637
  const isSameModel = assistantMsg.provider === model.provider && assistantMsg.api === model.api && assistantMsg.model === model.id;
625
638
  const transformedContent = assistantMsg.content.flatMap((block) => {
626
639
  if (block.type === "thinking") {
627
- if (block.redacted) {
628
- return isSameModel ? block : [];
629
- }
630
- if (isSameModel && block.thinkingSignature)
640
+ if (!isSameModel)
641
+ return [];
642
+ if (block.redacted || block.thinkingSignature)
631
643
  return block;
632
644
  if (!block.thinking || block.thinking.trim() === "")
633
645
  return [];
634
- if (isSameModel)
635
- return block;
636
- return {
637
- type: "text",
638
- text: block.thinking
639
- };
646
+ return block;
640
647
  }
641
648
  if (block.type === "text") {
642
649
  if (isSameModel)
@@ -827,6 +834,12 @@ function convertResponsesMessages(model, context, allowedToolCallProviders, opti
827
834
  });
828
835
  };
829
836
  const includeInitialSystemMessage = options?.includeSystemPrompt ?? true;
837
+ const pushConfigurationUpdate = (effort) => {
838
+ messages.push({
839
+ type: "configuration_update",
840
+ reasoning: { effort }
841
+ });
842
+ };
830
843
  const compat = model.compat;
831
844
  const instructionRole = model.reasoning && compat?.supportsDeveloperRole !== false ? "developer" : "system";
832
845
  let msgIndex = 0;
@@ -933,6 +946,9 @@ function convertResponsesMessages(model, context, allowedToolCallProviders, opti
933
946
  }
934
947
  if (output.length === 0)
935
948
  continue;
949
+ if (options?.configurationUpdateEffort !== undefined && isSameModel && msg.providerThinkingLevel !== undefined) {
950
+ pushConfigurationUpdate(msg.providerThinkingLevel);
951
+ }
936
952
  messages.push(...output);
937
953
  } else if (msg.role === "toolResult") {
938
954
  const [callId] = msg.toolCallId.split("|");
@@ -954,6 +970,8 @@ function convertResponsesMessages(model, context, allowedToolCallProviders, opti
954
970
  if (!isLeadingSystemMessage)
955
971
  msgIndex++;
956
972
  }
973
+ if (options?.configurationUpdateEffort !== undefined)
974
+ pushConfigurationUpdate(options.configurationUpdateEffort);
957
975
  return messages;
958
976
  }
959
977
  function convertResponsesTools(tools, options) {
@@ -1450,7 +1468,7 @@ var SNAPSHOT_TTL_MS = 5 * 60000;
1450
1468
  function equal(left, right) {
1451
1469
  return JSON.stringify(left) === JSON.stringify(right);
1452
1470
  }
1453
- function serializeInput(model, context, declarations = context) {
1471
+ function serializeInput(model, context, declarations = context, configurationUpdateEffort) {
1454
1472
  const compat = model.compat;
1455
1473
  const flag = (name) => compat?.[name] === true;
1456
1474
  const codex = model.api === "openai-codex-responses";
@@ -1458,6 +1476,7 @@ function serializeInput(model, context, declarations = context) {
1458
1476
  const tools = resolveTranscript2(declarations, flag("supportsMidConvoSystemMessages"));
1459
1477
  return convertResponsesMessages(model, normalized, model.api === "azure-openai-responses" ? AZURE_TOOL_CALL_PROVIDERS : TOOL_CALL_PROVIDERS, {
1460
1478
  includeSystemPrompt: !codex,
1479
+ configurationUpdateEffort,
1461
1480
  grammarToolInputProperties: createGrammarToolInputProperties(getDeclaredTools(tools.messages), flag("supportsOpenAIGrammarTools")),
1462
1481
  supportsMidConvoSystemMessages: flag("supportsMidConvoSystemMessages"),
1463
1482
  supportsAdditionalTools: flag("supportsAdditionalTools"),
@@ -1482,12 +1501,15 @@ function capturePreparedRequest(model, context, canonical, payload, reject = ()
1482
1501
  reject("snapshot exceeds size limit");
1483
1502
  return;
1484
1503
  }
1485
- const expected = serializeInput(model, context);
1504
+ const last = payload.input.at(-1);
1505
+ const compat = model.compat;
1506
+ const configurationUpdateEffort = model.api === "openai-responses" && compat?.supportsMidConvoEffort === true && last?.type === "configuration_update" && isObject(last.reasoning) && typeof last.reasoning.effort === "string" ? last.reasoning.effort : undefined;
1507
+ const expected = serializeInput(model, context, context, configurationUpdateEffort);
1486
1508
  if (!equal(expected, payload.input)) {
1487
1509
  reject("prepared input differs from serializer");
1488
1510
  return;
1489
1511
  }
1490
- return { context: structuredClone(context), canonical: structuredClone(canonical), payload: structuredClone(payload), portableInput: expected, createdAt: Date.now() };
1512
+ return { context: structuredClone(context), canonical: structuredClone(canonical), payload: structuredClone(payload), portableInput: expected, configurationUpdateEffort, createdAt: Date.now() };
1491
1513
  }
1492
1514
  function promptCount(input) {
1493
1515
  let count = 0;
@@ -1495,6 +1517,10 @@ function promptCount(input) {
1495
1517
  count++;
1496
1518
  return count;
1497
1519
  }
1520
+ function serializePrefix(model, snapshot, prefix) {
1521
+ const input = serializeInput(model, prefix, snapshot.context, snapshot.configurationUpdateEffort);
1522
+ return snapshot.configurationUpdateEffort === undefined ? input : input.slice(0, -1);
1523
+ }
1498
1524
  function closedTools(context) {
1499
1525
  const pending = new Set;
1500
1526
  for (const message of context.messages) {
@@ -1538,7 +1564,7 @@ function selectCoveredPrefix(model, snapshot, prefix, reject = () => {}) {
1538
1564
  reject("projected prefix differs from request context");
1539
1565
  return;
1540
1566
  }
1541
- const expected = serializeInput(model, mapped, snapshot.context);
1567
+ const expected = serializePrefix(model, snapshot, mapped);
1542
1568
  if (!equal(snapshot.portableInput.slice(0, expected.length), expected)) {
1543
1569
  reject("serialized prefix does not match prepared input");
1544
1570
  return;
@@ -1571,7 +1597,7 @@ function summarySlot(model, snapshot, summaryPrefix) {
1571
1597
  const mapped = mappedPrefix(snapshot, summaryPrefix);
1572
1598
  if (!mapped)
1573
1599
  return;
1574
- const prefix = serializeInput(model, mapped, snapshot.context);
1600
+ const prefix = serializePrefix(model, snapshot, mapped);
1575
1601
  const index = promptCount(prefix);
1576
1602
  if (prefix.length !== index + 1 || !equal(snapshot.portableInput.slice(0, prefix.length), prefix))
1577
1603
  return;
@@ -1692,8 +1718,8 @@ function pi_provider_compaction_default(pi) {
1692
1718
  let snapshot;
1693
1719
  let active;
1694
1720
  let pending;
1695
- let uninstall;
1696
- let installedProvider;
1721
+ let observer;
1722
+ let unregisterCleanup;
1697
1723
  let generation = 0;
1698
1724
  let captureStatus = "no request observed";
1699
1725
  let compactionStatus = "not attempted in this runtime";
@@ -1707,7 +1733,7 @@ function pi_provider_compaction_default(pi) {
1707
1733
  description: "Show content-free native compaction eligibility and last attempt",
1708
1734
  handler: async (_args, context) => {
1709
1735
  context.ui.notify(`Provider compaction
1710
- Observer: ${installedProvider ? "installed" : "unavailable for current registration"}
1736
+ Observer: ${observer?.isInstalled() ? "installed" : "unavailable for current registration"}
1711
1737
  Capture: ${captureStatus}
1712
1738
  Last attempt: ${compactionStatus}`, "info");
1713
1739
  }
@@ -1720,12 +1746,30 @@ Last attempt: ${compactionStatus}`, "info");
1720
1746
  pending = undefined;
1721
1747
  captureStatus = "invalidated by lifecycle/history/policy change";
1722
1748
  };
1749
+ const retire = () => {
1750
+ invalidate();
1751
+ const installed = observer;
1752
+ observer = undefined;
1753
+ ctx = undefined;
1754
+ unregisterCleanup?.();
1755
+ unregisterCleanup = undefined;
1756
+ installed?.remove();
1757
+ };
1723
1758
  const generic = (context) => resolveCompactionModelPolicy(pi, context).hasSelectors;
1724
1759
  const route = (model, options) => routeDigest({ model, apiKey: options.apiKey, headers: options.headers, env: options.env });
1725
1760
  const unchanged = (branch, length, hash) => branch.length >= length && digest(branch.slice(0, length)) === hash && !branch.slice(length).some((entry) => entry.type === "context_edit");
1726
1761
  const observe = (model, context, options) => {
1727
1762
  const owner = ctx;
1728
- if (!owner || options.sessionId !== owner.sessionManager.getSessionId())
1763
+ if (!owner)
1764
+ return;
1765
+ let manager;
1766
+ try {
1767
+ manager = owner.sessionManager;
1768
+ } catch {
1769
+ retire();
1770
+ return;
1771
+ }
1772
+ if (options.sessionId !== manager.getSessionId())
1729
1773
  return;
1730
1774
  if (!supportsNativeCompaction(model)) {
1731
1775
  captureRejected("unsupported API");
@@ -1744,10 +1788,19 @@ Last attempt: ${compactionStatus}`, "info");
1744
1788
  return;
1745
1789
  }
1746
1790
  const ticket = generation;
1747
- const branch = owner.sessionManager.getBranch();
1791
+ const branch = manager.getBranch();
1748
1792
  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;
1793
+ const canonical = transcript(manager.buildSessionProjection().messages);
1794
+ const current = () => {
1795
+ if (generation !== ticket || ctx !== owner || options.signal?.aborted || !supportedRouteOptions(model, options))
1796
+ return false;
1797
+ try {
1798
+ return digest(owner.sessionManager.getBranch()) === branchHash;
1799
+ } catch {
1800
+ retire();
1801
+ return false;
1802
+ }
1803
+ };
1751
1804
  return async (payload) => {
1752
1805
  if (!current())
1753
1806
  return payload;
@@ -1811,21 +1864,26 @@ Last attempt: ${compactionStatus}`, "info");
1811
1864
  };
1812
1865
  const install = (context) => {
1813
1866
  ctx = context;
1814
- if (installedProvider === context.model?.provider)
1867
+ if (observer?.provider === context.model?.provider && observer?.isInstalled())
1815
1868
  return;
1816
- uninstall?.();
1817
- uninstall = undefined;
1818
- installedProvider = undefined;
1869
+ if (observer) {
1870
+ invalidate();
1871
+ observer.remove();
1872
+ observer = undefined;
1873
+ }
1819
1874
  if (!supportsNativeCompaction(context.model))
1820
1875
  return;
1821
- uninstall = installObserver(pi, context, observe);
1822
- if (uninstall)
1823
- installedProvider = context.model.provider;
1824
- captureStatus = uninstall ? "awaiting prepared request" : "unsupported provider registration";
1876
+ observer = installObserver(pi, context, observe);
1877
+ captureStatus = observer ? "awaiting prepared request" : "unsupported provider registration";
1825
1878
  };
1826
1879
  pi.on("session_start", (_event, context) => {
1827
- invalidate();
1880
+ retire();
1828
1881
  install(context);
1882
+ const sessionId = context.sessionManager.getSessionId();
1883
+ unregisterCleanup = registerSessionResourceCleanup((disposedId) => {
1884
+ if (disposedId === undefined || disposedId === sessionId)
1885
+ retire();
1886
+ });
1829
1887
  const latest = latestCompaction(context.sessionManager.getBranch());
1830
1888
  if (latest?.summary === NATIVE_COMPACTION_SUMMARY)
1831
1889
  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 +1898,7 @@ Last attempt: ${compactionStatus}`, "info");
1840
1898
  pi.on("session_tree", () => {
1841
1899
  invalidate();
1842
1900
  });
1843
- pi.on("session_shutdown", () => {
1844
- invalidate();
1845
- uninstall?.();
1846
- uninstall = undefined;
1847
- installedProvider = undefined;
1848
- ctx = undefined;
1849
- });
1901
+ pi.on("session_shutdown", retire);
1850
1902
  const removeInvalidation = pi.events.on("pi-provider-compaction:invalidate", invalidate);
1851
1903
  pi.on("session_shutdown", () => {
1852
1904
  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.7",
4
4
  "type": "module",
5
5
  "description": "Provider-native context compaction adapters for Pi",
6
6
  "keywords": [
@@ -44,9 +44,9 @@
44
44
  "LICENSE"
45
45
  ],
46
46
  "devDependencies": {
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",
47
+ "@earendil-works/pi-ai": "1.3.0",
48
+ "@earendil-works/pi-agent-core": "1.3.0",
49
+ "@earendil-works/pi-coding-agent": "1.3.0",
50
50
  "@types/bun": "latest",
51
51
  "oxlint": "^1.81.0",
52
52
  "typescript": "^6.0.3"
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
  }
package/replay.ts CHANGED
@@ -14,7 +14,7 @@ export function equal(left: unknown, right: unknown): boolean {
14
14
  }
15
15
 
16
16
  /** Validator only. No serialized item from this function is transmitted. */
17
- export function serializeInput(model: Model<any>, context: TranscriptContext, declarations = context): ResponseItem[] {
17
+ export function serializeInput(model: Model<any>, context: TranscriptContext, declarations = context, configurationUpdateEffort?: string): ResponseItem[] {
18
18
  const compat = model.compat as Record<string, unknown> | undefined;
19
19
  const flag = (name: string) => compat?.[name] === true;
20
20
  const codex = model.api === "openai-codex-responses";
@@ -22,6 +22,7 @@ export function serializeInput(model: Model<any>, context: TranscriptContext, de
22
22
  const tools = resolveTranscript(declarations, flag("supportsMidConvoSystemMessages"));
23
23
  return convertResponsesMessages(model, normalized, model.api === "azure-openai-responses" ? AZURE_TOOL_CALL_PROVIDERS : TOOL_CALL_PROVIDERS, {
24
24
  includeSystemPrompt: !codex,
25
+ configurationUpdateEffort,
25
26
  grammarToolInputProperties: createGrammarToolInputProperties(getDeclaredTools(tools.messages), flag("supportsOpenAIGrammarTools")),
26
27
  supportsMidConvoSystemMessages: flag("supportsMidConvoSystemMessages"),
27
28
  supportsAdditionalTools: flag("supportsAdditionalTools"),
@@ -40,6 +41,8 @@ export interface PreparedRequest {
40
41
  payload: JsonObject & { input: ResponseItem[] };
41
42
  /** The portable wire input before our sole summary substitution. */
42
43
  portableInput: ResponseItem[];
44
+ /** The final request-only effort update; historical efforts come from the transcript. */
45
+ configurationUpdateEffort?: string;
43
46
  replacement?: { index: number; count: number };
44
47
  createdAt: number;
45
48
  }
@@ -52,9 +55,16 @@ export function capturePreparedRequest(
52
55
  // Reject stateful provider requests: coverage must be the complete visible input.
53
56
  if (payload.previous_response_id || payload.conversation) { reject("stateful request"); return; }
54
57
  if (Buffer.byteLength(JSON.stringify({ context, payload })) > MAX_SNAPSHOT_BYTES) { reject("snapshot exceeds size limit"); return; }
55
- const expected = serializeInput(model, context);
58
+ // Read the actual prepared request's mode, not provider/auth guesses. Exact conversion
59
+ // below still validates every historical update, its position and its complete shape.
60
+ const last = payload.input.at(-1);
61
+ const compat = model.compat as Record<string, unknown> | undefined;
62
+ const configurationUpdateEffort = model.api === "openai-responses" && compat?.supportsMidConvoEffort === true &&
63
+ last?.type === "configuration_update" && isObject(last.reasoning) && typeof last.reasoning.effort === "string"
64
+ ? last.reasoning.effort : undefined;
65
+ const expected = serializeInput(model, context, context, configurationUpdateEffort);
56
66
  if (!equal(expected, payload.input)) { reject("prepared input differs from serializer"); return; }
57
- return { context: structuredClone(context), canonical: structuredClone(canonical), payload: structuredClone(payload) as PreparedRequest["payload"], portableInput: expected, createdAt: Date.now() };
67
+ return { context: structuredClone(context), canonical: structuredClone(canonical), payload: structuredClone(payload) as PreparedRequest["payload"], portableInput: expected, configurationUpdateEffort, createdAt: Date.now() };
58
68
  }
59
69
 
60
70
  function promptCount(input: readonly ResponseItem[]): number {
@@ -63,6 +73,13 @@ function promptCount(input: readonly ResponseItem[]): number {
63
73
  return count;
64
74
  }
65
75
 
76
+ function serializePrefix(model: Model<any>, snapshot: PreparedRequest, prefix: TranscriptContext): ResponseItem[] {
77
+ const input = serializeInput(model, prefix, snapshot.context, snapshot.configurationUpdateEffort);
78
+ // The converter appends the next reply's effort even to a partial transcript.
79
+ // It is not historical coverage and must remain in the live suffix on replay.
80
+ return snapshot.configurationUpdateEffort === undefined ? input : input.slice(0, -1);
81
+ }
82
+
66
83
  /** No open/synthetic tool exchange may cross the coverage boundary. */
67
84
  function closedTools(context: TranscriptContext): boolean {
68
85
  const pending = new Set<string>();
@@ -94,7 +111,7 @@ export function selectCoveredPrefix(model: Model<any>, snapshot: PreparedRequest
94
111
  if (!closedTools(prefix)) { reject("prefix has an incomplete tool exchange or failed assistant"); return; }
95
112
  const mapped = mappedPrefix(snapshot, prefix);
96
113
  if (!mapped) { reject("projected prefix differs from request context"); return; }
97
- const expected = serializeInput(model, mapped, snapshot.context);
114
+ const expected = serializePrefix(model, snapshot, mapped);
98
115
  if (!equal(snapshot.portableInput.slice(0, expected.length), expected)) { reject("serialized prefix does not match prepared input"); return; }
99
116
  const start = promptCount(expected);
100
117
  let end = expected.length;
@@ -121,7 +138,7 @@ export function substituteSummary(
121
138
  export function summarySlot(model: Model<any>, snapshot: PreparedRequest, summaryPrefix: TranscriptContext): number | undefined {
122
139
  const mapped = mappedPrefix(snapshot, summaryPrefix);
123
140
  if (!mapped) return;
124
- const prefix = serializeInput(model, mapped, snapshot.context);
141
+ const prefix = serializePrefix(model, snapshot, mapped);
125
142
  const index = promptCount(prefix);
126
143
  if (prefix.length !== index + 1 || !equal(snapshot.portableInput.slice(0, prefix.length), prefix)) return;
127
144
  return index;