Skip to content

Commit 7f512e7

Browse files
author
gluck
committed
merge fix/show-tail: show walks back from the tail
A 4000 message aria answered 'show' with its first 1000 entries and called that the whole thing. The walk, the honest header, and the wiring test come in together.
2 parents 69e019b + c1598bc commit 7f512e7

5 files changed

Lines changed: 595 additions & 92 deletions

File tree

‎internal/cli/aria.go‎

Lines changed: 146 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,12 @@ import (
1313

1414
"github.com/jack-work/largo"
1515

16-
"github.com/jack-work/figaro/internal/compose"
1716
"github.com/jack-work/figaro/internal/config"
1817
"github.com/jack-work/figaro/internal/livedoc"
1918
"github.com/jack-work/figaro/internal/livelog/aria"
2019
"github.com/jack-work/figaro/internal/message"
2120
"github.com/jack-work/figaro/internal/store"
2221
"github.com/jack-work/figaro/internal/term"
23-
"github.com/jack-work/figaro/internal/turns"
2422
)
2523

2624
// runShow handles `figaro show [--id <id>] [N] [-v|-l|-a]`.
@@ -38,6 +36,7 @@ type showOpts struct {
3836
verbose bool
3937
literal bool
4038
details bool // -o: the metadata Ctrl-O shows in the pager
39+
maxBytes int // --max-bytes: paint budget; the OLDEST turns are dropped first. 0 = none
4140
}
4241

