@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.
Files changed (53) hide show
  1. package/README.md +2 -2
  2. package/extensions/agent-workflow-audit/index.ts +41 -8
  3. package/extensions/agent-workflow-audit/package.json +1 -1
  4. package/extensions/annotate-git-diff/README.md +7 -3
  5. package/extensions/annotate-git-diff/git.ts +3 -2
  6. package/extensions/annotate-git-diff/index.ts +366 -81
  7. package/extensions/annotate-git-diff/package.json +2 -2
  8. package/extensions/annotate-git-diff/quiet-glimpse.ts +4 -2
  9. package/extensions/annotate-git-diff/types.ts +6 -0
  10. package/extensions/annotate-git-diff/web/app.js +54 -4
  11. package/extensions/annotate-git-diff/web/index.html +2 -2
  12. package/extensions/annotate-last-message/README.md +9 -1
  13. package/extensions/annotate-last-message/index.ts +53 -13
  14. package/extensions/annotate-last-message/package.json +2 -1
  15. package/extensions/annotate-last-message/quiet-glimpse.ts +4 -2
  16. package/extensions/annotate-last-message/theme.ts +247 -0
  17. package/extensions/annotate-last-message/ui.ts +28 -2
  18. package/extensions/annotate-last-message/web/app.js +351 -162
  19. package/extensions/annotate-last-message/web/index.html +153 -24
  20. package/extensions/annotate-last-message/web/md-renderer.js +458 -0
  21. package/extensions/code-reviewer/index.ts +12 -1
  22. package/extensions/code-reviewer/package.json +2 -1
  23. package/extensions/context-cap/README.md +33 -4
  24. package/extensions/context-cap/index.ts +271 -68
  25. package/extensions/context-cap/package.json +1 -1
  26. package/extensions/context-inspector/README.md +2 -0
  27. package/extensions/context-inspector/index.ts +90 -9
  28. package/extensions/context-inspector/package.json +1 -1
  29. package/extensions/contrarian/index.ts +21 -15
  30. package/extensions/contrarian/package.json +1 -1
  31. package/extensions/dynamic-context-pruning/README.md +17 -5
  32. package/extensions/dynamic-context-pruning/index.ts +17 -4
  33. package/extensions/dynamic-context-pruning/package.json +1 -1
  34. package/extensions/fast/README.md +24 -3
  35. package/extensions/fast/index.ts +2 -2
  36. package/extensions/fast/package.json +1 -1
  37. package/extensions/git-footer/README.md +1 -0
  38. package/extensions/git-footer/package.json +1 -1
  39. package/extensions/librarian/index.ts +7 -0
  40. package/extensions/librarian/package.json +2 -1
  41. package/extensions/minimal-footer/README.md +16 -4
  42. package/extensions/minimal-footer/index.ts +263 -14
  43. package/extensions/minimal-footer/openai-usage.ts +62 -8
  44. package/extensions/minimal-footer/package.json +1 -1
  45. package/extensions/oracle/index.ts +19 -18
  46. package/extensions/oracle/package.json +1 -1
  47. package/extensions/permission-gate/README.md +6 -0
  48. package/extensions/permission-gate/index.ts +37 -16
  49. package/extensions/permission-gate/package.json +1 -1
  50. package/extensions/quiet-tools/README.md +1 -1
  51. package/extensions/quiet-tools/package.json +1 -1
  52. package/extensions/triage-comments/package.json +2 -1
  53. 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
