Skip to content

Read agent trajectories: records inside a document, a record that opens into itself, motion that can read it - #14

Merged
viict merged 9 commits into
masterfrom
feature/atif
Aug 13, 2026
Merged

Read agent trajectories: records inside a document, a record that opens into itself, motion that can read it#14
viict merged 9 commits into
masterfrom
feature/atif

Conversation

@viict

@viict viict commented Aug 12, 2026

Copy link
Copy Markdown
Owner

--lens could read a file of lines. An ATIF agent trajectory is one JSON
document whose records are the elements of its steps array, and each step is
a whole turn: a message, a thought, several tool calls and their results at
once. Reading one meant the generic tree, where half of what you see is
machine output.

tread --lens atif trajectory.json

▾ assistant  10:55   Reading the failing test first. The suite names a
                     fixture that no longer exists, so that is where it
                     starts.
                     thinking
                       The failing case reads a fixture the test tree no
                       longer ships, so the panic is a missing file.
                     ▾ bash    cargo test -q parse            → 32 lines
                         command   cargo test -q parse
                         timeout   120000
                         running 3 tests
                         test parse::empty ... FAILED
                         ⋯ +29 lines

The commits

  • 2d133e2 record: lift the lens seam above any one record format.
    Grouping, row arithmetic, folding, search and painting stop belonging to
    .jsonl and sit behind a trait. Byte-identical output.
  • 08adbd5 lens: read an agent trajectory whose records live inside a
    document.
    A lens says where its records are; the array is a second store
    over the existing lazy index, so a step is parsed when it is painted. The
    document's other top-level keys become the session record rather than being
    dropped.
  • 4f21dd0 lens: put what was said under the row that says who said it.
    The summary row is the message's own first line and the rest follows under
    it, clipped with a truthful remainder.
  • 38284ef pager: move by block where a document reads in blocks.
  • abe52fb record: let a block boundary descend into a run that is open.
  • f244400 lens: open a record into what it did, not into its JSON.
    A record opens into itself first: what was said in full, what it was
    thinking, and its tool calls as calls. A call is a row and opens too, into
    its arguments and what it returned. zt still goes straight to the tree.
    A seam change, not an ATIF one -- the agent dialect answers as well, so a
    Claude Code session opens the same way.
  • 6c1e757 pager: move the cursor by row and put the jump on Tab.
    Reading real trajectories showed the block cursor was wrong: one j over an
    opened record leapt thirty rows of it. A closed block is exactly one row, so
    block motion differs from row motion only where it skips what the reader
    just opened. j/k move a row; Tab/S-Tab are the jump.

The last two commits are what the first attempt at this got wrong, found by
reading real trajectories rather than by any test.

Verification

  • 1482 unit + 90 integration tests passing, from 1308 + 90 on master.
  • clippy --all-targets -D warnings clean; all five cross-targets check;
    musl static build; tools/soak_pty.py clean.
  • Opening does not track file size: 1.7 ms on an 86 MB synthetic trajectory
    against 1.8 ms on 55 KB.
  • Rendered text is identical at width 92 and 1100 (126 709 non-space
    characters), the check that caught text painted nowhere on a wide terminal.
  • Every painted row is reachable with j alone from the row above it, pinned
    mechanically rather than by inspection.

Known, and not fixed here

  • tools/soak_json.sh and tools/soak_csv.sh each report one failure. Both
    predate this branch -- verified by building master -- and are the scripts
    asserting that a directory is an error, which the directory reader made
    false.
  • A record in the array index costs about twice what a line costs in the line
    index (~630 B against ~315 B).
  • A message row does not show its · 2 tool calls clause: that column is the
    message itself, and the calls are now rows under it. --toc still prints
    the clause.
  • Crossing a clipped message is seven j presses. Tab clears it in one.

@viict viict changed the title Read agent trajectories: records inside a document, the message under the row, motion by block Read agent trajectories: records inside a document, a record that opens into itself, motion that can read it Aug 12, 2026
viict added 9 commits August 13, 2026 15:11
The grouping, the two-level row arithmetic and the summary painting were
dialect-agnostic but not format-agnostic: they reached records through the
line index, so a second record format could not use them.

They now live in src/source/record/, over a Records trait that says how many
records are indexed, how to reach record i, and whether it opens. jsonl keeps
what costs a parse -- a record's own tree -- and implements the trait in
lens.rs. ops.rs takes the group operations with it, so a format that
implements Records gets zR, zM, Tab, Y-on-a-group, fold round-trip and
search-reveal rather than a plan it cannot fold.

No behaviour change: rendered frames are byte-identical with and without
--lens, and the test count is unmoved.
A lens meant a file of lines. An ATIF trajectory is one JSON document whose
records are the elements of its steps array, so a lens now says where its
records live: Lines, as every dialect did before, or a named member.

The array is a second Store over the existing lazy structural index, so a
step is parsed when it is painted and opening the file still costs a stat.
Everything above the Store -- grouping, folding, row arithmetic, search,
yanking, the outline -- is the seam from the previous commit, unchanged and
now shared by both record formats.

