It was the only MiMo row still rendering as its raw slug next to
"MiMo v2.5" and "MiMo v2.5 Pro". SORTED_SHORT_NAMES is longest-first, so
the two v2.5 entries keep their own labels.
The `mimo-v2-flash -> xiaomi/mimo-v2-flash` alias shipped before this
branch and already cycled through display-name resolution, so
getShortModelName threw RangeError on every real MiMo v2 Flash session.
The new cycle-safe resolver fixes it, but nothing pinned the ids that
actually crashed in production: cover the four spellings found in a real
session cache, including the unnamespaced `mimo/mimo-v2-flash`.
Add the base `mimo-v2.5` display name so the row reads next to
"MiMo v2.5 Pro" instead of showing a raw slug; SORTED_SHORT_NAMES is
longest-first, so the Pro tier still wins its own entry.
CI typecheck failed: sessions-report maps getShortModelName, and
the Extra High cycle Set was a second parameter. Array.map fed
the index as `seen`.
Cycle tracking stays on an internal helper. Display and alias
behavior unchanged. No second Extra High.
Extra High MERGE AFTER FIX on d3f86f5. mimo-v2.5 aliased to
xiaomi/mimo-v2.5 then last-segment recursed forever. Looking up
SHORT_NAMES before resolveAlias also froze user remaps of known
ids (gpt-4o still displayed as GPT-4o).
Follow user aliases first. Break strip→alias→leaf cycles.
Do not invent a Kimi rate. Do not paper over this with a
mimo-v2.5 SHORT_NAMES row.
Hermes and token-plan sessions store mimo-v2.5-pro. The snapshot
row is xiaomi/mimo-v2.5-pro. Same class as the existing
mimo-v2-flash alias. No invented rate.
Looking up the display name on the stripped leaf before following a
pricing alias, so cline-pass/mimo-v2.5-pro cannot recurse
strip → alias → last-segment forever.
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.
Both multi-model tests assert that chooseAuthoritativeModel skips a
modelUsage id it cannot price and falls back to a priced one. They got that
"cannot price" from the bundled LiteLLM snapshot happening not to carry
grok-4.6-build, so `npm run build` - which re-fetches the snapshot - flipped
both assertions the moment an xai/grok-4.6 entry appeared upstream and the
prefix match started pricing the id.
Stub getModelCosts for that one id instead. The reporter's real ids stay in
the fixtures, so the tests still document the #998 case, and calculateCost is
left alone: module-internal calls are not intercepted, so cost assertions
keep pricing off the real tables.
Verified by re-running both files against a refreshed snapshot that does
carry xai/grok-4.6; 26/26 pass where they previously failed.
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.
The rewritten cost pass charges each session only for the schemas that
session loaded, which changes local-only estimates too (on a real corpus
the mcp-low-coverage estimate roughly halves). Pin it so the change is
deliberate rather than a side effect of the connector split.
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.
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.
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.
The Optimize scan does real fs I/O (readdir/stat) that only resolves on a
real event-loop turn, but the wait loop counted 20 vi.advanceTimersByTimeAsync
hops under full fake timers, which flush fake timers + microtasks but never
give real I/O a chance to complete. Under load that read a stale
"Scanning Today..." frame. Scope fake timers to just what the 60s
auto-refresh interval needs, leave setImmediate/Date real, and wait on a
real wall-clock deadline instead of a fixed hop count.
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.