bermudis-pi-goodies 0.26.0 → 0.27.1

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 CHANGED
@@ -23,10 +23,17 @@ extensions. One entry point, thirteen independent features.
23
23
 
24
24
  ## Install
25
25
 
26
- After publishing the package to npm:
26
+ First install, pinned to a published version:
27
27
 
28
28
  ```bash
29
- pi install npm:bermudis-pi-goodies@0.26.0
29
+ pi install npm:bermudis-pi-goodies@0.27.1
30
+ ```
31
+
32
+ Picking up a new release on an existing install (`pi install` is not the
33
+ update path):
34
+
35
+ ```bash
36
+ pi update --extensions
30
37
  ```
31
38
 
32
39
  Remove any old `bermudis-pi-goodies.ts` symlink before reloading Pi. Each
@@ -373,11 +380,16 @@ session. `/side-exit` closes it and chooses what the main agent receives.
373
380
  How it works:
374
381
 
375
382
  - **Badge**: while a side session is open, the footer's model readout becomes
376
- `side: (zai) glm-5.3 • max` — the provider-balance footer prepends the
377
- badge to the stats line instead of adding a separate `side:` status line
378
- (which remains the fallback when that footer is not mounted). The readout
379
- tracks mid-side ctrl+l switches, and the badge clears on exit or when
380
- switching to a session that is not in a side consultation.
383
+ `side: (zai) glm-5.3 • max · ~46k lensed` — the provider-balance footer
384
+ prepends the badge to the stats line instead of adding a separate `side:`
385
+ status line (which remains the fallback when that footer is not mounted).
386
+ The `~46k lensed` suffix is the estimated size of the request the lens will
387
+ actually send (quote + side turns, pi's own chars/4 estimator): pi's footer
388
+ context-usage figure estimates the raw session branch, which the lens then
389
+ rewrites at the wire, so it over-reports during side sessions. The suffix
390
+ is dropped first when the line gets tight. The readout tracks mid-side
391
+ ctrl+l switches, and the badge clears on exit or when switching to a
392
+ session that is not in a side consultation.
381
393
  - **Entry**: `/side` opens pi's own model selector (falls back to a plain
382
394
  selector when pi's internal runtime is not reachable; in headless modes pass
383
395
  the model explicitly as `/side provider/model-id[:level]`). The main agent's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bermudis-pi-goodies",
