dsh-usage-badge 0.1.11 → 0.1.13

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/README.md CHANGED
@@ -83,7 +83,7 @@ dsh plugin --profile web remove dsh-usage-badge
83
83
 
84
84
  ## 已知限制
85
85
 
86
- - **首次启动要冷扫一遍全部会话日志**(200 多份实测约 10 秒;分片让出事件循环,界面不会卡住)。之后走增量,几乎瞬时。
86
+ - **首次启动要冷扫一遍全部会话日志**(本机 338 份 / 348 MB 实测约 19 秒,随机器负载浮动;按解码字节数让出事件循环,最长一次阻塞约 0.4 秒,宿主启动与界面都不会被卡住,徽标先显示「…」再填上数字)。**之后每次启动只读增量**:只解压新追加的那几帧 —— 同一台机器实测「1 个日志、2 KB、57 ms」,没有任何变化时「0 个、16 ms」。**挂载插件本身不碰磁盘**(只登记一条路由,实测 0.75 ms),打开缓存库、读历史、折日志都发生在界面第一次请求时 —— 那时宿主早已启动完成。启动的每一段都有数字:日志里的 `ready in …`、面板上的**「上次启动」**一行会写明打开库 / 载入 / 列日志 / 折叠各用了多久,觉得慢时看这一行就知道慢在哪。
87
87
  - **历史依赖日志,但日志变短不再吃掉历史**:日志被删掉后靠缓存继续计入;缓存也丢了,那段历史就没了。同一个会话的日志**变短或还原成更早的备份**时,缓存会**把更完整的那份留着**(折叠结果变小就只作为备份副本存下,统计仍用完整的那份),要等日志真的更全、却不再包含某一天,那天才会被丢掉。**例外是改名**:会话是按「日志所在目录」识别的,把某个会话目录改名会让同一份用量各算一份(见下)。DSH 自己从不裁日志:格式升级时它另写一代新文件、旧代保留,而实测换代后统计结果逐项相同。
88
88
  - **日粒度区间仍只覆盖最近一年**:`近 7 日` / `近 30 日` / `近 12 个月` 与它们下面的滚动热力图都基于快照的最近 400 天。要看更早的日子,用 **`按年`**:那一档的月度合计、年度总额与**自然年热力图**都是直接查缓存库的,任何一年都在。
89
89
  - **只统计 DSH 记进日志的调用**:某个东西直接请求外部 API 且不向 DSH 上报 usage,这部分统计不到。
@@ -327,6 +327,50 @@ dsh-usage-badge
327
327
 
328
328
  删掉 `cache.sqlite` 与删掉 `cache.json` 的后果完全一样:下次启动冷扫一遍日志重建;只有「日志已被删除」的那段历史会随之消失。
329
329
 
330
+ ### 折叠的代价:只解码新增的那几个字节
331
+
332
+ 「DSH 启动很慢」查下来是这个插件自己:**每启动一次就把整份日志重新解码一遍**。本机实测(373 个日志 / 348 MB):一次启动折 25 个日志要 **3.6 秒**,冷启动(没有缓存)**20 秒**,而耗时**八成在 zstd 解压**——12.7 MB 的日志解压 775 ms,解析 JSON 只要 133 ms。两个原因叠在一起:
333
+
334
+ 1. **游标取错了地方**:会话的 `fileSize`/`fileMtimeMs` 原来取自它**最早那一天的行走**(每天的行记的是写它的那次折叠的戳,而折叠只重写 payload 真的变了的天),所以长寿会话的最早一天带着几个月前的戳 —— 于是**每次启动都判定成「变了」**,多天的会话全部重折。现在 `resolveRows` 取会话行本身的游标(`sessions.file_size`),那才是「这份日志我折到哪儿了」。
335
+ 2. **变了就从头折**:游标的 `fileSize` 本来**就是**上次折到哪儿的字节偏移,所以「新增了 1 KB」不需要从第 0 字节重新解压。现在 `foldSession(file, previous, { fromByte })` 只读 `[fromByte, size)`:按 zstd **帧**切(一帧要么整个折进去,要么留给下一趟)、纯文本按**最后一个换行**切,末尾那半行只有在它自身是完整 JSON 时才算数(日志最后一行常常没有换行符,漏掉它就正好漏掉最新一次请求)。
336
+
337
+ 规则与代价:
338
+
339
+ | | 改之前 | 改之后 |
340
+ |---|---|---|
341
+ | 启动(25 个日志有增量时) | 折 25 个、读 45 MB、**3.6 s** | 折 1 个、读 **2 KB**、**57 ms** |
342
+ | 启动(无变化) | 每个多天会话都重折 | **0 个、16 ms** |
343
+ | 冷启动(无缓存,338 个日志) | 19.6 s,单次阻塞可到 ~0.8 s | 18.6 s,**最长阻塞 ~0.4 s** |
344
+ | 使用中(每次轮询) | 活动会话整份重解压 | 只解压新追加的帧 |
345
+
346
+ 三条不变量:
347
+
348
+ - **折不全就重折**:只有「同一路径、比游标更长」才走增量;变短、换路径、无游标、无旧记录一律从头折 —— 尾部读法假设前缀没变。
349
+ - **不删自己没看过的天**:增量折出来的记录只覆盖窗口内的天,所以 `put(record, { keepOtherDays: true })` 跳过「记录里没有的天就删掉」那一步。否则折一次今天就会把窗口外的历史删光。
350
+ - **不许伪造新日期**:尾部里的某天若不在旧记录里(时钟跳了、日志被原地重写),这次折叠作废、改为从头折。`test/cache.verify.mjs` 里 8 条断言钉住这些(只读增量字节数、两天相加不翻倍、无变化读 0、游标是日志大小而不是旧天戳、半行不算/补全后只算一次)。
351
+
352
+ 剩下的一份成本是**冷启动必须把 348 MB 读一遍**(一次性的,之后每次启动都只读增量)。它现在是**按解码字节数让出事件循环**的(每 1 MB 让一次 —— 按帧让会适得其反:一个 12 MB 日志有 34 000 帧,`setImmediate` 往返比解码本身还贵,实测 21 s 变 52 s):18.6 秒的总时长里最长阻塞 ~0.4 秒,宿主照常启动、界面照常响应,徽标先显示「…」再填上数字。
353
+
354
+ **等价性核对(真机)**:同一份 334 MB / 338 个日志,「读快照 + 只折尾部」与「清空缓存全量重折」在**共同可见的 68 天上逐日完全一致**(input、请求数逐项相等);库里另外多的 3 天来自**日志已被删除**的会话 —— 那正是缓存有意保留的历史,全量重折本来就看不回来。
355
+
356
+ **挂载时插件什么都不做,是真的什么都不做**。DSH 的 Loader 在 `boot()` 里挂载整棵插件树,所以「晚点再加载这个插件」不是插件能选的 —— 能选的是**挂载之后干什么**。`apply()` 现在只做一件事:往 `ctx.webServer` 登记一条路由(实测 **0.75 ms**,不开库、不读价格表、不碰日志、连 `sessions` 目录都不列)。真正的活全部落在**界面第一次请求**上:那条请求不可能早于宿主启动完成。
357
+
358
+ 启动的每一段都有数字,不再靠感觉:
359
+
360
+ | 段 | 什么时候做 | 含义 | 变大意味着 |
361
+ |---|---|---|---|
362
+ | 挂载 | DSH 启动时 | 登记路由 | 不该长大 |
363
+ | 打开库 `storeMs` | 第一次请求 | 打开 `cache.sqlite`、设 pragma、查 schema | 磁盘慢 / 杀毒软件在扫那个文件 |
364
+ | 载入 `loadMs` | 第一次请求 | 把窗口内的历史读进内存 | 缓存库大,或磁盘慢 |
365
+ | 列日志 `listMs` | 每次折叠 | 递归遍历 `sessions` 目录 | 会话特别多,或磁盘慢 |
366
+ | 折叠 `foldMs` | 每次折叠 | 解码新追加的字节 + 重建合并视图 | 增量确实很大(会同时报读了多少 KB / 折了几个日志) |
367
+
368
+ `ready in …`(日志)与面板上的「上次启动」一行报的就是这条链路;`waitedMs` 单独记「从挂载到界面来问」等了多久 —— 那是宿主的启动时间,不是插件的成本。
369
+
370
+ 本机实测(348 MB / 341 个日志,已是最坏的那类安装):`apply()` **0.75 ms**;第一次请求里打开库 22 ms、载入 46 ms、列日志 16 ms、折叠 41 ms(1 个日志、8 KB),**合计 ~103 ms**;之后每轮 18 ms 左右,没有变化时就只有列日志 + stat。
371
+
372
+ **兜底计时器**:如果宿主压根没人打开界面(没人请求数据),`DSH_USAGE_BADGE_WARM_MS`(默认 3000 ms)之后也会自己热身一次,纯粹为了让日志里有那条 `ready` 行;两条路径都会走同一个 `warmOnce()`,所以只会热身一次。
373
+
330
374
  ### `按年` 视图:年份列表属于数据,不属于代码
