dsh-all-usage 1.0.5 → 1.0.7

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/CHANGELOG.md ADDED
@@ -0,0 +1,57 @@
1
+ # Changelog
2
+
3
+ All notable changes to `dsh-all-usage` are documented here.
4
+
5
+ ## [1.0.7] - 2026-08-23
6
+
7
+ ### Added
8
+
9
+ - Added an incremental ledger cursor: baseline rescans reuse the durable ledger as a per-session cursor, so unchanged sessions seed from the ledger instead of re-folding every historical event, and changed sessions fold only the new tail.
10
+ - Added an all-zero usage guard: zero-token usage replays no longer overwrite already-recorded turn/step usage, while pure cache-read requests (billable cache hits with no input/output) are still counted.
11
+ - Added a structured, machine-readable token semantics declaration (`tokenSemantics.semantics`) to the `/api/all-usage` snapshot: input is fresh (excludes cache), cache and reasoning are separate buckets.
12
+
13
+ ### Fixed
14
+
15
+ - Reduced startup scan cost on long usage history: sessions whose events are unchanged since the last ledger write are no longer re-folded or rewritten.
16
+ - Prevented a live retry of an already-recorded step from double-counting after a fresh-instance seed.
17
+
18
+ ## [1.0.6] - 2026-08-23
19
+
20
+ ### Added
21
+
22
+ - Added a durable per-session usage ledger written at the awaited `session/flush` boundary.
23
+ - Preserved successfully flushed turns and token usage after conversation logs are deleted.
24
+ - Added automatic rebuilds from ledger records after session disposal or reconciliation detects missing logs.
25
+
26
+ ### Fixed
27
+
28
+ - Prevented stale baseline snapshots from overwriting newer flushed ledger records.
29
+ - Normalized duplicate ledger keys during hydration and guarded ledger writes during plugin disposal.
30
+ - Prevented inactive Cordis timer contexts from escalating into fatal DSH load failures.
31
+ - Replaced invalid hyphenated storage unit names with valid underscore names.
32
+
33
+ ## [1.0.5] - 2026-08-20
34
+
35
+ ### Added
36
+
37
+ - Added custom start/end date ranges across all available historical daily data.
38
+ - Kept the heatmap as a fixed latest-53-week view while allowing older range aggregation and CSV export.
39
+ - Added distinct session counts for bounded ranges.
40
+
41
+ ### Fixed
42
+
43
+ - Added baseline retry with backoff after transient session registry failures.
44
+ - Stopped in-flight scans and stale polling responses after plugin disposal or refresh races.
45
+
46
+ ## [1.0.4] - 2026-08-19
47
+
48
+ ### Fixed
49
+
50
+ - Hardened loopback, same-origin, method, and process-token checks for dashboard API routes.
51
+ - Allowed same-origin browser balance GET requests that omit `Origin` while retaining token protection.
52
+ - Standardized English date buckets, range filters, streaks, heatmap dates, and export timestamps on UTC.
53
+
54
+ [1.0.7]: https://github.com/ParticleLight/dsh-all-usage/releases/tag/v1.0.7
55
+ [1.0.6]: https://github.com/ParticleLight/dsh-all-usage/releases/tag/v1.0.6
56
+ [1.0.5]: https://github.com/ParticleLight/dsh-all-usage/releases/tag/v1.0.5
57
+ [1.0.4]: https://github.com/ParticleLight/dsh-all-usage/releases/tag/v1.0.4
package/README.md CHANGED
@@ -15,8 +15,20 @@ DeepSeek Harness 全量用量看板:按模型、供应商、工作区和时间
15
15
  - **摘要与工作区**:Token 用量、缓存命中、账户余额、连续使用、工作区 Token 分布和明细
16
16
  - **导出**:按当前时间范围和模型聚合方式导出 CSV
17
17
  - **时间范围**:今日、近 30 天、近 90 天、全部,或在全部可扫描历史日数据中自定义起止日期;热力图始终展示最近 53 周
18
- - **工作区别名**:在侧栏入口打开看板后管理,持久化保存到 $DSH_HOME/storages 的 KV 单元 `all-usage-aliases`
18
+ - **工作区别名**:在侧栏入口打开看板后管理,持久化保存到 $DSH_HOME/storages 的 KV 单元 `all_usage_aliases`
19
19
  - **界面语言**:在看板顶部切换中文与 English;选择会保存到浏览器本地
20
+ - **完整历史与增量重建**:基线扫描全部可读历史会话;独立用量账本同时作为每会话游标——未变化的会话直接复用账本,新增事件只增量回填,长历史重启不再全量重建
21
+ - **Token 口径**:输入按「未含缓存命中」计,缓存命中 / 写入与推理独立成桶;全 0 用量的重放事件不会覆盖已记录的真实用量,仅缓存命中的请求也会计入
22
+
23
+ ### 最近更新
24
+
25
+ **v1.0.7**
26
+
27
+ - 增量游标:启动重扫只增量回填新增事件,未变化的会话直接从独立账本复用,不再全量重建
28
+ - 全 0 用量防护:全 0 用量的重放事件不会覆盖已记录的真实用量;纯缓存命中的请求仍会计入
29
+ - 结构化语义声明:API 返回机读的 token 口径(输入不含缓存命中,缓存与推理独立成桶)
30
+
31
+ 完整版本记录见 [CHANGELOG.md](CHANGELOG.md)。
20
32
 
