bermudis-pi-goodies 0.24.0 → 0.24.2

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
@@ -26,7 +26,7 @@ extensions. One entry point, thirteen independent features.
26
26
  After publishing the package to npm:
27
27
 
28
28
  ```bash
29
- pi install npm:bermudis-pi-goodies@0.24.0
29
+ pi install npm:bermudis-pi-goodies@0.24.2
30
30
  ```
31
31
 
32
32
  Remove any old `bermudis-pi-goodies.ts` symlink before reloading Pi. Each
@@ -390,9 +390,10 @@ Details worth knowing:
390
390
  - The footer badge (`side: kilo/glm-5.3`) tracks the active side model,
391
391
  including manual ctrl+l switches; it is restored when pi reopens a session
392
392
  that is already on a side limb. Handoffs speak for the model actually
393
- active at exit (a mid-side `/side provider/x` swap changes the summarizer
393
+ active at exit (a mid-side ctrl+l switch changes the summarizer
394
394
  and the handoff labels), while the transcript itself keeps per-turn model
395
- attribution.
395
+ attribution. `/side` inside a side session just points you at ctrl+l —
396
+ pi's model selector is the swap mechanism.
396
397
  - Thinking levels round-trip: pi's model switch applies the per-model default
397
398
  level, so the marker snapshots the session level at entry and `/side-exit`
398
399
  restores it explicitly — a session parked at `:low` comes back at `:low`,
package/clean-tui.ts CHANGED
@@ -143,7 +143,10 @@ type Entry = {
143
143
  * groups. Calls with no boundary between them share a segment.
144
144
  */
145
145
  seg: number;
146
- /** Position in `entries`; stable because entries are append-only. */
146
+ /**
147
+ * Monotonic creation number (its position in `entries` is
148
+ * `index - entriesBase`; the base advances when history is pruned).
149
+ */
147
150
  index: number;
148
151
  result?: {
149
152
  content: Array<{ type: string; text?: string; data?: string }>;
@@ -186,9 +189,38 @@ let curAssistantMessage: any;
186
189
  let replaying = true;
187
190
  const replaySegByToolCallId = new Map<string, number>();
188
191
  const entries: Entry[] = [];
192
+ // `entries[0]`'s position in the monotonic entry numbering. Pruning drops
193
+ // from the front (pruneHistoryIfNeeded) and advances this base, so an
194
+ // entry's `index` stays stable for its lifetime while its position in the
195
+ // array shifts. Callers that need an array position subtract the base.
196
+ let entriesBase = 0;
189
197
  const entryById = new Map<string, Entry>();
190
198
  const invalidateById = new Map<string, () => void>();
191
199
 
200
+ // History cap. `entries` is walked linearly by stampSummaryRequested on
201
+ // every bash renderCall and by invalidateRowsForCommand on every summary
202
+ // landing, so an unbounded history makes long sessions slower as they grow
203
+ // (thousands of finished rows re-scanned per render). Prune down to KEEP
204
+ // once MAX is exceeded. Pruned rows keep whatever is already painted on
205
+ // screen — pi components hold their own output — but a LATE re-render of a
206
+ // pruned row (expanding a burst far up the transcript) re-registers it as a
207
+ // solo row: burst context beyond the cap is gone. Cheap by design: entries
208
+ // are small metadata; the result payloads belong to pi's components.
209
+ const MAX_HISTORY_ENTRIES = 600;
210
+ const HISTORY_KEEP_ENTRIES = 400;
211
+
212
+ /** Drop the oldest history once the cap is exceeded (see MAX_HISTORY_ENTRIES). */
213
+ function pruneHistoryIfNeeded(): void {
214
+ if (entries.length <= MAX_HISTORY_ENTRIES) return;
215
+ const drop = entries.length - HISTORY_KEEP_ENTRIES;
216
+ for (let i = 0; i < drop; i++) {
217
+ entryById.delete(entries[i].toolCallId);
218
+ invalidateById.delete(entries[i].toolCallId);
219
+ }
220
+ entries.splice(0, drop);
221
+ entriesBase += drop;
222
+ }
223
+
192
224
  function upsertEntry(
193
225
  toolCallId: string,
194
226
  toolName: string,
@@ -215,10 +247,11 @@ function upsertEntry(
215
247
  toolName,
216
248
  args,
217
249
  seg,
218
- index: entries.length,
250
+ index: entries.length + entriesBase,
219
251
  };
220
252
  entries.push(e);
221
253
  entryById.set(toolCallId, e);
254
+ pruneHistoryIfNeeded();
222
255
  } else {
223
256
  e.args = args;
224
257
  }
@@ -326,7 +359,7 @@ function getBurstForId(
326
359
  ): { entries: Entry[]; index: number } | null {
327
360
  const entry = entryById.get(toolCallId);
328
361
  if (!entry) return null;
329
- const idx = entry.index;
362
+ const idx = entry.index - entriesBase;
330
363
  let start = idx;
331
364
  while (start > 0 && shouldGroup(entries[start - 1], entries[start])) start--;
332
365
  let end = idx;
@@ -362,11 +395,21 @@ function revalidateBurstsAround(changedId: string) {
362
395
  // burst; pending/error flags surface on the leader). Rerender those runs —
363
396
  // bounded, unlike scanning the whole history per result.
364
397
  //
365
- // The changed row itself is NOT invalidated here: pi is already re-rendering
366
- // it (we are inside its render slot), and invalidating it would synchronously
367
- // re-enter this code path via updateDisplay -> renderResult -> invalidate.
368
- const idx = changed.index;
369
- const ranges: Array<[number, number]> = [];
398
+ // The changed row's OWN run is included deliberately. pi's updateDisplay
399
+ // runs renderCall BEFORE renderResult in the same pass, so the box painted
400
+ // when a result arrives still reflects pre-result state. Neighbors were
401
+ // already woken here, but a row with no groupable neighbor had no wake-up
402
+ // at all: a solo row stayed "running" after it finished, a solo failure
403
+ // never turned red, and an image result at the END of a burst never split
404
+ // off into its own row (a middle image only appeared because the next job
405
+ // woke it). The self-wake closes all three.
406
+ //
407
+ // The synchronous re-entry (invalidate -> updateDisplay -> renderResult
408
+ // -> recordResult) terminates immediately: the contentRef check above
409
+ // classifies the replayed wrapper as a plain re-render, not a new result,
410
+ // so no further invalidation fires.
411
+ const idx = changed.index - entriesBase;
412
+ const ranges: Array<[number, number]> = [runAround(idx)];
370
413
  if (idx > 0) ranges.push(runAround(idx - 1));
371
414
  if (idx + 1 < entries.length) ranges.push(runAround(idx + 1));
372
415
  const seen = new Set<number>();
@@ -450,6 +493,12 @@ const THINKING_SUMMARY_PROMPT =
450
493
  const SUMMARY_ERROR_SNIPPET_CHARS = 200;
451
494
 
452
495
  const summaryCache = new Map<string, string>();
496
+ // Cache cap: one entry per distinct long command, and the key is the FULL
497
+ // command text (heredocs make fat keys), so a long session accumulates
498
+ // without bound. FIFO eviction is the right shape — recent commands are the
499
+ // ones whose rows still re-render; an evicted command just falls back to
500
+ // its raw text if it ever reappears.
501
+ const SUMMARY_CACHE_MAX = 200;
453
502
  const pendingSummaries = new Set<string>();
454
503
  // Commands whose requests were deferred by the inflight cap or a failure
455
504
  // backoff. Drained whenever a slot frees (request settle) or a later
@@ -568,6 +617,24 @@ export function __clearSummaryCache(): void {
568
617
  summaryFailureWaveObservedAt = 0;
569
618
  }
570
619
 
620
+ /** History sizes for tests (entries cap + id maps must stay in lockstep). */
621
+ export function __historyStatsForTesting(): {
622
+ entries: number;
623
+ entryById: number;
624
+ invalidateById: number;
625
+ } {
626
+ return {
627
+ entries: entries.length,
628
+ entryById: entryById.size,
629
+ invalidateById: invalidateById.size,
630
+ };
631
+ }
632
+
633
+ /** Saved-summary count for tests (the cache is capped, not unbounded). */
634
+ export function __summaryCacheSizeForTesting(): number {
635
+ return summaryCache.size;
636
+ }
637
+
571
638
  function isSummarizable(cmd: string): boolean {
572
639
  return cmd.length > SUMMARY_THRESHOLD_CHARS;
573
640
  }
@@ -1275,6 +1342,11 @@ function startSummaryRequest(cmd: string): void {
1275
1342
  pendingSummaries.delete(cmd);
1276
1343
  const recovered = noteSummarySuccess(requestStartedAt);
1277
1344
  summaryCache.set(cmd, normalizeSummary(result.text));
1345
+ while (summaryCache.size > SUMMARY_CACHE_MAX) {
1346
+ const oldest = summaryCache.keys().next().value;
1347
+ if (oldest === undefined) break;
1348
+ summaryCache.delete(oldest);
1349
+ }
1278
1350
  logGoodiesEvent({
1279
1351
  type: "summary_request",
1280
1352
  outcome: "ok",
@@ -1751,6 +1823,25 @@ function makeBox(
1751
1823
  return box;
1752
1824
  }
1753
1825
 
1826
+ /**
1827
+ * The shared expanded-detail rule (the read view's): preview the first `max`
1828
+ * lines, and whenever anything was cut, append a muted "... N more lines"
1829
+ * note — a cut with no note reads as the complete output. Every burst tool's
1830
+ * grouped details use this so the expanded view stays even across tools;
1831
+ * missing info is skipped or guarded by the callers, never interpolated.
1832
+ */
1833
+ export function previewLines(txt: string, theme: any, max = 12): string {
1834
+ const lines = txt.split("\n");
1835
+ const shown = lines
1836
+ .slice(0, max)
1837
+ .map((l) => theme.fg("toolOutput", l))
1838
+ .join("\n");
1839
+ const remaining = lines.length - max;
1840
+ return remaining > 0
1841
+ ? `${shown}\n${theme.fg("muted", `... ${remaining} more lines`)}`
1842
+ : shown;
1843
+ }
1844
+
1754
1845
  /** While set, sibling extension tools in this package render in burst style
1755
1846
  * (same contract @bermudi/pi-codex mirrors via Symbol.for). Set at load,
1756
1847
  * cleared when the feature is disabled, so /reload converges. Consumers must
@@ -1835,6 +1926,9 @@ export function createBurstRenderer(spec: BurstToolSpec): {
1835
1926
 
1836
1927
  // solo
1837
1928
  let line = spec.soloHeader(args, theme, ctx);
1929
+ // Image parity with grouped bullets: a solo image read is visibly
1930
+ // marked too (the image itself is painted by pi's image layer).
1931
+ if (entry.hasImage) line += theme.fg("success", " [image]");
1838
1932
  if (ctx.expanded) {
1839
1933
  const extra = spec.soloExpanded(entry, args, theme);
1840
1934
  if (extra) line += `\n${extra}`;
@@ -2067,6 +2161,7 @@ export default function cleanTui(pi: ExtensionAPI): void {
2067
2161
  curAssistantMessage = undefined;
2068
2162
  replaying = true;
2069
2163
  entries.length = 0;
2164
+ entriesBase = 0;
2070
2165
  entryById.clear();
2071
2166
  invalidateById.clear();
2072
2167
  pendingSummaries.clear();
@@ -2081,11 +2176,16 @@ export default function cleanTui(pi: ExtensionAPI): void {
2081
2176
  // undefined and every TUI failure took the console.error branch, flashing
2082
2177
  // raw stderr across the terminal; the widget never showed.
2083
2178
  const ui = (ctx as { ui?: Partial<SummaryUi> } | undefined)?.ui;
2084
- const setWidget = ui?.setWidget;
2085
- if (typeof setWidget === "function") {
2179
+ if (ui && typeof ui.setWidget === "function") {
2180
+ // Call through the ui object — never through a detached copy of the
2181
+ // method. The receiver IS the link back to pi: today ctx.ui.setWidget
2182
+ // arrives as a closure so detaching happens to work, but that is an
2183
+ // implementation detail; a prototype method would lose `this` and
2184
+ // silently break. Tests inject plain functions, so only calling
2185
+ // through the object keeps this honest.
2086
2186
  summaryUi = {
2087
2187
  hasUI: ctx.hasUI,
2088
- setWidget: (key, content) => setWidget(key, content),
2188
+ setWidget: (key, content) => ui.setWidget!(key, content),
2089
2189
  };
2090
2190
  }
2091
2191
  clearSummaryPauseWidget();
@@ -2146,7 +2246,13 @@ export default function cleanTui(pi: ExtensionAPI): void {
2146
2246
  ...createBurstRenderer(spec),
2147
2247
  async execute(toolCallId, params, signal, onUpdate, ctx) {
2148
2248
  const tool = (getBuiltInTools(ctx.cwd) as any)[spec.name];
2149
- return tool.execute(toolCallId, params, signal, onUpdate);
2249
+ // Forward the ENTIRE pi call, ctx included. The built-ins mostly
2250
+ // ignore ctx today (they fall back to their construction cwd), but
2251
+ // not entirely: read's execute already reads ctx.model to append the
2252
+ // visionless-model image note, and any future field would be
2253
+ // silently dropped by a partial passthrough — the kind of quiet
2254
+ // breakage nothing tests because "it works today".
2255
+ return tool.execute(toolCallId, params, signal, onUpdate, ctx);
2150
2256
  },
2151
2257
  });
2152
2258
  }
@@ -2171,16 +2277,9 @@ export default function cleanTui(pi: ExtensionAPI): void {
2171
2277
  }
2172
2278
  const txt = resultText(e.result as any);
2173
2279
  if (!txt) continue;
2174
- const preview = txt
2175
- .split("\n")
2176
- .slice(0, 12)
2177
- .map((l) => theme.fg("toolOutput", l))
2178
- .join("\n");
2179
- const remaining = txt.split("\n").length - 12;
2180
- let block = `\n${theme.fg("muted", `— ${shortenPath(e.args.path || "...")}`)}:\n${preview}`;
2181
- if (remaining > 0)
2182
- block += `\n${theme.fg("muted", `... ${remaining} more lines`)}`;
2183
- details.push(block);
2280
+ details.push(
2281
+ `\n${theme.fg("muted", `— ${shortenPath(e.args.path || "...")}`)}:\n${previewLines(txt, theme)}`,
2282
+ );
2184
2283
  }
2185
2284
  return details.length ? `\n${details.join("\n")}` : "";
2186
2285
  },
@@ -2232,12 +2331,9 @@ export default function cleanTui(pi: ExtensionAPI): void {
2232
2331
  details.push(`\n${theme.fg("muted", `— $ ${cmd}`)}`);
2233
2332
  continue;
2234
2333
  }
2235
- const preview = txt
2236
- .split("\n")
2237
- .slice(0, 12)
2238
- .map((l) => theme.fg("toolOutput", l))
2239
- .join("\n");
2240
- details.push(`\n${theme.fg("muted", `— $ ${cmd}`)}:\n${preview}`);
2334
+ details.push(
2335
+ `\n${theme.fg("muted", `— $ ${cmd}`)}:\n${previewLines(txt, theme)}`,
2336
+ );
2241
2337
  }
2242
2338
  return details.length ? `\n${details.join("\n")}` : "";
2243
2339
  },
@@ -2310,7 +2406,7 @@ export default function cleanTui(pi: ExtensionAPI): void {
2310
2406
  .map((e) => {
2311
2407
  const txt = e.result ? resultText(e.result as any) : undefined;
2312
2408
  return txt
2313
- ? `\n${theme.fg("muted", `— ${shortenPath(e.args.path || "...")}`)}:\n${theme.fg("toolOutput", txt.slice(0, 600))}`
2409
+ ? `\n${theme.fg("muted", `— ${shortenPath(e.args.path || "...")}`)}:\n${previewLines(txt, theme)}`
2314
2410
  : "";
2315
2411
  })
2316
2412
  .join("");
@@ -2338,11 +2434,7 @@ export default function cleanTui(pi: ExtensionAPI): void {
2338
2434
  ? resultText(e.result as any)?.trim()
2339
2435
  : undefined;
2340
2436
  return txt
2341
- ? `\n${theme.fg("muted", `— ${e.args.pattern}`)}:\n${txt
2342
- .split("\n")
2343
- .slice(0, 10)
2344
- .map((l) => theme.fg("toolOutput", l))
2345
- .join("\n")}`
2437
+ ? `\n${theme.fg("muted", `— ${e.args.pattern ?? ""}`)}:\n${previewLines(txt, theme)}`
2346
2438
  : "";
2347
2439
  })
2348
2440
  .join("");
@@ -2375,11 +2467,7 @@ export default function cleanTui(pi: ExtensionAPI): void {
2375
2467
  ? resultText(e.result as any)?.trim()
2376
2468
  : undefined;
2377
2469
  return txt
2378
- ? `\n${theme.fg("muted", `— /${e.args.pattern}/`)}:\n${txt
2379
- .split("\n")
2380
- .slice(0, 10)
2381
- .map((l) => theme.fg("toolOutput", l))
2382
- .join("\n")}`
2470
+ ? `\n${theme.fg("muted", `— /${e.args.pattern ?? ""}/`)}:\n${previewLines(txt, theme)}`
2383
2471
  : "";
2384
2472
  })
2385
2473
  .join("");
@@ -2414,11 +2502,7 @@ export default function cleanTui(pi: ExtensionAPI): void {
2414
2502
  ? resultText(e.result as any)?.trim()
2415
2503
  : undefined;
2416
2504
  return txt
2417
- ? `\n${theme.fg("muted", `— ${shortenPath(e.args.path || ".")}`)}:\n${txt
2418
- .split("\n")
2419
- .slice(0, 10)
2420
- .map((l) => theme.fg("toolOutput", l))
2421
- .join("\n")}`
2505
+ ? `\n${theme.fg("muted", `— ${shortenPath(e.args.path || ".")}`)}:\n${previewLines(txt, theme)}`
2422
2506
  : "";
2423
2507
  })
2424
2508
  .join("");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bermudis-pi-goodies",
3
- "version": "0.24.0",
3
+ "version": "0.24.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/bermudi/agent-extensions.git",
package/side.ts CHANGED
@@ -508,7 +508,7 @@ function sideMarkerFromBranch(
508
508
 
509
509
  /**
510
510
  * The model actually serving this side limb: the branch's last model_change
511
- * after the marker (a mid-side /side provider/x swap updates it), falling
511
+ * after the marker (a mid-side ctrl+l switch updates it), falling
512
512
  * back to the model the marker recorded.
513
513
  */
514
514
  export function activeSideModel(
@@ -605,7 +605,7 @@ export default function side(pi: ExtensionAPI): void {
605
605
 
606
606
  // Restore the badge when a session resumes already on a side limb. The
607
607
  // effective model is the branch's last model_change (a mid-side
608
- // /side provider/x swap updates it; the marker keeps the original).
608
+ // ctrl+l switch updates it; the marker keeps the original).
609
609
  pi.on("session_start", (_event, ctx) => {
610
610
  modelRegistryRef = ctx.modelRegistry; // for /side argument completions
611
611
  const marker = sideMarkerFromBranch(ctx);
@@ -675,9 +675,12 @@ export default function side(pi: ExtensionAPI): void {
675
675
  const markerIdx = findSideBoundary(branch);
676
676
  const arg = args.trim();
677
677
 
678
- if (markerIdx !== -1 && arg === "") {
678
+ // Model switching inside a side session is pi's own ctrl+l — the
679
+ // model_select handler keeps the badge honest and /side-exit reads the
680
+ // live model — so /side has no in-side behavior at all.
681
+ if (markerIdx !== -1) {
679
682
  ctx.ui.notify(
680
- "side: already in a side session — /side-exit to return, or /side provider/model-id to swap the model",
683
+ "side: already in a side session — ctrl+l switches the side model, /side-exit to return",
681
684
  "warning",
682
685
  );
683
686
  return;
@@ -736,12 +739,6 @@ export default function side(pi: ExtensionAPI): void {
736
739
  }
737
740
  updateBadge(ctx, model);
738
741
 
739
- if (markerIdx !== -1) {
740
- ctx.ui.notify(`side model swapped to ${modelRef(model)}`, "info");
741
- logGoodiesEvent({ type: "side_model_swap", model: modelRef(model) });
742
- return;
743
- }
744
-
745
742
  // setModel appends a model_change entry; the marker branches from the
746
743
  // new leaf so the parked tip includes it.
747
744
  const mainTipId = ctx.sessionManager.getLeafId();
@@ -798,7 +795,7 @@ export default function side(pi: ExtensionAPI): void {
798
795
  const branch = ctx.sessionManager.getBranch();
799
796
  const markerIdx = findSideBoundary(branch);
800
797
  // The handoff must speak for the model actually serving the side limb
801
- // at exit — a mid-side /side provider/x swap updates model_change
798
+ // at exit — a mid-side ctrl+l switch updates model_change
802
799
  // entries, not the marker.
803
800
  const effectiveSide = activeSideModel(branch, marker.data);
804
801
  const sideEntries = branch.slice(markerIdx + 1);
package/vision.ts CHANGED
@@ -45,6 +45,7 @@ import type {
45
45
  import {
46
46
  createBurstRenderer,
47
47
  isCleanTuiActive,
48
+ previewLines,
48
49
  shortenPath,
49
50
  } from "./clean-tui.ts";
50
51
 
@@ -153,11 +154,16 @@ const visionBurstSpec = {
153
154
  groupedDetails(entries: any[], theme: any) {
154
155
  return entries
155
156
  .map((e) => {
156
- const label = `— ${shortenPath(e.args.path || "...")}${e.args.followUp ? " (follow-up)" : ""}: "${questionPreview(e.args.prompt, 90)}"`;
157
+ // Read-view rule: info that is missing is skipped, never rendered as
158
+ // junk (no `: ""` when a prompt is absent) — and long answers get the
159
+ // same 12-line preview + "... N more lines" note as every other tool.
160
+ const follow = e.args.followUp ? " (follow-up)" : "";
161
+ const q = questionPreview(e.args.prompt, 90);
162
+ const label = `— ${shortenPath(e.args.path || "...")}${follow}${q ? `: "${q}"` : ""}`;
157
163
  if (!e.result) return `\n${theme.fg("warning", `${label} (pending)`)}`;
158
164
  const txt = answerText(e.result);
159
165
  if (!txt) return `\n${theme.fg("muted", label)}`;
160
- return `\n${theme.fg("muted", label)}\n${e.isError ? theme.fg("error", txt) : theme.fg("toolOutput", txt)}`;
166
+ return `\n${theme.fg("muted", label)}\n${e.isError ? theme.fg("error", txt) : previewLines(txt, theme)}`;
161
167
  })
162
168
  .join("");
163
169
  },