Nothing in the document is lost: the top-level keys the array does not hold
become the session record above the first step, and open into their tree.

The atif dialect reads opencode's ATIF-v1.7: a step that says something is a
message and carries its tool calls as a count, a step that only works folds
into a run with its neighbours.
A lens row was a headline and nothing else, so reading a conversation meant
opening every message in turn to see any of it.

A summary row for a message is now the message's own first line, and the rest
of it follows underneath, wrapped to the width and indented to the same
column. It is one wrap split in two, so the opening words are on the screen
once. Enter opens the whole message; zt opens the raw record, whatever the
message is doing. Mechanics are untouched and stay one line.

A message is clipped to six rows until it is opened, and the row that ends a
clip says what it is not showing -- a lens may fold, never hide.

The price is that a row's height now depends on the width. A resize re-lays
the bodies without re-reading a record, and a mark into a lensed document is
the record rather than the row, so the cursor comes back to the step it was
on rather than to a row number that moved.
A trajectory is a list of blocks -- a message with what it said, or a folded
run of mechanics -- and stepping through it a terminal row at a time is the
wrong unit: j moved a third of a message.

A source can now say it reads in blocks, and only a record file under a lens
does. There, j and k move between blocks and the viewport frames the block it
lands on: whole if it fits, first row at the top if it does not. Tab is the
next message, since j does what Tab used to. Every other format sees a default
of false and moves exactly as it did.

Block motion is the default unit, never the only one: Ctrl-E and Ctrl-Y scroll
a row whatever the blocks are, which is how a message taller than the screen
is read. Adding them turned up a decoder hole -- lookup rejected any event
carrying a ctrl bit, so kitty's CSI u form of Ctrl-E, and of Ctrl-C, resolved
to nothing. A key that is already Key::Ctrl is no longer rejected for saying
so twice.

The status bar names the block, and says >=N while grouping is still catching
up, because that total both grows and shrinks as it does.
Opening a run is the reader asking to see what is inside it, and j then
stepped straight over the whole thing: the steps it had just revealed were
reachable only by Ctrl-E, one row at a time.

A shut run is still one block, because that is what makes it a summary. An
open one is its own row and then a block per step, so j walks what opening it
put on the screen and k walks back out. The block total grows when a run
opens, and the status bar says so.

Tab is unchanged and still steps by message: every member of a run is a step,
so descending would only ever land it on mechanics.
A record had two states: the headline, and the raw tree. So a reader who
wanted to know what a tool was called with had to read JSON, and a message
whose body happened to fit had no state to open at all -- the key fell
through and the tree appeared unasked.

A record now opens into itself first. Enter descends a level a press: the
headline, then the record read as a record -- what was said in full, what it
was thinking, and its tool calls as calls -- then the raw tree, then back.
zt still goes straight to the tree from any level, for when JSON is what you
came for.

A call is a row, and it opens too: its arguments one per line, and what it
returned, clipped like a message with the count it left out. Reasoning is
text and now reads as text rather than as the word "thinking".

This is a seam change, not an ATIF one: a dialect says what a record's parts
are and nothing about rows, and the agent dialect answers as well, so a
Claude Code session opens the same way. What a lens may never do is unchanged
-- every argument, every result and every byte is still reachable, and every
clip says what it hid.
A closed block is exactly one row, so where a document is collapsed "next
block" and "next row" are the same motion. They part only where something is
open -- and there the difference is precisely the rows the reader just asked
to see. One j over an opened record leapt thirty rows of it.

So j and k move one visible row, in every format, and Tab and S-Tab are the
jump, framing the block they land on. Anything that can be opened can now be
read with the same key that got you there. Ctrl-E and Ctrl-Y go: they existed
only to escape block motion.

Tab jumps by block rather than by message. A message is a block, so nothing a
message jump reached is lost, and the mechanics a trajectory is mostly made
of stop being reachable only a row at a time.
A folded row says how much it is not showing, and that count was appended
after the text: a headline that filled the width pushed its (49 lines) past
the right edge, where only a sideways scroll would find it -- on the row whose
whole job is to say there is more.

The count is the half that must survive, so the text gives way to it. It is
cut to leave room and marked, and the line itself is untouched, so scrolling
right still reads the rest of it exactly as before.
Enter walked three rungs -- the clip, the record open, then its JSON -- so
getting from the bytes back to the reading meant a lap of the whole ladder,
and a key that was meant for reading kept ending up in a tree.

Enter is now the two levels a reader moves between, and r is the record's own
bytes from either of them. Neither undoes the other: with a tree open, Enter
toggles the rows underneath it and leaves the tree where it is, and r is its
own way back. zt is gone; one key per job.

A record row answers for Enter whether or not it has anywhere to go, so the
key can no longer fall through to the outline and open the very tree this
commit took off the ladder -- which is what it did on a record whose message
already fitted and that made no calls, half the records in a real trajectory.
@viict
viict merged commit 6de4761 into master Aug 13, 2026
7 checks passed
@viict
viict deleted the feature/atif branch August 13, 2026 16:30
@viict viict self-assigned this Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant