@tzyc/dsh-model-visibility 1.9.0 → 1.10.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.
@@ -0,0 +1,574 @@
1
+ /**
2
+ * Token-usage ledger for the settings page.
3
+ *
4
+ * The page shows how many tokens the user burned today, this ISO week, this
5
+ * calendar month and in total. DSH's own token-meter keeps per-session totals
6
+ * (`session_projcache`), but the page needs per-day accounting with route
7
+ * attribution, so this module rolls its own ledger from the durable session
8
+ * logs under `$DSH_HOME/sessions/**`.
9
+ *
10
+ * Session logs are JSONL, line-batched into individually compressed Zstandard
11
+ * frames (the same container `dsh-session-persistence-jsonl` writes). Each
12
+ * `assistant/message` / `assistant/attempt` event carries provider-reported
13
+ * usage plus the routed model, and `llm/retry-started` closes a replaced
14
+ * attempt so the retried call adds instead of overwriting. The fold below is
15
+ * byte-identical to the shipped projection on the reference logs (checked by
16
+ * the test suite's fixture walk).
17
+ *
18
+ * Scans are incremental: a file whose (mtime, size) pair is unchanged reuses
19
+ * its stored buckets. Events from the current month get their exact event-day
20
+ * attribution; older events are attributed to the session's creation day (the
21
+ * user accepted a rough month for history), while per-model attribution stays
22
+ * exact because every event names its route.
23
+ *
24
+ * Nothing here reads credentials, and nothing but capability-free aggregate
25
+ * numbers ever reaches the client.
26
+ *
27
+ * @module lib/usage-ledger
28
+ */
29
+ import fs from 'node:fs'
30
+ import fsp from 'node:fs/promises'
31
+ import os from 'node:os'
32
+ import path from 'node:path'
33
+ import zlib from 'node:zlib'
34
+
35
+ /** Magic number of a complete Zstandard frame. */
36
+ const ZSTD_MAGIC = 4247762216
37
+
38
+ /**
39
+ * Locate complete frames without decompressing their blocks (same structural
40
+ * walk as the persistence backend: frame header, then block headers, then the
41
+ * optional checksum). EOF inside a frame is reported as `tornStart`, not an
42
+ * error, because the persistence writer can crash mid-append.
43
+ * @param buffer - complete bytes of one session log.
44
+ * @returns `{ frames: [{ start, end }], tornStart? }`.
45
+ */
46
+ export function scanZstdFrames(buffer) {
47
+ const frames = []
48
+ let offset = 0
49
+ while (offset < buffer.length) {
50
+ const start = offset
51
+ if (buffer.length - offset < 4) return { frames, tornStart: start }
52
+ if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) throw new Error(`invalid frame magic at byte ${offset}`)
53
+ offset += 4
54
+ if (offset === buffer.length) return { frames, tornStart: start }
55
+ const descriptor = buffer.readUInt8(offset)
56
+ offset += 1
57
+ if ((descriptor & 24) !== 0) throw new Error(`reserved frame-header bit at byte ${offset - 1}`)
58
+ const contentSizeFlag = descriptor >>> 6
59
+ const singleSegment = (descriptor & 32) !== 0
60
+ const checksum = (descriptor & 4) !== 0
61
+ const dictionaryBytes = (descriptor & 3) === 3 ? 4 : descriptor & 3
62
+ const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag
63
+ const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes
64
+ if (buffer.length - offset < remainingHeaderBytes) return { frames, tornStart: start }
65
+ offset += remainingHeaderBytes
66
+ for (;;) {
67
+ if (buffer.length - offset < 3) return { frames, tornStart: start }
68
+ const blockHeader = buffer.readUIntLE(offset, 3)
69
+ offset += 3
70
+ const lastBlock = (blockHeader & 1) !== 0
71
+ const blockType = blockHeader >>> 1 & 3
72
+ const blockSize = blockHeader >>> 3
73
+ if (blockType === 3) throw new Error(`reserved block type at byte ${offset - 3}`)
74
+ const payloadBytes = blockType === 1 ? 1 : blockSize
75
+ if (buffer.length - offset < payloadBytes) return { frames, tornStart: start }
76
+ offset += payloadBytes
77
+ if (lastBlock) break
78
+ }
79
+ if (checksum) {
80
+ if (buffer.length - offset < 4) return { frames, tornStart: start }
81
+ offset += 4
82
+ }
83
+ frames.push({ start, end: offset })
84
+ }
85
+ return { frames }
86
+ }
87
+
88
+ /**
89
+ * Decode one session log into its JSON lines.
90
+ *
91
+ * Plaintext `.jsonl` files scan straight through; Zstandard files split into
92
+ * frames and decompress each one. A corrupt frame is skipped rather than
93
+ * failing the ledger (totals then under-count that session, matching what any
94
+ * reader that survives the same container can recover).
95
+ * @param buffer - the file bytes.
96
+ * @param isZstd - whether the suffix promises a Zstandard container.
97
+ * @returns an array of plain per-line strings.
98
+ */
99
+ export function decodeLogLines(buffer, isZstd) {
100
+ if (!isZstd) {
101
+ return buffer
102
+ .toString('utf8')
103
+ .split('\n')
104
+ .filter((line) => line !== '')
105
+ }
106
+ const lines = []
107
+ let bad = 0
108
+ let scan
109
+ try {
110
+ scan = scanZstdFrames(buffer)
111
+ } catch (_error) {
112
+ return lines
113
+ }
114
+ for (const { start, end } of scan.frames) {
115
+ let text
116
+ try {
117
+ text = zlib.zstdDecompressSync(buffer.subarray(start, end))
118
+ } catch (_error) {
119
+ bad += 1
120
+ continue
121
+ }
122
+ for (const line of text.toString('utf8').split('\n')) {
123
+ if (line !== '') lines.push(line)
124
+ }
125
+ }
126
+ if (bad > 0 && lines.length === 0) return []
127
+ return lines
128
+ }
129
+
130
+ /** Local-calendar `YYYY-MM-DD` key for one epoch-ms timestamp. */
131
+ export function dayKeyOf(time) {
132
+ const date = new Date(time)
133
+ if (Number.isNaN(date.getTime())) return undefined
134
+ const year = date.getFullYear()
135
+ const month = String(date.getMonth() + 1).padStart(2, '0')
136
+ const day = String(date.getDate()).padStart(2, '0')
137
+ return `${year}-${month}-${day}`
138
+ }
139
+
140
+ /** Local-calendar `YYYY-MM` prefix of a day key. */
141
+ export function monthKeyOf(dayKey) {
142
+ return typeof dayKey === 'string' && dayKey.length >= 7 ? dayKey.slice(0, 7) : undefined
143
+ }
144
+
145
+ /** First day (Monday) of the local ISO week containing `now`. */
146
+ export function weekStartKey(now = Date.now()) {
147
+ const date = new Date(now)
148
+ const weekday = (date.getDay() + 6) % 7 // Monday = 0
149
+ date.setDate(date.getDate() - weekday)
150
+ return dayKeyOf(date.getTime())
151
+ }
152
+
153
+ /** Empty usage bucket. */
154
+ function zeroBuckets() {
155
+ return [0, 0, 0, 0]
156
+ }
157
+
158
+ /** Add bucket source into destination, in place. */
159
+ function addInto(destination, source) {
160
+ for (let index = 0; index < 4; index += 1) {
161
+ if (source[index] !== undefined) destination[index] += source[index] ?? 0
162
+ }
163
+ return destination
164
+ }
165
+
166
+ /** Provider-reported usage record of one settlement event, or undefined. */
167
+ function usageOf(event) {
168
+ const data = event?.data
169
+ if (event?.type === 'assistant/message') {
170
+ return data?.usage !== null && typeof data?.usage === 'object' ? data.usage : undefined
171
+ }
172
+ if (event?.type !== 'assistant/attempt') return undefined
173
+ // The last usage chunk in the recorded stream is the settlement sample.
174
+ const stream = Array.isArray(data?.stream) ? data.stream : []
175
+ for (let index = stream.length - 1; index >= 0; index -= 1) {
176
+ const chunk = stream[index]?.chunk
177
+ if (chunk?.type === 'usage' && chunk?.usage !== null && typeof chunk?.usage === 'object') {
178
+ return chunk.usage
179
+ }
180
+ }
181
+ return undefined
182
+ }
183
+
184
+ /** Bucket values of a provider usage record. */
185
+ function bucketsOf(usage) {
186
+ const input = usage?.inputTokens
187
+ const output = usage?.outputTokens
188
+ if (!Number.isFinite(input) || !Number.isFinite(output)) return undefined
189
+ return [
190
+ Math.max(0, Math.trunc(input)),
191
+ Math.max(0, Math.trunc(output)),
192
+ Math.max(0, Math.trunc(usage?.cacheReadTokens ?? 0)),
193
+ Math.max(0, Math.trunc(usage?.cacheWriteTokens ?? 0)),
194
+ ]
195
+ }
196
+
197
+ /**
198
+ * Fold one session's events into per-day per-model buckets.
199
+ *
200
+ * `preciseFrom` is the earliest day key that still earns exact event-day
201
+ * attribution; older events keep exact totals but count on the session's
202
+ * creation day. The fold keeps the shipped token-usage projection's
203
+ * replacement and retry semantics.
204
+ * @param lines - decoded JSONL strings of one session log.
205
+ * @param options - `{ createdAt?, preciseFrom? }`.
206
+ * @returns `{ createdAt?, buckets: { [dayKey]: { [route]: [in,out,cr,cw] } } }`.
207
+ */
208
+ export function foldLogToBuckets(lines, options = {}) {
209
+ const preciseFrom = options.preciseFrom
210
+ const createdAtTime = Number.isSafeInteger(options.createdAt) ? options.createdAt : undefined
211
+ let headerCreation = undefined
212
+ const buckets = {}
213
+ const totals = zeroBuckets()
214
+ /** The sample the next same-(turn,step) settlement may replace. */
215
+ let lastKey = null
216
+
217
+ const routeOf = (event) => {
218
+ const source = event?.data?.message?.source
219
+ if (source !== null && typeof source === 'object' && source.kind === 'model') {
220
+ const provider = typeof source.provider === 'string' && source.provider !== '' ? source.provider : undefined
221
+ const model = typeof source.model === 'string' && source.model !== '' ? source.model : undefined
222
+ return provider !== undefined && model !== undefined ? `${provider}/${model}` : model
223
+ }
224
+ const data = event?.data
225
+ if (typeof data?.provider === 'string' && typeof data?.model === 'string') {
226
+ return `${data.provider}/${data.model}`
227
+ }
228
+ return 'unknown'
229
+ }
230
+
231
+ const record = (dayKey, route, values) => {
232
+ if (dayKey === undefined) return
233
+ const byModel = buckets[dayKey] ?? (buckets[dayKey] = {})
234
+ addInto(byModel[route] ?? (byModel[route] = zeroBuckets()), values)
235
+ addInto(byModel['*'] ?? (byModel['*'] = zeroBuckets()), values)
236
+ addInto(totals, values)
237
+ }
238
+
239
+ for (const line of lines) {
240
+ let event
241
+ try {
242
+ event = JSON.parse(line)
243
+ } catch (_error) {
244
+ continue
245
+ }
246
+ if (event === null || typeof event !== 'object') continue
247
+ if (event.type === 'session') {
248
+ headerCreation = Number.isSafeInteger(event.createdAt) && event.createdAt > 0 ? event.createdAt : headerCreation
249
+ continue
250
+ }
251
+ if (event.type === 'llm/retry-started') {
252
+ if (lastKey !== null && lastKey.turn === event.data?.turn && lastKey.step === event.data?.step) {
253
+ lastKey = null
254
+ }
255
+ continue
256
+ }
257
+ const usage = usageOf(event)
258
+ if (usage === undefined) continue
259
+ const values = bucketsOf(usage)
260
+ if (values === undefined) continue
261
+ const turn = Number.isSafeInteger(event?.data?.turn) ? event.data.turn : -1
262
+ const step = Number.isSafeInteger(event?.data?.step) ? event.data.step : -1
263
+ const previous =
264
+ lastKey !== null && lastKey.turn === turn && lastKey.step === step ? lastKey : undefined
265
+ if (previous !== undefined) {
266
+ const same =
267
+ previous.buckets[0] === values[0] &&
268
+ previous.buckets[1] === values[1] &&
269
+ previous.buckets[2] === values[2] &&
270
+ previous.buckets[3] === values[3]
271
+ if (same) continue
272
+ record(previous.dayKey, previous.route, [-previous.buckets[0], -previous.buckets[1], -previous.buckets[2], -previous.buckets[3]])
273
+ }
274
+ const time = Number.isSafeInteger(event?.time) && event.time > 0 ? event.time : undefined
275
+ const fallbackTime = createdAtTime ?? headerCreation
276
+ const eventDay = dayKeyOf(time)
277
+ const dayKey =
278
+ accurateDay(eventDay, preciseFrom) ?? dayKeyOf(fallbackTime) ?? eventDay
279
+ record(dayKey, routeOf(event), values)
280
+ lastKey = { turn, step, dayKey, route: routeOf(event), buckets: values }
281
+ }
282
+ return {
283
+ ...(createdAtTime !== undefined ? { createdAt: createdAtTime } : {}),
284
+ ...(buckets === undefined || Object.keys(buckets).length === 0 ? {} : { buckets }),
285
+ }
286
+ }
287
+
288
+ /** Event-day attribution only below (and including) the precise window. */
289
+ function accurateDay(eventDay, preciseFrom) {
290
+ if (eventDay === undefined) return undefined
291
+ if (preciseFrom === undefined) return eventDay
292
+ return eventDay >= preciseFrom ? eventDay : undefined
293
+ }
294
+
295
+ /** Absolute sessions directory for the ledger. */
296
+ function sessionsRoot(home) {
297
+ return path.join(home, 'sessions')
298
+ }
299
+
300
+ const SESSION_FILES = ['session.v4.jsonl.zstd', 'session.v4.jsonl']
301
+
302
+ /**
303
+ * One recursive pass over the sessions tree collecting per-file candidates.
304
+ * @param root - sessions directory.
305
+ * @returns `{ root, files: [{ relPath, absPath, size, mtime }] }`.
306
+ */
307
+ export async function listSessionFiles(root) {
308
+ const files = []
309
+ const walk = async (dir, relPrefix) => {
310
+ let entries
311
+ try {
312
+ entries = await fsp.readdir(dir, { withFileTypes: true })
313
+ } catch (_error) {
314
+ return
315
+ }
316
+ for (const entry of entries) {
317
+ const child = path.join(dir, entry.name)
318
+ const rel = relPrefix === '' ? entry.name : `${relPrefix}/${entry.name}`
319
+ if (entry.isDirectory()) {
320
+ await walk(child, rel)
321
+ continue
322
+ }
323
+ if (!entry.isFile()) continue
324
+ if (!SESSION_FILES.includes(entry.name)) continue
325
+ try {
326
+ const stat = await fsp.stat(child)
327
+ files.push({ relPath: rel, absPath: child, size: stat.size, mtime: Math.trunc(stat.mtimeMs) })
328
+ } catch (_error) {
329
+ // A file vanishing mid-scan is ordinary.
330
+ }
331
+ }
332
+ }
333
+ await walk(root, '')
334
+ return { root, files }
335
+ }
336
+
337
+ /**
338
+ * The ledger state and scans behind the `/usage` route.
339
+ *
340
+ * Everything is best-effort: a scan never throws to the caller, errors land in
341
+ * the diagnostics list, and a partially scanned ledger still answers.
342
+ * @param options - `{ home?, file?, logger?, preciseMonths?, now? }`.
343
+ */
344
+ export function createUsageLedger(options = {}) {
345
+ const logger = options.logger ?? console
346
+ const now = typeof options.now === 'function' ? options.now : Date.now
347
+ const home = options.home || process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
348
+ const file = options.file || path.join(home, 'dsh-model-visibility', 'usage-ledger.json')
349
+ const root = options.sessionsRoot || sessionsRoot(home)
350
+ /** Exact event-day attribution window, in one calendar month. */
351
+ const preciseMonths = Number.isFinite(options.preciseMonths) ? Math.min(6, Math.max(1, options.preciseMonths)) : 1
352
+
353
+ /** `{ files: { relPath: record } }` after the last successful load. */
354
+ let stored = { files: {} }
355
+ /** Longer-lived plugin-recorded usage: `{ dayKey: { route: buckets } }`. */
356
+ let pluginBuckets = {}
357
+
358
+ let loaded = false
359
+ let scanInflight = null
360
+ let scanProgress = null
361
+ let lastError = undefined
362
+ let dirty = false
363
+
364
+ const warn = (message, error) => {
365
+ lastError = message
366
+ if (typeof logger.warn === 'function') {
367
+ logger.warn(message)
368
+ if (error !== undefined) logger.warn(error)
369
+ }
370
+ }
371
+
372
+ const load = () => {
373
+ if (loaded) return
374
+ loaded = true
375
+ try {
376
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
377
+ stored = parsed !== null && typeof parsed === 'object' ? parsed : null
378
+ stored = { files: stored?.files !== null && typeof stored?.files === 'object' ? stored.files : {} }
379
+ const plugin = parsed !== null && typeof parsed === 'object' ? parsed?.plugin : undefined
380
+ pluginBuckets = plugin !== null && typeof plugin === 'object' ? plugin : {}
381
+ } catch (_error) {
382
+ stored = { files: {} }
383
+ pluginBuckets = {}
384
+ }
385
+ }
386
+
387
+ const persist = () => {
388
+ load()
389
+ try {
390
+ fs.mkdirSync(path.dirname(file), { recursive: true })
391
+ const body = { version: 1, updatedAt: now(), plugin: pluginBuckets, files: stored.files }
392
+ const temp = `${file}.${process.pid}.tmp`
393
+ fs.writeFileSync(temp, `${JSON.stringify(body)}\n`)
394
+ fs.renameSync(temp, file)
395
+ dirty = false
396
+ } catch (error) {
397
+ warn('model-visibility: could not write the usage ledger', error)
398
+ }
399
+ }
400
+
401
+ /** Merge one file's folded buckets into the stored records. */
402
+ const absorb = (relPath, meta, folded) => {
403
+ stored.files[relPath] = {
404
+ ...(stored.files[relPath] ?? {}),
405
+ mtime: meta.mtime,
406
+ size: meta.size,
407
+ createdAt: folded.createdAt,
408
+ buckets: folded.buckets ?? {},
409
+ }
410
+ }
411
+
412
+ /** Drop ledger entries long gone and month data past the retention window. */
413
+ const prune = () => {
414
+ const horizon = new Date(now())
415
+ horizon.setMonth(horizon.getMonth() - 14)
416
+ const horizonKey = dayKeyOf(horizon.getTime())
417
+ let changed = false
418
+ if (horizonKey !== undefined) {
419
+ for (const record of Object.values(stored.files)) {
420
+ const buckets = record.buckets ?? {}
421
+ for (const key of Object.keys(buckets)) {
422
+ if (key < horizonKey) {
423
+ delete buckets[key]
424
+ changed = true
425
+ }
426
+ }
427
+ }
428
+ for (const key of Object.keys(pluginBuckets)) {
429
+ if (key < horizonKey) {
430
+ delete pluginBuckets[key]
431
+ changed = true
432
+ }
433
+ }
434
+ }
435
+ return changed
436
+ }
437
+
438
+ /**
439
+ * Scan session files; incremental by (mtime, size).
440
+ * @param options - `{ force? }` re-decodes every file when set.
441
+ */
442
+ const rescan = async ({ force = false } = {}) => {
443
+ load()
444
+ if (scanInflight !== null) return scanInflight
445
+ scanProgress = { done: 0, total: null }
446
+ const stale = new Date(now())
447
+ stale.setMonth(stale.getMonth() - preciseMonths)
448
+ const preciseFrom = dayKeyOf(stale.getTime())
449
+ const run = (async () => {
450
+ const listing = await listSessionFiles(root)
451
+ scanProgress.total = listing.files.length
452
+ for (const meta of listing.files) {
453
+ const previous = force ? undefined : stored.files[meta.relPath]
454
+ if (previous?.mtime === meta.mtime && previous?.size === meta.size) {
455
+ // Already counted with the same content; keep the old buckets.
456
+ scanProgress.done += 1
457
+ continue
458
+ }
459
+ try {
460
+ const buffer = await fsp.readFile(meta.absPath)
461
+ const lines = decodeLogLines(buffer, meta.absPath.endsWith('.zstd'))
462
+ const folded = foldLogToBuckets(lines, { preciseFrom })
463
+ absorb(meta.relPath, meta, { ...folded, createdAt: folded.createdAt })
464
+ } catch (error) {
465
+ warn(`model-visibility: session log scan failed for ${meta.relPath}`, error)
466
+ }
467
+ scanProgress.done += 1
468
+ // Yield so a large tree cannot starve the loop it runs on.
469
+ if (scanProgress.done % 8 === 0) await new Promise((resolve) => setImmediate(resolve))
470
+ }
471
+ // Persist whenever anything changed — including a clean first scan, so a
472
+ // restart resumes from the ledger instead of re-reading every log.
473
+ if (prune() || dirty || (scanProgress !== null && scanProgress.total > 0)) persist()
474
+ scanProgress = null
475
+ })().catch((error) => {
476
+ // The scan never propagates: a failed pass still leaves the last ledger.
477
+ warn('model-visibility: usage scan failed', error)
478
+ scanProgress = null
479
+ })
480
+ const tracked = run.finally(() => {
481
+ if (scanInflight !== null) scanInflight = null
482
+ })
483
+ scanInflight = tracked
484
+ return tracked
485
+ }
486
+
487
+ /** Fold one plugin-attributed settlement (the verify sweep records itself). */
488
+ const notePluginUsage = (entry) => {
489
+ load()
490
+ const time = Number.isSafeInteger(entry?.time) ? entry.time : now()
491
+ const dayKey = dayKeyOf(time)
492
+ if (dayKey === undefined) return
493
+ const values = bucketsOf(entry?.usage)
494
+ if (values === undefined) return
495
+ const route =
496
+ typeof entry?.provider === 'string' && typeof entry?.model === 'string'
497
+ ? `${entry.provider}/${entry.model}`
498
+ : 'plugin'
499
+ const byModel = pluginBuckets[dayKey] ?? (pluginBuckets[dayKey] = {})
500
+ addInto(byModel[route] ?? (byModel[route] = zeroBuckets()), values)
501
+ addInto(byModel['*'] ?? (byModel['*'] = zeroBuckets()), values)
502
+ dirty = true
503
+ }
504
+
505
+ /**
506
+ * Combined day/model bucket map for `$windowFrom`-and-after days.
507
+ * @returns `{ days, byModel, newerThan? }`.
508
+ */
509
+ const collect = (windowFrom) => {
510
+ load()
511
+ const days = {}
512
+ const byModel = {}
513
+ const absorbMap = (map, from) => {
514
+ for (const [dayKey, byRoute] of Object.entries(map)) {
515
+ if (from !== undefined && dayKey < from) continue
516
+ const day = days[dayKey] ?? (days[dayKey] = zeroBuckets())
517
+ for (const [route, values] of Object.entries(byRoute)) {
518
+ // `*` is a per-day convenience row only; aggregates derive from the
519
+ // real routes so no sum ever double counts.
520
+ if (route === '*') continue
521
+ addInto(day, values)
522
+ const row = byModel[route] ?? (byModel[route] = zeroBuckets())
523
+ addInto(row, values)
524
+ }
525
+ }
526
+ }
527
+ const from = windowFrom
528
+ for (const record of Object.values(stored.files)) {
529
+ absorbMap(record.buckets ?? {}, from)
530
+ }
531
+ absorbMap(pluginBuckets, from)
532
+ return { days, byModel }
533
+ }
534
+
535
+ const sumBuckets = (map) => {
536
+ const total = zeroBuckets()
537
+ for (const values of Object.values(map)) addInto(total, values)
538
+ return total
539
+ }
540
+
541
+ /** The API the route serves: ranges resolved against the local calendar. */
542
+ const read = (options2 = {}) => {
543
+ load()
544
+ const stamp = now()
545
+ const todayKey = dayKeyOf(stamp)
546
+ const weekKey = weekStartKey(stamp)
547
+ const monthPrefix = todayKey.slice(0, 7)
548
+ const total = collect()
549
+ const month = collect(monthPrefix)
550
+ const week = collect(weekKey)
551
+ const day = collect(todayKey)
552
+ const byModel = month.byModel
553
+ delete byModel['*']
554
+ return {
555
+ generatedAt: stamp,
556
+ computing: scanInflight !== null,
557
+ progress: scanProgress,
558
+ ...(lastError !== undefined ? { error: lastError } : {}),
559
+ ranges: {
560
+ today: sumBuckets(todayKey !== undefined && day.days[todayKey] ? [day.days[todayKey]] : []),
561
+ week: sumBuckets(week.days),
562
+ month: sumBuckets(month.days),
563
+ total: sumBuckets(total.days),
564
+ },
565
+ days: total.days,
566
+ byModel,
567
+ todayKey,
568
+ weekKey,
569
+ monthPrefix,
570
+ }
571
+ }
572
+
573
+ return { file, read, rescan, notePluginUsage, isScanning: () => scanInflight !== null }
574
+ }