perf(desktop): warm reports and cache compare scans

This commit is contained in:
reviewer 2026-07-22 01:30:52 +02:00
parent 6e3c57a9ff
commit 5c7ff78a89
21 changed files with 418 additions and 84 deletions

View file

@ -32,7 +32,7 @@ This follows the menubar pattern:
Current bridge calls:
- Overview: `codeburn status --format menubar-json --period <period> [--provider <provider>]`
- Overview: `codeburn status --format menubar-json --period <period> --no-timeline --no-optimize [--provider <provider>]`
- Plans: `codeburn status --format json --period <period>`
- Models: `codeburn models --format json --period <period> [--provider <provider>] [--by-task]`
- Optimize: `codeburn yield --format json --period <period>`

View file

@ -456,9 +456,9 @@ describe('spawnCli concurrency scheduler', () => {
const releaseDir = join(dir, 'release'); mkdirSync(releaseDir)
schedulerBin(startedFile, releaseDir)
// Fill both slots so the next two calls must queue.
const p1 = spawnCli(['fill1'], { priority: 'background' })
const p2 = spawnCli(['fill2'], { priority: 'background' })
// Fill both slots with real interactive work so the next two calls queue.
const p1 = spawnCli(['fill1'], { priority: 'interactive' })
const p2 = spawnCli(['fill2'], { priority: 'interactive' })
await waitUntil(() => startedList(startedFile).length === 2)
// Queue a background first, then an interactive.
@ -478,6 +478,27 @@ describe('spawnCli concurrency scheduler', () => {
await Promise.all([p1, p2, pbg, pint])
})
it('reserves one CLI lane while background prefetch is running', async () => {
const startedFile = join(dir, 'started')
const releaseDir = join(dir, 'release'); mkdirSync(releaseDir)
schedulerBin(startedFile, releaseDir)
const bg1 = spawnCli(['bg1'], { priority: 'background' })
const bg2 = spawnCli(['bg2'], { priority: 'background' })
await waitUntil(() => startedList(startedFile).includes('bg1'))
await delay(50)
expect(startedList(startedFile)).toEqual(['bg1'])
const interactive = spawnCli(['clicked'], { priority: 'interactive' })
await waitUntil(() => startedList(startedFile).includes('clicked'))
expect(startedList(startedFile)).not.toContain('bg2')
release(releaseDir, 'bg1'); release(releaseDir, 'clicked')
await waitUntil(() => startedList(startedFile).includes('bg2'))
release(releaseDir, 'bg2')
await Promise.all([bg1, bg2, interactive])
})
it('does not spend a slot on a coalesced (same-argv) call', async () => {
const startedFile = join(dir, 'started')
const releaseDir = join(dir, 'release'); mkdirSync(releaseDir)

View file

@ -77,10 +77,13 @@ let running = 0
const interactiveQueue: SlotWaiter[] = []
const backgroundQueue: SlotWaiter[] = []
/** Grant free slots to queued waiters, interactive first, up to the cap. */
/** Grant free slots to queued waiters, interactive first, up to the cap.
* Background warmups intentionally use at most MAX-1 slots so speculative tab
* prefetch can never occupy every CLI lane; a click always has one lane ready. */
function pumpSlots(): void {
while (running < MAX_CONCURRENT_CLI) {
const waiter = interactiveQueue.shift() ?? backgroundQueue.shift()
const interactive = interactiveQueue.shift()
const waiter = interactive ?? (running < MAX_CONCURRENT_CLI - 1 ? backgroundQueue.shift() : undefined)
if (!waiter) return
running += 1
waiter.resolve()

View file

@ -72,8 +72,8 @@ const CHANNELS = [
] as const
const ARGV_CASES: Array<{ channel: string; args: unknown[]; argv: string[] }> = [
{ channel: 'codeburn:getOverview', args: ['30days', 'claude'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--provider', 'claude'] },
{ channel: 'codeburn:getOverview', args: ['30days', 'all'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline'] },
{ channel: 'codeburn:getOverview', args: ['30days', 'claude'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--no-optimize', '--provider', 'claude'] },
{ channel: 'codeburn:getOverview', args: ['30days', 'all'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--no-optimize'] },
{ channel: 'codeburn:getPlans', args: ['week'], argv: ['status', '--format', 'json', '--period', 'week'] },
{ channel: 'codeburn:getActReport', args: [], argv: ['act', 'report', '--json'] },
{ channel: 'codeburn:getModels', args: ['week', 'claude', true], argv: ['models', '--format', 'json', '--period', 'week', '--provider', 'claude', '--by-task'] },
@ -86,9 +86,9 @@ const ARGV_CASES: Array<{ channel: string; args: unknown[]; argv: string[] }> =
{ channel: 'codeburn:getYield', args: ['today', 'claude'], argv: ['yield', '--format', 'json', '--period', 'today', '--provider', 'claude'] },
{ channel: 'codeburn:getSpendFlow', args: ['month', 'openai'], argv: ['spend', '--format', 'flow-json', '--period', 'month', '--provider', 'openai'] },
{ channel: 'codeburn:getOptimizeReport', args: ['month', 'openai'], argv: ['optimize', '--format', 'json', '--period', 'month', '--provider', 'openai'] },
{ channel: 'codeburn:getOverview', args: ['30days', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--from', '2026-07-01', '--to', '2026-07-11'] },
{ channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, 'claude-config:91dda17e8cf35193'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--claude-config-source', 'claude-config:91dda17e8cf35193'] },
{ channel: 'codeburn:getOverview', args: ['month', 'claude', { from: '2026-07-01', to: '2026-07-11' }, 'claude-desktop:980e1e488a654830'], argv: ['status', '--format', 'menubar-json', '--period', 'month', '--no-timeline', '--provider', 'claude', '--from', '2026-07-01', '--to', '2026-07-11', '--claude-config-source', 'claude-desktop:980e1e488a654830'] },
{ channel: 'codeburn:getOverview', args: ['30days', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--no-optimize', '--from', '2026-07-01', '--to', '2026-07-11'] },
{ channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, 'claude-config:91dda17e8cf35193'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--no-optimize', '--claude-config-source', 'claude-config:91dda17e8cf35193'] },
{ channel: 'codeburn:getOverview', args: ['month', 'claude', { from: '2026-07-01', to: '2026-07-11' }, 'claude-desktop:980e1e488a654830'], argv: ['status', '--format', 'menubar-json', '--period', 'month', '--no-timeline', '--no-optimize', '--provider', 'claude', '--from', '2026-07-01', '--to', '2026-07-11', '--claude-config-source', 'claude-desktop:980e1e488a654830'] },
{ channel: 'codeburn:getModels', args: ['week', 'claude', true, { from: '2026-07-01', to: '2026-07-11' }], argv: ['models', '--format', 'json', '--period', 'week', '--provider', 'claude', '--by-task', '--from', '2026-07-01', '--to', '2026-07-11'] },
{ channel: 'codeburn:getYield', args: ['today', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['yield', '--format', 'json', '--period', 'today', '--from', '2026-07-01', '--to', '2026-07-11'] },
{ channel: 'codeburn:getSpendFlow', args: ['month', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['spend', '--format', 'flow-json', '--period', 'month', '--from', '2026-07-01', '--to', '2026-07-11'] },
@ -163,7 +163,7 @@ describe('createBridgeHandlers (IPC wiring)', () => {
const { spawnCli, spawnCliAction, calls } = fakeSpawn()
const handlers = createBridgeHandlers(withQuota({ spawnCli, spawnCliAction, resolveCodeburnPath: () => '/bin/codeburn' }))
const res = await handlers['codeburn:getOverview']!('30days', 'all')
expect(calls[0]).toEqual(['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline'])
expect(calls[0]).toEqual(['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--no-optimize'])
expect(res).toEqual({ ok: true, value: { current: { cost: 12.34 } } })
})
@ -443,6 +443,21 @@ describe('createBridgeHandlers (cold-start warmup)', () => {
expect(opts[2]?.priority).toBe('background')
})
it('runs section prefetch reads at background priority without changing argv', async () => {
const opts: Array<Record<string, unknown> | undefined> = []
const spawnCli = vi.fn(async (_args: string[], o?: Record<string, unknown>) => { opts.push(o); return [] })
const handlers = createBridgeHandlers(base({ spawnCli }))
await handlers['codeburn:getSessions']!('30days', 'all', undefined, true)
await handlers['codeburn:getModels']!('30days', 'all', false, undefined, true)
await handlers['codeburn:getCompareModels']!('30days', 'all', true)
expect(spawnCli.mock.calls[0]?.[0]).toEqual(['sessions', '--format', 'json', '--period', '30days'])
expect(spawnCli.mock.calls[1]?.[0]).toEqual(['models', '--format', 'json', '--period', '30days'])
expect(spawnCli.mock.calls[2]?.[0]).toEqual(['compare', '--format', 'json', '--period', '30days'])
expect(opts.map(o => o?.priority)).toEqual(['background', 'background', 'background'])
})
it('re-arms the long timeout when the first overview fails (cache is still cold)', async () => {
const opts: Array<{ timeoutMs?: number } | undefined> = []
let n = 0

View file

@ -253,12 +253,15 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re
telemetry?.track('cold_start', { ms: Date.now() - (coldStartBegan ?? Date.now()), timedOut })
}
const run = (build: (...args: any[]) => string[]): Handler => async (...args: any[]) => {
const run = (build: (...args: any[]) => string[], backgroundArg?: number): Handler => async (...args: any[]) => {
let cmd: string | undefined
try {
const argv = build(...args)
cmd = argv[0]
return { ok: true, value: await deps.spawnCli(argv) }
const priority: SpawnPriority | undefined = backgroundArg !== undefined && args[backgroundArg] === true
? 'background'
: undefined
return { ok: true, value: await deps.spawnCli(argv, priority ? { priority } : undefined) }
} catch (err) {
const error = toEnvelopeError(err)
telemetry?.track('cli_error', cliErrorProps(err, cmd))
@ -270,7 +273,7 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re
// --no-timeline (skips buildGranularHistory on every poll). The Swift menubar
// omits the flag and keeps the timeline unchanged.
const buildOverviewArgs = (period: string, provider: string, range?: DateRange, configSource?: string | null): string[] => [
'status', '--format', 'menubar-json', '--period', vPeriod(period), '--no-timeline',
'status', '--format', 'menubar-json', '--period', vPeriod(period), '--no-timeline', '--no-optimize',
...providerArgs(vProvider(provider)), ...rangeArgs(vRange(range)), ...configSourceArgs(vConfigSource(configSource)),
]
@ -319,13 +322,13 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re
'codeburn:getActReport': run(() => ['act', 'report', '--json']),
'codeburn:getModels': run((period: string, provider: string, byTask: boolean, range?: DateRange) => [
'models', '--format', 'json', '--period', vPeriod(period), ...providerArgs(vProvider(provider)), ...(byTask ? ['--by-task'] : []), ...rangeArgs(vRange(range)),
]),
], 4),
'codeburn:getSessions': run((period: string, provider: string, range?: DateRange) => [
'sessions', '--format', 'json', '--period', vPeriod(period), ...providerArgs(vProvider(provider)), ...rangeArgs(vRange(range)),
]),
], 3),
'codeburn:getCompareModels': run((period: string, provider: string) => [
'compare', '--format', 'json', '--period', vPeriod(period), ...providerArgs(vProvider(provider)),
]),
], 2),
'codeburn:getCompare': run((period: string, provider: string, modelA: string, modelB: string) => [
'compare', '--format', 'json', '--period', vPeriod(period), ...providerArgs(vProvider(provider)), '--model-a', vToken(modelA), '--model-b', vToken(modelB),
]),
@ -334,10 +337,10 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re
]),
'codeburn:getSpendFlow': run((period: string, provider: string, range?: DateRange) => [
'spend', '--format', 'flow-json', '--period', vPeriod(period), ...providerArgs(vProvider(provider)), ...rangeArgs(vRange(range)),
]),
], 3),
'codeburn:getOptimizeReport': run((period: string, provider: string, range?: DateRange) => [
'optimize', '--format', 'json', '--period', vPeriod(period), ...providerArgs(vProvider(provider)), ...rangeArgs(vRange(range)),
]),
], 3),
'codeburn:getDevices': run((period: string) => ['devices', '--format', 'json', '--period', vPeriod(period)]),
'codeburn:getDevicesScan': run(() => ['devices', 'scan', '--format', 'json']),
'codeburn:getShareStatus': run(() => ['share', 'status', '--format', 'json']),

View file

@ -23,13 +23,13 @@ const bridge = {
getOverview: (period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean) => invoke('codeburn:getOverview', period, provider, range, configSource, background),
getPlans: (period: string) => invoke('codeburn:getPlans', period),
getActReport: () => invoke('codeburn:getActReport'),
getModels: (period: string, provider: string, byTask: boolean, range?: DateRange) => invoke('codeburn:getModels', period, provider, byTask, range),
getSessions: (period: string, provider: string, range?: DateRange) => invoke('codeburn:getSessions', period, provider, range),
getCompareModels: (period: string, provider: string) => invoke('codeburn:getCompareModels', period, provider),
getModels: (period: string, provider: string, byTask: boolean, range?: DateRange, background?: boolean) => invoke('codeburn:getModels', period, provider, byTask, range, background),
getSessions: (period: string, provider: string, range?: DateRange, background?: boolean) => invoke('codeburn:getSessions', period, provider, range, background),
getCompareModels: (period: string, provider: string, background?: boolean) => invoke('codeburn:getCompareModels', period, provider, background),
getCompare: (period: string, provider: string, modelA: string, modelB: string) => invoke('codeburn:getCompare', period, provider, modelA, modelB),
getYield: (period: string, provider: string, range?: DateRange) => invoke('codeburn:getYield', period, provider, range),
getSpendFlow: (period: string, provider: string, range?: DateRange) => invoke('codeburn:getSpendFlow', period, provider, range),
getOptimizeReport: (period: string, provider: string, range?: DateRange) => invoke('codeburn:getOptimizeReport', period, provider, range),
getSpendFlow: (period: string, provider: string, range?: DateRange, background?: boolean) => invoke('codeburn:getSpendFlow', period, provider, range, background),
getOptimizeReport: (period: string, provider: string, range?: DateRange, background?: boolean) => invoke('codeburn:getOptimizeReport', period, provider, range, background),
getDevices: (period: string) => invoke('codeburn:getDevices', period),
getDevicesScan: () => invoke('codeburn:getDevicesScan'),
getShareStatus: () => invoke('codeburn:getShareStatus'),

View file

@ -17,6 +17,7 @@ import { formatCompact, formatUsd, setActiveCurrency } from './lib/format'
import { motionClass } from './lib/motion'
import { codeburn } from './lib/ipc'
import { localDateKey } from './lib/period'
import { compareModelsReportKey, modelsReportKey, optimizeReportKey, sessionsReportKey, spendFlowReportKey } from './lib/reportKeys'
import { persistRefreshValue, readRefreshValue, refreshValueToMs, RefreshCadenceContext, type RefreshCadence } from './lib/refreshCadence'
import { OverviewContent } from './sections/Overview'
import { OptimizeContent } from './sections/Optimize'
@ -141,13 +142,15 @@ const PREFETCH_START_DELAY_MS = 1500
// A warm spawn takes seconds, so a 400ms stagger let the loop fire the whole set
// almost at once; pace it wide enough that each warm genuinely trails the last.
const PREFETCH_STAGGER_MS = 2000
const SECTION_PREFETCH_START_DELAY_MS = 500
const SECTION_PREFETCH_STAGGER_MS = 100
// Base instant-switch memo keys live during overview polling besides the per-
// provider prefetch entries: `overview|all`, `overview-act`, `overview-yield`,
// plus one slot of headroom for section navigation. The memo cap is sized to
// provider prefetch entries plus the active provider's sessions, models, spend,
// optimize, yield, and compare-picker reports. The memo cap is sized to
// (detected providers + this) so warmed entries — and the base overview key —
// never LRU-evict between polls (which would blank the overview and re-arm the
// prefetch every cycle).
const BASE_MEMO_KEYS = 4
const BASE_MEMO_KEYS = 12
function isPeriod(value: string): value is Period {
return (STANDARD_PERIODS as string[]).includes(value)
@ -281,7 +284,9 @@ function AppMain() {
void (async () => {
let modelCategories: Map<string, string> | undefined
try {
modelCategories = topCategoryByModel(await codeburn.getModels(period, 'all', false))
const rows = await codeburn.getModels(period, 'all', false)
primePolledMemo(modelsReportKey(period, 'all', false), rows)
modelCategories = topCategoryByModel(rows)
} catch { /* degrade: emit the snapshot without per-model topCategory */ }
trackEvent('usage_snapshot', usageSnapshotProps(payload, modelCategories))
})()
@ -393,6 +398,58 @@ function AppMain() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, period, provider, customRange, claudeConfigSource, detectedProviders, overview.data == null])
// Warm the first view of each heavyweight section after the overview paints.
// These run one at a time at background priority; the main-process scheduler
// reserves the second CLI lane for a real click. Results seed the same memo
// keys usePolled reads, so the first navigation paints immediately instead of
// showing a skeleton while a fresh process starts.
const warmedSectionKeys = useRef<Set<string>>(new Set())
useEffect(() => {
if (!ready || overview.data == null || customRange || claudeConfigSource) return
const tasks: Array<{ key: string; fetch: () => Promise<unknown> }> = [
{
key: sessionsReportKey(period, provider),
fetch: () => codeburn.getSessions(period, provider, undefined, true),
},
{
key: modelsReportKey(period, provider, false),
fetch: () => codeburn.getModels(period, provider, false, undefined, true),
},
{
key: spendFlowReportKey(period, provider),
fetch: () => codeburn.getSpendFlow(period, provider, undefined, true),
},
{
key: optimizeReportKey(period, provider),
fetch: () => codeburn.getOptimizeReport(period, provider, undefined, true),
},
{
key: compareModelsReportKey(period, provider),
fetch: () => codeburn.getCompareModels(period, provider, true),
},
]
let cancelled = false
const warm = async () => {
for (const task of tasks) {
if (cancelled) return
if (warmedSectionKeys.current.has(task.key) || hasPolledMemo(task.key)) continue
warmedSectionKeys.current.add(task.key)
try {
const value = await task.fetch()
if (!cancelled) primePolledMemo(task.key, value)
} catch {
// Best effort. Remove the guard so a later dependency change can retry;
// a real navigation still fetches and surfaces the structured error.
warmedSectionKeys.current.delete(task.key)
}
if (!cancelled) await new Promise(resolve => setTimeout(resolve, SECTION_PREFETCH_STAGGER_MS))
}
}
const start = setTimeout(() => { void warm() }, SECTION_PREFETCH_START_DELAY_MS)
return () => { cancelled = true; clearTimeout(start) }
}, [ready, period, provider, customRange, claudeConfigSource, overview.data == null])
useEffect(() => {
const id = window.setInterval(() => setNow(Date.now()), 1000)
return () => window.clearInterval(id)
@ -411,6 +468,8 @@ function AppMain() {
// couple seconds (quick like the menubar) instead of at the next poll.
const onConfigMutated = useCallback(() => {
clearPolledMemo()
warmedKeys.current.clear()
warmedSectionKeys.current.clear()
refreshVisible()
}, [refreshVisible])

View file

@ -101,7 +101,7 @@ describe('usePolled', () => {
// Mount on key kA, resolve to A0 → memoized under kA.
const { result, rerender } = renderHook(
({ k }: { k: string }) => usePolled(fetcher, [k], { memoKey: k }),
({ k }: { k: string }) => usePolled(fetcher, [k], { memoKey: k, intervalMs: null }),
{ initialProps: { k: 'kA' } },
)
await act(async () => { resolvers[0]!('A0') })
@ -126,6 +126,21 @@ describe('usePolled', () => {
expect(result.current.switching).toBe(false)
})
it('does not refetch a fresh memoized result on remount, but manual refresh bypasses freshness', async () => {
primePolledMemo('fresh-tab', 'cached')
const fetcher = vi.fn().mockResolvedValue('fresh')
const { result } = renderHook(() => usePolled(fetcher, [], { memoKey: 'fresh-tab', intervalMs: 60_000 }))
await act(async () => {})
expect(result.current.data).toBe('cached')
expect(result.current.loading).toBe(false)
expect(fetcher).not.toHaveBeenCalled()
await act(async () => { result.current.refresh() })
expect(fetcher).toHaveBeenCalledTimes(1)
expect(result.current.data).toBe('fresh')
})
it('clears stale data on a switch to an unmemoized key (skeleton, never the prior filter)', async () => {
const resolvers: Array<(v: string) => void> = []
const fetcher = vi.fn(() => new Promise<string>(resolve => { resolvers.push(resolve) }))

View file

@ -29,9 +29,10 @@ export type Polled<T> = {
// storm). The App raises it via setPolledMemoMax to (detected providers + base
// keys); DEFAULT_MEMO_MAX is the floor for isolated hook/component tests.
const DEFAULT_MEMO_MAX = 8
const MEMO_MAX_CAP = 24
const MEMO_MAX_CAP = 64
let memoMax = DEFAULT_MEMO_MAX
const memoStore = new Map<string, unknown>()
type MemoEntry = { value: unknown; at: number }
const memoStore = new Map<string, MemoEntry>()
/** Raise (or lower) the instant-switch memo cap so warmed entries survive between
* polls. Clamped to [DEFAULT_MEMO_MAX, MEMO_MAX_CAP]; trims immediately if the
@ -46,18 +47,18 @@ export function setPolledMemoMax(n: number): void {
}
}
function memoGet<T>(key: string): T | undefined {
function memoGet<T>(key: string): { value: T; at: number } | undefined {
if (!memoStore.has(key)) return undefined
const value = memoStore.get(key) as T
const entry = memoStore.get(key) as MemoEntry
// Touch recency.
memoStore.delete(key)
memoStore.set(key, value)
return value
memoStore.set(key, entry)
return { value: entry.value as T, at: entry.at }
}
function memoSet(key: string, value: unknown): void {
function memoSet(key: string, value: unknown, at = Date.now()): void {
if (memoStore.has(key)) memoStore.delete(key)
memoStore.set(key, value)
memoStore.set(key, { value, at })
while (memoStore.size > memoMax) {
const oldest = memoStore.keys().next().value
if (oldest === undefined) break
@ -120,11 +121,15 @@ export function usePolled<T>(
const intervalMs = opts.intervalMs !== undefined ? opts.intervalMs : cadence.intervalMs
const enabled = opts.enabled ?? true
const memoKey = opts.memoKey
const [data, setData] = useState<T | null>(() => (memoKey ? memoGet<T>(memoKey) ?? null : null))
const [data, setData] = useState<T | null>(() => (memoKey ? memoGet<T>(memoKey)?.value ?? null : null))
const [error, setError] = useState<CliError | null>(null)
const [loading, setLoading] = useState(true)
const [switching, setSwitching] = useState(false)
const [lastSuccessAt, setLastSuccessAt] = useState<number | null>(null)
// Fresh memo data suppresses only the first load after a component remount.
// Later dependency changes (provider/period/config/refreshToken) must still
// revalidate, even when they switch to a recently-prefetched key.
const initialLoadRef = useRef(true)
// Generation counter: every load() (mount, deps change, interval, refresh)
// claims the next epoch; a fetch applies its result only while its epoch is
// still current. This is what keeps a slow fetch from an older deps/period
@ -134,8 +139,10 @@ export function usePolled<T>(
// visibilitychange catch-up can read it without re-subscribing on every poll.
const lastSuccessRef = useRef<number | null>(null)
const load = useCallback(() => {
const load = useCallback((force = false) => {
if (!enabled) return
const initialLoad = initialLoadRef.current
initialLoadRef.current = false
const epoch = ++epochRef.current
// Instant paint: on a deps/key change, if a last-good result for the new key
// is cached, show it immediately and flag `switching` while the fresh fetch
@ -144,11 +151,30 @@ export function usePolled<T>(
// numbers. (An interval re-poll keeps the same key, whose last result is
// always cached, so a background refresh never blanks.)
let servedCached = false
let cachedAt: number | null = null
if (memoKey) {
const cached = memoGet<T>(memoKey)
if (cached !== undefined) { setData(cached); servedCached = true }
if (cached !== undefined) {
setData(cached.value)
servedCached = true
cachedAt = cached.at
}
else setData(null)
}
// A tab remount or switch-back should not immediately launch an identical
// CLI process when its last-good result is still inside the configured
// refresh cadence. Manual cadence keeps memoized data until Refresh is
// explicitly clicked. Stale entries continue through the normal
// stale-while-revalidate path below.
const cachedIsFresh = cachedAt !== null && (intervalMs === null || Date.now() - cachedAt < intervalMs)
if (servedCached && initialLoad && !force && cachedIsFresh) {
setLoading(false)
setSwitching(false)
setError(null)
setLastSuccessAt(cachedAt)
lastSuccessRef.current = cachedAt
return
}
setLoading(true)
setSwitching(servedCached)
// Clear any prior error at the start of each attempt so a fresh poll never
@ -177,7 +203,7 @@ export function usePolled<T>(
// `memoKey` are prepended so flipping the gate / key re-creates load and
// fires immediately.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, memoKey, ...deps])
}, [enabled, memoKey, intervalMs, ...deps])
useEffect(() => {
load()
@ -212,7 +238,7 @@ export function usePolled<T>(
}, [load, intervalMs])
const refresh = useCallback(() => {
load()
load(true)
}, [load])
return { data, error, loading, switching, lastSuccessAt, refresh }

View file

@ -0,0 +1,29 @@
import type { DateRange, Period } from './types'
function rangeKey(range: DateRange | null | undefined): string {
return `${range?.from ?? ''}-${range?.to ?? ''}`
}
export function sessionsReportKey(period: Period, provider: string, range?: DateRange | null): string {
return `sessions|${period}|${provider}|${rangeKey(range)}`
}
export function modelsReportKey(period: Period, provider: string, byTask: boolean, range?: DateRange | null): string {
return `models|${period}|${provider}|${byTask}|${rangeKey(range)}`
}
export function spendFlowReportKey(period: Period, provider: string, range?: DateRange | null): string {
return `spendflow|${period}|${provider}|${rangeKey(range)}`
}
export function optimizeReportKey(period: Period, provider: string, range?: DateRange | null): string {
return `optimize|${period}|${provider}|${rangeKey(range)}`
}
export function yieldReportKey(period: Period, provider: string, range?: DateRange | null): string {
return `yield|${period}|${provider}|${rangeKey(range)}`
}
export function compareModelsReportKey(period: Period, provider: string): string {
return `comparemodels|${period}|${provider}`
}

View file

@ -644,13 +644,13 @@ export interface CodeburnBridge {
/** Node process.arch of the host ('arm64', 'x64', ...). Absent on preloads
* that predate the direct-download update link. */
readonly arch?: string
getModels(period: Period, provider: string, byTask: boolean, range?: DateRange): Promise<ModelReportRow[]>
getSessions(period: Period, provider: string, range?: DateRange): Promise<SessionRow[]>
getCompareModels(period: Period, provider: string): Promise<ModelStats[]>
getModels(period: Period, provider: string, byTask: boolean, range?: DateRange, background?: boolean): Promise<ModelReportRow[]>
getSessions(period: Period, provider: string, range?: DateRange, background?: boolean): Promise<SessionRow[]>
getCompareModels(period: Period, provider: string, background?: boolean): Promise<ModelStats[]>
getCompare(period: Period, provider: string, modelA: string, modelB: string): Promise<CompareJsonReport>
getYield(period: Period, provider: string, range?: DateRange): Promise<YieldJsonReport>
getSpendFlow(period: Period, provider: string, range?: DateRange): Promise<SpendFlow>
getOptimizeReport(period: Period, provider: string, range?: DateRange): Promise<OptimizeJsonReport>
getSpendFlow(period: Period, provider: string, range?: DateRange, background?: boolean): Promise<SpendFlow>
getOptimizeReport(period: Period, provider: string, range?: DateRange, background?: boolean): Promise<OptimizeJsonReport>
getDevices(period: Period): Promise<CombinedUsage>
getDevicesScan(): Promise<DeviceScanResult>
getShareStatus(): Promise<ShareStatus>

View file

@ -8,6 +8,7 @@ import { SectionSkeleton } from '../components/Skeleton'
import { usePolled } from '../hooks/usePolled'
import { formatCompact, formatUsd } from '../lib/format'
import { codeburn } from '../lib/ipc'
import { compareModelsReportKey } from '../lib/reportKeys'
import type { CompareJsonReport, ComparisonRow, DateRange, ModelStats, Period, WorkingStyleRow } from '../lib/types'
function fmtMetric(v: number | null, fn: 'cost' | 'number' | 'percent' | 'decimal'): string {
@ -44,7 +45,7 @@ export function Compare({
const models = usePolled<ModelStats[]>(
() => codeburn.getCompareModels(period, provider),
[period, provider, refreshToken],
{ enabled: ready, memoKey: `comparemodels|${period}|${provider}` },
{ enabled: ready, memoKey: compareModelsReportKey(period, provider) },
)
const [modelA, setModelA] = useState<string | null>(null)
const [modelB, setModelB] = useState<string | null>(null)

View file

@ -11,6 +11,7 @@ import type { Section } from '../components/Sidebar'
import { usePolled } from '../hooks/usePolled'
import { formatCompact, formatUsd } from '../lib/format'
import { codeburn } from '../lib/ipc'
import { modelsReportKey } from '../lib/reportKeys'
import type { AuditRow, DateRange, ModelReportRow, Period } from '../lib/types'
import type { SettingsPane } from './Settings'
@ -95,7 +96,7 @@ function ModelsUsage({
const report = usePolled<ModelReportRow[]>(
() => range ? codeburn.getModels(period, provider, byTask, range) : codeburn.getModels(period, provider, byTask),
[period, provider, byTask, range?.from, range?.to, refreshToken],
{ enabled: ready, memoKey: `models|${period}|${provider}|${byTask}|${range?.from ?? ''}-${range?.to ?? ''}` },
{ enabled: ready, memoKey: modelsReportKey(period, provider, byTask, range) },
)
if (!report.data) {

View file

@ -208,7 +208,7 @@ describe('Optimize', () => {
expect(screen.getByText('No abandoned sessions in this range yet.')).toBeInTheDocument()
})
it('labels the Fixes tab with the rendered list length, not the menubar-wide findingCount', async () => {
it('labels the Fixes tab from the full optimize report', async () => {
const payload = makePayload()
// The menubar counts 25 findings, but the Fixes tab only renders topFindings.
payload.optimize = {
@ -223,7 +223,7 @@ describe('Optimize', () => {
render(<Optimize period="30days" provider="all" />)
expect(await screen.findByRole('tab', { name: 'Fixes 2' })).toBeInTheDocument()
expect(await screen.findByRole('tab', { name: 'Fixes 3' })).toBeInTheDocument()
expect(screen.queryByRole('tab', { name: 'Fixes 25' })).not.toBeInTheDocument()
})

View file

@ -9,6 +9,7 @@ import { StaleBanner } from '../components/StaleBanner'
import { type Polled, usePolled } from '../hooks/usePolled'
import { formatCompact, formatUsd } from '../lib/format'
import { codeburn } from '../lib/ipc'
import { optimizeReportKey, yieldReportKey } from '../lib/reportKeys'
import type { DateRange, MenubarPayload, OptimizeJsonReport, Period, SessionYieldJson, WasteAction, YieldJsonReport } from '../lib/types'
type OptimizeTab = 'waste' | 'reverts' | 'abandoned' | 'fixes'
@ -41,12 +42,12 @@ export function OptimizeContent({
const optimizeReport = usePolled<OptimizeJsonReport>(
() => range ? codeburn.getOptimizeReport(period, provider, range) : codeburn.getOptimizeReport(period, provider),
[period, provider, range?.from, range?.to, refreshToken],
{ enabled: ready, memoKey: `optimize|${period}|${provider}|${range?.from ?? ''}-${range?.to ?? ''}` },
{ enabled: ready, memoKey: optimizeReportKey(period, provider, range) },
)
const yieldReport = usePolled<YieldJsonReport>(
() => range ? codeburn.getYield(period, provider, range) : codeburn.getYield(period, provider),
[period, provider, range?.from, range?.to, refreshToken],
{ enabled: ready, memoKey: `optyield|${period}|${provider}|${range?.from ?? ''}-${range?.to ?? ''}` },
{ enabled: ready, memoKey: yieldReportKey(period, provider, range) },
)
const [tab, setTab] = useState<OptimizeTab>('waste')
@ -59,11 +60,11 @@ export function OptimizeContent({
const revertedTotal = yieldData ? formatUsd(yieldData.summary.reverted.costUSD) : '—'
const abandonedTotal = yieldData ? formatUsd(yieldData.summary.abandoned.costUSD) : '—'
const options = [
{ value: 'waste', label: `Waste ${formatUsd(overview.data.optimize.savingsUSD)}` },
{ value: 'waste', label: `Waste ${formatUsd(optimizeReport.data?.summary.potentialSavingsCostUSD ?? 0)}` },
{ value: 'reverts', label: `Reverts ${revertedTotal}` },
{ value: 'abandoned', label: `Abandoned ${abandonedTotal}` },
// The Fixes tab renders topFindings (capped list), so label the count that shows.
{ value: 'fixes', label: `Fixes ${overview.data.optimize.topFindings.length.toLocaleString('en-US')}` },
// The Fixes tab renders the complete optimize report, so use its full count.
{ value: 'fixes', label: `Fixes ${(optimizeReport.data?.summary.findingCount ?? 0).toLocaleString('en-US')}` },
]
return (
@ -83,7 +84,7 @@ export function OptimizeContent({
) : tab === 'abandoned' ? (
<YieldRows report={yieldReport} category="abandoned" empty="No abandoned sessions in this range yet." />
) : (
<FixesRows data={overview.data} />
<FixesRows report={optimizeReport} />
)}
</Panel>
</>
@ -234,6 +235,15 @@ function YieldRows({
)
}
function FixesRows({ data }: { data: MenubarPayload }) {
return <FindingRows findings={data.optimize.topFindings} empty="No fixes in this range yet." />
function FixesRows({ report }: { report: Polled<OptimizeJsonReport> }) {
if (!report.data) {
if (report.error) return <CliErrorPanel error={report.error} subject="optimize findings" />
return <EmptyNote>Scanning optimize findings</EmptyNote>
}
const findings: Finding[] = report.data.findings.map(finding => ({
title: finding.title,
impact: finding.severity,
savingsUSD: finding.estimatedSavingsUSD,
}))
return <FindingRows findings={findings} empty="No fixes in this range yet." />
}

View file

@ -12,12 +12,14 @@ import { motionEnabled, useBarGrowIn } from '../lib/motion'
import { type Polled, usePolled } from '../hooks/usePolled'
import { formatCompact, formatUsd } from '../lib/format'
import { codeburn } from '../lib/ipc'
import { optimizeReportKey, yieldReportKey } from '../lib/reportKeys'
import { contiguousDailyWindow, dataStartKey, formatChartDate, localDateKey, sliceDailyToPeriod, sliceDailyToRange } from '../lib/period'
import type {
ActReportJson,
DailyHistoryEntry,
DateRange,
MenubarPayload,
OptimizeJsonReport,
Period,
YieldJsonReport,
} from '../lib/types'
@ -669,8 +671,40 @@ export function OverviewContent({
// so the cold hydration runs once (via overview) rather than 3 parses at once
// on boot. Defaults true so standalone renders/tests poll normally.
const actReport = usePolled<ActReportJson>(() => codeburn.getActReport(), [], { enabled: ready, memoKey: 'overview-act' })
const yieldReport = usePolled<YieldJsonReport>(() => codeburn.getYield(period, provider), [period, provider], { enabled: ready, memoKey: `overview-yield|${period}|${provider}` })
const { data, error } = overview
const yieldReport = usePolled<YieldJsonReport>(() => codeburn.getYield(period, provider), [period, provider], { enabled: ready, memoKey: yieldReportKey(period, provider) })
const optimizeReport = usePolled<OptimizeJsonReport | null>(
async () => {
if (typeof codeburn.getOptimizeReport !== 'function') return null
const report = range
? await codeburn.getOptimizeReport(period, provider, range)
: await codeburn.getOptimizeReport(period, provider)
// Some older preload/test bridges expose the method but return no value.
// Treat that as unavailable enrichment; the lightweight overview remains
// fully usable with the optimize fields already present in its payload.
return report ?? null
},
[period, provider, range?.from, range?.to],
{ enabled: ready, memoKey: optimizeReportKey(period, provider, range) },
)
const error = overview.error
// The headline status call intentionally skips optimize work so the first
// frame arrives quickly. Enrich that payload when the independently-cached
// optimize report resolves; until then the rest of Overview remains usable.
const data = useMemo<MenubarPayload | null>(() => {
if (!overview.data || !optimizeReport.data) return overview.data
return {
...overview.data,
optimize: {
findingCount: optimizeReport.data.summary.findingCount,
savingsUSD: optimizeReport.data.summary.potentialSavingsCostUSD,
topFindings: optimizeReport.data.findings.slice(0, 5).map(finding => ({
title: finding.title,
impact: finding.severity,
savingsUSD: finding.estimatedSavingsUSD,
})),
},
}
}, [overview.data, optimizeReport.data])
const modelIndex = useMemo(() => data ? buildModelIndex(data) : new Map<string, string>(), [data])
if (!data) {

View file

@ -11,6 +11,7 @@ import { Stat } from '../components/Stat'
import { usePolled } from '../hooks/usePolled'
import { formatCompact, formatDayLong, formatDayShort, formatDuration, formatUsd, shortenProjectPath } from '../lib/format'
import { codeburn } from '../lib/ipc'
import { sessionsReportKey } from '../lib/reportKeys'
import type { DateRange, Period, SessionRow } from '../lib/types'
export const INITIAL_VISIBLE = 120
@ -120,7 +121,7 @@ export function Sessions({
const report = usePolled<SessionRow[]>(
() => range ? codeburn.getSessions(period, provider, range) : codeburn.getSessions(period, provider),
[period, provider, range?.from, range?.to, refreshToken],
{ enabled: ready, memoKey: `sessions|${period}|${provider}|${range?.from ?? ''}-${range?.to ?? ''}` },
{ enabled: ready, memoKey: sessionsReportKey(period, provider, range) },
)
const rows = report.data ?? []
const q = query.trim().toLowerCase()

View file

@ -11,6 +11,7 @@ import { StaleBanner } from '../components/StaleBanner'
import { type Polled, usePolled } from '../hooks/usePolled'
import { formatUsd } from '../lib/format'
import { codeburn } from '../lib/ipc'
import { spendFlowReportKey } from '../lib/reportKeys'
import { contiguousDailyWindow, dataStartKey, localDateKey } from '../lib/period'
import type { CliError, DateRange, MenubarPayload, Period, SpendFlow } from '../lib/types'
@ -61,7 +62,7 @@ export function SpendContent({
const flow = usePolled<SpendFlow>(
() => range ? codeburn.getSpendFlow(period, provider, range) : codeburn.getSpendFlow(period, provider),
[period, provider, range?.from, range?.to, refreshToken],
{ enabled: ready, memoKey: `spendflow|${period}|${provider}|${range?.from ?? ''}-${range?.to ?? ''}` },
{ enabled: ready, memoKey: spendFlowReportKey(period, provider, range) },
)
if (!overview.data) {

View file

@ -1,4 +1,5 @@
import { readdir, readFile } from 'fs/promises'
import { mkdir, readdir, readFile, stat, writeFile } from 'fs/promises'
import { homedir } from 'os'
import { join } from 'path'
import type { ProjectSummary } from './types.js'
@ -342,6 +343,64 @@ function isCompactFile(name: string): boolean {
return name.includes('compact')
}
const SELF_CORRECTION_CACHE_VERSION = 1
const SELF_CORRECTION_CACHE_FILE = `compare-self-corrections.v${SELF_CORRECTION_CACHE_VERSION}.json`
type CorrectionEvent = { key: string; model: string; correction: boolean }
type CorrectionFileCache = {
mtimeMs: number
sizeBytes: number
events: CorrectionEvent[]
}
type CorrectionCache = {
version: number
files: Record<string, CorrectionFileCache>
}
function correctionCacheDir(): string {
return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn')
}
function correctionCachePath(): string {
return join(correctionCacheDir(), SELF_CORRECTION_CACHE_FILE)
}
async function loadCorrectionCache(): Promise<CorrectionCache> {
try {
const parsed = JSON.parse(await readFile(correctionCachePath(), 'utf8')) as Partial<CorrectionCache>
if (parsed.version !== SELF_CORRECTION_CACHE_VERSION || !parsed.files || typeof parsed.files !== 'object') {
return { version: SELF_CORRECTION_CACHE_VERSION, files: {} }
}
return { version: SELF_CORRECTION_CACHE_VERSION, files: parsed.files }
} catch {
return { version: SELF_CORRECTION_CACHE_VERSION, files: {} }
}
}
function isCorrectionFileCache(value: unknown): value is CorrectionFileCache {
if (!value || typeof value !== 'object') return false
const entry = value as Partial<CorrectionFileCache>
return typeof entry.mtimeMs === 'number'
&& typeof entry.sizeBytes === 'number'
&& Array.isArray(entry.events)
&& entry.events.every(event => !!event
&& typeof event === 'object'
&& typeof event.key === 'string'
&& typeof event.model === 'string'
&& typeof event.correction === 'boolean')
}
async function saveCorrectionCache(cache: CorrectionCache): Promise<void> {
try {
await mkdir(correctionCacheDir(), { recursive: true })
// This is a performance-only cache. A partial write is harmless: the next
// run fails validation/JSON parsing and rebuilds it from the transcripts.
await writeFile(correctionCachePath(), JSON.stringify(cache), { encoding: 'utf8', mode: 0o600 })
} catch {
// Read-only homes and transient antivirus locks must never break compare.
}
}
async function collectJsonlFiles(sessionDir: string): Promise<string[]> {
const entries = await readdir(sessionDir, { withFileTypes: true })
const files: string[] = []
@ -363,6 +422,10 @@ async function collectJsonlFiles(sessionDir: string): Promise<string[]> {
export async function scanSelfCorrections(projectDirs: string[]): Promise<Map<string, number>> {
const counts = new Map<string, number>()
const seen = new Set<string>()
const cache = await loadCorrectionCache()
let cacheChanged = false
const allFiles: string[] = []
const discovered = new Set<string>()
for (const dir of projectDirs) {
let entries
@ -372,22 +435,37 @@ export async function scanSelfCorrections(projectDirs: string[]): Promise<Map<st
continue
}
const allFiles: string[] = []
for (const entry of entries) {
if (entry.isFile() && entry.name.endsWith('.jsonl') && !isCompactFile(entry.name)) {
allFiles.push(join(dir, entry.name))
const file = join(dir, entry.name)
if (!discovered.has(file)) { discovered.add(file); allFiles.push(file) }
} else if (entry.isDirectory()) {
try {
const sessionFiles = await collectJsonlFiles(join(dir, entry.name))
allFiles.push(...sessionFiles)
for (const file of sessionFiles) {
if (!discovered.has(file)) { discovered.add(file); allFiles.push(file) }
}
} catch {
continue
}
}
}
}
for (const file of allFiles) {
for (const file of allFiles) {
let events: CorrectionEvent[]
let fingerprint: { mtimeMs: number; sizeBytes: number }
try {
const info = await stat(file)
fingerprint = { mtimeMs: info.mtimeMs, sizeBytes: info.size }
} catch {
continue
}
const cached = cache.files[file]
if (isCorrectionFileCache(cached) && cached.mtimeMs === fingerprint.mtimeMs && cached.sizeBytes === fingerprint.sizeBytes) {
events = cached.events
} else {
let raw: string
try {
raw = await readFile(file, 'utf8')
@ -395,6 +473,8 @@ export async function scanSelfCorrections(projectDirs: string[]): Promise<Map<st
continue
}
events = []
for (const line of raw.split('\n')) {
const trimmed = line.trim()
if (!trimmed) continue
@ -418,16 +498,24 @@ export async function scanSelfCorrections(projectDirs: string[]): Promise<Map<st
if (typeof model !== 'string' || model === '<synthetic>') continue
const dedupeKey = `${model}:${ts}`
if (seen.has(dedupeKey)) continue
seen.add(dedupeKey)
const text = extractText(msgRec['content'])
if (SELF_CORRECTION_PATTERNS.some(p => p.test(text))) {
counts.set(model, (counts.get(model) ?? 0) + 1)
}
events.push({ key: dedupeKey, model, correction: SELF_CORRECTION_PATTERNS.some(p => p.test(text)) })
}
cache.files[file] = { ...fingerprint, events }
cacheChanged = true
}
// Keep the original global model+timestamp deduplication semantics even
// when events came from different cached files.
for (const event of events) {
if (seen.has(event.key)) continue
seen.add(event.key)
if (event.correction) counts.set(event.model, (counts.get(event.model) ?? 0) + 1)
}
}
if (cacheChanged) await saveCorrectionCache(cache)
return counts
}

View file

@ -1861,6 +1861,20 @@ program
const projects = await parseAllSessions(range, opts.provider)
const models = aggregateModelStats(projects)
// The desktop uses the no-pair form only to populate its model pickers.
// Self-correction counts require a separate raw JSONL scan and are not
// displayed in that picker, so return the lightweight aggregate before
// doing that work. The selected pair path below still enriches both
// models with the full metric.
if (!opts.modelA && !opts.modelB) {
process.stdout.write(JSON.stringify(models, null, 2) + '\n')
return
}
if (!opts.modelA || !opts.modelB) {
process.stderr.write('codeburn compare: --model-a and --model-b must be provided together.\n')
process.exit(1)
}
const providers = await getAllProviders()
const dirs: string[] = []
for (const provider of providers) {
@ -1872,14 +1886,6 @@ program
model.selfCorrections = corrections.get(model.model) ?? 0
}
if (!opts.modelA && !opts.modelB) {
process.stdout.write(JSON.stringify(models, null, 2) + '\n')
return
}
if (!opts.modelA || !opts.modelB) {
process.stderr.write('codeburn compare: --model-a and --model-b must be provided together.\n')
process.exit(1)
}
const modelA = models.find(model => model.model === opts.modelA)
const modelB = models.find(model => model.model === opts.modelB)
if (!modelA) {

View file

@ -278,12 +278,17 @@ function jsonlLine(type: string, model: string, text: string, timestamp = '2026-
describe('scanSelfCorrections', () => {
let tmpDir: string
let previousCacheDir: string | undefined
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'codeburn-test-'))
previousCacheDir = process.env['CODEBURN_CACHE_DIR']
process.env['CODEBURN_CACHE_DIR'] = join(tmpDir, 'cache')
})
afterEach(async () => {
if (previousCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR']
else process.env['CODEBURN_CACHE_DIR'] = previousCacheDir
await rm(tmpDir, { recursive: true, force: true })
})
@ -406,6 +411,22 @@ describe('scanSelfCorrections', () => {
await rm(dir2, { recursive: true, force: true })
}
})
it('invalidates only a changed file fingerprint and includes appended corrections', async () => {
const sessionDir = join(tmpDir, 'session-growing')
await mkdir(sessionDir)
const file = join(sessionDir, 'session.jsonl')
await writeFile(file, jsonlLine('assistant', 'opus-4-6', 'I was wrong.', '2026-04-15T10:00:00Z') + '\n')
expect((await scanSelfCorrections([tmpDir])).get('opus-4-6')).toBe(1)
await writeFile(file, [
jsonlLine('assistant', 'opus-4-6', 'I was wrong.', '2026-04-15T10:00:00Z'),
jsonlLine('assistant', 'opus-4-6', 'My mistake.', '2026-04-15T10:01:00Z'),
].join('\n') + '\n')
expect((await scanSelfCorrections([tmpDir])).get('opus-4-6')).toBe(2)
})
})
describe('computeCategoryComparison', () => {