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.
- package/README.md +16 -4
- package/clean-tui.ts +144 -20
- 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.
|
|
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
|
-
- **
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
453
|
-
`
|
|
454
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
541
|
-
//
|
|
542
|
-
//
|
|
543
|
-
//
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
//
|
|
549
|
-
// is
|
|
550
|
-
//
|
|
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 (
|
|
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
|
-
|
|
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 =
|