331
375
 
332
376
  历史不删,就迟早要看更早的年。这一年视图有三条决定:
@@ -373,7 +417,7 @@ npm test
373
417
  | `test/pricing.verify.mjs` | 用临时 `DSH_HOME` + 合成会话日志跑真实宿主半,断言精确金额。第一组用例**逐字移植自桌面壳的 `pricing-key-case.verify.mjs`**(同样的 fixture、同样的期望值),用来证明移植后的定价与原实现一致;另覆盖上下文档位(严格大于、`"128K"` 紧凑写法、缓存 token 计入上下文)、峰谷 `days` 规则、跨币种换算、前缀行的四条优先级规则(精确行优先、长前缀优先、星号不是分隔符、裸 `*` 仍是死配置)、隐式前缀回退(官方行兜住自己的变体、分隔符边界让 `gpt-4` 吞不掉 `gpt-4o`、开关经 PUT 路由后在**同一个进程内**立刻重算),以及未匹配模型不计价的语义(哪些行算「匹配」、token 仍计入、快照列出被跳过的路由)、逐行/表级的 `honorHolidays`(既有「节假日盖过行内规则」也有「行内拒绝日历后照样收峰价」)、行编辑器的写入路径(整份 `overrides` 经 PUT 落盘、其它字段与两个开关不受影响)、**`default` 行是整体替换而非字段合并**(清空的字段真的消失、`{}` 被当成「没有这一行」)、以及**一行 `null` 覆盖行**被跳过而不是让 `/summary` 500 |
374
418
  | `test/holidays.verify.mjs` | 节假日日历的行为:工作日节假日全天空闲、**调休周末仍按空闲价**(政策原文说周末全天空闲)、旧配置里的 `holidays.workdays` 被忽略、关闭日历时回到旧的按星期行为、自定义 `extra`、同一个月内不同日子的分类互不干扰,以及**2027 年的单日端午/中秋**(同一天 10:30,节当天 x1、旁边的周三 x2)与 2027-02-14 调休周日 |
375
419
  | `test/official-pricing.verify.mjs` | 解析器对**中英文两个页面快照**的逐字段断言(含旧模型别名、时区识别与换算);页面结构变化时会抛错而不是静默返回错价;`应用` 的合并语义(未知字段存活、已有的 `default` 行不动、没有 `default` 行时按官方第一条补一个并报告 `defaultFrom`、页面第一条确实是最便宜那档);并用**被 stub 掉的 fetch** 跑通两个路由,最后验证应用后的价格真的参与计费(含未列出模型按生成的 default 行计价),以及兜底行的完整生命周期:删除 → 回到内置模板并把 `defaultSource` 报成 `template` → 再次应用从官方第一条生成回来 → 手改过的兜底行不会被应用覆盖 |
376
- | `test/cache.verify.mjs` | 缓存存储层:**两个后端跑同一套契约断言**(会话游标与桶的往返、`hourly` 槽位与逐请求记录、重写会话即替换它的日期集合、`years()` 的年份列表与有界区间读),以及只有跨重启才看得见的行为 —— **删掉会话日志后历史仍在**(新挂载的宿主照旧报出同样的金额)、**没有变化的一轮刷新不写文件**、**超出加载窗口的那天留在库里只是不读进来**(换个更宽的窗口还能取到)、旧 `cache.json` **迁移一次**并把旧文件改名为 `.migrated`(且 `.migrated` 之后**不再被读**)、**读不出来的缓存文件被改名保留并新建一个**、**更新版本的库只读使用且绝不改名**、加载窗口必须大于快照窗口这条不变量,以及 `按年` 路由(三年以前那一年照样读得到、12 个月恒定、`daily` 带节假日标记与 provider 拆分、没有数据的年份是 12 个空月而不是报错、非法年份 400)。 |
420
+ | `test/cache.verify.mjs` | 缓存存储层:**两个后端跑同一套契约断言**(会话游标与桶的往返、`hourly` 槽位与逐请求记录、重写会话即替换它的日期集合、`years()` 的年份列表与有界区间读),以及只有跨重启才看得见的行为 —— **删掉会话日志后历史仍在**(新挂载的宿主照旧报出同样的金额)、**没有变化的一轮刷新不写文件**、**超出加载窗口的那天留在库里只是不读进来**(换个更宽的窗口还能取到)、旧 `cache.json` **迁移一次**并把旧文件改名为 `.migrated`(且 `.migrated` 之后**不再被读**)、**读不出来的缓存文件被改名保留并新建一个**、**更新版本的库只读使用且绝不改名**、加载窗口必须大于快照窗口这条不变量、**增量折叠**(重启只读新追加的字节且读的字节数正好等于追加量、两天相加不翻倍、无变化时读 0、游标是日志大小而不是最早那天的戳、半行不算而补全后只算一次),**启动让出宿主**(还没折叠的挂载如实说「没折过」而不是看起来没数据、等待与工作分开计时),以及 `按年` 路由(三年以前那一年照样读得到、12 个月恒定、`daily` 带节假日标记与 provider 拆分、没有数据的年份是 12 个空月而不是报错、非法年份 400)。 |
377
421
  | `test/client.render.mjs` | 用替身 Loader 加载浏览器 bundle,检查 slot 注册形状与 `id`,再用 `react-dom/server` 渲染两个面板,断言金额格式、区间页签(含 `按年`:下拉只列出宿主报的那几年、选中的那一年被选中、月份轴恒为 12、点数是 12 × 序列数、四张卡片是那一年的合计、这一档不画热力图、没有年份时不渲染空下拉)、四序列折线的条数与配色、图例开关(单独显示某一条时的折线条数,以及面积填充有没有跟着走)、悬停 tooltip 的装配、provider 页签、热力图的格数(371 − 今天之后的那几天)与分档、官方价格表内容、两个开关(前缀回退与未匹配模型计价:开/关两种状态各自渲染出的说明与动作、default 行的来源标注、以及「哪些模型没价格」的提示)、**覆盖行编辑器**(每行的编辑/删除动作、倍率与峰谷列恒定存在、表单用被编辑行的真实数值预填而不是空模板、自定义规则才展开规则字段、新增行不给删除按钮、**兜底行同样可编辑可删除且不提供那些不生效的字段**、**重名键与「改过的行」会被保存拦下**、**旧宿主不会被说成「兜底行还没有」**、以及「保留未显示字段」与拒绝写 NaN 的源码断言)、节假日徽标与日历区块、展开/收起两种行形态、**没有用量的那天金额仍贴右**(token 槽位渲染但为空)、**弹窗那一段 CSS 里不许出现任何宿主颜色令牌(`--dsw-`),必须自带浅色/深色两套 `--dub-*` 调色板**,以及空数据 / 稀疏 provider 两个边界不崩 |
378
422
  | `test/smoke.mjs` | 对着**本机真实**的 `$DSH_HOME` 跑一遍,打印徽标金额、今日 token 构成、当前峰谷档位、节假日日历状态、最近 7 天,以及滚动热力图那 371 天的分档分布(有用量的天数与四档各多少天,都挤在一档说明这张图会显得很平) |
379
423
  | `test/official-pricing.live.mjs` | **联网**核对:抓真实页面并与 `test/fixtures/` 的快照逐字段比对,确认官方页面仍能解析、价格未变 |
@@ -824,6 +868,7 @@ storages/usage-badge/host.lock # { pid, startedAt, instanceId, hostname, at
824
868
  - **首次启动要冷扫一遍全部日志**(实测 209 份日志约 10 秒,分片让出事件循环,不会卡住宿主)。之后走增量:实测热启动 237ms、折叠 0 份。缓存文件丢失才会再次冷扫。
