@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 +3 -3
- package/dist/index.js +90 -51
- package/index.ts +41 -16
- package/package.json +1 -1
- package/policy.ts +15 -40
- package/provider.ts +34 -16
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`)
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
202
|
-
|
|
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) =>
|
|
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
|
-
|
|
217
|
-
|
|
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 = { ...
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
1696
|
-
let
|
|
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: ${
|
|
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
|
|
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 =
|
|
1778
|
+
const branch = manager.getBranch();
|
|
1748
1779
|
const branchHash = digest(branch);
|
|
1749
|
-
const canonical = transcript(
|
|
1750
|
-
const current = () =>
|
|
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 (
|
|
1854
|
+
if (observer?.provider === context.model?.provider && observer?.isInstalled())
|
|
1815
1855
|
return;
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1856
|
+
if (observer) {
|
|
1857
|
+
invalidate();
|
|
1858
|
+
observer.remove();
|
|
1859
|
+
observer = undefined;
|
|
1860
|
+
}
|
|
1819
1861
|
if (!supportsNativeCompaction(context.model))
|
|
1820
1862
|
return;
|
|
1821
|
-
|
|
1822
|
-
|
|
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
|
-
|
|
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
|
|
156
|
-
let
|
|
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: ${
|
|
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
|
|
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 =
|
|
199
|
+
const branch = manager.getBranch();
|
|
184
200
|
const branchHash = digest(branch);
|
|
185
|
-
const canonical = transcript(
|
|
186
|
-
const current = () =>
|
|
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 (
|
|
238
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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",
|
|
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
package/policy.ts
CHANGED
|
@@ -1,31 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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, "
|
|
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
|
|
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
|
-
|
|
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):
|
|
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) =>
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
74
|
-
|
|
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 = { ...
|
|
79
|
-
|
|
80
|
-
|
|
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
|
}
|