Merge remote-tracking branch 'origin/main' into ci/upgrade-path-verification

This commit is contained in:
iamtoruk 2026-08-18 12:48:30 -07:00
commit a336b6bbe5
12 changed files with 279 additions and 29 deletions

View file

@ -4,10 +4,12 @@
### Added
- **`codeburn models --unpriced`.** The dashboard warns about models that price at $0 and points at `codeburn model-alias`, but the list itself was hard to get out of the TUI. This filters the plain-stdout `models` report to exactly those rows, reusing `findUnpricedModels` so local, free, aliased and price-overridden models are treated the same way the warning treats them, and defaulting that mode's min-cost to 0 so $0 rows are not pre-filtered away. Thanks @kocaemre. (#969)
- **`optimize` spots the same long block pasted at the start of many sessions.** The new `recurring-context` detector groups sessions by their opening block — normalized for whitespace and ANSI, hashed over the first 2 KB — and reports a block of at least 1.5 KB that opens 5 or more sessions, with the top three by tokens, their session counts and the project each is confined to. It is a habit, not an apply-able fix: CodeBurn will not move your own text into `CLAUDE.md` for you, so the finding asks Claude to give the block a permanent home (a `CLAUDE.md` rule, or a file read on demand) and hand back a one-line pointer to open sessions with instead. Savings count the repeats only, never the first paste, and are marked `estimated`: provider usage is counted per API call, where the pasted block is mixed in with the system prompt, tool schemas and `CLAUDE.md`, so the block is sized from its own bytes. Injected system reminders and slash-command wrappers are not pastes and are skipped, and neither is a prompt a program wrote — an SDK session or a subagent task — read from the entry's flags, or off the ends of the raw line when the entry is too large for the parser to keep them. The opening block comes from the session scan that already runs, so nothing extra is read from disk.
- **`optimize` spots the same long block pasted at the start of many sessions.** The new `recurring-context` detector groups sessions by their opening block — normalized for whitespace and ANSI, hashed over the first 2 KB — and reports a block of at least 1.5 KB that opens 5 or more sessions, with the top three by tokens, their session counts and the project each is confined to. It is a habit, not an apply-able fix: CodeBurn will not move your own text into `CLAUDE.md` for you, so the finding asks Claude to give the block a permanent home (a `CLAUDE.md` rule, or a file read on demand) and hand back a one-line pointer to open sessions with instead. Savings count the repeats only, never the first paste, and are marked `estimated`: provider usage is counted per API call, where the pasted block is mixed in with the system prompt, tool schemas and `CLAUDE.md`, so the block is sized from its own bytes. Injected system reminders and slash-command wrappers are not pastes and are skipped, and neither is a prompt a program wrote — an SDK session or a subagent task — read from the entry's flags, which survive the parser's large-line path. The opening block comes from the session scan that already runs, so nothing extra is read from disk.
- **Applied fixes get re-measured on every `optimize` run, and told plainly whether they worked.** After `codeburn optimize --apply`, every still-applied fix comes back in an `Applied fixes` section on subsequent `codeburn optimize` runs, carrying the verdict `act report` already computes from the same reconciliation: `worked` (at least 70% of its window-scaled estimate realized), `partial` (something, but under that), `no-effect` (no measured reduction, printed with the exact `codeburn act undo <id>` that puts it back), or `measuring` for anything younger than the 3-day measurement window. The numbers are measured — provider-counted usage over the post-apply window — not re-estimated. `--apply` now says when the re-measure will happen, `--format json` gains `appliedFixes[]` (add-only), and the same section appears in the dashboard TUI and the desktop app. New `codeburn optimize --auto-revert` undoes the fixes that measured no reduction at all through the same code path as `codeburn act undo`; it never touches `partial` or still-measuring fixes, and never auto-reverts a `CLAUDE.md` rule (it prints the undo command instead), matching the `--yes` guardrail.
- **Optimize findings say what to do with them and where their number came from.** Every finding now carries a class and a basis, and every surface groups by it: `Fix now (apply-able)` for findings `codeburn optimize --apply` can write itself, `Habits` for the behavioural ones, `FYI` for informational ones whose cost may be justified. A finding only counts as apply-able when a plan can actually be built for that instance, so an `mcp-deferral-off` caused by Vertex policy or a shell-profile override is grouped as a habit rather than promising a fix that does not exist. Alongside it, each finding is marked `measured` (summed from provider-counted usage) or `estimated` (a schema-size or recovery-fraction model), with the split reported in the header as `N measured · M estimated` in place of the blanket "Estimates only." footer. Sessions whose cost the provider never reported are kept out of the `cost-outliers` peer comparison, and a provider that only ever estimates gets the finding marked `estimated` rather than dropped. `--format json` gains `class` and `basis` per finding plus `summary.measuredSavingsUSD` (existing fields unchanged), and the new `docs/optimize.md` covers what is scanned, exactly what `--apply` may write, and how to read the health grade.
- **`CODEBURN_CACHE_SCOPE=all` forces a full session-cache read.** A ranged query reads only the month shards that can contribute a turn to it, which is a real behaviour change on a warm cache; this is the escape hatch for the case where a number looks wrong and you want to know whether the scoped read is why. Set it and every load ignores its scope and reads every shard, one-shot runs and the resident `codeburn serve` alike. It is a read policy, not an input to any cache fingerprint: setting or unsetting it re-parses nothing and invalidates nothing.
### Added (Windows)
- **`codeburn menubar` installs and launches the tray app on Windows.** The same command that installs the macOS menubar now does the Windows one, through the same pinned-release path: it resolves `windows-v<cliVersion>`, falls back to a scan of the newest `windows-v*` release carrying both assets when that tag has none, downloads the `.msi` with the same retry and backoff, and verifies its sha256 before anything executes it — a mismatch aborts without ever handing the file to the installer. It then runs `msiexec` out of `%SystemRoot%\System32` (never a bare name, so nothing dropped next to the CLI can impersonate it) with `/i <msi> /passive /norestart`, treats exit 3010 as installed-pending-restart and 1602 as a cancelled install rather than failures, and launches the exe named by the product's Uninstall registry key. An already-installed matching version skips the download and just launches; `--force` reinstalls.
- **A menubar app for Windows.** `windows/` is a Tauri 2 tray app — Rust binary, React popover — that puts today's spend in the notification area and mirrors the macOS menubar screen for screen: agent tabs, period switcher, Trend, Forecast, Pulse, Stats and Plan insights, activity and model breakdowns, optimize findings, CSV/JSON export, launch at login, currency, and theme. Windows has no menubar title, so the number lives in a second tray icon rendered from the system font at the panel's native icon size (Settings can turn it off; the tooltip always carries it). It reads everything through the CLI like the macOS and GNOME clients do, and gates on **codeburn 0.9.9 or newer** — the first release accepting `status --format menubar-json --no-optimize` — showing a setup screen with the install command until it finds one. Refresh follows popover visibility the way the macOS app does: 60 s with optimize findings while open, 2 minutes for today's total while closed, and immediately on open when what you are looking at has gone stale. The Claude quota view never spends Claude's single-use refresh token; on a 401 it re-reads Claude Code's own credential file for a token it has already rotated, matching the macOS client. Ships as an unsigned `.msi` from the `windows-v*` tag, which `codeburn menubar` now installs for you. The same crate still builds and runs a tray on Linux, but that stays experimental and unreleased — `gnome/` is the supported Linux surface.
@ -31,6 +33,9 @@
- **The resident `codeburn serve` child.** The first real panel request is also the cache warm-up, so startup never runs an artificial warm-up query beside a duplicate one-shot child; each served command carries its own read-only option allowlist, and anything outside it falls back to a normal spawn; the child exits when its stdin closes, so it can never outlive the app. Requests whose response exceeds the 16 MiB frame limit still replace the child, but that deliberate kill no longer spends the resident's unexpected-death budget. (#972)
### Fixed
- **A date-ranged run no longer republishes the month shards it never read.** A scoped load leaves an out-of-range month on disk, so the files it holds have no visible cache entry and the reconcile re-parses them — re-deriving the entry the shard already stores. That re-parse marked the unloaded month dirty, and the save merged and republished it under a fresh nonce name on every single run, byte-identical content and all, so a repeated `codeburn status --format json` churned old months (on a real corpus: claude/2026-03, cursor/2026-02 and warp/2026-03 renamed every run) and left the retired shards for the sweeper. A merge into an unloaded month that neither adds, changes nor removes an entry now keeps the published shard, so unchanged months keep their names and their bytes. (#1032)
- **`models` and `audit` no longer show two identical `Grok 4.5` rows.** `grok-4.5-build` — the Grok Build harness's variant id — fell into the `grok-4.5` display entry by prefix, and since rows bucket by model id, not display name, the two came out as visually identical rows with different numbers. The variant now shows as `Grok 4.5 (build)`. Display only: no id is rewritten and no cost moves. (#1029)
- **An upgrade no longer loses history for days whose transcripts have only PARTLY aged out.** The never-lose contract carried a cached (day, provider) slice forward only when the re-derivation found NOTHING for it, but transcripts expire per FILE rather than per day: on a day whose sources are mostly gone, a handful of turns from surviving later files still bucket onto it, so the fresh slice came back non-empty but truncated and REPLACED the full cached one. On a real cache upgrading from the last shipped daily-cache version, 2026-07-16 fell from $1,685.17 / 12,530 calls to $385.44 / 560 calls, and 13 days lost $2,765.75, 19,209 calls and 520 sessions in total. A fresh slice now replaces a settled baseline slice only when it carries at least as many CALLS - the same or more evidence; fewer calls means the source set demonstrably lost data, and the baseline is kept whole. The comparison is on calls alone: cost and tokens are re-priced accounting on the same evidence, which is exactly what a legitimate re-derivation changes (the Grok accounting fix keeps its per-day calls and is unaffected), and session counts drift down by a few on days whose sources are entirely intact. Days inside a 7-day settle window stay authoritative - their session files are still on disk, so a shrink there is a real change rather than expiry. The trade-off is deliberate and matches the direction this cache has always chosen: a future fix that legitimately REDUCES calls on a settled day keeps the older, higher value until that day is re-derived at an equal or greater call count. The timezone-change re-derive gets the exact form of the same rule - what the fresh parse can no longer explain under the old bucketing is added on top of the fresh slice instead of being dropped - and the cross-file adoption union is unchanged, where the newer schema still wins per (day, provider).
- **The session chart legend now leads with a visible session disambiguator and title instead of the project path.** Every series in a monorepo shared the same project prefix, so the only thing separating them was a truncated hex fragment — and per-application cost attribution is the main reason to open that chart. `SessionSummary.title` is already parsed and already rendered in the Context tab; the legend now puts the short session id first, prefers the title, and falls back to the previous project-based label when a session never produced one. Titles come from transcripts, so they are stripped of ANSI and control characters and capped before they reach either the legend or the tooltip. (#997)
- **Context-bloat detection now counts reasoning tokens as generated output.** `detectContextBloat` divided context by `totalOutputTokens` alone, but reasoning is stored beside output rather than inside it, so for every reasoning-bearing provider the detector saw a fraction of the tokens actually generated and invented findings - a session whose real ratio was 20:1, under the 25:1 threshold, was reported as 133:1 and "high impact". It now uses the same `output + reasoning` sum the reports use, which corrects grok, codex, kiro, hermes, qwen and cursor-agent alike.
- **The unpriced-models warning in the dashboard is now readable at every terminal width.** It lived in a fixed-width panel with an inline model list and a fix command, so it clipped mid-name at 80 columns and clipped *earlier* at 200, where the three-column layout narrows each panel - neither the affected models nor a runnable command survived. The panel line is now a pointer, `! N unpriced: codeburn models --unpriced` (shortened to `! N: codeburn models --unpriced` below 45 columns of panel), and the model list moves to that command's plain output, which is full width, copyable, and lists every model rather than the first two. The command's hint no longer reads as an unconditional instruction to alias: a subscription or flat-rate model is correctly $0, and mapping it onto another model's per-token rate would invent spend that was never billed. Provider-supplied model IDs are now stripped of terminal control characters in every human-readable report rather than only on the unpriced path, and `--unpriced` shows raw IDs instead of friendly names because `model-alias` keys on the raw ID. (#969)

View file

@ -142,6 +142,8 @@ Three caches under `~/.cache/codeburn/` (override with `CODEBURN_CACHE_DIR`):
All three use atomic write (temp file + `rename`) and write with mode `0o600`. All three carry a numeric `version` field; bumping it forces a recompute next run.
The session cache (`src/session-cache.ts`) sits beside them as a directory of per-provider-month shards. A date-ranged query reads only the shards whose months can contribute a turn to that range; `CODEBURN_CACHE_SCOPE=all` turns that off and reads every shard, whatever the range. It is a read policy only — it is not part of any provider's env fingerprint, so setting or unsetting it never invalidates the cache.
### Optimize Detectors
`src/optimize.ts` exports 20 detectors. Each returns a `WasteFinding | null`. They are composed by `runOptimize()` which collects findings, ranks them by impact, and returns them with `WasteAction` objects (paste-to-CLAUDE.md, paste-to-session-opener, prompt-now, edit shell config).

View file

@ -15,7 +15,11 @@ import type { DateRange, ProjectSummary } from './types.js'
// not just Grok. That pass reads the warm session cache (CACHE_VERSION is
// unchanged and only PROVIDER_PARSE_VERSIONS.grok moved), so it costs seconds
// rather than a full re-parse, and adoptOlderDailyCaches keeps the superseded
// file as the baseline for days no source can still re-derive.
// file as the baseline for days no source can still re-derive. Days whose
// sources only PARTLY survive are held by the partial-survival guard in
// mergeDayEntries, which is what makes a global re-derive safe to force: on a
// real 108-day cache the 17 -> 19 pass moves Grok cost and tokens and nothing
// else - the day-by-day call counts come back identical.
//
// The shipped predecessor is v17; v18 was an unreleased draft of this change
// and only exists in pre-release checkouts. 19 clears both.
@ -856,6 +860,52 @@ function hasPositiveDayContent(day: DailyEntry): boolean {
return false
}
/// PARTIAL SURVIVAL (the v14 never-lose contract, extended past all-or-nothing).
/// v14 protected a (date, provider) slice only when the fresh derivation found
/// NOTHING there. But transcripts age out per FILE, not per day: Claude Code
/// deletes them after ~30 days, and turn-anchored bucketing means a handful of
/// turns from surviving later files still land on a mostly-aged-out day. The
/// fresh slice is then non-empty but truncated, and replacing the baseline with
/// it silently deletes the rest (measured on a real cache upgrading 17 -> 19:
/// 2026-07-16 fell from $1,685.17 / 12,530 calls to $385.44 / 560 calls).
///
/// So a fresh slice replaces a settled baseline slice only when it carries at
/// least as many CALLS — the same or more evidence. Fewer calls means the
/// source set demonstrably lost data, and the baseline is kept whole.
///
/// Why calls and not sessions: session counts shrink routinely on days whose
/// sources are entirely intact (a session's turns re-attribute to a neighbouring
/// day), measured at 1-5 sessions on recent days whose call counts were
/// identical across the re-derivation. A sessions test would freeze stale slices
/// on healthy days. Why not cost/tokens: those are re-priced accounting on the
/// same evidence — exactly what a legitimate re-derivation changes (#1015 Grok
/// keeps its per-day calls and raises cost, and is unaffected by this guard).
///
/// Why no source-set test instead: a day entry records no source files, counts
/// or fingerprints, and the session cache is keyed by file rather than by day,
/// so "were this day's sources all present?" cannot be answered from the cache.
/// The calls comparison is the available proxy.
///
/// TRADE-OFF: a future fix that legitimately REDUCES calls on a settled day
/// (deduplication) is blocked, and that day keeps the older, higher value until
/// its slice is re-derived under an equal-or-greater call count. That is the
/// "estimate high, never lose" direction v14 already chose over silent loss.
///
/// Recent days stay authoritative: within the settle window their session files
/// are still on disk, so a shrink there is a real change (the user deleted a
/// transcript), not aged-out sources. Seven days is far inside the ~30-day
/// retention floor of the shortest-lived source we know of, so any shrink older
/// than that is source loss with overwhelming likelihood.
const SETTLE_DAYS = 7
function settleCutoffDate(now: Date): string {
return toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - SETTLE_DAYS))
}
function isPartialSurvival(date: string, baseline: ProviderDaySlice, fresh: ProviderDaySlice, settleCutoff: string): boolean {
return date < settleCutoff && fresh.calls < baseline.calls
}
/// Index `freshUnderOldTz` (the same parse re-aggregated under the cache's OLD
/// tzKey) by date then provider, so the merge can subtract exactly what the
/// fresh parse still explains under the old bucketing.
@ -886,21 +936,34 @@ function buildTzSubtraction(days: DailyEntry[]): ReadonlyMap<string, ReadonlyMap
/// nothing — its day-level totals cannot be attributed without slices.
/// A primary slice blocks a secondary one only when it carries DATA; a
/// zero-data placeholder (sessions only) is merged into, not treated as a
/// re-derivation of the provider's day.
/// re-derivation of the provider's day — UNLESS that data is a strict shrink of
/// the baseline on a settled day, see `isPartialSurvival`.
/// `subtract`, present ONLY on the tz-change re-derive, maps (date, provider)
/// to the content the fresh parse still attributes there under the OLD
/// bucketing. Every baseline slice the merge would otherwise carry has that
/// content subtracted first (clamped at 0, dropped when nothing positive
/// remains), so turns that re-bucketed across local midnight are not counted on
/// both their old and new days. Absent (undefined) on every other path, which
/// keeps those merges byte-identical to the pre-fix behavior.
/// both their old and new days. What remains is by construction content NO
/// surviving source explains, so it is added even when the fresh slice for that
/// (date, provider) already carries data — the tz path's form of the
/// partial-survival rule, exact instead of heuristic. Absent (undefined) on
/// every other path, which keeps those merges byte-identical to the pre-fix
/// behavior apart from that rule.
export function mergeDayEntries(
primary: DailyEntry[],
secondary: DailyEntry[],
markSecondaryCarried: boolean,
subtract?: ReadonlyMap<string, ReadonlyMap<string, ProviderDaySlice>>,
/// Set ONLY by the complete-parse re-derive, where `primary` is a fresh
/// derivation from live sources and `secondary` is the cache baseline: a
/// primary slice with fewer calls there means sources aged out, so the
/// baseline wins on settled days (`isPartialSurvival`). The adoption union
/// leaves it off - both sides are cache generations there and the newer
/// schema deliberately wins per (date, provider).
guardPartialSurvival = false,
): DailyEntry[] {
const byDate = new Map<string, DailyEntry>()
const settleCutoff = settleCutoffDate(new Date())
for (const day of primary) byDate.set(day.date, structuredClone(day))
for (const day of secondary) {
const existing = byDate.get(day.date)
@ -927,7 +990,6 @@ export function mergeDayEntries(
// day) still carry a real session count — worth preserving.
if (!hasSliceData(slice) && !(slice.sessions ?? 0)) continue
const existingSlice = Object.hasOwn(existing.providers, provider) ? existing.providers[provider] : undefined
if (existingSlice && hasSliceData(existingSlice)) continue
let toAdd = slice
let residual = false
if (subtract) {
@ -943,6 +1005,13 @@ export function mergeDayEntries(
residual = true
}
}
if (existingSlice && hasSliceData(existingSlice) && !residual) {
if (!guardPartialSurvival || !isPartialSurvival(day.date, slice, existingSlice, settleCutoff)) continue
// The baseline holds more evidence than the sources can still produce:
// swap the fresh slice back out for it (inverse of addSliceIntoDay, so
// the day's totals and nested maps stay reconciled with its slices).
subtractSliceFromDay(existing, provider, existingSlice)
}
addSliceIntoDay(existing, provider, toAdd, residual)
if (markSecondaryCarried) existing.carried = true
}
@ -1105,7 +1174,7 @@ export async function ensureCacheHydrated(
tzSubtraction = buildTzSubtraction(aggregateDaysInTz(wideProjects, c.tzKey))
}
const merged = parseWasComplete
? mergeDayEntries(freshDays, baseline, true, tzSubtraction)
? mergeDayEntries(freshDays, baseline, true, tzSubtraction, true)
: mergeDayEntries(baseline, freshDays, false)
c = {
version: DAILY_CACHE_VERSION,

View file

@ -947,6 +947,10 @@ const SHORT_NAMES: Record<string, string> = {
// The Grok Build harness reports the model it runs (`grok-4.5`), so this is
// the model's own name; `grok-build*` ids still resolve to "Grok Build".
'grok-4.5': 'Grok 4.5',
// The harness also reports a `-build` variant of that model. It is a distinct
// id and reports bucket by id, so without its own entry the prefix match gave
// it the same name as `grok-4.5` and the report showed two identical rows.
'grok-4.5-build': 'Grok 4.5 (build)',
// ClinePass routes models as `cline-pass/<slug>`; getShortModelName's path
// fallback strips the prefix and re-resolves the bare slug through this
// table, the same way it handles `accounts/fireworks/models/<slug>`.

View file

@ -663,22 +663,12 @@ function normalizeOpener(text: string): string {
return stripAnsi(text).replace(/\s+/g, ' ').trim()
}
const MACHINE_PROMPT_HEAD_BYTES = 2048
const MACHINE_PROMPT_PATTERN = /"promptSource"\s*:\s*"sdk"|"isSidechain"\s*:\s*true/
/// True when a program wrote this prompt rather than a person pasting it: an
/// SDK caller, or a parent agent writing a subagent's task. Either repeats by
/// design and has no home in CLAUDE.md. A user entry over the parser's
/// large-line threshold — routine for generated prompts — comes back without
/// its root flags, so those are read off the raw line instead: the ends of it,
/// since the fields sit either side of the message that made the line large.
function isMachineWrittenPrompt(entry: Record<string, unknown>, line: string | Buffer): boolean {
if (entry['promptSource'] === 'sdk' || entry['isSidechain'] === true) return true
const edge = (start: number, end: number): string =>
typeof line === 'string' ? line.slice(start, end) : line.subarray(start, end).toString('utf-8')
const head = edge(0, MACHINE_PROMPT_HEAD_BYTES)
const tail = edge(Math.max(MACHINE_PROMPT_HEAD_BYTES, line.length - MACHINE_PROMPT_HEAD_BYTES), line.length)
return MACHINE_PROMPT_PATTERN.test(head) || MACHINE_PROMPT_PATTERN.test(tail)
/// design and has no home in CLAUDE.md. Both flags survive the parser's
/// large-line path, which is where generated prompts routinely land.
function isMachineWrittenPrompt(entry: Record<string, unknown>): boolean {
return entry['promptSource'] === 'sdk' || entry['isSidechain'] === true
}
/// A session's opening block, or null when it is too small to matter or is
@ -768,7 +758,7 @@ export async function scanJsonlFile(
userMessages.push(msgContent.slice(0, OPTIMIZE_TEXT_CAP))
if (!sawUserText) {
sawUserText = true
const opener = isMachineWrittenPrompt(entry, line) ? null : toSessionOpener(msgContent, project)
const opener = isMachineWrittenPrompt(entry) ? null : toSessionOpener(msgContent, project)
if (opener) openers.push(opener)
}
} else if (Array.isArray(msgContent)) {
@ -781,7 +771,7 @@ export async function scanJsonlFile(
remaining -= text.length
if (!sawUserText) {
sawUserText = true
const opener = isMachineWrittenPrompt(entry, line) ? null : toSessionOpener(block.text, project)
const opener = isMachineWrittenPrompt(entry) ? null : toSessionOpener(block.text, project)
if (opener) openers.push(opener)
}
}

View file

@ -557,7 +557,7 @@ function extractObjectFields(
return captured
}
const LARGE_ROOT_FIELDS = ['type', 'timestamp', 'sessionId', 'cwd', 'gitBranch', 'attachment', 'message', 'isSidechain'] as const
const LARGE_ROOT_FIELDS = ['type', 'timestamp', 'sessionId', 'cwd', 'gitBranch', 'attachment', 'message', 'isSidechain', 'promptSource'] as const
const LARGE_ASSISTANT_MESSAGE_FIELDS = ['model', 'usage', 'id', 'content'] as const
function parseLargeJsonl(line: string | Buffer): JournalEntry | null {
@ -578,10 +578,12 @@ function parseLargeJsonl(line: string | Buffer): JournalEntry | null {
const sessionId = readJsonString(source, root['sessionId'])
const cwd = readJsonString(source, root['cwd'])
const gitBranch = readJsonString(source, root['gitBranch'])
const promptSource = readJsonString(source, root['promptSource'])
if (timestamp !== undefined) entry.timestamp = timestamp
if (sessionId !== undefined) entry.sessionId = sessionId
if (cwd !== undefined) entry.cwd = cwd
if (gitBranch !== undefined) entry.gitBranch = gitBranch
if (promptSource !== undefined) entry.promptSource = promptSource
const addedNames = extractLargeAddedNames(source, root['attachment'])
if (addedNames.length > 0) {
;(entry as Record<string, unknown>)['attachment'] = { type: 'deferred_tools_delta', addedNames }

View file

@ -792,8 +792,15 @@ async function loadShard(path: string): Promise<Record<string, CachedFile> | nul
* full: the first because its cache is the only surviving record of pruned
* usage, the second because a fingerprint change discards the whole section and
* must see every entry it is discarding.
*
* `CODEBURN_CACHE_SCOPE=all` is the escape hatch: it drops the scope here, at
* the one place every caller routes through, so a suspect scoped read can be
* compared against a full one without a rebuild. It is a READ policy and
* deliberately not part of any env fingerprint (PROVIDER_ENV_VARS) setting or
* unsetting it must never invalidate a cache, only change how much of it is read.
*/
export async function loadCache(scope?: CacheLoadScope): Promise<SessionCache> {
if (process.env['CODEBURN_CACHE_SCOPE'] === 'all') scope = undefined
const dir = sessionCacheDir()
const envelope = await readEnvelope(dir)
if (!envelope) return afterMissingShardCache()
@ -1036,6 +1043,15 @@ export async function saveCache(cache: SessionCache, verifyStillOwner?: () => Pr
const files = plan.groups.get(bucket)!
const onDisk = from ? await loadShard(join(dir, from)) : null
if (!onDisk) return writeShard(provider, bucket, files)
// A file whose month this run never loaded has no visible cache entry, so it
// looks uncached and is re-parsed into the same bucket — re-deriving the
// entry the shard already holds. Republishing then churns the shard's nonce
// name on every run for content that never changed (#1032), so a merge that
// neither adds, changes nor removes an entry keeps the published shard.
const adds = Object.entries(files).some(([path, file]) =>
onDisk[path] === undefined || JSON.stringify(onDisk[path]) !== JSON.stringify(file))
const removes = [...plan.moved].some(path => onDisk[path] !== undefined && files[path] === undefined)
if (!adds && !removes) return { name: from!, until: untilMonth(onDisk) }
for (const path of plan.moved) delete onDisk[path]
return writeShard(provider, bucket, { ...onDisk, ...files })
}

View file

@ -712,3 +712,102 @@ describe('adoption union across older cache files', () => {
expect(hydrated.complete).toBe(true)
})
})
describe('partial survival: a truncated fresh slice cannot delete a settled baseline', () => {
// The real 0.9.20 -> next upgrade loss: transcripts age out per FILE, so a
// mostly-forgotten day still gets a handful of turns from surviving later
// files. The fresh slice is non-empty but truncated, and before this guard it
// replaced the baseline outright ($1,685.17 / 12,530 calls -> $385.44 / 560).
const settled = daysAgoStr(33)
it('keeps the baseline slice when the fresh derivation lost calls on a settled day', () => {
const fresh = day(settled, { claude: slice(385.44, 560, { sessions: 0, inputTokens: 10 }) })
const baseline = day(settled, { claude: slice(1685.17, 12530, { sessions: 214, inputTokens: 500 }) })
const merged = mergeDayEntries([fresh], [baseline], true, undefined, true)
const m = merged[0]!
expect(m.providers['claude']).toMatchObject({ cost: 1685.17, calls: 12530, sessions: 214 })
// Day totals track the swap - they must equal the kept slice, not the sum.
expect(m.cost).toBeCloseTo(1685.17, 5)
expect(m.calls).toBe(12530)
expect(m.sessions).toBe(214)
expect(m.inputTokens).toBe(500)
expect(m.carried).toBe(true)
})
it('a truncated slice cannot take other providers down with it', () => {
const fresh = day(settled, { claude: slice(10, 5), codex: slice(7, 3) })
const baseline = day(settled, { claude: slice(100, 50) })
const m = mergeDayEntries([fresh], [baseline], true, undefined, true)[0]!
expect(m.providers['claude']).toMatchObject({ cost: 100, calls: 50 })
expect(m.providers['codex']).toMatchObject({ cost: 7, calls: 3 })
expect(m.cost).toBeCloseTo(107, 5)
expect(m.calls).toBe(53)
})
it('the fresh slice wins on equal calls at a different cost (the Grok re-pricing)', () => {
const fresh = day(settled, { grok: slice(11.89, 21, { sessions: 21, outputTokens: 900 }) })
const baseline = day(settled, { grok: slice(3.29, 21, { sessions: 21, outputTokens: 200 }) })
const m = mergeDayEntries([fresh], [baseline], true, undefined, true)[0]!
expect(m.providers['grok']).toMatchObject({ cost: 11.89, calls: 21, outputTokens: 900 })
expect(m.cost).toBeCloseTo(11.89, 5)
expect(m.carried).toBeUndefined()
})
it('a fresh slice with FEWER sessions but equal calls still wins (sessions drift on healthy days)', () => {
const fresh = day(settled, { claude: slice(1021.11, 3295, { sessions: 71 }) })
const baseline = day(settled, { claude: slice(1021.11, 3295, { sessions: 72 }) })
const m = mergeDayEntries([fresh], [baseline], true, undefined, true)[0]!
expect(m.providers['claude']!.sessions).toBe(71)
})
it('recent days stay authoritative: a shrink inside the settle window is honored', () => {
const recent = daysAgoStr(2)
const fresh = day(recent, { claude: slice(20, 4) })
const baseline = day(recent, { claude: slice(90, 40) })
const m = mergeDayEntries([fresh], [baseline], true, undefined, true)[0]!
expect(m.providers['claude']).toMatchObject({ cost: 20, calls: 4 })
expect(m.cost).toBe(20)
})
it('the adoption union is unguarded: the newer schema still wins per (date, provider)', () => {
const newer = day(settled, { claude: slice(50, 5) })
const older = day(settled, { claude: slice(100, 10) })
const m = mergeDayEntries([newer], [older], true)[0]!
expect(m.providers['claude']).toMatchObject({ cost: 50, calls: 5 })
})
it('end-to-end: a version-bump re-derive whose transcripts aged out keeps the full day', async () => {
const cache: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: 'cfg-A',
tzKey: currentTzKey(),
lastComputedDate: daysAgoStr(1),
days: [day(settled, { claude: slice(1685.17, 12530, { sessions: 214 }) })],
complete: false, // what a version bump / adoption leaves behind
}
await saveDailyCache(cache)
const truncated = [day(settled, { claude: slice(385.44, 560, { sessions: 0 }) })]
const out = await ensureCacheHydrated(noSessions, () => truncated, 'cfg-A')
const kept = out.days.find(d => d.date === settled)!
expect(kept.providers['claude']).toMatchObject({ cost: 1685.17, calls: 12530 })
expect(kept.cost).toBeCloseTo(1685.17, 5)
// A --period all payload sums the kept slices, not the truncated ones.
const period = buildPeriodDataFromDays(out.days, 'all')
expect(period.cost).toBeCloseTo(1685.17, 5)
expect(period.calls).toBe(12530)
})
it('a fully sourceless day is still carried whole (v14 behavior, unchanged)', async () => {
const cache: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: 'cfg-A',
tzKey: currentTzKey(),
lastComputedDate: daysAgoStr(1),
days: [day(settled, { claude: slice(230.06, 400) })],
complete: false,
}
await saveDailyCache(cache)
const out = await ensureCacheHydrated(noSessions, () => [], 'cfg-A')
expect(out.days[0]).toMatchObject({ date: settled, cost: 230.06, calls: 400, carried: true })
})
})

View file

@ -551,17 +551,15 @@ describe('detectRecurringContext', () => {
expect(detectRecurringContext(openers)).toBeNull()
})
// Over 32 KB the JSONL parser returns a reduced entry without the root
// flags, so the markers have to be read off the raw line.
it('skips machine-written prompts too large for the parser to keep flags on', async () => {
// Over 32 KB the JSONL parser returns a reduced entry; the root flags are
// part of that reduction, so the markers survive.
it('skips machine-written prompts on lines too large for a full parse', async () => {
const root = makeFixtureRoot()
const now = new Date().toISOString()
const huge = BRIEF + 'x'.repeat(40_000)
const openers: SessionOpener[] = []
for (let i = 0; i < 6; i++) {
const filePath = join(root, `huge-${i}.jsonl`)
// Field order matters: the flags land past the head, behind the very
// message that made the line large.
writeFile(filePath, JSON.stringify({
isSidechain: false, type: 'user', message: { content: huge }, timestamp: now, promptSource: 'sdk',
}))

View file

@ -44,7 +44,25 @@ function largeAssistantLine(): string {
})
}
// The fields sit either side of the message that makes the line large, which
// is where a generated prompt puts them in the wild.
function largeMachineWrittenLine(): string {
return JSON.stringify({
isSidechain: true,
type: 'user',
message: { role: 'user', content: 'brief ' + 'x'.repeat(40_000) },
timestamp: '2026-05-01T00:00:00Z',
promptSource: 'sdk',
})
}
describe('large JSONL compact scanner', () => {
it('keeps the flags marking a program-written prompt', () => {
const parsed = parseJsonlLine(largeMachineWrittenLine())
expect(parsed?.promptSource).toBe('sdk')
expect(parsed?.isSidechain).toBe(true)
})
it('extracts user text from array content without full JSON.parse', () => {
const parsed = parseJsonlLine(largeUserLine())
expect(parsed?.type).toBe('user')

View file

@ -490,6 +490,13 @@ describe('grok provider - display names', () => {
expect(provider.modelDisplayName('grok-build')).toBe('Grok Build')
})
// Two distinct ids, so two rows; identical names made them look like one row
// printed twice (#1029).
it('distinguishes the build variant of a model from the model itself', () => {
expect(provider.modelDisplayName('grok-4.5')).toBe('Grok 4.5')
expect(provider.modelDisplayName('grok-4.5-build')).toBe('Grok 4.5 (build)')
})
it('normalizes tool names', () => {
expect(provider.toolDisplayName('run_terminal_command')).toBe('Bash')
expect(provider.toolDisplayName('mystery_tool')).toBe('mystery_tool')

View file

@ -499,6 +499,28 @@ describe('scoped load', () => {
.toEqual(['/live/apr.jsonl', '/live/jun.jsonl', '/live/jun2.jsonl', '/live/mar.jsonl'])
})
it('keeps the unloaded month\'s shard name when a re-parse re-derives the same entry', async () => {
await seedThreeMonths()
// The March entry is invisible to a June-scoped run, so the reconcile
// re-parses that file and writes the identical entry straight back. Nothing
// changed, so the March shard must keep its name run after run (#1032).
const nameOf = async (): Promise<string> => (await envelope()).providers['claude']!.shards['2026-03']!.name
const before = await nameOf()
for (let run = 0; run < 2; run++) {
clearLoadCacheMemo()
const scoped = await loadCache(juneScope)
scoped.providers['claude']!.files['/live/mar.jsonl'] = fileSpanning('2026-03-10T10:00:00Z')
markCacheDirty(scoped, 'claude', '/live/mar.jsonl')
await saveCache(scoped)
expect(await nameOf(), `March republished on run ${run + 1}`).toBe(before)
}
clearLoadCacheMemo()
const full = await loadCache()
expect(Object.keys(full.providers['claude']!.files).sort())
.toEqual(['/live/apr.jsonl', '/live/jun.jsonl', '/live/mar.jsonl'])
})
it('merges rather than replaces when a re-parse lands in an unloaded month', async () => {
await seedThreeMonths()
clearLoadCacheMemo()
@ -516,6 +538,24 @@ describe('scoped load', () => {
.toEqual(['/live/apr.jsonl', '/live/jun.jsonl', '/live/mar.jsonl'])
})
it('CODEBURN_CACHE_SCOPE=all reads every month and memoizes as unscoped', async () => {
await seedThreeMonths()
clearLoadCacheMemo()
const unscoped = await loadCache()
clearLoadCacheMemo()
process.env['CODEBURN_CACHE_SCOPE'] = 'all'
try {
const forced = await loadCache(juneScope)
expect(forced).toEqual(unscoped)
// Memoized as a full load, so a resident serve reuses it for any range.
delete process.env['CODEBURN_CACHE_SCOPE']
expect(await loadCache(juneScope)).toBe(forced)
} finally {
delete process.env['CODEBURN_CACHE_SCOPE']
}
})
it('never scopes a provider whose fingerprint moved, or a durable one', async () => {
const cache: SessionCache = {
version: CACHE_VERSION,