Lens: read a session as what it spent - #18
Merged
Merged
Conversation
A record can record what it cost, and two rows want the total: the group row over a folded run, and the status bar over the document. Both are in the record seam, because a lens may not decide a row. Summary::tokens is the exact count a dialect read, and lens::tokens is the one spelling of it — floored to at most four columns, so a row that says 18k promises at least 18,000 and never overstates. A group row gains a third clause, omitted at zero, so the two shipped dialects paint exactly as before.
--lens usage over a Claude Code session log answers what each record cost rather than what it said: a fixed-width block of four token counters, then what the record did. Three different things a cell can say, and they are kept apart deliberately — a number means the format recorded that value (0 included), a dash means this record did not record a field its format has, and an absent column means the format has no such field. Numbers live at message.usage and nowhere else; usage.iterations is counted and never summed, because its elements repeat the outer counters. A human turn breaks the run and everything else is mechanics, so the group row over a run totals exactly one turn. A subagent is marked as ↳assistant, ten columns exactly, because a mark that cost a column would bend the numbers on more than half the rows of a real session.
--lens usage-atif is the document twin of usage, over the same numeric block and a different set of counters — and the difference is visible on the row. ATIF-v1.7 records no cache-creation counter of any kind, so this dialect has three columns and never a fourth: a format-level absence removes the column for the whole file, where a record-level absence inside a format that has the field prints a dash. Reasoning is a subset of the completion count and llm_call_count counts models rather than tools, so neither reaches the total. Two registry entries over one shared module because records_at() returns a single answer, and a lens declaring Lines cannot legally be pointed at a document. --lens list now keeps its gutter outside the padding, since usage-atif fills the name field exactly.
A whole-file token total belongs where the seam allows it rather than where a dialect wishes: a lens may not decide a row, and the record seam has no document-level row to hang a footer on, so it goes in the one per-document field a format already owns. The plan keeps the total running as records are classified — one add per record, nothing per frame, and classification runs once per record, so opening a folded run cannot make the total jump. It carries the same lower-bound mark the record count beside it does, because classification only reaches as far as the reader has scrolled. The clause is omitted at zero, so no shipped lens's status bar moves.
Four floored columns are what a row can honestly carry, so the level under it is where they become the integers the file wrote, and where everything the row had no column for lands: the cache-creation breakdown, the thinking count that is a subset of the output, the model the numbers were spent on, and the build that wrote the record. Built from text parts alone — this lens is not re-telling the conversation. A service tier or a speed is shown only when it is not the standard one, because a column for the ordinary case would be noise and the exceptions are what a reader opened the row to find. An ATIF step opens into no cache-creation number under any circumstance, since there is none to show.
The column table, the number rule and why it floors, the three-way distinction between a printed number, a dash and an absent column, and the two places a total lives — the group row and the status bar — with the reason neither is in a dialect. Also the note that records_at() is one answer, so a dialect that wants to read two file shapes is two entries over one shared module.
Fixtures only prove the shapes someone thought of. Two opt-in harnesses in the shape of the existing ones sweep a real corpus and hold the lens to its own contract there: every record with counters totals their exact sum, the numeric block is the same width on every row of every file, a subagent mark never outgrows the actor column, and a line that does not parse reaches the generic row rather than aborting the sweep. They read private files, so they print counts only and nothing from either corpus is copied anywhere. Both are no-ops without their environment variable, so a clean checkout is unaffected.
The Source impl for a record document had grown past the size limit with every fold question in it. Those are one subject - what folds, what a fold hides, and the fold state a session saves - and they now have their own file, which leaves the view with the layout, the positions and the reads.
A session log has a second usage object: a subagent result carries one at toolUseResult.usage. The lens still reads message.usage alone - a whole run's total does not belong in a column of per-request numbers - but the module said no other record had numbers at all, which was wrong. The refusal of a counter that is not a non-negative integer also claimed more than it does: it spells such a value the same way it spells a field the record never wrote. Say so rather than imply the two are told apart.
usage.iterations[] is one element per attempt, and where a record carries more than one the outer counters are the last element's rather than the sum. Skipping the list stays right; the reason for it was wrong, and the consequence went unsaid: on a retried request every number this lens shows is the surviving attempt's spend, and the file states no total across attempts for it to show instead.
The three things a usage cell can say are not three things that happen. No record measured has ever carried some of its format's counters and not the others, so the dash is a defensive path held to by hand-written tests. Say that where both documents present the distinction, so a later reader does not go looking for the file that produces it.
The corpus sweep proved no cache-creation column by searching the row for "new", but the row is the numbers *and* what the step did - a command or a path with "new" in it reddened the sweep with a message about a column that was never there. It now reads the block alone. The plan fixture's spend went through an unchecked i64-to-u64 cast in the one place the running total's arithmetic is exercised, where a negative would have pinned the saturating total at its ceiling.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #7.
Two
--lensdialects that answer what a record cost rather than what itsaid:
usageover Claude Code.jsonlsession logs, andusage-atifover ATIFtrajectories, sharing one numeric vocabulary.
A row reads:
the column stays addable down the file; the row never wraps, it pans.
queue-operation,file-history-snapshot— never a row of zeroes. A kind the dialect has notseen prints its own name rather than being swallowed.
↳assistant, exactly 10 columns, so the 57% ofrecords that are sidechains do not shift the numeric column by one.
· 128k tokens) and thestatus bar. A lens may not decide rows, so the seam grew
Summary::tokensandone number spelling that both the row and the group row read — two spellings
would drift.
Grounded in measurement, not assumption
Every reading rule was checked against a real corpus (aggregate counts only; no
session content appears in this branch, and all fixtures are hand-written):
message.usageis present on 31,897/31,897assistantrecords and on norecord of any other type. Six
userrecords carrytoolUseResult.usage—subagent run totals, whose per-request numbers live in another session file —
and the docs say so and say what such a row shows.
usage.iterations[]is not summed: of 22,166 records carrying it, 7 havemore than one element, and on all 7 the outer counters equal the last
iteration, never the sum. Recorded as a consequence: a retried request reports
the last attempt's spend.
number/-/ absent) has one leg no realrecord exhibits; the docs mark it as a defensive path rather than observed
behaviour.
Structure
src/source/record/view.rswas over the 500-line limit before this branch;folding moved out to a new
folds.rsand it is now 386. No file this branchtouches exceeds 500 lines and no function exceeds 50.
Verified
cargo test: 1636 passing, 0 failed (master: 1587), no suite collapsed.cargo clippy --all-targets -- -D warnings: clean.Darwin.
Open, and deliberately not decided here
usage/usage-atif, notcost— no price table iscompiled in and no money is shown; a lens must not promise a currency it
cannot compute.
agentandatiflenses leaveSummary::tokensat 0, so theirgroup rows are byte-identical to today's.