dsh-usage-badge 0.1.2 → 0.1.4

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/fold.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * dsh-usage-badge — session-log folding and its incremental cache.
2
+ * dsh-usage-badge — session-log folding.
3
3
  *
4
4
  * DSH persists one append-only log per session under `$DSH_HOME/sessions`. This
5
5
  * module folds every log it finds into per-day / per-(provider, model) token
@@ -10,31 +10,28 @@
10
10
  * Ported from the desktop shell's `usage-sidecar.mjs` fold, with the same
11
11
  * discovery rules (generation selection per session directory) and the same
12
12
  * size/mtime cursor that keeps a refresh proportional to what actually changed.
13
- * The cache file is this plugin's own (`usage-badge-cache.json`) rather than the
14
- * shell's `usage-cache.json`: both read the same logs, but sharing one cache file
15
- * would mean two writers racing over it.
13
+ * Where the folded history lives between runs is `cache-store.js`'s business, not
14
+ * this module's: a fold hands it one session at a time and asks it for the window
15
+ * back.
16
16
  *
17
17
  * @module dsh-usage-badge/fold
18
18
  */
19
19
 
20
- import { mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
20
+ import { readFileSync, readdirSync, statSync } from 'node:fs'
21
21
  import { homedir } from 'node:os'
22
22
  import { join, resolve } from 'node:path'
23
23
  import { zstdDecompressSync } from 'node:zlib'
24
24
 
25
- import { dayKey, initHourly, localHour } from './pricing.js'
25
+ import { addBucket, emptyBucket, UNKNOWN } from './buckets.js'
26
+ import { dayKey, localHour } from './pricing.js'
26
27
 
27
- /** Bucket key and display name used when a log records no provider/model. */
28
- export const UNKNOWN = 'unknown'
28
+ export { UNKNOWN }
29
29
 
30
30
  /** Matches `session.jsonl`, `session.jsonl.zstd`, `session.v2.jsonl(.zstd)`, … */
31
31
  const SESSION_LOG_RE = /^session(?:\.v(\d+))?\.jsonl(?:\.zstd)?$/
32
32
 
33
33
  const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
34
34
 
35
- /** This plugin's cache format version. Unknown versions are ignored, not migrated. */
36
- const CACHE_VERSION = 1
37
-
38
35
  /** Files folded between event-loop yields, so a cold scan never stalls the host. */
39
36
  const YIELD_EVERY = 25
40
37
 
@@ -141,58 +138,6 @@ export function sessionIdFromLogPath(file) {
141
138
  return parts.length >= 2 ? parts[parts.length - 2] : file
142
139
  }
143
140
 
144
- function emptyBucket(provider, model) {
145
- return {
146
- provider,
147
- model,
148
- input: 0,
149
- cacheRead: 0,
150
- cacheWrite: 0,
151
- output: 0,
152
- requests: 0,
153
- hourly: initHourly(),
154
- usageRecords: [],
155
- }
156
- }
157
-
158
- /**
159
- * Add `source` into `target`.
160
- *
161
- * A missing per-request list marks a legacy aggregate row: mixing one into a
162
- * bucket that has a list would silently drop the aggregate's share from the
163
- * context-tier price, so the list is dropped to `null` for the whole bucket
164
- * instead, and that bucket falls back to flat pricing. Appending is looped rather
165
- * than spread because one day bucket can hold hundreds of thousands of requests,
166
- * which would overflow the argument list long before it exhausted memory.
167
- */
168
- function addBucket(target, source) {
169
- target.input += Number(source?.input) || 0
170
- target.cacheRead += Number(source?.cacheRead) || 0
171
- target.cacheWrite += Number(source?.cacheWrite) || 0
172
- target.output += Number(source?.output) || 0
173
- target.requests += Number(source?.requests) || 0
174
- if (!Array.isArray(target.hourly)) target.hourly = initHourly()
175
- if (Array.isArray(source?.hourly)) {
176
- for (let hour = 0; hour < 24; hour++) {
177
- const from = source.hourly[hour]
178
- const to = target.hourly[hour]
179
- if (!from || !to) continue
180
- to.input += Number(from.input) || 0
181
- to.cacheRead += Number(from.cacheRead) || 0
182
- to.cacheWrite += Number(from.cacheWrite) || 0
183
- to.output += Number(from.output) || 0
184
- to.requests += Number(from.requests) || 0
185
- }
186
- }
187
- if (!Array.isArray(target.usageRecords) || !Array.isArray(source?.usageRecords)) {
188
- target.usageRecords = null
189
- } else {
190
- for (const usage of source.usageRecords) {
191
- if (usage && typeof usage === 'object') target.usageRecords.push(usage)
192
- }
193
- }
194
- }
195
-
196
141
  /**
197
142
  * Fold one session log into per-day, per-route buckets.
198
143
  *
@@ -299,47 +244,31 @@ export function foldSession(file, previous) {
299
244
  }
300
245
  }
301
246
 
302
- function serializeSession(record) {
303
- const days = {}
304
- for (const [date, dayObj] of record.days ?? []) {
305
- days[date] = {}
306
- for (const [key, bucket] of dayObj) days[date][key] = bucket
307
- }
308
- return {
309
- path: record.path,
310
- fileSize: record.fileSize,
311
- fileMtimeMs: record.fileMtimeMs,
312
- seq: record.seq,
313
- provider: record.provider,
314
- model: record.model,
315
- days,
316
- }
317
- }
318
-
319
- function deserializeSession(sessionId, record) {
320
- if (!record || typeof record !== 'object' || !record.days || typeof record.days !== 'object') return null
321
- const days = new Map()
322
- for (const [date, sourceDay] of Object.entries(record.days)) {
323
- if (!sourceDay || typeof sourceDay !== 'object') continue
324
- const dayObj = new Map()
325
- for (const [key, source] of Object.entries(sourceDay)) {
326
- if (!source || typeof source !== 'object') continue
327
- const bucket = emptyBucket(String(source.provider ?? UNKNOWN), String(source.model ?? UNKNOWN))
328
- addBucket(bucket, source)
329
- dayObj.set(key, bucket)
247
+ /**
248
+ * Merge session records into the per-day / per-route view the snapshot reads.
249
+ *
250
+ * Exported because a caller that reads a slice of history straight from the store (the year
251
+ * view does) needs exactly this merge rather than a second implementation of it.
252
+ *
253
+ * @param {Iterable<object>} records - session records, each carrying a `days` map.
254
+ */
255
+ export function mergeRecords(records) {
256
+ const merged = new Map()
257
+ for (const record of records) {
258
+ for (const [date, sourceDay] of record.days ?? []) {
259
+ let dayObj = merged.get(date)
260
+ if (!dayObj) {
261
+ dayObj = new Map()
262
+ merged.set(date, dayObj)
263
+ }
264
+ for (const [key, source] of sourceDay) {
265
+ const target = dayObj.get(key) ?? emptyBucket(source.provider, source.model)
266
+ addBucket(target, source)
267
+ dayObj.set(key, target)
268
+ }
330
269
  }
331
- days.set(date, dayObj)
332
- }
333
- return {
334
- sessionId,
335
- path: String(record.path ?? ''),
336
- fileSize: Number(record.fileSize) || 0,
337
- fileMtimeMs: Number(record.fileMtimeMs) || 0,
338
- seq: Number(record.seq) || -1,
339
- provider: record.provider ?? null,
340
- model: record.model ?? null,
341
- days,
342
270
  }
271
+ return merged
343
272
  }
344
273
 
345
274
  /**
@@ -350,46 +279,37 @@ function deserializeSession(sessionId, record) {
350
279
  * per-session records on every refresh; that is deliberate, because rebuilding is
351
280
  * what makes a deleted log keep its cached contribution instead of erasing
352
281
  * history that the log no longer exists to prove.
282
+ *
283
+ * @param {{sessionsRoot:string, store:object, log?:Function}} options `store` is the cache
284
+ * from `cache-store.js`: a fold writes the sessions it changed and reads a window back.
353
285
  */
354
- export function createAggregator({ sessionsRoot, cachePath, log = () => {} }) {
286
+ export function createAggregator({ sessionsRoot, store, log = () => {} }) {
355
287
  const records = new Map()
356
288
  const cursors = new Map()
357
289
  let days = new Map()
358
- let saveTimer = null
359
290
  let lastRefresh = null
360
291
 
361
292
  function mergeDays() {
362
- const merged = new Map()
363
- for (const record of records.values()) {
364
- for (const [date, sourceDay] of record.days ?? []) {
365
- let dayObj = merged.get(date)
366
- if (!dayObj) {
367
- dayObj = new Map()
368
- merged.set(date, dayObj)
369
- }
370
- for (const [key, source] of sourceDay) {
371
- const target = dayObj.get(key) ?? emptyBucket(source.provider, source.model)
372
- addBucket(target, source)
373
- dayObj.set(key, target)
374
- }
375
- }
376
- }
377
- days = merged
293
+ days = mergeRecords(records.values())
378
294
  }
379
295
 
296
+ /**
297
+ * Take back the stored history, oldest row first dropped into the window the panel can
298
+ * show. Sessions whose days all fall outside the window still come back — with their
299
+ * cursor, so their logs are never folded again — just without the buckets that no view
300
+ * asks for. Their rows stay in the store.
301
+ */
380
302
  function loadCache() {
381
- let raw
303
+ let loaded
382
304
  try {
383
- raw = JSON.parse(readFileSync(cachePath, 'utf8'))
384
- } catch {
305
+ loaded = store.load()
306
+ } catch (error) {
307
+ log(`cache load failed: ${error?.message ?? error}`)
385
308
  return false
386
309
  }
387
- if (!raw || raw.version !== CACHE_VERSION || !raw.sessions || typeof raw.sessions !== 'object') return false
388
- for (const [sessionId, source] of Object.entries(raw.sessions)) {
389
- const record = deserializeSession(sessionId, source)
390
- if (!record) continue
391
- records.set(sessionId, record)
392
- cursors.set(sessionId, {
310
+ for (const record of loaded ?? []) {
311
+ records.set(record.sessionId, record)
312
+ cursors.set(record.sessionId, {
393
313
  seq: record.seq,
394
314
  provider: record.provider,
395
315
  model: record.model,
@@ -399,64 +319,36 @@ export function createAggregator({ sessionsRoot, cachePath, log = () => {} }) {
399
319
  })
400
320
  }
401
321
  mergeDays()
402
- log(`cache loaded: ${records.size} session(s)`)
403
- return true
322
+ log(`cache loaded: ${records.size} session(s) from ${store.kind}`)
323
+ return records.size > 0
404
324
  }
405
325
 
406
- function saveCache() {
407
- const sessions = {}
408
- for (const [sessionId, record] of records) sessions[sessionId] = serializeSession(record)
409
- // The temporary name carries pid, time and entropy so a restarted host can
410
- // never collide with a stale temp file another process still holds open.
411
- const temp = `${cachePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
412
- try {
413
- mkdirSync(join(cachePath, '..'), { recursive: true })
414
- writeFileSync(temp, JSON.stringify({ version: CACHE_VERSION, sessions }))
415
- try {
416
- renameSync(temp, cachePath)
417
- } catch {
418
- // Windows cannot rename over an existing file; the complete temp file is
419
- // already on disk, so removing the old one first is safe here.
420
- try {
421
- unlinkSync(cachePath)
422
- } catch {
423
- // absent is fine
424
- }
425
- renameSync(temp, cachePath)
426
- }
427
- } catch (error) {
428
- try {
429
- unlinkSync(temp)
430
- } catch {
431
- // best-effort cleanup only
432
- }
433
- log(`cache save failed: ${error?.message ?? error}`)
434
- }
326
+ /** Write through anything the store is holding back; called on warm-up and on unload. */
327
+ function flush() {
328
+ store.flush()
435
329
  }
436
330
 
437
- function scheduleCacheSave() {
438
- if (saveTimer) return
439
- saveTimer = setTimeout(() => {
440
- saveTimer = null
441
- saveCache()
442
- }, 2000)
443
- saveTimer.unref?.()
331
+ /** Flush and release the store's handle. */
332
+ function close() {
333
+ store.close()
444
334
  }
445
335
 
446
336
  /**
447
337
  * Fold every changed log.
448
338
  *
339
+ * A changed session is written back immediately, one session at a time: that is the whole
340
+ * reason the store is not a single document, and it means there is no save timer to lose
341
+ * work in and no moment where a burst of edits has to be batched.
342
+ *
449
343
  * @returns {Promise<{files:number, folded:number, ms:number}>} what this pass did.
450
344
  */
451
345
  async function refresh() {
452
346
  const started = Date.now()
453
347
  const files = listLogFiles(sessionsRoot)
454
- const seen = new Set()
455
348
  let folded = 0
456
349
  let sinceYield = 0
457
350
  for (const file of files) {
458
351
  const sessionId = sessionIdFromLogPath(file)
459
- seen.add(sessionId)
460
352
  const cursor = cursors.get(sessionId)
461
353
  let stat
462
354
  try {
@@ -467,8 +359,14 @@ export function createAggregator({ sessionsRoot, cachePath, log = () => {} }) {
467
359
  if (cursor && cursor.fileSize === stat.size && cursor.fileMtimeMs === (stat.mtimeMs || 0) && cursor.path === file) {
468
360
  continue
469
361
  }
470
- const record = foldSession(file, records.get(sessionId))
471
- if (record) {
362
+ const previous = records.get(sessionId)
363
+ const record = foldSession(file, previous)
364
+ // `foldSession` hands the previous record straight back when it could not read the log, and
365
+ // that record is already in the store — writing it again would replace the session's rows
366
+ // with only the days this process happens to hold (the loading window), deleting the older
367
+ // ones on the way past. Leaving the cursor untouched also means the next refresh retries the
368
+ // fold, which is what a transient read failure wants.
369
+ if (record && record !== previous) {
472
370
  records.set(sessionId, record)
473
371
  cursors.set(sessionId, {
474
372
  seq: record.seq,
@@ -479,18 +377,23 @@ export function createAggregator({ sessionsRoot, cachePath, log = () => {} }) {
479
377
  path: record.path,
480
378
  })
481
379
  folded++
380
+ // Written back one session at a time, right here: a save costs the changed session's
381
+ // days, not the whole history, so there is nothing to batch and no timer to lose it in.
382
+ try {
383
+ store.put(record)
384
+ } catch (error) {
385
+ log(`cache write failed for ${sessionId}: ${error?.message ?? error}`)
386
+ }
482
387
  }
483
388
  if (++sinceYield >= YIELD_EVERY) {
484
389
  sinceYield = 0
485
390
  await new Promise((r) => setImmediate(r))
486
391
  }
487
392
  }
488
- // A session whose log disappeared keeps its record (and therefore its
489
- // history) but stops being refreshed.
490
- if (folded > 0) {
491
- mergeDays()
492
- scheduleCacheSave()
493
- }
393
+ // A session whose log disappeared keeps its record (and therefore its history) but stops
394
+ // being refreshed — and, because nothing writes it again, that history stays in the store
395
+ // for good rather than being erased by the log's absence.
396
+ if (folded > 0) mergeDays()
494
397
  lastRefresh = { files: files.length, folded, ms: Date.now() - started, at: Date.now() }
495
398
  return lastRefresh
496
399
  }
@@ -498,7 +401,8 @@ export function createAggregator({ sessionsRoot, cachePath, log = () => {} }) {
498
401
  return {
499
402
  loadCache,
500
403
  refresh,
501
- saveCache,
404
+ flush,
405
+ close,
502
406
  get days() {
503
407
  return days
504
408
  },
package/lib/holidays.js CHANGED
@@ -6,10 +6,11 @@
6
6
  * > 空闲时段价格为高峰时段价格的一半。北京时间周一至周五(**不含中国法定节假日**)
7
7
  * > 9:00 - 12:00、14:00 - 18:00 为高峰时段;其余时段,包括周末及**中国法定节假日全天均为空闲时段**。
8
8
  *
9
- * So a weekday holiday must be billed entirely off-peak, and a 调休 weekend that
10
- * the State Council turns into a working day must be billed as a working day
11
- * again. Both directions need the real calendar, which is why this module ships
12
- * one rather than inferring from the weekday alone.
9
+ * So a weekday holiday must be billed entirely off-peak, and — the part that is easy to
10
+ * get backwards — a 调休 weekend the State Council turns into a working day stays a
11
+ * *weekend*, because the policy names 周末 as off-peak in full. This module therefore
12
+ * only ever *adds* off-peak days; it never turns one into a working day. (The 调休
13
+ * workdays are still recorded in `holidays-cn.json`, for audit.)
13
14
  *
14
15
  * The bundled table is per-year data transcribed from the State Council notices
15
16
  * (see `holidays-cn.json`, which records document numbers and URLs). A year the