Skip to content

feat(importers): project Hermes session-level token accounting - #339

Merged
upsetbit merged 2 commits into
masterfrom
feat/hermes-session-usage
Sep 9, 2026
Merged

upsetbit merged 2 commits into
masterfrom
feat/hermes-session-usage

Conversation

@upsetbit

@upsetbit upsetbit commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

What

Hermes sessions have always reported zero cost. The importer derived usage
from messages.token_count, but Hermes stopped populating that column when it
moved token accounting onto the sessions row. In a live state.db it is
NULL across all 37,889 messages, so ClassifyUsage returned Unknown and no
session_usage row was ever written.

This reads the five counters Hermes keeps per session instead.

The mapping

prosa TokenUsage Hermes column
InputTokens input_tokens + cache_read_tokens + cache_write_tokens
CacheReadTokens, CachedTokens cache_read_tokens
CacheCreationTokens cache_write_tokens
OutputTokens output_tokens

Two parts are easy to get wrong and both are pinned by tests.

input_tokens holds uncached input alone, while prosa's canonical aggregate is
the cache-inclusive grand total. That is the same shape the Claude Code
importer builds, and exactly how Hermes derives its own prompt_tokens.

reasoning_tokens stays out of OutputTokens. Hermes already counts it there,
which its own total_tokens confirms by not adding it. Folding it in would
double count, so it survives as provenance only.

Two behaviours worth reviewing

A row whose counters are all zero keeps classifying Unknown rather than
ExplicitZero, mirroring Hermes's own has_usage flag. The distinction is not
cosmetic: the import policy drops ExplicitZero outright, so the literal reading
would have deleted the 17 sessions that carry no counters.

The projected JSONL gains a leading {"type":"session_usage","data":{…}} line
carrying the counters verbatim, following the envelope Grok Build already uses
for non-message rows. Without it the preserved raw could not explain the usage
prosa derives from it. Hermes is already authorized to project rather than copy
as a multi-session container, so this needs no new authorization, only the
version bump.

Measured result

Run against the maintainer's real ~/.hermes/state.db in an isolated
PROSA_HOME, so nothing touched the live store or the server:

Model Sessions Tokens Estimated cost
gpt-5.5 669 591,114,621 $612.27
grok-4.5 168 226,501,115 $130.99
grok-4.6 22 125,667,954 $82.31
claude-opus-4-7 1 127,963 $0.32
Total 860 943,411,653 $825.89

Every model involved was already priced, so no pricing work was needed.

Known limitation

90 of the 950 Hermes sessions still get no usage, worth a further 354,968,047
tokens. These are the ones that defer to a fuller sibling transcript: the
counters live only on the state.db row, and a transcript-sourced session's
raw is a verbatim copy that cannot carry them. Giving them usage means merging
both sources at projection time, which is the separate cut
docs/sources/hermes.md already anticipates under "Dual-source gap" and which
would extend the projection authorization to a third shape. Left out
deliberately; the doc now names this as the visible cost of that gap.

Operational note

ProjectionVersion goes 12 to 13, so the next sync re-imports and re-pushes
every Hermes session, roughly 325 MB of raw across 940 sessions. That re-push
would happen with or without the new projection line, because the server's push
short-circuit requires the projection version to be current.

Verification

just ci green end to end, plus just quality and commitlint. Five new tests
cover the mapping, the cache-inclusive sum, reasoning not being double counted,
zero counters staying admitted, and a Hermes build predating the columns still
importing through the NULL AS placeholders. The determinism test now carries
counters so the usage line's byte stability stays covered. prosa show still
renders a session whose raw leads with the new line.

🤖 Generated with Claude Code

https://claude.ai/code/session_015UmSUq3v73JA3ZJr4nFhm9

upsetbit and others added 2 commits September 9, 2026 17:56
Hermes sessions have always reported zero cost. The importer derived usage
from messages.token_count, but Hermes stopped populating that column when it
moved token accounting onto the sessions row: in a live state.db it is NULL
across all 37,889 messages, so ClassifyUsage returned Unknown and no
session_usage row was ever written.

Read the five counters Hermes keeps per session instead. Two parts of the
mapping are easy to get wrong and both are pinned by tests:

InputTokens is the cache-inclusive sum of input + cache_read + cache_write.
Hermes stores uncached input alone, while prosa's canonical aggregate is the
grand total — the same shape the Claude Code importer builds, and exactly how
Hermes derives its own prompt_tokens.

reasoning_tokens stays out of OutputTokens. Hermes already counts it there,
which its own total_tokens property confirms by not adding it; folding it in
would double count. It survives as provenance only.

A row whose counters are all zero keeps classifying Unknown rather than
ExplicitZero, mirroring Hermes's own has_usage flag. The distinction matters:
the import policy drops ExplicitZero outright, so the literal reading would
have deleted the 17 sessions that carry no counters.

The projected JSONL gains a leading {"type":"session_usage","data":{…}} line
carrying the counters verbatim, following the envelope Grok Build already uses
for non-message rows. Without it the preserved raw could not explain the usage
prosa derives from it. Hermes is already authorized to project rather than
copy as a multi-session container, so this needs no new authorization.

The sessions query now uses the same NULL-placeholder projection the messages
query has, so a Hermes build predating the counters still imports and falls
back to token_count. That replaces the single-column parent_session_id probe,
leaving tableHasColumn unused.

ProjectionVersion 12 to 13 so existing rows re-project and re-push.

Measured against the maintainer's store: 860 of 950 Hermes sessions gain
usage, worth 943,411,653 tokens and $825.89 of previously invisible spend. The
90 sessions that defer to a fuller sibling transcript keep no usage — their
raw is a verbatim copy that cannot carry the counters, and merging the two
sources at projection time is the separate cut docs/sources/hermes.md already
anticipates under "Dual-source gap".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015UmSUq3v73JA3ZJr4nFhm9
The state.db schema block predated every token column. It now shows the five
counters and estimated_cost_usd, with a mapping table onto prosa's canonical
aggregate and the two rules that make it correct: input_tokens is uncached
input alone, so prosa's InputTokens sums all three prompt columns, and
reasoning_tokens is already inside output_tokens.

Also records why all-zero counters stay Unknown, why prosa ignores Hermes's
estimated_cost_usd, that older Hermes builds fall back to token_count, and
the new leading session_usage line in the projection.

The dual-source gap section now names its visible cost: a session that defers
to a fuller sibling transcript gets no usage, because the counters live only
on the state.db row and a transcript's raw is a verbatim copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015UmSUq3v73JA3ZJr4nFhm9
@upsetbit
upsetbit merged commit c845d85 into master Sep 9, 2026
5 checks passed
@upsetbit
upsetbit deleted the feat/hermes-session-usage branch September 9, 2026 21:13
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