825
869
  - **历史依赖日志仍在盘上,但「变短」不再吃掉历史**:已删除的日志靠缓存里保留的贡献继续计入;缓存也丢了,那段历史就没了。日志**变短或还原成更早的备份**时不再删缓存里的天:折叠结果变小只会写进 `fallback` 槽位,`current`(读的时候认的那份)仍是更完整的那份。只有日志**更全**、却不再包含某一天时,那天才会被丢掉(section 8 有正反两个方向的断言)。**例外是改名**:会话身份是日志所在目录名,改名会让同一份用量各算一份。
826
870
  - **缓存只把最近 430 天装进内存**(`CACHE_WINDOW_DAYS`,比快照要的 400 天宽):更早的行留在库里,`按年` 视图能直接查它们,但**日粒度**的区间(`近 N 日`)与热力图仍只覆盖最近一年。
871
+ - **第一次折叠(或缓存被删掉之后)仍要把全部会话日志读一遍**:本机 348 MB / 338 个日志约 21 秒。它是按帧让出事件循环的(实测最长一次阻塞 203 ms),所以不会拖住宿主启动,但徽标会先显示「…」再填上数字;之后每次启动只读增量(同一台机器实测:1 个日志、2 KB、57 ms)。
827
872
  - **没有 `node:sqlite` 的运行时会退回旧的 JSON 缓存**:功能一样,但保存代价与常驻内存随历史长度增长(就是换 SQLite 之前的实测数字)。面板的 ⓘ 会写明当前用的是哪一种。
828
873
  - **云同步是手动触发的**:面板上的「立即同步」+「测试连接」是目前唯一的入口(价格表变更后的自动推送、启动后延迟一次都还没接)。数据仍然只在一台机器上直到你点它 —— 换机器、重装、或删掉缓存目录,都会丢掉「日志里已经没有」的那段历史(日志还在的部分能重新折出来)。设计见上面「云同步」一节。
829
874
  - **折叠结果是日志所在目录 × 日期**:缓存按 `(会话目录, 日期)` 存,所以把某个会话目录**改名**会让同一份用量出现两个会话,**各算一份**(实测 `¥1.5 → ¥3.0`)。行模型只能处理「同一个会话的多个副本」,管不了会话 id 本身漂移 —— 要修需要从日志 header 取稳定的会话 id。删日志、截断日志、还原旧备份这三种情况现在都不会丢历史。
package/lib/buckets.js CHANGED
@@ -297,12 +297,18 @@ export function resolveRows(records) {
297
297
  const byDate = [...entry.winners.values()].sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0))
298
298
  for (const row of byDate) days.set(row.date, deserializeDay(row.routes))
299
299
  const winner = byDate[0] ?? null