21
33
  ### 截图 / Screenshots
22
34
 
@@ -26,7 +38,7 @@ DeepSeek Harness 全量用量看板:按模型、供应商、工作区和时间
26
38
 
27
39
  ### 安装
28
40
 
29
- 本插件是标准的 DSH 社区插件包(声明 `dsh.bundle` manifest 与 Web Client),数据全部来自持久化会话日志,安装后自动回填历史。
41
+ 本插件是标准的 DSH 社区插件包(声明 `dsh.bundle` manifest 与 Web Client),数据来自持久化会话日志和独立用量账本,安装后自动回填历史。
30
42
 
31
43
  #### 官方插件命令(推荐)
32
44
 
@@ -64,9 +76,11 @@ dsh plugin --profile web add github:ParticleLight/dsh-all-usage
64
76
 
65
77
  ### 数据说明
66
78
 
67
- - 使用次数与 Token 全部来自 DSH 持久化会话日志,插件激活时会自动回填全部历史,插件卸载/重启后数据不丢
79
+ - 使用次数与 Token 来自 DSH 会话日志,并在 `session/flush` 时写入独立用量账本;插件激活时会回填日志与账本历史,插件卸载/重启后已成功持久化的数据不丢
68
80
  - 按日范围统计会保留全部可读取历史会话的有使用记录日期;热力图仅作为最近 53 周的固定视图窗口
81
+ - 会话删除后,已成功 flush 的用量仍从独立账本恢复;会话销毁提示和周期对账只负责触发重建,不会删除账本记录
69
82
  - 同一会话的同一 `turn / step` 只保留一份最终 usage;重试或替换消息会替换旧贡献,不重复累计
83
+ - 输入 Token 按「未含缓存命中」计(缓存命中 / 写入独立成桶);全 0 用量的重放事件不会覆盖已记录的真实用量,纯缓存命中的请求仍会计入
70
84
  - 看板中的总处理量 = 输入 + 输出 + 缓存读写 + 推理;缓存命中表示复用的上下文 Token,不等于新生成 Token 或实际费用
71
85
  - 余额查询走 DeepSeek 官方 `/user/balance` 接口;未配置 API Key 时卡片显示引导文案
72
86
  - 仅统计能归属到已注册工作区(按会话 cwd 匹配)的会话
@@ -87,12 +101,24 @@ A full usage dashboard for DeepSeek Harness. Analyze tokens, cache behavior, acc
87
101
  - **Summary and workspaces**: processed tokens, cache hits, account balance, usage streaks, workspace distribution, and details
88
102
  - **CSV export**: export data using the selected time range and aggregation mode
89
103
  - **Time ranges**: today, last 30 days, last 90 days, all time, or a custom start/end date across all available historical daily data; the heatmap always shows the latest 53 weeks
90
- - **Workspace aliases**: manage aliases from the sidebar dashboard; values persist in the $DSH_HOME/storages KV cell `all-usage-aliases`
104
+ - **Workspace aliases**: manage aliases from the sidebar dashboard; values persist in the $DSH_HOME/storages KV cell `all_usage_aliases`
91
105
  - **Interface language**: switch between Chinese and English from the dashboard header; your choice persists locally in the browser
106
+ - **Full history & incremental rebuild**: the baseline scans every readable historical session; the durable usage ledger doubles as a per-session cursor, so unchanged sessions are reused straight from the ledger and only newly appended events are folded — long histories restart without a full rebuild
107
+ - **Token accounting semantics**: input tokens are fresh (exclude cache hits/writes, which sit in separate buckets along with reasoning); all-zero usage replays never overwrite recorded usage, while cache-only requests still count
108
+
109
+ ### Latest Update
110
+
111
+ **v1.0.7**
112
+
113
+ - Incremental scan cursor: restarts re-fold only newly appended events; unchanged sessions are seeded straight from the durable ledger instead of a full rebuild
114
+ - All-zero usage guard: zero-usage replays no longer overwrite recorded usage, while pure cache-read requests still count
115
+ - Structured token semantics: the API now declares a machine-readable accounting contract (input excludes cache; cache and reasoning are separate buckets)
116
+
117
+ See [CHANGELOG.md](CHANGELOG.md) for the complete version history.
92
118
 
93
119
  ### Installation
94
120
 
95
- This is a standard DSH community bundle. It declares a `dsh.bundle` manifest and a web client, and backfills its data from persisted session logs after installation.
121
+ This is a standard DSH community bundle. It declares a `dsh.bundle` manifest and a web client, and backfills its data from persisted session logs and the durable usage ledger after installation.
96
122
 
97
123
  #### Official plugin command (recommended)
98
124
 
@@ -130,9 +156,11 @@ The profile patch layer hot-reloads; save the file and refresh the page.
130
156
 
131
157
  ### Data semantics
