bermudis-pi-goodies 0.13.2 → 0.13.4

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 (3) hide show
  1. package/README.md +16 -4
  2. package/clean-tui.ts +144 -20
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -23,7 +23,7 @@ extensions. One entry point, eleven independent features.
23
23
  After publishing the package to npm:
24
24
 
25
25
  ```bash
26
- pi install npm:bermudis-pi-goodies@0.13.2
26
+ pi install npm:bermudis-pi-goodies@0.13.4
27
27
  ```
28
28
 
29
29
  Remove any old `bermudis-pi-goodies.ts` symlink before reloading Pi. Each
@@ -53,6 +53,11 @@ are no extra endpoints or keys to configure. `/goodies list` shows whether
53
53
  summaries are on or off, and `/goodies summary-model off` disables them
54
54
  again.
55
55
 
56
+ Failures are invisible in the TUI (pi owns the terminal), so each distinct
57
+ failure is also appended to `~/.pi/agent/goodies.log` (capped at 256 KB,
58
+ oldest lines dropped), along with one `clean-tui active; summary-model=…`
59
+ line per load. If summaries silently stop, look there first.
60
+
56
61
  Two practical notes:
57
62
 
58
63
  - **Pick a fast non-thinking model.** Summaries get a tiny response budget;
@@ -93,12 +98,19 @@ clean-tui therefore follows two rules in its render paths:
93
98
  - **Height-neutral swaps:** text that may be replaced by a summary later is
94
99
  capped at the same width as summaries (`BASH_BULLET_WIDTH`), so an
95
100
  arriving summary never collapses a wrapped line (rule 1).
96
- - **Pending-only refresh:** when a summary lands, only rows whose tool is
97
- still executing are re-rendered — those sit at the transcript tail, inside
98
- the viewport, so the swap is a cheap differential update. Finished rows
101
+ - **Tail-only refresh:** when a summary lands, only rows that are still
102
+ executing, or that finished while their summary was in flight (bounded by
103
+ a ~10s freshness window), are re-rendered — at landing such rows sit at
104
+ the transcript tail, inside the viewport, so the swap is a cheap
105
+ differential update. This is what lets fast commands — finished before
106
+ the ~2s summary arrives — show their summary at all. Older finished rows
99
107
  (including replayed ones from before a `/resume`) keep the raw command
100
108
  text for the rest of the session; the summary stays cached, and future
101
109
  rows of the same command render it from the start (rule 2).
110
+ - **Queued, not dropped:** burst rows beyond the two-concurrent-requests
111
+ cap, and requests deferred by failure backoff, are queued and drained
112
+ when a slot frees — never silently dropped (their rows may never
113
+ re-render to retry).
102
114
 
103
115
  To catch a flash red-handed, run `PI_DEBUG_REDRAW=1 pi`, reproduce, then
104
116
  `grep fullRender ~/.pi/agent/pi-debug.log` — every line is one screen wipe
package/clean-tui.ts CHANGED
@@ -35,6 +35,8 @@ import {
35
35
  } from "@earendil-works/pi-coding-agent";
36
36
  import { Box, Container, Text } from "@earendil-works/pi-tui";
37
37
  import { homedir } from "node:os";
38
+ import { join } from "node:path";
39
+ import { appendFileSync, readFileSync, statSync, writeFileSync } from "node:fs";
38
40
  import { completeSimple } from "@earendil-works/pi-ai/compat";
