@nijaru/pi-provider-compaction 0.0.8 → 0.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -16
- package/cached-summary.ts +230 -0
- package/dist/index.js +945 -234
- package/index.ts +228 -71
- package/package.json +7 -5
- package/policy.ts +9 -16
- package/protocol.ts +27 -14
- package/provider.ts +222 -40
- package/usage-observer.ts +52 -0
package/index.ts
CHANGED
|
@@ -3,7 +3,9 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
|
3
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 {
|
|
6
|
+
import { cacheConfig, cachedSummary, captureCachedRequest, finishCachedRequest, requestCacheState, sameCachedPrompt, supportsCachedSummary, withFileTracking, type CachedRequest } from "./cached-summary";
|
|
7
|
+
import { bindUsageHeaders, installObserver, type InstalledObserver, type ObserveRequest } from "./provider";
|
|
8
|
+
import { serveUsageObservers } from "./usage-observer";
|
|
7
9
|
import { capturePreparedRequest, equal, requestPolicy, selectCoveredPrefix, substituteSummary, summarySlot, type PreparedRequest } from "./replay";
|
|
8
10
|
import { isObject, NativeCompactionValidationError, requestProviderCompaction, resolveNativeProtocol, supportsResponsesApi, validateCompactedOutput, type NativeProtocol, type ResponseItem } from "./protocol";
|
|
9
11
|
|
|
@@ -143,41 +145,108 @@ interface Active {
|
|
|
143
145
|
payloadModel?: string;
|
|
144
146
|
}
|
|
145
147
|
|
|
148
|
+
interface ExpiryAttempt {
|
|
149
|
+
sessionId: string;
|
|
150
|
+
cycle: string;
|
|
151
|
+
isCurrent(): boolean;
|
|
152
|
+
signal?: AbortSignal;
|
|
153
|
+
}
|
|
154
|
+
|
|
146
155
|
export default function (pi: ExtensionAPI): void {
|
|
147
156
|
// Evidence is deliberately process/session-local. A reload cannot prove unchanged
|
|
148
157
|
// hook/privacy policy, even if the visible summary text happens to match.
|
|
158
|
+
// Older hosts in the native adapter's supported range lack getSettings.
|
|
159
|
+
// Keep their existing native path; cache preferences require that host API.
|
|
160
|
+
const configuration = () => typeof pi.getSettings === "function" ? cacheConfig(pi.getSettings()) : { cachedSummaries: false, outcomeNotifications: false, cacheRetention: undefined };
|
|
149
161
|
const secret = randomBytes(32);
|
|
150
162
|
const routeDigest = (value: unknown) => createHmac("sha256", secret).update(JSON.stringify(value)).digest("hex");
|
|
151
163
|
let runtimeId = randomUUID();
|
|
152
164
|
let ctx: ExtensionContext | undefined;
|
|
165
|
+
let usageObservers = serveUsageObservers(pi, () => ctx);
|
|
153
166
|
let snapshot: Captured | undefined;
|
|
167
|
+
let cache: CachedRequest | undefined;
|
|
168
|
+
let compactionRequest = false;
|
|
169
|
+
let expiryAttempt: ExpiryAttempt | undefined;
|
|
170
|
+
let lastOutcome: { path: string; elapsedMs: number; usage?: Usage; reason?: string } | undefined;
|
|
154
171
|
let active: Active | undefined;
|
|
155
172
|
let pending: { details: NativeCompactionDetails; capture: Captured; summary: string } | undefined;
|
|
156
173
|
let observer: InstalledObserver | undefined;
|
|
157
174
|
let unregisterCleanup: (() => void) | undefined;
|
|
158
175
|
let generation = 0;
|
|
176
|
+
let requestSequence = 0;
|
|
159
177
|
let captureStatus = "no request observed";
|
|
160
178
|
let compactionStatus = "not attempted in this runtime";
|
|
179
|
+
let fallbackReason: string | undefined;
|
|
161
180
|
const captureRejected = (reason: string) => { captureStatus = reason; };
|
|
162
|
-
const fallback = (reason: string) => { compactionStatus = `portable: ${reason}`; };
|
|
181
|
+
const fallback = (reason: string) => { fallbackReason = reason; compactionStatus = `portable: ${reason}`; };
|
|
163
182
|
pi.registerCommand("provider-compaction-status", {
|
|
164
|
-
description: "Show
|
|
183
|
+
description: "Show cache/native eligibility and measured compaction usage",
|
|
165
184
|
handler: async (_args, context) => {
|
|
166
|
-
|
|
185
|
+
const state = cacheState(context);
|
|
186
|
+
const remaining = state.expiresAt === undefined ? "unknown lifetime" : `estimated ${Math.max(0, Math.ceil((state.expiresAt - Date.now()) / 1000))}s remaining (model declaration; best effort)`;
|
|
187
|
+
const usage = lastOutcome?.usage;
|
|
188
|
+
context.ui.notify(`Provider compaction\nObserver: ${observer?.isInstalled() ? "installed" : "unavailable for current registration"}\nCapture: ${captureStatus}\nCache: ${state.reason}; ${remaining}\nLast attempt: ${compactionStatus}${lastOutcome ? `\nOutcome: ${lastOutcome.path}, ${lastOutcome.elapsedMs}ms${usage ? `; input ${usage.input}, cache read ${usage.cacheRead}, write ${usage.cacheWrite}, output ${usage.output}; estimated cost $${usage.cost.total.toFixed(6)}` : "; usage unavailable"}${lastOutcome.reason ? `; ${lastOutcome.reason}` : ""}` : ""}`, "info");
|
|
167
189
|
},
|
|
168
190
|
});
|
|
169
|
-
const invalidate = () => { generation++; runtimeId = randomUUID(); snapshot = undefined; active = undefined; pending = undefined; captureStatus = "invalidated by lifecycle/history/policy change"; };
|
|
191
|
+
const invalidate = () => { generation++; runtimeId = randomUUID(); snapshot = undefined; cache = undefined; active = undefined; pending = undefined; expiryAttempt = undefined; captureStatus = "invalidated by lifecycle/history/policy change"; };
|
|
170
192
|
const retire = () => {
|
|
193
|
+
usageObservers.clear();
|
|
171
194
|
invalidate();
|
|
172
195
|
const installed = observer;
|
|
173
196
|
observer = undefined; ctx = undefined;
|
|
174
197
|
unregisterCleanup?.(); unregisterCleanup = undefined;
|
|
175
198
|
installed?.remove();
|
|
199
|
+
lastOutcome = undefined;
|
|
176
200
|
};
|
|
201
|
+
pi.on("before_provider_headers", (event, context) => {
|
|
202
|
+
bindUsageHeaders(event.headers, context.sessionManager.getSessionId());
|
|
203
|
+
// Lazy API loading or catalog refresh can recompose effective providers
|
|
204
|
+
// between auxiliary calls that have no before_agent_start boundary.
|
|
205
|
+
usageObservers.ready(context);
|
|
206
|
+
});
|
|
177
207
|
const generic = (context: ExtensionContext) => resolveCompactionModelPolicy(pi, context).hasSelectors;
|
|
178
208
|
const route = (model: Model<any>, options: ProviderStreamOptions | SimpleStreamOptions) => routeDigest({ model, apiKey: options.apiKey, headers: options.headers, env: options.env });
|
|
179
209
|
const unchanged = (branch: readonly SessionEntry[], length: number, hash: string) => branch.length >= length && digest(branch.slice(0, length)) === hash && !branch.slice(length).some((entry) => entry.type === "context_edit");
|
|
180
210
|
|
|
211
|
+
const cacheState = (context: ExtensionContext) => {
|
|
212
|
+
let reason: string | undefined;
|
|
213
|
+
if (!configuration().cachedSummaries) reason = "cached summaries disabled";
|
|
214
|
+
else if (generic(context)) reason = "explicit compaction model takes precedence";
|
|
215
|
+
else if (cache && !observer?.isInstalled()) reason = "request observer no longer owned";
|
|
216
|
+
else if (cache && (cache.sessionId !== context.sessionManager.getSessionId() || context.model?.id !== cache.model.id || context.model?.provider !== cache.model.provider || context.model?.api !== cache.model.api || context.modelRegistry.getProvider(cache.model.provider) !== cache.provider || !unchanged(context.sessionManager.getBranch(), cache.branchLength, cache.branchHash))) reason = "cache snapshot no longer matches session/model/history";
|
|
217
|
+
return requestCacheState(cache, reason);
|
|
218
|
+
};
|
|
219
|
+
const removeCacheState = pi.events.on("pi-provider-compaction:cache-state", (request) => {
|
|
220
|
+
if (!isObject(request) || !isObject(request.context) || typeof request.respond !== "function") return;
|
|
221
|
+
try { request.respond(cacheState(request.context as unknown as ExtensionContext)); } catch { /* Stale requester. */ }
|
|
222
|
+
});
|
|
223
|
+
const removeExpiry = pi.events.on("pi-provider-compaction:compact-before-expiry", (request) => {
|
|
224
|
+
if (!isObject(request) || !isObject(request.context) || typeof request.respond !== "function" || typeof request.run !== "function" || typeof request.isCurrent !== "function") return;
|
|
225
|
+
const context = request.context as unknown as ExtensionContext;
|
|
226
|
+
const run = request.run as () => Promise<boolean>;
|
|
227
|
+
const authorized = request.isCurrent as () => boolean;
|
|
228
|
+
const execute = async () => {
|
|
229
|
+
const state = cacheState(context);
|
|
230
|
+
if (expiryAttempt || !state.available || state.cycle !== request.cycle || state.expiresAt === undefined || Date.now() >= state.expiresAt || !context.isIdle() || context.hasPendingMessages() || !authorized()) return false;
|
|
231
|
+
const ticket = generation;
|
|
232
|
+
const attempt: ExpiryAttempt = {
|
|
233
|
+
sessionId: context.sessionManager.getSessionId(), cycle: state.cycle,
|
|
234
|
+
isCurrent: () => {
|
|
235
|
+
try {
|
|
236
|
+
return expiryAttempt === attempt && generation === ticket &&
|
|
237
|
+
context.sessionManager.getSessionId() === attempt.sessionId &&
|
|
238
|
+
!context.hasPendingMessages() && authorized();
|
|
239
|
+
} catch { return false; }
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
expiryAttempt = attempt;
|
|
243
|
+
try { return await run(); }
|
|
244
|
+
finally { if (expiryAttempt === attempt) expiryAttempt = undefined; }
|
|
245
|
+
};
|
|
246
|
+
request.respond(execute());
|
|
247
|
+
});
|
|
248
|
+
pi.on("session_shutdown", () => { removeCacheState(); removeExpiry(); });
|
|
249
|
+
|
|
181
250
|
const observe: ObserveRequest = (model, context, options) => {
|
|
182
251
|
const owner = ctx;
|
|
183
252
|
if (!owner) return;
|
|
@@ -190,7 +259,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
190
259
|
return;
|
|
191
260
|
}
|
|
192
261
|
if (options.sessionId !== manager.getSessionId()) return;
|
|
193
|
-
if (
|
|
262
|
+
if (compactionRequest) return;
|
|
263
|
+
if (!supportsNativeCompaction(model) && !supportsCachedSummary(model)) { captureRejected("unsupported API"); return; }
|
|
194
264
|
if (!options.onPayload) {
|
|
195
265
|
// The portable-summary request re-enters this observer without a payload
|
|
196
266
|
// callback. Do not overwrite the main request's capture diagnostic.
|
|
@@ -202,19 +272,22 @@ export default function (pi: ExtensionAPI): void {
|
|
|
202
272
|
// fallbacks. Those must not silently select a route absent from our evidence.
|
|
203
273
|
if (!supportedRouteOptions(model, options)) { captureRejected("unsupported route overrides"); return; }
|
|
204
274
|
const ticket = generation;
|
|
275
|
+
const startedAt = Date.now();
|
|
276
|
+
const sequence = ++requestSequence;
|
|
277
|
+
const config = configuration();
|
|
278
|
+
const effectiveOptions = { ...options, ...(config.cacheRetention === undefined ? {} : { cacheRetention: config.cacheRetention }) };
|
|
279
|
+
let candidate: CachedRequest | undefined;
|
|
205
280
|
const branch = manager.getBranch();
|
|
206
281
|
const branchHash = digest(branch);
|
|
207
282
|
const canonical = transcript(manager.buildSessionProjection().messages);
|
|
208
283
|
const current = () => {
|
|
209
|
-
if (generation !== ticket || ctx !== owner || options.signal?.aborted || !supportedRouteOptions(model, options)) return false;
|
|
284
|
+
if (sequence !== requestSequence || generation !== ticket || ctx !== owner || options.signal?.aborted || !supportedRouteOptions(model, options)) return false;
|
|
210
285
|
try { return unchanged(owner.sessionManager.getBranch(), branch.length, branchHash); }
|
|
211
286
|
catch { retire(); return false; }
|
|
212
287
|
};
|
|
213
|
-
|
|
288
|
+
const handler = async (payload: unknown) => {
|
|
214
289
|
if (!current()) return payload;
|
|
215
290
|
snapshot = undefined;
|
|
216
|
-
const prepared = capturePreparedRequest(model, context, canonical, payload, captureRejected);
|
|
217
|
-
if (!prepared) { active = undefined; return payload; }
|
|
218
291
|
const auth = await owner.modelRegistry.getApiKeyAndHeaders(model);
|
|
219
292
|
if (!current()) return payload;
|
|
220
293
|
// A registry lookup after payload hooks must describe the credentials used by
|
|
@@ -222,13 +295,24 @@ export default function (pi: ExtensionAPI): void {
|
|
|
222
295
|
// exact auth correspondence are deliberately ineligible.
|
|
223
296
|
const normalizedHeaders = (headers: ProviderHeaders | undefined) => Object.entries(headers ?? {}).map(([name, value]) => [name.toLowerCase(), value]).sort(([a], [b]) => String(a).localeCompare(String(b)));
|
|
224
297
|
if (!auth.ok || generic(owner) || auth.apiKey !== options.apiKey || (auth.baseUrl ?? model.baseUrl) !== model.baseUrl || !equal(normalizedHeaders(auth.headers), normalizedHeaders(options.headers)) || !equal(auth.env ?? {}, options.env ?? {})) {
|
|
225
|
-
active = undefined;
|
|
298
|
+
active = undefined; cache = undefined;
|
|
226
299
|
captureRejected("resolved auth/route differs from prepared request, or generic policy changed");
|
|
227
300
|
return payload;
|
|
228
301
|
}
|
|
229
302
|
const provider = owner.modelRegistry.getProvider(model.provider);
|
|
230
303
|
if (!provider) { captureRejected("provider unavailable"); return payload; }
|
|
231
304
|
const routeHash = route(model, options);
|
|
305
|
+
if (config.cachedSummaries && supportsCachedSummary(model)) {
|
|
306
|
+
const warming = options.maxTokens === 1 && cache && cache.authHash === routeDigest(auth) && sameCachedPrompt(cache.payload, payload) && equal(cache.context, context);
|
|
307
|
+
candidate = warming ? { ...cache!, startedAt, cycle: randomUUID(), usage: undefined, expiresAt: undefined } : captureCachedRequest({
|
|
308
|
+
model, provider, context, canonical, options: effectiveOptions, startedAt,
|
|
309
|
+
sessionId: manager.getSessionId(), branchLength: branch.length, branchHash, authHash: routeDigest(auth),
|
|
310
|
+
}, payload);
|
|
311
|
+
if (!warming) cache = undefined;
|
|
312
|
+
}
|
|
313
|
+
if (!supportsNativeCompaction(model)) { captureStatus = candidate ? "eligible cached request captured" : "cached request ineligible"; return payload; }
|
|
314
|
+
const prepared = capturePreparedRequest(model, context, canonical, payload, captureRejected);
|
|
315
|
+
if (!prepared) { active = undefined; return payload; }
|
|
232
316
|
const policyHash = digest(requestPolicy(model, prepared));
|
|
233
317
|
const latest = latestCompaction(branch);
|
|
234
318
|
const payloadModel = isObject(payload) && typeof payload.model === "string" ? payload.model : undefined;
|
|
@@ -245,6 +329,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
245
329
|
result = replaced;
|
|
246
330
|
prepared.payload = structuredClone(replaced);
|
|
247
331
|
prepared.replacement = { index: slot, count: details.output.length };
|
|
332
|
+
// The actual request contains opaque state, not the captured readable prefix.
|
|
333
|
+
candidate = undefined; cache = undefined;
|
|
248
334
|
} else active = undefined;
|
|
249
335
|
} else active = undefined;
|
|
250
336
|
} else active = undefined;
|
|
@@ -257,85 +343,155 @@ export default function (pi: ExtensionAPI): void {
|
|
|
257
343
|
};
|
|
258
344
|
return result;
|
|
259
345
|
};
|
|
346
|
+
return Object.assign(handler, {
|
|
347
|
+
cacheRetention: config.cacheRetention,
|
|
348
|
+
complete: (message: Parameters<typeof finishCachedRequest>[1]) => {
|
|
349
|
+
if (!candidate || sequence !== requestSequence || !current()) return;
|
|
350
|
+
finishCachedRequest(candidate, message);
|
|
351
|
+
if (candidate.usage) cache = candidate;
|
|
352
|
+
},
|
|
353
|
+
});
|
|
260
354
|
};
|
|
261
355
|
|
|
262
356
|
const install = (context: ExtensionContext) => {
|
|
263
357
|
ctx = context;
|
|
264
|
-
if (observer?.provider === context.model?.provider && observer?.isInstalled()) return;
|
|
358
|
+
if (observer?.provider === context.model?.provider && observer?.api === context.model?.api && observer?.isInstalled()) return;
|
|
265
359
|
if (observer) { invalidate(); observer.remove(); observer = undefined; }
|
|
266
|
-
if (!supportsNativeCompaction(context.model)) return;
|
|
360
|
+
if (!supportsNativeCompaction(context.model) && !supportsCachedSummary(context.model)) return;
|
|
267
361
|
observer = installObserver(pi, context, observe);
|
|
268
362
|
captureStatus = observer ? "awaiting prepared request" : "unsupported provider registration";
|
|
269
363
|
};
|
|
270
364
|
pi.on("session_start", (_event, context) => {
|
|
271
|
-
retire();
|
|
365
|
+
retire();
|
|
366
|
+
usageObservers.dispose();
|
|
367
|
+
usageObservers = serveUsageObservers(pi, () => ctx);
|
|
368
|
+
install(context);
|
|
369
|
+
usageObservers.ready(context);
|
|
272
370
|
const sessionId = context.sessionManager.getSessionId();
|
|
273
371
|
// AgentSession.dispose() cleans session resources even without emitting
|
|
274
372
|
// session_shutdown. The captured registry keeps owned cleanup usable.
|
|
275
373
|
unregisterCleanup = registerSessionResourceCleanup((disposedId) => {
|
|
276
|
-
if (disposedId === undefined || disposedId === sessionId) retire();
|
|
374
|
+
if (disposedId === undefined || disposedId === sessionId) { retire(); usageObservers.dispose(); }
|
|
277
375
|
});
|
|
278
376
|
const latest = latestCompaction(context.sessionManager.getBranch());
|
|
279
377
|
if (latest?.summary === NATIVE_COMPACTION_SUMMARY) context.ui.notify("Legacy native compaction has no portable summary. Native replay is disabled; recover from earlier history or start a new session.", "warning");
|
|
280
378
|
});
|
|
281
|
-
pi.on("before_agent_start", (_event, context) => { install(context); });
|
|
282
|
-
pi.on("model_select", (_event, context) => { invalidate(); install(context); });
|
|
379
|
+
pi.on("before_agent_start", (_event, context) => { install(context); usageObservers.ready(context); });
|
|
380
|
+
pi.on("model_select", (_event, context) => { invalidate(); install(context); usageObservers.ready(context); });
|
|
283
381
|
pi.on("session_tree", () => { invalidate(); });
|
|
284
|
-
pi.on("session_shutdown", retire);
|
|
382
|
+
pi.on("session_shutdown", () => { retire(); usageObservers.dispose(); });
|
|
285
383
|
// Privacy-policy owners must invalidate before changing a policy that can affect
|
|
286
384
|
// hidden covered history. Arbitrary dynamic privacy compositions are unsupported.
|
|
287
385
|
const removeInvalidation = pi.events.on("pi-provider-compaction:invalidate", invalidate);
|
|
288
386
|
pi.on("session_shutdown", () => { if (typeof removeInvalidation === "function") removeInvalidation(); });
|
|
289
387
|
|
|
290
388
|
pi.on("session_before_compact", async (event, context) => {
|
|
389
|
+
const began = Date.now();
|
|
291
390
|
compactionStatus = "preparing compaction";
|
|
391
|
+
fallbackReason = undefined;
|
|
392
|
+
const attempt = expiryAttempt;
|
|
292
393
|
const capture = snapshot;
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
394
|
+
const cached = cacheState(context).available ? cache : undefined;
|
|
395
|
+
snapshot = undefined; cache = undefined; pending = undefined;
|
|
396
|
+
const cancelled = (usage?: Usage, reason = "compaction ownership or input changed") => {
|
|
397
|
+
lastOutcome = { path: attempt ? "expiry cancelled" : "cancelled", elapsedMs: Date.now() - began, usage, reason };
|
|
398
|
+
return { cancel: true as const };
|
|
399
|
+
};
|
|
400
|
+
const reject = (reason: string) => {
|
|
401
|
+
fallback(reason);
|
|
402
|
+
return attempt ? cancelled(undefined, reason) : undefined;
|
|
403
|
+
};
|
|
404
|
+
// The provider may run before the companion's handler. Share its live
|
|
405
|
+
// ownership/input guard instead of paying for work that a later handler cancels.
|
|
406
|
+
if (attempt) {
|
|
407
|
+
if (event.reason !== "manual" || !attempt.isCurrent() || !cached || cached.cycle !== attempt.cycle || attempt.signal && attempt.signal !== event.signal) return reject("idle transaction no longer authorized");
|
|
408
|
+
attempt.signal = event.signal;
|
|
409
|
+
}
|
|
410
|
+
if ((!capture && !cached) || !context.model || generic(context) || event.signal.aborted) return reject("no eligible snapshot, explicit model override, or aborted attempt");
|
|
298
411
|
const ticket = generation;
|
|
299
412
|
const branch = context.sessionManager.getBranch();
|
|
300
413
|
const branchHash = digest(branch);
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
const input = selectCoveredPrefix(capture.model, capture.prepared, prefix, fallback);
|
|
307
|
-
if (!input) return;
|
|
308
|
-
const current = () => ticket === generation && !event.signal.aborted && supportedRouteOptions(capture.model, capture.options) && !generic(context) && context.modelRegistry.getProvider(capture.model.provider) === capture.provider && unchanged(context.sessionManager.getBranch(), branch.length, branchHash);
|
|
414
|
+
const source = cached ?? capture!;
|
|
415
|
+
if (context.sessionManager.getSessionId() !== source.sessionId || !equal(branch, event.branchEntries) || !unchanged(branch, source.branchLength, source.branchHash)) return reject("history/session changed since capture");
|
|
416
|
+
const current = () => ticket === generation && !event.signal.aborted && (!attempt || attempt.isCurrent()) && supportedRouteOptions(source.model, source.options) && !generic(context) &&
|
|
417
|
+
context.model?.id === source.model.id && context.model?.provider === source.model.provider && context.model?.api === source.model.api &&
|
|
418
|
+
observer?.isInstalled() === true && context.modelRegistry.getProvider(source.model.provider) === source.provider && unchanged(context.sessionManager.getBranch(), branch.length, branchHash);
|
|
309
419
|
const auth = await context.modelRegistry.getApiKeyAndHeaders(context.model);
|
|
310
|
-
if (!current()) return
|
|
311
|
-
if (!auth.ok || routeDigest(auth) !==
|
|
312
|
-
|
|
313
|
-
let
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
420
|
+
if (!current()) return cancelled();
|
|
421
|
+
if (!auth.ok || routeDigest(auth) !== source.authHash) return reject("auth changed since capture");
|
|
422
|
+
const protocol = capture ? resolveNativeProtocol(context.model) : undefined;
|
|
423
|
+
let input: ResponseItem[] | undefined;
|
|
424
|
+
if (capture && protocol && unchanged(branch, capture.branchLength, capture.branchHash) && source.authHash === capture.authHash) {
|
|
425
|
+
const projection = context.sessionManager.buildSessionProjection();
|
|
426
|
+
const boundary = projection.entries.findIndex((entry) => entry.sourceEntry.id === event.preparation.firstKeptEntryId);
|
|
427
|
+
if (boundary >= 0) input = selectCoveredPrefix(capture.model, capture.prepared, transcript(projection.entries.slice(0, boundary).flatMap((entry) => entry.messages)), fallback);
|
|
428
|
+
}
|
|
429
|
+
let portable: Awaited<ReturnType<typeof compact>> | undefined;
|
|
430
|
+
let attemptedUsage: Usage | undefined;
|
|
431
|
+
let summaryPath = "standard";
|
|
432
|
+
let summaryReason: string | undefined;
|
|
433
|
+
if (cached) {
|
|
434
|
+
compactionStatus = "generating cached portable summary";
|
|
435
|
+
compactionRequest = true;
|
|
436
|
+
try {
|
|
437
|
+
const result = await cachedSummary(cached, context, event.preparation, event.customInstructions, event.signal, current);
|
|
438
|
+
portable = result.result;
|
|
439
|
+
attemptedUsage = result.usage;
|
|
440
|
+
summaryReason = result.reason;
|
|
441
|
+
if (portable) summaryPath = "cached";
|
|
442
|
+
} finally { compactionRequest = false; }
|
|
443
|
+
if (!current()) return cancelled(attemptedUsage);
|
|
444
|
+
}
|
|
445
|
+
if (!portable) {
|
|
446
|
+
if (attempt) return cancelled(attemptedUsage, summaryReason ?? "warm cache unavailable");
|
|
447
|
+
// Without native coverage or a dispatched cached attempt, Pi can own fallback.
|
|
448
|
+
if (!input && !attemptedUsage) return reject(summaryReason ?? fallbackReason ?? "native coverage unavailable");
|
|
449
|
+
compactionStatus = "generating portable summary";
|
|
450
|
+
try {
|
|
451
|
+
portable = await compact(event.preparation, source.model, auth.apiKey, auth.headers as Record<string, string> | undefined, event.customInstructions, event.signal, context.thinkingLevel, source.provider.streamSimple.bind(source.provider), auth.env, undefined, undefined, source.sessionId);
|
|
452
|
+
portable = withFileTracking(portable, event.preparation);
|
|
453
|
+
portable.usage = combineUsage(attemptedUsage, portable.usage);
|
|
454
|
+
} catch {
|
|
455
|
+
if (!current()) return cancelled(attemptedUsage);
|
|
456
|
+
return reject("portable summary generation failed");
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (!current() || !portable.summary.trim() || portable.summary === NATIVE_COMPACTION_SUMMARY) return cancelled(portable.usage);
|
|
460
|
+
const outcome = (path: string, result: typeof portable, reason?: string) => {
|
|
461
|
+
lastOutcome = { path, elapsedMs: Date.now() - began, usage: result!.usage, reason };
|
|
462
|
+
result!.details = { ...(isObject(result!.details) ? result!.details : {}), providerCompaction: lastOutcome };
|
|
463
|
+
return { compaction: result! };
|
|
464
|
+
};
|
|
465
|
+
if (!capture || !protocol || !input) {
|
|
466
|
+
compactionStatus = `${summaryPath} portable summary prepared`;
|
|
467
|
+
return outcome(summaryPath, portable, summaryReason);
|
|
321
468
|
}
|
|
322
|
-
if (!current() || !portable.summary.trim() || portable.summary === NATIVE_COMPACTION_SUMMARY) return { cancel: true };
|
|
323
469
|
try {
|
|
324
470
|
const freshAuth = await context.modelRegistry.getApiKeyAndHeaders(context.model);
|
|
325
|
-
if (!current()) return
|
|
326
|
-
if (!freshAuth.ok || routeDigest(freshAuth) !== capture.authHash) { fallback("auth changed after portable generation"); return
|
|
471
|
+
if (!current()) return cancelled(portable.usage);
|
|
472
|
+
if (!freshAuth.ok || routeDigest(freshAuth) !== capture.authHash) { fallback("auth changed after portable generation"); return outcome(summaryPath, portable, "native auth changed"); }
|
|
327
473
|
compactionStatus = "requesting native checkpoint";
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
474
|
+
let native: Awaited<ReturnType<typeof requestProviderCompaction>>;
|
|
475
|
+
compactionRequest = true;
|
|
476
|
+
try {
|
|
477
|
+
native = await requestProviderCompaction({
|
|
478
|
+
provider: capture.provider, model: capture.model, context: capture.prepared.context,
|
|
479
|
+
input, preparedPayload: capture.prepared.payload, protocol,
|
|
480
|
+
apiKey: capture.options.apiKey, headers: capture.options.headers, env: capture.options.env,
|
|
481
|
+
signal: event.signal, isCurrent: current, fetch: capture.options.fetch, sessionId: source.sessionId,
|
|
482
|
+
...(capture.options.serviceTier === undefined ? {} : { serviceTier: capture.options.serviceTier }),
|
|
483
|
+
});
|
|
484
|
+
} finally { compactionRequest = false; }
|
|
485
|
+
if (!current()) return cancelled(combineUsage(native.usage, portable.usage));
|
|
336
486
|
const finalAuth = await context.modelRegistry.getApiKeyAndHeaders(context.model);
|
|
337
|
-
if (!current()) return
|
|
338
|
-
if (!finalAuth.ok || routeDigest(finalAuth) !== capture.authHash) {
|
|
487
|
+
if (!current()) return cancelled(combineUsage(native.usage, portable.usage));
|
|
488
|
+
if (!finalAuth.ok || routeDigest(finalAuth) !== capture.authHash) {
|
|
489
|
+
// The checkpoint is no longer safe to commit, but both requests
|
|
490
|
+
// completed and their known usage still belongs in the session.
|
|
491
|
+
portable.usage = combineUsage(native.usage, portable.usage);
|
|
492
|
+
fallback("auth changed after native generation");
|
|
493
|
+
return outcome(summaryPath, portable, "native auth changed");
|
|
494
|
+
}
|
|
339
495
|
const details: NativeCompactionDetails = {
|
|
340
496
|
...(isObject(portable.details) ? portable.details : {}),
|
|
341
497
|
type: NATIVE_COMPACTION_TYPE, version: 3, provider: capture.model.provider, api: capture.model.api, model: capture.model.id,
|
|
@@ -343,28 +499,29 @@ export default function (pi: ExtensionAPI): void {
|
|
|
343
499
|
nativeUsage: native.usage, portableUsage: portable.usage,
|
|
344
500
|
coverage: { kind: "summarized-prefix", firstKeptEntryId: portable.firstKeptEntryId, sourceLeafId: branch.at(-1)!.id, sourceHash: branchHash, policyHash: capture.policyHash, inputHash: digest(input), runtimeId },
|
|
345
501
|
};
|
|
346
|
-
|
|
347
|
-
|
|
502
|
+
const result = outcome(`${summaryPath} + native`, { ...portable, usage: combineUsage(native.usage, portable.usage), details }, summaryReason);
|
|
503
|
+
pending = { details: result.compaction.details as NativeCompactionDetails, capture, summary: portable.summary };
|
|
504
|
+
return result;
|
|
348
505
|
} catch (error) {
|
|
349
|
-
if (!current()) return
|
|
350
|
-
// Do not expose exception text: provider errors may contain request data.
|
|
506
|
+
if (!current()) return cancelled(error instanceof NativeCompactionValidationError ? combineUsage(error.usage, portable.usage) : portable.usage);
|
|
351
507
|
fallback("native acquisition or validation failed");
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
// an unusable window.
|
|
355
|
-
return {
|
|
356
|
-
compaction: error instanceof NativeCompactionValidationError && error.usage !== undefined
|
|
357
|
-
? { ...portable, usage: combineUsage(error.usage, portable.usage) }
|
|
358
|
-
: portable,
|
|
359
|
-
};
|
|
508
|
+
if (error instanceof NativeCompactionValidationError && error.usage !== undefined) portable.usage = combineUsage(error.usage, portable.usage);
|
|
509
|
+
return outcome(summaryPath, portable, "native acquisition or validation failed");
|
|
360
510
|
}
|
|
361
511
|
});
|
|
362
512
|
pi.on("session_compact", (event, context) => {
|
|
513
|
+
const recorded = isObject(event.compactionEntry.details) ? event.compactionEntry.details.providerCompaction : undefined;
|
|
514
|
+
const owned = isObject(recorded) && equal(recorded, lastOutcome);
|
|
363
515
|
if (readNativeCompactionDetails(event.compactionEntry.details)?.version === 3) compactionStatus = "native checkpoint committed";
|
|
364
|
-
else if (
|
|
516
|
+
else if (fallbackReason) compactionStatus = `portable: ${fallbackReason}`;
|
|
517
|
+
else if (owned) compactionStatus = `${lastOutcome!.path} portable summary committed`;
|
|
518
|
+
else fallback("another handler supplied the committed result");
|
|
519
|
+
if (owned) {
|
|
520
|
+
if (configuration().outcomeNotifications && lastOutcome) context.ui.notify(`Compacted via ${lastOutcome.path} in ${(lastOutcome.elapsedMs / 1000).toFixed(1)}s${lastOutcome.usage ? `; cache read ${lastOutcome.usage.cacheRead}, write ${lastOutcome.usage.cacheWrite}; estimated cost $${lastOutcome.usage.cost.total.toFixed(6)}` : ""}`, "info");
|
|
521
|
+
} else lastOutcome = { path: "Pi/other handler", elapsedMs: 0, usage: event.compactionEntry.usage, reason: compactionStatus };
|
|
365
522
|
// Only static diagnostics are persisted, never request/auth data or error text.
|
|
366
|
-
if (
|
|
367
|
-
active = undefined; snapshot = undefined;
|
|
523
|
+
if (fallbackReason) pi.appendEntry("pi-provider-compaction:diagnostic", { status: `portable: ${fallbackReason}` });
|
|
524
|
+
active = undefined; snapshot = undefined; cache = undefined;
|
|
368
525
|
const candidate = pending; pending = undefined;
|
|
369
526
|
if (!candidate || !equal(event.compactionEntry.details, candidate.details) || event.compactionEntry.summary !== candidate.summary) return;
|
|
370
527
|
const branch = context.sessionManager.getBranch();
|
|
@@ -381,5 +538,5 @@ export default function (pi: ExtensionAPI): void {
|
|
|
381
538
|
...(typeof candidate.capture.prepared.payload.model === "string" ? { payloadModel: candidate.capture.prepared.payload.model } : {}),
|
|
382
539
|
};
|
|
383
540
|
});
|
|
384
|
-
pi.on("session_compact_failed", () => { pending = undefined; snapshot = undefined; compactionStatus = "compaction failed or cancelled"; });
|
|
541
|
+
pi.on("session_compact_failed", () => { pending = undefined; snapshot = undefined; cache = undefined; compactionStatus = "compaction failed or cancelled"; });
|
|
385
542
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nijaru/pi-provider-compaction",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.10",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Provider-native context compaction adapters for Pi",
|
|
6
6
|
"keywords": [
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"build": "bun run build.ts",
|
|
30
30
|
"prepack": "bun run build",
|
|
31
31
|
"test": "bun test",
|
|
32
|
-
"lint": "oxlint build.ts index.ts protocol.ts policy.ts provider.ts replay.ts tests/",
|
|
32
|
+
"lint": "oxlint build.ts index.ts protocol.ts policy.ts provider.ts usage-observer.ts replay.ts cached-summary.ts tests/",
|
|
33
33
|
"typecheck": "tsc --noEmit",
|
|
34
34
|
"check": "bun run build && bun test && bun run lint && tsc --noEmit"
|
|
35
35
|
},
|
|
@@ -39,14 +39,16 @@
|
|
|
39
39
|
"protocol.ts",
|
|
40
40
|
"policy.ts",
|
|
41
41
|
"provider.ts",
|
|
42
|
+
"usage-observer.ts",
|
|
42
43
|
"replay.ts",
|
|
44
|
+
"cached-summary.ts",
|
|
43
45
|
"README.md",
|
|
44
46
|
"LICENSE"
|
|
45
47
|
],
|
|
46
48
|
"devDependencies": {
|
|
47
|
-
"@earendil-works/pi-ai": "1.
|
|
48
|
-
"@earendil-works/pi-agent-core": "1.
|
|
49
|
-
"@earendil-works/pi-coding-agent": "1.
|
|
49
|
+
"@earendil-works/pi-ai": "1.4.0",
|
|
50
|
+
"@earendil-works/pi-agent-core": "1.4.0",
|
|
51
|
+
"@earendil-works/pi-coding-agent": "1.4.0",
|
|
50
52
|
"@types/bun": "latest",
|
|
51
53
|
"oxlint": "^1.81.0",
|
|
52
54
|
"typescript": "^6.0.3"
|
package/policy.ts
CHANGED
|
@@ -20,7 +20,6 @@ export const COMPACTION_MODEL_FLAG = "compaction-model";
|
|
|
20
20
|
const PROJECT_POLICY = "compaction-policy.json";
|
|
21
21
|
const AGENT_DIR_OVERRIDE = "PI_COMPACTOR_AGENT_DIR";
|
|
22
22
|
const MAX_POLICY_BYTES = 64 * 1024;
|
|
23
|
-
const MAX_MODEL_SELECTORS = 8;
|
|
24
23
|
const MAX_SELECTOR_LENGTH = 512;
|
|
25
24
|
|
|
26
25
|
type PolicyContext = Pick<ExtensionContext, "cwd" | "isProjectTrusted"> & {
|
|
@@ -43,21 +42,15 @@ function normalizeSelector(value: unknown): string | undefined {
|
|
|
43
42
|
return selector;
|
|
44
43
|
}
|
|
45
44
|
|
|
46
|
-
function
|
|
45
|
+
function policyHasSelectors(value: unknown): boolean | undefined {
|
|
47
46
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
48
47
|
if (!("models" in value) || !Array.isArray(value.models)) return undefined;
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const selector = normalizeSelector(model);
|
|
53
|
-
if (selector === undefined || selectors.includes(selector)) continue;
|
|
54
|
-
selectors.push(selector);
|
|
55
|
-
if (selectors.length === MAX_MODEL_SELECTORS) break;
|
|
56
|
-
}
|
|
57
|
-
return selectors;
|
|
48
|
+
// This extension only decides precedence. The companion owns the actual
|
|
49
|
+
// selector list, including deduplication and its model-attempt limit.
|
|
50
|
+
return value.models.some(model => normalizeSelector(model) !== undefined);
|
|
58
51
|
}
|
|
59
52
|
|
|
60
|
-
function
|
|
53
|
+
function readPolicySelection(configPath: string): boolean | undefined {
|
|
61
54
|
let fd: number | undefined;
|
|
62
55
|
try {
|
|
63
56
|
fd = openSync(configPath, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK);
|
|
@@ -74,7 +67,7 @@ function readPolicySelectors(configPath: string): string[] | undefined {
|
|
|
74
67
|
bytesRead += count;
|
|
75
68
|
}
|
|
76
69
|
if (bytesRead > MAX_POLICY_BYTES) return undefined;
|
|
77
|
-
return
|
|
70
|
+
return policyHasSelectors(JSON.parse(buffer.subarray(0, bytesRead).toString("utf8")) as unknown);
|
|
78
71
|
} catch {
|
|
79
72
|
// A malformed or unreadable policy must not prevent native compaction.
|
|
80
73
|
return undefined;
|
|
@@ -177,11 +170,11 @@ export function resolveCompactionModelPolicy(
|
|
|
177
170
|
candidates.push({ path: join(agentDir, PROJECT_POLICY), source: "agent-policy" });
|
|
178
171
|
|
|
179
172
|
for (const candidate of candidates) {
|
|
180
|
-
const
|
|
173
|
+
const hasSelectors = readPolicySelection(candidate.path);
|
|
181
174
|
// A readable policy stops the search: an empty models list is an
|
|
182
175
|
// explicit choice to use no generic compaction model.
|
|
183
|
-
if (
|
|
184
|
-
return
|
|
176
|
+
if (hasSelectors !== undefined) {
|
|
177
|
+
return hasSelectors ? { hasSelectors: true, source: candidate.source } : { hasSelectors: false };
|
|
185
178
|
}
|
|
186
179
|
}
|
|
187
180
|
return { hasSelectors: false };
|