3
- "version": "0.26.0",
3
+ "version": "0.27.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/bermudi/agent-extensions.git",
@@ -32,6 +32,7 @@ import {
32
32
  import { getKiloCatalogStatus, type KiloCatalogStatus } from "./kilo.ts";
33
33
  import { logGoodiesEvent, reportFailure } from "./goodies-log.ts";
34
34
  import {
35
+ formatSideLensSuffix,
35
36
  getSideSessionModel,
36
37
  onSideBadgeChange,
37
38
  setMergedSideBadgeInstalled,
@@ -1306,11 +1307,13 @@ const SGR_SEQUENCE = /\x1b\[[0-9;:]*m/g;
1306
1307
 
1307
1308
  /**
1308
1309
  * Prepend a badge to the right-aligned model readout of the stats line pi's
1309
- * FooterComponent produced: `side: (zai) glm-5.3 • max`. The line is built
1310
- * as `<dim statsLeft><dim padding + model>` — split at the opener of the
1311
- * final styled run (the last SGR sequence is its closer, so the opener is
1312
- * the second-to-last), keep the left half verbatim (it may carry a colored
1313
- * context percentage), and rebuild the right half around the badge.
1310
+ * FooterComponent produced: `side: (zai) glm-5.3 • max · ~46k lensed`. The
1311
+ * line is built as `<dim statsLeft><dim padding + model>` — split at the
1312
+ * opener of the final styled run (the last SGR sequence is its closer, so
1313
+ * the opener is the second-to-last), keep the left half verbatim (it may
1314
+ * carry a colored context percentage), and rebuild the right half around
1315
+ * the badge. A suffix that does not fit is dropped before the badge itself
1316
+ * is ever truncated — the model name outranks the token estimate.
1314
1317
  *
1315
1318
  * Returns undefined when the line lacks that two-run shape — the caller then
1316
1319
  * leaves the line untouched and the /side badge falls back to a status line.
@@ -1322,6 +1325,7 @@ export function mergeSideBadgeIntoStatsLine(
1322
1325
  width: number,
1323
1326
  theme: FooterTheme,
1324
1327
  prefix = "side: ",
1328
+ suffix = "",
1325
1329
  ): string | undefined {
1326
1330
  if (!line || width <= 0) return undefined;
1327
1331
  const runs = [...line.matchAll(SGR_SEQUENCE)];
@@ -1332,14 +1336,17 @@ export function mergeSideBadgeIntoStatsLine(
1332
1336
  const tail = stripTerminalSequences(line.slice(opener)).trim();
1333
1337
  if (!tail || !stripTerminalSequences(head).trim()) return undefined;
1334
1338
 
1335
- const labeled = `${prefix}${tail}`;
1339
+ const withSuffix = `${prefix}${tail}${suffix}`;
1340
+ const bare = `${prefix}${tail}`;
1336
1341
  const headWidth = visibleWidth(head);
1337
1342
  const room = width - headWidth - 2; // pi keeps ≥2 columns between the halves
1338
1343
  if (room <= 0) return undefined;
1339
1344
  const right =
1340
- visibleWidth(labeled) <= room
1341
- ? labeled
1342
- : truncateToWidth(labeled, room, "");
1345
+ visibleWidth(withSuffix) <= room
1346
+ ? withSuffix
1347
+ : visibleWidth(bare) <= room
1348
+ ? bare
1349
+ : truncateToWidth(bare, room, "");
1343
1350
  const padding = " ".repeat(
1344
1351
  Math.max(2, width - headWidth - visibleWidth(right)),
1345
1352
  );
@@ -1781,6 +1788,8 @@ export default function providerBalance(
1781
1788
  lines[1],
1782
1789
  width,
1783
1790
  theme,
1791
+ "side: ",
1792
+ formatSideLensSuffix(),
1784
1793
  );
1785
1794
  if (statsLine !== undefined) {
1786
1795
  lines[1] = statsLine;
package/side-state.ts CHANGED
@@ -54,6 +54,42 @@ export function getSideSessionModel(): SideModelRef | undefined {
54
54
  return activeModel;
55
55
  }
56
56
 
57
+ let lensTokens: number | undefined;
58
+
59
+ /**
60
+ * side.ts: estimated token size of the LENSED request (the quote + side
61
+ * turns actually sent to the side model), via pi's own chars/4 estimator
62
+ * so it stays comparable with the footer's raw-branch figure. Undefined on
63
+ * non-side branches — pi's footer estimate is correct there.
64
+ *
65
+ * Why: pi's footer context-usage and pre-send compaction threshold both
66
+ * estimate the RAW session projection; the side lens rewrites the request
67
+ * at the wire, after every estimator. The footer therefore over-reports
68
+ * while a side session is active. The badge carries this estimate as the
69
+ * honest counterpoint.
70
+ */
71
+ export function setSideLensTokens(tokens: number | undefined): void {
72
+ if (lensTokens === tokens) return;
73
+ lensTokens = tokens;
74
+ emit();
75
+ }
76
+
77
+ export function getSideLensTokens(): number | undefined {
78
+ return lensTokens;
79
+ }
80
+
81
+ /** Badge suffix for the lensed estimate: " · ~46k lensed", or "" when off. */
82
+ export function formatSideLensSuffix(): string {
83
+ if (lensTokens === undefined || lensTokens < 1_000) return "";
84
+ return ` · ~${formatSideLensTokens(lensTokens)} lensed`;
85
+ }
86
+
87
+ function formatSideLensTokens(tokens: number): string {
88
+ if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
89
+ if (tokens >= 10_000) return `${Math.round(tokens / 1_000)}k`;
90
+ return `${(tokens / 1_000).toFixed(1)}k`;
91
+ }
92
+
57
93
  /** provider-balance: its footer now owns (or handed back) the stats line. */
58
94
  export function setMergedSideBadgeInstalled(installed: boolean): void {
59
95
  if (mergedInstalled === installed) return;
@@ -88,6 +124,7 @@ export function onSideBadgeChange(listener: () => void): () => void {
88
124
  /** Test-only: restore the pristine module state (undefined/false/false). */
89
125
  export function resetSideBadgeState(): void {
90
126
  activeModel = undefined;
127
+ lensTokens = undefined;
91
128
  mergedInstalled = false;
92
129
  mergedRendered = false;
93
130
  listeners.clear();
package/side.ts CHANGED
@@ -28,9 +28,13 @@
28
28
  * limb that is ever resumed delivers only newer turns. v1 always starts a
29
29
  * fresh side limb, but the bookkeeping keeps that future-safe.
30
30
  *
31
- * Known cosmetic limitation: the footer's context-usage estimate reflects the
32
- * raw session branch, not the lens output, so it over-reports while a side
33
- * session is active.
31
+ * Known limitation: pi's footer context-usage estimate (and its pre-send
32
+ * compaction threshold) reflect the raw session branch, not the lens
33
+ * output — the footer over-reports while a side session is active, and
34
+ * premature auto-compaction is possible on large side consults (the lens
35
+ * is compaction-aware and survives it; the main limb is never touched).
36
+ * The side badge carries pi's own estimator applied to the lensed messages
37
+ * ("· ~46k lensed") as the honest counterpoint to the inflated figure.
34
38
  */
35
39
 
36
40
  import type {
@@ -43,15 +47,18 @@ import type {
43
47
  } from "@earendil-works/pi-coding-agent";
44
48
  import {
45
49
  ModelSelectorComponent,
50
+ estimateTokens,
46
51
  sessionEntryToContextMessages,
47
52
  } from "@earendil-works/pi-coding-agent";
48
53
  import { describeError, extractTextParts } from "./json-file.ts";
49
54
  import { buildTrajectory, renderTrajectory } from "./copy-trajectory.ts";
50
55
  import { logGoodiesEvent } from "./goodies-log.ts";
51
56
  import {
57
+ formatSideLensSuffix,
52
58
  getSideSessionModel,
53
59
  isMergedSideBadgeActive,
54
60
  onSideBadgeChange,
61
+ setSideLensTokens,
55
62
  setSideSessionModel,
56
63
  type SideModelRef,
57
64
  } from "./side-state.ts";
@@ -298,6 +305,22 @@ export function buildLensMessages(
298
305
  return messages;
299
306
  }
300
307
 
308
+ /**
309
+ * Estimate the token size of the lensed request pi will actually send
310
+ * (quote + side-native messages), using pi's own chars/4 estimator so the
311
+ * number is comparable with the footer's raw-branch estimate. Undefined
312
+ * when markerIdx does not mark a side session.
313
+ */
314
+ export function estimateLensedContextTokens(
315
+ awareEntries: readonly SessionEntry[],
316
+ rawBranch: readonly SessionEntry[],
317
+ markerIdx: number,
318
+ ): number | undefined {
319
+ const messages = buildLensMessages(awareEntries, rawBranch, markerIdx, []);
320
+ if (!messages) return undefined;
321
+ return messages.reduce((sum, message) => sum + estimateTokens(message), 0);
322
+ }
323
+
301
324
  // ---------------------------------------------------------------------------
302
325
  // Handoff: what the main agent is given at exit
303
326
  // ---------------------------------------------------------------------------
@@ -511,7 +534,7 @@ export function applySideStatus(ctx: ExtensionContext): void {
511
534
  const model: SideModelRef | undefined = getSideSessionModel();
512
535
  const text =
513
536
  model && !isMergedSideBadgeActive()
514
- ? `side: ${modelRef(model)}`
537
+ ? `side: ${modelRef(model)}${formatSideLensSuffix()}`
515
538
  : undefined;
516
539
  ctx.ui.setStatus("side", text);
517
540
  }
@@ -547,6 +570,26 @@ export function activeSideModel(
547
570
  return markerData.sideModel;
548
571
  }
549
572
 
573
+ /**
574
+ * Refresh the badge's lensed-context estimate from the live branch —
575
+ * undefined on non-side branches, so the suffix also clears on exit and
576
+ * navigation. O(branch text) char counting: cheap per turn, far too hot
577
+ * for per-frame footer renders (those read the cached side-state value).
578
+ */
579
+ function refreshSideLens(ctx: ExtensionContext): void {
580
+ const raw = ctx.sessionManager.getBranch();
581
+ const markerIdx = findSideBoundary(raw);
582
+ let tokens: number | undefined;
583
+ if (markerIdx !== -1) {
584
+ tokens = estimateLensedContextTokens(
585
+ ctx.sessionManager.buildContextEntries(),
586
+ raw,
587
+ markerIdx,
588
+ );
589
+ }
590
+ setSideLensTokens(tokens);
591
+ }
592
+
550
593
  async function summarizeDelta(
551
594
  ctx: ExtensionCommandContext,
552
595
  sideModel: ModelRef,
@@ -627,6 +670,7 @@ export default function side(pi: ExtensionAPI): void {
627
670
  setSideSessionModel(undefined);
628
671
  applySideStatus(ctx);
629
672
  }
673
+ refreshSideLens(ctx);
630
674
  });
631
675
 
632
676
  // Restore the badge when a session resumes already on a side limb. The
@@ -650,8 +694,17 @@ export default function side(pi: ExtensionAPI): void {
650
694
  setSideSessionModel(undefined);
651
695
  applySideStatus(ctx);
652
696
  }
697
+ refreshSideLens(ctx);
653
698
  });
654
699
 
700
+ // The lensed-context estimate the badge carries: the quote is frozen at
701
+ // entry, so it only moves when the side limb grows, compaction rewrites
702
+ // the view, or navigation changes the limb. Non-side events are a cheap
703
+ // marker scan that ends in a no-op set.
704
+ pi.on("turn_start", (_event, ctx) => refreshSideLens(ctx));
705
+ pi.on("message_end", (_event, ctx) => refreshSideLens(ctx));
706
+ pi.on("session_compact", (_event, ctx) => refreshSideLens(ctx));
707
+
655
708
  // The footer-rendered badge needs a re-render when side state changes;
656
709
  // the status-line fallback needs re-applying when the merged footer
657
710
  // appears or disappears (install order between the two modules is not
package/vision-core.ts CHANGED
@@ -1,14 +1,16 @@
1
1
  /**
2
2
  * vision-core — pure logic for the `vision` tool.
3
3
  *
4
- * No pi imports: structural types stand in for pi's registry/model objects so
5
- * this module (and its tests) run under plain `bun` without a pi install.
4
+ * No pi runtime imports: structural types stand in for registry/model objects.
5
+ * The request context uses pi's types so incomplete replay messages cannot
6
+ * silently cross the completion boundary.
6
7
  * index.ts wires these against the real pi APIs.
7
8
  */
8
9
  import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
10
  import { readFile, stat } from "node:fs/promises";
10
11
  import { homedir } from "node:os";
11
12
  import { dirname, extname, join, resolve } from "node:path";
13
+ import type { Context, Message } from "@earendil-works/pi-ai/compat";
12
14
 
13
15
  // --- structural stand-ins for pi types ---------------------------------------
14
16
 
@@ -16,6 +18,7 @@ import { dirname, extname, join, resolve } from "node:path";
16
18
  export interface ModelLike {
17
19
  id: string;
18
20
  provider: string;
21
+ api: string;
19
22
  input: string[];
20
23
  }
21
24
 
@@ -558,9 +561,10 @@ export const VISION_SYSTEM_PROMPT = [
558
561
  export function buildVisionContext(
559
562
  prompt: string,
560
563
  image: { data: string; mimeType: string },
564
+ model: ModelLike,
561
565
  history: StoredTurn[] = [],
562
- ): object {
563
- const past = history.flatMap((turn) => [
566
+ ): Context {
567
+ const past = history.flatMap<Message>((turn) => [
564
568
  {
565
569
  role: "user",
566
570
  content: [{ type: "text", text: turn.question }],
@@ -569,6 +573,21 @@ export function buildVisionContext(
569
573
  {
570
574
  role: "assistant",
571
575
  content: [{ type: "text", text: turn.answer }],
576
+ api: model.api,
577
+ provider: model.provider,
578
+ model: model.id,
579
+ stopReason: "stop",
580
+ // These are text-only replays, not the original provider responses.
581
+ // Zero usage makes Pi estimate this request's actual content instead of
582
+ // treating a previous request's token count as an authoritative prefix.
583
+ usage: {
584
+ input: 0,
585
+ output: 0,
586
+ cacheRead: 0,
587
+ cacheWrite: 0,
588
+ totalTokens: 0,
589
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
590
+ },
572
591
  timestamp: Date.now(),
573
592
  },
574
593
  ]);
@@ -644,7 +663,7 @@ export interface VisionToolDeps {
644
663
  /** pi-ai completeSimple (injected so tests stub it). */
645
664
  complete(
646
665
  model: ModelLike,
647
- context: object,
666
+ context: Context,
648
667
  options: {
649
668
  apiKey?: string;
650
669
  headers?: Record<string, string | null>;
@@ -760,6 +779,7 @@ export async function runVisionTool(
760
779
  data: image.data as string,
761
780
  mimeType: image.mimeType as string,
762
781
  },
782
+ transport.model,
763
783
  history,
764
784
  ),
765
785
  {
package/vision.ts CHANGED
@@ -133,6 +133,13 @@ function questionPreview(prompt: unknown, cap: number): string {
133
133
  return head;
134
134
  }
135
135
 
136
+ /** Expanded views show the raw truth: the whole prompt, uncapped — the cap
137
+ * is a compact-view aid, and ctrl+o exists to reveal what was actually
138
+ * asked (bash's expanded-command rule; multi-line renders multi-line). */
139
+ function fullQuestion(prompt: unknown): string {
140
+ return typeof prompt === "string" ? prompt : "";
141
+ }
142
+
136
143
  /** Answer text of a recorded vision result (its content is one text block). */
137
144
  function answerText(result: unknown): string {
138
145
  const content = (result as { content?: Array<{ text?: string }> } | undefined)
@@ -160,8 +167,10 @@ const visionBurstSpec = {
160
167
  // Read-view rule: info that is missing is skipped, never rendered as
161
168
  // junk (no `: ""` when a prompt is absent) — and long answers get the
162
169
  // same 12-line preview + "... N more lines" note as every other tool.
170
+ // Details render only when expanded, so questions show uncapped
171
+ // (same rule as bash's expanded details).
163
172
  const follow = e.args.followUp ? " (follow-up)" : "";
164
- const q = questionPreview(e.args.prompt, 90);
173
+ const q = fullQuestion(e.args.prompt);
165
174
  const label = `— ${shortenPath(e.args.path || "...")}${follow}${q ? `: "${q}"` : ""}`;
166
175
  if (!e.result) return `\n${theme.fg("warning", `${label} (pending)`)}`;
167
176
  const txt = answerText(e.result);
@@ -170,9 +179,14 @@ const visionBurstSpec = {
170
179
  })
171
180
  .join("");
172
181
  },
173
- soloHeader(args: any, theme: any) {
182
+ soloHeader(args: any, theme: any, ctx: any) {
174
183
  const follow = args.followUp ? theme.fg("muted", " (follow-up)") : "";
175
- const q = questionPreview(args.prompt, 90);
184
+ // Expanded shows the full question, uncapped — the 90-char preview is a
185
+ // compact-view aid, and the whole point of ctrl+o is to reveal what was
186
+ // actually asked (same rule as bash's expanded command).
187
+ const q = ctx?.expanded
188
+ ? fullQuestion(args.prompt)
189
+ : questionPreview(args.prompt, 90);
176
190
  return `${theme.fg("toolTitle", theme.bold("vision"))} ${theme.fg("accent", shortenPath(args.path || "..."))}${follow}${q ? ` ${theme.fg("toolOutput", `"${q}"`)}` : ""}`;
177
191
  },
178
192
  soloExpanded(entry: any, _args: any, theme: any) {
@@ -367,18 +381,14 @@ export default function (pi: ExtensionAPI): void {
367
381
  ) as Promise<{ content: ContentBlockLike[]; isError?: boolean }>,
368
382
  readRaw: readRawImage,
369
383
  complete: (model, context, options) =>
370
- completeSimple(
371
- model as never,
372
- context as never,
373
- {
374
- apiKey: options.apiKey,
375
- headers: options.headers,
376
- maxTokens: options.maxTokens,
377
- signal: options.signal,
378
- timeoutMs: 120_000,
379
- maxRetries: 1,
380
- } as never,
381
- ) as Promise<never>,
384
+ completeSimple(model as never, context, {
385
+ apiKey: options.apiKey,
386
+ headers: options.headers,
387
+ maxTokens: options.maxTokens,
388
+ signal: options.signal,
389
+ timeoutMs: 120_000,
390
+ maxRetries: 1,
391
+ } as never) as Promise<never>,
382
392
  },
383
393
  (text) =>
384
394
  onUpdate?.({