4342
// renderAria prints history for an aria. The default view derives the
@@ -46,65 +45,7 @@ type showOpts struct {
4645
// delta compression). N / --last N / --from A [--to B] / --all select a
4746
// unit range. --verbose and --literal use the raw IR path.
4847
func renderAria(loaded *config.Loaded, id string, args []string) {
49-
opts := showOpts{last: 10, from: -1, to: -1, before: -1}
50-
51-
expanded := make([]string, 0, len(args))
52-
for _, a := range args {
53-
if len(a) > 2 && a[0] == '-' && a[1] != '-' { // expand bundled bool shorts (-vj)
54-
for _, r := range a[1:] {
55-
expanded = append(expanded, "-"+string(r))
56-
}
57-
continue
58-
}
59-
expanded = append(expanded, a)
60-
}
61-
needInt := func(i int) int {
62-
if i+1 >= len(expanded) {
63-
die("show: %s requires a value", expanded[i])
64-
}
65-
return mustAtoi(expanded[i+1])
66-
}
67-
for i := 0; i < len(expanded); i++ {
68-
a := expanded[i]
69-
switch {
70-
case a == "-o" || a == "--details":
71-
opts.details = true
72-
case a == "-v" || a == "--verbose":
73-
opts.verbose = true
74-
case a == "-l" || a == "--literal":
75-
opts.literal = true
76-
case a == "-a" || a == "--all":
77-
opts.all = true
78-
case a == "-j" || a == "--json":
79-
opts.jsonOut = true
80-
case a == "--from":
81-
opts.from = needInt(i)
82-
i++
83-
case a == "--to":
84-
opts.to = needInt(i)
85-
i++
86-
case a == "--last":
87-
opts.last = needInt(i)
88-
i++
89-
case strings.HasPrefix(a, "--from="):
90-
opts.from = mustAtoi(strings.TrimPrefix(a, "--from="))
91-
case strings.HasPrefix(a, "--to="):
92-
opts.to = mustAtoi(strings.TrimPrefix(a, "--to="))
93-
case strings.HasPrefix(a, "--last="):
94-
opts.last = mustAtoi(strings.TrimPrefix(a, "--last="))
95-
case a == "--before":
96-
opts.before = needInt(i)
97-
i++
98-
case strings.HasPrefix(a, "--before="):
99-
opts.before = mustAtoi(strings.TrimPrefix(a, "--before="))
100-
default:
101-
n, err := strconv.Atoi(a)
102-
if err != nil {
103-
die("usage: figaro show [--id <id>] [N | --last N | --from A [--to B] | -a] [-j|--json] [-o] [-v] [-l]")
104-
}
105-
opts.last = n
106-
}
107-
}
48+
opts := parseShowArgs(args)
10849

10950
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
11051
defer cancel()
@@ -123,46 +64,63 @@ func renderAria(loaded *config.Loaded, id string, args []string) {
12364
figaroID = r.FigaroID
12465
}
12566

126-
// Read the IR through the angelus's shared LogCache.
67+
// Read the IR through the angelus's shared LogCache, walking BACKWARD from
68+
// the tail until the selector is covered (see show_window.go). One forward
69+
// read from LT 0 is what this used to do, and the angelus caps a single
70+
// read at 1000 entries, so a long aria was shown its own prefix and told
71+
// that was all of it.
12772
//
128-
// SEAM (Phase 3): this reads the whole log and pages in memory, because
129-
// every selector now speaks turn ids and turns are not derivable from an LT
130-
// window: you cannot know which LT a turn starts at without walking. The
131-
// paginated turn-aware read (Page/TurnPart, byte budget, bidirectional)
132-
// replaces this wholesale; when it lands, `show` becomes a thin client of it
133-
// and this block collapses to one call.
134-
resp, err := acli.AriaRead(ctx, figaroID, 0, 0)
73+
// SEAM (Phase 3): the walk still derives turns client-side, because turns
74+
// are not derivable from an LT window without composing one. The paginated
75+
// turn-aware read (Page/TurnPart, byte budget, bidirectional) replaces the
76+
// walk wholesale; when it lands, `show` becomes a thin client of it.
77+
w, err := gatherShowWindow(ctx, acli, figaroID, opts)
13578
if err != nil {
13679
die("aria.read: %s", err)
13780
}
138-
if len(resp.Entries) == 0 && resp.Total == 0 {
81+
// A pre-turn-id aria can only be numbered from its head: see derivedIDs.
82+
if !w.atHead && derivedIDs(w.entries) {
83+
resp, rerr := acli.AriaRead(ctx, figaroID, 0, 0)
84+
if rerr != nil {
85+
die("aria.read: %s", rerr)
86+
}
87+
entries := make([]store.Entry[message.Message], len(resp.Entries))
88+
for i, e := range resp.Entries {
89+
entries[i].LT = e.LT
90+
if uerr := json.Unmarshal(e.Payload, &entries[i].Payload); uerr != nil {
91+
die("aria.read: parse LT=%d: %s", e.LT, uerr)
92+
}
93+
}
94+
w = showWindow{entries: entries, total: resp.Total, atHead: len(entries) >= resp.Total, pages: 1, fromHead: true}
95+
if !w.atHead {
96+
showNote("this aria stores no turn ids (written before they existed), so its turns can only be counted from the head: showing the first %d of %d entries", len(w.entries), w.total)
97+
}
98+
}
99+
if len(w.entries) == 0 && w.total == 0 {
139100
fmt.Fprintln(os.Stderr, "(empty aria)")
140101
return
141102
}
142-
entries := make([]store.Entry[message.Message], len(resp.Entries))
143-
for i, e := range resp.Entries {
144-
entries[i].LT = e.LT
145-
if err := json.Unmarshal(e.Payload, &entries[i].Payload); err != nil {
146-
die("aria.read: parse LT=%d: %s", e.LT, err)
147-
}
148-
}
103+
entries := w.entries
149104

150105
// --verbose / --literal: the raw IR path (inline transitions + extras,
151106
// or unrendered IR markdown).
152107
if opts.verbose || opts.literal {
153-
renderAriaIR(loaded, figaroID, entries, opts)
108+
renderAriaIR(loaded, figaroID, w, opts)
154109
return
155110
}
156111

157-
// Default + --json: conversational units derived from the IR.
158-
msgs := make([]message.Message, len(entries))
159-
for i, e := range entries {
160-
msgs[i] = e.Payload
161-
msgs[i].LogicalTime = e.LT
162-
}
163-
turns.StampIDs(msgs)
164-
turns := compose.Turns(msgs)
112+
// Default + --json: conversational units derived from the IR. A window
113+
// that does not start at the head drops its oldest turn: the page boundary
114+
// fell inside it, and a turn without its prompt renders as an answer to
115+
// nothing.
116+
turns := trimPartialHead(composeTurns(entries), w.atHead)
165117
lo, hi := selectTurnRange(turns, opts)
118+
if lo < hi && opts.maxBytes > 0 {
119+
if _, dropped := clipToBudget(turns[lo:hi], opts.maxBytes); dropped > 0 {
120+
showNote("--max-bytes %d dropped the %d oldest turn(s) of this selection; a budget clips the head, never the tail", opts.maxBytes, dropped)
121+
lo += dropped
122+
}
123+
}
166124

167125
if opts.jsonOut {
168126
// The UI IR wire shape VERBATIM, an aria.Page, exactly what figaro.read
@@ -174,7 +132,9 @@ func renderAria(loaded *config.Loaded, id string, args []string) {
174132
// Whole turns: `show` selects by turn, so no part is ever clipped.
175133
page.Parts = append(page.Parts, aria.TurnPart{Turn: t, From: 0})
176134
}
177-
page.More = aria.More{Before: lo > 0, After: hi < len(turns)}
135+
// Before is true when anything older exists, whether it was sliced
136+
// off here or simply never walked to.
137+
page.More = aria.More{Before: lo > 0 || !w.atHead, After: hi < len(turns)}
178138
enc := json.NewEncoder(os.Stdout)
179139
enc.SetIndent("", " ")
180140
if err := enc.Encode(page); err != nil {
@@ -188,10 +148,27 @@ func renderAria(loaded *config.Loaded, id string, args []string) {
188148
// while ids are gapless and 1-based; printing lo+1 here would be a fourth
189149
// coordinate system in the one command that just collapsed three.
190150
if lo >= hi {
191-
fmt.Printf("# aria %s: %d turns (no turn in range)\n\n", figaroID, len(turns))
151+
if w.atHead {
152+
fmt.Printf("# aria %s: %d turns (no turn in range)\n\n", figaroID, len(turns))
153+
} else if w.fromHead {
154+
fmt.Printf("# aria %s: no turn in that range within the first %d turns\n\n", figaroID, len(turns))
155+
} else {
156+
// The count is NOT known here: the walk stopped as soon as the
157+
// selector was covered, so len(turns) is the size of the window,
158+
// not of the aria. Printing it as a total restates the old bug.
159+
fmt.Printf("# aria %s: no turn in that range within the %d turns walked back from the tail\n\n", figaroID, len(turns))
160+
}
192161
return
193162
}
194-
fmt.Printf("# aria %s: %d turns (showing %d–%d) · [N] is the turn to fork/send at\n\n", figaroID, len(turns), turns[lo].ID, turns[hi-1].ID)
163+
if w.atHead {
164+
fmt.Printf("# aria %s: %d turns (showing %d..%d) · [N] is the turn to fork/send at\n\n", figaroID, len(turns), turns[lo].ID, turns[hi-1].ID)
165+
} else if w.fromHead {
166+
// The legacy path: a forward read that stopped at the angelus's cap.
167+
// Say FIRST, because that is what it is.
168+
fmt.Printf("# aria %s: turns %d..%d, the first %d of %d entries · [N] is the turn to fork/send at\n\n", figaroID, turns[lo].ID, turns[hi-1].ID, len(w.entries), w.total)
169+
} else {
170+
fmt.Printf("# aria %s: turns %d..%d, walked back from the tail of %d entries · [N] is the turn to fork/send at\n\n", figaroID, turns[lo].ID, turns[hi-1].ID, w.total)
171+
}
195172
for i := lo; i < hi; i++ {
196173
u := turns[i]
197174
fmt.Println(term.Dim(fmt.Sprintf("[%d]", u.ID)))
@@ -210,6 +187,80 @@ func renderAria(loaded *config.Loaded, id string, args []string) {
210187
}
211188
}
212189

190+
// parseShowArgs turns `show`'s reassembled argv into showOpts.
191+
//
192+
// It is a named function, not a loop inside renderAria, because the flag most
193+
// recently broken here was broken by NOT BEING PARSED: cmdkit consumed
194+
// --max-bytes, the reassembly in cli.go did not pass it on, and `show`
195+
// exited 0 having ignored it. A parser you cannot call is a parser you cannot
196+
// test, and the unit test for the clipper it feeds passed the entire time.
197+
func parseShowArgs(args []string) showOpts {
198+
opts := showOpts{last: 10, from: -1, to: -1, before: -1}
199+
200+
expanded := make([]string, 0, len(args))
201+
for _, a := range args {
202+
if len(a) > 2 && a[0] == '-' && a[1] != '-' { // expand bundled bool shorts (-vj)
203+
for _, r := range a[1:] {
204+
expanded = append(expanded, "-"+string(r))
205+
}
206+
continue
207+
}
208+
expanded = append(expanded, a)
209+
}
210+
needInt := func(i int) int {
211+
if i+1 >= len(expanded) {
212+
die("show: %s requires a value", expanded[i])
213+
}
214+
return mustAtoi(expanded[i+1])
215+
}
216+
for i := 0; i < len(expanded); i++ {
217+
a := expanded[i]
218+
switch {
219+
case a == "-o" || a == "--details":
220+
opts.details = true
221+
case a == "-v" || a == "--verbose":
222+
opts.verbose = true
223+
case a == "-l" || a == "--literal":
224+
opts.literal = true
225+
case a == "-a" || a == "--all":
226+
opts.all = true
227+
case a == "-j" || a == "--json":
228+
opts.jsonOut = true
229+
case a == "--from":
230+
opts.from = needInt(i)
231+
i++
232+
case a == "--to":
233+
opts.to = needInt(i)
234+
i++
235+
case a == "--max-bytes":
236+
opts.maxBytes = needInt(i)
237+
i++
238+
case a == "--last":
239+
opts.last = needInt(i)
240+
i++
241+
case strings.HasPrefix(a, "--from="):
242+
opts.from = mustAtoi(strings.TrimPrefix(a, "--from="))
243+
case strings.HasPrefix(a, "--to="):
244+
opts.to = mustAtoi(strings.TrimPrefix(a, "--to="))
245+
case strings.HasPrefix(a, "--last="):
246+
opts.last = mustAtoi(strings.TrimPrefix(a, "--last="))
247+
case a == "--before":
248+
opts.before = needInt(i)
249+
i++
250+
case strings.HasPrefix(a, "--before="):
251+
opts.before = mustAtoi(strings.TrimPrefix(a, "--before="))
252+
default:
253+
n, err := strconv.Atoi(a)
254+
if err != nil {
255+
die("usage: figaro show [--id <id>] [N | --last N | --from A [--to B] | -a] [-j|--json] [-o] [-v] [-l]")
256+
}
257+
opts.last = n
258+
}
259+
}
260+
261+
return opts
262+
}
263+
213264
func mustAtoi(s string) int {
214265
v, err := strconv.Atoi(s)
215266
if err != nil {
@@ -280,7 +331,8 @@ func selectTurnRange(turns []aria.Turn, o showOpts) (int, int) {
280331
// renderAriaIR is the raw IR path for --verbose / --literal: it renders
281332
// each message (markdown via largo, or unrendered when --literal) and, in
282333
// verbose mode, appends credo / state transitions / form.
283-
func renderAriaIR(loaded *config.Loaded, figaroID string, entries []store.Entry[message.Message], opts showOpts) {
334+
func renderAriaIR(loaded *config.Loaded, figaroID string, win showWindow, opts showOpts) {
335+
entries := win.entries
284336
start := 0
285337
if !opts.all && len(entries) > opts.last {
286338
start = len(entries) - opts.last
@@ -297,7 +349,10 @@ func renderAriaIR(loaded *config.Loaded, figaroID string, entries []store.Entry[
297349
flush = func() { sw.Flush() }
298350
}
299351

300-
fmt.Fprintf(w, "# aria %s: showing %d of %d messages\n\n", figaroID, len(entries)-start, len(entries))
352+
// The denominator is the ARIA's message count, not the window's. A tail
353+
// window that reported its own size as the total is exactly how `show`
354+
// used to claim a 4000 message aria had 1000 messages in it.
355+
fmt.Fprintf(w, "# aria %s: showing %d of %d messages\n\n", figaroID, len(entries)-start, win.total)
301356
for _, e := range entries[start:] {
302357
renderMessage(w, e.Payload, e.LT, opts.verbose)
303358
}

‎internal/cli/cli.go‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ func buildRouter(progName string, loaded *config.Loaded) *cmdkit.Router {
204204
Aliases: []string{"history"},
205205
Group: "Prompt",
206206
Short: "Render an aria's message history",
207-
Usage: "show [<id>] [-n N | --from A [--to B] | --before T | -a] [-j] [-o] [-v] [-l]",
207+
Usage: "show [<id>] [-n N | --from A [--to B] | --before T | -a] [--max-bytes N] [-j] [-o] [-v] [-l]",
208208
Long: `Render an aria's history as turns. A turn is one exchange: your
209209
question and every node the agent produced about it. The optional
210210
positional is the target aria id; default is the pid-bound aria.
@@ -223,6 +223,17 @@ Turns are labeled by their turn id: the coordinate send/fork
223223
figaro show -o with each block's address and timestamp
224224
figaro show eac16fef -v verbose IR, labeled by LT
225225
figaro show -l raw IR, no rendering
226+
figaro show --max-bytes 200000 cap what is painted; the OLDEST turns go first
227+
228+
show reads from the TAIL backwards, one page at a time, and stops as soon as
229+
your selection is covered. Length is not a limit: the last N turns of a 40,000
230+
turn aria cost the same read as the last N of a short one, so redirecting show
231+
to a file captures the tail of any aria. Diagnostics go to stderr, so the file
232+
holds the conversation and nothing else.
233+
234+
--max-bytes is the one cap, and it is opt-in. When the selection is larger
235+
than the budget, the OLDEST turns of it are dropped, never the newest, and a
236+
line on stderr says how many.
226237
227238
LT is the model's coordinate: it counts the steps the model experienced,
228239
and most LTs sit mid-tool. It stays visible under -v/-l for debugging the
@@ -238,6 +249,7 @@ fig IR, but it is not an address: turns are.`,
238249
{Long: "from", Description: "Start turn id (inclusive)"},
239250
{Long: "to", Description: "End turn id (inclusive)"},
240251
{Long: "before", Description: "Show N turns before this turn id (paginate backwards)"},
252+
{Long: "max-bytes", Description: "Cap the painted size; the oldest turns of the selection are dropped first"},
241253
{Long: "last", Short: "n", Description: "Show the last N turns (paginate backwards from the end)"},
242254
},
243255
Run: func(ctx *cmdkit.RunContext) error {
@@ -276,6 +288,13 @@ fig IR, but it is not an address: turns are.`,
276288
if v := ctx.Flag("last"); v != "" {
277289
args = append(args, "--last", v)
278290
}
291+
// A flag declared in the registry and not reassembled here is
292+
// SILENTLY IGNORED: cmdkit consumes it, renderAria never sees it,
293+
// and the command succeeds having done nothing you asked. That is
294+
// how --max-bytes did nothing on its first run.
295+
if v := ctx.Flag("max-bytes"); v != "" {
296+
args = append(args, "--max-bytes", v)
297+
}
279298
runShow(ld, ariaID, args)
280299
return nil
281300
},

0 commit comments

Comments
 (0)