132
158
 
133
- - All calls and tokens come from persisted DSH session logs; historical data is backfilled when the plugin activates and survives reloads or uninstall/reinstall cycles
159
+ - Calls and tokens come from DSH session logs and a separate usage ledger written at `session/flush`; readable logs and ledger history are backfilled when the plugin activates, so successfully persisted data survives reloads or session deletion
134
160
  - Day-level range data retains every readable historical session date with tracked usage; the heatmap is only a fixed latest-53-week view
161
+ - After a session is deleted, successfully flushed usage is restored from the separate ledger; disposal hints and periodic reconciliation trigger rebuilds without deleting ledger rows
135
162
  - For each session and logical `turn / step`, only the final usage contribution is kept; retries or replaced messages do not double-count
163
+ - Input tokens are fresh (exclude cache hits/writes, which sit in their own buckets); all-zero usage replays do not overwrite recorded usage and pure cache-read requests still count
136
164
  - Processed tokens = input + output + cache read/write + reasoning; a cache hit means reused context, not newly generated tokens or actual cost
137
165
  - Balance data comes from DeepSeek’s official `/user/balance` endpoint; the card shows guidance when no API key is configured
138
166
  - English mode uses UTC for date buckets, range filters, streaks, heatmap dates, and export timestamps; Chinese mode uses local time
package/lib/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  import { randomBytes, timingSafeEqual } from 'node:crypto'
4
4
 
5
5
  const name = 'dsh-all-usage'
6
- const inject = ['sessionQuery', 'workspaceRegistry', 'timer']
6
+ const inject = ['sessionQuery', 'workspaceRegistry', 'timer', 'storage']
7
7
 
8
8
  // webServer route handlers do not inherit the connection API fence; keep this plugin
9
9
  // local and require a browser-originated capability for state-changing reads/writes.
