Extra High review held #1039. Stop pricing every future glm-5.x as 5.2,
stop collapsing unknown provider/org/model trees onto a priced leaf, and
stop calling ACP Buzz — source=acp is a transport and the DB has no
client field. Strip trailing punctuation on PR URLs and bump the Hermes
parse version so old cache rows reparse.
Price any OmniRoute/Cline/cmd/antigravity wrapper by peeling prefixes
and, for GLM 5.x, falling back to the newest priced sibling. Do not
require a new alias per model id.
Classify Hermes sessions by surface and workspace: ACP is the Buzz
app; project comes from git root or a real cwd, never $HOME or a
profile name.
Hermes stores Cline Pass as cp/cline-pass/glm-5.3. One prefix strip
leaves cline-pass/glm-5.3, which missed the bare glm-5.3 alias and
stayed $0. Price the last path segment through the same aliases.
GLM-5.3 is missing from LiteLLM, so Hermes/Cline sessions priced at $0.
Alias it to the glm-5p2 sibling and keep the display name GLM-5.3.
The root Hermes profile was shown as project "default". Use "hermes"
for that profile only; named profiles and cwd inference are unchanged.
Scan Hermes transcripts for https GitHub pull URLs and attach them so
the Pull requests page can attribute those sessions.
A date-ranged query loads only the months its range can report on, so a
file in an out-of-range month has no visible cache entry: the reconcile
re-parses it and writes the identical entry back, which marks a bucket
this run never loaded dirty. The save then merged and republished that
month under a fresh nonce name on every run, with byte-identical
content.
A merge into an unloaded month that neither adds, changes nor removes an
entry now keeps the published shard.
Fixes#1032
The never-lose contract carried a (day, provider) slice forward only when
the re-derivation found nothing for it. Transcripts expire per file, so a
mostly-aged-out day still gets a few turns from surviving later files: the
fresh slice came back non-empty but truncated and replaced the full cached
one (a real cache lost $2,765.75 / 19,209 calls / 520 sessions over 13 days
on the 17 -> 19 upgrade).
A fresh slice now replaces a settled baseline slice only when it carries at
least as many calls. Comparison is on calls alone - cost and tokens are
re-priced accounting on the same evidence, and session counts drift down on
healthy days. Days inside a 7-day settle window stay authoritative. The
tz-change re-derive gets the exact form of the rule: the subtraction residual
is added on top of a data-carrying fresh slice instead of being dropped. The
cross-file adoption union is unchanged.
Reports bucket rows by model id and label them afterwards, so the two ids
collapsing onto one display name printed what looked like the same row twice
with different numbers. Give the variant its own SHORT_NAMES entry, which the
longest-first match picks over the grok-4.5 prefix. Display only: ids are
untouched, so nothing re-parses and no cost moves.
Fixed in the shared table rather than the grok provider so the menubar and
model-breakdown, which call getShortModelName directly, get it too.
Fixes#1029.
parseLargeJsonl dropped promptSource for exactly the lines SDK-generated
prompts live on, so the recurring-context detector regex-scanned the ends of
the raw line for it. Add the field to LARGE_ROOT_FIELDS (tiny scalar,
add-only, isSidechain already there) and delete the workaround: it read only
2 KB from each end, so a flag further in was missed.
No cache change: optimize scans the raw JSONL each run, so promptSource never
has to persist on CachedFile.
Fixes#1030. With #994 this closes#1023.
The month-scoped load a ranged query takes is a behaviour change on a warm
cache with no way back except deleting it. Drop the scope in loadCache, the
one place every caller (including the resident serve) routes through, so a
suspect scoped read can be compared against a full one in place.
Read policy only: deliberately not in PROVIDER_ENV_VARS, so setting or
unsetting it invalidates nothing.
node:sqlite enables SQLITE_OPEN_URI from Node 22.15 on. Below that -- 22.13 is
the package floor -- a `file:...` location is a literal filename, so the
immutable open failed as CANTOPEN and the copy quietly stood in for it. That
was the right outcome by accident; the test asserted the newer behaviour and
failed on the floor.
The support question is now asked once per process, with an in-memory URI that
touches no filesystem whichever answer comes back, and the immutable open is
attempted only when the answer is yes. The test asks the same question rather
than skipping, so both CI lines assert something: rows are correct either way,
in place where URI filenames work and from a copy where they do not.
Four things the copy fallback got wrong.
A database whose -wal is absent or empty has no un-checkpointed frames, so
there is nothing to go stale and nothing worth copying: immutable=1 opens the
source in place and SQLite skips the -shm it cannot create. The copy is now
taken only when a non-empty -wal exists, which is the case where dropping it
would lose rows.
A copy is published under a name carrying its fingerprint, so refreshing one
never has to unlink a file another process may still hold open, which Windows
does not allow. The -wal is published before the database so a reader can
never see the database without the sidecar holding its newest rows, and losing
a publish race to an identical copy is not an error. That removes the metadata
sidecar: the name is the fingerprint.
Superseded copies are evicted rather than overwritten -- the one in use plus at
most one predecessor, and anything untouched for a day, which is also what a
source path that no longer exists looks like. Reuse touches the copy, so its
mtime is last use.
A cache directory that cannot be written no longer fails the same way the bug
did. It emits the once-per-database notice naming the database and the reason
before the database is skipped, instead of going quiet.
The daily-cache re-derivation test seeded v18, a version that only ever
existed as an unreleased draft of this change. Seed the shipped v17 so the
test models the 17 -> 19 upgrade path users actually hit, and rename it: the
bump re-derives every day for every provider, not just Grok, because the
daily cache has no per-provider invalidation. The Grok day stays as the
fixture since Grok is what the bump exists to correct.
The changelog entry now says outright that Grok totals change materially on
upgrade (150K -> 96.3M cache-read tokens on a 568-session corpus), that a
turn without a turn_completed record inside an otherwise-covered session is
dropped rather than estimated, and that the one-time daily re-derivation
reads the warm session cache and keeps the superseded file. The
context-bloat denominator fix moves to Fixed and names the providers it
corrects.
docs/providers/grok.md gets the same undercount warning in the token model
and a matching entry under Quirks.
A read-only parent with a -wal but no -shm fails as SQLITE_CANTOPEN (14),
not SQLITE_READONLY (8), so the fallback never ran and the un-checkpointed
rows in the -wal stayed invisible. openReadonlyCache already re-throws the
original error when the database itself is missing, which is the other
CANTOPEN, so widening the trigger keeps that case distinguishable.
Also stops copying the source -shm: SQLite rebuilds the wal-index from the
-wal in the writable cache directory, so the copy is dead weight.
The unpriced-models pointer told every user to run `codeburn model-alias`. A
subscription or flat-rate model is correctly $0, and mapping it onto another
model's per-token rate invents spend that was never billed (#968), so the hint
now states the condition instead of the instruction.
`sanitizeModelForDisplay` guarded only the `--unpriced` path, leaving every
other command and format rendering provider-supplied IDs unfiltered. It moves
to the `formatModel` closures in models-report and audit-report, the two sites
every renderer routes through, covering the raw-ID fallback as well. The
`--unpriced` override stays: it bypasses that path deliberately, because
`model-alias` keys on the raw ID, not the friendly name. README says so.
Below 45 columns of panel the dashboard warning dropped its marker and count
and printed a bare command, so a narrow terminal gave no signal that anything
was wrong. `! N: codeburn models --unpriced` is 31 characters and fits.
Only duplicate-reads has a structural reason to skip them: a subagent
starts on a fresh context, so re-reading what its parent read is a
necessary read, not a repeat. Reading node_modules or editing without
reading is the same waste whoever does it, and the CLAUDE.md rule both
findings suggest binds subagents too - filtering them there discarded
most of the evidence on a subagent-heavy corpus.
The prefix alone could not tell a claude.ai connector from a local MCP
server that happens to be named claude_ai_*, so the latter lost its
removal command and its apply plan. A namespace is now a connector only
when no readable local config claims that exact name; localMcpServerNames
supplies the set from the same files the remove plan edits (settings.json,
.mcp.json, and ~/.claude.json top-level plus per-project mcpServers).
A prefixed name that local config does own stays local: normal removal
command, apply plan, class fix, full savings attribution. Because the
transcript still cannot rule out a same-name connector, the finding adds a
manual note about it instead of asserting the server is one. Config that
cannot be read contributes no names, which leaves every prefixed namespace
on the conservative connector path.
A mixed local + claude.ai connector finding is class `fix`, but `--apply`
only mutates the local servers. classTotals now credits the `fix` group
with `applyTokensSaved` when present, so the "Fix now (apply-able)"
subtotal, the "apply-able: ~$X" headline and `summary.byClass.fix` (CLI,
TUI and desktop all read these) describe what apply can actually recover.
The finding keeps the whole opportunity in its own `tokensSaved`.
Also fixes the desktop connector fixture, which predated the class/basis
fields, and adds class-level coverage: connector-only findings resolve to
`nudge` (no apply payload), a local server named like a connector stays
manual-only, and local-only findings keep their full subtotal.
Shares the mac release resolution behind a per-platform spec (tag prefix, asset name, error text), so the Windows path reuses the pinned-version URL, the release-API fallback scan, the retrying download and the sha256 verify unchanged. Windows then runs msiexec out of %SystemRoot%\System32 with /i /passive /norestart, treats 3010 and 1602 as non-failures, and launches the exe named by the product's Uninstall registry key.
README gains a Windows section next to the macOS and GNOME ones, a download
badge on the menubar card, and an honest note that the Tauri tray builds on
Linux but is unreleased there. docs/architecture.md picks up windows/ in the
surfaces diagram and gets a section covering the crate layout, the PATH and
System32 spawn rules, and the Claude quota parity.
`codeburn menubar` on Windows now points at the windows-v release page instead
of failing with "macOS only". The generalized installer from the source branch
is not brought over: it is 1182 commits behind this file and would drop the
proxy support, retry/backoff, checksum and bundle verification, and persistent
CLI path handling that landed since.
An SDK session's opening prompt and a subagent's task prompt are written by
a program: they repeat by design and have no home in CLAUDE.md. Both are
flagged on the entry, but a user entry over the parser's large-line
threshold comes back without its root flags - routine for generated prompts,
which are exactly the long ones - so the markers are read off the ends of
the raw line, where the fields sit either side of the oversized message.
Same section, compact: one line per still-applied fix with the verdict
glyph, and the undo command for the ones that measured nothing. The app
reads appliedFixes[] off the optimize JSON, tolerating its absence from
an older CLI.
Every still-applied journal entry now comes back with a verdict on the
next optimize run: worked (>=70% of its window-scaled estimate
realized), partial, no-effect (printed with its undo command), or
measuring while it is younger than the 3-day window. The verdicts come
off the rows act report already computes, so there is one
reconciliation, not two; the AppliedFix type and its formatter live in
act/types.ts so the optimize renderer can use them without importing
report.ts back into optimize.ts.
--auto-revert undoes the no-effect entries through the same code path as
codeburn act undo. It never touches partial or measuring entries, and
never a claude-md-rule - those land in whatever directory the user
happened to be in, the same reason --yes skips them.
--apply now names when the re-measure happens, and --format json carries
appliedFixes[] (add-only).
Groups sessions by their opening block (whitespace/ANSI-normalized, hashed
over the first 2 KB) and flags a block of at least 1.5 KB that opens five or
more sessions. Class nudge: CodeBurn will not move the user's own text into
CLAUDE.md, so the fix asks Claude to give the block a permanent home. Only
the repeats count as savings, sized from the block's bytes because provider
usage is per API call and cannot isolate the paste. The opener comes from
the session scan that already runs, so nothing extra is read.
Each class header now carries its own token/dollar subtotal and finding
count, so the apply-able slice is never mistaken for the whole board; the
headline savings line names that slice explicitly. CLI and TUI share one
classHeaderLine helper, the desktop app reads the same numbers from the
new summary.byClass in --format json (add-only; the three subtotals sum to
findingCount and potentialSavingsTokens).
Also scopes the SHELL_PROFILE_SCOPE comment to what is actually true: the
MCP deferral plans refuse to rewrite a shell profile, but bash-output-cap
appends its own marker block to one.
Every finding now resolves to a class (apply-able fix, habit nudge, or
informational keep) and a basis (measured from provider-counted usage, or
estimated from a schema/heuristic model), both from one table next to the
FindingId union. The class follows the plan layer: an id is 'fix' only when
buildPlan routes it, and an instance drops to 'nudge' when it lacks the
payload or cause its builder needs.
CLI, TUI and the desktop app group findings under Fix now / Habits / FYI
with continuous numbering; the CLI header reports 'N measured · M
estimated' in place of the blanket 'Estimates only.' footer. The JSON
report gains class + basis per finding and summary.measuredSavingsUSD;
existing fields are unchanged. The menubar's top three follow the same
order, since every surface reads the sorted findings list.
Sessions whose cost the provider never reported leave the cost-outliers
peer math; when nothing else is priced the comparison falls back to them
and the finding reports itself as estimated instead of disappearing.
Three follow-ups from an adversarial pass over this branch.
The title cap was sized against the wrong number. 80 code points was chosen
"for both the max-w-40 legend and the tooltip", but max-w-40 is 160px and the
legend renders at text-[10px], which shows roughly 32 characters. Everything
past that is clipped -- and that is exactly where the short session id, the
provider and every collision-tier suffix lived. Two sessions in one repository
whose AI titles share a 32-character prefix rendered as the same legend entry,
which is worse than main and is the scenario #997 is about. The label now leads
with the disambiguator so it is always inside the visible width, and both the
legend and the tooltip carry title= so the full label is reachable on hover.
injectDashboardBootstrap was not safe by construction. Extracting the helper
fixed the $-substitution problem but left the security-critical '<' escaping at
the call site 94 lines away, and the new test called the helper with raw
JSON.stringify output -- so deleting that escape left every test green while
the served page became injectable through any project, device or model name.
Nothing in tests/ asserted that escaping at all. The escaping moves inside the
helper, with a test that pushes </script> through a payload value.
preferredSessionTitle picked alphabetically, not most recently. types.ts
documents title as the last ai-title entry, so when one session id yields two
summaries the legend could show the superseded one. It now picks the greatest
lastTimestamp, keeping the alphabetical order only to break exact ties so the
result stays deterministic. Entries are also ordered by key before the
collision tiers run, so the same corpus cannot emit a different label set
depending on input order.
`openDatabase` opens provider databases with `readOnly: true`, which is not
enough for a WAL-mode database: SQLite has to create `<db>-shm` and an empty
`<db>-wal` in the database's own directory unless they already exist. Two
things follow, and both are real.
The open writes. On a writable directory it succeeds and leaves two files
behind in the user's provider directory, which is not what "CodeBurn only reads
your session logs" implies.
On a non-writable parent it fails outright with "attempt to write a readonly
database". It is conditional on -shm being absent, which is exactly the state
after the tool exits cleanly, so the symptom is intermittent: a provider's
spend disappears whenever that tool is not running. Both discovery sites
swallowed it with a bare `catch { continue }`, so the provider reported zero
sessions with nothing on stderr - indistinguishable from the tool not being
installed. It covers cursor, cursor-agent, opencode, goose, warp, kilo-code,
zerostack and the copilot agent-traces DB.
The direct open stays the fast path and is unchanged when it succeeds: no stat,
no permission probe. Only when SQLite reports SQLITE_READONLY does the fallback
run, copying the database and its -wal/-shm siblings into the CodeBurn cache
directory and opening the copy there. The copy is fingerprinted the same way
session-cache fingerprints a SQLite source, so an unchanged database is not
copied twice, and there is one bounded entry per source path.
The discovery sites now tell SQLITE_READONLY apart from ENOENT and emit one
notice per source path rather than per session, matching what the parse-time
paths already do.
This is not specific to any one sandbox: it applies to a database on read-only
media, a restrictive-permissions setup, and both the Flatpak and snap
confinements. The snap was narrowed to a read-only personal-files plug in #978
and is likely affected; I have no snap install to confirm that on.
Grok CLI writes a turn_completed update carrying a full usage object --
inputTokens, outputTokens, cachedReadTokens, cacheCreationTokens,
reasoningTokens -- into the same updates.jsonl the parser already reads. We
ignored it and reconstructed an estimate from _meta.totalTokens, a running
context-size counter that rides on unrelated events, with a
total < prevTotal * 0.5 reset as the turn boundary.
On the cache-heavy session reported in #998 that reconstruction captured about
1.4% of the real cache-read volume and roughly 6% of the day's tokens, while
over-counting output about fivefold. cacheCreationInputTokens and
reasoningTokens were hardcoded to zero regardless of what the session held.
The parser now reads turn_completed.usage, keyed by the record's snake_case
prompt_id so a re-emitted turn cannot double count, and sums across turns.
Two decompositions matter, both derivable from the reported numbers:
totalTokens equals inputTokens + outputTokens exactly, so cachedReadTokens and
cacheCreationTokens are subsets of input and are subtracted out per record
before pricing, matching the cache-exclusive convention codex and copilot
already use; and reasoningTokens is a subset of output.
That second one needs care, because the repo contract is the opposite of
Grok's: ParsedProviderCall.reasoningTokens is exclusive of outputTokens
everywhere, and every consumer sums the two -- tests/providers/kiro.test.ts
says so outright. So reasoning is clamped to the reported output and output is
emitted without it, and the downstream sum reconstructs Grok's number. Without
the clamp a record with reasoning > output produced a negative output and left
the pipeline pricing reasoning instead.
Multi-model attribution is deliberately out of scope. modelUsage only selects a
priced attribution id; a session that used two models is priced at one rate.
Splitting per model was tried and dropped: chooseAuthoritativeModel's
priced-id fallback exists to avoid a truthful-but-$0 row when modelUsage names
an id this checkout cannot price, and per-model pricing loses it -- the
reporter's own session collapsed from $1.20 to near zero the moment a second
id appeared.
When no valid completed record exists -- older Grok CLI versions -- the old
heuristic still runs, unchanged. The decision is taken from the deduplicated
records rather than latched per line, so a superseded or all-zero record cannot
flip a session off the heuristic and drop it. A session only partly covered by
turn_completed records keeps costIsEstimated: true rather than presenting
itself as fully provider-measured.
costUsdTicks is deliberately not read. Its scale is undocumented, and guessing
it would fabricate spend.
Bumps the grok parse version, and DAILY_CACHE_VERSION with
MIN_SUPPORTED_VERSION together, since the daily cache serves every day before
today and retains ten years. Moving them in lockstep is what keeps the
carry-forward lossless: the filename is version-suffixed, so the old file stays
on disk and is adopted for days no source can still re-derive.
Separately, detectContextBloat divided by outputTokens alone. Reasoning is
stored beside output for every reasoning-bearing provider, so the detector saw
a fraction of the generated tokens and invented high-impact findings -- a
session whose provider-reported ratio is 20:1, below the 25:1 threshold, was
reported as 133:1 with 710K tokens of claimed savings. It now uses the same
output + reasoning sum the reports use, which fixes codex, kiro, hermes, qwen
and cursor-agent too.
Reported in #998.
Filtering before the slice was necessary but not sufficient. Every unpriced row
is $0 on both cost and savings -- findUnpricedModels excludes anything carrying
a local-savings baseline -- so they all tie under aggregateModels' sort key, and
Array#sort is stable. The surviving order was Map insertion order: the order
each model's first assistant call appears in the transcript. So --unpriced
--top N kept the N that showed up earliest, and a model holding almost all of
the unpriced volume was dropped if it appeared late.
findUnpricedModels already sorts by tokens descending, then calls, then model
name, and the dashboard warning renders that order. It is now called once over
the whole row set, its order becomes a rank index, and the rows are ranked
before the slice -- so the CLI and the warning agree on which N, which is what
the README row claims. In the breakdown modes several rows share one model, so
they share that model's rank and N still counts rows.
The previous test could not catch this: its fixture held one unpriced model, so
--top 2 never truncated anything and deleting the slice line left the suite
green. It now uses three unpriced models emitted in an order that differs from
their size order, and asserts which two survive rather than only how many.
Follow-ups to the title change on this branch, all from an adversarial pass over
it.
Two series could render byte-identical labels. sessionKey is
provider/projectPath/sessionId, so the same session id under two project paths
is two series -- a session resumed in a different cwd, or two Claude config
directories holding the same project slug. The old label led with the project
so they stayed apart; leading with the title dropped the only thing separating
them. Labels are now built once at the end from per-key inputs: identical base
labels get the short project label appended, and the residual case (two
worktrees whose last two path segments match) falls back to the full path plus
full session id.
The label also depended on cache state. The hoist's comment claimed every input
was constant per sessionKey, which is false: title and project name are not in
the key, so two SessionSummary objects can share a key with different titles and
whichever arrived first won. Reproduced through the real parser -- two config
dirs, same project slug and session id, one with a title. Title candidates are
now collected per key and a valid one is preferred over none, deterministically.
The length cap sliced UTF-16 code units, so a title with an astral character on
the boundary left a lone high surrogate in the payload. It counts code points
now.
Separately, and pre-existing rather than introduced here: the web dashboard
injects its bootstrap with html.replace(string, string), so $-substitution
patterns in the replacement are interpreted. A payload value containing $` or $'
expands to the raw document around the match -- which contains </script> -- so
the '<' escaping upstream does not stop it. Device, project and model names
already reached that sink; session titles only widen the surface. The injection
is factored out and uses a replacer function.