Skip to content

Lens: read a session as what it spent - #18

Merged
viict merged 12 commits into
masterfrom
feat/usage-lens
Aug 13, 2026
Merged

Lens: read a session as what it spent#18
viict merged 12 commits into
masterfrom
feat/usage-lens

Conversation

@viict

@viict viict commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Closes #7.

Two --lens dialects that answer what a record cost rather than what it
said: usage over Claude Code .jsonl session logs, and usage-atif over ATIF
trajectories, sharing one numeric vocabulary.

A row reads:

10:04  assistant   in 1.2k  out  380  read  18k  new 2.1k  ·  Bash(cargo test)
  • Numbers first, because that is the column being scanned. Fixed width, so
    the column stays addable down the file; the row never wraps, it pans.
  • A record with no usage shows its kind and nothing morequeue-operation,
    file-history-snapshot — never a row of zeroes. A kind the dialect has not
    seen prints its own name rather than being swallowed.
  • Subagents are marked ↳assistant, exactly 10 columns, so the 57% of
    records that are sidechains do not shift the numeric column by one.
  • Totals go where the seam allows: the group row (· 128k tokens) and the
    status bar. A lens may not decide rows, so the seam grew Summary::tokens and
    one 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.usage is present on 31,897/31,897 assistant records and on no
    record of any other type. Six user records carry toolUseResult.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 have
    more 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.
  • The three-way cell distinction (number / - / absent) has one leg no real
    record exhibits; the docs mark it as a defensive path rather than observed
    behaviour.

Structure

src/source/record/view.rs was over the 500-line limit before this branch;
folding moved out to a new folds.rs and it is now 386. No file this branch
touches 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.
  • All five cross-targets check clean: both Windows MSVC, Windows GNU, both
    Darwin.
  • Opt-in real-corpus harness: 430 files, 64,472 records, green.

Open, and deliberately not decided here

  • The dialects are named usage / usage-atif, not cost — no price table is
    compiled in and no money is shown; a lens must not promise a currency it
    cannot compute.
  • The shipped agent and atif lenses leave Summary::tokens at 0, so their
    group rows are byte-identical to today's.

viict added 12 commits August 13, 2026 16:01
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.
@viict
viict merged commit 996e046 into master Aug 13, 2026
7 checks passed
@viict
viict deleted the feat/usage-lens branch August 13, 2026 20:19
@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.

Lens: agent session usage

1 participant