diff --git a/CHANGELOG.md b/CHANGELOG.md index a5a9dc2a..9bee3bad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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`, 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 /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. @@ -32,6 +34,7 @@ ### 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. diff --git a/docs/architecture.md b/docs/architecture.md index 3b949bb4..5ec39e28 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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). diff --git a/src/models.ts b/src/models.ts index dbe1c49d..c0e6746f 100644 --- a/src/models.ts +++ b/src/models.ts @@ -947,6 +947,10 @@ const SHORT_NAMES: Record = { // 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/`; getShortModelName's path // fallback strips the prefix and re-resolves the bare slug through this // table, the same way it handles `accounts/fireworks/models/`. diff --git a/src/optimize.ts b/src/optimize.ts index 425792e8..2ecd348b 100644 --- a/src/optimize.ts +++ b/src/optimize.ts @@ -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, 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): 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) } } diff --git a/src/parser.ts b/src/parser.ts index 6b8a75fc..d67aaab4 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -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)['attachment'] = { type: 'deferred_tools_delta', addedNames } diff --git a/src/session-cache.ts b/src/session-cache.ts index a17df0a8..79e8ee2b 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -792,8 +792,15 @@ async function loadShard(path: string): Promise | 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 { + if (process.env['CODEBURN_CACHE_SCOPE'] === 'all') scope = undefined const dir = sessionCacheDir() const envelope = await readEnvelope(dir) if (!envelope) return afterMissingShardCache() diff --git a/tests/optimize-fs.test.ts b/tests/optimize-fs.test.ts index 60b64b4f..96110ba5 100644 --- a/tests/optimize-fs.test.ts +++ b/tests/optimize-fs.test.ts @@ -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', })) diff --git a/tests/parser-large-json-scanner.test.ts b/tests/parser-large-json-scanner.test.ts index 00ebe5de..d483eeee 100644 --- a/tests/parser-large-json-scanner.test.ts +++ b/tests/parser-large-json-scanner.test.ts @@ -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') diff --git a/tests/providers/grok.test.ts b/tests/providers/grok.test.ts index 24585e29..eba7dc2b 100644 --- a/tests/providers/grok.test.ts +++ b/tests/providers/grok.test.ts @@ -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') diff --git a/tests/session-cache-shards.test.ts b/tests/session-cache-shards.test.ts index e965f181..17602980 100644 --- a/tests/session-cache-shards.test.ts +++ b/tests/session-cache-shards.test.ts @@ -538,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,