mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-20 11:44:39 +00:00
Some checks are pending
CI / semgrep (push) Waiting to run
Tests / test-platforms (windows-latest) (push) Waiting to run
Tests / desktop (push) Waiting to run
Tests / test (22) (push) Waiting to run
Tests / test (22.13.0) (push) Waiting to run
Tests / test (24) (push) Waiting to run
Tests / test (26) (push) Waiting to run
Tests / test-platforms (macos-latest) (push) Waiting to run
* feat(cursor): import Cursor's usage export to replace local estimates (#1545) * Price Cursor-hosted grok-4.6-high like grok-4.6 in the Cursor import * Skip no-op Cursor re-imports and price cursor-grok-4.6-high ids locally * test(cursor-import): write agent fixtures under os.homedir() so Windows finds them
1672 lines
90 KiB
TypeScript
1672 lines
90 KiB
TypeScript
import { randomBytes } from 'crypto'
|
||
import { existsSync } from 'fs'
|
||
import { mkdir, open, readdir, readFile, rename, stat, unlink } from 'fs/promises'
|
||
import { join } from 'path'
|
||
|
||
import { getCodeburnCacheDir, RETIRED_PROVIDER_NAMES } from './cache-dir.js'
|
||
import { sweepSupersededCacheFiles } from './cache-sweep.js'
|
||
import type { ProjectFilterTarget } from './parser.js'
|
||
import type { DateRange, ProjectSummary } from './types.js'
|
||
|
||
// Bumped to 27: claude-haiku-4.5 copilot store rows now price correctly (alias added) — #1093.
|
||
// Previously the raw id 'claude-haiku-4.5' (tier-first, dot) from session-store.db had no
|
||
// pricing alias, so days finalized with this model were stuck at $0. No self-heal via prefix
|
||
// fallback exists for this id, so a version bump is required to force re-derivation.
|
||
// Bumped to 26: copilot input/cache tokens for sessions covered by the CLI's
|
||
// session-store.db move from one shutdown-rollup lump (stamped at session end)
|
||
// to per-request DB rows with real timestamps, supplementary accounting calls
|
||
// (rollups, residuals, paired rows) stop counting as api/model calls, and
|
||
// reasoning tokens leave the copilot cost recompute (they are inside the
|
||
// output the per-turn calls already bill). Per-day attribution, call counts and
|
||
// costs all move, so days finalized under an earlier version would disagree
|
||
// with the live parse.
|
||
//
|
||
// Why 26 and not 21 (the number this change first claimed): 20 is #1040 (codex
|
||
// model attribution), 18 was burned by an earlier public head of THIS change
|
||
// under different accounting, and 21-25 landed on main while this branch was in
|
||
// validation — 25 in particular was spent by #1056 (`codex-auto-review`
|
||
// pricing) after this PR had already minted it, which is why the number moves
|
||
// again here. 26 is the first free number on main's ladder — and it is load
|
||
// bearing beyond the collision: the branch's own validators already hold
|
||
// daily-cache.v21.json files whose copilot slices were carried stale by the
|
||
// bug PENDING_REDERIVE_PROVIDER_VERSIONS fixes below, and only a number those files
|
||
// cannot claim gets them re-derived. isMigratableCache/adoptOlderDailyCaches
|
||
// carry a same-or-newer version forward as FINALIZED without re-deriving it,
|
||
// so a number can never mean two accountings. (feat/core-extraction also sits
|
||
// at 26 and reconciles at its final merge by keeping the max.)
|
||
//
|
||
// Bumped to 25: `codex-auto-review` now prices as the recommended GPT-5.5
|
||
// row (#1047). Days already finalized under v24 keep that id at $0
|
||
// forever unless MIN_SUPPORTED_VERSION moves: the daily cache has no
|
||
// per-provider invalidation. The Codex parse version and CODEX_CACHE_VERSION
|
||
// move with this so the lower caches reprice first; this pass then re-derives
|
||
// ALL days from the warm session cache (seconds, not a full re-parse).
|
||
// adoptOlderDailyCaches keeps the superseded file as the baseline. v21 is
|
||
// #946, v22 was this PR's earlier claim, v23 is #1075, v24 is #1090.
|
||
//
|
||
// Bumped to 20: the Codex fast-path read a nested
|
||
// `base_instructions.provenance.model` out of `session_meta` as if it were
|
||
// `payload.model` (#1040), so every call a rollout attributed from session
|
||
// metadata - the ones before its first `turn_context`, and every one after a
|
||
// mid-file `session_meta` - was credited to the wrong model. The codex parse
|
||
// version and CODEX_CACHE_VERSION move with it, but the daily cache has no
|
||
// per-provider invalidation, so days already finalized keep the wrong model
|
||
// rows forever unless MIN_SUPPORTED_VERSION moves too. This pass re-derives
|
||
// ALL days for EVERY provider off the warm session cache, so it costs seconds
|
||
// rather than a full re-parse, and it is lossless: adoptOlderDailyCaches keeps
|
||
// the superseded v19 file as the baseline, and the partial-survival guard from
|
||
// #1033 holds any day whose sources have only partly aged out. On a real
|
||
// 110-day cache the 19 -> 20 pass moved ZERO days down and lost none: 100 days
|
||
// came back byte-identical and 9 grok days rose by $19.80 in total, clearing
|
||
// rollups the grok parse change had left stale, while every codex model row
|
||
// stayed identical (that corpus predates the `provenance` field, so the fix it
|
||
// carries has nothing to correct there).
|
||
//
|
||
// Bumped to 19: Grok authoritative usage now keeps one session-level rollup
|
||
// from top-level totals, clamps reasoning to reported output, and labels mixed
|
||
// authoritative/heuristic coverage. Every day finalized under the previous
|
||
// accounting carries the old Grok totals, and the daily cache has no
|
||
// per-provider invalidation, so raising MIN_SUPPORTED_VERSION is the only
|
||
// lever: it forces a one-time re-derivation of ALL days, for every provider,
|
||
// 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. 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.
|
||
//
|
||
// Bumped to 17: copilot CLI sessions were misclassified as VS Code transcripts
|
||
// (#944), so days finalized at v16 or earlier carry output-only copilot costs —
|
||
// the session.shutdown rollup's input/cache tokens were dropped. Raising
|
||
// MIN_SUPPORTED_VERSION forces the one-time re-derivation under the
|
||
// provenance-based classification; sourceless days carry forward as-is.
|
||
//
|
||
// v16: Codex discovery is structural instead of originator-gated
|
||
// (#873/#626), so rollouts written by third-party frontends driving
|
||
// `codex app-server` ("t3code_desktop", "JetBrains.IntelliJ IDEA", ...) now
|
||
// contribute usage that v15 rollups never contained. Those files were rejected
|
||
// before they were ever parsed, so nothing downstream can notice on its own:
|
||
// `usage-aggregator` serves every day before today from this cache, and
|
||
// retention is ten years, so an upgrading user with a warm cache would keep the
|
||
// pre-fix history forever while today's numbers silently disagreed with it.
|
||
// Raising MIN_SUPPORTED_VERSION forces the one-time re-derivation.
|
||
//
|
||
// v15: per-project daily rollups. Days and provider slices now carry
|
||
// a `projects` breakdown (cost/calls/savings/sessions per project) so project
|
||
// history outlives the session files, like models and categories already do.
|
||
// This bump is the first to ride the v14 carry-forward: the old cache is
|
||
// adopted losslessly and only days whose sources survive are re-derived (now
|
||
// with projects); days already sourceless keep their totals and simply have
|
||
// no project split.
|
||
//
|
||
// v14: NEVER-LOSE history. Session files are ephemeral (Claude Code
|
||
// deletes transcripts after ~30 days), so a day that can no longer be re-derived
|
||
// from sources exists ONLY in this cache. Every earlier version treated the
|
||
// cache as disposable — schema bumps, savings-config changes, timezone changes
|
||
// and incomplete-hydration retries all dropped the days and re-derived from
|
||
// whatever sources survived, silently truncating history to the source-retention
|
||
// window (five bumps between 2026-06-22 and 2026-07-16 erased everything before
|
||
// 2026-04-24 on a machine with usage since March). From v14 on, invalidation
|
||
// re-derives what it can and CARRIES FORWARD every (day, provider) slice it
|
||
// cannot, and loading a missing/unsupported cache file adopts days from every
|
||
// older daily-cache file in the cache dir instead of starting empty. Bumping
|
||
// the version now only forces re-derivation of days whose sources still exist;
|
||
// it must never again lose the rest. DailyEntry.providers slices carry a full
|
||
// per-provider breakdown (tokens, models, categories) so those carry-forwards
|
||
// stay exact across rebuilds.
|
||
//
|
||
// v13: day bucketing is now TURN-anchored (a turn's whole cost/calls
|
||
// land on the day of its user-message timestamp) to match the live headline/
|
||
// report rollup. v12 bucketed each call by its own timestamp, so a midnight-
|
||
// straddling turn split across two days and history.daily / the provider
|
||
// breakdown never reconciled to current.cost. Raising MIN_SUPPORTED_VERSION
|
||
// forces the one-time re-hydration that rebuilds history under turn bucketing.
|
||
//
|
||
// v12: CodeWhale support adds historical usage that earlier rollups
|
||
// did not contain. Both the CodeWhale branch and the kiro credit-pricing
|
||
// change (below) claimed v11 independently, so v12 is the first version that
|
||
// contains both; raising MIN_SUPPORTED_VERSION forces the one-time
|
||
// re-hydration for days finalized at either v11.
|
||
//
|
||
// v11: kiro cost accounting changed (metered credits pass through
|
||
// the session cache instead of being re-priced from estimated tokens), so
|
||
// days finalized at v10 carry token-estimated kiro costs that were off by up
|
||
// to 16× per model. Raising MIN_SUPPORTED_VERSION forces the one-time full
|
||
// re-hydration that backfills history under credit-based pricing.
|
||
//
|
||
// v10: cursor accounting changed (real composer context tokens on
|
||
// conversation-anchored records, Cursor-published composer pricing), so days
|
||
// finalized at v9 carry the old double-counted agentKv estimates and
|
||
// sonnet-proxy composer costs.
|
||
//
|
||
// v9: providers added since the v8 rollup (Grok, Hermes, ZCode) parse usage
|
||
// that older binaries skipped. v8 added local-model savings to the daily
|
||
// rollup; the `savingsConfigHash` field is invalidated separately when the
|
||
// user changes their `localModelSavings` mapping.
|
||
// v23: codex pricing fix (#1075) - reasoning tokens were billed on top of
|
||
// output (they are a subset of it) and cache_write_input_tokens was ignored, so
|
||
// days finalized at v20 carry codex costs overstated by ~3.5% and codex output
|
||
// tokens overstated by ~34.6%. Raising MIN_SUPPORTED_VERSION forces the
|
||
// one-time re-derivation.
|
||
// It takes 23, not 21: v21 is claimed by the #946 landing branch and v22 by
|
||
// PR #1056, so those numbers are spoken for and reusing one would let two
|
||
// incompatible schemas share a filename. (feat/core-extraction sits at 26 and
|
||
// reconciles at its final merge by keeping the max.)
|
||
//
|
||
// v24: gpt-5.6-codex / gpt-5.6-codex-max pricing (#1077) - added as explicit
|
||
// litellm-snapshot.json rows. getModelCosts already resolved both ids to the
|
||
// correct rate via the `gpt-5.6` prefix fallback before this landed, so a day
|
||
// finalized on any binary that had a `gpt-5.6` snapshot row already carries
|
||
// the right cost; this bump only matters for a day finalized before THAT (a
|
||
// window where neither existed). The daily cache has no per-provider
|
||
// invalidation, so there is no way to tell those days apart from here -
|
||
// raising MIN_SUPPORTED_VERSION forces the one-time re-derivation for
|
||
// everyone, which is a lossless no-op for days already correct.
|
||
// v25: #1047 activity-id pricing. v24 on main already shipped #1090.
|
||
// v26: #946 copilot session-store accounting (see the top of this ladder).
|
||
// v27: #1093 claude-haiku-4.5 alias (see top).
|
||
// v28: #1115 report/optimize output tokens go through billableOutputTokens.
|
||
// Exclusive providers (Grok and the rest) were under-counted by reasoningTokens
|
||
// in finalized daily rows; inclusive {claude,codex,copilot} were correct there
|
||
// but optimize added reasoning again. Re-derive so report matches the live parse.
|
||
// v29: #1118 OrcaRouter route pricing (fusion aliases + peel; auto stays unpriced).
|
||
// v28 on main already shipped #1115.
|
||
// v31: Hermes cost provenance. Subscription-included sessions stay $0,
|
||
// explicit estimates retain their status, and surviving Hermes sources replace
|
||
// v29 slices produced by the old API-equivalent fallback.
|
||
// 31: #1234 Hermes cost contract; 30 is claimed by #1132.
|
||
// v32: DSH session formats v1-v3 and inclusive reasoning accounting. Re-derive
|
||
// finalized DSH days so migrated generations and retry attempts replace the
|
||
// v0-only totals, and reasoning detail is not added on top of full output.
|
||
// v33: #1450 billing routes. `day.models` is keyed by modelRowKey (display
|
||
// name + route label) instead of the raw provider id, so a route the provider
|
||
// recorded in its own column (Hermes `billing_provider`) survives into the
|
||
// finalized day; the raw id alone cannot carry it. A v32 day holds raw ids,
|
||
// which re-derive to the same rows for direct calls and to "(Bedrock)" rows
|
||
// for Bedrock-shaped ids, but its Hermes column routes are unrecoverable
|
||
// without a re-parse, so hermes joins PENDING_REDERIVE_PROVIDER_VERSIONS.
|
||
// v34: Warp cost accounting changed the same way kiro's did at v11 - Warp's
|
||
// own billing record (provider cost / charged usage / metered credits) now
|
||
// passes through the session cache instead of being re-priced from the token
|
||
// floor, which understated or overstated every Warp day. Days finalized at v33
|
||
// carry the floor figure; the bump re-derives those whose Warp conversations
|
||
// still exist (Warp's sqlite is durable, so effectively all of them) and
|
||
// carries the rest forward untouched. Call counts are unchanged, so no
|
||
// PENDING_REDERIVE entry is needed and the partial-survival guard is unaffected.
|
||
// v35: a non-Anthropic model with no published cache-write rate now bills
|
||
// cache-write tokens at its input rate instead of a fabricated 1.25x. Days
|
||
// finalized at v34 overstate those tokens; the bump re-derives surviving days.
|
||
// v36: Cursor Agent counts every assistant message of an agentic loop, not
|
||
// just the first after each user message, and bills tool_use inputs and the
|
||
// full user text. Days finalized at v35 hold a fraction of those calls; the
|
||
// provider parse version alone re-parses sessions but never reopens a
|
||
// finalized day, so the bump re-derives surviving days. Call counts only
|
||
// rise, so no PENDING_REDERIVE entry is needed.
|
||
// v37: #1547 OpenCode/KiloCode. A turn CodeBurn cannot price keeps the cost
|
||
// OpenCode recorded instead of $0, and Vertex turns land on a "(Vertex)" row.
|
||
// Days finalized at v36 hold $0 and the unrouted row; the bump re-derives
|
||
// surviving days. Call counts are unchanged.
|
||
// v38: #1529 Copilot OTel conversations land on their VS Code workspace
|
||
// instead of `copilot-chat`. Only the per-project split moves; cost, tokens and
|
||
// calls are unchanged. Settled days keep the old split unless they re-derive,
|
||
// and the session cache still holds their calls, so the bump re-derives them.
|
||
// Calls whose spans were already pruned from agent-traces.db keep the old
|
||
// label: the copilot re-parse only replaces calls the DB still holds.
|
||
// v39: #986 Cursor Agent reads CLI sessions that exist only in
|
||
// ~/.cursor/chats/*/*/store.db. Days finalized at v38 miss those calls; the
|
||
// bump re-derives surviving days. Call counts only rise.
|
||
export const DAILY_CACHE_VERSION = 39
|
||
const MIN_SUPPORTED_VERSION = 28
|
||
|
||
/// Providers whose per-day CALL COUNT means something different at
|
||
/// DAILY_CACHE_VERSION 26 than it did before it. Copilot's supplementary
|
||
/// accounting calls (rollups, residuals, store rows paired with a per-turn
|
||
/// call) stopped counting as api calls here, so a settled day's re-derivation
|
||
/// legitimately reports FEWER calls than the cache holds — which is exactly
|
||
/// the shape `isPartialSurvival` reads as "the sources aged out" (see the
|
||
/// TRADE-OFF note there, which called this case out in advance). Left
|
||
/// unhandled the guard pins every store-era copilot slice to its pre-store
|
||
/// value: measured on a real 17 -> 21 upgrade, `overview` served 2,980,804
|
||
/// tokens for a day whose fresh derivation is 74,811,412, while
|
||
/// `export`/`models`/`audit` (which never read this cache) served the
|
||
/// corrected figures off the same machine.
|
||
///
|
||
/// The exemption is one-shot and provider-scoped, not a relaxation of the
|
||
/// guard: `pendingRederive` is set only when a cache is migrated FROM a
|
||
/// version below this one, it is spent by the first complete re-derivation,
|
||
/// and it only ever fires where that re-derivation actually produced a slice
|
||
/// for the (day, provider). A day whose copilot sources are gone yields no
|
||
/// fresh slice at all, so it still carries forward whole — the #1033 bar is
|
||
/// untouched, in both directions, and every other provider keeps the guard.
|
||
const PENDING_REDERIVE_PROVIDER_VERSIONS: Readonly<Record<string, number>> = {
|
||
copilot: 26,
|
||
// 31: a v30 file may have been written by #1132's accounting, which never
|
||
// carried the Hermes cost contract. 33: day.models is keyed by route, and a
|
||
// v32 Hermes day cannot know which of its rows went through
|
||
// `billing_provider = bedrock` / `openrouter` (#1450).
|
||
hermes: 33,
|
||
// DSH v0-only parsing and exclusive-reasoning display were both stale in
|
||
// finalized days written before the multi-generation reader.
|
||
dsh: 32,
|
||
}
|
||
|
||
function providersPendingRederiveFrom(fromVersion: number): string[] {
|
||
return Object.entries(PENDING_REDERIVE_PROVIDER_VERSIONS)
|
||
.filter(([, contractVersion]) => fromVersion < contractVersion)
|
||
.map(([provider]) => provider)
|
||
}
|
||
// Version-suffixed so different binaries each own a distinct file and never
|
||
// clobber an incompatible schema. Bumping the version mints a fresh filename;
|
||
// adoptOlderDailyCaches then unions days out of every previous file (including
|
||
// the pre-versioning `daily-cache.json`, which old binaries still own and we
|
||
// never write or delete).
|
||
const DAILY_CACHE_FILENAME = `daily-cache.v${DAILY_CACHE_VERSION}.json`
|
||
|
||
export type ModelDayStats = {
|
||
calls: number
|
||
cost: number
|
||
savingsUSD: number
|
||
inputTokens: number
|
||
outputTokens: number
|
||
cacheReadTokens: number
|
||
cacheWriteTokens: number
|
||
}
|
||
|
||
export type CategoryDayStats = { turns: number; cost: number; savingsUSD: number; editTurns: number; oneShotTurns: number }
|
||
|
||
/// `path` is the project's filesystem path when known — it is what display
|
||
/// layers derive a friendly name from once the sessions that carried the
|
||
/// mapping are gone.
|
||
export type ProjectDayStats = { cost: number; calls: number; savingsUSD: number; sessions: number; path?: string }
|
||
|
||
export type ProviderDaySlice = {
|
||
calls: number
|
||
cost: number
|
||
savingsUSD: number
|
||
/// Full per-provider breakdown, written since v14. Slices adopted from older
|
||
/// caches carry only the three fields above; carrying such a slice forward
|
||
/// restores exact cost/calls/savings but not the day's token/model/category
|
||
/// split for that provider.
|
||
sessions?: number
|
||
inputTokens?: number
|
||
outputTokens?: number
|
||
cacheReadTokens?: number
|
||
cacheWriteTokens?: number
|
||
editTurns?: number
|
||
oneShotTurns?: number
|
||
models?: Record<string, ModelDayStats>
|
||
categories?: Record<string, CategoryDayStats>
|
||
projects?: Record<string, ProjectDayStats>
|
||
}
|
||
|
||
export type DailyEntry = {
|
||
date: string
|
||
cost: number
|
||
savingsUSD: number
|
||
calls: number
|
||
sessions: number
|
||
inputTokens: number
|
||
outputTokens: number
|
||
cacheReadTokens: number
|
||
cacheWriteTokens: number
|
||
editTurns: number
|
||
oneShotTurns: number
|
||
models: Record<string, ModelDayStats>
|
||
categories: Record<string, CategoryDayStats>
|
||
providers: Record<string, ProviderDaySlice>
|
||
/// Per-project rollup (session-level project attribution). Absent on days
|
||
/// recorded before v15 — those days keep their totals but have no project
|
||
/// split, and nothing can reconstruct one once the sources are gone.
|
||
projects?: Record<string, ProjectDayStats>
|
||
/// Present when some of this day's data was carried forward from an earlier
|
||
/// cache generation instead of re-derived from session files (the files no
|
||
/// longer exist). Carried values keep the accounting of the version that
|
||
/// recorded them — stale accounting beats a silent zero.
|
||
carried?: true
|
||
}
|
||
|
||
export type DailyCache = {
|
||
version: number
|
||
/// Hash of the active `localModelSavings` config at the time the cache
|
||
/// was last written. When the user changes their baseline mapping the
|
||
/// hash mismatches and `ensureCacheHydrated` re-derives available history,
|
||
/// then carries forward slices whose sources are gone.
|
||
savingsConfigHash: string
|
||
/// IANA local timezone the days were bucketed under (day boundaries are
|
||
/// local-time). If the machine's timezone changes, previously-cached days are
|
||
/// bucketed against the wrong midnight, so a mismatch forces a full re-hydrate
|
||
/// (same self-heal as `savingsConfigHash`). Absent on caches written before
|
||
/// this field existed → not treated as a mismatch (no gratuitous rebuild).
|
||
tzKey?: string
|
||
lastComputedDate: string | null
|
||
days: DailyEntry[]
|
||
/// True only once the full backfill window has been hydrated from a COMPLETE
|
||
/// session parse. A cache that was finalized against a partial (interrupted)
|
||
/// session hydration — the "chart is empty for the first ~20 days" bug — reads
|
||
/// as incomplete and is fully re-backfilled. Absent on caches written before
|
||
/// this field existed → treated as incomplete (one self-healing re-backfill).
|
||
complete?: boolean
|
||
/// True once a COMPLETE parse finalized this watermark. The pull-back below
|
||
/// only distrusts caches WITHOUT this stamp: a degraded parse can no longer
|
||
/// set `complete`, so a stamped cache whose watermark sits past its newest
|
||
/// populated day is a legitimately idle tail (recent days had no activity),
|
||
/// not a frozen hole, and re-deriving it every launch is pure waste. Absent
|
||
/// on caches written before this field: distrusted once (one healing
|
||
/// pull-back), then stamped.
|
||
watermarkTrusted?: boolean
|
||
/// Providers still owed the one re-derivation that a migration from a
|
||
/// pre-`DAILY_CACHE_VERSION` cache entitles them to, despite a shrinking
|
||
/// call count (see PENDING_REDERIVE_PROVIDER_VERSIONS). Set by the migration,
|
||
/// cleared by the first COMPLETE re-derive — persisted rather than computed
|
||
/// so a partial parse in between does not silently spend the entitlement.
|
||
pendingRederive?: string[]
|
||
}
|
||
|
||
/** IANA name of the current local timezone (respects the TZ env var). Days are
|
||
* bucketed by local midnight, so this tags the cache for TZ-change invalidation. */
|
||
export function currentTzKey(): string {
|
||
try { return Intl.DateTimeFormat().resolvedOptions().timeZone || '' } catch { return '' }
|
||
}
|
||
|
||
function getCachePath(): string {
|
||
return join(getCodeburnCacheDir(), DAILY_CACHE_FILENAME)
|
||
}
|
||
|
||
/** Absolute path of the active (version-suffixed) daily cache file. */
|
||
export function dailyCachePath(): string {
|
||
return getCachePath()
|
||
}
|
||
|
||
export function emptyCache(savingsConfigHash = ''): DailyCache {
|
||
return { version: DAILY_CACHE_VERSION, savingsConfigHash, tzKey: currentTzKey(), lastComputedDate: null, days: [], complete: false }
|
||
}
|
||
|
||
function isMigratableCache(parsed: unknown): parsed is { version: number; lastComputedDate: string | null; savingsConfigHash?: string; tzKey?: string; days: Record<string, unknown>[]; complete?: boolean } {
|
||
if (!parsed || typeof parsed !== 'object') return false
|
||
const c = parsed as Partial<DailyCache>
|
||
if (typeof c.version !== 'number') return false
|
||
if (!Array.isArray(c.days)) return false
|
||
return c.version >= MIN_SUPPORTED_VERSION && c.version <= DAILY_CACHE_VERSION
|
||
}
|
||
|
||
function num(v: unknown): number {
|
||
return typeof v === 'number' && Number.isFinite(v) ? v : 0
|
||
}
|
||
|
||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||
return !!value && typeof value === 'object' && !Array.isArray(value)
|
||
}
|
||
|
||
function sanitizeModels(raw: unknown): DailyEntry['models'] {
|
||
if (!isRecord(raw)) return {}
|
||
const out: DailyEntry['models'] = {}
|
||
for (const [name, m] of Object.entries(raw)) {
|
||
if (name in Object.prototype || !isRecord(m)) continue
|
||
setOwn(out, name, {
|
||
calls: num(m.calls),
|
||
cost: num(m.cost),
|
||
savingsUSD: num(m.savingsUSD),
|
||
inputTokens: num(m.inputTokens),
|
||
outputTokens: num(m.outputTokens),
|
||
cacheReadTokens: num(m.cacheReadTokens),
|
||
cacheWriteTokens: num(m.cacheWriteTokens),
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
function sanitizeCategories(raw: unknown): DailyEntry['categories'] {
|
||
if (!isRecord(raw)) return {}
|
||
const out: DailyEntry['categories'] = {}
|
||
for (const [name, c] of Object.entries(raw)) {
|
||
if (name in Object.prototype || !isRecord(c)) continue
|
||
setOwn(out, name, {
|
||
turns: num(c.turns),
|
||
cost: num(c.cost),
|
||
savingsUSD: num(c.savingsUSD),
|
||
editTurns: num(c.editTurns),
|
||
oneShotTurns: num(c.oneShotTurns),
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
const OPTIONAL_SLICE_NUMERICS = ['sessions', 'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'editTurns', 'oneShotTurns'] as const
|
||
|
||
/// Same junk-tolerance as sanitizeProjects, one level up: a foreign cache can
|
||
/// hold anything under a provider slice, and structuredClone in the merge
|
||
/// would faithfully preserve that junk into the next cache generation. Numeric
|
||
/// fields and nested maps are sanitized before the slice enters the cache.
|
||
function sanitizeProviders(raw: unknown): DailyEntry['providers'] {
|
||
if (!isRecord(raw)) return {}
|
||
const out: DailyEntry['providers'] = {}
|
||
for (const [name, s] of Object.entries(raw)) {
|
||
if (name in Object.prototype || !isRecord(s)) continue
|
||
const slice = s
|
||
const clean: ProviderDaySlice = { calls: num(slice.calls), cost: num(slice.cost), savingsUSD: num(slice.savingsUSD) }
|
||
for (const key of OPTIONAL_SLICE_NUMERICS) {
|
||
if (slice[key] !== undefined) clean[key] = num(slice[key])
|
||
}
|
||
if (isRecord(slice.models)) clean.models = sanitizeModels(slice.models)
|
||
if (isRecord(slice.categories)) clean.categories = sanitizeCategories(slice.categories)
|
||
const projects = sanitizeProjects(slice.projects).projects
|
||
if (projects) clean.projects = projects
|
||
setOwn(out, name, clean)
|
||
}
|
||
return out
|
||
}
|
||
|
||
/// Foreign or hand-edited caches can hold anything under `projects`; keep only
|
||
/// a plain record of finite numeric stats (arrays and null entries dropped) so
|
||
/// later carry merges can't crash on junk.
|
||
function sanitizeProjects(raw: unknown): { projects?: DailyEntry['projects'] } {
|
||
if (!isRecord(raw)) return {}
|
||
const out: NonNullable<DailyEntry['projects']> = {}
|
||
for (const [name, p] of Object.entries(raw)) {
|
||
// A project key is a directory basename, so it can legitimately be a
|
||
// prototype-member name ("constructor", "valueOf", ...). `setOwn` writes it
|
||
// as an own property via defineProperty, so keeping it is pollution-safe —
|
||
// and dropping it would silently subtract that project's cost from a
|
||
// --project/--exclude total (the day's split would no longer sum to its own
|
||
// cost, which the filtered headline relies on).
|
||
if (!isRecord(p)) continue
|
||
setOwn(out, name, {
|
||
cost: num(p.cost),
|
||
calls: num(p.calls),
|
||
savingsUSD: num(p.savingsUSD),
|
||
sessions: num(p.sessions),
|
||
...(typeof p.path === 'string' && p.path.length > 0 ? { path: p.path } : {}),
|
||
})
|
||
}
|
||
return Object.keys(out).length > 0 ? { projects: out } : {}
|
||
}
|
||
|
||
const DATE_KEY_RE = /^\d{4}-\d{2}-\d{2}$/
|
||
|
||
/// The row that owns a provider slice's calls and cost no model row explains.
|
||
/// Pre-v14 slices carry only calls/cost/savings, so their money exists in the
|
||
/// day total with nothing to attribute it to and every model table silently
|
||
/// totals less than the headline it sits under.
|
||
export const CARRIED_MODEL_NAME = 'Unknown (carried)'
|
||
|
||
const REMAINDER_KEYS = ['calls', 'cost', 'savingsUSD', 'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens'] as const
|
||
|
||
/// Money is compared at the cent every surface reports it in. Summing a day's
|
||
/// model rows in a different order than its total was accumulated leaves a
|
||
/// 1e-13 residue on most days; without this, every one of them would grow a
|
||
/// carried row holding a fraction of a cent and no calls.
|
||
const CENT = 0.005
|
||
|
||
/// Credit whatever `totals` holds that its own model rows cannot explain to
|
||
/// CARRIED_MODEL_NAME. Idempotent: the row it writes is part of the next sum,
|
||
/// so a second pass sees a zero remainder. Only ever adds: money with no calls
|
||
/// against it (a slice that recorded cost but never a request) still gets a row.
|
||
function creditCarriedRemainder(
|
||
totals: { calls: number; cost: number; savingsUSD?: number; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number },
|
||
models: Record<string, ModelDayStats>,
|
||
): void {
|
||
const rows = Object.values(models)
|
||
const rest = emptyModelStats()
|
||
for (const key of REMAINDER_KEYS) {
|
||
rest[key] = Math.max(0, num(totals[key]) - rows.reduce((sum, m) => sum + m[key], 0))
|
||
}
|
||
// Token remainders (whole tokens) are part of REMAINDER_KEYS too: a day whose
|
||
// cost and calls reconcile but whose token totals fall short must still credit
|
||
// the missing tokens to the carried row, or the model rows sum to fewer tokens
|
||
// than the day headline.
|
||
const tokenRemainder = rest.inputTokens + rest.outputTokens + rest.cacheReadTokens + rest.cacheWriteTokens
|
||
if (rest.calls <= 0 && rest.cost < CENT && rest.savingsUSD < CENT && tokenRemainder < 1) return
|
||
const acc = Object.hasOwn(models, CARRIED_MODEL_NAME) ? models[CARRIED_MODEL_NAME]! : emptyModelStats()
|
||
for (const key of REMAINDER_KEYS) acc[key] += rest[key]
|
||
setOwn(models, CARRIED_MODEL_NAME, acc)
|
||
}
|
||
|
||
function migrateDays(days: Record<string, unknown>[]): DailyEntry[] {
|
||
return days
|
||
.filter(d => d && typeof d === 'object' && typeof d.date === 'string' && DATE_KEY_RE.test(d.date))
|
||
.map((d): DailyEntry => ({
|
||
date: d.date as string,
|
||
cost: num(d.cost),
|
||
savingsUSD: num(d.savingsUSD),
|
||
calls: num(d.calls),
|
||
sessions: num(d.sessions),
|
||
inputTokens: num(d.inputTokens),
|
||
outputTokens: num(d.outputTokens),
|
||
cacheReadTokens: num(d.cacheReadTokens),
|
||
cacheWriteTokens: num(d.cacheWriteTokens),
|
||
editTurns: num(d.editTurns),
|
||
oneShotTurns: num(d.oneShotTurns),
|
||
models: sanitizeModels(d.models),
|
||
categories: sanitizeCategories(d.categories),
|
||
providers: sanitizeProviders(d.providers),
|
||
...(sanitizeProjects(d.projects)),
|
||
...(d.carried === true ? { carried: true as const } : {}),
|
||
}))
|
||
.filter(day => {
|
||
let retired = false
|
||
for (const provider of RETIRED_PROVIDER_NAMES) {
|
||
if (!Object.hasOwn(day.providers, provider)) continue
|
||
subtractSliceFromDay(day, provider, day.providers[provider]!)
|
||
retired = true
|
||
}
|
||
return !retired || hasPositiveDayContent(day)
|
||
})
|
||
// Day and slices are summed independently: a day can hold the full model
|
||
// split while one of its slices was written before slices carried one (or
|
||
// the reverse), and a provider-scoped view reads the slice's map alone.
|
||
.map(day => {
|
||
for (const slice of Object.values(day.providers)) creditCarriedRemainder(slice, slice.models ??= {})
|
||
creditCarriedRemainder(day, day.models)
|
||
return day
|
||
})
|
||
}
|
||
|
||
/// The providers a cache at `fromVersion` still owes a re-derivation, carrying
|
||
/// an unspent entitlement out of the parsed file so a same-version reload does
|
||
/// not drop it.
|
||
function pendingRederiveFor(fromVersion: number, parsed: unknown): string[] | undefined {
|
||
const raw = (parsed as { pendingRederive?: unknown } | null)?.pendingRederive
|
||
const pending = new Set(
|
||
Array.isArray(raw) ? raw.filter((p): p is string => typeof p === 'string') : [],
|
||
)
|
||
if (fromVersion < DAILY_CACHE_VERSION) {
|
||
for (const provider of providersPendingRederiveFrom(fromVersion)) pending.add(provider)
|
||
}
|
||
return pending.size > 0 ? [...pending] : undefined
|
||
}
|
||
|
||
function migratedFrom(parsed: { version: number; lastComputedDate: string | null; savingsConfigHash?: string; tzKey?: string; days: Record<string, unknown>[]; complete?: boolean; watermarkTrusted?: boolean }): DailyCache {
|
||
const pendingRederive = pendingRederiveFor(parsed.version, parsed)
|
||
return {
|
||
...(pendingRederive ? { pendingRederive } : {}),
|
||
version: DAILY_CACHE_VERSION,
|
||
savingsConfigHash: parsed.savingsConfigHash ?? '',
|
||
tzKey: parsed.tzKey,
|
||
lastComputedDate: typeof parsed.lastComputedDate === 'string' && DATE_KEY_RE.test(parsed.lastComputedDate)
|
||
? parsed.lastComputedDate
|
||
: null,
|
||
days: migrateDays(parsed.days),
|
||
// Only a cache explicitly marked complete stays trusted; one written before
|
||
// the marker existed reads false and is re-backfilled once.
|
||
complete: parsed.complete === true,
|
||
// Absent on a pre-fix cache: the watermark is distrusted once (healing
|
||
// pull-back), then re-stamped by the finalize that follows.
|
||
watermarkTrusted: parsed.watermarkTrusted === true,
|
||
}
|
||
}
|
||
|
||
export async function loadDailyCache(): Promise<DailyCache> {
|
||
const path = getCachePath()
|
||
if (existsSync(path)) {
|
||
try {
|
||
const parsed: unknown = JSON.parse(await readFile(path, 'utf-8'))
|
||
if (isMigratableCache(parsed)) {
|
||
const migrated = migratedFrom(parsed)
|
||
if (parsed.version < DAILY_CACHE_VERSION) await saveDailyCache(migrated).catch(() => {})
|
||
return migrated
|
||
}
|
||
} catch {
|
||
// fall through to adoption — a corrupt current file must not cost history
|
||
// that older cache files still hold.
|
||
}
|
||
return adoptOlderDailyCaches()
|
||
}
|
||
return adoptOlderDailyCaches()
|
||
}
|
||
|
||
type AdoptableCache = { version: number; lastComputedDate?: string | null; savingsConfigHash?: string; tzKey?: string; days: Record<string, unknown>[]; complete?: boolean }
|
||
|
||
function isAdoptableCache(parsed: unknown): parsed is AdoptableCache {
|
||
if (!parsed || typeof parsed !== 'object') return false
|
||
const c = parsed as Partial<DailyCache>
|
||
return typeof c.version === 'number' && Array.isArray(c.days)
|
||
}
|
||
|
||
/// Versioned file absent (or unreadable): adopt days from EVERY other
|
||
/// daily-cache file in the cache dir — the legacy unversioned file, older
|
||
/// versioned files, and manual .bak copies. Files are read, never written or
|
||
/// deleted (old binaries still own theirs). A candidate at exactly our version
|
||
/// (the legacy file written by a same-version binary) is fully trusted and
|
||
/// becomes the base; every other candidate contributes per-(day, provider)
|
||
/// slices it alone still has, marked `carried`. This is what makes a schema
|
||
/// bump lossless: the new version starts from the union of everything every
|
||
/// previous version ever recorded, then re-derives what sources still support.
|
||
async function adoptOlderDailyCaches(): Promise<DailyCache> {
|
||
const dir = getCodeburnCacheDir()
|
||
let names: string[] = []
|
||
try {
|
||
names = await readdir(dir)
|
||
} catch {
|
||
return emptyCache()
|
||
}
|
||
const candidates: { parsed: AdoptableCache; mtimeMs: number }[] = []
|
||
for (const name of names) {
|
||
if (!name.startsWith('daily-cache') || !name.includes('.json')) continue
|
||
if (name === DAILY_CACHE_FILENAME) continue
|
||
// .tmp files are included deliberately: a crash between the atomic write
|
||
// completing and the rename landing leaves the NEWEST state only in the
|
||
// .tmp. A truncated half-write fails JSON.parse below and is skipped.
|
||
const path = join(dir, name)
|
||
try {
|
||
const parsed: unknown = JSON.parse(await readFile(path, 'utf-8'))
|
||
if (!isAdoptableCache(parsed)) continue
|
||
candidates.push({ parsed, mtimeMs: (await stat(path)).mtimeMs })
|
||
} catch {
|
||
continue
|
||
}
|
||
}
|
||
if (candidates.length === 0) return emptyCache()
|
||
// Priority: newer schema first, then most recently written. Higher priority
|
||
// wins per (day, provider); lower priority only fills what is missing.
|
||
candidates.sort((a, b) => (b.parsed.version - a.parsed.version) || (b.mtimeMs - a.mtimeMs))
|
||
|
||
let base: DailyCache
|
||
let rest = candidates
|
||
if (candidates[0]!.parsed.version === DAILY_CACHE_VERSION && isMigratableCache(candidates[0]!.parsed)) {
|
||
base = migratedFrom(candidates[0]!.parsed as Parameters<typeof migratedFrom>[0])
|
||
rest = candidates.slice(1)
|
||
} else {
|
||
base = emptyCache()
|
||
}
|
||
let days = base.days
|
||
for (const { parsed } of rest) {
|
||
days = mergeDayEntries(days, migrateDays(parsed.days), true)
|
||
}
|
||
// loadDailyCache has standalone readers, so the adopted result must already
|
||
// satisfy the cache's own invariants: no today/future entries (they would be
|
||
// served frozen instead of recomputed live) and nothing past retention.
|
||
const now = new Date()
|
||
const todayStr = toDateString(now)
|
||
const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1))
|
||
days = applyRetention(days.filter(d => d.date < todayStr), yesterdayStr)
|
||
// A trusted base can carry lastComputedDate >= today (clock skew wrote a
|
||
// frozen today entry that the purge above just removed). Left as-is it would
|
||
// make hydration skip the gap parse forever and the purged day would never
|
||
// be recomputed. Clamp back to the retained data.
|
||
let lastComputedDate = base.lastComputedDate
|
||
if (lastComputedDate && lastComputedDate > yesterdayStr) {
|
||
lastComputedDate = days.length > 0 ? days[days.length - 1]!.date : null
|
||
}
|
||
const adopted: DailyCache = {
|
||
...base,
|
||
lastComputedDate,
|
||
days,
|
||
// Anything adopted out of an OLDER file was derived under an older
|
||
// accounting, so the providers whose call counts changed meaning get their
|
||
// one guarded-shrink-exempt re-derivation (see
|
||
// PENDING_REDERIVE_PROVIDER_VERSIONS).
|
||
pendingRederive: (() => {
|
||
const pending = new Set(base.pendingRederive ?? [])
|
||
for (const candidate of rest) {
|
||
for (const provider of pendingRederiveFor(candidate.parsed.version, candidate.parsed) ?? []) {
|
||
pending.add(provider)
|
||
}
|
||
}
|
||
return pending.size > 0 ? [...pending] : undefined
|
||
})(),
|
||
// An untrusted base means nothing here was derived under the current
|
||
// accounting: leave complete unset so the next hydration re-derives every
|
||
// day whose sources survive (the merge keeps the rest).
|
||
complete: rest.length === candidates.length ? false : base.complete,
|
||
watermarkTrusted: rest.length === candidates.length ? false : base.watermarkTrusted,
|
||
}
|
||
await saveDailyCache(adopted).catch(() => {})
|
||
return adopted
|
||
}
|
||
|
||
export async function saveDailyCache(cache: DailyCache): Promise<void> {
|
||
const dir = getCodeburnCacheDir()
|
||
if (!existsSync(dir)) await mkdir(dir, { recursive: true })
|
||
const finalPath = getCachePath()
|
||
const tempPath = `${finalPath}.${randomBytes(8).toString('hex')}.tmp`
|
||
const payload = JSON.stringify(cache)
|
||
const handle = await open(tempPath, 'w', 0o600)
|
||
try {
|
||
await handle.writeFile(payload, { encoding: 'utf-8' })
|
||
await handle.sync()
|
||
} finally {
|
||
await handle.close()
|
||
}
|
||
try {
|
||
await rename(tempPath, finalPath)
|
||
} catch (err) {
|
||
try { await unlink(tempPath) } catch { /* ignore */ }
|
||
throw err
|
||
}
|
||
// Off the hot path and at most once a day: the save is already done.
|
||
await sweepSupersededCacheFiles()
|
||
}
|
||
|
||
export function addNewDays(cache: DailyCache, incoming: DailyEntry[], newestDate: string): DailyCache {
|
||
const byDate = new Map(cache.days.map(d => [d.date, d]))
|
||
for (const day of incoming) {
|
||
byDate.set(day.date, day)
|
||
}
|
||
const merged = Array.from(byDate.values()).sort((a, b) => a.date.localeCompare(b.date))
|
||
const nextLast = cache.lastComputedDate && cache.lastComputedDate > newestDate
|
||
? cache.lastComputedDate
|
||
: newestDate
|
||
return {
|
||
version: DAILY_CACHE_VERSION,
|
||
savingsConfigHash: cache.savingsConfigHash,
|
||
tzKey: cache.tzKey,
|
||
lastComputedDate: nextLast,
|
||
days: applyRetention(merged, newestDate),
|
||
complete: cache.complete,
|
||
watermarkTrusted: cache.watermarkTrusted,
|
||
}
|
||
}
|
||
|
||
/// Prune entries older than the retention window so the cache file does not
|
||
/// grow unbounded over years of daily use. Anchor the cutoff on newestDate so
|
||
/// a stale or stuck clock can't accidentally evict everything. Skip the prune
|
||
/// entirely if newestDate is malformed — an invalid Date would produce a NaN
|
||
/// cutoff and `d.date >= "Invalid Date"` would silently drop every entry.
|
||
function applyRetention(days: DailyEntry[], newestDate: string): DailyEntry[] {
|
||
const cutoffDate = new Date(`${newestDate}T00:00:00Z`)
|
||
if (isNaN(cutoffDate.getTime())) return days
|
||
cutoffDate.setUTCDate(cutoffDate.getUTCDate() - DAILY_CACHE_RETENTION_DAYS)
|
||
const cutoff = toDateString(cutoffDate)
|
||
return days.filter(d => d.date >= cutoff)
|
||
}
|
||
|
||
function hasSliceData(slice: ProviderDaySlice): boolean {
|
||
return slice.cost > 0 || slice.calls > 0 || (slice.savingsUSD ?? 0) > 0
|
||
}
|
||
|
||
/// A day from a pre-v5-era cache: day-level totals exist but the providers map
|
||
/// is empty, so nothing can be attributed per provider. Such a day merges
|
||
/// all-or-nothing — filling slices into it would double-count whatever share
|
||
/// of its totals the incoming provider already contributed.
|
||
function isOpaqueDay(day: DailyEntry): boolean {
|
||
return (day.cost > 0 || day.calls > 0) && Object.keys(day.providers).length === 0
|
||
}
|
||
|
||
function emptyModelStats(): ModelDayStats {
|
||
return { calls: 0, cost: 0, savingsUSD: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
|
||
}
|
||
|
||
/// Fold one provider's day slice into a day: the providers map, the day-level
|
||
/// totals, and (when the slice carries them — v14+ slices do) the model and
|
||
/// category breakdowns. Skinny slices from pre-v14 caches restore only
|
||
/// cost/calls/savings; the day's other totals simply don't grow. A zero-data
|
||
/// placeholder already present for the provider (a session that started this
|
||
/// day but whose turns all landed on another) only contributes its session
|
||
/// count, deduplicated by max — the same real session may be counted on both
|
||
/// sides.
|
||
/// `residual` marks a slice that came out of the tz subtraction (issue #770):
|
||
/// the subtraction already removed the placeholder's sessions (the ones the
|
||
/// fresh parse explained), so the residual sessions are all distinct from the
|
||
/// placeholder's and must ADD to it, not max-dedup against it. Max would clamp
|
||
/// max(placeholder, residual) and permanently drop the source-gone sessions the
|
||
/// residual still carries.
|
||
function addSliceIntoDay(day: DailyEntry, provider: string, slice: ProviderDaySlice, residual = false): void {
|
||
// Reads keyed by names from foreign caches use hasOwn throughout: a plain
|
||
// lookup of "__proto__" returns the prototype object, and accumulating into
|
||
// it pollutes every object in the process.
|
||
const placeholder = Object.hasOwn(day.providers, provider) ? day.providers[provider] : undefined
|
||
const placeholderSessions = placeholder?.sessions ?? 0
|
||
const merged = structuredClone(slice)
|
||
if (residual) {
|
||
// The subtraction removed the placeholder's sessions from this residual, so
|
||
// every remaining session is distinct from the placeholder's - add, don't
|
||
// max (max would clamp 1 + 1 to 1 and lose the source-gone session).
|
||
merged.sessions = placeholderSessions + (merged.sessions ?? 0)
|
||
} else if (placeholderSessions > (merged.sessions ?? 0)) {
|
||
merged.sessions = placeholderSessions
|
||
}
|
||
setOwn(day.providers, provider, merged)
|
||
day.cost += slice.cost
|
||
day.calls += slice.calls
|
||
day.savingsUSD += slice.savingsUSD ?? 0
|
||
day.sessions += residual ? (slice.sessions ?? 0) : Math.max(0, (slice.sessions ?? 0) - placeholderSessions)
|
||
day.inputTokens += slice.inputTokens ?? 0
|
||
day.outputTokens += slice.outputTokens ?? 0
|
||
day.cacheReadTokens += slice.cacheReadTokens ?? 0
|
||
day.cacheWriteTokens += slice.cacheWriteTokens ?? 0
|
||
day.editTurns += slice.editTurns ?? 0
|
||
day.oneShotTurns += slice.oneShotTurns ?? 0
|
||
for (const [name, m] of Object.entries(slice.models ?? {})) {
|
||
const acc = Object.hasOwn(day.models, name) ? day.models[name]! : emptyModelStats()
|
||
acc.calls += m.calls
|
||
acc.cost += m.cost
|
||
acc.savingsUSD += m.savingsUSD ?? 0
|
||
acc.inputTokens += m.inputTokens
|
||
acc.outputTokens += m.outputTokens
|
||
acc.cacheReadTokens += m.cacheReadTokens
|
||
acc.cacheWriteTokens += m.cacheWriteTokens
|
||
setOwn(day.models, name, acc)
|
||
}
|
||
for (const [cat, c] of Object.entries(slice.categories ?? {})) {
|
||
const acc = Object.hasOwn(day.categories, cat) ? day.categories[cat]! : { turns: 0, cost: 0, savingsUSD: 0, editTurns: 0, oneShotTurns: 0 }
|
||
acc.turns += c.turns
|
||
acc.cost += c.cost
|
||
acc.savingsUSD += c.savingsUSD ?? 0
|
||
acc.editTurns += c.editTurns
|
||
acc.oneShotTurns += c.oneShotTurns
|
||
setOwn(day.categories, cat, acc)
|
||
}
|
||
const placeholderProjects = placeholder?.projects ?? {}
|
||
for (const [name, p] of Object.entries(slice.projects ?? {})) {
|
||
if (!p || typeof p !== 'object' || Array.isArray(p)) continue
|
||
const dayProjects = (day.projects ??= {})
|
||
const acc = Object.hasOwn(dayProjects, name) ? dayProjects[name]! : { cost: 0, calls: 0, savingsUSD: 0, sessions: 0 }
|
||
acc.cost += num(p.cost)
|
||
acc.calls += num(p.calls)
|
||
acc.savingsUSD += num(p.savingsUSD)
|
||
if (!acc.path && typeof p.path === 'string') acc.path = p.path
|
||
// Same session dedup as the slice-level sessions above: a placeholder's
|
||
// project sessions were already counted into the day when the fresh day
|
||
// was built, so only the excess is added.
|
||
const placeholderProjectSessions = Object.hasOwn(placeholderProjects, name) ? num(placeholderProjects[name]?.sessions) : 0
|
||
acc.sessions += residual ? num(p.sessions) : Math.max(0, num(p.sessions) - placeholderProjectSessions)
|
||
setOwn(dayProjects, name, acc)
|
||
}
|
||
// Placeholder-only projects (session counted fresh, calls landed elsewhere)
|
||
// survive on the merged slice rather than being dropped by the clone above.
|
||
const mergedProjects = merged.projects
|
||
if (mergedProjects) {
|
||
for (const [name, p] of Object.entries(placeholderProjects)) {
|
||
if (!p || typeof p !== 'object') continue
|
||
if (Object.hasOwn(mergedProjects, name)) {
|
||
if (residual) {
|
||
mergedProjects[name]!.sessions = num(mergedProjects[name]!.sessions) + num(p.sessions)
|
||
} else if (num(p.sessions) > num(mergedProjects[name]!.sessions)) {
|
||
mergedProjects[name]!.sessions = num(p.sessions)
|
||
}
|
||
} else {
|
||
setOwn(mergedProjects, name, { cost: 0, calls: 0, savingsUSD: 0, sessions: num(p.sessions) })
|
||
}
|
||
}
|
||
} else if (placeholder?.projects) {
|
||
merged.projects = structuredClone(placeholder.projects)
|
||
}
|
||
}
|
||
|
||
/// Assign via defineProperty so filesystem-derived keys like "__proto__" become
|
||
/// ordinary own properties instead of mutating the prototype link.
|
||
function setOwn<T>(target: Record<string, T>, key: string, value: T): void {
|
||
Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true })
|
||
}
|
||
|
||
// --- tz-aware carry subtraction (issue #770) ---------------------------------
|
||
//
|
||
// After a timezone change the full re-derive re-aggregates the same session
|
||
// parse under the CURRENT tz and merges it over the cached (old-tz) days.
|
||
// mergeDayEntries carries a baseline slice only when the fresh day has no data
|
||
// slice for that (date, provider), so a turn that re-bucketed across local
|
||
// midnight leaves its old day sliceless, gets carried there, AND counts again on
|
||
// its new day. The fix subtracts from each carried baseline slice the content
|
||
// the fresh parse still attributes to that (date, provider) under the OLD
|
||
// bucketing (`freshUnderOldTz`): exactly the re-bucketed turns, nothing else.
|
||
// A sources-gone slice has no such content and survives untouched; a slice fully
|
||
// explained away is dropped.
|
||
|
||
/// Reduce `base` by `sub` at the slice level, clamping every field at 0 and
|
||
/// dropping nested entries that reduce to nothing. Returns null when no positive
|
||
/// data remains; the merge then drops the slice instead of carrying an empty
|
||
/// one. `sub` is always a subset of `base` in practice (same parse, old bucketing
|
||
/// vs cached baseline), so the clamp only guards rounding and cache/baseline skew.
|
||
function subtractSlice(base: ProviderDaySlice, sub: ProviderDaySlice): ProviderDaySlice | null {
|
||
const calls = Math.max(0, base.calls - (sub.calls ?? 0))
|
||
const cost = Math.max(0, base.cost - (sub.cost ?? 0))
|
||
const savingsUSD = Math.max(0, (base.savingsUSD ?? 0) - (sub.savingsUSD ?? 0))
|
||
const sessions = Math.max(0, (base.sessions ?? 0) - (sub.sessions ?? 0))
|
||
const inputTokens = Math.max(0, (base.inputTokens ?? 0) - (sub.inputTokens ?? 0))
|
||
const outputTokens = Math.max(0, (base.outputTokens ?? 0) - (sub.outputTokens ?? 0))
|
||
const cacheReadTokens = Math.max(0, (base.cacheReadTokens ?? 0) - (sub.cacheReadTokens ?? 0))
|
||
const cacheWriteTokens = Math.max(0, (base.cacheWriteTokens ?? 0) - (sub.cacheWriteTokens ?? 0))
|
||
const editTurns = Math.max(0, (base.editTurns ?? 0) - (sub.editTurns ?? 0))
|
||
const oneShotTurns = Math.max(0, (base.oneShotTurns ?? 0) - (sub.oneShotTurns ?? 0))
|
||
const models = subtractModels(base.models, sub.models)
|
||
const categories = subtractCategories(base.categories, sub.categories)
|
||
const projects = subtractProjects(base.projects, sub.projects)
|
||
const out: ProviderDaySlice = {
|
||
calls, cost, savingsUSD,
|
||
...(sessions > 0 ? { sessions } : {}),
|
||
...(inputTokens > 0 ? { inputTokens } : {}),
|
||
...(outputTokens > 0 ? { outputTokens } : {}),
|
||
...(cacheReadTokens > 0 ? { cacheReadTokens } : {}),
|
||
...(cacheWriteTokens > 0 ? { cacheWriteTokens } : {}),
|
||
...(editTurns > 0 ? { editTurns } : {}),
|
||
...(oneShotTurns > 0 ? { oneShotTurns } : {}),
|
||
...(models ? { models } : {}),
|
||
...(categories ? { categories } : {}),
|
||
...(projects ? { projects } : {}),
|
||
}
|
||
return hasSliceData(out) || (out.sessions ?? 0) > 0 ? out : null
|
||
}
|
||
|
||
function subtractModelStats(base: ModelDayStats, sub: ModelDayStats): ModelDayStats | null {
|
||
const calls = Math.max(0, base.calls - (sub.calls ?? 0))
|
||
const cost = Math.max(0, base.cost - (sub.cost ?? 0))
|
||
const savingsUSD = Math.max(0, (base.savingsUSD ?? 0) - (sub.savingsUSD ?? 0))
|
||
const inputTokens = Math.max(0, base.inputTokens - (sub.inputTokens ?? 0))
|
||
const outputTokens = Math.max(0, base.outputTokens - (sub.outputTokens ?? 0))
|
||
const cacheReadTokens = Math.max(0, base.cacheReadTokens - (sub.cacheReadTokens ?? 0))
|
||
const cacheWriteTokens = Math.max(0, base.cacheWriteTokens - (sub.cacheWriteTokens ?? 0))
|
||
if (calls === 0 && cost === 0 && savingsUSD === 0 && inputTokens === 0 && outputTokens === 0 && cacheReadTokens === 0 && cacheWriteTokens === 0) return null
|
||
return { calls, cost, savingsUSD, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens }
|
||
}
|
||
|
||
function subtractModels(base: DailyEntry['models'] | undefined, sub: DailyEntry['models'] | undefined): DailyEntry['models'] | undefined {
|
||
if (!base) return undefined
|
||
const out: DailyEntry['models'] = {}
|
||
for (const [name, stats] of Object.entries(base)) {
|
||
const s = sub && Object.hasOwn(sub, name) ? sub[name] : undefined
|
||
const reduced = s ? subtractModelStats(stats, s) : stats
|
||
if (reduced) setOwn(out, name, reduced)
|
||
}
|
||
return Object.keys(out).length > 0 ? out : undefined
|
||
}
|
||
|
||
function subtractCategoryStats(base: CategoryDayStats, sub: CategoryDayStats): CategoryDayStats | null {
|
||
const turns = Math.max(0, base.turns - (sub.turns ?? 0))
|
||
const cost = Math.max(0, base.cost - (sub.cost ?? 0))
|
||
const savingsUSD = Math.max(0, (base.savingsUSD ?? 0) - (sub.savingsUSD ?? 0))
|
||
const editTurns = Math.max(0, base.editTurns - (sub.editTurns ?? 0))
|
||
const oneShotTurns = Math.max(0, base.oneShotTurns - (sub.oneShotTurns ?? 0))
|
||
if (turns === 0 && cost === 0 && savingsUSD === 0 && editTurns === 0 && oneShotTurns === 0) return null
|
||
return { turns, cost, savingsUSD, editTurns, oneShotTurns }
|
||
}
|
||
|
||
function subtractCategories(base: DailyEntry['categories'] | undefined, sub: DailyEntry['categories'] | undefined): DailyEntry['categories'] | undefined {
|
||
if (!base) return undefined
|
||
const out: DailyEntry['categories'] = {}
|
||
for (const [name, stats] of Object.entries(base)) {
|
||
const s = sub && Object.hasOwn(sub, name) ? sub[name] : undefined
|
||
const reduced = s ? subtractCategoryStats(stats, s) : stats
|
||
if (reduced) setOwn(out, name, reduced)
|
||
}
|
||
return Object.keys(out).length > 0 ? out : undefined
|
||
}
|
||
|
||
function subtractProjectStats(base: ProjectDayStats, sub: ProjectDayStats): ProjectDayStats | null {
|
||
const cost = Math.max(0, base.cost - (sub.cost ?? 0))
|
||
const calls = Math.max(0, base.calls - (sub.calls ?? 0))
|
||
const savingsUSD = Math.max(0, (base.savingsUSD ?? 0) - (sub.savingsUSD ?? 0))
|
||
const sessions = Math.max(0, (base.sessions ?? 0) - (sub.sessions ?? 0))
|
||
if (cost === 0 && calls === 0 && savingsUSD === 0 && sessions === 0) return null
|
||
return { cost, calls, savingsUSD, sessions, ...(base.path ? { path: base.path } : {}) }
|
||
}
|
||
|
||
function subtractProjects(base: DailyEntry['projects'] | undefined, sub: DailyEntry['projects'] | undefined): DailyEntry['projects'] | undefined {
|
||
if (!base) return undefined
|
||
const out: DailyEntry['projects'] = {}
|
||
for (const [name, stats] of Object.entries(base)) {
|
||
const s = sub && Object.hasOwn(sub, name) ? sub[name] : undefined
|
||
const reduced = s ? subtractProjectStats(stats, s) : stats
|
||
if (reduced) setOwn(out, name, reduced)
|
||
}
|
||
return Object.keys(out).length > 0 ? out : undefined
|
||
}
|
||
|
||
/// How much a nested stat entry actually lost: `base` before minus `reduced`
|
||
/// after, or null when nothing was lost. The raw `sub` is only a lower bound -
|
||
/// with tz skew it can exceed the slice, and subtracting it would eat OTHER
|
||
/// providers' share of the day-level breakdown.
|
||
function modelStatsDelta(base: ModelDayStats, reduced: ModelDayStats): ModelDayStats | null {
|
||
const calls = base.calls - reduced.calls
|
||
const cost = base.cost - reduced.cost
|
||
const savingsUSD = (base.savingsUSD ?? 0) - (reduced.savingsUSD ?? 0)
|
||
const inputTokens = base.inputTokens - reduced.inputTokens
|
||
const outputTokens = base.outputTokens - reduced.outputTokens
|
||
const cacheReadTokens = base.cacheReadTokens - reduced.cacheReadTokens
|
||
const cacheWriteTokens = base.cacheWriteTokens - reduced.cacheWriteTokens
|
||
if (calls === 0 && cost === 0 && savingsUSD === 0 && inputTokens === 0 && outputTokens === 0 && cacheReadTokens === 0 && cacheWriteTokens === 0) return null
|
||
return { calls, cost, savingsUSD, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens }
|
||
}
|
||
|
||
function categoryStatsDelta(base: CategoryDayStats, reduced: CategoryDayStats): CategoryDayStats | null {
|
||
const turns = base.turns - reduced.turns
|
||
const cost = base.cost - reduced.cost
|
||
const savingsUSD = (base.savingsUSD ?? 0) - (reduced.savingsUSD ?? 0)
|
||
const editTurns = base.editTurns - reduced.editTurns
|
||
const oneShotTurns = base.oneShotTurns - reduced.oneShotTurns
|
||
if (turns === 0 && cost === 0 && savingsUSD === 0 && editTurns === 0 && oneShotTurns === 0) return null
|
||
return { turns, cost, savingsUSD, editTurns, oneShotTurns }
|
||
}
|
||
|
||
function projectStatsDelta(base: ProjectDayStats, reduced: ProjectDayStats): ProjectDayStats | null {
|
||
const cost = base.cost - reduced.cost
|
||
const calls = base.calls - reduced.calls
|
||
const savingsUSD = (base.savingsUSD ?? 0) - (reduced.savingsUSD ?? 0)
|
||
const sessions = (base.sessions ?? 0) - (reduced.sessions ?? 0)
|
||
if (cost === 0 && calls === 0 && savingsUSD === 0 && sessions === 0) return null
|
||
return { cost, calls, savingsUSD, sessions }
|
||
}
|
||
|
||
/// Remove `sub`'s contribution from a carried baseline day (the baseline-only
|
||
/// date branch of the merge, where the whole day clones over). Reduces the
|
||
/// provider's slice, the day-level totals, and the day-level models/categories/
|
||
/// projects maps that `addSliceIntoDay` would have grown them by.
|
||
///
|
||
/// Every day-level subtraction uses the EFFECTIVE removal - what the provider
|
||
/// slice actually lost (current before minus reduced after) - not the raw `sub`.
|
||
/// With tz skew (`freshUnderOldTz` content larger than the baseline slice), the
|
||
/// raw sub exceeds the slice and subtracting it would over-remove the day's
|
||
/// totals and its nested maps, eating unrelated providers' carried history and
|
||
/// breaking the invariant that a day's totals sum to its slices. A provider
|
||
/// slice that was absent has an effective removal of zero: nothing is subtracted
|
||
/// from the day.
|
||
function subtractSliceFromDay(day: DailyEntry, provider: string, sub: ProviderDaySlice): void {
|
||
const current = Object.hasOwn(day.providers, provider) ? day.providers[provider] : undefined
|
||
if (!current) return
|
||
const reduced = subtractSlice(current, sub)
|
||
if (reduced) setOwn(day.providers, provider, reduced)
|
||
else delete day.providers[provider]
|
||
|
||
day.cost = Math.max(0, day.cost - (current.cost - (reduced?.cost ?? 0)))
|
||
day.calls = Math.max(0, day.calls - (current.calls - (reduced?.calls ?? 0)))
|
||
day.savingsUSD = Math.max(0, (day.savingsUSD ?? 0) - ((current.savingsUSD ?? 0) - (reduced?.savingsUSD ?? 0)))
|
||
day.sessions = Math.max(0, day.sessions - ((current.sessions ?? 0) - (reduced?.sessions ?? 0)))
|
||
day.inputTokens = Math.max(0, day.inputTokens - ((current.inputTokens ?? 0) - (reduced?.inputTokens ?? 0)))
|
||
day.outputTokens = Math.max(0, day.outputTokens - ((current.outputTokens ?? 0) - (reduced?.outputTokens ?? 0)))
|
||
day.cacheReadTokens = Math.max(0, day.cacheReadTokens - ((current.cacheReadTokens ?? 0) - (reduced?.cacheReadTokens ?? 0)))
|
||
day.cacheWriteTokens = Math.max(0, day.cacheWriteTokens - ((current.cacheWriteTokens ?? 0) - (reduced?.cacheWriteTokens ?? 0)))
|
||
day.editTurns = Math.max(0, day.editTurns - ((current.editTurns ?? 0) - (reduced?.editTurns ?? 0)))
|
||
day.oneShotTurns = Math.max(0, day.oneShotTurns - ((current.oneShotTurns ?? 0) - (reduced?.oneShotTurns ?? 0)))
|
||
|
||
for (const [name, m] of Object.entries(current.models ?? {})) {
|
||
const rm = reduced?.models && Object.hasOwn(reduced.models, name) ? reduced.models[name] : undefined
|
||
const removed = rm ? modelStatsDelta(m, rm) : m
|
||
if (!removed) continue
|
||
const acc = Object.hasOwn(day.models, name) ? day.models[name] : undefined
|
||
if (!acc) continue
|
||
const reducedM = subtractModelStats(acc, removed)
|
||
if (reducedM) setOwn(day.models, name, reducedM)
|
||
else delete day.models[name]
|
||
}
|
||
for (const [cat, c] of Object.entries(current.categories ?? {})) {
|
||
const rc = reduced?.categories && Object.hasOwn(reduced.categories, cat) ? reduced.categories[cat] : undefined
|
||
const removed = rc ? categoryStatsDelta(c, rc) : c
|
||
if (!removed) continue
|
||
const acc = Object.hasOwn(day.categories, cat) ? day.categories[cat] : undefined
|
||
if (!acc) continue
|
||
const reducedC = subtractCategoryStats(acc, removed)
|
||
if (reducedC) setOwn(day.categories, cat, reducedC)
|
||
else delete day.categories[cat]
|
||
}
|
||
if (!day.projects) return
|
||
for (const [name, p] of Object.entries(current.projects ?? {})) {
|
||
const rp = reduced?.projects && Object.hasOwn(reduced.projects, name) ? reduced.projects[name] : undefined
|
||
const removed = rp ? projectStatsDelta(p, rp) : p
|
||
if (!removed) continue
|
||
const acc = Object.hasOwn(day.projects, name) ? day.projects[name] : undefined
|
||
if (!acc) continue
|
||
const reducedP = subtractProjectStats(acc, removed)
|
||
if (reducedP) setOwn(day.projects, name, reducedP)
|
||
else delete day.projects[name]
|
||
}
|
||
}
|
||
|
||
/// Did the tz subtraction leave any positive data on a carried baseline day?
|
||
/// Mirrors the merge's own carry criterion (`hasSliceData` or sessions) at the
|
||
/// day level, extended to the day's other scalar and nested content.
|
||
function hasPositiveDayContent(day: DailyEntry): boolean {
|
||
if (day.cost > 0 || day.calls > 0 || (day.savingsUSD ?? 0) > 0 || day.sessions > 0) return true
|
||
if (day.inputTokens > 0 || day.outputTokens > 0 || day.cacheReadTokens > 0 || day.cacheWriteTokens > 0) return true
|
||
if (day.editTurns > 0 || day.oneShotTurns > 0) return true
|
||
if (Object.keys(day.providers).length > 0) return true
|
||
if (Object.keys(day.models).length > 0 || Object.keys(day.categories).length > 0) return true
|
||
if (day.projects && Object.keys(day.projects).length > 0) return true
|
||
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
|
||
}
|
||
|
||
/// The read path's rule: keep whichever derivation explains MORE calls, on
|
||
/// every date, with ties going to the baseline. Unlike the write path this is
|
||
/// not deciding what to freeze, so it has no reason to prefer a thinner slice
|
||
/// on a recent day — but it is also not the place a re-pricing lands, so an
|
||
/// equal-call slice leaves the durable value alone.
|
||
function baselineExplainsMore(baseline: ProviderDaySlice, fresh: ProviderDaySlice): boolean {
|
||
return 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.
|
||
function buildTzSubtraction(days: DailyEntry[]): ReadonlyMap<string, ReadonlyMap<string, ProviderDaySlice>> {
|
||
const byDate = new Map<string, Map<string, ProviderDaySlice>>()
|
||
for (const day of days) {
|
||
if (Object.keys(day.providers).length === 0) continue
|
||
const byProvider = new Map<string, ProviderDaySlice>()
|
||
for (const [provider, slice] of Object.entries(day.providers)) {
|
||
byProvider.set(provider, slice)
|
||
}
|
||
byDate.set(day.date, byProvider)
|
||
}
|
||
return byDate
|
||
}
|
||
|
||
/// Merge two day lists per (date, provider): `primary` wins wherever both have
|
||
/// data; `secondary` only fills dates primary lacks entirely and provider
|
||
/// slices primary lacks on shared dates. Nothing in secondary can overwrite or
|
||
/// double into primary. With markSecondaryCarried, every day that received a
|
||
/// secondary contribution is flagged `carried`.
|
||
///
|
||
/// Days that cannot be attributed per provider merge all-or-nothing:
|
||
/// - an OPAQUE primary day (pre-v5-era: totals but empty providers map) is
|
||
/// never slice-filled — its totals may already contain the incoming
|
||
/// provider's share, so adding would double-count;
|
||
/// - an opaque secondary day on a date primary already has contributes
|
||
/// 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 — 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. 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).
|
||
///
|
||
/// `'prefer-richer'` is the read-path union's rule (`unionDaysForPeriod`):
|
||
/// on EVERY date, keep the slice explaining more calls, ties to the baseline.
|
||
/// That path is choosing what to REPORT, not what to freeze, so a thinner
|
||
/// derivation is never the better answer there — while the write path still
|
||
/// lets a recent day shrink, on the grounds that a still-settling day is
|
||
/// defined by its fresh parse.
|
||
guardPartialSurvival: boolean | 'prefer-richer' = false,
|
||
/// Providers whose baseline slices were recorded under an accounting where a
|
||
/// call meant something else, so a shrink is not evidence of source loss for
|
||
/// this one re-derivation (see PENDING_REDERIVE_PROVIDER_VERSIONS). Only consulted
|
||
/// where the fresh parse actually produced a slice for the (date, provider);
|
||
/// a slice it could not produce at all is carried by the branch above,
|
||
/// exactly as before.
|
||
pendingRederive?: ReadonlySet<string>,
|
||
): 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)
|
||
if (!existing) {
|
||
const copy = structuredClone(day)
|
||
if (subtract) {
|
||
const subForDate = subtract.get(day.date)
|
||
if (subForDate) {
|
||
for (const [provider, slice] of Object.entries(copy.providers)) {
|
||
const subSlice = subForDate.get(provider)
|
||
if (!subSlice) continue
|
||
subtractSliceFromDay(copy, provider, subSlice)
|
||
}
|
||
if (!hasPositiveDayContent(copy)) continue
|
||
}
|
||
}
|
||
if (markSecondaryCarried) copy.carried = true
|
||
byDate.set(day.date, copy)
|
||
continue
|
||
}
|
||
if (isOpaqueDay(existing)) continue
|
||
for (const [provider, slice] of Object.entries(day.providers)) {
|
||
// Sessions-only slices (a session whose calls all landed on another
|
||
// 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
|
||
let toAdd = slice
|
||
let residual = false
|
||
if (subtract) {
|
||
const subSlice = subtract.get(day.date)?.get(provider)
|
||
if (subSlice) {
|
||
const reduced = subtractSlice(slice, subSlice)
|
||
if (!reduced) continue
|
||
toAdd = reduced
|
||
// The subtraction already removed the sessions the fresh parse
|
||
// explained, so the residual's sessions are distinct from the fresh
|
||
// placeholder's: merging over it must ADD, not max-dedup (fix round
|
||
// 1 - max would drop the source-gone sessions the residual carries).
|
||
residual = true
|
||
}
|
||
}
|
||
if (existingSlice && hasSliceData(existingSlice) && !residual) {
|
||
const keepBaseline = guardPartialSurvival === 'prefer-richer'
|
||
? baselineExplainsMore(slice, existingSlice)
|
||
: isPartialSurvival(day.date, slice, existingSlice, settleCutoff)
|
||
if (!guardPartialSurvival || pendingRederive?.has(provider) || !keepBaseline) 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)
|
||
// The result day is seeded from `primary`, which has no provenance, so a
|
||
// secondary day already marked carried would silently lose the mark on
|
||
// every date both sides hold. Re-assert it only where this slice brought
|
||
// calls the primary could not produce — that is what "preserved from
|
||
// expired session logs" claims. An equal-call slice claims nothing.
|
||
if (markSecondaryCarried || (day.carried && slice.calls > (existingSlice?.calls ?? 0))) existing.carried = true
|
||
}
|
||
}
|
||
return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date))
|
||
}
|
||
|
||
/// Forget the given providers' slices on the local days `start`..`end` and pull
|
||
/// the watermark back before them, so the next hydration re-derives those days
|
||
/// from sources. For a provider whose data changed on settled days by the
|
||
/// user's own hand (a usage import added or removed), which the partial-
|
||
/// survival guard would otherwise read as sources aging out and keep the old
|
||
/// slices over.
|
||
export async function invalidateProviderDays(providers: readonly string[], start: string, end: string): Promise<void> {
|
||
await withDailyCacheLock(async () => {
|
||
if (!existsSync(getCachePath())) return
|
||
const c = await loadDailyCache()
|
||
for (const day of c.days) {
|
||
if (day.date < start || day.date > end) continue
|
||
for (const provider of providers) {
|
||
if (Object.hasOwn(day.providers, provider)) subtractSliceFromDay(day, provider, day.providers[provider]!)
|
||
}
|
||
}
|
||
const before = toDateString(new Date(Number(start.slice(0, 4)), Number(start.slice(5, 7)) - 1, Number(start.slice(8, 10)) - 1))
|
||
const lastComputedDate = c.lastComputedDate !== null && c.lastComputedDate > before ? before : c.lastComputedDate
|
||
await saveDailyCache({ ...c, days: c.days.filter(hasPositiveDayContent), lastComputedDate })
|
||
})
|
||
}
|
||
|
||
export function getDaysInRange(cache: DailyCache, start: string, end: string): DailyEntry[] {
|
||
return cache.days.filter(d => d.date >= start && d.date <= end)
|
||
}
|
||
|
||
function phantomAmountBucket(cost: number): string {
|
||
if (cost < 1) return '<$1'
|
||
if (cost < 10) return '$1-10'
|
||
if (cost < 100) return '$10-100'
|
||
if (cost < 1000) return '$100-1000'
|
||
return '>$1000'
|
||
}
|
||
|
||
/// DETECTION-ONLY tripwire for the "phantom spend" anomaly: a rare, non-repro
|
||
/// over-count that attributed cost/calls to days with ZERO underlying source
|
||
/// records. Pure observer — never changes a day, a total, or any output; it only
|
||
/// warns (at most once per run). A non-carried day is expected to be backed by a
|
||
/// fresh source record for its date; a carried/preserved day legitimately has
|
||
/// spend with no live records (its session files expired), so it is never
|
||
/// suspect. `datesWithSourceRecords` is the set of dates the fresh parse
|
||
/// actually produced records for (a date is in it iff it had >=1 record).
|
||
export function detectPhantomSpend(
|
||
days: DailyEntry[],
|
||
datesWithSourceRecords: ReadonlySet<string>,
|
||
warn: (message: string) => void = message => console.warn(message),
|
||
): void {
|
||
for (const day of days) {
|
||
if (day.carried === true) continue
|
||
if (day.cost <= 0 && day.calls <= 0) continue
|
||
if (datesWithSourceRecords.has(day.date)) continue
|
||
warn(`codeburn: phantom-spend guard tripped — ${day.date} has spend without source records (${phantomAmountBucket(day.cost)}); totals unchanged, please report`)
|
||
return
|
||
}
|
||
}
|
||
|
||
let lockChain: Promise<unknown> = Promise.resolve()
|
||
|
||
export function withDailyCacheLock<T>(fn: () => Promise<T>): Promise<T> {
|
||
const next = lockChain.then(() => fn())
|
||
lockChain = next.catch(() => undefined)
|
||
return next
|
||
}
|
||
|
||
export const MS_PER_DAY = 24 * 60 * 60 * 1000
|
||
export const BACKFILL_DAYS = 365
|
||
// Ten years. This cache is the ONLY durable record of carried days (their
|
||
// session files are long deleted), and the uncapped `lifetime` period reads
|
||
// from it via buildDurablePeriod, so pruning at the old 2-year mark would
|
||
// have replayed the lost-history bug in slow motion at that horizon.
|
||
// Measured envelope keeps this honest: ~2.3 MB / ~11 ms JSON parse per 730
|
||
// days of fully dense data, so even a decade of daily use stays ~11 MB and
|
||
// well under 100 ms on the polling path.
|
||
export const DAILY_CACHE_RETENTION_DAYS = 3650
|
||
|
||
export function toDateString(date: Date): string {
|
||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||
}
|
||
|
||
/// A day whose ONLY content is turn-anchored residue (one straddling turn's
|
||
/// category/edit counts, no calls) is not a derived day — it is a day the parse
|
||
/// under-read. Re-derive it instead of serving it.
|
||
export function isTurnResidueOnly(day: DailyEntry): boolean {
|
||
if (day.cost > 0 || day.calls > 0 || day.sessions > 0) return false
|
||
if (day.inputTokens + day.outputTokens + day.cacheReadTokens + day.cacheWriteTokens > 0) return false
|
||
return Object.values(day.categories).some(c => c.turns > 0)
|
||
}
|
||
|
||
/// Keep only the days a RANGED parse actually covered (issue #1130): a turn
|
||
/// anchored before the range start survives range slicing whole, so the
|
||
/// aggregator can emit a residue day outside the parsed range — and out-of-range
|
||
/// residue days must not reach durable history even with the merge guards.
|
||
/// Date keys are zero-padded YYYY-MM-DD, so a string compare is exact.
|
||
export function daysInRange(days: DailyEntry[], range: DateRange, dateKeyFn: (date: Date) => string = toDateString): DailyEntry[] {
|
||
const first = dateKeyFn(range.start)
|
||
const last = dateKeyFn(range.end)
|
||
return days.filter(d => d.date >= first && d.date <= last)
|
||
}
|
||
|
||
export async function ensureCacheHydrated(
|
||
parseSessions: (range: DateRange) => Promise<ProjectSummary[]>,
|
||
aggregateDays: (projects: ProjectSummary[]) => DailyEntry[],
|
||
/// Hash of the active `localModelSavings` config. When this changes
|
||
/// (user re-mapped a baseline) the cached `savingsUSD` totals are no
|
||
/// longer accurate, so we treat the cache as stale and force a full
|
||
/// re-hydration. Pass `''` for "no savings config" to disable.
|
||
savingsConfigHash: string = '',
|
||
/// Whether the session parse that fed this backfill left the session cache
|
||
/// fully hydrated. A partial (interrupted) session cache yields empty/partial
|
||
/// older days; finalizing them would freeze that gap into the daily history.
|
||
/// So the backfill is only marked `complete` when this returns true. Defaults
|
||
/// to a trusting `true` for callers that don't (or can't) supply it.
|
||
sessionComplete: () => boolean = () => true,
|
||
/// Re-aggregate the SAME parsed projects under an explicit timezone instead of
|
||
/// the machine's local one. Used only on a tz-change re-derive: the result is
|
||
/// compared against the fresh local-tz days to subtract the turns that
|
||
/// re-bucketed across local midnight from the carried baseline (issue #770).
|
||
/// Absent, the tz-change path carries forward exactly as it did before.
|
||
aggregateDaysInTz?: (projects: ProjectSummary[], tz: string) => DailyEntry[],
|
||
): Promise<DailyCache> {
|
||
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(now.getFullYear(), now.getMonth(), now.getDate() - 1))
|
||
|
||
return withDailyCacheLock(async () => {
|
||
let c = await loadDailyCache()
|
||
|
||
// Drop any cached entry dated today or later BEFORE anything else can
|
||
// carry it forward. The cache only ever stores complete past days (up to
|
||
// yesterday), so a >= today entry can only come from the clock moving
|
||
// backward or a stale older cache; left in place it would be served frozen
|
||
// instead of recomputed live. Yesterday and earlier stay cached.
|
||
const todayStr = toDateString(now)
|
||
if (c.days.some(d => d.date >= todayStr)) {
|
||
const freshDays = c.days.filter(d => d.date < todayStr)
|
||
const latestFresh = freshDays.length > 0 ? freshDays[freshDays.length - 1].date : null
|
||
c = { ...c, days: freshDays, lastComputedDate: latestFresh }
|
||
}
|
||
|
||
// A cache can claim `complete` while its watermark points PAST its newest
|
||
// populated day — what a run finalizing off a degraded (read-only) parse
|
||
// leaves behind: it advanced lastComputedDate over days the parse never
|
||
// covered. Since gapStart is lastComputedDate + 1, that hole is invisible
|
||
// to the gap logic forever. Trust the DATA over the marker: pull the
|
||
// watermark back to the newest day actually present so the ordinary gap
|
||
// parse re-derives the tail. Nothing is dropped — the cached days all stay.
|
||
//
|
||
// Only UNSTAMPED caches are distrusted here. A degraded parse can no longer
|
||
// set `complete` (that is this fix), so the corrupt state can only be
|
||
// written by pre-fix code: an unstamped cache. A stamped one whose watermark
|
||
// outruns its newest day is a legitimately idle tail (recent days had no
|
||
// activity), and re-deriving that empty tail on every launch is the
|
||
// regression this guard avoids. A cache with NO days is exempt: it has no
|
||
// newest day to trust, and a machine with no history at all must still be
|
||
// able to finalize (below) rather than re-backfill on every launch.
|
||
const newestCachedDate = c.days.reduce<string | null>((max, d) => (max === null || d.date > max ? d.date : max), null)
|
||
if (c.watermarkTrusted !== true && newestCachedDate !== null && c.lastComputedDate !== null && c.lastComputedDate > newestCachedDate) {
|
||
c = { ...c, lastComputedDate: newestCachedDate }
|
||
}
|
||
|
||
// Never seal a residue-only day (issue #1127): a day whose only content is
|
||
// turn-anchored residue was under-read by the parse that produced it, so
|
||
// trust the DATA over the marker again — pull the watermark back to just
|
||
// before the oldest such day and let the ordinary gap parse re-derive it.
|
||
// This is what heals every already-broken cache on the next launch, with
|
||
// no version bump.
|
||
//
|
||
// Caveat: a day that GENUINELY held only one straddling turn and no other
|
||
// activity re-derives to the same residue shape and would be re-parsed on
|
||
// every launch. Bound that cost: skip the pull-back when the residue day
|
||
// is the oldest day in the cache, and only pull back inside the settle
|
||
// window — outside it the source files are gone and re-deriving cannot
|
||
// recover the day anyway.
|
||
const oldestCachedDate = c.days.reduce<string | null>((min, d) => (min === null || d.date < min ? d.date : min), null)
|
||
const residueSettleCutoff = settleCutoffDate(now)
|
||
const oldestResidueDate = c.days.reduce<string | null>(
|
||
(min, d) => (d.date >= residueSettleCutoff && d.date !== oldestCachedDate && isTurnResidueOnly(d) && (min === null || d.date < min) ? d.date : min),
|
||
null,
|
||
)
|
||
if (oldestResidueDate !== null && c.lastComputedDate !== null && c.lastComputedDate >= oldestResidueDate) {
|
||
const pulledBack = new Date(
|
||
parseInt(oldestResidueDate.slice(0, 4)),
|
||
parseInt(oldestResidueDate.slice(5, 7)) - 1,
|
||
parseInt(oldestResidueDate.slice(8, 10)) - 1
|
||
)
|
||
c = { ...c, lastComputedDate: toDateString(pulledBack) }
|
||
}
|
||
|
||
// Three reasons to re-derive the whole retention window:
|
||
// 1. Savings config changed — cached `savingsUSD` totals are stale.
|
||
// 2. The cache was never finalized against a COMPLETE session parse (an old
|
||
// pre-marker cache, an adoption from older cache files, or one frozen
|
||
// from a partial/interrupted hydration).
|
||
// 3. The local timezone changed — days are bucketed by local midnight, so a
|
||
// TZ change mis-buckets every cached day. Only invalidate when a tzKey is
|
||
// present and differs (a cache written before this field, or a test
|
||
// fixture, has none → left alone rather than force a spurious rebuild).
|
||
//
|
||
// Re-derive, NOT discard. Session files are ephemeral; a cached day whose
|
||
// sources are gone exists nowhere else, so the old days stay as a baseline
|
||
// and the fresh parse overrides per (day, provider) wherever it actually
|
||
// produced data. What it could not re-derive is carried forward (marked
|
||
// `carried`) with its old accounting — every wipe here before v14 turned
|
||
// into permanently lost history.
|
||
const tzKey = currentTzKey()
|
||
const tzChanged = c.tzKey !== undefined && c.tzKey !== tzKey
|
||
if (c.savingsConfigHash !== savingsConfigHash || c.complete !== true || tzChanged) {
|
||
const baseline = c.days
|
||
const priorWatermark = c.lastComputedDate
|
||
const backfillStart = new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS)
|
||
let freshDays: DailyEntry[] = []
|
||
let projects: ProjectSummary[] = []
|
||
if (backfillStart.getTime() <= yesterdayEnd.getTime()) {
|
||
// Hoisted so a tz-change re-derive can aggregate the SAME parse twice
|
||
// (once under the current tz as freshDays, once under the cache's old
|
||
// tzKey as freshUnderOldTz) without a second session parse.
|
||
//
|
||
// The parse stops at yesterdayEnd. Keeping it a HISTORY parse is what
|
||
// makes the parser slice a midnight-straddling turn at the yesterday
|
||
// boundary: day-N's turn-level category/counts then carry only the
|
||
// pre-midnight half and today's live parse carries the rest, so the two
|
||
// sides reconcile (issue #852). Widening THIS parse through now would
|
||
// leave the full turn on day N while today's half was excluded from the
|
||
// cache, breaking that reconciliation - so the subtraction below gets
|
||
// its own through-now parse instead.
|
||
projects = await parseSessions({ start: backfillStart, end: yesterdayEnd })
|
||
freshDays = daysInRange(aggregateDays(projects), { start: backfillStart, end: yesterdayEnd })
|
||
}
|
||
const parseWasComplete = sessionComplete()
|
||
// A PARTIAL parse must not overwrite finalized baseline days with
|
||
// undercounts (if their sources die before the next complete parse, the
|
||
// undercount would be what survives). Partial fresh data only fills days
|
||
// and slices the baseline lacks; the next complete parse gets to win.
|
||
//
|
||
// On a complete-parse TZ re-derive (savings config untouched), subtract
|
||
// from each carried baseline slice the content the fresh parse still
|
||
// attributes to that (date, provider) under the OLD bucketing: the turns
|
||
// that re-bucketed across local midnight. That is the issue #770
|
||
// double-count; re-pricing drift (a savings-hash change) must never be
|
||
// subtracted, so a hash change in the same re-derive skips this entirely.
|
||
let tzSubtraction: ReadonlyMap<string, ReadonlyMap<string, ProviderDaySlice>> | undefined
|
||
if (parseWasComplete && tzChanged && c.savingsConfigHash === savingsConfigHash && aggregateDaysInTz && c.tzKey !== undefined) {
|
||
// The subtraction re-parses THROUGH NOW (fix round 1): a call bucketed
|
||
// to OLD-tz yesterday that re-buckets to NEW-tz TODAY sits past the
|
||
// history parse's yesterdayEnd, so `freshUnderOldTz` built from `projects`
|
||
// would never see it - the baseline slice would be carried un-subtracted
|
||
// while today's live parse counts it again. This second parse exists
|
||
// ONLY for the subtraction; it never feeds freshDays, so the merged
|
||
// days written to the cache stay exactly the history days and today is
|
||
// still owned by the caller's live parse.
|
||
const wideProjects = await parseSessions({ start: backfillStart, end: now })
|
||
tzSubtraction = buildTzSubtraction(aggregateDaysInTz(wideProjects, c.tzKey))
|
||
}
|
||
const pendingRederive = c.pendingRederive?.length ? new Set(c.pendingRederive) : undefined
|
||
const merged = parseWasComplete
|
||
? mergeDayEntries(freshDays, baseline, true, tzSubtraction, true, pendingRederive)
|
||
: mergeDayEntries(baseline, freshDays, false)
|
||
// Only the complete re-derive re-parses the whole window, so freshDays is
|
||
// the authoritative record set and every non-carried merged day should be
|
||
// one of them; the partial path only fills gaps and cannot vouch for
|
||
// record presence, so it is not checked. Observer only — wrapped so it can
|
||
// never affect hydration.
|
||
if (parseWasComplete) {
|
||
try { detectPhantomSpend(merged, new Set(freshDays.map(d => d.date))) } catch { /* detection must never break hydration */ }
|
||
}
|
||
c = {
|
||
version: DAILY_CACHE_VERSION,
|
||
savingsConfigHash,
|
||
tzKey,
|
||
// Spent: this re-derivation was the one the migration owed those
|
||
// providers. A PARTIAL parse never got to use it (its fresh data only
|
||
// filled gaps), so the entitlement is kept for the next complete run.
|
||
...(parseWasComplete || !c.pendingRederive ? {} : { pendingRederive: c.pendingRederive }),
|
||
// The watermark records how far history has actually been derived, so
|
||
// only a COMPLETE parse may advance it. A partial one produced no data
|
||
// for whatever it could not read; moving the watermark to yesterday
|
||
// anyway would place those days behind the next run's gapStart and
|
||
// freeze the hole in (retention still anchors on yesterdayStr — the
|
||
// real calendar edge — so holding the watermark can't evict anything).
|
||
lastComputedDate: parseWasComplete ? yesterdayStr : priorWatermark,
|
||
days: applyRetention(merged, yesterdayStr),
|
||
complete: parseWasComplete,
|
||
// Stamp the watermark as trusted only when a COMPLETE parse produced it,
|
||
// so a later idle tail under this watermark is not distrusted above.
|
||
watermarkTrusted: parseWasComplete,
|
||
}
|
||
await saveDailyCache(c)
|
||
return c
|
||
}
|
||
if (c.tzKey === undefined) {
|
||
// First write under the tzKey scheme: tag the cache so a later TZ change is
|
||
// detectable, without discarding the (still-valid, same-TZ) cached days.
|
||
c = { ...c, tzKey }
|
||
}
|
||
|
||
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(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS)
|
||
|
||
if (gapStart.getTime() <= yesterdayEnd.getTime()) {
|
||
const gapRange: DateRange = { start: gapStart, end: yesterdayEnd }
|
||
const gapProjects = await parseSessions(gapRange)
|
||
const gapDays = daysInRange(aggregateDays(gapProjects), gapRange)
|
||
const parseWasComplete = sessionComplete()
|
||
const priorWatermark = c.lastComputedDate
|
||
// Gate the merge on parse completeness (issue #1127): replacing cached
|
||
// days wholesale with the gap parse's days let a PARTIAL parse overwrite
|
||
// a populated baseline day with its under-read version — and if the
|
||
// day's sources die before the next complete parse, the undercount is
|
||
// what survives. A complete parse still wins per (date, provider)
|
||
// exactly as the re-derive path does (partial-survival guarded); a
|
||
// partial one only fills days and slices the baseline lacks, so the gap
|
||
// merge is strictly additive and no cached data can shrink.
|
||
const merged = parseWasComplete
|
||
? mergeDayEntries(gapDays, c.days, false, undefined, true)
|
||
: mergeDayEntries(c.days, gapDays, false)
|
||
// Finalize as complete ONLY when the session parse that produced these days
|
||
// was itself complete. If it was partial, leave `complete: false` so the
|
||
// next launch (once the session cache is whole) re-backfills instead of
|
||
// freezing the partial history — and hold the watermark where it was, for
|
||
// the same reason as the re-derive path above: a partial parse cannot
|
||
// vouch for the days it never read, and gapStart is the only thing that
|
||
// will ever bring them back.
|
||
c = { ...c, days: applyRetention(merged, yesterdayStr), lastComputedDate: parseWasComplete ? yesterdayStr : priorWatermark, complete: parseWasComplete, watermarkTrusted: parseWasComplete }
|
||
await saveDailyCache(c)
|
||
} else if (c.complete !== true && sessionComplete()) {
|
||
// No gap to fill (already current through yesterday) but not yet marked —
|
||
// e.g. a brand-new machine whose only data is today. Finalize so future
|
||
// launches don't re-backfill the whole window every time.
|
||
c = { ...c, complete: true, watermarkTrusted: true }
|
||
await saveDailyCache(c)
|
||
}
|
||
return c
|
||
})
|
||
}
|
||
|
||
/// The projects the day cache still knows about over a range, whose sources may
|
||
/// be long expired. A --project/--exclude pattern is only reported as naming
|
||
/// nothing once it has missed here too: the cache is what the totals are built
|
||
/// from, so a path it carries is a path the report bills, whatever the live
|
||
/// parse can still see. Read all-provider on purpose, like the day split itself,
|
||
/// so a provider-scoped command does not call a path wrong because the spend
|
||
/// sits under another tool.
|
||
export function cachedProjectIdentities(cache: DailyCache, startStr: string, endStr: string): ProjectFilterTarget[] {
|
||
const identities: ProjectFilterTarget[] = []
|
||
for (const day of cache.days) {
|
||
if (day.date < startStr || day.date > endStr || !day.projects) continue
|
||
for (const [name, stats] of Object.entries(day.projects)) {
|
||
identities.push({ project: name, projectPath: stats.path ?? '' })
|
||
}
|
||
}
|
||
return identities
|
||
}
|
||
|
||
export async function cachedProjectIdentitiesForRange(range: DateRange): Promise<ProjectFilterTarget[]> {
|
||
return cachedProjectIdentities(await loadDailyCache(), toDateString(range.start), toDateString(range.end))
|
||
}
|