39
41
  import {
40
42
  findSummaryModel,
@@ -132,6 +134,14 @@ type Entry = {
132
134
  hasImage?: boolean;
133
135
  /** Content array of the last result seen; detects real mutations vs re-renders. */
134
136
  contentRef?: unknown;
137
+ /**
138
+ * When a summary request was first fired for this entry's command. A row
139
+ * whose result landed after this stamp finished while its summary was in
140
+ * flight — it may still swap when the summary lands (viewport-tail safe).
141
+ */
142
+ summaryRequestedAt?: number;
143
+ /** When the first genuine tool result landed (stamped in recordResult). */
144
+ resultAt?: number;
135
145
  };
136
146
 
137
147
  let liveSeg = 0;
@@ -270,6 +280,7 @@ function recordResult(entry: Entry | undefined, result: any, ctx: any) {
270
280
  if (!entry || entry.contentRef === result?.content) return;
271
281
  entry.contentRef = result?.content;
272
282
  entry.result = result;
283
+ entry.resultAt = Date.now();
273
284
  entry.isError = !!ctx.isError || !!result.isError;
274
285
  entry.hasImage = hasImageContent(result);
275
286
  revalidateBurstsAround(entry.toolCallId);
@@ -312,6 +323,10 @@ const SUMMARY_ERROR_SNIPPET_CHARS = 200;
312
323
 
313
324
  const summaryCache = new Map<string, string>();
314
325
  const pendingSummaries = new Set<string>();
326
+ // Commands whose requests were deferred by the inflight cap or a failure
327
+ // backoff. Drained whenever a slot frees (request settle) or a later
328
+ // renderCall finds capacity — no timers. Cleared on session switch.
329
+ const summaryRequestQueue: string[] = [];
315
330
  // A burst of distinct long commands can fan out N simultaneous renders; keep
316
331
  // concurrent provider requests bounded so we don't hammer the rate limiter.
317
332
  const SUMMARY_MAX_INFLIGHT = 2;
@@ -323,6 +338,7 @@ export function __setSummaryEnabled(v: boolean): void {
323
338
  export function __clearSummaryCache(): void {
324
339
  summaryCache.clear();
325
340
  pendingSummaries.clear();
341
+ summaryRequestQueue.length = 0;
326
342
  summaryFailuresLogged.clear();
327
343
  summaryFailStreak = 0;
328
344
  summaryBlockedUntil = 0;
@@ -449,10 +465,48 @@ function logSummaryFailure(cmd: string, err: unknown, pauseMs?: number) {
449
465
  const key = `${msg}${pause}`;
450
466
  if (summaryFailuresLogged.has(key)) return;
451
467
  summaryFailuresLogged.add(key);
452
- console.error(
453
- `[clean-tui] command summary failed (${msg})${pause}; keeping heuristic hint. ` +
454
- `Command starts: ${JSON.stringify(cmd.slice(0, 60))}`,
455
- );
468
+ const detail =
469
+ `command summary failed (${msg})${pause}; keeping heuristic hint. ` +
470
+ `Command starts: ${JSON.stringify(cmd.slice(0, 60))}`;
471
+ console.error(`[clean-tui] ${detail}`);
472
+ appendSummaryLog(detail);
473
+ }
474
+
475
+ // ── Summary log file ────────────────────────────────────────────
476
+ //
477
+ // console.error is invisible in TUI mode (pi owns the terminal), so summary
478
+ // failures also append to a small capped log file next to goodies.json and
479
+ // pi-debug.log. Failures only — successes are noise. Every fs call is
480
+ // guarded: an unwritable log destination must never break rendering or
481
+ // swallow the original error (console.error above already carried it).
482
+ const SUMMARY_LOG_DEFAULT_PATH = join(homedir(), ".pi", "agent", "goodies.log");
483
+ let summaryLogPath = SUMMARY_LOG_DEFAULT_PATH;
484
+ const SUMMARY_LOG_MAX_BYTES = 256 * 1024;
485
+ const SUMMARY_LOG_KEEP_BYTES = 64 * 1024;
486
+
487
+ /** Redirect the failure log (tests point this at scratch storage). */
488
+ export function __setSummaryLogPathForTesting(path?: string): void {
489
+ summaryLogPath = path ?? SUMMARY_LOG_DEFAULT_PATH;
490
+ }
491
+
492
+ function appendSummaryLog(line: string): void {
493
+ const stamped = `${new Date().toISOString()} [${process.pid}] ${line}`;
494
+ try {
495
+ if (statSync(summaryLogPath).size > SUMMARY_LOG_MAX_BYTES) {
496
+ // Size cap without timers or rotation daemons: keep the newest tail.
497
+ writeFileSync(
498
+ summaryLogPath,
499
+ readFileSync(summaryLogPath).subarray(-SUMMARY_LOG_KEEP_BYTES),
500
+ );
501
+ }
502
+ } catch {
503
+ // Missing/unreadable file — the append below (re)creates it.
504
+ }
505
+ try {
506
+ appendFileSync(summaryLogPath, `${stamped}\n`);
507
+ } catch {
508
+ // Unwritable destination — nothing else to do; console.error carried it.
509
+ }
456
510
  }
457
511
 
458
512
  // Failure backoff: requestSummary runs on every bash renderCall, so after a
@@ -494,6 +548,9 @@ function noteSummaryFailure(err: unknown): number {
494
548
  }
495
549
 
496
550
  function requestSummary(cmd: string): void {
551
+ // Deferred requests drain here too: renderCalls are the heartbeat that
552
+ // notices backoff expiry when nothing else is in flight.
553
+ drainSummaryQueue();
497
554
  // Guard order matters: renderCall fires on every rerender, so all guards
498
555
  // here are cheap sync checks, and anything that can differ across rerenders
499
556
  // of the same command must not mutate state (mutating in a render path once
@@ -503,12 +560,29 @@ function requestSummary(cmd: string): void {
503
560
  replaying ||
504
561
  !isSummarizable(cmd) ||
505
562
  !getSummaryModel() || // unset = feature off: no resolution, no network.
506
- summaryCache.has(cmd) ||
507
- pendingSummaries.has(cmd) ||
563
+ summaryCache.has(cmd)
564
+ )
565
+ return;
566
+ // Stamp before any deferral: a row whose result lands while its summary is
567
+ // queued or in flight may still swap when the summary arrives (see
568
+ // invalidateRowsForCommand) — at landing such rows are at most one summary
569
+ // latency old, so they sit at the viewport tail.
570
+ stampSummaryRequested(cmd);
571
+ if (pendingSummaries.has(cmd) || summaryRequestQueue.includes(cmd)) return;
572
+ if (
508
573
  Date.now() < summaryBlockedUntil ||
509
574
  pendingSummaries.size >= SUMMARY_MAX_INFLIGHT
510
- )
575
+ ) {
576
+ // Defer, don't drop: a burst of N commands renders faster than summaries
577
+ // complete, and a dropped request would never retry (its row may not
578
+ // re-render). Drained on settle and on later renderCalls.
579
+ summaryRequestQueue.push(cmd);
511
580
  return;
581
+ }
582
+ startSummaryRequest(cmd);
583
+ }
584
+
585
+ function startSummaryRequest(cmd: string): void {
512
586
  pendingSummaries.add(cmd);
513
587
  const signal = summarySessionAbort.signal;
514
588
  activeBackend()
@@ -533,29 +607,72 @@ function requestSummary(cmd: string): void {
533
607
  if (signal.aborted || (err as Error)?.name === "AbortError") return;
534
608
  const pauseMs = noteSummaryFailure(err);
535
609
  logSummaryFailure(cmd, err, pauseMs);
610
+ })
611
+ .finally(() => {
612
+ if (!signal.aborted) drainSummaryQueue();
536
613
  });
537
614
  }
538
615
 
616
+ /** Start queued requests while capacity allows and no backoff is active. */
617
+ function drainSummaryQueue(): void {
618
+ while (
619
+ summaryRequestQueue.length > 0 &&
620
+ pendingSummaries.size < SUMMARY_MAX_INFLIGHT &&
621
+ Date.now() >= summaryBlockedUntil
622
+ ) {
623
+ const cmd = summaryRequestQueue.shift()!;
624
+ if (summaryCache.has(cmd) || pendingSummaries.has(cmd)) continue;
625
+ startSummaryRequest(cmd);
626
+ }
627
+ }
628
+
629
+ function stampSummaryRequested(cmd: string): void {
630
+ const now = Date.now();
631
+ for (const e of entries) {
632
+ if (e.args?.command === cmd && e.summaryRequestedAt === undefined)
633
+ e.summaryRequestedAt = now;
634
+ }
635
+ }
636
+
539
637
  function invalidateRowsForCommand(cmd: string): void {
540
- // Only rows that are STILL EXECUTING get refreshed when a summary lands.
541
- // Executing rows sit at the transcript tail, inside pi's viewport, so the
542
- // re-render is a cheap differential line update. Finished rows — including
543
- // replayed ones from before a /resume — can sit far above the viewport on a
544
- // long transcript, and pi's diff renderer answers any change above the
545
- // viewport with fullRender(true): clear screen + scrollback wipe + full
546
- // repaint, i.e. the "flicker while pi is working" seen on 0.11.x (fits any
547
- // width; reproduced reasoning from tui-main-screen.js `firstChanged <
548
- // prevViewportTop`). Finished rows simply keep the raw command text, which
549
- // is more informative than the summary anyway; the summary stays cached
550
- // and future rows of the same command render it from the start.
638
+ // Refresh rows that are STILL EXECUTING, plus rows that finished while
639
+ // their summary was in flight — at landing those are at most one summary
640
+ // latency old, so they sit at the viewport tail and a differential
641
+ // re-render is safe. This is what makes fast commands (finished before the
642
+ // ~2s summary arrives) visibly summarize at all. Older finished rows —
643
+ // including replayed ones from before a /resume — keep the raw command
644
+ // text: they can sit far above the viewport on a long transcript, and pi's
645
+ // diff renderer answers any change above the viewport with fullRender(true):
646
+ // clear screen + scrollback wipe + full repaint, i.e. the "flicker while pi
647
+ // is working" seen on 0.11.x. The summary stays cached either way, and
648
+ // future rows of the same command render it from the start.
551
649
  for (const e of entries) {
552
- if (!e.result && e.args?.command === cmd) {
650
+ if (e.args?.command !== cmd) continue;
651
+ const finishedDuringFlight =
652
+ e.resultAt !== undefined &&
653
+ e.summaryRequestedAt !== undefined &&
654
+ e.resultAt >= e.summaryRequestedAt &&
655
+ // Strictly younger than the window: a zero window must admit nothing,
656
+ // and stamp/result often land in the same millisecond in tests.
657
+ Date.now() - e.resultAt < summarySwapMaxAgeMs;
658
+ if (!e.result || finishedDuringFlight) {
553
659
  const fn = invalidateById.get(e.toolCallId);
554
660
  if (fn) fn();
555
661
  }
556
662
  }
557
663
  }
558
664
 
665
+ // Bounds how long after finishing a row may still swap. Covers the normal
666
+ // race (summary lands ~2s after start, row finished ≤2s ago) with margin;
667
+ // backoff-delayed landings (30s+) exceed it and correctly keep raw text,
668
+ // since such rows may have scrolled above the viewport.
669
+ const SUMMARY_SWAP_MAX_AGE_MS = 10_000;
670
+ let summarySwapMaxAgeMs = SUMMARY_SWAP_MAX_AGE_MS;
671
+
672
+ export function __setSummarySwapMaxAgeForTesting(ms: number): void {
673
+ summarySwapMaxAgeMs = ms;
674
+ }
675
+
559
676
  function bgFor(
560
677
  pending: boolean,
561
678
  isError: boolean,
@@ -698,6 +815,11 @@ function formatLsBullet(entry: Entry, theme: any): string {
698
815
 
699
816
  export default function cleanTui(pi: ExtensionAPI): void {
700
817
  setCleanTuiActive(true);
818
+ // One line per load (pi process start, /reload) so the log answers "was the
819
+ // feature even on, and pointing at which model" without guessing.
820
+ appendSummaryLog(
821
+ `clean-tui active; summary-model=${getSummaryModel() ?? "off"}`,
822
+ );
701
823
  const schemaTools = getBuiltInTools(process.cwd());
702
824
 
703
825
  pi.on("agent_start", (_event, ctx) => {
@@ -749,6 +871,7 @@ export default function cleanTui(pi: ExtensionAPI): void {
749
871
  entryById.clear();
750
872
  invalidateById.clear();
751
873
  pendingSummaries.clear();
874
+ summaryRequestQueue.length = 0;
752
875
  // Capture the registry slice render context lacks, and cut off any
753
876
  // summaries still in flight from the previous session.
754
877
  const modelRegistry = (
@@ -889,8 +1012,9 @@ export default function cleanTui(pi: ExtensionAPI): void {
889
1012
  );
890
1013
  },
891
1014
  renderCall(args, theme, ctx: any) {
892
- if (args.command) requestSummary(args.command);
1015
+ // Upsert before requestSummary so the stamp sees this row's entry.
893
1016
  const entry = upsertEntry(ctx.toolCallId, "bash", args, ctx.invalidate);
1017
+ if (args.command) requestSummary(args.command);
894
1018
  const burst = getBurstForId(ctx.toolCallId);
895
1019
  const isGrouped = burst && burst.entries.length > 1;
896
1020
  const isLeader =
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bermudis-pi-goodies",
3
- "version": "0.13.2",
3
+ "version": "0.13.4",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/bermudi/agent-extensions.git",