@nijaru/pi-provider-compaction 0.0.9 → 0.0.11
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 +7 -5
- package/dist/index.js +226 -123
- package/index.ts +63 -63
- package/package.json +3 -2
- package/policy.ts +9 -16
- package/protocol.ts +16 -13
- package/provider.ts +81 -14
- package/usage-observer.ts +52 -0
package/index.ts
CHANGED
|
@@ -4,7 +4,8 @@ import { normalizeContext, registerSessionResourceCleanup, type Model, type Prov
|
|
|
4
4
|
import { compact, convertToLlm, type ExtensionAPI, type ExtensionContext, type SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { resolveCompactionModelPolicy } from "./policy";
|
|
6
6
|
import { cacheConfig, cachedSummary, captureCachedRequest, finishCachedRequest, requestCacheState, sameCachedPrompt, supportsCachedSummary, withFileTracking, type CachedRequest } from "./cached-summary";
|
|
7
|
-
import { installObserver, type InstalledObserver, type ObserveRequest } from "./provider";
|
|
7
|
+
import { bindUsageHeaders, installObserver, type InstalledObserver, type ObserveRequest } from "./provider";
|
|
8
|
+
import { serveUsageObservers } from "./usage-observer";
|
|
8
9
|
import { capturePreparedRequest, equal, requestPolicy, selectCoveredPrefix, substituteSummary, summarySlot, type PreparedRequest } from "./replay";
|
|
9
10
|
import { isObject, NativeCompactionValidationError, requestProviderCompaction, resolveNativeProtocol, supportsResponsesApi, validateCompactedOutput, type NativeProtocol, type ResponseItem } from "./protocol";
|
|
10
11
|
|
|
@@ -161,9 +162,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
161
162
|
const routeDigest = (value: unknown) => createHmac("sha256", secret).update(JSON.stringify(value)).digest("hex");
|
|
162
163
|
let runtimeId = randomUUID();
|
|
163
164
|
let ctx: ExtensionContext | undefined;
|
|
165
|
+
let usageObservers = serveUsageObservers(pi, () => ctx);
|
|
164
166
|
let snapshot: Captured | undefined;
|
|
165
167
|
let cache: CachedRequest | undefined;
|
|
166
|
-
let
|
|
168
|
+
let compactionRequest = false;
|
|
167
169
|
let expiryAttempt: ExpiryAttempt | undefined;
|
|
168
170
|
let lastOutcome: { path: string; elapsedMs: number; usage?: Usage; reason?: string } | undefined;
|
|
169
171
|
let active: Active | undefined;
|
|
@@ -172,22 +174,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
172
174
|
let unregisterCleanup: (() => void) | undefined;
|
|
173
175
|
let generation = 0;
|
|
174
176
|
let requestSequence = 0;
|
|
175
|
-
let captureStatus = "no request observed";
|
|
176
|
-
let compactionStatus = "not attempted in this runtime";
|
|
177
177
|
let fallbackReason: string | undefined;
|
|
178
|
-
const
|
|
179
|
-
const
|
|
180
|
-
pi.registerCommand("provider-compaction-status", {
|
|
181
|
-
description: "Show cache/native eligibility and measured compaction usage",
|
|
182
|
-
handler: async (_args, context) => {
|
|
183
|
-
const state = cacheState(context);
|
|
184
|
-
const remaining = state.expiresAt === undefined ? "unknown lifetime" : `estimated ${Math.max(0, Math.ceil((state.expiresAt - Date.now()) / 1000))}s remaining (model declaration; best effort)`;
|
|
185
|
-
const usage = lastOutcome?.usage;
|
|
186
|
-
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}; catalog cost $${usage.cost.total.toFixed(6)}` : "; usage unavailable"}${lastOutcome.reason ? `; ${lastOutcome.reason}` : ""}` : ""}`, "info");
|
|
187
|
-
},
|
|
188
|
-
});
|
|
189
|
-
const invalidate = () => { generation++; runtimeId = randomUUID(); snapshot = undefined; cache = undefined; active = undefined; pending = undefined; expiryAttempt = undefined; captureStatus = "invalidated by lifecycle/history/policy change"; };
|
|
178
|
+
const fallback = (reason: string) => { fallbackReason = reason; };
|
|
179
|
+
const invalidate = () => { generation++; runtimeId = randomUUID(); snapshot = undefined; cache = undefined; active = undefined; pending = undefined; expiryAttempt = undefined; };
|
|
190
180
|
const retire = () => {
|
|
181
|
+
usageObservers.clear();
|
|
191
182
|
invalidate();
|
|
192
183
|
const installed = observer;
|
|
193
184
|
observer = undefined; ctx = undefined;
|
|
@@ -195,6 +186,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
195
186
|
installed?.remove();
|
|
196
187
|
lastOutcome = undefined;
|
|
197
188
|
};
|
|
189
|
+
pi.on("before_provider_headers", (event, context) => {
|
|
190
|
+
bindUsageHeaders(event.headers, context.sessionManager.getSessionId());
|
|
191
|
+
// Lazy API loading or catalog refresh can recompose effective providers
|
|
192
|
+
// between auxiliary calls that have no before_agent_start boundary.
|
|
193
|
+
usageObservers.ready(context);
|
|
194
|
+
});
|
|
198
195
|
const generic = (context: ExtensionContext) => resolveCompactionModelPolicy(pi, context).hasSelectors;
|
|
199
196
|
const route = (model: Model<any>, options: ProviderStreamOptions | SimpleStreamOptions) => routeDigest({ model, apiKey: options.apiKey, headers: options.headers, env: options.env });
|
|
200
197
|
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");
|
|
@@ -250,18 +247,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
250
247
|
return;
|
|
251
248
|
}
|
|
252
249
|
if (options.sessionId !== manager.getSessionId()) return;
|
|
253
|
-
if (
|
|
254
|
-
if (!supportsNativeCompaction(model) && !supportsCachedSummary(model))
|
|
255
|
-
if (!options.onPayload)
|
|
256
|
-
// The portable-summary request re-enters this observer without a payload
|
|
257
|
-
// callback. Do not overwrite the main request's capture diagnostic.
|
|
258
|
-
if (captureStatus !== "eligible prepared request captured") captureRejected("no payload callback");
|
|
259
|
-
return;
|
|
260
|
-
}
|
|
261
|
-
if (generic(owner)) { captureRejected("generic compaction model takes precedence"); return; }
|
|
250
|
+
if (compactionRequest) return;
|
|
251
|
+
if (!supportsNativeCompaction(model) && !supportsCachedSummary(model)) return;
|
|
252
|
+
if (!options.onPayload || generic(owner)) return;
|
|
262
253
|
// The Azure adapter also accepts API-specific overrides and process-env
|
|
263
254
|
// fallbacks. Those must not silently select a route absent from our evidence.
|
|
264
|
-
if (!supportedRouteOptions(model, options))
|
|
255
|
+
if (!supportedRouteOptions(model, options)) return;
|
|
265
256
|
const ticket = generation;
|
|
266
257
|
const startedAt = Date.now();
|
|
267
258
|
const sequence = ++requestSequence;
|
|
@@ -287,11 +278,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
287
278
|
const normalizedHeaders = (headers: ProviderHeaders | undefined) => Object.entries(headers ?? {}).map(([name, value]) => [name.toLowerCase(), value]).sort(([a], [b]) => String(a).localeCompare(String(b)));
|
|
288
279
|
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 ?? {})) {
|
|
289
280
|
active = undefined; cache = undefined;
|
|
290
|
-
captureRejected("resolved auth/route differs from prepared request, or generic policy changed");
|
|
291
281
|
return payload;
|
|
292
282
|
}
|
|
293
283
|
const provider = owner.modelRegistry.getProvider(model.provider);
|
|
294
|
-
if (!provider)
|
|
284
|
+
if (!provider) return payload;
|
|
295
285
|
const routeHash = route(model, options);
|
|
296
286
|
if (config.cachedSummaries && supportsCachedSummary(model)) {
|
|
297
287
|
const warming = options.maxTokens === 1 && cache && cache.authHash === routeDigest(auth) && sameCachedPrompt(cache.payload, payload) && equal(cache.context, context);
|
|
@@ -301,8 +291,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
301
291
|
}, payload);
|
|
302
292
|
if (!warming) cache = undefined;
|
|
303
293
|
}
|
|
304
|
-
if (!supportsNativeCompaction(model))
|
|
305
|
-
const prepared = capturePreparedRequest(model, context, canonical, payload
|
|
294
|
+
if (!supportsNativeCompaction(model)) return payload;
|
|
295
|
+
const prepared = capturePreparedRequest(model, context, canonical, payload);
|
|
306
296
|
if (!prepared) { active = undefined; return payload; }
|
|
307
297
|
const policyHash = digest(requestPolicy(model, prepared));
|
|
308
298
|
const latest = latestCompaction(branch);
|
|
@@ -325,7 +315,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
325
315
|
} else active = undefined;
|
|
326
316
|
} else active = undefined;
|
|
327
317
|
} else active = undefined;
|
|
328
|
-
captureStatus = "eligible prepared request captured";
|
|
329
318
|
snapshot = {
|
|
330
319
|
prepared, model: structuredClone(model), provider,
|
|
331
320
|
options: { apiKey: options.apiKey, headers: structuredClone(options.headers), env: structuredClone(options.env), fetch: options.fetch, ...(typeof (options as { serviceTier?: unknown }).serviceTier === "string" ? { serviceTier: (options as { serviceTier: string }).serviceTier } : {}) },
|
|
@@ -346,42 +335,51 @@ export default function (pi: ExtensionAPI): void {
|
|
|
346
335
|
|
|
347
336
|
const install = (context: ExtensionContext) => {
|
|
348
337
|
ctx = context;
|
|
349
|
-
if (observer?.provider === context.model?.provider && observer?.isInstalled()) return;
|
|
338
|
+
if (observer?.provider === context.model?.provider && observer?.api === context.model?.api && observer?.isInstalled()) return;
|
|
350
339
|
if (observer) { invalidate(); observer.remove(); observer = undefined; }
|
|
351
340
|
if (!supportsNativeCompaction(context.model) && !supportsCachedSummary(context.model)) return;
|
|
352
341
|
observer = installObserver(pi, context, observe);
|
|
353
|
-
captureStatus = observer ? "awaiting prepared request" : "unsupported provider registration";
|
|
354
342
|
};
|
|
355
343
|
pi.on("session_start", (_event, context) => {
|
|
356
|
-
retire();
|
|
344
|
+
retire();
|
|
345
|
+
usageObservers.dispose();
|
|
346
|
+
usageObservers = serveUsageObservers(pi, () => ctx);
|
|
347
|
+
install(context);
|
|
348
|
+
usageObservers.ready(context);
|
|
357
349
|
const sessionId = context.sessionManager.getSessionId();
|
|
358
350
|
// AgentSession.dispose() cleans session resources even without emitting
|
|
359
351
|
// session_shutdown. The captured registry keeps owned cleanup usable.
|
|
360
352
|
unregisterCleanup = registerSessionResourceCleanup((disposedId) => {
|
|
361
|
-
if (disposedId === undefined || disposedId === sessionId) retire();
|
|
353
|
+
if (disposedId === undefined || disposedId === sessionId) { retire(); usageObservers.dispose(); }
|
|
362
354
|
});
|
|
363
355
|
const latest = latestCompaction(context.sessionManager.getBranch());
|
|
364
356
|
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");
|
|
365
357
|
});
|
|
366
|
-
pi.on("before_agent_start", (_event, context) => { install(context); });
|
|
367
|
-
pi.on("model_select", (_event, context) => { invalidate(); install(context); });
|
|
358
|
+
pi.on("before_agent_start", (_event, context) => { install(context); usageObservers.ready(context); });
|
|
359
|
+
pi.on("model_select", (_event, context) => { invalidate(); install(context); usageObservers.ready(context); });
|
|
368
360
|
pi.on("session_tree", () => { invalidate(); });
|
|
369
|
-
pi.on("session_shutdown", retire);
|
|
361
|
+
pi.on("session_shutdown", () => { retire(); usageObservers.dispose(); });
|
|
370
362
|
// Privacy-policy owners must invalidate before changing a policy that can affect
|
|
371
363
|
// hidden covered history. Arbitrary dynamic privacy compositions are unsupported.
|
|
372
364
|
const removeInvalidation = pi.events.on("pi-provider-compaction:invalidate", invalidate);
|
|
373
365
|
pi.on("session_shutdown", () => { if (typeof removeInvalidation === "function") removeInvalidation(); });
|
|
374
366
|
|
|
375
367
|
pi.on("session_before_compact", async (event, context) => {
|
|
376
|
-
const began = Date.now();
|
|
377
|
-
|
|
368
|
+
const began = Date.now(), ticket = generation;
|
|
369
|
+
const sessionId = context.sessionManager.getSessionId();
|
|
378
370
|
fallbackReason = undefined;
|
|
379
371
|
const attempt = expiryAttempt;
|
|
380
372
|
const capture = snapshot;
|
|
381
373
|
const cached = cacheState(context).available ? cache : undefined;
|
|
382
374
|
snapshot = undefined; cache = undefined; pending = undefined;
|
|
383
375
|
const cancelled = (usage?: Usage, reason = "compaction ownership or input changed") => {
|
|
384
|
-
|
|
376
|
+
// A cancelled attempt has no checkpoint to carry its known bill. Retain
|
|
377
|
+
// that usage in the existing session diagnostic, not a command-only cache.
|
|
378
|
+
if (usage && ticket === generation && ctx) {
|
|
379
|
+
try {
|
|
380
|
+
if (ctx.sessionManager.getSessionId() === sessionId) pi.appendEntry("pi-provider-compaction:diagnostic", { status: `${attempt ? "expiry cancelled" : "cancelled"}: ${reason}`, usage });
|
|
381
|
+
} catch { /* A disposed session cannot accept a diagnostic. */ }
|
|
382
|
+
}
|
|
385
383
|
return { cancel: true as const };
|
|
386
384
|
};
|
|
387
385
|
const reject = (reason: string) => {
|
|
@@ -395,7 +393,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
395
393
|
attempt.signal = event.signal;
|
|
396
394
|
}
|
|
397
395
|
if ((!capture && !cached) || !context.model || generic(context) || event.signal.aborted) return reject("no eligible snapshot, explicit model override, or aborted attempt");
|
|
398
|
-
const ticket = generation;
|
|
399
396
|
const branch = context.sessionManager.getBranch();
|
|
400
397
|
const branchHash = digest(branch);
|
|
401
398
|
const source = cached ?? capture!;
|
|
@@ -418,22 +415,20 @@ export default function (pi: ExtensionAPI): void {
|
|
|
418
415
|
let summaryPath = "standard";
|
|
419
416
|
let summaryReason: string | undefined;
|
|
420
417
|
if (cached) {
|
|
421
|
-
|
|
422
|
-
summarizing = true;
|
|
418
|
+
compactionRequest = true;
|
|
423
419
|
try {
|
|
424
420
|
const result = await cachedSummary(cached, context, event.preparation, event.customInstructions, event.signal, current);
|
|
425
421
|
portable = result.result;
|
|
426
422
|
attemptedUsage = result.usage;
|
|
427
423
|
summaryReason = result.reason;
|
|
428
424
|
if (portable) summaryPath = "cached";
|
|
429
|
-
} finally {
|
|
425
|
+
} finally { compactionRequest = false; }
|
|
430
426
|
if (!current()) return cancelled(attemptedUsage);
|
|
431
427
|
}
|
|
432
428
|
if (!portable) {
|
|
433
429
|
if (attempt) return cancelled(attemptedUsage, summaryReason ?? "warm cache unavailable");
|
|
434
430
|
// Without native coverage or a dispatched cached attempt, Pi can own fallback.
|
|
435
431
|
if (!input && !attemptedUsage) return reject(summaryReason ?? fallbackReason ?? "native coverage unavailable");
|
|
436
|
-
compactionStatus = "generating portable summary";
|
|
437
432
|
try {
|
|
438
433
|
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);
|
|
439
434
|
portable = withFileTracking(portable, event.preparation);
|
|
@@ -450,25 +445,33 @@ export default function (pi: ExtensionAPI): void {
|
|
|
450
445
|
return { compaction: result! };
|
|
451
446
|
};
|
|
452
447
|
if (!capture || !protocol || !input) {
|
|
453
|
-
compactionStatus = `${summaryPath} portable summary prepared`;
|
|
454
448
|
return outcome(summaryPath, portable, summaryReason);
|
|
455
449
|
}
|
|
456
450
|
try {
|
|
457
451
|
const freshAuth = await context.modelRegistry.getApiKeyAndHeaders(context.model);
|
|
458
452
|
if (!current()) return cancelled(portable.usage);
|
|
459
453
|
if (!freshAuth.ok || routeDigest(freshAuth) !== capture.authHash) { fallback("auth changed after portable generation"); return outcome(summaryPath, portable, "native auth changed"); }
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
454
|
+
let native: Awaited<ReturnType<typeof requestProviderCompaction>>;
|
|
455
|
+
compactionRequest = true;
|
|
456
|
+
try {
|
|
457
|
+
native = await requestProviderCompaction({
|
|
458
|
+
provider: capture.provider, model: capture.model, context: capture.prepared.context,
|
|
459
|
+
input, preparedPayload: capture.prepared.payload, protocol,
|
|
460
|
+
apiKey: capture.options.apiKey, headers: capture.options.headers, env: capture.options.env,
|
|
461
|
+
signal: event.signal, isCurrent: current, fetch: capture.options.fetch, sessionId: source.sessionId,
|
|
462
|
+
...(capture.options.serviceTier === undefined ? {} : { serviceTier: capture.options.serviceTier }),
|
|
463
|
+
});
|
|
464
|
+
} finally { compactionRequest = false; }
|
|
468
465
|
if (!current()) return cancelled(combineUsage(native.usage, portable.usage));
|
|
469
466
|
const finalAuth = await context.modelRegistry.getApiKeyAndHeaders(context.model);
|
|
470
467
|
if (!current()) return cancelled(combineUsage(native.usage, portable.usage));
|
|
471
|
-
if (!finalAuth.ok || routeDigest(finalAuth) !== capture.authHash) {
|
|
468
|
+
if (!finalAuth.ok || routeDigest(finalAuth) !== capture.authHash) {
|
|
469
|
+
// The checkpoint is no longer safe to commit, but both requests
|
|
470
|
+
// completed and their known usage still belongs in the session.
|
|
471
|
+
portable.usage = combineUsage(native.usage, portable.usage);
|
|
472
|
+
fallback("auth changed after native generation");
|
|
473
|
+
return outcome(summaryPath, portable, "native auth changed");
|
|
474
|
+
}
|
|
472
475
|
const details: NativeCompactionDetails = {
|
|
473
476
|
...(isObject(portable.details) ? portable.details : {}),
|
|
474
477
|
type: NATIVE_COMPACTION_TYPE, version: 3, provider: capture.model.provider, api: capture.model.api, model: capture.model.id,
|
|
@@ -489,13 +492,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
489
492
|
pi.on("session_compact", (event, context) => {
|
|
490
493
|
const recorded = isObject(event.compactionEntry.details) ? event.compactionEntry.details.providerCompaction : undefined;
|
|
491
494
|
const owned = isObject(recorded) && equal(recorded, lastOutcome);
|
|
492
|
-
if (readNativeCompactionDetails(event.compactionEntry.details)?.version
|
|
493
|
-
else if (fallbackReason) compactionStatus = `portable: ${fallbackReason}`;
|
|
494
|
-
else if (owned) compactionStatus = `${lastOutcome!.path} portable summary committed`;
|
|
495
|
-
else fallback("another handler supplied the committed result");
|
|
495
|
+
if (!owned && !fallbackReason && readNativeCompactionDetails(event.compactionEntry.details)?.version !== 3) fallback("another handler supplied the committed result");
|
|
496
496
|
if (owned) {
|
|
497
|
-
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};
|
|
498
|
-
}
|
|
497
|
+
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");
|
|
498
|
+
}
|
|
499
499
|
// Only static diagnostics are persisted, never request/auth data or error text.
|
|
500
500
|
if (fallbackReason) pi.appendEntry("pi-provider-compaction:diagnostic", { status: `portable: ${fallbackReason}` });
|
|
501
501
|
active = undefined; snapshot = undefined; cache = undefined;
|
|
@@ -515,5 +515,5 @@ export default function (pi: ExtensionAPI): void {
|
|
|
515
515
|
...(typeof candidate.capture.prepared.payload.model === "string" ? { payloadModel: candidate.capture.prepared.payload.model } : {}),
|
|
516
516
|
};
|
|
517
517
|
});
|
|
518
|
-
pi.on("session_compact_failed", () => { pending = undefined; snapshot = undefined; cache = undefined;
|
|
518
|
+
pi.on("session_compact_failed", () => { pending = undefined; snapshot = undefined; cache = undefined; });
|
|
519
519
|
}
|
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.11",
|
|
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 cached-summary.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,6 +39,7 @@
|
|
|
39
39
|
"protocol.ts",
|
|
40
40
|
"policy.ts",
|
|
41
41
|
"provider.ts",
|
|
42
|
+
"usage-observer.ts",
|
|
42
43
|
"replay.ts",
|
|
43
44
|
"cached-summary.ts",
|
|
44
45
|
"README.md",
|
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 };
|
package/protocol.ts
CHANGED
|
@@ -208,7 +208,7 @@ async function collectProviderUsage(stream: AssistantMessageEventStream, signal:
|
|
|
208
208
|
let usage: Usage | undefined;
|
|
209
209
|
for await (const event of stream) {
|
|
210
210
|
if (signal.aborted) throw new DOMException("Compaction aborted", "AbortError");
|
|
211
|
-
if (event.type === "error") throw new
|
|
211
|
+
if (event.type === "error") throw new NativeCompactionValidationError("Native compaction provider stream failed", event.error.usage);
|
|
212
212
|
if (event.type === "done") usage = event.message.usage;
|
|
213
213
|
}
|
|
214
214
|
if (!usage) throw new Error("Native compaction provider stream ended without usage");
|
|
@@ -227,6 +227,7 @@ interface TransportRequest {
|
|
|
227
227
|
env?: Record<string, string>;
|
|
228
228
|
/** Cost-relevant request option captured with the original request. */
|
|
229
229
|
serviceTier?: string;
|
|
230
|
+
sessionId?: string;
|
|
230
231
|
signal: AbortSignal;
|
|
231
232
|
/** Revalidate request ownership after asynchronous provider preparation. */
|
|
232
233
|
isCurrent?: () => boolean;
|
|
@@ -252,7 +253,7 @@ export interface TransportResponse {
|
|
|
252
253
|
|
|
253
254
|
async function requestResponsesCompact(request: TransportRequest): Promise<TransportResponse> {
|
|
254
255
|
let preparedPayload: JsonObject | undefined;
|
|
255
|
-
let compacted:
|
|
256
|
+
let compacted: ResponseItem[] | undefined;
|
|
256
257
|
let bridgeError: unknown;
|
|
257
258
|
let dispatches = 0;
|
|
258
259
|
const baseFetch = request.fetch ?? globalThis.fetch;
|
|
@@ -274,13 +275,13 @@ async function requestResponsesCompact(request: TransportRequest): Promise<Trans
|
|
|
274
275
|
});
|
|
275
276
|
if (!response.ok) return response;
|
|
276
277
|
const parsed = await readJsonBounded(response, request.signal);
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
}
|
|
283
|
-
return
|
|
278
|
+
if (!isObject(parsed)) throw new Error("Responses Compact returned a non-object response");
|
|
279
|
+
// Let the SDK normalize and price reported usage even when the returned
|
|
280
|
+
// checkpoint is invalid. A synthetic HTTP error would discard that bill.
|
|
281
|
+
const completion = syntheticCompletion(parsed, preparedPayload);
|
|
282
|
+
try { compacted = validateCompactedOutput(parsed); }
|
|
283
|
+
catch (error) { bridgeError = new NativeCompactionValidationError(error instanceof Error ? error.message : String(error)); }
|
|
284
|
+
return completion;
|
|
284
285
|
} catch (error) {
|
|
285
286
|
bridgeError = error;
|
|
286
287
|
return errorResponse(error);
|
|
@@ -296,6 +297,7 @@ async function requestResponsesCompact(request: TransportRequest): Promise<Trans
|
|
|
296
297
|
transport: "sse",
|
|
297
298
|
cacheRetention: "none",
|
|
298
299
|
maxRetries: 0,
|
|
300
|
+
sessionId: request.sessionId,
|
|
299
301
|
fetch: bridgeFetch,
|
|
300
302
|
onPayload: (payload: unknown) => {
|
|
301
303
|
assertCurrent(request);
|
|
@@ -307,13 +309,13 @@ async function requestResponsesCompact(request: TransportRequest): Promise<Trans
|
|
|
307
309
|
});
|
|
308
310
|
|
|
309
311
|
const usage = await collectProviderUsage(stream, request.signal).catch((error) => {
|
|
310
|
-
if (bridgeError) throw
|
|
312
|
+
if (bridgeError) throw new NativeCompactionValidationError("Native compaction transport or validation failed", error instanceof NativeCompactionValidationError ? error.usage : undefined);
|
|
311
313
|
throw error;
|
|
312
314
|
});
|
|
313
315
|
if (bridgeError instanceof NativeCompactionValidationError) throw new NativeCompactionValidationError(bridgeError.message, usage);
|
|
314
316
|
if (bridgeError) throw bridgeError;
|
|
315
317
|
if (!compacted || dispatches !== 1) throw new Error("Responses Compact did not complete exactly one request");
|
|
316
|
-
return { output: compacted
|
|
318
|
+
return { output: compacted, usage };
|
|
317
319
|
}
|
|
318
320
|
|
|
319
321
|
interface CollectedSse {
|
|
@@ -438,6 +440,7 @@ async function requestRemoteV2(request: TransportRequest): Promise<TransportResp
|
|
|
438
440
|
transport: "sse",
|
|
439
441
|
cacheRetention: "none",
|
|
440
442
|
maxRetries: 0,
|
|
443
|
+
sessionId: request.sessionId,
|
|
441
444
|
fetch: inspectedFetch,
|
|
442
445
|
onPayload: (payload: unknown) => {
|
|
443
446
|
assertCurrent(request);
|
|
@@ -449,9 +452,9 @@ async function requestRemoteV2(request: TransportRequest): Promise<TransportResp
|
|
|
449
452
|
});
|
|
450
453
|
try {
|
|
451
454
|
const usage = await collectProviderUsage(stream, request.signal);
|
|
452
|
-
if (!sentInput || inspections.length !== 1) throw new
|
|
455
|
+
if (!sentInput || inspections.length !== 1) throw new NativeCompactionValidationError(`Remote V2 observed ${inspections.length} successful provider responses; expected exactly one`, usage);
|
|
453
456
|
const inspection = await inspections[0]!;
|
|
454
|
-
if ("error" in inspection) throw
|
|
457
|
+
if ("error" in inspection) throw new NativeCompactionValidationError("Remote V2 checkpoint validation failed", usage);
|
|
455
458
|
return { output: buildRemoteV2History(sentInput, inspection.result.item), usage };
|
|
456
459
|
} finally {
|
|
457
460
|
controller.abort();
|