@aragon-agent/cli 0.5.6 → 0.5.8
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/CHANGELOG.md +77 -0
- package/README.md +60 -2
- package/dist/agent/entry-limits.d.ts +65 -0
- package/dist/agent/entry-limits.js +133 -0
- package/dist/agent/reducer.d.ts +8 -0
- package/dist/agent/reducer.js +67 -15
- package/dist/cli.js +36 -2
- package/dist/commands/builtins.js +5 -0
- package/dist/commands/perf.d.ts +45 -0
- package/dist/commands/perf.js +78 -0
- package/dist/config/env.d.ts +3 -1
- package/dist/config/env.js +31 -1
- package/dist/config/load.d.ts +14 -0
- package/dist/config/load.js +43 -2
- package/dist/config/schema.d.ts +61 -8
- package/dist/config/schema.js +43 -6
- package/dist/ui/App.js +86 -9
- package/dist/ui/Markdown.d.ts +7 -1
- package/dist/ui/Markdown.js +15 -48
- package/dist/ui/StatusBar.d.ts +13 -0
- package/dist/ui/StatusBar.js +4 -2
- package/dist/ui/Transcript.d.ts +56 -4
- package/dist/ui/Transcript.js +112 -10
- package/dist/ui/entries/AssistantEntry.d.ts +18 -1
- package/dist/ui/entries/AssistantEntry.js +33 -3
- package/dist/ui/entries/TeamCard.d.ts +9 -1
- package/dist/ui/entries/TeamCard.js +22 -1
- package/dist/ui/entries/TodoCard.d.ts +9 -1
- package/dist/ui/entries/TodoCard.js +23 -1
- package/dist/ui/entries/ToolCard.d.ts +13 -1
- package/dist/ui/entries/ToolCard.js +39 -5
- package/dist/ui/entries/ToolPreview.d.ts +2 -2
- package/dist/ui/entries/UserEntry.d.ts +9 -1
- package/dist/ui/entries/UserEntry.js +17 -1
- package/dist/ui/exit-snapshot.d.ts +7 -0
- package/dist/ui/layout/MeasuredEntry.d.ts +21 -0
- package/dist/ui/layout/MeasuredEntry.js +30 -0
- package/dist/ui/layout/ScrollViewport.js +8 -2
- package/dist/ui/layout/viewport-geometry.d.ts +28 -0
- package/dist/ui/layout/viewport-geometry.js +27 -0
- package/dist/ui/layout/virtual-window.d.ts +100 -0
- package/dist/ui/layout/virtual-window.js +204 -0
- package/dist/ui/live-clamp.d.ts +42 -0
- package/dist/ui/live-clamp.js +74 -0
- package/dist/ui/markdown-blocks.d.ts +25 -1
- package/dist/ui/markdown-blocks.js +43 -0
- package/dist/ui/render-cache.d.ts +81 -0
- package/dist/ui/render-cache.js +195 -0
- package/dist/ui/render-governor.d.ts +54 -0
- package/dist/ui/render-governor.js +78 -0
- package/dist/ui/transcript-text.d.ts +11 -0
- package/dist/ui/transcript-text.js +5 -0
- package/dist/ui/use-height-store.d.ts +31 -0
- package/dist/ui/use-height-store.js +76 -0
- package/dist/ui/use-render-governor.d.ts +39 -0
- package/dist/ui/use-render-governor.js +57 -0
- package/dist/ui/use-startup-notices.d.ts +9 -1
- package/dist/ui/use-startup-notices.js +35 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,8 +7,85 @@ were written.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- **A long session stays as fast as a short one.** The TUI used to get slower as
|
|
13
|
+
a session grew and, past a threshold, stopped making progress at all. That was
|
|
14
|
+
structural rather than incidental: Ink has no output caching, so every frame
|
|
15
|
+
re-walked the entire mounted tree, re-measured every text node and
|
|
16
|
+
re-serialised the frame. The per-frame cost was therefore proportional to every
|
|
17
|
+
character the session had ever produced — and `transcriptWindow` bounded the
|
|
18
|
+
entry *count*, which bounds nothing when one entry can be fifty thousand lines.
|
|
19
|
+
|
|
20
|
+
Entries outside the visible band are no longer mounted. The viewport renders
|
|
21
|
+
what you can see plus a couple either side and replaces the rest with two
|
|
22
|
+
spacers of the right height, so the frame costs what the screen costs.
|
|
23
|
+
Scrolling, the `↑N` readout and the wheel routing are untouched. Because the
|
|
24
|
+
horizon is no longer a rendering budget, `transcriptWindow` rises from 300 to
|
|
25
|
+
1000 and its ceiling from 2000 to 20000; an existing setting is still valid
|
|
26
|
+
and still clamps.
|
|
27
|
+
|
|
28
|
+
Syntax highlighting and markdown parsing are memoised on their input, so a
|
|
29
|
+
200-line code block is tokenised once rather than thirty times a second for as
|
|
30
|
+
long as it stays on screen. Only the *parsed* form is cached and never a themed
|
|
31
|
+
one, so `/theme` still takes effect on the next frame.
|
|
32
|
+
|
|
33
|
+
Under genuine load the streaming interval widens — 33 ms up to a 320 ms
|
|
34
|
+
ceiling — so keystrokes and `Ctrl+C` do not queue behind renders. This is
|
|
35
|
+
visible rather than silent: a muted **`eco`** chip appears in the status bar
|
|
36
|
+
while it is in effect, because a user who cannot see why the stream got
|
|
37
|
+
chunkier will conclude the model got slower. `--no-render-governor`,
|
|
38
|
+
`renderGovernor: false` or `ARAGON_RENDER_GOVERNOR=0` turns it off;
|
|
39
|
+
`--max-render-interval 33` flattens the ladder without disabling the mechanism.
|
|
40
|
+
|
|
41
|
+
- **A live thinking block no longer grows without limit.** While an answer is
|
|
42
|
+
streaming, visible thinking is clamped to its newest 24 rows behind a
|
|
43
|
+
`... N earlier lines` marker, and the whole block renders again the moment the
|
|
44
|
+
answer settles. Thinking is scratch reasoning you read from its newest end, and
|
|
45
|
+
an unbounded one was the easiest way for a single live entry to fill the
|
|
46
|
+
viewport on its own.
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- **Inline mode could hard-freeze the terminal, and now cannot.** Once a live
|
|
51
|
+
entry grew taller than the terminal, Ink took a branch that writes
|
|
52
|
+
`clearTerminal` plus the *entire accumulated session history* on every
|
|
53
|
+
subsequent frame — bypassing both of its own dedupe gates, and growing without
|
|
54
|
+
bound for the rest of the session. Any inline user (`--no-fullscreen`,
|
|
55
|
+
`TERM=dumb`, CI, a terminal under 12 rows or 40 columns) whose model wrote a
|
|
56
|
+
long answer could reach it. The live region is now clamped below that
|
|
57
|
+
threshold, with a `... N earlier lines` marker naming what is deferred. Nothing
|
|
58
|
+
is lost: the entry prints in full into the terminal's own scrollback the moment
|
|
59
|
+
it settles.
|
|
60
|
+
|
|
61
|
+
- **The view state no longer grows without a ceiling.** A single assistant answer
|
|
62
|
+
is capped at 256 KiB in the view (thinking at 64 KiB, streamed tool arguments
|
|
63
|
+
at 16 KiB) by eliding the MIDDLE, so both the opening and the live cursor
|
|
64
|
+
survive; a marker states how many characters went. `transcriptRetain`
|
|
65
|
+
(default 1000) caps the entries kept in memory, and what it removed is counted
|
|
66
|
+
and reported both by `/perf` and at the top of the exit replay. A model that
|
|
67
|
+
emits five megabytes no longer makes every later frame a five-megabyte scan.
|
|
68
|
+
|
|
10
69
|
### Added
|
|
11
70
|
|
|
71
|
+
- **`/perf`.** Reports what the renderer is actually doing: the frame interval
|
|
72
|
+
and the last commit cost, how many entries exist / are retained / were dropped
|
|
73
|
+
/ are mounted, height-cache and render-cache occupancy, and the viewport
|
|
74
|
+
geometry. `/perf reset` clears the caches and returns the governor to its
|
|
75
|
+
fastest rung. Silent degradation is the one thing a claim of robustness cannot
|
|
76
|
+
afford, and this is the place that names it.
|
|
77
|
+
|
|
78
|
+
- **Three configuration keys**, each with a flag and an environment override:
|
|
79
|
+
`transcriptRetain` (`--transcript-retain`, `ARAGON_TRANSCRIPT_RETAIN`),
|
|
80
|
+
`renderGovernor` (`--render-governor` / `--no-render-governor`,
|
|
81
|
+
`ARAGON_RENDER_GOVERNOR`) and `maxRenderIntervalMs`
|
|
82
|
+
(`--max-render-interval`, `ARAGON_MAX_RENDER_INTERVAL_MS`). No migration and no
|
|
83
|
+
config version bump: a file written before they existed loads unchanged.
|
|
84
|
+
`transcriptRetain` is raised to `transcriptWindow` when the two conflict —
|
|
85
|
+
retaining less than you can scroll to would make part of the horizon
|
|
86
|
+
unreachable — and a startup notice names both numbers, because overriding a
|
|
87
|
+
value you typed and saying nothing is the one thing none of this may do.
|
|
88
|
+
|
|
12
89
|
- **Following the plan through, on a budget you can do arithmetic on.** A run
|
|
13
90
|
that ended with steps outstanding used to print one sentence and stop. It now
|
|
14
91
|
asks three questions the CLI already knew the answers to — did you abort it,
|
package/README.md
CHANGED
|
@@ -80,6 +80,9 @@ aragon --help | -h Print help
|
|
|
80
80
|
| `--no-skills` | Disable the skill system entirely (no catalog, no skill tools) |
|
|
81
81
|
| `--skill <name>` | Force-load a skill's full body for this run (repeatable) |
|
|
82
82
|
| `--skills-yes` | Approve skill installs for this run (CI / headless) |
|
|
83
|
+
| `--transcript-retain <n>` | Entries kept in memory, clamped to `[200, 20000]`. Older ones are dropped and counted |
|
|
84
|
+
| `--render-governor` / `--no-render-governor` | Adapt the frame interval under load, or pin it at 33 ms |
|
|
85
|
+
| `--max-render-interval <ms>` | Governor ceiling, clamped to `[33, 1000]`. `33` flattens the ladder |
|
|
83
86
|
| `--no-color` | Disable ANSI color |
|
|
84
87
|
| `--quiet` | (print mode) suppress tool/usage lines on stderr |
|
|
85
88
|
|
|
@@ -107,6 +110,7 @@ Exit codes: `0` success · `1` agent/runtime error · `2` config/usage error ·
|
|
|
107
110
|
| `/theme <auto\|warm\|cool\|light>` | Switch the color theme live (persisted) |
|
|
108
111
|
| `/expand [n]` | Expand / collapse the n-th-from-last (default last) tool card |
|
|
109
112
|
| `/copy` | Copy the last answer to the clipboard |
|
|
113
|
+
| `/perf` | What the renderer is doing: frame interval, mounted entries, dropped entries, cache occupancy. `/perf reset` clears the caches and the governor |
|
|
110
114
|
| `/logs` | Show where this session is being logged, and at what level |
|
|
111
115
|
| `/reload` | Re-read `config.json` after editing it in another window (refused mid-run) |
|
|
112
116
|
| `/skills [sub]` | List and manage skills (see below) |
|
|
@@ -220,6 +224,55 @@ this mode, and mouse tracking is deliberately left off because enabling it costs
|
|
|
220
224
|
text selection and copy in most terminals. If a crash ever strands your terminal
|
|
221
225
|
on the alternate screen, `reset` restores it.
|
|
222
226
|
|
|
227
|
+
## Render performance
|
|
228
|
+
|
|
229
|
+
A long session used to get slower and slower, and past a point it stopped
|
|
230
|
+
responding at all. The cause was structural rather than incidental: Ink has no
|
|
231
|
+
output caching, so every frame re-walked the whole mounted tree, re-measured
|
|
232
|
+
every text node and re-serialised the frame. The cost was therefore proportional
|
|
233
|
+
to **every character the session had ever produced**, not to the characters
|
|
234
|
+
actually on screen — and `transcriptWindow` bounded the entry *count*, which is
|
|
235
|
+
not the same thing when one entry can be fifty thousand lines.
|
|
236
|
+
|
|
237
|
+
Four things changed, and you should not have to think about any of them:
|
|
238
|
+
|
|
239
|
+
- **Off-screen entries are not mounted.** The viewport renders the band you can
|
|
240
|
+
see plus a couple of entries either side, and replaces the rest with two
|
|
241
|
+
spacers of the right height. Scrolling and the `↑N` readout are unchanged.
|
|
242
|
+
- **The expensive pure work is cached.** Syntax highlighting and markdown
|
|
243
|
+
parsing now run once per distinct block instead of once per frame. Themes are
|
|
244
|
+
still applied at render time, so `/theme` takes effect immediately.
|
|
245
|
+
- **The transcript is bounded.** A single answer cannot exceed 256 KiB in the
|
|
246
|
+
view, and `transcriptRetain` caps how many entries are kept. Both bounds
|
|
247
|
+
report what they removed rather than dropping it silently.
|
|
248
|
+
- **The frame interval adapts.** If a frame is genuinely expensive, updates
|
|
249
|
+
coalesce into a wider window — 33 ms up to a 320 ms ceiling — so the terminal
|
|
250
|
+
stays responsive to typing and to `Ctrl+C`. A muted **`eco`** chip appears in
|
|
251
|
+
the status bar whenever this is in effect: the stream getting chunkier is
|
|
252
|
+
something you should be able to see a reason for.
|
|
253
|
+
|
|
254
|
+
`/perf` reports exactly what the renderer is doing:
|
|
255
|
+
|
|
256
|
+
```
|
|
257
|
+
render rung 2 - interval 80ms - last commit 61ms - eco
|
|
258
|
+
transcript 4213 entries - 1000 retained - 3213 dropped - 38 mounted
|
|
259
|
+
heights 412 cached - 6 estimated - cols 132
|
|
260
|
+
caches md 178 (1.9MB) - hl 96 (1.4MB) - lines 340
|
|
261
|
+
mode fullscreen - viewport 44 rows - offset 0
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
`/perf reset` clears the render caches and returns the governor to its fastest
|
|
265
|
+
rung. `--no-render-governor` (or `renderGovernor: false`, or
|
|
266
|
+
`ARAGON_RENDER_GOVERNOR=0`) turns the adaptation off entirely; the output is
|
|
267
|
+
identical, just heavier under load.
|
|
268
|
+
|
|
269
|
+
**Inline mode** (`--no-fullscreen`, `TERM=dumb`, CI, a very small terminal) gets
|
|
270
|
+
one extra guarantee. Ink repaints the *entire session history* on every frame
|
|
271
|
+
once a live entry grows taller than the terminal, which is a genuine freeze
|
|
272
|
+
rather than a slowdown. The live region is now clamped to stay below that
|
|
273
|
+
threshold, with a `... N earlier lines` marker naming what is deferred; the
|
|
274
|
+
entry prints **in full** into your scrollback the moment it finishes.
|
|
275
|
+
|
|
223
276
|
## Plan mode
|
|
224
277
|
|
|
225
278
|
`aragon` has two postures, and `Shift+Tab` switches between them. `/plan` does
|
|
@@ -789,7 +842,9 @@ env / `.env` → CLI flags**.
|
|
|
789
842
|
`ARAGON_TODO_FOLLOW` (`notify` / `auto` / `off`),
|
|
790
843
|
`ARAGON_SKILLS` (`0` disables), `ARAGON_SKILLS_PATH`,
|
|
791
844
|
`ARAGON_SKILLS_DISABLED` (comma-separated names), `ARAGON_LOG_LEVEL`,
|
|
792
|
-
`ARAGON_LOG_FILE` (`0` disables), `ARAGON_LOG_DIR
|
|
845
|
+
`ARAGON_LOG_FILE` (`0` disables), `ARAGON_LOG_DIR`,
|
|
846
|
+
`ARAGON_TRANSCRIPT_RETAIN`, `ARAGON_RENDER_GOVERNOR` (`0` disables),
|
|
847
|
+
`ARAGON_MAX_RENDER_INTERVAL_MS`.
|
|
793
848
|
`ARAGON_HOME` is the exception that **cannot** come from `.env`: it is read
|
|
794
849
|
before any `.env` is loaded. See [Files & logs](#files--logs).
|
|
795
850
|
- **User config file** (`aragon config path`): JSON, written `0600` on POSIX.
|
|
@@ -805,7 +860,10 @@ env / `.env` → CLI flags**.
|
|
|
805
860
|
| `maxTokens` | `64000` | Output token cap, clamped to `[256, 200000]`. `null` means **auto** — use each model's own ceiling, never above 64000. Absent means the default. See [Output token limits](#output-token-limits). |
|
|
806
861
|
| `fullscreen` | `true` | Use the full-screen TUI (still subject to the automatic downgrades). Setting it to `false` opts out permanently; leaving it `true` is *not* a force — only `--fullscreen` / `ARAGON_FULLSCREEN=1` override the heuristics. |
|
|
807
862
|
| `exitTranscript` | `true` | Replay a plain-text session summary after exiting (full-screen only). |
|
|
808
|
-
| `transcriptWindow` | `
|
|
863
|
+
| `transcriptWindow` | `1000` | How far back you can scroll, clamped to `[50, 20000]`. Entries beyond it collapse into one line. It is no longer a rendering budget: the viewport is virtualised, so off-screen entries are not laid out at all. |
|
|
864
|
+
| `transcriptRetain` | `1000` | Entries kept in memory, clamped to `[200, 20000]` and raised to `transcriptWindow` if the two conflict — with a startup notice saying so, because retaining less than you can scroll to would otherwise make part of the horizon quietly unreachable. Older entries are dropped, counted, and reported by `/perf` and by the exit replay. |
|
|
865
|
+
| `renderGovernor` | `true` | Widen the streaming frame interval when a frame is genuinely expensive. See [Render performance](#render-performance). |
|
|
866
|
+
| `maxRenderIntervalMs` | `320` | Ceiling of the governor ladder, clamped to `[33, 1000]`. `33` flattens it to the pre-0.6 cadence without disabling the mechanism. |
|
|
809
867
|
| `planModeDefault` | `false` | Start every session in plan mode. `--no-plan` still overrides it for a single run. |
|
|
810
868
|
| `planModeMaxAskRounds` | `4` | `ask_user` rounds allowed per user message, clamped to `[1, 10]`. The counter resets on your next message; steering does **not** reset it. |
|
|
811
869
|
| `planModeHumanTimeoutMs` | `1800000` | Ceiling on a single question / plan wait, clamped to `[60000, 7200000]`. Thirty minutes is far outside "went to get coffee", but a ceiling has to exist so an unattended terminal cannot wedge a CI job forever. |
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded view state (tui-render-performance L1 / R5).
|
|
3
|
+
*
|
|
4
|
+
* `ViewState.entries` used to grow without any ceiling: `argsRaw`, assistant
|
|
5
|
+
* `text` and `thinking` all concatenated forever, and the entry array was never
|
|
6
|
+
* trimmed. That makes the per-frame cost of the renderer proportional to the
|
|
7
|
+
* total number of characters the session has ever produced, because Ink's
|
|
8
|
+
* `renderNodeToOutput` walks and measures every mounted text node whether or not
|
|
9
|
+
* `overflow: hidden` clips it.
|
|
10
|
+
*
|
|
11
|
+
* Two bounds live here, both pure and React-free:
|
|
12
|
+
* - `appendBounded` caps one field by eliding its MIDDLE, and
|
|
13
|
+
* - `trimEntries` ring-trims the entry array itself.
|
|
14
|
+
*
|
|
15
|
+
* ASCII ONLY. This module is produced outside `src/ui/**`, where no terminal
|
|
16
|
+
* capabilities are available, so the elision marker has to be safe on a legacy
|
|
17
|
+
* `cmd.exe` -- the same rule `PREVIEW_TRUNCATION_MARK` in `reducer.ts` records.
|
|
18
|
+
*/
|
|
19
|
+
import type { Entry } from './reducer.js';
|
|
20
|
+
export interface EntryLimits {
|
|
21
|
+
/** Assistant answer text ceiling, in characters. */
|
|
22
|
+
text: number;
|
|
23
|
+
/** Assistant thinking ceiling. */
|
|
24
|
+
thinking: number;
|
|
25
|
+
/** Streamed tool-argument JSON ceiling. */
|
|
26
|
+
argsRaw: number;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The per-field ceilings.
|
|
30
|
+
*
|
|
31
|
+
* `text` is the generous one on purpose: a 256 KiB answer is already far past
|
|
32
|
+
* anything a terminal can usefully show, and the middle is what gets elided, so
|
|
33
|
+
* both the opening and the live cursor survive. `argsRaw` is the tight one --
|
|
34
|
+
* it is a streamed JSON fragment rendered as a 60-character card summary, so
|
|
35
|
+
* anything past a few KiB is invisible by construction.
|
|
36
|
+
*/
|
|
37
|
+
export declare const ENTRY_LIMITS: EntryLimits;
|
|
38
|
+
/** Marker inserted at the elision point. ASCII -- produced outside `src/ui/**`. */
|
|
39
|
+
export declare const ELISION_MARK_PREFIX = "\n[... ";
|
|
40
|
+
export declare const ELISION_MARK_SUFFIX = " characters elided ...]\n";
|
|
41
|
+
/**
|
|
42
|
+
* Append `delta` to `prev`, keeping the result at or below `cap` by eliding the
|
|
43
|
+
* MIDDLE. Head and tail are both preserved: the head is the answer's opening,
|
|
44
|
+
* the tail is where the cursor is. Idempotent -- re-eliding an already elided
|
|
45
|
+
* string collapses the two markers into one.
|
|
46
|
+
*
|
|
47
|
+
* The returned string is NEVER longer than `cap` (AC-7). The marker's own digit
|
|
48
|
+
* count is bounded before the tail budget is computed, so the arithmetic cannot
|
|
49
|
+
* overshoot by a digit the way a naive `cap - marker.length` would.
|
|
50
|
+
*/
|
|
51
|
+
export declare function appendBounded(prev: string, delta: string, cap: number): string;
|
|
52
|
+
export interface TrimResult {
|
|
53
|
+
entries: Entry[];
|
|
54
|
+
dropped: number;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Ring-trim to `retain` entries; returns the SAME array reference when no-op.
|
|
58
|
+
*
|
|
59
|
+
* The identity return is load-bearing: the reducer spreads this result into a
|
|
60
|
+
* new `ViewState` on every append, and a fresh array on every action would
|
|
61
|
+
* defeat the `React.memo` boundaries L2 installs on the transcript.
|
|
62
|
+
*/
|
|
63
|
+
export declare function trimEntries(entries: Entry[], retain: number): TrimResult;
|
|
64
|
+
export declare function setEntryRetain(value: number): void;
|
|
65
|
+
export declare function entryRetain(): number;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded view state (tui-render-performance L1 / R5).
|
|
3
|
+
*
|
|
4
|
+
* `ViewState.entries` used to grow without any ceiling: `argsRaw`, assistant
|
|
5
|
+
* `text` and `thinking` all concatenated forever, and the entry array was never
|
|
6
|
+
* trimmed. That makes the per-frame cost of the renderer proportional to the
|
|
7
|
+
* total number of characters the session has ever produced, because Ink's
|
|
8
|
+
* `renderNodeToOutput` walks and measures every mounted text node whether or not
|
|
9
|
+
* `overflow: hidden` clips it.
|
|
10
|
+
*
|
|
11
|
+
* Two bounds live here, both pure and React-free:
|
|
12
|
+
* - `appendBounded` caps one field by eliding its MIDDLE, and
|
|
13
|
+
* - `trimEntries` ring-trims the entry array itself.
|
|
14
|
+
*
|
|
15
|
+
* ASCII ONLY. This module is produced outside `src/ui/**`, where no terminal
|
|
16
|
+
* capabilities are available, so the elision marker has to be safe on a legacy
|
|
17
|
+
* `cmd.exe` -- the same rule `PREVIEW_TRUNCATION_MARK` in `reducer.ts` records.
|
|
18
|
+
*/
|
|
19
|
+
import { DEFAULT_TRANSCRIPT_RETAIN } from '../config/schema.js';
|
|
20
|
+
/**
|
|
21
|
+
* The per-field ceilings.
|
|
22
|
+
*
|
|
23
|
+
* `text` is the generous one on purpose: a 256 KiB answer is already far past
|
|
24
|
+
* anything a terminal can usefully show, and the middle is what gets elided, so
|
|
25
|
+
* both the opening and the live cursor survive. `argsRaw` is the tight one --
|
|
26
|
+
* it is a streamed JSON fragment rendered as a 60-character card summary, so
|
|
27
|
+
* anything past a few KiB is invisible by construction.
|
|
28
|
+
*/
|
|
29
|
+
export const ENTRY_LIMITS = {
|
|
30
|
+
text: 262_144,
|
|
31
|
+
thinking: 65_536,
|
|
32
|
+
argsRaw: 16_384,
|
|
33
|
+
};
|
|
34
|
+
/** Marker inserted at the elision point. ASCII -- produced outside `src/ui/**`. */
|
|
35
|
+
export const ELISION_MARK_PREFIX = '\n[... ';
|
|
36
|
+
export const ELISION_MARK_SUFFIX = ' characters elided ...]\n';
|
|
37
|
+
/** Matches a marker this module wrote, so re-elision collapses rather than nests. */
|
|
38
|
+
const ELISION_RE = /\n\[\.\.\. (\d+) characters elided \.\.\.\]\n/;
|
|
39
|
+
/** Fixed cost of the marker, before the digits of the count. */
|
|
40
|
+
const MARKER_FIXED = ELISION_MARK_PREFIX.length + ELISION_MARK_SUFFIX.length;
|
|
41
|
+
/**
|
|
42
|
+
* Fraction of the cap reserved for the HEAD. The tail gets the rest because that
|
|
43
|
+
* is where the cursor is; the head exists so the answer still starts where the
|
|
44
|
+
* model started it.
|
|
45
|
+
*/
|
|
46
|
+
const HEAD_FRACTION = 4;
|
|
47
|
+
/** Undo one elision, so a second one merges into it rather than nesting. */
|
|
48
|
+
function splitElided(prev) {
|
|
49
|
+
const m = ELISION_RE.exec(prev);
|
|
50
|
+
if (!m)
|
|
51
|
+
return { head: '', elided: 0, tail: prev };
|
|
52
|
+
const index = m.index;
|
|
53
|
+
return {
|
|
54
|
+
head: prev.slice(0, index),
|
|
55
|
+
elided: Number.parseInt(m[1], 10) || 0,
|
|
56
|
+
tail: prev.slice(index + m[0].length),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Append `delta` to `prev`, keeping the result at or below `cap` by eliding the
|
|
61
|
+
* MIDDLE. Head and tail are both preserved: the head is the answer's opening,
|
|
62
|
+
* the tail is where the cursor is. Idempotent -- re-eliding an already elided
|
|
63
|
+
* string collapses the two markers into one.
|
|
64
|
+
*
|
|
65
|
+
* The returned string is NEVER longer than `cap` (AC-7). The marker's own digit
|
|
66
|
+
* count is bounded before the tail budget is computed, so the arithmetic cannot
|
|
67
|
+
* overshoot by a digit the way a naive `cap - marker.length` would.
|
|
68
|
+
*/
|
|
69
|
+
export function appendBounded(prev, delta, cap) {
|
|
70
|
+
if (!Number.isFinite(cap) || cap <= 0)
|
|
71
|
+
return '';
|
|
72
|
+
const split = splitElided(prev);
|
|
73
|
+
let head = split.head;
|
|
74
|
+
let tail = split.tail + delta;
|
|
75
|
+
let elided = split.elided;
|
|
76
|
+
// Fast path: nothing has ever been elided and it still fits. This is the path
|
|
77
|
+
// every ordinary answer takes, so it must allocate nothing beyond the concat.
|
|
78
|
+
if (elided === 0 && tail.length <= cap)
|
|
79
|
+
return tail;
|
|
80
|
+
const headBudget = Math.max(0, Math.min(Math.floor(cap / HEAD_FRACTION), cap - MARKER_FIXED - 1));
|
|
81
|
+
if (elided === 0) {
|
|
82
|
+
// First elision: the head has to be carved out of the accumulated text.
|
|
83
|
+
head = tail.slice(0, headBudget);
|
|
84
|
+
tail = tail.slice(headBudget);
|
|
85
|
+
}
|
|
86
|
+
else if (head.length > headBudget) {
|
|
87
|
+
// A later `cap` reduction can leave the stored head oversized; charge the
|
|
88
|
+
// difference to the elision count rather than silently keeping it.
|
|
89
|
+
elided += head.length - headBudget;
|
|
90
|
+
head = head.slice(0, headBudget);
|
|
91
|
+
}
|
|
92
|
+
if (head.length + MARKER_FIXED >= cap) {
|
|
93
|
+
// Degenerate cap (only reachable from a hand-written test): keep the tail.
|
|
94
|
+
return tail.slice(Math.max(0, tail.length - cap));
|
|
95
|
+
}
|
|
96
|
+
// `elided + tail.length` is an UPPER BOUND on the final count, so its digit
|
|
97
|
+
// count is an upper bound on the marker's width. Budgeting against the bound
|
|
98
|
+
// is what makes the `<= cap` guarantee hold without a second pass.
|
|
99
|
+
const digitBound = String(elided + tail.length).length;
|
|
100
|
+
const markerBound = MARKER_FIXED + digitBound;
|
|
101
|
+
const tailKeep = Math.max(0, Math.min(tail.length, cap - head.length - markerBound));
|
|
102
|
+
const total = elided + (tail.length - tailKeep);
|
|
103
|
+
return `${head}${ELISION_MARK_PREFIX}${total}${ELISION_MARK_SUFFIX}${tail.slice(tail.length - tailKeep)}`;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Ring-trim to `retain` entries; returns the SAME array reference when no-op.
|
|
107
|
+
*
|
|
108
|
+
* The identity return is load-bearing: the reducer spreads this result into a
|
|
109
|
+
* new `ViewState` on every append, and a fresh array on every action would
|
|
110
|
+
* defeat the `React.memo` boundaries L2 installs on the transcript.
|
|
111
|
+
*/
|
|
112
|
+
export function trimEntries(entries, retain) {
|
|
113
|
+
const keep = Math.max(1, Math.floor(retain));
|
|
114
|
+
if (entries.length <= keep)
|
|
115
|
+
return { entries, dropped: 0 };
|
|
116
|
+
return { entries: entries.slice(entries.length - keep), dropped: entries.length - keep };
|
|
117
|
+
}
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// The resolved retain ceiling
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
/**
|
|
122
|
+
* `viewReducer` is pure and takes no config, so the resolved `transcriptRetain`
|
|
123
|
+
* reaches it through a module-level singleton -- the shape `prompt-history.ts`
|
|
124
|
+
* already uses for `setHistoryEnabled`. `load.ts` is the ONLY writer.
|
|
125
|
+
*/
|
|
126
|
+
let resolvedRetain = DEFAULT_TRANSCRIPT_RETAIN;
|
|
127
|
+
export function setEntryRetain(value) {
|
|
128
|
+
if (Number.isFinite(value) && value > 0)
|
|
129
|
+
resolvedRetain = Math.floor(value);
|
|
130
|
+
}
|
|
131
|
+
export function entryRetain() {
|
|
132
|
+
return resolvedRetain;
|
|
133
|
+
}
|
package/dist/agent/reducer.d.ts
CHANGED
|
@@ -160,6 +160,14 @@ export interface ViewState {
|
|
|
160
160
|
errorNoticed: boolean;
|
|
161
161
|
/** The current run was aborted by the user (suppresses the failure guard). */
|
|
162
162
|
aborted: boolean;
|
|
163
|
+
/**
|
|
164
|
+
* Entries removed by `transcriptRetain` (tui-render-performance L1).
|
|
165
|
+
*
|
|
166
|
+
* Rendered by `transcript-text` and reported by `/perf`; NEVER PERSISTED —
|
|
167
|
+
* `SavedSession` keeps `{ model, messages, entries, todos }` exactly as it is,
|
|
168
|
+
* so `/save` and `/resume` need no format change and old files load unchanged.
|
|
169
|
+
*/
|
|
170
|
+
droppedEntries: number;
|
|
163
171
|
}
|
|
164
172
|
export declare function initialViewState(): ViewState;
|
|
165
173
|
export type ViewAction = {
|
package/dist/agent/reducer.js
CHANGED
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
* - `agent_end` after a turn that produced no `turn_end` and no error notice
|
|
11
11
|
* synthesizes a generic error notice (a swallowed throw is never a no-op).
|
|
12
12
|
*/
|
|
13
|
+
import process from 'node:process';
|
|
13
14
|
import { computeCost } from './usage.js';
|
|
15
|
+
import { ENTRY_LIMITS, appendBounded, entryRetain, trimEntries } from './entry-limits.js';
|
|
14
16
|
/** Default auto-dismiss window for a toast. */
|
|
15
17
|
export const DEFAULT_TOAST_TTL_MS = 2500;
|
|
16
18
|
export function initialViewState() {
|
|
@@ -36,6 +38,7 @@ export function initialViewState() {
|
|
|
36
38
|
turnProduced: false,
|
|
37
39
|
errorNoticed: false,
|
|
38
40
|
aborted: false,
|
|
41
|
+
droppedEntries: 0,
|
|
39
42
|
};
|
|
40
43
|
}
|
|
41
44
|
// ---------------------------------------------------------------------------
|
|
@@ -201,6 +204,35 @@ function nextId(state) {
|
|
|
201
204
|
function mapEntry(entries, id, fn) {
|
|
202
205
|
return entries.map((e) => (e.id === id ? fn(e) : e));
|
|
203
206
|
}
|
|
207
|
+
/**
|
|
208
|
+
* Append one entry and ring-trim the result (tui-render-performance L1).
|
|
209
|
+
*
|
|
210
|
+
* TRIMMING HAPPENS ONLY AT APPEND POINTS, and that is the whole reason this is a
|
|
211
|
+
* helper rather than a line in `viewReducer`. A mid-turn trim triggered by an
|
|
212
|
+
* UPDATE action could drop the entry `streamingId` / `teamEntryId` /
|
|
213
|
+
* `todoEntryId` points at while it is still being written to; appending is the
|
|
214
|
+
* only moment at which the tail is by definition the newest thing.
|
|
215
|
+
*
|
|
216
|
+
* I-L1-1 — the trim must never drop a live target. With `retain >= 200` and a
|
|
217
|
+
* trim that only ever removes from the head this is structurally true, but a
|
|
218
|
+
* future clamp change would break it SILENTLY (the `mapEntry` calls would simply
|
|
219
|
+
* stop matching), so development builds assert it.
|
|
220
|
+
*/
|
|
221
|
+
function appendEntry(state, entries, entry) {
|
|
222
|
+
const trimmed = trimEntries([...entries, entry], entryRetain());
|
|
223
|
+
if (trimmed.dropped > 0 && process.env.NODE_ENV !== 'production') {
|
|
224
|
+
const live = [state.streamingId, state.teamEntryId, state.todoEntryId].filter((id) => typeof id === 'string');
|
|
225
|
+
for (const id of live) {
|
|
226
|
+
if (!trimmed.entries.some((e) => e.id === id)) {
|
|
227
|
+
throw new Error(`trimEntries dropped a live entry (${id}) - see I-L1-1`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
entries: trimmed.entries,
|
|
233
|
+
droppedEntries: state.droppedEntries + trimmed.dropped,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
204
236
|
function findToolEntryId(state, toolCallId) {
|
|
205
237
|
for (let i = state.entries.length - 1; i >= 0; i -= 1) {
|
|
206
238
|
const e = state.entries[i];
|
|
@@ -217,7 +249,7 @@ export function viewReducer(state, action) {
|
|
|
217
249
|
return {
|
|
218
250
|
...state,
|
|
219
251
|
seq,
|
|
220
|
-
|
|
252
|
+
...appendEntry(state, state.entries, entry),
|
|
221
253
|
streamingId: undefined,
|
|
222
254
|
// A new turn gets its own card, in that turn's position in the
|
|
223
255
|
// transcript. The PREVIOUS card stays exactly where it is — it is
|
|
@@ -246,7 +278,7 @@ export function viewReducer(state, action) {
|
|
|
246
278
|
thinkingOpen: false,
|
|
247
279
|
streaming: true,
|
|
248
280
|
};
|
|
249
|
-
return { ...state, seq,
|
|
281
|
+
return { ...state, seq, ...appendEntry(state, state.entries, entry), streamingId: id };
|
|
250
282
|
}
|
|
251
283
|
case 'thinkingStart': {
|
|
252
284
|
if (!state.streamingId)
|
|
@@ -262,7 +294,7 @@ export function viewReducer(state, action) {
|
|
|
262
294
|
return {
|
|
263
295
|
...state,
|
|
264
296
|
entries: mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant'
|
|
265
|
-
? { ...e, thinking: (e.thinking ?? ''
|
|
297
|
+
? { ...e, thinking: appendBounded(e.thinking ?? '', action.delta, ENTRY_LIMITS.thinking) }
|
|
266
298
|
: e),
|
|
267
299
|
};
|
|
268
300
|
}
|
|
@@ -271,7 +303,9 @@ export function viewReducer(state, action) {
|
|
|
271
303
|
return state;
|
|
272
304
|
return {
|
|
273
305
|
...state,
|
|
274
|
-
entries: mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant'
|
|
306
|
+
entries: mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant'
|
|
307
|
+
? { ...e, text: appendBounded(e.text, action.delta, ENTRY_LIMITS.text) }
|
|
308
|
+
: e),
|
|
275
309
|
};
|
|
276
310
|
}
|
|
277
311
|
case 'toolCallStart': {
|
|
@@ -285,7 +319,7 @@ export function viewReducer(state, action) {
|
|
|
285
319
|
argsRaw: '',
|
|
286
320
|
status: 'pending',
|
|
287
321
|
};
|
|
288
|
-
return { ...state, seq,
|
|
322
|
+
return { ...state, seq, ...appendEntry(state, state.entries, entry) };
|
|
289
323
|
}
|
|
290
324
|
case 'toolCallDelta': {
|
|
291
325
|
const id = findToolEntryId(state, action.toolCallId);
|
|
@@ -293,7 +327,9 @@ export function viewReducer(state, action) {
|
|
|
293
327
|
return state;
|
|
294
328
|
return {
|
|
295
329
|
...state,
|
|
296
|
-
entries: mapEntry(state.entries, id, (e) => e.kind === 'tool'
|
|
330
|
+
entries: mapEntry(state.entries, id, (e) => e.kind === 'tool'
|
|
331
|
+
? { ...e, argsRaw: appendBounded(e.argsRaw, action.argsDelta, ENTRY_LIMITS.argsRaw) }
|
|
332
|
+
: e),
|
|
297
333
|
};
|
|
298
334
|
}
|
|
299
335
|
case 'toolCallEnd': {
|
|
@@ -358,22 +394,27 @@ export function viewReducer(state, action) {
|
|
|
358
394
|
? mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant' ? { ...e, streaming: false } : e)
|
|
359
395
|
: state.entries;
|
|
360
396
|
let seq = state.seq;
|
|
397
|
+
let droppedEntries = state.droppedEntries;
|
|
361
398
|
// Silent-failure guard (R2): a swallowed throw shows nothing otherwise.
|
|
362
399
|
// A user abort is an expected empty run, not a failure — skip the guard.
|
|
363
400
|
if (!state.turnProduced && !state.errorNoticed && !state.aborted) {
|
|
364
401
|
const next = nextId({ ...state, seq });
|
|
365
402
|
seq = next.seq;
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
403
|
+
const appended = appendEntry(state, entries, {
|
|
404
|
+
id: next.id,
|
|
405
|
+
kind: 'notice',
|
|
406
|
+
level: 'error',
|
|
407
|
+
text: GENERIC_RUN_FAILURE,
|
|
408
|
+
});
|
|
409
|
+
entries = appended.entries;
|
|
410
|
+
droppedEntries = appended.droppedEntries;
|
|
370
411
|
}
|
|
371
412
|
// The card may now reach `<Static>` (C-5 / I-6): the turn that owns it is
|
|
372
413
|
// over, so nothing will rewrite it.
|
|
373
414
|
if (state.todoEntryId) {
|
|
374
415
|
entries = mapEntry(entries, state.todoEntryId, (e) => e.kind === 'todo' ? { ...e, live: false } : e);
|
|
375
416
|
}
|
|
376
|
-
return { ...state, entries, seq, status: 'idle', streamingId: undefined };
|
|
417
|
+
return { ...state, entries, seq, droppedEntries, status: 'idle', streamingId: undefined };
|
|
377
418
|
}
|
|
378
419
|
case 'notice': {
|
|
379
420
|
const { id, seq } = nextId(state);
|
|
@@ -381,7 +422,7 @@ export function viewReducer(state, action) {
|
|
|
381
422
|
return {
|
|
382
423
|
...state,
|
|
383
424
|
seq,
|
|
384
|
-
|
|
425
|
+
...appendEntry(state, state.entries, entry),
|
|
385
426
|
errorNoticed: action.level === 'error' ? true : state.errorNoticed,
|
|
386
427
|
};
|
|
387
428
|
}
|
|
@@ -403,6 +444,11 @@ export function viewReducer(state, action) {
|
|
|
403
444
|
return {
|
|
404
445
|
...state,
|
|
405
446
|
entries: [],
|
|
447
|
+
// The count describes what the RETAIN RING removed from the visible
|
|
448
|
+
// transcript. `/clear` empties it by intent, so carrying the number
|
|
449
|
+
// over would make the exported transcript claim a loss that did not
|
|
450
|
+
// happen.
|
|
451
|
+
droppedEntries: 0,
|
|
406
452
|
streamingId: undefined,
|
|
407
453
|
expandedToolIds: {},
|
|
408
454
|
teamEntryId: undefined,
|
|
@@ -416,6 +462,7 @@ export function viewReducer(state, action) {
|
|
|
416
462
|
return {
|
|
417
463
|
...state,
|
|
418
464
|
entries: [],
|
|
465
|
+
droppedEntries: 0,
|
|
419
466
|
streamingId: undefined,
|
|
420
467
|
expandedToolIds: {},
|
|
421
468
|
contextTokens: 0,
|
|
@@ -481,7 +528,7 @@ export function viewReducer(state, action) {
|
|
|
481
528
|
return {
|
|
482
529
|
...state,
|
|
483
530
|
seq,
|
|
484
|
-
|
|
531
|
+
...appendEntry(state, state.entries, entry),
|
|
485
532
|
teamEntryId: id,
|
|
486
533
|
team: {
|
|
487
534
|
dispatchId: action.dispatchId,
|
|
@@ -558,7 +605,7 @@ export function viewReducer(state, action) {
|
|
|
558
605
|
...state,
|
|
559
606
|
seq,
|
|
560
607
|
todos: action.snapshot,
|
|
561
|
-
|
|
608
|
+
...appendEntry(state, state.entries, entry(id)),
|
|
562
609
|
todoEntryId: id,
|
|
563
610
|
};
|
|
564
611
|
}
|
|
@@ -572,9 +619,14 @@ export function viewReducer(state, action) {
|
|
|
572
619
|
const n = Number.parseInt(e.id.replace(/^e/, ''), 10);
|
|
573
620
|
return Number.isFinite(n) ? Math.max(acc, n) : acc;
|
|
574
621
|
}, state.seq);
|
|
622
|
+
// A session file can be longer than the retain ring; trimming it here is
|
|
623
|
+
// the one append-free place the bound still has to hold, and the count
|
|
624
|
+
// starts from this restore rather than inheriting the previous session's.
|
|
625
|
+
const restored = trimEntries([...action.entries], entryRetain());
|
|
575
626
|
return {
|
|
576
627
|
...state,
|
|
577
|
-
entries:
|
|
628
|
+
entries: restored.entries,
|
|
629
|
+
droppedEntries: restored.dropped,
|
|
578
630
|
seq: maxSeq,
|
|
579
631
|
teamEntryId: undefined,
|
|
580
632
|
// As for `team`: the restored cards are history and no turn owns them.
|