dsh-tacit 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/usage.js ADDED
@@ -0,0 +1,708 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 hackernotfound — https://github.com/hackernotfound/dsh-tacit
3
+ /**
4
+ * dsh-tacit — the usage/cost tracker.
5
+ *
6
+ * Groups metered model calls into *runs* (one bootstrap batch, one
7
+ * auto-analysis, one ✨ Improve, ...) and each underlying `run()` of
8
+ * `callCoachModel` into an *attempt*. Attempts arrive through
9
+ * `attemptSink(runId, tag)`, the `onUsage` callback the analyzer calls.
10
+ *
11
+ * Two hard rules shape the design:
12
+ * - **Synchronous.** Every mutation here is plain JS with no `await`, so the
13
+ * four bootstrap workers calling sinks concurrently interleave safely and
14
+ * no model call ever waits on the ledger. Disk writes happen on `endRun`
15
+ * and on a debounced, `unref()`'d flush timer that never holds the process
16
+ * open.
17
+ * - **Content-free.** A run carries ids, counts, tokens and money — never
18
+ * prompts, responses, tool arguments or full paths (`workspace` is the
19
+ * label, not the path).
20
+ *
21
+ * Totals are kept twice: on the run (written into its day file) and in the
22
+ * in-memory summary (loaded once at creation, delta-applied at record time,
23
+ * flushed to `usage/summary.json`) so reports never re-scan every day file.
24
+ */
25
+
26
+ import { dayKey } from './store.js'
27
+ import { USAGE_OPS, USAGE_RUN_TYPES } from './schema.js'
28
+
29
+ const ATTEMPT_STATUSES = ['ok', 'failed', 'unmetered']
30
+ const RUN_STATUSES = ['running', 'success', 'partial', 'failed']
31
+ const TOKEN_KEYS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens']
32
+ /** Finished runs kept addressable for `runSummary()` after they leave `live`. */
33
+ const MAX_REMEMBERED_RUNS = 50
34
+ const MS_PER_DAY = 24 * 60 * 60 * 1000
35
+ /** Report defaults for the wire-optional filter fields. */
36
+ const DEFAULT_RANGE = '30d'
37
+ const DEFAULT_PAGE_SIZE = 20
38
+ /** Days each `range` looks back over (`month`/`all` are computed instead). */
39
+ const RANGE_DAYS = { today: 1, '7d': 7, '30d': 30 }
40
+ /** Spend at or above this share of the limit is a warning (below the limit itself). */
41
+ const WARN_AT = 0.8
42
+ /** The one honest claim the cost cards may make: real usage, list-price arithmetic. */
43
+ const PRICING_LABEL = 'Measured usage · list-price cost'
44
+
45
+ /** A non-negative finite number, or 0. */
46
+ function count(value) {
47
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0
48
+ }
49
+
50
+ function isPlainObject(value) {
51
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
52
+ }
53
+
54
+ function emptyTokens() {
55
+ return { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 }
56
+ }
57
+
58
+ function emptyTotals() {
59
+ return { attempts: 0, billedCalls: 0, unmeteredCalls: 0, unpricedCalls: 0, tokens: emptyTokens(), usdKnown: 0 }
60
+ }
61
+
62
+ function emptyDayTotals() {
63
+ return { ...emptyTotals(), byType: {} }
64
+ }
65
+
66
+ /**
67
+ * Billed token count: uncached input + cache reads + cache writes + output.
68
+ * `reasoningTokens` is a subset of `outputTokens` (DeepSeek adapter) and is
69
+ * deliberately NOT added — summing it would double count every reasoning
70
+ * token in every total.
71
+ */
72
+ export function totalTokens(tokens) {
73
+ if (!isPlainObject(tokens)) return 0
74
+ return count(tokens.inputTokens) + count(tokens.outputTokens) + count(tokens.cacheReadTokens) + count(tokens.cacheWriteTokens)
75
+ }
76
+
77
+ /** A `tokenBucketsSchema` object from an untrusted usage record, or `null` for an unmetered call. */
78
+ function narrowUsage(usage) {
79
+ if (!isPlainObject(usage)) return null
80
+ const out = emptyTokens()
81
+ for (const key of TOKEN_KEYS) out[key] = count(usage[key])
82
+ return out
83
+ }
84
+
85
+ /** Add `delta` into `target` in place (both are `usageTotalsSchema` shaped). */
86
+ function addTotals(target, delta) {
87
+ target.attempts += delta.attempts
88
+ target.billedCalls += delta.billedCalls
89
+ target.unmeteredCalls += delta.unmeteredCalls
90
+ target.unpricedCalls += delta.unpricedCalls
91
+ target.usdKnown += delta.usdKnown
92
+ for (const key of TOKEN_KEYS) target.tokens[key] += delta.tokens[key]
93
+ }
94
+
95
+ /** `record[key]`, creating it from `factory` the first time (and repairing a corrupt bucket). */
96
+ function bucketOf(record, key, factory) {
97
+ const existing = record[key]
98
+ if (!isPlainObject(existing)) {
99
+ record[key] = factory()
100
+ return record[key]
101
+ }
102
+ // A summary read back from disk is schema-validated, but a bucket that
103
+ // predates a field still needs it before `+=` turns it into NaN.
104
+ const filled = { ...factory(), ...existing }
105
+ filled.tokens = { ...emptyTokens(), ...(isPlainObject(existing.tokens) ? existing.tokens : {}) }
106
+ record[key] = filled
107
+ return filled
108
+ }
109
+
110
+ /** Only finite numbers may reach `results` (`z.record(z.number())` would drop the whole run otherwise). */
111
+ function narrowResults(results) {
112
+ const out = {}
113
+ if (!isPlainObject(results)) return out
114
+ for (const [key, value] of Object.entries(results)) {
115
+ if (typeof value === 'number' && Number.isFinite(value)) out[key] = value
116
+ }
117
+ return out
118
+ }
119
+
120
+ /** no attempts → failed; any failure → partial (all failed → failed); otherwise success. */
121
+ function deriveStatus(attempts) {
122
+ if (attempts.length === 0) return 'failed'
123
+ const failed = attempts.filter((attempt) => attempt.status === 'failed').length
124
+ if (failed === 0) return 'success'
125
+ return failed === attempts.length ? 'failed' : 'partial'
126
+ }
127
+
128
+ /**
129
+ * The `count` day keys ending on `endMs`'s own day, ascending. Anchored at
130
+ * local noon so a DST shift can never drop or repeat a calendar day.
131
+ */
132
+ function dayKeysEnding(endMs, count) {
133
+ const anchor = new Date(endMs)
134
+ anchor.setHours(12, 0, 0, 0)
135
+ const out = []
136
+ for (let back = count - 1; back >= 0; back -= 1) out.push(dayKey(anchor.getTime() - back * MS_PER_DAY))
137
+ return out
138
+ }
139
+
140
+ /** Middle value (mean of the two middles when even); `null` for an empty list. */
141
+ function median(values) {
142
+ if (values.length === 0) return null
143
+ const sorted = [...values].sort((a, b) => a - b)
144
+ const mid = sorted.length >> 1
145
+ return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
146
+ }
147
+
148
+ /** A `usageTotalsSchema`-shaped value from an untrusted bucket (a report must never add `undefined`). */
149
+ function safeTotals(value) {
150
+ const source = isPlainObject(value) ? value : {}
151
+ const out = { ...emptyTotals(), ...source }
152
+ out.tokens = { ...emptyTokens(), ...(isPlainObject(source.tokens) ? source.tokens : {}) }
153
+ for (const key of ['attempts', 'billedCalls', 'unmeteredCalls', 'unpricedCalls', 'usdKnown']) out[key] = count(out[key])
154
+ for (const key of TOKEN_KEYS) out.tokens[key] = count(out.tokens[key])
155
+ return out
156
+ }
157
+
158
+ /** The totals delta one stored attempt contributed — the same arithmetic `recordAttempt` applied live. */
159
+ function attemptDelta(attempt) {
160
+ const usage = narrowUsage(attempt.usage)
161
+ const priced = isPlainObject(attempt.priced) ? attempt.priced : null
162
+ return {
163
+ attempts: 1,
164
+ billedCalls: usage !== null ? 1 : 0,
165
+ unmeteredCalls: usage === null ? 1 : 0,
166
+ unpricedCalls: usage !== null && priced === null ? 1 : 0,
167
+ tokens: usage ?? emptyTokens(),
168
+ usdKnown: count(priced?.usd),
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Cache-hit share of the billed input: `cacheRead / (input + cacheRead)`.
174
+ * `null` when nothing was billed for input at all (0 % would be a lie).
175
+ */
176
+ function cachedInputRateOf(tokens) {
177
+ const billedInput = count(tokens.inputTokens) + count(tokens.cacheReadTokens)
178
+ return billedInput === 0 ? null : count(tokens.cacheReadTokens) / billedInput
179
+ }
180
+
181
+ /** `'none'` with no limit set; `'exceeded'` at or over it; `'warn'` from 80 % up. */
182
+ function warningLevel(limit, spent) {
183
+ if (!(limit > 0)) return 'none'
184
+ if (spent >= limit) return 'exceeded'
185
+ return spent >= WARN_AT * limit ? 'warn' : 'none'
186
+ }
187
+
188
+ /**
189
+ * The run/attempt ledger. `config` is the effective-config getter (only
190
+ * `costHistoryDays` is read); `pricing` is anything with `priceCall`
191
+ * (`lib/pricing-source.js`); `now`/`flushDelayMs` are injectable for tests.
192
+ */
193
+ export function createUsageTracker({ store, config, pricing, now = Date.now, flushDelayMs = 250 }) {
194
+ /** Runs that have begun and not yet ended, by runId. */
195
+ const live = new Map()
196
+ /** Finished run summaries, so `runSummary()` still answers after `endRun`. */
197
+ const remembered = new Map()
198
+ /** Day keys whose file has unwritten live-run state. */
199
+ const dirtyDays = new Set()
200
+ let summaryDirty = false
201
+ let timer = null
202
+ let seq = 0
203
+ /** '' until the first prune; then the day key it last ran on. */
204
+ let lastPruneDay = ''
205
+
206
+ // Reassigned by `clear()`, which reloads the (fresh) summary the store wrote.
207
+ let summary = store.readUsageSummary()
208
+ if (isFreshSummary(summary)) {
209
+ summary.trackingSince = now()
210
+ store.writeUsageSummary(summary)
211
+ }
212
+
213
+ /** A summary with nothing recorded yet was just created by the store — persist it so `trackingSince` sticks. */
214
+ function isFreshSummary(value) {
215
+ return count(value?.lifetime?.attempts) === 0
216
+ && Object.keys(value?.days ?? {}).length === 0
217
+ && Object.keys(value?.byType ?? {}).length === 0
218
+ && Object.keys(value?.byModel ?? {}).length === 0
219
+ }
220
+
221
+ /** The day file a run belongs to (its start, so a run never splits across two files). */
222
+ function runDay(run) {
223
+ return dayKey(run.startedAt)
224
+ }
225
+
226
+ function scheduleFlush() {
227
+ if (timer !== null) return
228
+ timer = setTimeout(() => {
229
+ timer = null
230
+ flush()
231
+ }, flushDelayMs)
232
+ if (typeof timer?.unref === 'function') timer.unref()
233
+ }
234
+
235
+ /** Read the day file, upsert `runs` by runId, write it back. */
236
+ function upsertDay(day, runs) {
237
+ if (runs.length === 0) return
238
+ const file = store.readUsageDay(day)
239
+ const list = Array.isArray(file?.runs) ? [...file.runs] : []
240
+ for (const run of runs) {
241
+ const at = list.findIndex((existing) => existing?.runId === run.runId)
242
+ if (at >= 0) list[at] = run
243
+ else list.push(run)
244
+ }
245
+ store.writeUsageDay(day, { version: 1, day, runs: list })
246
+ }
247
+
248
+ /** Expire old day files at most once per calendar day. */
249
+ function pruneIfNewDay() {
250
+ const today = dayKey(now())
251
+ if (today === lastPruneDay) return
252
+ lastPruneDay = today
253
+ const keepDays = count(config?.().costHistoryDays)
254
+ store.pruneUsageDays(keepDays > 0 ? keepDays : 30, today)
255
+ }
256
+
257
+ function beginRun({ type, trigger = '', sessionId = '', turn = null, workspace = '', model = '', provider = '' } = {}) {
258
+ const startedAt = now()
259
+ const runId = `u${startedAt.toString(36)}-${(seq++).toString(36)}`
260
+ if (!USAGE_RUN_TYPES.includes(type)) {
261
+ // Never track a run the day-file schema would reject: one bad row makes
262
+ // the whole day unreadable. The id stays valid; every sink ignores it.
263
+ console.warn(`[tacit] usage: unknown run type ${JSON.stringify(type)}, not tracked`)
264
+ return runId
265
+ }
266
+ live.set(runId, {
267
+ runId,
268
+ type,
269
+ trigger: String(trigger ?? ''),
270
+ startedAt,
271
+ endedAt: 0,
272
+ status: 'running',
273
+ sessionId: String(sessionId ?? ''),
274
+ turn: typeof turn === 'number' && Number.isFinite(turn) ? turn : null,
275
+ workspace: String(workspace ?? ''),
276
+ model: String(model ?? ''),
277
+ provider: String(provider ?? ''),
278
+ results: {},
279
+ attempts: [],
280
+ totals: emptyTotals(),
281
+ })
282
+ return runId
283
+ }
284
+
285
+ /** Fold one attempt into the run totals and every summary bucket. Synchronous by contract. */
286
+ function recordAttempt(runId, { op, sessionId = '', turn = null } = {}, record) {
287
+ const run = live.get(runId)
288
+ if (run === undefined || !isPlainObject(record)) return
289
+ if (!USAGE_OPS.includes(op)) return
290
+
291
+ const usage = narrowUsage(record.usage)
292
+ const startedAt = count(record.startedAt) > 0 ? record.startedAt : now()
293
+ const model = typeof record.model === 'string' ? record.model : ''
294
+ const provider = typeof record.provider === 'string' ? record.provider : ''
295
+ const priced = usage === null ? null : (pricing?.priceCall({ model, provider, atMs: startedAt, usage }) ?? null)
296
+
297
+ const attempt = {
298
+ id: `${runId}:${run.attempts.length}`,
299
+ op,
300
+ startedAt,
301
+ durationMs: count(record.durationMs),
302
+ model,
303
+ provider,
304
+ reasoningEffort: typeof record.reasoningEffort === 'string' ? record.reasoningEffort : null,
305
+ finish: typeof record.finish === 'string' ? record.finish : '',
306
+ status: ATTEMPT_STATUSES.includes(record.status) ? record.status : (usage === null ? 'unmetered' : 'ok'),
307
+ code: typeof record.code === 'string' ? record.code : '',
308
+ sessionId: String(sessionId ?? ''),
309
+ turn: typeof turn === 'number' && Number.isFinite(turn) ? turn : null,
310
+ usage,
311
+ priced: isPlainObject(priced) ? priced : null,
312
+ }
313
+ run.attempts.push(attempt)
314
+
315
+ const delta = {
316
+ attempts: 1,
317
+ billedCalls: usage !== null ? 1 : 0,
318
+ unmeteredCalls: usage === null ? 1 : 0,
319
+ unpricedCalls: usage !== null && attempt.priced === null ? 1 : 0,
320
+ tokens: usage ?? emptyTokens(),
321
+ usdKnown: count(attempt.priced?.usd),
322
+ }
323
+ addTotals(run.totals, delta)
324
+ addTotals(bucketOf(summary, 'lifetime', emptyTotals), delta)
325
+ addTotals(bucketOf(summary.byType, run.type, emptyTotals), delta)
326
+ if (model.length > 0) addTotals(bucketOf(summary.byModel, model, emptyTotals), delta)
327
+ const day = bucketOf(summary.days, dayKey(startedAt), emptyDayTotals)
328
+ if (!isPlainObject(day.byType)) day.byType = {}
329
+ addTotals(day, delta)
330
+ addTotals(bucketOf(day.byType, run.type, emptyTotals), delta)
331
+
332
+ dirtyDays.add(runDay(run))
333
+ summaryDirty = true
334
+ scheduleFlush()
335
+ }
336
+
337
+ /** The `onUsage` callback for one op: a closed-over `recordAttempt`. Unknown runs are ignored. */
338
+ function attemptSink(runId, tag = {}) {
339
+ return (record) => recordAttempt(runId, tag, record)
340
+ }
341
+
342
+ /** Close a run: derive its status, write its day file, evict it, and expire old days. */
343
+ function endRun(runId, { results = {}, status } = {}) {
344
+ const run = live.get(runId)
345
+ if (run === undefined) return null
346
+ run.endedAt = now()
347
+ run.status = RUN_STATUSES.includes(status) ? status : deriveStatus(run.attempts)
348
+ run.results = narrowResults(results)
349
+ upsertDay(runDay(run), [run])
350
+ live.delete(runId)
351
+ remember(run)
352
+ pruneIfNewDay()
353
+ return runSummary(runId)
354
+ }
355
+
356
+ /** Keep the newest finished runs addressable (bounded, FIFO). */
357
+ function remember(run) {
358
+ remembered.set(run.runId, summarize(run))
359
+ if (remembered.size > MAX_REMEMBERED_RUNS) {
360
+ const oldest = remembered.keys().next()
361
+ if (!oldest.done) remembered.delete(oldest.value)
362
+ }
363
+ }
364
+
365
+ function summarize(run) {
366
+ return {
367
+ runId: run.runId,
368
+ type: run.type,
369
+ status: run.status,
370
+ attempts: run.totals.attempts,
371
+ billedCalls: run.totals.billedCalls,
372
+ unmeteredCalls: run.totals.unmeteredCalls,
373
+ unpricedCalls: run.totals.unpricedCalls,
374
+ tokens: { ...run.totals.tokens },
375
+ usdKnown: run.totals.usdKnown,
376
+ }
377
+ }
378
+
379
+ /** Counters for a live or recently finished run; `null` when the id is unknown. */
380
+ function runSummary(runId) {
381
+ const run = live.get(runId)
382
+ if (run !== undefined) return summarize(run)
383
+ return remembered.get(runId) ?? null
384
+ }
385
+
386
+ /** Write every dirty day file (live runs included, still `running`) and the summary. Synchronous. */
387
+ function flush() {
388
+ if (timer !== null) {
389
+ clearTimeout(timer)
390
+ timer = null
391
+ }
392
+ const byDay = new Map()
393
+ for (const run of live.values()) {
394
+ const day = runDay(run)
395
+ const runs = byDay.get(day)
396
+ if (runs === undefined) byDay.set(day, [run])
397
+ else runs.push(run)
398
+ }
399
+ for (const day of dirtyDays) upsertDay(day, byDay.get(day) ?? [])
400
+ dirtyDays.clear()
401
+ if (summaryDirty) {
402
+ store.writeUsageSummary(summary)
403
+ summaryDirty = false
404
+ }
405
+ }
406
+
407
+ // ── the read side (reports, one run, clear) ──────────────────────────────
408
+
409
+ /** How many days of detail are on disk at all — the hard bound on every range. */
410
+ function retentionDays() {
411
+ const keepDays = count(config?.().costHistoryDays)
412
+ return keepDays > 0 ? keepDays : 30
413
+ }
414
+
415
+ /** Ascending day keys one `range` covers, never reaching past retention. */
416
+ function rangeKeys(range, keepDays, available) {
417
+ if (range === 'all') {
418
+ // "All" is still only as far back as detail is kept — bound by day key,
419
+ // not by file count, so a sparse history cannot smuggle in older days.
420
+ // The window is `keepDays + 1` keys wide because that is exactly what
421
+ // `pruneUsageDays` keeps (day keys `>= today - keepDays`, today included);
422
+ // a narrower "all" would leave the oldest surviving file unreachable.
423
+ const oldest = dayKeysEnding(now(), keepDays + 1)[0]
424
+ return available.filter((day) => day >= oldest)
425
+ }
426
+ if (range === 'month') {
427
+ const today = dayKey(now())
428
+ const dayOfMonth = Number(today.slice(-2))
429
+ return dayKeysEnding(now(), Math.min(dayOfMonth, keepDays))
430
+ }
431
+ return dayKeysEnding(now(), Math.min(RANGE_DAYS[range] ?? RANGE_DAYS[DEFAULT_RANGE], keepDays))
432
+ }
433
+
434
+ /**
435
+ * `day → runs` for the day files inside `keys` that `available` says exist —
436
+ * one `readdir` up front means only the reads that can pay off happen.
437
+ * This runs on every 10 s poll of the cost panel.
438
+ */
439
+ function loadDays(keys, available) {
440
+ const out = new Map()
441
+ for (const day of keys) {
442
+ if (!available.has(day)) continue
443
+ const file = store.readUsageDay(day)
444
+ if (Array.isArray(file?.runs)) out.set(day, file.runs.filter(isPlainObject))
445
+ }
446
+ return out
447
+ }
448
+
449
+ /** Summary day buckets summed over `keys` (missing days simply contribute nothing). */
450
+ function totalsOver(keys) {
451
+ const out = emptyTotals()
452
+ for (const day of keys) {
453
+ const bucket = summary.days?.[day]
454
+ if (isPlainObject(bucket)) addTotals(out, safeTotals(bucket))
455
+ }
456
+ return out
457
+ }
458
+
459
+ /**
460
+ * One period card: the summary's own totals plus the two derived figures
461
+ * the panel shows. `avgAnalysisUsd` is a median (a single bootstrap batch
462
+ * must not drag the typical cost of one analysis upwards) over the priced
463
+ * `analysis` attempts of the day files that were loaded — so it is only as
464
+ * deep as the retention window, which is exactly how deep detail goes.
465
+ */
466
+ function periodOf(totals, attempts) {
467
+ const usds = []
468
+ for (const attempt of attempts) {
469
+ if (attempt.op !== 'analysis') continue
470
+ if (!isPlainObject(attempt.priced)) continue
471
+ usds.push(count(attempt.priced.usd))
472
+ }
473
+ return { ...totals, avgAnalysisUsd: median(usds), cachedInputRate: cachedInputRateOf(totals.tokens) }
474
+ }
475
+
476
+ /**
477
+ * What an analysis has actually cost lately: the median priced `analysis`
478
+ * attempt over the last 30 days of day files (`samples` is how many it rests
479
+ * on), plus the median priced directive distillation a batch also pays for.
480
+ * Both are `null` with nothing to measure. Read-side only — this reads day
481
+ * files, so it never sits on a model-call path.
482
+ */
483
+ function analysisCostSample() {
484
+ const available = store.listUsageDays()
485
+ const loaded = loadDays(rangeKeys('30d', retentionDays(), available), new Set(available))
486
+ const analyses = []
487
+ const distillations = []
488
+ for (const dayRuns of loaded.values()) {
489
+ for (const run of dayRuns) {
490
+ if (!Array.isArray(run.attempts)) continue
491
+ for (const attempt of run.attempts) {
492
+ if (!isPlainObject(attempt) || !isPlainObject(attempt.priced)) continue
493
+ if (attempt.op === 'analysis') analyses.push(count(attempt.priced.usd))
494
+ else if (attempt.op === 'directive-distillation') distillations.push(count(attempt.priced.usd))
495
+ }
496
+ }
497
+ }
498
+ return { samples: analyses.length, perAnalysisUsd: median(analyses), distillationUsd: median(distillations) }
499
+ }
500
+
501
+ /** `[{day, usdKnown, billedCalls}]`, zero-filled, ending on today. */
502
+ function seriesOf(days) {
503
+ return dayKeysEnding(now(), days).map((day) => {
504
+ const bucket = summary.days?.[day]
505
+ const totals = isPlainObject(bucket) ? safeTotals(bucket) : emptyTotals()
506
+ return { day, usdKnown: totals.usdKnown, billedCalls: totals.billedCalls }
507
+ })
508
+ }
509
+
510
+ /** A run row without its attempt array: the counters plus the identity the list shows. */
511
+ function runItem(run) {
512
+ return {
513
+ ...summarize({ ...run, totals: safeTotals(run.totals) }),
514
+ trigger: typeof run.trigger === 'string' ? run.trigger : '',
515
+ startedAt: count(run.startedAt),
516
+ endedAt: count(run.endedAt),
517
+ sessionId: typeof run.sessionId === 'string' ? run.sessionId : '',
518
+ turn: typeof run.turn === 'number' ? run.turn : null,
519
+ workspace: typeof run.workspace === 'string' ? run.workspace : '',
520
+ model: typeof run.model === 'string' ? run.model : '',
521
+ provider: typeof run.provider === 'string' ? run.provider : '',
522
+ results: narrowResults(run.results),
523
+ }
524
+ }
525
+
526
+ /** Every filter is an exact match; an absent filter matches everything. */
527
+ function matchesFilters(run, filters) {
528
+ if (filters.type !== undefined && run.type !== filters.type) return false
529
+ if (filters.status !== undefined && run.status !== filters.status) return false
530
+ if (filters.model !== undefined && run.model !== filters.model) return false
531
+ if (filters.workspace !== undefined && run.workspace !== filters.workspace) return false
532
+ if (filters.sessionId !== undefined && run.sessionId !== filters.sessionId) return false
533
+ return true
534
+ }
535
+
536
+ /**
537
+ * The whole cost panel in one read: period cards, the two sparkline series,
538
+ * the type/model breakdowns, the budget warnings and one page of runs.
539
+ *
540
+ * Cheap by construction — every total comes from the in-memory summary, and
541
+ * the only disk work is one `readdir` plus the day files of the *detail
542
+ * window*: the requested range unioned with the last 30 days and the current
543
+ * month, every part of it capped by `costHistoryDays`. The union matters —
544
+ * the period medians, `byModel` and `byType` describe fixed windows and must
545
+ * not shrink just because the run list was narrowed to `range: 'today'`. For
546
+ * the default `30d` request the union is the same set of files the range
547
+ * alone would have loaded.
548
+ */
549
+ function report({ config: effective = {}, pricingStatus = {}, pricingRates = {}, filters = {} } = {}) {
550
+ // The caller's effective config wins (it is the same getter in production);
551
+ // the injected one is the fallback for a bare `report()`.
552
+ const keepDays = count(effective?.costHistoryDays) > 0 ? Math.floor(effective.costHistoryDays) : retentionDays()
553
+ const range = typeof filters.range === 'string' ? filters.range : DEFAULT_RANGE
554
+ const page = count(filters.page) > 0 ? Math.floor(filters.page) : 1
555
+ const pageSize = count(filters.pageSize) > 0 ? Math.floor(filters.pageSize) : DEFAULT_PAGE_SIZE
556
+
557
+ const available = store.listUsageDays()
558
+ const availableSet = new Set(available)
559
+ // The run list follows `range`; every fixed-window figure below reads the
560
+ // union, so a narrow range can never shrink a 30-day breakdown or median.
561
+ const listKeys = rangeKeys(range, keepDays, available)
562
+ const detailKeys = [...new Set([
563
+ ...listKeys,
564
+ ...rangeKeys('30d', keepDays, available),
565
+ ...rangeKeys('month', keepDays, available),
566
+ ])].sort()
567
+ const loaded = loadDays(detailKeys, availableSet)
568
+
569
+ const attempts = []
570
+ /** Loaded attempts by the day they were billed on, so each period medians only its own. */
571
+ const attemptsByDay = new Map()
572
+ for (const dayRuns of loaded.values()) {
573
+ for (const run of dayRuns) {
574
+ if (!Array.isArray(run.attempts)) continue
575
+ for (const attempt of run.attempts) {
576
+ if (!isPlainObject(attempt)) continue
577
+ attempts.push(attempt)
578
+ const day = dayKey(count(attempt.startedAt))
579
+ const list = attemptsByDay.get(day)
580
+ if (list === undefined) attemptsByDay.set(day, [attempt])
581
+ else list.push(attempt)
582
+ }
583
+ }
584
+ }
585
+ const attemptsIn = (keys) => keys.flatMap((day) => attemptsByDay.get(day) ?? [])
586
+
587
+ const todayKeys = dayKeysEnding(now(), 1)
588
+ const last7Keys = dayKeysEnding(now(), 7)
589
+ const last30Keys = dayKeysEnding(now(), 30)
590
+ const monthPrefix = dayKey(now()).slice(0, 7)
591
+ const monthKeys = Object.keys(summary.days ?? {}).filter((day) => day.startsWith(monthPrefix)).sort()
592
+
593
+ // byType comes straight from the summary's own per-day buckets; byModel is
594
+ // folded from the loaded attempts because the day buckets carry no model
595
+ // split. Both cover the same last-30-days window, whatever `range` asked
596
+ // for — the detail window above always includes those 30 days.
597
+ const byType = {}
598
+ for (const day of last30Keys) {
599
+ const buckets = summary.days?.[day]?.byType
600
+ if (!isPlainObject(buckets)) continue
601
+ for (const [type, totals] of Object.entries(buckets)) addTotals(bucketOf(byType, type, emptyTotals), safeTotals(totals))
602
+ }
603
+ const byModel = {}
604
+ for (const attempt of attemptsIn(last30Keys)) {
605
+ const model = typeof attempt.model === 'string' ? attempt.model : ''
606
+ if (model.length === 0) continue
607
+ addTotals(bucketOf(byModel, model, emptyTotals), attemptDelta(attempt))
608
+ }
609
+
610
+ const today = periodOf(totalsOver(todayKeys), attemptsIn(todayKeys))
611
+ const month = periodOf(totalsOver(monthKeys), attemptsIn(monthKeys))
612
+ const dailyLimit = count(effective?.costWarnDailyUsd)
613
+ const monthlyLimit = count(effective?.costWarnMonthlyUsd)
614
+
615
+ const listRuns = []
616
+ for (const day of listKeys) listRuns.push(...(loaded.get(day) ?? []))
617
+ const matched = listRuns
618
+ .filter((run) => matchesFilters(run, filters))
619
+ .sort((a, b) => count(b.startedAt) - count(a.startedAt))
620
+ const from = (page - 1) * pageSize
621
+
622
+ return {
623
+ ok: true,
624
+ trackingSince: count(summary.trackingSince),
625
+ pricing: { ...pricingStatus, rates: pricingRates, label: PRICING_LABEL },
626
+ today,
627
+ month,
628
+ last7: periodOf(totalsOver(last7Keys), attemptsIn(last7Keys)),
629
+ last30: periodOf(totalsOver(last30Keys), attemptsIn(last30Keys)),
630
+ lifetime: periodOf(safeTotals(summary.lifetime), attempts),
631
+ byType,
632
+ byModel,
633
+ series7: seriesOf(7),
634
+ series30: seriesOf(30),
635
+ warnings: {
636
+ daily: { limit: dailyLimit, spent: today.usdKnown, level: warningLevel(dailyLimit, today.usdKnown) },
637
+ monthly: { limit: monthlyLimit, spent: month.usdKnown, level: warningLevel(monthlyLimit, month.usdKnown) },
638
+ },
639
+ runs: {
640
+ items: matched.slice(from, from + pageSize).map(runItem),
641
+ page,
642
+ pageSize,
643
+ total: matched.length,
644
+ },
645
+ code: '',
646
+ detail: '',
647
+ }
648
+ }
649
+
650
+ /**
651
+ * One run with its attempts — the live copy first (so a run is addressable
652
+ * before it is ever written), then the day files newest-first. `null` when
653
+ * the id is unknown or its day has expired. A live run is deep-copied: the
654
+ * caller gets a snapshot it can hold and serialise while the run keeps
655
+ * recording, and nothing it does can reach the ledger's own state.
656
+ */
657
+ function run(runId) {
658
+ const liveRun = live.get(runId)
659
+ if (liveRun !== undefined) return structuredClone(liveRun)
660
+ for (const day of store.listUsageDays().reverse()) {
661
+ const file = store.readUsageDay(day)
662
+ const found = Array.isArray(file?.runs) ? file.runs.find((entry) => entry?.runId === runId) : undefined
663
+ if (found !== undefined) return found
664
+ }
665
+ return null
666
+ }
667
+
668
+ /**
669
+ * Delete the ledger: every day file (never anything else in `usage/`) and
670
+ * the summary, which the store replaces with a fresh one. Live runs are
671
+ * deliberately kept — a bootstrap batch mid-flight keeps recording, into the
672
+ * new tracking window. One consequence is deliberate: a surviving run carries
673
+ * its pre-clear attempts, so the day file it is eventually written to holds
674
+ * attempts the fresh summary never counted.
675
+ */
676
+ function clear() {
677
+ if (timer !== null) {
678
+ clearTimeout(timer)
679
+ timer = null
680
+ }
681
+ // Nothing pending may resurrect what was just deleted.
682
+ dirtyDays.clear()
683
+ summaryDirty = false
684
+ lastPruneDay = ''
685
+ const { removed } = store.clearUsage()
686
+ summary = store.readUsageSummary()
687
+ // The store stamps the new window with its own `Date.now()`; the tracker's
688
+ // injected clock is the one every other timestamp here comes from.
689
+ summary.trackingSince = now()
690
+ store.writeUsageSummary(summary)
691
+ return { removed, trackingSince: summary.trackingSince }
692
+ }
693
+
694
+ return {
695
+ beginRun,
696
+ attemptSink,
697
+ recordAttempt,
698
+ endRun,
699
+ runSummary,
700
+ flush,
701
+ report,
702
+ analysisCostSample,
703
+ run,
704
+ clear,
705
+ summary: () => summary,
706
+ liveRuns: () => [...live.values()],
707
+ }
708
+ }