diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index eacfa9c..4ac3948 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -246,8 +246,8 @@ enum ProviderFilter: String, CaseIterable, Identifiable { var providerKeys: [String] { switch self { case .cursor: ["cursor", "cursor agent"] - case .rooCode: ["roo-code"] - case .kiloCode: ["kilo-code"] + case .rooCode: ["roo-code", "roo code"] + case .kiloCode: ["kilo-code", "kilocode"] case .openclaw: ["openclaw"] default: [rawValue.lowercased()] } diff --git a/src/cli.ts b/src/cli.ts index 0f37829..116866c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,7 +7,7 @@ import { convertCost } from './currency.js' import { renderStatusBar } from './format.js' import { type PeriodData, type ProviderCost } from './menubar-json.js' import { buildMenubarPayload } from './menubar-json.js' -import { addNewDays, getDaysInRange, loadDailyCache, saveDailyCache, withDailyCacheLock } from './daily-cache.js' +import { getDaysInRange, ensureCacheHydrated, emptyCache, MS_PER_DAY, BACKFILL_DAYS, toDateString } from './daily-cache.js' import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKey } from './day-aggregator.js' import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js' import { renderDashboard } from './dashboard.js' @@ -24,11 +24,15 @@ const require = createRequire(import.meta.url) const { version } = require('../package.json') import { loadCurrency, getCurrency, isValidCurrencyCode } from './currency.js' -const MS_PER_DAY = 24 * 60 * 60 * 1000 -const BACKFILL_DAYS = 365 - -function toDateString(date: Date): string { - return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` +async function hydrateCache() { + try { + return await ensureCacheHydrated( + (range) => parseAllSessions(range, 'all'), + aggregateProjectsIntoDays, + ) + } catch { + return emptyCache() + } } function getDateRange(period: string): { range: DateRange; label: string } { @@ -304,6 +308,7 @@ program const period = toPeriod(opts.period) if (opts.format === 'json') { await loadPricing() + await hydrateCache() if (customRange) { const label = `${opts.from ?? 'all'} to ${opts.to ?? 'today'}` const projects = filterProjectsByName( @@ -317,6 +322,7 @@ program } return } + await hydrateCache() await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude, customRange) }) @@ -377,41 +383,10 @@ program const periodInfo = getDateRange(opts.period) const now = new Date() const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) - const yesterdayEnd = new Date(todayStart.getTime() - 1) const yesterdayStr = toDateString(new Date(todayStart.getTime() - MS_PER_DAY)) const isAllProviders = pf === 'all' - // The daily cache is provider-agnostic: always backfill it from .all so subsequent - // provider-filtered reads can derive per-provider cost+calls from DailyEntry.providers. - // Yesterday is always recomputed: it may have been cached mid-day with partial data. - const cache = await withDailyCacheLock(async () => { - let c = await loadDailyCache() - - // Evict yesterday (and any stale future entries) so the gap fill recomputes them. - const hadYesterday = c.days.some(d => d.date >= yesterdayStr) - if (hadYesterday) { - const freshDays = c.days.filter(d => d.date < yesterdayStr) - const latestFresh = freshDays.length > 0 ? freshDays[freshDays.length - 1].date : null - c = { ...c, days: freshDays, lastComputedDate: latestFresh } - } - - const gapStart = c.lastComputedDate - ? new Date( - parseInt(c.lastComputedDate.slice(0, 4)), - parseInt(c.lastComputedDate.slice(5, 7)) - 1, - parseInt(c.lastComputedDate.slice(8, 10)) + 1 - ) - : new Date(todayStart.getTime() - BACKFILL_DAYS * MS_PER_DAY) - - if (gapStart.getTime() <= yesterdayEnd.getTime()) { - const gapRange: DateRange = { start: gapStart, end: yesterdayEnd } - const gapProjects = filterProjectsByName(await parseAllSessions(gapRange, 'all'), opts.project, opts.exclude) - const gapDays = aggregateProjectsIntoDays(gapProjects) - c = addNewDays(c, gapDays, yesterdayStr) - await saveDailyCache(c) - } - return c - }) + const cache = await hydrateCache() // CURRENT PERIOD DATA // - .all provider: assemble from cache + today (fast) @@ -529,6 +504,7 @@ program } if (opts.format === 'json') { + await hydrateCache() const todayData = buildPeriodData('today', fp(await parseAllSessions(getDateRange('today').range, pf))) const monthData = buildPeriodData('month', fp(await parseAllSessions(getDateRange('month').range, pf))) const { code, rate } = getCurrency() @@ -550,6 +526,7 @@ program return } + await hydrateCache() const monthProjects = fp(await parseAllSessions(getDateRange('month').range, pf)) console.log(renderStatusBar(monthProjects)) }) @@ -567,6 +544,7 @@ program await runJsonReport('today', opts.provider, opts.project, opts.exclude) return } + await hydrateCache() await renderDashboard('today', opts.provider, opts.refresh, opts.project, opts.exclude) }) @@ -583,6 +561,7 @@ program await runJsonReport('month', opts.provider, opts.project, opts.exclude) return } + await hydrateCache() await renderDashboard('month', opts.provider, opts.refresh, opts.project, opts.exclude) }) @@ -596,6 +575,7 @@ program .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, []) .action(async (opts) => { await loadPricing() + await hydrateCache() const pf = opts.provider const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude) const periods: PeriodExport[] = [ @@ -873,6 +853,7 @@ program .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') .action(async (opts) => { await loadPricing() + await hydrateCache() const { range, label } = getDateRange(opts.period) const projects = await parseAllSessions(range, opts.provider) await runOptimize(projects, label, range) @@ -885,6 +866,7 @@ program .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') .action(async (opts) => { await loadPricing() + await hydrateCache() const { range } = getDateRange(opts.period) await renderCompare(range, opts.provider) }) @@ -896,6 +878,7 @@ program .action(async (opts) => { const { computeYield, formatYieldSummary } = await import('./yield.js') await loadPricing() + await hydrateCache() const { range, label } = getDateRange(opts.period) console.log(`\n Analyzing yield for ${label}...\n`) const summary = await computeYield(range, process.cwd()) diff --git a/src/daily-cache.ts b/src/daily-cache.ts index adc04ae..6e30727 100644 --- a/src/daily-cache.ts +++ b/src/daily-cache.ts @@ -3,8 +3,10 @@ import { existsSync } from 'fs' import { mkdir, open, readFile, rename, unlink } from 'fs/promises' import { homedir } from 'os' import { join } from 'path' +import type { DateRange, ProjectSummary } from './types.js' export const DAILY_CACHE_VERSION = 4 +const MIN_SUPPORTED_VERSION = 2 const DAILY_CACHE_FILENAME = 'daily-cache.json' export type DailyEntry = { @@ -44,16 +46,39 @@ function getCachePath(): string { return join(getCacheDir(), DAILY_CACHE_FILENAME) } -function emptyCache(): DailyCache { +export function emptyCache(): DailyCache { return { version: DAILY_CACHE_VERSION, lastComputedDate: null, days: [] } } -function isValidCache(parsed: unknown): parsed is DailyCache { +function isMigratableCache(parsed: unknown): parsed is { version: number; lastComputedDate: string | null; days: Record[] } { if (!parsed || typeof parsed !== 'object') return false const c = parsed as Partial - if (c.version !== DAILY_CACHE_VERSION) return false + if (typeof c.version !== 'number') return false if (!Array.isArray(c.days)) return false - return true + return c.version >= MIN_SUPPORTED_VERSION && c.version <= DAILY_CACHE_VERSION +} + +function migrateDays(days: Record[]): DailyEntry[] { + return days.map(d => ({ + date: d.date as string, + cost: (d.cost as number) ?? 0, + calls: (d.calls as number) ?? 0, + sessions: (d.sessions as number) ?? 0, + inputTokens: (d.inputTokens as number) ?? 0, + outputTokens: (d.outputTokens as number) ?? 0, + cacheReadTokens: (d.cacheReadTokens as number) ?? 0, + cacheWriteTokens: (d.cacheWriteTokens as number) ?? 0, + editTurns: (d.editTurns as number) ?? 0, + oneShotTurns: (d.oneShotTurns as number) ?? 0, + models: (d.models as DailyEntry['models']) ?? {}, + categories: (d.categories as DailyEntry['categories']) ?? {}, + providers: (d.providers as DailyEntry['providers']) ?? {}, + })) +} + +async function backupOldCache(path: string, version: number): Promise { + const backupPath = `${path}.v${version}.bak` + try { await rename(path, backupPath) } catch { /* best-effort */ } } export async function loadDailyCache(): Promise { @@ -62,8 +87,20 @@ export async function loadDailyCache(): Promise { try { const raw = await readFile(path, 'utf-8') const parsed: unknown = JSON.parse(raw) - if (!isValidCache(parsed)) return emptyCache() - return parsed + if (isMigratableCache(parsed)) { + const migrated: DailyCache = { + version: DAILY_CACHE_VERSION, + lastComputedDate: parsed.lastComputedDate, + days: migrateDays(parsed.days), + } + if (parsed.version < DAILY_CACHE_VERSION) { + await saveDailyCache(migrated).catch(() => {}) + } + return migrated + } + const oldVersion = (parsed as { version?: number })?.version + if (typeof oldVersion === 'number') await backupOldCache(path, oldVersion) + return emptyCache() } catch { return emptyCache() } @@ -113,3 +150,48 @@ export function withDailyCacheLock(fn: () => Promise): Promise { lockChain = next.catch(() => undefined) return next } + +export const MS_PER_DAY = 24 * 60 * 60 * 1000 +export const BACKFILL_DAYS = 365 + +export function toDateString(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` +} + +export async function ensureCacheHydrated( + parseSessions: (range: DateRange) => Promise, + aggregateDays: (projects: ProjectSummary[]) => DailyEntry[], +): Promise { + const now = new Date() + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const yesterdayEnd = new Date(todayStart.getTime() - 1) + const yesterdayStr = toDateString(new Date(todayStart.getTime() - MS_PER_DAY)) + + return withDailyCacheLock(async () => { + let c = await loadDailyCache() + + const hadYesterday = c.days.some(d => d.date >= yesterdayStr) + if (hadYesterday) { + const freshDays = c.days.filter(d => d.date < yesterdayStr) + const latestFresh = freshDays.length > 0 ? freshDays[freshDays.length - 1].date : null + c = { ...c, days: freshDays, lastComputedDate: latestFresh } + } + + const gapStart = c.lastComputedDate + ? new Date( + parseInt(c.lastComputedDate.slice(0, 4)), + parseInt(c.lastComputedDate.slice(5, 7)) - 1, + parseInt(c.lastComputedDate.slice(8, 10)) + 1 + ) + : new Date(todayStart.getTime() - BACKFILL_DAYS * MS_PER_DAY) + + if (gapStart.getTime() <= yesterdayEnd.getTime()) { + const gapRange: DateRange = { start: gapStart, end: yesterdayEnd } + const gapProjects = await parseSessions(gapRange) + const gapDays = aggregateDays(gapProjects) + c = addNewDays(c, gapDays, yesterdayStr) + await saveDailyCache(c) + } + return c + }) +} diff --git a/src/models.ts b/src/models.ts index c1c01d8..12dff01 100644 --- a/src/models.ts +++ b/src/models.ts @@ -212,6 +212,8 @@ const autoModelNames: Record = { 'cursor-auto': 'Cursor (auto)', 'cursor-agent-auto': 'Cursor (auto)', 'copilot-auto': 'Copilot (auto)', + 'copilot-openai-auto': 'Copilot (OpenAI)', + 'copilot-anthropic-auto': 'Copilot (Anthropic)', 'kiro-auto': 'Kiro (auto)', 'cline-auto': 'Cline (auto)', 'openclaw-auto': 'OpenClaw (auto)', diff --git a/tests/daily-cache.test.ts b/tests/daily-cache.test.ts index e89d65d..3582e8a 100644 --- a/tests/daily-cache.test.ts +++ b/tests/daily-cache.test.ts @@ -62,11 +62,11 @@ describe('loadDailyCache', () => { expect(cache.days).toEqual([]) }) - it('returns an empty cache when the version does not match', async () => { - const saved: DailyCache = { - version: DAILY_CACHE_VERSION - 999, + it('returns an empty cache and backs up when version is too old to migrate', async () => { + const saved = { + version: 1, lastComputedDate: '2026-04-10', - days: [emptyDay('2026-04-10', 10)], + days: [{ date: '2026-04-10', cost: 10, calls: 5 }], } const { writeFile, mkdir } = await import('fs/promises') await mkdir(TMP_CACHE_ROOT, { recursive: true }) @@ -74,6 +74,32 @@ describe('loadDailyCache', () => { const cache = await loadDailyCache() expect(cache.days).toEqual([]) expect(cache.lastComputedDate).toBeNull() + expect(existsSync(join(TMP_CACHE_ROOT, 'daily-cache.json.v1.bak'))).toBe(true) + }) + + it('migrates an older supported version by filling missing fields', async () => { + const saved = { + version: 2, + lastComputedDate: '2026-04-10', + days: [{ + date: '2026-04-10', cost: 10, calls: 5, sessions: 2, + inputTokens: 1000, outputTokens: 500, cacheReadTokens: 200, cacheWriteTokens: 100, + models: { 'claude-opus-4-6': { calls: 5, cost: 10, inputTokens: 1000, outputTokens: 500, cacheReadTokens: 200, cacheWriteTokens: 100 } }, + }], + } + const { writeFile, mkdir } = await import('fs/promises') + await mkdir(TMP_CACHE_ROOT, { recursive: true }) + await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), JSON.stringify(saved), 'utf-8') + const cache = await loadDailyCache() + expect(cache.version).toBe(DAILY_CACHE_VERSION) + expect(cache.days).toHaveLength(1) + expect(cache.days[0].date).toBe('2026-04-10') + expect(cache.days[0].cost).toBe(10) + expect(cache.days[0].editTurns).toBe(0) + expect(cache.days[0].oneShotTurns).toBe(0) + expect(cache.days[0].categories).toEqual({}) + expect(cache.days[0].providers).toEqual({}) + expect(cache.days[0].models['claude-opus-4-6'].calls).toBe(5) }) it('round-trips a valid cache through save and load', async () => {