- const originalContextWindows = new WeakMap<AnyModel, number>();
8
- const touchedModels = new Set<AnyModel>();
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: number;
14
- effective: number;
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 getOriginalContextWindow(model: AnyModel): number {
22
- const existing = originalContextWindows.get(model);
23
- if (typeof existing === "number") return existing;
52
+ function isRecord(value: unknown): value is Record<string, unknown> {
53
+ return typeof value === "object" && value !== null && !Array.isArray(value);
54
+ }
24
55
 
25
- originalContextWindows.set(model, model.contextWindow);
26
- touchedModels.add(model);
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 getEffectiveContextWindow(model: AnyModel): number {
31
- return Math.min(getOriginalContextWindow(model), DEFAULT_MAX_CONTEXT_WINDOW);
60
+ function isNonNegativeSafeInteger(value: unknown): value is number {
61
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
32
62
  }
33
63
 
34
- function applyContextCap(model: AnyModel | undefined): ApplyResult | undefined {
35
- if (!model) return undefined;
64
+ function isVirtualModel(model: AnyModel): boolean {
65
+ return (model as AnyModel & { api?: string }).api === "pi-virtual";
66
+ }
36
67
 
37
- const key = modelKey(model);
38
- const original = getOriginalContextWindow(model);
39
- const effective = Math.min(original, DEFAULT_MAX_CONTEXT_WINDOW);
40
- const changed = model.contextWindow !== effective;
41
- model.contextWindow = effective;
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
- return { changed, key, original, effective };
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 restoreContextWindow(model: AnyModel | undefined): boolean {
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 original = originalContextWindows.get(model);
50
- if (typeof original !== "number" || model.contextWindow === original) return false;
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.contextWindow = original;
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 forEachRegistryModel(ctx: ExtensionContext, callback: (model: AnyModel) => void): void {
57
- try {
58
- for (const model of ctx.modelRegistry.getAll()) {
59
- callback(model);
60
- }
61
- } catch {
62
- // Best effort only. The active ctx.model is handled separately by callers.
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
- function applyContextCapToSession(ctx: ExtensionContext): number {
67
- let changed = 0;
138
+ const hold = processModelHolds.get(model);
139
+ if (!isKnownContextWindow(model.contextWindow) && !hold) {
140
+ return { changed: false, key, reason: "unknown" };
141
+ }
68
142
 
69
- forEachRegistryModel(ctx, (model) => {
70
- if (applyContextCap(model)?.changed) changed++;
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
- if (applyContextCap(ctx.model)?.changed) changed++;
74
- return changed;
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
- function restoreContextCapForSession(ctx: ExtensionContext): number {
78
- let changed = 0;
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
- forEachRegistryModel(ctx, (model) => {
81
- if (restoreContextWindow(model)) changed++;
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
- if (restoreContextWindow(ctx.model)) changed++;
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
- if (enabled) applyContextCapToSession(ctx);
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
- result.original > DEFAULT_MAX_CONTEXT_WINDOW
108
- ? `ctx cap ${formatTokens(result.effective)}/${formatTokens(result.original)}`
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("session_shutdown", async (_event, ctx) => {
114
- restoreContextCapForSession(ctx);
115
- for (const model of touchedModels) restoreContextWindow(model);
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 changed = applyContextCapToSession(ctx);
131
- ctx.ui.setStatus("context-cap", `ctx cap ${formatTokens(DEFAULT_MAX_CONTEXT_WINDOW)}`);
132
- ctx.ui.notify(`Context cap enabled (${changed} model window(s) capped/restored).`, "info");
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 = restoreContextCapForSession(ctx);
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 = restoreContextCapForSession(ctx);
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 changed = applyContextCapToSession(ctx);
153
- ctx.ui.setStatus("context-cap", `ctx cap ${formatTokens(DEFAULT_MAX_CONTEXT_WINDOW)}`);
154
- ctx.ui.notify(`Context cap enabled (${changed} model window(s) capped/restored).`, "info");
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
- const original = getOriginalContextWindow(model);
168
- const effective = enabled ? getEffectiveContextWindow(model) : model.contextWindow;
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
- `Context cap is ${status}. Current model: ${modelKey(model)} (${formatTokens(effective)}/${formatTokens(original)} effective/original).`,
373
+ `${prefix} Current model: ${modelKey(model)} (${formatWindow(model.contextWindow)}/${formatWindow(original)} effective/original).${holdStatus(model, heldModels)}`,
171
374
  "info",
172
375
  );
173
376
  return;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-context-cap",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "A pi extension that caps effective model context windows at 200k tokens for earlier auto-compaction.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -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: Array.isArray(projection.messages) ? projection.messages : entries.flatMap((entry) => entry.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 = resolveCurrentContextAnalysis(ctx, branchEntries, options.redact);
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 ?? null;
1015
- const contextWindow = usage?.contextWindow ?? ctx.model?.contextWindow ?? null;
1016
- const contextPercent = usage?.percent ?? (contextTokens != null && contextWindow ? (contextTokens / contextWindow) * 100 : null);
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 (usage?.tokens == null) {
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: ctx.model?.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 model = [data.model.provider, data.model.id].filter(Boolean).join('/') || 'no model';
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, data.model.thinkingLevel ? 'thinking: ' + data.model.thinkingLevel : ''],
1473
+ ['Model', model, modelHint],
1393
1474
  ];
1394
1475
  for (const [label, value, hint] of cardData) {
1395
1476
  cards.append(el('div', { class: 'card' }, [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-context-inspector",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "A pi extension that opens a local HTML dashboard explaining where the current session context is going.",
5
5
  "keywords": [
6
6
  "pi-package",