300
+ // The session's stamp is its **cursor** — the log version this device last folded it from —
301
+ // not the stamp of one of its days. A day row keeps the stamp of the fold that wrote it, and a
302
+ // fold rewrites only the days whose buckets changed, so the oldest day of a long-lived session
303
+ // carries a months-old stamp. Reading the cursor off that day made every multi-day session look
304
+ // changed on every start, which re-folded the whole log (measured: 3.6 s per start, 20 s cold).
305
+ // The day stamp is only a fallback for a record that arrived without a cursor.
300
306
  resolved.push({
301
307
  sessionId: entry.sessionId,
302
308
  path: entry.path,
303
- fileSize: winner?.fileSize ?? entry.fileSize ?? 0,
304
- fileMtimeMs: winner?.fileMtimeMs ?? entry.fileMtimeMs ?? 0,
305
- seq: winner?.seq ?? entry.seq ?? -1,
309
+ fileSize: entry.fileSize ?? winner?.fileSize ?? 0,
310
+ fileMtimeMs: entry.fileMtimeMs ?? winner?.fileMtimeMs ?? 0,
311
+ seq: entry.seq ?? winner?.seq ?? -1,
306
312
  provider: entry.provider,
307
313
  model: entry.model,
308
314
  days,
@@ -351,6 +351,9 @@ function createJsonStore({ path, log }) {
351
351
  put(record, options = {}) {
352
352
  const doc = ensureDocument()
353
353
  const at = options.at ?? Date.now()
354
+ // A tail fold carries only the days the appended bytes touched, so the days it does not
355
+ // mention are not rows the log lost — they are rows this fold never looked at.
356
+ const keepOtherDays = options.keepOtherDays === true
354
357
  const previous = documentToRecord(record.sessionId, doc.sessions[record.sessionId])
355
358
  const incoming = rowsOf(record, { source: LOCAL_SOURCE, at })
356
359
  const incomingKeys = new Set(incoming.map((row) => `${row.date}\u0000${row.source}`))
@@ -366,7 +369,7 @@ function createJsonStore({ path, log }) {
366
369
  // reaches back to it. A row older than the incoming cursor was folded from a fuller log
367
370
  // — a shorter one now, or a restored backup — and it is all that is left of those days.
368
371
  const theirs = rowSource(row) !== LOCAL_SOURCE || rowKind(row) === FALLBACK
369
- const keep = theirs || incomingKeys.has(key) || !rowIsNewerOrEqual(cursor, row)
372
+ const keep = keepOtherDays || theirs || incomingKeys.has(key) || !rowIsNewerOrEqual(cursor, row)
370
373
  if (keep) kept.push(row)
371
374
  }
372
375
  // Grouped by `(date, source, kind)`: the `current` and `fallback` copies of one date share a
@@ -793,7 +796,7 @@ function createSqliteStore({ path, jsonPath, log, DatabaseSync, db: given = null
793
796
  * - when the fold is shorter *and* drops a day, the day is rolled back rather than deleted —
794
797
  * this is what a restored backup looks like, and it is the case the whole row model exists for.
795
798
  */
796
- const writeSession = (record, { source = LOCAL_SOURCE, at = Date.now() } = {}) => {
799
+ const writeSession = (record, { source = LOCAL_SOURCE, at = Date.now(), keepOtherDays = false } = {}) => {
797
800
  writeCursor(record)
798
801
  const cursor = readCursor(record.sessionId)
799
802
  const incoming = rowsOf(record, { source, at })
@@ -817,6 +820,12 @@ function createSqliteStore({ path, jsonPath, log, DatabaseSync, db: given = null
817
820
  // log. Its `current` row goes with it — unless this log version is itself weaker than the copy
818
821
  // already stored, in which case a shortened log is treated as a rollback rather than as a
819
822
  // deletion, and the day is kept.
823
+ //
824
+ // `keepOtherDays` skips this whole step: a **tail fold** only ever looked at the appended
825
+ // bytes, so a day it does not mention is a day it never read, not one the log dropped. Without
826
+ // it, folding the last five minutes of a growing log would delete every day outside the loading
827
+ // window the record was built from.
828
+ if (keepOtherDays) return
820
829
  for (const row of stored.values()) {
821
830
  if (cursor && !rowIsNewerOrEqual(cursor, row)) continue
822
831
  deleteRow.run(record.sessionId, row.date, source, CURRENT)
@@ -1036,11 +1045,11 @@ function createSqliteStore({ path, jsonPath, log, DatabaseSync, db: given = null
1036
1045
  * short fold it just produced. Returning the resolved record is what makes "the cache kept it"
1037
1046
  * true in memory as well as on disk.
1038
1047
  */
1039
- put(record, { at = Date.now() } = {}) {
1048
+ put(record, { at = Date.now(), keepOtherDays = false } = {}) {
1040
1049
  if (!writable) return null
1041
1050
  try {
1042
1051
  db.exec('BEGIN IMMEDIATE')
1043
- writeSession(record, { source: LOCAL_SOURCE, at })
1052
+ writeSession(record, { source: LOCAL_SOURCE, at, keepOtherDays })
1044
1053
  db.exec('COMMIT')
1045
1054
  } catch (error) {
1046
1055
  try {
package/lib/client.js CHANGED
@@ -1937,6 +1937,16 @@ html[data-theme="dark"] .dub-mask{
1937
1937
  '新增一行',
1938
1938
  ),
1939
1939
  ),
1940
+ // How the last start spent its time. A slow start used to be a feeling; these are the
1941
+ // numbers behind it, so "it is slow again" can say *which* phase grew.
1942
+ config?.boot?.readyAt
1943
+ ? h(
1944
+ 'div',
1945
+ { className: 'dub-note' },
1946
+ `上次启动:共 ${config.boot.totalMs} ms —— 打开库 ${config.boot.storeMs} ms · 载入 ${config.boot.loadMs} ms · 列日志 ${config.boot.listMs} ms · 折叠 ${config.boot.foldMs} ms` +
1947
+ `(${config.boot.folded}/${config.boot.files} 个日志,读 ${Math.round((config.boot.bytesRead ?? 0) / 1024)} KB;共 ${config.boot.sessions} 个会话)`,
1948
+ )
1949
+ : null,
1940
1950
  !effective
1941
1951
  ? h('div', { className: 'dub-note' }, '正在读取当前配置…')
1942
1952
  : h(
package/lib/fold.js CHANGED
@@ -17,7 +17,7 @@
17
17
  * @module dsh-usage-badge/fold
18
18
  */
19
19
 
20
- import { readFileSync, readdirSync, statSync } from 'node:fs'
20
+ import { closeSync, openSync, readSync, 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'
@@ -57,34 +57,6 @@ export function resolveDshHome(configured, env = process.env) {
57
57
  return resolve(expandHome(chosen))
58
58
  }
59
59
 
60
- /**
61
- * Decode a log body that may hold one or more concatenated zstd frames.
62
- *
63
- * DSH appends frames as a session grows, so a `.zstd` log is a frame sequence
64
- * rather than one stream; `zstdDecompressSync` handles a single frame, so the
65
- * buffer is split on the frame magic first. A body with no frame magic is
66
- * returned as UTF-8, which keeps plain `.jsonl` and any partially written file
67
- * readable instead of throwing.
68
- */
69
- export function decodeLogBody(buf) {
70
- const starts = []
71
- let i = 0
72
- while ((i = buf.indexOf(ZSTD_MAGIC, i)) !== -1) {
73
- starts.push(i)
74
- i += 1
75
- }
76
- if (starts.length === 0) return buf.toString('utf8')
77
- if (typeof zstdDecompressSync !== 'function') {
78
- throw new Error('this Node build has no zlib.zstdDecompressSync; a zstd session log cannot be read')
79
- }
80
- let out = ''
81
- for (let k = 0; k < starts.length; k++) {
82
- const end = k + 1 < starts.length ? starts[k + 1] : buf.length
83
- out += zstdDecompressSync(buf.subarray(starts[k], end)).toString('utf8')
84
- }
85
- return out
86
- }
87
-
88
60
  function logParent(file) {
89
61
  const index = Math.max(file.lastIndexOf('\\'), file.lastIndexOf('/'))
90
62
  return index >= 0 ? file.slice(0, index) : ''
@@ -139,45 +111,129 @@ export function sessionIdFromLogPath(file) {
139
111
  }
140
112
 
141
113
  /**
142
- * Fold one session log into per-day, per-route buckets.
143
- *
144
- * Route attribution follows the log's own control events: `request/header` (and
145
- * `request/context`) carry the provider and model, and each `assistant/message`
146
- * carries the provider-reported `usage` for one completed request. Requests are
147
- * stamped with the hour they were *sent* where the log allows it, because a
148
- * streaming call can cross a peak/valley boundary and filing it by completion
149
- * would price it in the wrong band.
114
+ * Read a log's tail without reading the whole file.
150
115
  *
151
- * @returns {object|null} the session record, or null when the file is unreadable.
116
+ * The fold's cost is dominated by zstd decompression (measured on a real log: 12.7 MB took 775 ms
117
+ * to decode against 133 ms of `JSON.parse`), so re-decoding a log whose only change is an appended
118
+ * frame is the one thing worth avoiding. `from` is the byte offset the last fold consumed — which
119
+ * is exactly the `fileSize` the cursor already stores, so this needs no new field.
152
120
  */
153
- export function foldSession(file, previous) {
154
- const sessionId = sessionIdFromLogPath(file)
155
- let stat
121
+ function readTail(file, from, size) {
122
+ if (!(from > 0)) return readFileSync(file)
123
+ const length = size - from
124
+ if (!(length > 0)) return Buffer.alloc(0)
125
+ const fd = openSync(file, 'r')
156
126
  try {
157
- stat = statSync(file)
158
- } catch {
159
- return null
127
+ const buf = Buffer.allocUnsafe(length)
128
+ let read = 0
129
+ while (read < length) {
130
+ const n = readSync(fd, buf, read, length - read, from + read)
131
+ if (n <= 0) break
132
+ read += n
133
+ }
134
+ return read === length ? buf : buf.subarray(0, read)
135
+ } finally {
136
+ closeSync(fd)
160
137
  }
161
- let body
138
+ }
139
+
140
+ /** Whether a fragment is exactly one complete JSON value — a torn append is not. */
141
+ function isCompleteJsonLine(fragment) {
142
+ if (!fragment || !fragment.trim()) return false
162
143
  try {
163
- body = readFileSync(file)
144
+ JSON.parse(fragment)
145
+ return true
164
146
  } catch {
165
- return null
147
+ return false
166
148
  }
167
- let text
168
- try {
169
- text = decodeLogBody(body)
170
- } catch {
171
- // An unreadable body keeps whatever this session contributed before, so a
172
- // transient decompression failure cannot erase history.
173
- return previous ?? null
149
+ }
150
+
151
+ /**
152
+ * Split a plain-text log into newline-aligned slices.
153
+ *
154
+ * Sliced rather than handed over whole so a large `.jsonl` log cannot hold the event loop for a
155
+ * second while the rest of the host waits. The final line may have no trailing newline yet — that
156
+ * is how the last request of an idle log looks, and dropping it would under-count the badge
157
+ * exactly where eyes are — so it counts when it parses, and a torn append is left for next time.
158
+ */
159
+ function* plainChunks(text) {
160
+ const SLICE = 1 << 20
161
+ let offset = 0
162
+ while (offset < text.length) {
163
+ let end = Math.min(offset + SLICE, text.length)
164
+ if (end < text.length) {
165
+ const nl = text.lastIndexOf('\n', end - 1)
166
+ end = nl >= offset ? nl + 1 : text.length
167
+ }
168
+ let piece = text.slice(offset, end)
169
+ if (end >= text.length) {
170
+ const nl = piece.lastIndexOf('\n')
171
+ piece = piece.slice(0, nl + 1) + (isCompleteJsonLine(piece.slice(nl + 1)) ? piece.slice(nl + 1) : '')
172
+ if (!piece) return
173
+ }
174
+ yield { bytes: Buffer.byteLength(piece, 'utf8'), text: piece }
175
+ offset = end
174
176
  }
177
+ }
175
178
 
176
- const sessionDays = new Map()
177
- let provider = null
178
- let model = null
179
- let maxSeq = -1
179
+ /**
180
+ * The chunks of a body that may be folded, each carrying how many bytes it accounts for.
181
+ *
182
+ * A `.zstd` log is a sequence of independently decodable frames that DSH appends to, so "how much
183
+ * can I fold" is a question about frames, not bytes: a frame that does not decode (the appender is
184
+ * mid-write) or whose text stops mid-line is left for the next pass rather than folded half way.
185
+ * Anything that does not begin with a frame magic is plain text, consumed up to its last newline.
186
+ */
187
+ function* foldableChunks(buf) {
188
+ const starts = []
189
+ let i = 0
190
+ while ((i = buf.indexOf(ZSTD_MAGIC, i)) !== -1) {
191
+ starts.push(i)
192
+ i += 1
193
+ }
194
+ if (starts.length === 0 || starts[0] !== 0) {
195
+ yield* plainChunks(buf.toString('utf8'))
196
+ return
197
+ }
198
+ if (typeof zstdDecompressSync !== 'function') {
199
+ throw new Error('this Node build has no zlib.zstdDecompressSync; a zstd session log cannot be read')
200
+ }
201
+ for (let k = 0; k < starts.length; k++) {
202
+ const end = k + 1 < starts.length ? starts[k + 1] : buf.length
203
+ let piece
204
+ try {
205
+ piece = zstdDecompressSync(buf.subarray(starts[k], end)).toString('utf8')
206
+ } catch {
207
+ return // an unfinished frame: the next pass picks it up
208
+ }
209
+ if (!piece.endsWith('\n')) {
210
+ // Only the last frame can be the end of a log, and then only when its final line is itself
211
+ // complete JSON; anything else is a torn append.
212
+ if (end === buf.length && isCompleteJsonLine(piece.slice(piece.lastIndexOf('\n') + 1))) {
213
+ yield { bytes: end - starts[k], text: piece }
214
+ }
215
+ return
216
+ }
217
+ yield { bytes: end - starts[k], text: piece }
218
+ }
219
+ }
220
+
221
+ /** A bucket copied deep enough that a fold can add to it without touching the stored record. */
222
+ function cloneBucket(bucket) {
223
+ return {
224
+ ...bucket,
225
+ hourly: Array.isArray(bucket.hourly) ? bucket.hourly.map((hour) => ({ ...hour })) : bucket.hourly,
226
+ usageRecords: Array.isArray(bucket.usageRecords) ? bucket.usageRecords.map((usage) => ({ ...usage })) : bucket.usageRecords,
227
+ }
228
+ }
180
229
 
230
+ /**
231
+ * Add one decoded chunk's events to the fold state.
232
+ *
233
+ * Events land in `state.fresh` rather than in the day maps the previous record handed over, so a
234
+ * tail fold that has to be abandoned leaves that record exactly as it was.
235
+ */
236
+ function foldText(text, state) {
181
237
  for (const line of text.split('\n')) {
182
238
  if (!line) continue
183
239
  let ev
@@ -187,25 +243,26 @@ export function foldSession(file, previous) {
187
243
  continue // a torn tail line is expected on a log being appended
188
244
  }
189
245
  const seq = typeof ev?.seq === 'number' ? ev.seq : -1
190
- if (seq > maxSeq) maxSeq = seq
246
+ if (seq > state.seq) state.seq = seq
191
247
  const data = ev?.data ?? {}
192
248
  if (ev?.type === 'request/header') {
193
249
  const config = data.header?.config ?? {}
194
- provider = config.provider ?? data.provider ?? null
195
- model = config.model ?? data.model ?? null
250
+ state.provider = config.provider ?? data.provider ?? null
251
+ state.model = config.model ?? data.model ?? null
196
252
  } else if (ev?.type === 'request/context') {
197
- provider = data.provider ?? provider
198
- model = data.model ?? model
253
+ state.provider = data.provider ?? state.provider
254
+ state.model = data.model ?? state.model
199
255
  } else if (ev?.type === 'assistant/message') {
200
256
  const usage = data.usage
201
257
  if (!usage || typeof usage !== 'object') continue
202
- const pm = provider ?? UNKNOWN
203
- const mm = model ?? UNKNOWN
258
+ const pm = state.provider ?? UNKNOWN
259
+ const mm = state.model ?? UNKNOWN
204
260
  const date = dayKey(ev.time ?? Date.now())
205
- let dayObj = sessionDays.get(date)
261
+ state.dates.add(date)
262
+ let dayObj = state.fresh.get(date)
206
263
  if (!dayObj) {
207
264
  dayObj = new Map()
208
- sessionDays.set(date, dayObj)
265
+ state.fresh.set(date, dayObj)
209
266
  }
210
267
  const key = `${pm}|${mm}`
211
268
  const bucket = dayObj.get(key) ?? emptyBucket(pm, mm)
@@ -231,16 +288,115 @@ export function foldSession(file, previous) {
231
288
  dayObj.set(key, bucket)
232
289
  }
233
290
  }
291
+ }
292
+
293
+ /** Commit this pass's own buckets into the record's days, cloning what it shares with the old one. */
294
+ function mergeFresh(state) {
295
+ for (const [date, dayObj] of state.fresh) {
296
+ const existing = state.days.get(date)
297
+ const target = existing ? new Map(existing) : new Map()
298
+ for (const [key, bucket] of dayObj) {
299
+ const known = target.get(key)
300
+ if (!known) {
301
+ target.set(key, bucket)
302
+ continue
303
+ }
304
+ const merged = cloneBucket(known)
305
+ addBucket(merged, bucket)
306
+ target.set(key, merged)
307
+ }
308
+ state.days.set(date, target)
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Fold one session log into per-day, per-route buckets.
314
+ *
315
+ * Route attribution follows the log's own control events: `request/header` (and
316
+ * `request/context`) carry the provider and model, and each `assistant/message`
317
+ * carries the provider-reported `usage` for one completed request. Requests are
318
+ * stamped with the hour they were *sent* where the log allows it, because a
319
+ * streaming call can cross a peak/valley boundary and filing it by completion
320
+ * would price it in the wrong band.
321
+ *
322
+ * **Only the new bytes are decoded.** `fromByte` is where the previous fold stopped (the cursor's
323
+ * `fileSize`), and the tail is decoded on its own and merged into `previous`: re-decoding the whole
324
+ * log for one appended frame is what made a start with 25 grown logs cost 3.6 s and a cold cache
325
+ * 20 s. The returned `fileSize` is the offset this pass actually reached — a torn tail stays
326
+ * unfolded and is retried next time for the price of the few bytes it holds.
327
+ *
328
+ * @param {string} file
329
+ * @param {object|null} previous - the record this session already has; required for a tail fold.
330
+ * @param {{fromByte?:number, yieldBytes?:number}} [options]
331
+ * @returns {Promise<object|null>} the session record, or null when the file is unreadable.
332
+ */
333
+ export async function foldSession(file, previous = null, { fromByte = 0, yieldBytes = 1 << 20 } = {}) {
334
+ const sessionId = sessionIdFromLogPath(file)
335
+ let stat
336
+ try {
337
+ stat = statSync(file)
338
+ } catch {
339
+ return null
340
+ }
341
+ const tail = fromByte > 0 && fromByte <= stat.size
342
+ let body
343
+ try {
344
+ body = readTail(file, tail ? fromByte : 0, stat.size)
345
+ } catch {
346
+ return null
347
+ }
348
+
349
+ const state = {
350
+ // A copy of the map, one level deep: `mergeFresh` replaces whole day entries and cloned
351
+ // buckets, so an abandoned tail fold cannot leave a mark on the record this session already has.
352
+ days: tail && previous?.days ? new Map(previous.days) : new Map(),
353
+ fresh: new Map(),
354
+ dates: new Set(),
355
+ provider: tail ? previous?.provider ?? null : null,
356
+ model: tail ? previous?.model ?? null : null,
357
+ seq: tail ? previous?.seq ?? -1 : -1,
358
+ }
359
+ // `body` holds the tail alone, so offsets inside it are buffer-relative; `consumed` is reported
360
+ // in file coordinates, which is what the cursor stores.
361
+ const shift = tail ? fromByte : 0
362
+ let consumed = shift
363
+ let sinceYield = 0
364
+ try {
365
+ for (const chunk of foldableChunks(body)) {
366
+ foldText(chunk.text, state)
367
+ consumed += chunk.bytes
368
+ // Yield on **bytes decoded**, not on a count of frames: a 12 MB log is ~34 000 frames, so
369
+ // yielding per few frames cost more in `setImmediate` round-trips than the decoding did
370
+ // (measured: a 338-log cold fold went from 21 s to 52 s). One megabyte is ~30 ms of zstd
371
+ // work — small enough that the host keeps breathing, rare enough to be free.
372
+ sinceYield += chunk.bytes
373
+ if (sinceYield >= yieldBytes) {
374
+ sinceYield = 0
375
+ await new Promise((resolve) => setImmediate(resolve))
376
+ }
377
+ }
378
+ } catch {
379
+ // An unreadable body keeps whatever this session contributed before, so a
380
+ // transient decompression failure cannot erase history.
381
+ return previous ?? null
382
+ }
383
+ mergeFresh(state)
234
384
 
235
385
  return {
236
386
  sessionId,
237
387
  path: file,
238
- fileSize: stat.size,
388
+ fileSize: consumed,
239
389
  fileMtimeMs: stat.mtimeMs || 0,
240
- seq: maxSeq,
241
- provider,
242
- model,
243
- days: sessionDays,
390
+ seq: state.seq,
391
+ provider: state.provider,
392
+ model: state.model,
393
+ days: state.days,
394
+ // Which days this pass itself added. A tail fold may add a day (a session running past
395
+ // midnight does exactly that), but it may never add one *older* than the newest day the record
396
+ // already holds: an append only moves forward, so an older day means the log was rewritten
397
+ // rather than appended to, and the caller re-folds the session from the start.
398
+ touchedDates: [...state.dates],
399
+ foldedBytes: consumed,
244
400
  }
245
401
  }
246
402
 
@@ -288,6 +444,15 @@ export function createAggregator({ sessionsRoot, store, log = () => {} }) {
288
444
  const cursors = new Map()
289
445
  let days = new Map()
290
446
  let lastRefresh = null
447
+ /**
448
+ * Whether the stored history has been read into this process yet.
449
+ *
450
+ * `loadCache()` is deliberately **not** called when the plugin mounts: mounting happens inside DSH's
451
+ * own startup, and reading 300+ sessions' payloads there put forty-odd milliseconds (more on a cold
452
+ * disk, or under a virus scanner) on the host's boot path for numbers nothing has asked for yet.
453
+ * The first `refresh()` — which is what the badge's first request runs — does it instead.
454
+ */
455
+ let loadedOnce = false
291
456
 
292
457
  function mergeDays() {
293
458
  days = mergeRecords(records.values())
@@ -331,6 +496,7 @@ export function createAggregator({ sessionsRoot, store, log = () => {} }) {
331
496
  }
332
497
  mergeDays()
333
498
  if (!quiet) log(`cache loaded: ${records.size} session(s) from ${store.kind}`)
499
+ loadedOnce = true
334
500
  return records.size > 0
335
501
  }
336
502
 
@@ -351,12 +517,23 @@ export function createAggregator({ sessionsRoot, store, log = () => {} }) {
351
517
  * reason the store is not a single document, and it means there is no save timer to lose
352
518
  * work in and no moment where a burst of edits has to be batched.
353
519
  *
354
- * @returns {Promise<{files:number, folded:number, ms:number}>} what this pass did.
520
+ * @returns {Promise<{files:number, folded:number, tailed:number, bytesRead:number, loadMs:number, listMs:number, ms:number}>} what this pass did.
355
521
  */
356
522
  async function refresh() {
357
523
  const started = Date.now()
524
+ let loadMs = 0
525
+ if (!loadedOnce) {
526
+ // The first pass of a process is where the stored history comes in — see `loadedOnce`.
527
+ const beforeLoad = Date.now()
528
+ loadCache()
529
+ loadMs = Date.now() - beforeLoad
530
+ }
531
+ const beforeList = Date.now()
358
532
  const files = listLogFiles(sessionsRoot)
533
+ const listMs = Date.now() - beforeList
359
534
  let folded = 0
535
+ let tailed = 0
536
+ let bytesRead = 0
360
537
  let sinceYield = 0
361
538
  for (const file of files) {
362
539
  const sessionId = sessionIdFromLogPath(file)
@@ -371,7 +548,35 @@ export function createAggregator({ sessionsRoot, store, log = () => {} }) {
371
548
  continue
372
549
  }
373
550
  const previous = records.get(sessionId)
374
- const record = foldSession(file, previous)
551
+ // The cursor's `fileSize` is the byte offset the last fold consumed, so a log that only grew
552
+ // is folded from there. Everything else — a shorter log, a different path, no cursor, no
553
+ // record to fold into — is a full re-fold, because a tail read assumes the prefix is intact.
554
+ const tailFrom =
555
+ previous && cursor && cursor.path === file && cursor.fileSize > 0 && stat.size > cursor.fileSize
556
+ ? cursor.fileSize
557
+ : 0
558
+ let record = await foldSession(file, tailFrom > 0 ? previous : null, { fromByte: tailFrom })
559
+ let tail = tailFrom > 0 && Boolean(record)
560
+ if (tail && record !== previous) {
561
+ // A tail fold may add a day — a session running past midnight does exactly that — but the
562
+ // days it adds cannot be *older* than the newest one already folded: an append only moves
563
+ // forward. An older day means the file was rewritten in place, so the prefix this pass did
564
+ // not look at is not the prefix the record was built from; fold that session from the start.
565
+ let newest = ''
566
+ for (const date of previous.days?.keys() ?? []) if (date > newest) newest = date
567
+ const stale = newest !== '' && record.touchedDates?.some((date) => date < newest)
568
+ if (stale) {
569
+ record = await foldSession(file, previous, {})
570
+ tail = false
571
+ }
572
+ } else if (!record) {
573
+ tail = false
574
+ }
575
+ if (record) {
576
+ bytesRead += tail && tailFrom > 0 ? Math.max(0, (record.fileSize ?? 0) - tailFrom) : (record.fileSize ?? 0)
577
+ delete record.touchedDates
578
+ delete record.foldedBytes
579
+ }
375
580
  // `foldSession` hands the previous record straight back when it could not read the log, and
376
581
  // that record is already in the store — writing it again would replace the session's rows
377
582
  // with only the days this process happens to hold (the loading window), deleting the older
@@ -379,6 +584,7 @@ export function createAggregator({ sessionsRoot, store, log = () => {} }) {
379
584
  // fold, which is what a transient read failure wants.
380
585
  if (record && record !== previous) {
381
586
  folded++
587
+ if (tail) tailed++
382
588
  // Written back one session at a time, right here: a save costs the changed session's
383
589
  // days, not the whole history, so there is nothing to batch and no timer to lose it in.
384
590
  //
@@ -386,9 +592,13 @@ export function createAggregator({ sessionsRoot, store, log = () => {} }) {
386
592
  // the fold just produced, but when the log got shorter the store keeps the fuller copy
387
593
  // it already had — and then *that* is the truth, because the whole point of the row
388
594
  // model is that a shortened log cannot make history disappear.
595
+ //
596
+ // `keepOtherDays` is what makes a tail fold safe to write: the record it carries covers
597
+ // the loading window, not the session's whole history, so the days outside the window are
598
+ // this process's blind spot rather than rows the log no longer has.
389
599
  let effective = record
390
600
  try {
391
- effective = store.put(record, { source: LOCAL_SOURCE }) ?? record
601
+ effective = store.put(record, { source: LOCAL_SOURCE, keepOtherDays: tail }) ?? record
392
602
  } catch (error) {
393
603
  log(`cache write failed for ${sessionId}: ${error?.message ?? error}`)
394
604
  }
@@ -411,7 +621,7 @@ export function createAggregator({ sessionsRoot, store, log = () => {} }) {
411
621
  // being refreshed — and, because nothing writes it again, that history stays in the store
412
622
  // for good rather than being erased by the log's absence.
413
623
  if (folded > 0) mergeDays()
414
- lastRefresh = { files: files.length, folded, ms: Date.now() - started, at: Date.now() }
624
+ lastRefresh = { files: files.length, folded, tailed, bytesRead, loadMs, listMs, ms: Date.now() - started, at: Date.now() }
415
625
  return lastRefresh
416
626
  }
417
627
 
package/lib/index.js CHANGED
@@ -87,6 +87,16 @@ const SNAPSHOT_TTL_MS = 2000
87
87
  */
88
88
  export const PRICING_PUSH_DELAY_MS = 3000
89
89
 
90
+ /**
91
+ * How long the plugin waits before warming itself up when nobody has asked for numbers.
92
+ *
93
+ * The **first request** is what really starts the work (see `warmOnce`), and that request cannot
94
+ * arrive before the harness is up — so this timer is only the fallback for a host nobody is looking
95
+ * at. It is deliberately generous: mounting happens inside DSH's own startup, and nothing the plugin
96
+ * does belongs on that path.
97
+ */
98
+ export const STARTUP_WARM_DELAY_MS = 3000
99
+
90
100
  /** How long after startup the first automatic sync runs, when sync is enabled. */
91
101
  export const STARTUP_SYNC_DELAY_MS = 8000
92
102
 
@@ -586,9 +596,46 @@ export function apply(ctx) {
586
596
  // The folded history lives in whichever store this runtime can offer — SQLite when
587
597
  // `node:sqlite` is there, the original single-document JSON otherwise. Either way it is one
588
598
  // file inside the plugin's own directory.
589
- const store = createCacheStore({ dir: dataDir, log })
590
- const aggregator = createAggregator({ sessionsRoot, store, log })
591
- aggregator.loadCache()
599
+ //
600
+ // **Nothing is opened here.** `apply()` runs inside DSH's own startup, and a plugin must not put
601
+ // its I/O on that path: opening the database, reading the price table and parsing the stored
602
+ // history all happen on first use — which in practice is the interface's first request, long after
603
+ // the harness is up. `boot` records how long each phase took, so a slow start names its culprit.
604
+ let opened = null
605
+ function ensureOpen() {
606
+ if (opened) return opened
607
+ const storeOpenStarted = Date.now()
608
+ const store = createCacheStore({ dir: dataDir, log })
609
+ const storeMs = Date.now() - storeOpenStarted
610
+ const aggregator = createAggregator({ sessionsRoot, store, log })
611
+ // The price table is read here rather than at mount for the same reason; `refreshOnce()` re-reads
612
+ // it on every pass, so an edit still applies without a restart.
613
+ const { path: pricingSourcePath } = readRawPricing(pricingPaths)
614
+ if (pricingSourcePath === legacyPricingPath) {
615
+ log(`reading the price table from the pre-isolation path ${legacyPricingPath}; the next write moves it to ${pricingPath}`)
616
+ }
617
+ pricing = readPricing(pricingPaths)
618
+ rules = resolveHolidayRules(pricing.holidays)
619
+ opened = { store, aggregator }
620
+ boot.storeMs = storeMs
621
+ return opened
622
+ }
623
+ const boot = {
624
+ mountedAt: Date.now(),
625
+ at: 0,
626
+ waitedMs: 0,
627
+ storeMs: 0,
628
+ loadMs: 0,
629
+ listMs: 0,
630
+ foldMs: 0,
631
+ totalMs: 0,
632
+ files: 0,
633
+ sessions: 0,
634
+ folded: 0,
635
+ tailed: 0,
636
+ bytesRead: 0,
637
+ readyAt: 0,
638
+ }
592
639
 
593
640
  /**
594
641
  * The sync system, created **on first use** rather than when the plugin mounts.
@@ -611,7 +658,7 @@ export function apply(ctx) {
611
658
  if (!lock.acquired) log(`usage-badge is read-only here: ${lock.reason}`)
612
659
  const engine = new SyncEngine({
613
660
  dataDir,
614
- store: lock.acquired ? store : null,
661
+ store: lock.acquired ? ensureOpen().store : null,
615
662
  log,
616
663
  config: document,
617
664
  state: document.state,
@@ -641,19 +688,20 @@ export function apply(ctx) {
641
688
  return system
642
689
  }
643
690
 
644
- const { path: pricingSourcePath } = readRawPricing(pricingPaths)
645
- if (pricingSourcePath === legacyPricingPath) {
646
- log(`reading the price table from the pre-isolation path ${legacyPricingPath}; the next write moves it to ${pricingPath}`)
647
- }
648
- let pricing = readPricing(pricingPaths)
649
- let rules = resolveHolidayRules(pricing.holidays)
691
+ let pricing = null
692
+ // A "nothing on" default so anything that reads `rules` before the first load (a log line, say)
693
+ // cannot trip over it; `ensureOpen()` replaces it with the real rules.
694
+ let rules = resolveHolidayRules(undefined)
650
695
  let snapshot = null
651
696
  let inFlight = null
652
697
  let official = null
698
+ /** The status of the warm-up pass, until the first snapshot has reported it. */
699
+ let firstStatus = null
653
700
 
654
701
  /** Serialize concurrent refreshes so a burst of polls folds the logs once. */
655
702
  function refreshOnce() {
656
703
  if (!inFlight) {
704
+ const { aggregator } = ensureOpen()
657
705
  inFlight = (async () => {
658
706
  const status = await aggregator.refresh()
659
707
  // Re-read pricing every pass so an edit applies without a restart.
@@ -871,7 +919,7 @@ export function apply(ctx) {
871
919
  // this reload is for) and the log says why, but the run is still what it was. `loadCache`
872
920
  // already swallows a store it cannot read; this covers everything else.
873
921
  try {
874
- aggregator.loadCache({ quiet: true })
922
+ ensureOpen().aggregator.loadCache({ quiet: true })
875
923
  } catch (error) {
876
924
  log(`the folded view could not be reloaded after the sync: ${error?.message ?? error}`)
877
925
  }
@@ -906,7 +954,11 @@ export function apply(ctx) {
906
954
  }
907
955
 
908
956
  async function getSnapshot() {
909
- const status = await refreshOnce()
957
+ const { store, aggregator } = ensureOpen()
958
+ // The first request reports the fold it caused, rather than immediately re-checking and reporting
959
+ // "nothing changed" — the numbers the caller is about to see came from that pass.
960
+ const status = firstStatus ?? (await refreshOnce())
961
+ firstStatus = null
910
962
  // Nothing changed and the previous payload is still fresh: reuse it rather
911
963
  // than re-costing every day on each poll.
912
964
  if (snapshot && status.folded === 0 && Date.now() - snapshot.generatedAt < SNAPSHOT_TTL_MS) return snapshot
@@ -1060,23 +1112,64 @@ export function apply(ctx) {
1060
1112
  log(`exit sync: ${instance.last?.result ?? outcome} — ${instance.last?.message ?? ''}`)
1061
1113
  }
1062
1114
 
1063
- async function warm() {
1064
- try {
1065
- const status = await refreshOnce()
1066
- // The SQLite store has already committed every session it folded; the JSON fallback
1067
- // batches, and a short-lived host would otherwise pay the full cold scan on every start.
1068
- aggregator.flush()
1069
- log(
1070
- `ready: ${aggregator.sessionCount} session(s) over ${status.files} log(s), folded ${status.folded} in ${status.ms}ms` +
1071
- `; cache: ${store.kind}` +
1072
- (rules.enabled ? `; holiday calendar on (${rules.holidays.size} holidays)` : ''),
1073
- )
1074
- if (rules.missingYears?.length) {
1075
- log(`holiday calendar has no data for ${rules.missingYears.join(', ')} — those days are treated as ordinary weekdays`)
1076
- }
1077
- } catch (error) {
1078
- log(`initial fold failed: ${error?.message ?? error}`)
1115
+ /**
1116
+ * The one warm-up pass: open, load the stored history, fold what changed, record the phases.
1117
+ *
1118
+ * It runs on the **first request**, not at mount. Mounting happens inside DSH's own startup and the
1119
+ * plugin has no business doing I/O there — so nothing is opened until something actually asks for
1120
+ * numbers, which is the interface's first poll, long after the harness is up. A fallback timer
1121
+ * (`DSH_USAGE_BADGE_WARM_MS`) covers a host nobody is looking at; whichever happens first wins, and
1122
+ * this is memoised so a burst of polls still only warms once.
1123
+ */
1124
+ let warmPromise = null
1125
+ function warmOnce() {
1126
+ if (warmPromise) return warmPromise
1127
+ if (!boot.at) {
1128
+ boot.at = Date.now()
1129
+ boot.waitedMs = boot.at - boot.mountedAt
1079
1130
  }
1131
+ warmPromise = (async () => {
1132
+ const { store, aggregator } = ensureOpen()
1133
+ try {
1134
+ const status = await refreshOnce()
1135
+ firstStatus = status
1136
+ // The SQLite store has already committed every session it folded; the JSON fallback
1137
+ // batches, and a short-lived host would otherwise pay the full cold scan on every start.
1138
+ aggregator.flush()
1139
+ const passMs = status.ms ?? 0
1140
+ const loadMs = status.loadMs ?? 0
1141
+ const listMs = status.listMs ?? 0
1142
+ Object.assign(boot, {
1143
+ loadMs,
1144
+ listMs,
1145
+ passMs,
1146
+ // What is left of the pass once loading and listing are accounted for: the folding itself.
1147
+ foldMs: Math.max(0, passMs - loadMs - listMs),
1148
+ totalMs: Date.now() - boot.at,
1149
+ files: status.files ?? 0,
1150
+ sessions: aggregator.sessionCount,
1151
+ folded: status.folded ?? 0,
1152
+ tailed: status.tailed ?? 0,
1153
+ bytesRead: status.bytesRead ?? 0,
1154
+ readyAt: Date.now(),
1155
+ })
1156
+ log(
1157
+ `ready in ${boot.totalMs}ms (${boot.waitedMs}ms after mount) — open ${boot.storeMs}ms, load ${boot.loadMs}ms,` +
1158
+ ` list ${boot.listMs}ms, fold ${boot.foldMs}ms;` +
1159
+ ` ${boot.sessions} session(s) over ${boot.files} log(s), ${boot.folded} folded` +
1160
+ ` (${boot.tailed} from the tail only, ${Math.round(boot.bytesRead / 1024)} KB read)` +
1161
+ `; cache: ${store.kind}` +
1162
+ (rules.enabled ? `; holiday calendar on (${rules.holidays.size} holidays)` : ''),
1163
+ )
1164
+ if (rules.missingYears?.length) {
1165
+ log(`holiday calendar has no data for ${rules.missingYears.join(', ')} — those days are treated as ordinary weekdays`)
1166
+ }
1167
+ } catch (error) {
1168
+ log(`initial fold failed: ${error?.message ?? error}`)
1169
+ }
1170
+ return boot
1171
+ })()
1172
+ return warmPromise
1080
1173
  }
1081
1174
 
1082
1175
  async function handle(req, res) {
@@ -1084,7 +1177,12 @@ export function apply(ctx) {
1084
1177
  const route = url.pathname.slice(ROUTE_PREFIX.length) || '/'
1085
1178
 
1086
1179
  try {
1180
+ // Every route needs the store and the price table; only the data routes additionally need the
1181
+ // logs folded, so the fold waits for those (see `warmOnce`).
1182
+ ensureOpen()
1087
1183
  if (route === '/summary' && (req.method === 'GET' || req.method === 'HEAD')) {
1184
+ // The interface's first poll is what starts the plugin's work: see `warmOnce`.
1185
+ await warmOnce()
1088
1186
  sendJson(res, 200, await getSnapshot())
1089
1187
  return
1090
1188
  }
@@ -1098,6 +1196,13 @@ export function apply(ctx) {
1098
1196
  sendJson(res, 400, { error: `year must be a four-digit calendar year, got ${JSON.stringify(year)}` })
1099
1197
  return
1100
1198
  }
1199
+ // A year older than the loading window is only in the store, so this route needs the logs
1200
+ // folded first — same warm-up the badge's own first request runs.
1201
+ await warmOnce()
1202
+ // This route never reports the warm pass, so do not let it be consumed by the next /summary:
1203
+ // that one should judge the logs as they are when it asks.
1204
+ firstStatus = null
1205
+ const { store } = ensureOpen()
1101
1206
  const records = store.load({ since: `${year}-01-01`, until: `${year}-12-31` })
1102
1207
  const dayRows = mergeRecords(records)
1103
1208
  sendJson(res, 200, summarizeYear(dayRows, pricing, rules, year))
@@ -1105,6 +1210,7 @@ export function apply(ctx) {
1105
1210
  }
1106
1211
 
1107
1212
  if (route === '/config' && req.method === 'GET') {
1213
+ const { store } = ensureOpen()
1108
1214
  const calendar = describeCalendar(rules)
1109
1215
  // The bundled table is reported separately from the resolved rules: a
1110
1216
  // deployment with the calendar off still needs to see what data is
@@ -1160,6 +1266,9 @@ export function apply(ctx) {
1160
1266
  // neither exists, so the built-in template is in force.
1161
1267
  pricingSource: raw.path,
1162
1268
  },
1269
+ // How the last start spent its time. A slow start is then attributable instead of a
1270
+ // feeling: the panel shows this line, and the same numbers go to the log.
1271
+ boot: { ...boot },
1163
1272
  })
1164
1273
  return
1165
1274
  }
@@ -1367,16 +1476,40 @@ export function apply(ctx) {
1367
1476
  // asynchronous, and a value read after it would be whatever the environment holds by then — which a
1368
1477
  // test that sets an override for exactly this mount would already have taken away.
1369
1478
  const startupDelay = delayFor('DSH_USAGE_BADGE_STARTUP_SYNC_MS', STARTUP_SYNC_DELAY_MS)
1479
+ // Same reasoning for the fallback warm-up: read the knob now, not inside the timer callback.
1480
+ const warmDelay = delayFor('DSH_USAGE_BADGE_WARM_MS', STARTUP_WARM_DELAY_MS)
1370
1481
  // Unloading before the first fold finishes used to schedule the first sync *after* the unload: the
1371
1482
  // callback ran anyway, took the lock back, and synced through a store that had already been closed
1372
1483
  // (`statement has been finalized`), leaving a lock file behind for a host that was gone.
1373
1484
  let disposed = false
1374
- void warm().then(() => {
1485
+ let warmTimer = null
1486
+ /**
1487
+ * Everything the plugin does automatically, in one place: warm the fold, then start the automatic
1488
+ * sync and the remote-device read.
1489
+ *
1490
+ * The fold itself is **not** what this timer is for — the interface's first request does that
1491
+ * (`warmOnce`), and that request cannot arrive before the harness is up. This is only the
1492
+ * fallback for a host nobody is looking at, which is why it waits a moment: nothing here belongs
1493
+ * in DSH's boot path. A zero delay means "warm on mount", which is what a test wants.
1494
+ */
1495
+ let scheduled = false
1496
+ const begin = () => {
1497
+ warmTimer = null
1375
1498
  if (disposed) return
1376
- scheduleStartupSync(startupDelay)
1377
- // Fire and forget: this only fills in what the server holds, for the panel's next read.
1378
- void refreshRemoteDevices()
1379
- })
1499
+ void warmOnce().then(() => {
1500
+ if (disposed) return
1501
+ if (!scheduled) {
1502
+ scheduled = true
1503
+ scheduleStartupSync(startupDelay)
1504
+ // Fire and forget: this only fills in what the server holds, for the panel's next read.
1505
+ void refreshRemoteDevices()
1506
+ }
1507
+ })
1508
+ }
1509
+ if (warmDelay > 0) {
1510
+ warmTimer = setTimeout(begin, warmDelay)
1511
+ warmTimer.unref?.()
1512
+ } else begin()
1380
1513
  // The disposer is **async on purpose**, and everything below depends on the order:
1381
1514
  //
1382
1515
  // 1. stop automatic work that would fire into a process that is going away;
@@ -1390,13 +1523,18 @@ export function apply(ctx) {
1390
1523
  disposed = true
1391
1524
  // Any pending automatic work is dropped on unload: a push that fires while the host is going away
1392
1525
  // would write state for a process that no longer exists.
1526
+ if (warmTimer) clearTimeout(warmTimer)
1393
1527
  if (pricingPushTimer) clearTimeout(pricingPushTimer)
1394
1528
  if (startupSyncTimer) clearTimeout(startupSyncTimer)
1395
1529
  if (system?.running) log('unloading while a sync is in flight; it stops at its next step boundary')
1396
- try {
1397
- await syncOnExit()
1398
- } catch (error) {
1399
- log(`exit sync failed: ${error?.message ?? error}`)
1530
+ // A host that was never asked for numbers never opened anything: there is no store to close, no
1531
+ // lock to hand back, and nothing worth publishing on the way out.
1532
+ if (opened) {
1533
+ try {
1534
+ await syncOnExit()
1535
+ } catch (error) {
1536
+ log(`exit sync failed: ${error?.message ?? error}`)
1537
+ }
1400
1538
  }
1401
1539
  // **And the lock is handed back.** Not releasing it leaves `host.lock` naming this dead process, and a
1402
1540
  // re-mount inside the same process — a hot reload, or a test mounting the same home twice — is then
@@ -1412,10 +1550,13 @@ export function apply(ctx) {
1412
1550
  system.engine.abort()
1413
1551
  system.engine.release()
1414
1552
  }
1553
+ // Unconditional, and `releaseLock` itself only removes a lock whose pid is this process: a lock left
1554
+ // behind by an earlier mount of this same process (a hot reload) is ours to clean up even when this
1555
+ // mount never created a sync system.
1415
1556
  releaseLock(dataDir)
1416
- aggregator.close()
1557
+ if (opened) opened.aggregator.close()
1417
1558
  }
1418
- }, 'usage-badge: warm the fold, schedule the first sync, and close the cache on unload')
1559
+ }, 'usage-badge: warm the fold on first use, schedule the first sync, and close the cache on unload')
1419
1560
 
1420
1561
  log(`mounted on ${ROUTE_PREFIX} (home: ${home}; data: ${dataDir})`)
1421
1562
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-usage-badge",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "DeepSeek Harness 的用量徽标插件:侧边栏底部一行显示当天的 token 总量与估算费用,点开可看 24 小时 / 近 7 日 / 近 30 日 / 近 12 个月 / 按年用量,按 provider 筛选,并按 DeepSeek 官方定价与中国法定节假日计价。A usage badge plugin for DeepSeek Harness: today's tokens and estimated spend in the sidebar, with 24h / 7d / 30d / 12-month and per-year charts, official DeepSeek pricing and Chinese public holidays.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",