@@ -98,9 +98,24 @@ function apply(ctx) {
98
98
  let aliasWriteChain = Promise.resolve()
99
99
  let balanceCache = { fetchedAt: 0, payload: null }
100
100
  const requestToken = randomBytes(32).toString('base64url')
101
+ const ledgerRecords = new Map()
102
+ let ledgerUnit = null
103
+ let ledgerReady = Promise.resolve()
104
+ let ledgerWriteChain = Promise.resolve()
105
+ const LEDGER_VERSION = 1
106
+ let ledgerRevision = Date.now()
101
107
  let disposed = false
102
108
  let baselineRetryDelay = 1000
103
109
  let baselineRetryScheduled = false
110
+ let baselineFallbackTimer = null
111
+ const knownSessionIds = new Set()
112
+ let aggregationGeneration = 0
113
+ let reconcileHintScheduled = false
114
+ let reconcilePending = false
115
+ let reconcileInFlight = false
116
+ let reconcileTimer = null
117
+ const RECONCILE_INTERVAL_MS = 120000
118
+ const RECONCILE_HINT_DELAY_MS = 3000
104
119
 
105
120
  function dayKey(ms) {
106
121
  const d = new Date(ms)
@@ -113,6 +128,45 @@ function apply(ctx) {
113
128
  function num(v) {
114
129
  return typeof v === 'number' && Number.isFinite(v) ? v : 0
115
130
  }
131
+ async function safeContextTimeout(ms) {
132
+ if (disposed) return false
133
+ try {
134
+ await ctx.timeout(ms)
135
+ return !disposed
136
+ } catch (err) {
137
+ if (!disposed) console.error('[all-usage] context timer unavailable:', err)
138
+ return false
139
+ }
140
+ }
141
+ function resetAggregationState() {
142
+ aggregationGeneration += 1
143
+ wsMeta.clear()
144
+ pathIndex.clear()
145
+ memberOf.clear()
146
+ byDay.clear()
147
+ byDayUtc.clear()
148
+ perWorkspace.clear()
149
+ perModel.clear()
150
+ usageByStep.clear()
151
+ sessionModel.clear()
152
+ sessionCount.clear()
153
+ sessionSeq.clear()
154
+ chains.clear()
155
+ knownSessionIds.clear()
156
+ totals.turns = 0
157
+ totals.input = 0
158
+ totals.output = 0
159
+ totals.cacheRead = 0
160
+ totals.cacheWrite = 0
161
+ totals.reasoning = 0
162
+ scan.started = false
163
+ scan.done = false
164
+ scan.scanned = 0
165
+ scan.total = 0
166
+ scan.failed = 0
167
+ baselineRetryDelay = 1000
168
+ return aggregationGeneration
169
+ }
116
170
  function ensureDay(dayMap, date) {
117
171
  let day = dayMap.get(date)
118
172
  if (day === undefined) {
@@ -205,6 +259,10 @@ function apply(ctx) {
205
259
  const values = usageValues(usage)
206
260
  const modelId = typeof model === 'string' && model !== '' ? model : '未知模型(历史记录缺少路由)'
207
261
  const eventSeq = typeof seq === 'number' ? seq : -1
262
+ // v1.0.7: a usage event carrying no billable token in any bucket must not add a
263
+ // meaningless row nor wipe previously recorded real usage (cc-switch
264
+ // has_billable_tokens parity). Pure cache-read requests are billable and pass.
265
+ if (noValues(values)) return
208
266
  const key = usageStepKey(sid, data, seq)
209
267
  const previous = usageByStep.get(key)
210
268
  // A late replay of an older raw event cannot replace the canonical later step.
@@ -236,6 +294,96 @@ function apply(ctx) {
236
294
  function modelFromMessage(data, fallback) {
237
295
  return routeLabel(data && data.message && data.message.source) || fallback
238
296
  }
297
+ function nextLedgerRevision() {
298
+ ledgerRevision = Math.max(ledgerRevision + 1, Date.now())
299
+ return ledgerRevision
300
+ }
301
+ function ledgerEventKey(event, index) {
302
+ return typeof event.seq === 'number' ? String(event.seq) : 'event:' + index
303
+ }
304
+ function buildLedgerRecord(session, workspaceId, source = 'scan') {
305
+ const sid = session && typeof session.id === 'string' ? session.id : ''
306
+ const events = session && Array.isArray(session.events) ? session.events : []
307
+ if (sid === '' || workspaceId === undefined) return null
308
+ const turns = new Map()
309
+ const usage = new Map()
310
+ let currentModel
311
+ for (let index = 0; index < events.length; index += 1) {
312
+ const event = events[index]
313
+ if (event === null || typeof event !== 'object') continue
314
+ const data = event.data
315
+ if (event.type === 'request/context' || event.type === 'request/header') {
316
+ const model = modelFromRoute(data)
317
+ if (model !== undefined) currentModel = model
318
+ continue
319
+ }
320
+ if (event.type === 'turn/end') {
321
+ const key = ledgerEventKey(event, index)
322
+ turns.set(key, { key, time: event.time, workspaceId })
323
+ continue
324
+ }
325
+ if (event.type !== 'assistant/message' || data === null || typeof data !== 'object' || data.usage === undefined) continue
326
+ const values = usageValues(data.usage)
327
+ // v1.0.7: all-zero usage rows carry no billable tokens and stay out of the
328
+ // durable ledger (cc-switch has_billable_tokens parity).
329
+ if (noValues(values)) continue
330
+ const modelId = typeof modelFromMessage(data, currentModel) === 'string' && modelFromMessage(data, currentModel) !== ''
331
+ ? modelFromMessage(data, currentModel) : '未知模型(历史记录缺少路由)'
332
+ const eventSeq = typeof event.seq === 'number' ? event.seq : -1
333
+ const key = usageStepKey(sid, data, event.seq)
334
+ const previous = usage.get(key)
335
+ if (previous !== undefined && eventSeq >= 0 && previous.seq > eventSeq) continue
336
+ usage.set(key, { key, seq: eventSeq, time: event.time, workspaceId, modelId, values })
337
+ }
338
+ return { version: LEDGER_VERSION, sessionId: sid, workspaceId, lastSeq: lastSeqOf(events), source, updatedAt: nextLedgerRevision(), turns: Array.from(turns.values()), usage: Array.from(usage.values()) }
339
+ }
340
+ function normalizeLedgerRecord(raw, key) {
341
+ if (raw === null || typeof raw !== 'object' || raw.version !== LEDGER_VERSION || typeof raw.sessionId !== 'string' || raw.sessionId !== key) return null
342
+ if (!Array.isArray(raw.turns) || !Array.isArray(raw.usage)) return null
343
+ const turnMap = new Map()
344
+ for (const turn of raw.turns) {
345
+ if (turn && typeof turn.key === 'string' && turn.workspaceId !== undefined && Number.isFinite(turn.time)) turnMap.set(turn.key, { key: turn.key, time: turn.time, workspaceId: turn.workspaceId })
346
+ }
347
+ const usageMap = new Map()
348
+ for (const item of raw.usage) {
349
+ if (!item || typeof item.key !== 'string' || item.workspaceId === undefined || typeof item.modelId !== 'string' || !Number.isFinite(item.time) || item.values === null || typeof item.values !== 'object') continue
350
+ const normalized = { key: item.key, seq: typeof item.seq === 'number' ? item.seq : -1, time: item.time, workspaceId: item.workspaceId, modelId: item.modelId, values: usageValues({ inputTokens: item.values.input, outputTokens: item.values.output, cacheReadTokens: item.values.cacheRead, cacheWriteTokens: item.values.cacheWrite, reasoningTokens: item.values.reasoning }) }
351
+ const previous = usageMap.get(normalized.key)
352
+ if (previous === undefined || previous.seq <= normalized.seq) usageMap.set(normalized.key, normalized)
353
+ }
354
+ const updatedAt = typeof raw.updatedAt === 'number' ? raw.updatedAt : 0
355
+ ledgerRevision = Math.max(ledgerRevision, updatedAt)
356
+ return { version: LEDGER_VERSION, sessionId: raw.sessionId, workspaceId: raw.workspaceId, lastSeq: typeof raw.lastSeq === 'number' ? raw.lastSeq : -1, source: raw.source === 'flush' ? 'flush' : 'scan', updatedAt, turns: Array.from(turnMap.values()), usage: Array.from(usageMap.values()) }
357
+ }
358
+ function applyLedgerRecord(record) {
359
+ if (record === null || record === undefined) return
360
+ for (const turn of record.turns) addTurn(turn.workspaceId, turn.time, record.sessionId)
361
+ for (const item of record.usage) adjustUsage(item.workspaceId, item.time, item.values, item.modelId, 1, record.sessionId)
362
+ if (record.turns.length > 0 || record.usage.length > 0) sessionCount.add(record.sessionId)
363
+ }
364
+ function ledgerRank(record) {
365
+ return [typeof record.lastSeq === 'number' ? record.lastSeq : -1, record.source === 'flush' ? 1 : 0, typeof record.updatedAt === 'number' ? record.updatedAt : 0]
366
+ }
367
+ function storeLedgerRecord(record) {
368
+ const current = ledgerRecords.get(record.sessionId)
369
+ if (current !== undefined) {
370
+ const nextRank = ledgerRank(record)
371
+ const currentRank = ledgerRank(current)
372
+ if (nextRank[0] < currentRank[0] || (nextRank[0] === currentRank[0] && (nextRank[1] < currentRank[1] || (nextRank[1] === currentRank[1] && nextRank[2] <= currentRank[2])))) return current
373
+ }
374
+ ledgerRecords.set(record.sessionId, record)
375
+ return record
376
+ }
377
+ function persistLedgerRecord(record) {
378
+ if (ledgerUnit === null || record === null || record === undefined) return ledgerWriteChain
379
+ const write = ledgerWriteChain.then(async () => {
380
+ if (ledgerUnit !== null && ledgerRecords.get(record.sessionId) === record) await ledgerUnit.putRecord('sessions', record.sessionId, record)
381
+ })
382
+ ledgerWriteChain = write.catch((err) => {
383
+ console.error('[all-usage] usage ledger write failed:', err)
384
+ })
385
+ return ledgerWriteChain
386
+ }
239
387
  function foldEvent(wsId, time, type, data, sid, seq) {
240
388
  if (type === 'request/context' || type === 'request/header') {
241
389
  const model = modelFromRoute(data)
@@ -276,13 +424,14 @@ function apply(ctx) {
276
424
  if (wsId !== undefined) memberOf.set(sid, wsId)
277
425
  return wsId
278
426
  }
279
- async function processLiveEvent(sid, wsId, event) {
280
- if (disposed) return
427
+ async function processLiveEvent(sid, wsId, event, generation = aggregationGeneration) {
428
+ if (disposed || generation !== aggregationGeneration) return
281
429
  const seq = typeof event.seq === 'number' ? event.seq : -1
282
430
  const last = sessionSeq.get(sid)
283
431
  if (last === undefined) {
284
432
  try {
285
433
  const snap = await ctx.sessionQuery.readSession(sid)
434
+ if (disposed || generation !== aggregationGeneration) return
286
435
  if (snap && Array.isArray(snap.events)) {
287
436
  foldEvents(wsId, snap.events, undefined, sid)
288
437
  sessionSeq.set(sid, lastSeqOf(snap.events))
@@ -295,6 +444,7 @@ function apply(ctx) {
295
444
  if (seq > last + 1) {
296
445
  try {
297
446
  const snap = await ctx.sessionQuery.readSession(sid)
447
+ if (disposed || generation !== aggregationGeneration) return
298
448
  if (snap && Array.isArray(snap.events)) {
299
449
  foldEvents(wsId, snap.events, last, sid)
300
450
  sessionSeq.set(sid, lastSeqOf(snap.events))
@@ -307,21 +457,55 @@ function apply(ctx) {
307
457
  sessionCount.add(sid)
308
458
  }
309
459
 
460
+ // ---------- durable usage ledger ----------
461
+ async function loadLedger() {
462
+ if (storage === undefined || storage.backend === undefined || typeof storage.backend.get !== 'function') return
463
+ try {
464
+ const backend = storage.backend.get('json')
465
+ if (backend === undefined || backend === null || backend.kv === undefined) return
466
+ const unit = await backend.kv.open({ name: 'all_usage_ledger', version: 0, tables: ['sessions'], hasGlobal: false })
467
+ if (disposed) { await unit.close().catch(() => {}); return }
468
+ ledgerUnit = unit
469
+ const snapshot = await unit.loadAll()
470
+ const rows = snapshot && snapshot.tables && snapshot.tables.sessions
471
+ if (rows !== null && rows !== undefined && typeof rows === 'object') {
472
+ for (const [key, raw] of Object.entries(rows)) {
473
+ const record = normalizeLedgerRecord(raw, key)
474
+ if (record === null) console.warn('[all-usage] ignoring malformed usage ledger row:', key)
475
+ else ledgerRecords.set(key, record)
476
+ }
477
+ }
478
+ } catch (err) {
479
+ console.error('[all-usage] usage ledger unavailable:', err)
480
+ }
481
+ }
482
+
310
483
  // ---------- baseline scan over durable logs ----------
311
- function scheduleBaselineRetry() {
312
- if (disposed || baselineRetryScheduled || scan.done) return
484
+ function scheduleNativeBaselineRetry(generation, delay) {
485
+ if (disposed || baselineFallbackTimer !== null) return
486
+ baselineFallbackTimer = setTimeout(() => {
487
+ baselineFallbackTimer = null
488
+ if (!disposed && generation === aggregationGeneration && !scan.started && !scan.done) void runBaseline(generation)
489
+ }, delay)
490
+ if (baselineFallbackTimer && typeof baselineFallbackTimer.unref === 'function') baselineFallbackTimer.unref()
491
+ }
492
+ function scheduleBaselineRetry(generation = aggregationGeneration) {
493
+ if (disposed || baselineRetryScheduled || scan.done || generation !== aggregationGeneration) return
313
494
  baselineRetryScheduled = true
314
495
  const delay = baselineRetryDelay
315
496
  baselineRetryDelay = Math.min(baselineRetryDelay * 2, 30000)
316
- void Promise.resolve(ctx.timeout(delay)).then(() => {
497
+ void safeContextTimeout(delay).then((ready) => {
317
498
  baselineRetryScheduled = false
318
- if (!disposed && !scan.started && !scan.done) return runBaseline()
499
+ if (ready && generation === aggregationGeneration && !scan.started && !scan.done) return runBaseline(generation)
500
+ if (!ready && generation === aggregationGeneration && !disposed) scheduleNativeBaselineRetry(generation, delay)
319
501
  return undefined
320
502
  })
321
503
  }
322
- async function runBaseline() {
323
- if (scan.started || disposed) return
504
+ async function runBaseline(generation = aggregationGeneration) {
505
+ if (scan.started || disposed || generation !== aggregationGeneration) return
324
506
  scan.started = true
507
+ await ledgerReady
508
+ if (disposed || generation !== aggregationGeneration) return
325
509
  let setupFailed = false
326
510
  try {
327
511
  const workspaces = ctx.workspaceRegistry.list()
@@ -346,16 +530,27 @@ function apply(ctx) {
346
530
  } catch (err) {
347
531
  console.error('[all-usage] session list failed:', err)
348
532
  }
349
- if (disposed) return
533
+ if (disposed || generation !== aggregationGeneration) return
350
534
  if (setupFailed || !Array.isArray(records)) {
351
535
  // A transient registry failure must not be reported as a completed empty scan.
352
536
  scan.started = false
353
- scheduleBaselineRetry()
537
+ scheduleBaselineRetry(generation)
354
538
  return
355
539
  }
356
540
  scan.total = records.length
541
+ const listedSessionIds = new Set()
357
542
  for (const record of records) {
358
- if (disposed) return
543
+ if (record === undefined || record === null || record.header === undefined) continue
544
+ const sid = record.header.id
545
+ const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
546
+ const wsId = cwd === '' ? undefined : pathIndex.get(cwd)
547
+ if (sid !== undefined && wsId !== undefined) listedSessionIds.add(sid)
548
+ }
549
+ for (const [sid, record] of ledgerRecords) {
550
+ if (!listedSessionIds.has(sid)) applyLedgerRecord(record)
551
+ }
552
+ for (const record of records) {
553
+ if (disposed || generation !== aggregationGeneration) return
359
554
  if (record === undefined || record === null || record.header === undefined) {
360
555
  scan.scanned += 1
361
556
  continue
@@ -367,26 +562,136 @@ function apply(ctx) {
367
562
  scan.scanned += 1
368
563
  continue
369
564
  }
565
+ listedSessionIds.add(sid)
370
566
  await enqueue(sid, async () => {
371
- if (disposed) return
567
+ if (disposed || generation !== aggregationGeneration) return
372
568
  try {
373
569
  if (sessionSeq.has(sid)) return
374
570
  const snap = await ctx.sessionQuery.readSession(sid)
571
+ if (disposed || generation !== aggregationGeneration) return
375
572
  if (snap && Array.isArray(snap.events)) {
376
- foldEvents(wsId, snap.events, undefined, sid)
377
- sessionSeq.set(sid, lastSeqOf(snap.events))
573
+ // v1.0.7: incremental seed — the durable ledger doubles as a per-session
574
+ // cursor (cc-switch session_log_sync mtime+offset parity). An unchanged
575
+ // session applies its canonical record directly and never re-folds;
576
+ // a changed session seeds the previous record once, then folds only the
577
+ // new tail (previously every listed session was re-read and fully rebuilt).
578
+ const currentLastSeq = lastSeqOf(snap.events)
579
+ const previous = ledgerRecords.get(sid)
580
+ if (previous !== undefined) {
581
+ applyLedgerRecord(previous)
582
+ // Seed usageByStep so a later live retry of an already-recorded step
583
+ // reverses the ledger contribution instead of double-counting it.
584
+ for (const item of previous.usage) {
585
+ usageByStep.set(item.key, { seq: item.seq, wsId: item.workspaceId, time: item.time, values: item.values, modelId: item.modelId, sid })
586
+ }
587
+ if (previous.lastSeq >= currentLastSeq) {
588
+ sessionSeq.set(sid, previous.lastSeq)
589
+ sessionCount.add(sid)
590
+ return
591
+ }
592
+ foldEvents(wsId, snap.events, previous.lastSeq, sid)
593
+ } else {
594
+ foldEvents(wsId, snap.events, undefined, sid)
595
+ }
596
+ const ledger = buildLedgerRecord({ id: sid, header: record.header, events: snap.events }, wsId)
597
+ const canonical = ledger === null ? ledgerRecords.get(sid) : storeLedgerRecord(ledger)
598
+ if (canonical === ledger) {
599
+ void persistLedgerRecord(ledger)
600
+ } else if (canonical !== undefined) {
601
+ applyLedgerRecord(canonical)
602
+ }
603
+ sessionSeq.set(sid, currentLastSeq)
378
604
  sessionCount.add(sid)
379
605
  }
380
606
  } catch (err) {
381
- sessionSeq.set(sid, -1)
382
- scan.failed += 1
607
+ const saved = ledgerRecords.get(sid)
608
+ if (saved !== undefined) {
609
+ applyLedgerRecord(saved)
610
+ sessionSeq.set(sid, -1)
611
+ sessionCount.add(sid)
612
+ } else if (generation === aggregationGeneration) {
613
+ sessionSeq.set(sid, -1)
614
+ scan.failed += 1
615
+ }
383
616
  } finally {
384
- scan.scanned += 1
617
+ if (generation === aggregationGeneration) scan.scanned += 1
385
618
  }
386
619
  })
387
- await ctx.timeout(0)
620
+ if (!(await safeContextTimeout(0))) {
621
+ scan.started = false
622
+ scheduleBaselineRetry(generation)
623
+ return
624
+ }
388
625
  }
626
+ if (disposed || generation !== aggregationGeneration) return
627
+ await ledgerWriteChain
628
+ if (disposed || generation !== aggregationGeneration) return
629
+ knownSessionIds.clear()
630
+ for (const sid of listedSessionIds) knownSessionIds.add(sid)
389
631
  scan.done = true
632
+ if (reconcilePending) scheduleReconcileHint()
633
+ }
634
+
635
+ function sessionIdsFromRecords(records) {
636
+ const ids = new Set()
637
+ for (const record of records) {
638
+ if (record === undefined || record === null || record.header === undefined) continue
639
+ const sid = record.header.id
640
+ const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
641
+ const wsId = cwd === '' ? undefined : pathIndex.get(cwd)
642
+ if (sid !== undefined && wsId !== undefined) ids.add(sid)
643
+ }
644
+ return ids
645
+ }
646
+ async function reconcileSessions() {
647
+ if (disposed || reconcileInFlight || !scan.done) return
648
+ reconcilePending = false
649
+ reconcileInFlight = true
650
+ try {
651
+ const records = await ctx.sessionQuery.listSessions()
652
+ if (disposed || !Array.isArray(records)) return
653
+ const currentIds = sessionIdsFromRecords(records)
654
+ let removed = false
655
+ for (const sid of knownSessionIds) {
656
+ if (!currentIds.has(sid)) { removed = true; break }
657
+ }
658
+ if (removed && !disposed && scan.done) {
659
+ console.info('[all-usage] session removal detected; rebuilding usage index')
660
+ const generation = resetAggregationState()
661
+ void runBaseline(generation)
662
+ return
663
+ }
664
+ knownSessionIds.clear()
665
+ for (const sid of currentIds) knownSessionIds.add(sid)
666
+ } catch (err) {
667
+ console.error('[all-usage] session reconciliation failed:', err)
668
+ } finally {
669
+ reconcileInFlight = false
670
+ if (reconcilePending && !disposed) scheduleReconcileHint()
671
+ }
672
+ }
673
+ function scheduleReconcileHint() {
674
+ if (disposed) return
675
+ reconcilePending = true
676
+ if (reconcileHintScheduled || reconcileInFlight) return
677
+ reconcileHintScheduled = true
678
+ void safeContextTimeout(RECONCILE_HINT_DELAY_MS).then((ready) => {
679
+ reconcileHintScheduled = false
680
+ if (ready && !disposed) void reconcileSessions()
681
+ }, () => {
682
+ reconcileHintScheduled = false
683
+ })
684
+ }
685
+ function scheduleReconcileTimer() {
686
+ if (disposed || reconcileTimer !== null) return
687
+ reconcileTimer = setTimeout(() => {
688
+ reconcileTimer = null
689
+ if (!disposed) {
690
+ void reconcileSessions()
691
+ scheduleReconcileTimer()
692
+ }
693
+ }, RECONCILE_INTERVAL_MS)
694
+ if (reconcileTimer && typeof reconcileTimer.unref === 'function') reconcileTimer.unref()
390
695
  }
391
696
 
392
697
  // ---------- live feed ----------
@@ -399,7 +704,23 @@ function apply(ctx) {
399
704
  if (typeof sid !== 'string') return
400
705
  const wsId = wsForLiveSession(session, sid)
401
706
  if (wsId === undefined) return
402
- enqueue(sid, () => processLiveEvent(sid, wsId, event))
707
+ const generation = aggregationGeneration
708
+ knownSessionIds.add(sid)
709
+ enqueue(sid, () => processLiveEvent(sid, wsId, event, generation))
710
+ })
711
+ ctx.on('session/flush', async (session) => {
712
+ if (disposed || session === null || typeof session !== 'object' || typeof session.id !== 'string') return
713
+ await ledgerReady
714
+ if (disposed) return
715
+ const wsId = wsForLiveSession(session, session.id)
716
+ if (wsId === undefined) return
717
+ const ledger = buildLedgerRecord(session, wsId, 'flush')
718
+ if (ledger === null) return
719
+ const canonical = storeLedgerRecord(ledger)
720
+ if (canonical === ledger) await persistLedgerRecord(ledger)
721
+ })
722
+ ctx.on('session/disposed', () => {
723
+ if (!disposed) scheduleReconcileHint()
403
724
  })
404
725
 
405
726
  // ---------- workspace aliases (durable, schema-free KV unit) ----------
@@ -408,7 +729,7 @@ function apply(ctx) {
408
729
  try {
409
730
  const backend = storage.backend.get('json')
410
731
  if (backend === undefined || backend === null || backend.kv === undefined) return
411
- const unit = await backend.kv.open({ name: 'all-usage-aliases', version: 0, tables: [], hasGlobal: true })
732
+ const unit = await backend.kv.open({ name: 'all_usage_aliases', version: 0, tables: [], hasGlobal: true })
412
733
  kvUnit = unit
413
734
  const snap = await unit.loadAll()
414
735
  const g = snap && snap.global
@@ -442,12 +763,26 @@ function apply(ctx) {
442
763
  }
443
764
  ctx.effect(() => () => {
444
765
  disposed = true
766
+ if (reconcileTimer !== null) {
767
+ clearTimeout(reconcileTimer)
768
+ reconcileTimer = null
769
+ }
770
+ if (baselineFallbackTimer !== null) {
771
+ clearTimeout(baselineFallbackTimer)
772
+ baselineFallbackTimer = null
773
+ }
445
774
  })
446
775
  ctx.effect(() => () => {
447
776
  const unit = kvUnit
448
777
  kvUnit = null
449
778
  if (unit !== null && unit !== undefined) void unit.close().catch(() => {})
450
779
  })
780
+ ctx.effect(() => async () => {
781
+ const unit = ledgerUnit
782
+ await ledgerWriteChain
783
+ if (unit !== null && unit !== undefined) await unit.close().catch(() => {})
784
+ if (ledgerUnit === unit) ledgerUnit = null
785
+ })
451
786
 
452
787
  // ---------- snapshot for the client ----------
453
788
  function serializeDays(dayMap) {
@@ -480,6 +815,18 @@ function apply(ctx) {
480
815
  processedTotal: 'input + output + cacheRead + cacheWrite + reasoning',
481
816
  cacheRead: 'reused context tokens; not newly generated output',
482
817
  cacheWrite: 'tokens written into a provider cache',
818
+ // v1.0.7: structured, machine-readable accounting semantics (cc-switch
819
+ // input_token_semantics parity). DSH reports input as fresh (cache read /
820
+ // cache write sit in their own buckets) — verified against real ledger data;
821
+ // reasoning is bucketed separately from output and assumed non-overlapping.
822
+ semantics: {
823
+ input: 'fresh (excludes cache-read and cache-write tokens, bucketed separately)',
824
+ buckets: ['input', 'output', 'cacheRead', 'cacheWrite', 'reasoning'],
825
+ inputIncludesCache: false,
826
+ cacheBucketed: true,
827
+ reasoningSeparate: true,
828
+ gate: 'all-zero usage rows are ignored; pure cache-read requests still count',
829
+ },
483
830
  },
484
831
  totals: { turns: totals.turns, sessions: sessionCount.size, input: totals.input, output: totals.output, cacheRead: totals.cacheRead, cacheWrite: totals.cacheWrite, reasoning: totals.reasoning },
485
832
  perWorkspace: Array.from(perWorkspace, (p) => ({ workspaceId: p[0], turns: p[1].turns, input: p[1].input, output: p[1].output, cacheRead: p[1].cacheRead, cacheWrite: p[1].cacheWrite, reasoning: p[1].reasoning })),
@@ -642,7 +989,9 @@ function apply(ctx) {
642
989
  }
643
990
 
644
991
  // ---------- start the historical backfill immediately ----------
992
+ ledgerReady = loadLedger()
645
993
  void runBaseline()
994
+ scheduleReconcileTimer()
646
995
  void loadAliases()
647
996
  }
648
997
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-all-usage",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "DeepSeek Harness usage dashboard with model, provider, workspace, cache, balance, and CSV insights",
5
5
  "repository": {
6
6
  "type": "git",
@@ -37,6 +37,7 @@
37
37
  "files": [
38
38
  "lib/index.js",
39
39
  "lib/client.js",
40
+ "CHANGELOG.md",
40
41
  "cordis.patch.yml",
41
42
  "assets"
42
43
  ],