@diegopetrucci/pi-extensions 0.1.72 → 0.1.73
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 +2 -2
- package/extensions/agent-workflow-audit/index.ts +41 -8
- package/extensions/agent-workflow-audit/package.json +1 -1
- package/extensions/annotate-git-diff/README.md +7 -3
- package/extensions/annotate-git-diff/git.ts +3 -2
- package/extensions/annotate-git-diff/index.ts +366 -81
- package/extensions/annotate-git-diff/package.json +2 -2
- package/extensions/annotate-git-diff/quiet-glimpse.ts +4 -2
- package/extensions/annotate-git-diff/types.ts +6 -0
- package/extensions/annotate-git-diff/web/app.js +54 -4
- package/extensions/annotate-git-diff/web/index.html +2 -2
- package/extensions/annotate-last-message/README.md +9 -1
- package/extensions/annotate-last-message/index.ts +53 -13
- package/extensions/annotate-last-message/package.json +2 -1
- package/extensions/annotate-last-message/quiet-glimpse.ts +4 -2
- package/extensions/annotate-last-message/theme.ts +247 -0
- package/extensions/annotate-last-message/ui.ts +28 -2
- package/extensions/annotate-last-message/web/app.js +351 -162
- package/extensions/annotate-last-message/web/index.html +153 -24
- package/extensions/annotate-last-message/web/md-renderer.js +458 -0
- package/extensions/code-reviewer/index.ts +12 -1
- package/extensions/code-reviewer/package.json +2 -1
- package/extensions/context-cap/README.md +33 -4
- package/extensions/context-cap/index.ts +271 -68
- package/extensions/context-cap/package.json +1 -1
- package/extensions/context-inspector/README.md +2 -0
- package/extensions/context-inspector/index.ts +90 -9
- package/extensions/context-inspector/package.json +1 -1
- package/extensions/contrarian/index.ts +21 -15
- package/extensions/contrarian/package.json +1 -1
- package/extensions/dynamic-context-pruning/README.md +17 -5
- package/extensions/dynamic-context-pruning/index.ts +17 -4
- package/extensions/dynamic-context-pruning/package.json +1 -1
- package/extensions/fast/README.md +24 -3
- package/extensions/fast/index.ts +2 -2
- package/extensions/fast/package.json +1 -1
- package/extensions/git-footer/README.md +1 -0
- package/extensions/git-footer/package.json +1 -1
- package/extensions/librarian/index.ts +7 -0
- package/extensions/librarian/package.json +2 -1
- package/extensions/minimal-footer/README.md +16 -4
- package/extensions/minimal-footer/index.ts +263 -14
- package/extensions/minimal-footer/openai-usage.ts +62 -8
- package/extensions/minimal-footer/package.json +1 -1
- package/extensions/oracle/index.ts +19 -18
- package/extensions/oracle/package.json +1 -1
- package/extensions/permission-gate/README.md +6 -0
- package/extensions/permission-gate/index.ts +37 -16
- package/extensions/permission-gate/package.json +1 -1
- package/extensions/quiet-tools/README.md +1 -1
- package/extensions/quiet-tools/package.json +1 -1
- package/extensions/triage-comments/package.json +2 -1
- package/package.json +4 -4
|
@@ -2,117 +2,285 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
2
2
|
import type { Model, Api } from "@earendil-works/pi-ai";
|
|
3
3
|
|
|
4
4
|
const DEFAULT_MAX_CONTEXT_WINDOW = 200_000;
|
|
5
|
+
const DEFAULT_RESERVE_TOKENS = 16_384;
|
|
6
|
+
const PROCESS_STATE_KEY = Symbol.for("pi-extensions.context-cap.model-holds.v1");
|
|
5
7
|
type AnyModel = Model<Api> | Model<any>;
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
+
type ModelWindow = number | undefined;
|
|
10
|
+
type HolderToken = object;
|
|
11
|
+
type ModelHold = {
|
|
12
|
+
original: number;
|
|
13
|
+
cap: number;
|
|
14
|
+
holders: Set<HolderToken>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type GlobalSymbolTable = typeof globalThis & { [key: symbol]: unknown };
|
|
18
|
+
|
|
19
|
+
// Pi can load the same extension source once per session. Keep coordination in
|
|
20
|
+
// a Symbol.for slot so separately loaded module copies share one WeakMap, while
|
|
21
|
+
// the WeakMap itself does not retain model objects after every holder releases.
|
|
22
|
+
function getProcessModelHolds(): WeakMap<AnyModel, ModelHold> {
|
|
23
|
+
const globalObject = globalThis as GlobalSymbolTable;
|
|
24
|
+
const existing = globalObject[PROCESS_STATE_KEY];
|
|
25
|
+
if (existing instanceof WeakMap) return existing as WeakMap<AnyModel, ModelHold>;
|
|
9
26
|
|
|
27
|
+
const created = new WeakMap<AnyModel, ModelHold>();
|
|
28
|
+
globalObject[PROCESS_STATE_KEY] = created;
|
|
29
|
+
return created;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const processModelHolds = getProcessModelHolds();
|
|
33
|
+
|
|
34
|
+
type CapReason = "virtual" | "unknown" | "reserve" | "modelOverride" | "settingsUnavailable";
|
|
10
35
|
type ApplyResult = {
|
|
11
36
|
changed: boolean;
|
|
12
37
|
key: string;
|
|
13
|
-
original
|
|
14
|
-
effective
|
|
38
|
+
original?: number;
|
|
39
|
+
effective?: number;
|
|
40
|
+
reserveTokens?: number;
|
|
41
|
+
holders?: number;
|
|
42
|
+
reason?: CapReason;
|
|
15
43
|
};
|
|
44
|
+
type CapGuard =
|
|
45
|
+
| { allowed: true }
|
|
46
|
+
| { allowed: false; reason: Exclude<CapReason, "virtual" | "unknown">; reserveTokens?: number };
|
|
16
47
|
|
|
17
48
|
function modelKey(model: AnyModel): string {
|
|
18
49
|
return `${model.provider}/${model.id}`;
|
|
19
50
|
}
|
|
20
51
|
|
|
21
|
-
function
|
|
22
|
-
|
|
23
|
-
|
|
52
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
53
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
54
|
+
}
|
|
24
55
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
return model.contextWindow;
|
|
56
|
+
function isKnownContextWindow(value: unknown): value is number {
|
|
57
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
|
28
58
|
}
|
|
29
59
|
|
|
30
|
-
function
|
|
31
|
-
return
|
|
60
|
+
function isNonNegativeSafeInteger(value: unknown): value is number {
|
|
61
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
32
62
|
}
|
|
33
63
|
|
|
34
|
-
function
|
|
35
|
-
|
|
64
|
+
function isVirtualModel(model: AnyModel): boolean {
|
|
65
|
+
return (model as AnyModel & { api?: string }).api === "pi-virtual";
|
|
66
|
+
}
|
|
36
67
|
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
68
|
+
function readCapGuard(pi: ExtensionAPI, model: AnyModel): CapGuard {
|
|
69
|
+
const getSettings = (pi as ExtensionAPI & { getSettings?: () => unknown }).getSettings;
|
|
70
|
+
// Pi before getSettings existed has no way to expose the effective
|
|
71
|
+
// compaction settings. Preserve the older-host cap behavior there.
|
|
72
|
+
if (typeof getSettings !== "function") return { allowed: true };
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const settings = getSettings.call(pi);
|
|
76
|
+
if (!isRecord(settings)) return { allowed: false, reason: "settingsUnavailable" };
|
|
77
|
+
const compactionValue = settings.compaction;
|
|
78
|
+
if (compactionValue === undefined) return { allowed: true };
|
|
79
|
+
if (!isRecord(compactionValue)) return { allowed: false, reason: "settingsUnavailable" };
|
|
80
|
+
|
|
81
|
+
const overridesValue = compactionValue.modelOverrides;
|
|
82
|
+
if (overridesValue !== undefined && !isRecord(overridesValue)) {
|
|
83
|
+
return { allowed: false, reason: "settingsUnavailable" };
|
|
84
|
+
}
|
|
85
|
+
if (overridesValue && Object.prototype.hasOwnProperty.call(overridesValue, modelKey(model))) {
|
|
86
|
+
return { allowed: false, reason: "modelOverride" };
|
|
87
|
+
}
|
|
42
88
|
|
|
43
|
-
|
|
89
|
+
const ordinaryReserve = compactionValue.reserveTokens;
|
|
90
|
+
if (ordinaryReserve !== undefined && !isNonNegativeSafeInteger(ordinaryReserve)) {
|
|
91
|
+
return { allowed: false, reason: "settingsUnavailable" };
|
|
92
|
+
}
|
|
93
|
+
const reserveTokens = ordinaryReserve ?? DEFAULT_RESERVE_TOKENS;
|
|
94
|
+
return reserveTokens >= DEFAULT_MAX_CONTEXT_WINDOW
|
|
95
|
+
? { allowed: false, reason: "reserve", reserveTokens }
|
|
96
|
+
: { allowed: true };
|
|
97
|
+
} catch {
|
|
98
|
+
// A host settings implementation that cannot return a coherent snapshot
|
|
99
|
+
// must not be guessed at: leave the model unmodified and report why.
|
|
100
|
+
return { allowed: false, reason: "settingsUnavailable" };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function originalContextWindow(model: AnyModel): ModelWindow {
|
|
105
|
+
const hold = processModelHolds.get(model);
|
|
106
|
+
return hold?.original ?? (isKnownContextWindow(model.contextWindow) ? model.contextWindow : undefined);
|
|
44
107
|
}
|
|
45
108
|
|
|
46
|
-
function
|
|
47
|
-
if (!model) return false;
|
|
109
|
+
function releaseContextCap(model: AnyModel | undefined, holderToken: HolderToken, heldModels: Set<AnyModel>): boolean {
|
|
110
|
+
if (!model || !heldModels.delete(model)) return false;
|
|
48
111
|
|
|
49
|
-
const
|
|
50
|
-
if (
|
|
112
|
+
const hold = processModelHolds.get(model);
|
|
113
|
+
if (!hold) return false;
|
|
114
|
+
hold.holders.delete(holderToken);
|
|
115
|
+
if (hold.holders.size > 0) return false;
|
|
51
116
|
|
|
52
|
-
model
|
|
117
|
+
processModelHolds.delete(model);
|
|
118
|
+
// Do not overwrite a value changed by the host or another extension while
|
|
119
|
+
// this holder was active. Equality is the only safe signal available here.
|
|
120
|
+
if (model.contextWindow !== hold.cap) return false;
|
|
121
|
+
model.contextWindow = hold.original;
|
|
53
122
|
return true;
|
|
54
123
|
}
|
|
55
124
|
|
|
56
|
-
function
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
125
|
+
function applyContextCap(
|
|
126
|
+
pi: ExtensionAPI,
|
|
127
|
+
model: AnyModel | undefined,
|
|
128
|
+
holderToken: HolderToken,
|
|
129
|
+
heldModels: Set<AnyModel>,
|
|
130
|
+
): ApplyResult | undefined {
|
|
131
|
+
if (!model) return undefined;
|
|
132
|
+
|
|
133
|
+
const key = modelKey(model);
|
|
134
|
+
if (isVirtualModel(model)) {
|
|
135
|
+
return { changed: false, key, reason: "virtual" };
|
|
63
136
|
}
|
|
64
|
-
}
|
|
65
137
|
|
|
66
|
-
|
|
67
|
-
|
|
138
|
+
const hold = processModelHolds.get(model);
|
|
139
|
+
if (!isKnownContextWindow(model.contextWindow) && !hold) {
|
|
140
|
+
return { changed: false, key, reason: "unknown" };
|
|
141
|
+
}
|
|
68
142
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
143
|
+
const original = hold?.original ?? model.contextWindow;
|
|
144
|
+
if (!isKnownContextWindow(original)) {
|
|
145
|
+
return { changed: false, key, reason: "unknown" };
|
|
146
|
+
}
|
|
147
|
+
const effective = hold?.cap ?? Math.min(original, DEFAULT_MAX_CONTEXT_WINDOW);
|
|
148
|
+
if (!hold && original <= DEFAULT_MAX_CONTEXT_WINDOW) {
|
|
149
|
+
return { changed: false, key, original, effective };
|
|
150
|
+
}
|
|
72
151
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
152
|
+
const guard = readCapGuard(pi, model);
|
|
153
|
+
if (!guard.allowed) {
|
|
154
|
+
if (heldModels.has(model)) releaseContextCap(model, holderToken, heldModels);
|
|
155
|
+
return {
|
|
156
|
+
changed: false,
|
|
157
|
+
key,
|
|
158
|
+
original,
|
|
159
|
+
effective,
|
|
160
|
+
reserveTokens: guard.reserveTokens,
|
|
161
|
+
reason: guard.reason,
|
|
162
|
+
holders: hold?.holders.size,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
76
165
|
|
|
77
|
-
|
|
78
|
-
|
|
166
|
+
const activeHold = hold ?? { original, cap: effective, holders: new Set<HolderToken>() };
|
|
167
|
+
if (!hold) processModelHolds.set(model, activeHold);
|
|
168
|
+
const alreadyHeld = heldModels.has(model);
|
|
169
|
+
if (!alreadyHeld) {
|
|
170
|
+
activeHold.holders.add(holderToken);
|
|
171
|
+
heldModels.add(model);
|
|
172
|
+
}
|
|
79
173
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
174
|
+
// Only the first holder writes the cap. If the host changed a shared model
|
|
175
|
+
// after that, later holders join the reference count without clobbering it.
|
|
176
|
+
const changed = !hold && model.contextWindow !== activeHold.cap;
|
|
177
|
+
if (changed) model.contextWindow = activeHold.cap;
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
changed,
|
|
181
|
+
key,
|
|
182
|
+
original: activeHold.original,
|
|
183
|
+
effective: activeHold.cap,
|
|
184
|
+
holders: activeHold.holders.size,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
83
187
|
|
|
84
|
-
|
|
188
|
+
function restoreTouchedModels(holderToken: HolderToken, heldModels: Set<AnyModel>): number {
|
|
189
|
+
let changed = 0;
|
|
190
|
+
for (const model of [...heldModels]) {
|
|
191
|
+
if (releaseContextCap(model, holderToken, heldModels)) changed++;
|
|
192
|
+
}
|
|
85
193
|
return changed;
|
|
86
194
|
}
|
|
87
195
|
|
|
196
|
+
function applyContextCapToSession(
|
|
197
|
+
pi: ExtensionAPI,
|
|
198
|
+
ctx: ExtensionContext,
|
|
199
|
+
holderToken: HolderToken,
|
|
200
|
+
heldModels: Set<AnyModel>,
|
|
201
|
+
): number {
|
|
202
|
+
return applyContextCap(pi, ctx.model, holderToken, heldModels)?.changed ? 1 : 0;
|
|
203
|
+
}
|
|
204
|
+
|
|
88
205
|
function formatTokens(tokens: number): string {
|
|
89
206
|
return tokens >= 1000 ? `${Math.round(tokens / 1000)}k` : String(tokens);
|
|
90
207
|
}
|
|
91
208
|
|
|
209
|
+
function formatWindow(window: ModelWindow): string {
|
|
210
|
+
return isKnownContextWindow(window) ? formatTokens(window) : "unknown";
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function unavailableReason(result: ApplyResult | undefined): string | undefined {
|
|
214
|
+
if (result?.reason === "virtual") {
|
|
215
|
+
return "virtual selection; routed physical limit is unavailable to this extension";
|
|
216
|
+
}
|
|
217
|
+
if (result?.reason === "unknown") {
|
|
218
|
+
return "physical model has no compatible context-window limit";
|
|
219
|
+
}
|
|
220
|
+
if (result?.reason === "reserve") {
|
|
221
|
+
return `compaction reserveTokens (${formatTokens(result.reserveTokens ?? DEFAULT_MAX_CONTEXT_WINDOW)}) is at least 200k; the auto-compaction threshold would be zero or negative`;
|
|
222
|
+
}
|
|
223
|
+
if (result?.reason === "modelOverride") {
|
|
224
|
+
return `compaction.modelOverrides[\"${result.key}\"] is configured; leaving the model window unchanged`;
|
|
225
|
+
}
|
|
226
|
+
if (result?.reason === "settingsUnavailable") {
|
|
227
|
+
return "compaction settings could not be resolved safely";
|
|
228
|
+
}
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function holdStatus(model: AnyModel, heldModels: Set<AnyModel>): string {
|
|
233
|
+
const hold = processModelHolds.get(model);
|
|
234
|
+
if (!hold) return " No process-wide context-cap hold is active.";
|
|
235
|
+
|
|
236
|
+
const otherHolders = Math.max(hold.holders.size - (heldModels.has(model) ? 1 : 0), 0);
|
|
237
|
+
const holderWord = otherHolders === 1 ? "holder" : "holders";
|
|
238
|
+
const holderVerb = otherHolders === 1 ? "holds" : "hold";
|
|
239
|
+
return ` Process-wide cap holders: ${hold.holders.size}; ${otherHolders} other ${holderWord} still ${holderVerb} the cap.`;
|
|
240
|
+
}
|
|
241
|
+
|
|
92
242
|
export default function contextCapExtension(pi: ExtensionAPI) {
|
|
93
243
|
let enabled = true;
|
|
244
|
+
const holderToken: HolderToken = {};
|
|
245
|
+
const heldModels = new Set<AnyModel>();
|
|
94
246
|
|
|
95
247
|
pi.on("session_start", async (_event, ctx) => {
|
|
96
|
-
|
|
248
|
+
// A reload/session replacement can reuse model objects. Release only this
|
|
249
|
+
// extension instance's holds before capturing the new active model.
|
|
250
|
+
restoreTouchedModels(holderToken, heldModels);
|
|
251
|
+
if (enabled) applyContextCapToSession(pi, ctx, holderToken, heldModels);
|
|
97
252
|
});
|
|
98
253
|
|
|
99
254
|
pi.on("model_select", async (event, ctx) => {
|
|
255
|
+
// Selection changes are the narrowest reliable boundary available to an
|
|
256
|
+
// extension. Release this instance's previous handle before applying the
|
|
257
|
+
// new physical model, and never infer a virtual route from its metadata.
|
|
258
|
+
releaseContextCap(event.previousModel, holderToken, heldModels);
|
|
100
259
|
if (!enabled) return;
|
|
101
260
|
|
|
102
|
-
const result = applyContextCap(event.model);
|
|
261
|
+
const result = applyContextCap(pi, event.model, holderToken, heldModels);
|
|
103
262
|
if (!result || !ctx.hasUI) return;
|
|
104
263
|
|
|
264
|
+
const unavailable = unavailableReason(result);
|
|
105
265
|
ctx.ui.setStatus(
|
|
106
266
|
"context-cap",
|
|
107
|
-
|
|
108
|
-
?
|
|
109
|
-
: undefined
|
|
267
|
+
unavailable
|
|
268
|
+
? "ctx cap unavailable"
|
|
269
|
+
: result.original !== undefined && result.original > DEFAULT_MAX_CONTEXT_WINDOW
|
|
270
|
+
? `ctx cap ${formatTokens(result.effective ?? result.original)}/${formatTokens(result.original)}`
|
|
271
|
+
: undefined,
|
|
110
272
|
);
|
|
111
273
|
});
|
|
112
274
|
|
|
113
|
-
pi.on("
|
|
114
|
-
|
|
115
|
-
|
|
275
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
276
|
+
// Branch changes do not expose a different routed model. Reapply only to
|
|
277
|
+
// the active physical handle after releasing this instance's old hold.
|
|
278
|
+
restoreTouchedModels(holderToken, heldModels);
|
|
279
|
+
if (enabled) applyContextCapToSession(pi, ctx, holderToken, heldModels);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
pi.on("session_shutdown", async (_event, _ctx) => {
|
|
283
|
+
restoreTouchedModels(holderToken, heldModels);
|
|
116
284
|
});
|
|
117
285
|
|
|
118
286
|
pi.registerCommand("context-cap", {
|
|
@@ -127,31 +295,45 @@ export default function contextCapExtension(pi: ExtensionAPI) {
|
|
|
127
295
|
|
|
128
296
|
if (action === "on" || action === "enable") {
|
|
129
297
|
enabled = true;
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
298
|
+
const result = applyContextCap(pi, ctx.model, holderToken, heldModels);
|
|
299
|
+
const changed = result?.changed ? 1 : 0;
|
|
300
|
+
const unavailable = unavailableReason(result);
|
|
301
|
+
ctx.ui.setStatus("context-cap", unavailable ? "ctx cap unavailable" : `ctx cap ${formatTokens(DEFAULT_MAX_CONTEXT_WINDOW)}`);
|
|
302
|
+
ctx.ui.notify(
|
|
303
|
+
unavailable
|
|
304
|
+
? `Context cap enabled, but unavailable: ${unavailable}.`
|
|
305
|
+
: `Context cap enabled (${changed} active model window(s) capped/restored).`,
|
|
306
|
+
"info",
|
|
307
|
+
);
|
|
133
308
|
return;
|
|
134
309
|
}
|
|
135
310
|
|
|
136
311
|
if (action === "off" || action === "disable") {
|
|
137
312
|
enabled = false;
|
|
138
|
-
const changed =
|
|
313
|
+
const changed = restoreTouchedModels(holderToken, heldModels);
|
|
139
314
|
ctx.ui.setStatus("context-cap", undefined);
|
|
140
|
-
ctx.ui.notify(`Context cap disabled for this extension session (${changed} model window(s) restored).`, "info");
|
|
315
|
+
ctx.ui.notify(`Context cap disabled for this extension session (${changed} active model window(s) restored).`, "info");
|
|
141
316
|
return;
|
|
142
317
|
}
|
|
143
318
|
|
|
144
319
|
if (action === "toggle") {
|
|
145
320
|
if (enabled) {
|
|
146
321
|
enabled = false;
|
|
147
|
-
const changed =
|
|
322
|
+
const changed = restoreTouchedModels(holderToken, heldModels);
|
|
148
323
|
ctx.ui.setStatus("context-cap", undefined);
|
|
149
|
-
ctx.ui.notify(`Context cap disabled for this extension session (${changed} model window(s) restored).`, "info");
|
|
324
|
+
ctx.ui.notify(`Context cap disabled for this extension session (${changed} active model window(s) restored).`, "info");
|
|
150
325
|
} else {
|
|
151
326
|
enabled = true;
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
327
|
+
const result = applyContextCap(pi, ctx.model, holderToken, heldModels);
|
|
328
|
+
const changed = result?.changed ? 1 : 0;
|
|
329
|
+
const unavailable = unavailableReason(result);
|
|
330
|
+
ctx.ui.setStatus("context-cap", unavailable ? "ctx cap unavailable" : `ctx cap ${formatTokens(DEFAULT_MAX_CONTEXT_WINDOW)}`);
|
|
331
|
+
ctx.ui.notify(
|
|
332
|
+
unavailable
|
|
333
|
+
? `Context cap enabled, but unavailable: ${unavailable}.`
|
|
334
|
+
: `Context cap enabled (${changed} active model window(s) capped/restored).`,
|
|
335
|
+
"info",
|
|
336
|
+
);
|
|
155
337
|
}
|
|
156
338
|
return;
|
|
157
339
|
}
|
|
@@ -164,10 +346,31 @@ export default function contextCapExtension(pi: ExtensionAPI) {
|
|
|
164
346
|
return;
|
|
165
347
|
}
|
|
166
348
|
|
|
167
|
-
|
|
168
|
-
|
|
349
|
+
if (isVirtualModel(model)) {
|
|
350
|
+
ctx.ui.notify(
|
|
351
|
+
`Context cap is ${status}. Current model: ${modelKey(model)} is a virtual selection; the routed physical limit is unavailable to this extension.`,
|
|
352
|
+
"info",
|
|
353
|
+
);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const hold = processModelHolds.get(model);
|
|
358
|
+
const original = originalContextWindow(model);
|
|
359
|
+
const shouldCheckGuard = enabled && (hold !== undefined || (isKnownContextWindow(model.contextWindow) && model.contextWindow > DEFAULT_MAX_CONTEXT_WINDOW));
|
|
360
|
+
const guard = shouldCheckGuard ? readCapGuard(pi, model) : { allowed: true as const };
|
|
361
|
+
const unavailable = !guard.allowed
|
|
362
|
+
? unavailableReason({
|
|
363
|
+
changed: false,
|
|
364
|
+
key: modelKey(model),
|
|
365
|
+
reason: guard.reason,
|
|
366
|
+
reserveTokens: guard.reserveTokens,
|
|
367
|
+
})
|
|
368
|
+
: undefined;
|
|
369
|
+
const prefix = unavailable
|
|
370
|
+
? `Context cap is ${status}, but unavailable: ${unavailable}.`
|
|
371
|
+
: `Context cap is ${status}.`;
|
|
169
372
|
ctx.ui.notify(
|
|
170
|
-
|
|
373
|
+
`${prefix} Current model: ${modelKey(model)} (${formatWindow(model.contextWindow)}/${formatWindow(original)} effective/original).${holdStatus(model, heldModels)}`,
|
|
171
374
|
"info",
|
|
172
375
|
);
|
|
173
376
|
return;
|
|
@@ -40,6 +40,8 @@ Options:
|
|
|
40
40
|
- The current-context chart reconciles to pi's footer-compatible context total when pi knows it.
|
|
41
41
|
- Immediately after compaction, pi may not know exact current context usage until the next model response.
|
|
42
42
|
- The full branch tab includes old pre-compaction history for inspection; that history is not necessarily sent verbatim to the model.
|
|
43
|
+
- When the selected model is virtual, the report labels it as a selection and shows the latest routed physical identity from an assistant response when available. It never treats virtual metadata as the provider's context limit; the context window is shown only from Pi's aggregate usage when known.
|
|
44
|
+
- Bounded `nestedCalls` metadata is noted on its parent tool result but is not expanded or counted as transcript output, because Pi does not record child results in the model context.
|
|
43
45
|
- `--keep` writes reports under `<pi-config-dir>/context-reports/`; avoid committing those reports because they can contain sensitive session data unless redacted.
|
|
44
46
|
|
|
45
47
|
## Install
|
|
@@ -122,11 +122,14 @@ type MinimalMessage = {
|
|
|
122
122
|
timestamp?: number;
|
|
123
123
|
provider?: string;
|
|
124
124
|
model?: string;
|
|
125
|
+
api?: string;
|
|
125
126
|
stopReason?: string;
|
|
126
127
|
usage?: unknown;
|
|
127
128
|
toolCallId?: string;
|
|
128
129
|
toolName?: string;
|
|
129
130
|
details?: unknown;
|
|
131
|
+
/** Pi keeps nested tool calls here as bounded metadata; they are not transcript content. */
|
|
132
|
+
nestedCalls?: unknown;
|
|
130
133
|
customType?: string;
|
|
131
134
|
display?: boolean;
|
|
132
135
|
summary?: string;
|
|
@@ -240,6 +243,11 @@ type Dataset = {
|
|
|
240
243
|
stats: DatasetStats;
|
|
241
244
|
};
|
|
242
245
|
|
|
246
|
+
type RoutedModel = {
|
|
247
|
+
provider: string;
|
|
248
|
+
id: string;
|
|
249
|
+
};
|
|
250
|
+
|
|
243
251
|
type ReportData = {
|
|
244
252
|
generatedAt: string;
|
|
245
253
|
cwd: string;
|
|
@@ -253,6 +261,8 @@ type ReportData = {
|
|
|
253
261
|
id?: string;
|
|
254
262
|
contextWindow?: number;
|
|
255
263
|
thinkingLevel?: string;
|
|
264
|
+
virtual?: boolean;
|
|
265
|
+
routed?: RoutedModel;
|
|
256
266
|
};
|
|
257
267
|
contextUsage: {
|
|
258
268
|
tokens: number | null;
|
|
@@ -371,6 +381,18 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
371
381
|
return typeof value === "object" && value !== null;
|
|
372
382
|
}
|
|
373
383
|
|
|
384
|
+
function isKnownPositiveNumber(value: unknown): value is number {
|
|
385
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function isKnownNonNegativeNumber(value: unknown): value is number {
|
|
389
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function isVirtualModel(model: unknown): boolean {
|
|
393
|
+
return isRecord(model) && model.api === "pi-virtual";
|
|
394
|
+
}
|
|
395
|
+
|
|
374
396
|
function getContentBlocks(content: unknown): MinimalContentBlock[] {
|
|
375
397
|
if (typeof content === "string") return [{ type: "text", text: content }];
|
|
376
398
|
if (!Array.isArray(content)) return [];
|
|
@@ -577,11 +599,22 @@ function analyzeAssistantContent(state: AnalyzerState, message: MinimalMessage,
|
|
|
577
599
|
}
|
|
578
600
|
}
|
|
579
601
|
|
|
602
|
+
function nestedCallsNote(message: MinimalMessage): string | undefined {
|
|
603
|
+
const nested = message.nestedCalls;
|
|
604
|
+
if (!isRecord(nested) || !Array.isArray(nested.calls)) return undefined;
|
|
605
|
+
const count = nested.calls.length;
|
|
606
|
+
if (nested.complete === false) {
|
|
607
|
+
return `Pi recorded ${count} bounded nested tool call${count === 1 ? "" : "s"} incompletely; nested results are not transcript context and are excluded from attribution.`;
|
|
608
|
+
}
|
|
609
|
+
return `Pi recorded ${count} bounded nested tool call${count === 1 ? "" : "s"}; nested results are not transcript context and are excluded from attribution.`;
|
|
610
|
+
}
|
|
611
|
+
|
|
580
612
|
function analyzeToolResult(state: AnalyzerState, message: MinimalMessage, entry?: MinimalEntry): void {
|
|
581
613
|
const timestamp = isoTimestamp(message, entry);
|
|
582
614
|
const entryId = entry?.id;
|
|
583
615
|
const toolName = message.toolName ?? "tool";
|
|
584
616
|
const blocks = getContentBlocks(message.content);
|
|
617
|
+
const nestedNote = nestedCallsNote(message);
|
|
585
618
|
for (const block of blocks) {
|
|
586
619
|
if (block.type === "text" && typeof block.text === "string") {
|
|
587
620
|
const noteParts: string[] = [];
|
|
@@ -589,6 +622,7 @@ function analyzeToolResult(state: AnalyzerState, message: MinimalMessage, entry?
|
|
|
589
622
|
if (isRecord(message.details.truncation) && message.details.truncation.truncated) noteParts.push("Result was truncated before entering context.");
|
|
590
623
|
if (typeof message.details.fullOutputPath === "string") noteParts.push(`Full output: ${message.details.fullOutputPath}`);
|
|
591
624
|
}
|
|
625
|
+
if (nestedNote) noteParts.push(nestedNote);
|
|
592
626
|
addTextSegment(state, "toolResults", `Tool result: ${toolName}`, block.text, {
|
|
593
627
|
source: "tool-result",
|
|
594
628
|
role: "toolResult",
|
|
@@ -801,10 +835,14 @@ function resolveSessionProjection(ctx: ExtensionCommandContext): MinimalSessionP
|
|
|
801
835
|
messages: candidate.messages,
|
|
802
836
|
});
|
|
803
837
|
}
|
|
838
|
+
// `entries` is the provenance-bearing canonical projection. Keep validating
|
|
839
|
+
// the optional flattened field for compatibility, but derive the list used by
|
|
840
|
+
// the report from entries so a stale/invented flattened list cannot reintroduce
|
|
841
|
+
// omitted history or nested metadata as transcript messages.
|
|
804
842
|
if ("messages" in projection && (!Array.isArray(projection.messages) || !projection.messages.every(isProjectionMessage))) return undefined;
|
|
805
843
|
return {
|
|
806
844
|
entries,
|
|
807
|
-
messages:
|
|
845
|
+
messages: entries.flatMap((entry) => entry.messages),
|
|
808
846
|
};
|
|
809
847
|
}
|
|
810
848
|
|
|
@@ -1005,15 +1043,41 @@ function getSessionName(ctx: ExtensionCommandContext): string | undefined {
|
|
|
1005
1043
|
return safeCall(() => ctx.sessionManager.getSessionName()) ?? safeCall(() => (ctx as unknown as { getSessionName?: () => string | undefined }).getSessionName?.());
|
|
1006
1044
|
}
|
|
1007
1045
|
|
|
1046
|
+
function latestRoutedModel(messages: MinimalMessage[]): RoutedModel | undefined {
|
|
1047
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1048
|
+
const message = messages[i];
|
|
1049
|
+
if (message?.role !== "assistant") continue;
|
|
1050
|
+
if (message.stopReason === "error" || message.stopReason === "aborted") continue;
|
|
1051
|
+
if (message.api === "pi-virtual") continue;
|
|
1052
|
+
if (typeof message.provider !== "string" || !message.provider || typeof message.model !== "string" || !message.model) continue;
|
|
1053
|
+
return { provider: message.provider, id: message.model };
|
|
1054
|
+
}
|
|
1055
|
+
return undefined;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1008
1058
|
function buildReportData(pi: ExtensionAPI, ctx: ExtensionCommandContext, options: CommandOptions): ReportData {
|
|
1009
1059
|
const branchEntries = ctx.sessionManager.getBranch() as MinimalEntry[];
|
|
1060
|
+
const projection = resolveSessionProjection(ctx);
|
|
1010
1061
|
const overhead = buildOverheadSegments(pi, ctx, options.redact);
|
|
1011
|
-
const currentAnalysis =
|
|
1062
|
+
const currentAnalysis = projection
|
|
1063
|
+
? analyzeProjection(projection, options.redact)
|
|
1064
|
+
: analyzeEntries(resolveCurrentContextEntries(ctx, branchEntries), options.redact);
|
|
1012
1065
|
const fullAnalysis = analyzeEntries(branchEntries, options.redact);
|
|
1013
1066
|
const usage = ctx.getContextUsage();
|
|
1014
|
-
const contextTokens = usage?.tokens
|
|
1015
|
-
const
|
|
1016
|
-
const
|
|
1067
|
+
const contextTokens = isKnownNonNegativeNumber(usage?.tokens) ? usage.tokens : null;
|
|
1068
|
+
const reportedContextWindow = isKnownPositiveNumber(usage?.contextWindow) ? usage.contextWindow : undefined;
|
|
1069
|
+
const selectedContextWindow = isKnownPositiveNumber(ctx.model?.contextWindow) ? ctx.model.contextWindow : undefined;
|
|
1070
|
+
const virtualSelection = isVirtualModel(ctx.model);
|
|
1071
|
+
const contextWindow = reportedContextWindow ?? (virtualSelection ? null : selectedContextWindow ?? null);
|
|
1072
|
+
const reportedPercent = isKnownNonNegativeNumber(usage?.percent) ? usage.percent : undefined;
|
|
1073
|
+
const contextPercent = reportedPercent ?? (contextTokens != null && contextWindow ? (contextTokens / contextWindow) * 100 : null);
|
|
1074
|
+
const projectedMessages = projection
|
|
1075
|
+
? projection.entries.flatMap((entry) => entry.messages)
|
|
1076
|
+
: branchEntries.flatMap((entry) => {
|
|
1077
|
+
const message = contextEntryToMessage(entry);
|
|
1078
|
+
return message ? [message] : [];
|
|
1079
|
+
});
|
|
1080
|
+
const routed = virtualSelection ? latestRoutedModel(projectedMessages) : undefined;
|
|
1017
1081
|
|
|
1018
1082
|
const currentSegments = [...overhead, ...currentAnalysis.segments];
|
|
1019
1083
|
const fullSegments = [...overhead, ...fullAnalysis.segments];
|
|
@@ -1041,9 +1105,19 @@ function buildReportData(pi: ExtensionAPI, ctx: ExtensionCommandContext, options
|
|
|
1041
1105
|
if (options.redact) {
|
|
1042
1106
|
notes.push("Redaction is enabled: message contents, paths, commands, session identifiers, and timestamps are hidden in this report.");
|
|
1043
1107
|
}
|
|
1044
|
-
if (
|
|
1108
|
+
if (contextTokens == null) {
|
|
1045
1109
|
notes.push("Current context usage is unknown, usually because compaction just ran and no model response has arrived yet.");
|
|
1046
1110
|
}
|
|
1111
|
+
if (virtualSelection) {
|
|
1112
|
+
const selected = [ctx.model?.provider, ctx.model?.id].filter(Boolean).join("/") || "virtual selection";
|
|
1113
|
+
if (routed) {
|
|
1114
|
+
notes.push(`Model selection ${selected} is virtual. Latest routed physical response: ${routed.provider}/${routed.id}. The context window shown below is provider-reported usage when available; it is not inferred from the virtual selection.`);
|
|
1115
|
+
} else {
|
|
1116
|
+
notes.push(`Model selection ${selected} is virtual. Pi has not exposed a routed physical response here, so no provider context window is inferred.`);
|
|
1117
|
+
}
|
|
1118
|
+
} else if (contextWindow == null) {
|
|
1119
|
+
notes.push("The selected model's context window is unknown because pi did not provide a finite positive limit.");
|
|
1120
|
+
}
|
|
1047
1121
|
if (currentDataset.stats.estimatorOverageTokens > 0) {
|
|
1048
1122
|
notes.push(`Local component estimates exceed pi's footer total by ${formatTokens(currentDataset.stats.estimatorOverageTokens)} tokens, so chart slices are scaled down proportionally.`);
|
|
1049
1123
|
}
|
|
@@ -1062,8 +1136,10 @@ function buildReportData(pi: ExtensionAPI, ctx: ExtensionCommandContext, options
|
|
|
1062
1136
|
model: {
|
|
1063
1137
|
provider: ctx.model?.provider,
|
|
1064
1138
|
id: ctx.model?.id,
|
|
1065
|
-
contextWindow:
|
|
1139
|
+
contextWindow: selectedContextWindow,
|
|
1066
1140
|
thinkingLevel: safeCall(() => pi.getThinkingLevel()),
|
|
1141
|
+
virtual: virtualSelection,
|
|
1142
|
+
routed,
|
|
1067
1143
|
},
|
|
1068
1144
|
contextUsage: {
|
|
1069
1145
|
tokens: contextTokens,
|
|
@@ -1384,12 +1460,17 @@ pre { margin: 0; white-space: pre-wrap; word-break: break-word; color: #cbd5e1;
|
|
|
1384
1460
|
function renderCards(ds) {
|
|
1385
1461
|
const cards = $('cards'); cards.replaceChildren();
|
|
1386
1462
|
const usage = data.contextUsage;
|
|
1387
|
-
const
|
|
1463
|
+
const selectedModel = [data.model.provider, data.model.id].filter(Boolean).join('/') || 'no model';
|
|
1464
|
+
const routedModel = data.model.virtual && data.model.routed ? [data.model.routed.provider, data.model.routed.id].join('/') : '';
|
|
1465
|
+
const model = routedModel && routedModel !== selectedModel ? selectedModel + ' → ' + routedModel : selectedModel;
|
|
1466
|
+
const modelHint = data.model.virtual
|
|
1467
|
+
? (routedModel ? 'virtual selection → latest routed physical response' : 'virtual selection; routed physical model unavailable')
|
|
1468
|
+
: (data.model.thinkingLevel ? 'thinking: ' + data.model.thinkingLevel : '');
|
|
1388
1469
|
const cardData = [
|
|
1389
1470
|
['Context used', usage.tokens == null ? fmt(ds.stats.tokens) : fmt(usage.tokens), usage.percent == null ? 'Footer percent is currently unknown.' : pct(usage.percent) + ' of ' + fmt(usage.contextWindow) + ' tokens'],
|
|
1390
1471
|
['Dataset total', fmt(ds.stats.tokens), ds.id === 'current' ? 'Reconciled to pi footer when available.' : 'Full branch is shown as raw local estimates.'],
|
|
1391
1472
|
['Biggest bucket', ds.stats.categories[0]?.label || '—', ds.stats.categories[0] ? fmt(ds.stats.categories[0].displayTokens) + ' tokens' : 'No context-bearing entries found.'],
|
|
1392
|
-
['Model', model,
|
|
1473
|
+
['Model', model, modelHint],
|
|
1393
1474
|
];
|
|
1394
1475
|
for (const [label, value, hint] of cardData) {
|
|
1395
1476
|
cards.append(el('div', { class: 'card' }, [
|