dsh-usage-badge 0.1.10 → 0.1.12

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」。
87
87
  - **历史依赖日志,但日志变短不再吃掉历史**:日志被删掉后靠缓存继续计入;缓存也丢了,那段历史就没了。同一个会话的日志**变短或还原成更早的备份**时,缓存会**把更完整的那份留着**(折叠结果变小就只作为备份副本存下,统计仍用完整的那份),要等日志真的更全、却不再包含某一天,那天才会被丢掉。**例外是改名**:会话是按「日志所在目录」识别的,把某个会话目录改名会让同一份用量各算一份(见下)。DSH 自己从不裁日志:格式升级时它另写一代新文件、旧代保留,而实测换代后统计结果逐项相同。
88
88
  - **日粒度区间仍只覆盖最近一年**:`近 7 日` / `近 30 日` / `近 12 个月` 与它们下面的滚动热力图都基于快照的最近 400 天。要看更早的日子,用 **`按年`**:那一档的月度合计、年度总额与**自然年热力图**都是直接查缓存库的,任何一年都在。
89
89
  - **只统计 DSH 记进日志的调用**:某个东西直接请求外部 API 且不向 DSH 上报 usage,这部分统计不到。
@@ -97,7 +97,7 @@ dsh plugin --profile web remove dsh-usage-badge
97
97
 
98
98
  ```sh
99
99
  npm install # 只为测试装 react / react-dom
100
- npm test # 11 个套件 908 条断言:包契约、定价、节假日、官方定价解析、缓存存储、同步分片、同步编排、宿主同步接线、自动触发、WebDAV(Digest)、浏览器半渲染
100
+ npm test # 11 个套件 911 条断言:包契约、定价、节假日、官方定价解析、缓存存储、同步分片、同步编排、宿主同步接线、自动触发、WebDAV(Digest)、浏览器半渲染
101
101
  ```
102
102
 
103
103
  云同步的前五步已经在真实的 `bytemark/webdav` 容器上核对通过。要自己核对一遍:
@@ -53,7 +53,7 @@ dsh plugin --profile web remove dsh-usage-badge
53
53
  - **「今日用量」** 标签
54
54
  - **峰谷圆点**:谷时灰色、峰时橙色;悬停圆点本身会显示 `峰时 ×2` / `谷时 ×1` / `国庆节 · 全天空闲`。橙色用静态色 `--dsw-static-amber-500`,**不要借 `--dsw-alias-brand-primary`** —— 那个 token 在本设计系统里是**前景墨色**(DSH 自己只用它做文字、描边、开关轨道):浅色主题解析成 `--dsw-static-neutral-bluish-1000`(`#0f1115`,近黑),深色主题解析成 near-white。借了它的后果是峰时圆点变成一个看不出含义的深色点,`峰时 ×2` 标签变成白底白字,主按钮(白字填色)在深色主题下文字直接消失 —— 三处都已改成显式颜色(`--dsw-static-amber-500` 与 `--dsw-static-deepseek-500`),并由 `test/client.render.mjs` 里一条「墨色 token 不得作白字填充背景」的断言钉住。
55
55
  - **当日 token 总量**(输入 + 缓存 + 输出)
56
- - **当日估算金额**
56
+ - **当日估算金额**:始终贴右边缘。撑开右边距的是 token 那一段的 `margin-left:auto`,所以**它在没有用量的日子也照旧渲染**(内容为空、宽度为 0,但空的 flex 项照样吃掉自由空间)。少了它,金额就退回贴着标签左对齐,同一行出现两套对齐 —— `test/client.render.mjs` 三条断言钉住,金额自己的 `margin-left` 必须保持固定值(两个 `auto` 会平分自由空间,把 token 数挤到行中间)。
57
57
 
58
58
  整行每 5 秒刷新,悬停显示完整明细(金额 / token / 请求数 / 当前档位)。点击打开两个面板:
59
59
 
@@ -327,6 +327,32 @@ 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
+
330
356
  ### `按年` 视图:年份列表属于数据,不属于代码
331
357
 
332
358
  历史不删,就迟早要看更早的年。这一年视图有三条决定:
@@ -373,8 +399,8 @@ npm test
373
399
  | `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
400
  | `test/holidays.verify.mjs` | 节假日日历的行为:工作日节假日全天空闲、**调休周末仍按空闲价**(政策原文说周末全天空闲)、旧配置里的 `holidays.workdays` 被忽略、关闭日历时回到旧的按星期行为、自定义 `extra`、同一个月内不同日子的分类互不干扰,以及**2027 年的单日端午/中秋**(同一天 10:30,节当天 x1、旁边的周三 x2)与 2027-02-14 调休周日 |
375
401
  | `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)。 |
377
- | `test/client.render.mjs` | 用替身 Loader 加载浏览器 bundle,检查 slot 注册形状与 `id`,再用 `react-dom/server` 渲染两个面板,断言金额格式、区间页签(含 `按年`:下拉只列出宿主报的那几年、选中的那一年被选中、月份轴恒为 12、点数是 12 × 序列数、四张卡片是那一年的合计、这一档不画热力图、没有年份时不渲染空下拉)、四序列折线的条数与配色、图例开关(单独显示某一条时的折线条数,以及面积填充有没有跟着走)、悬停 tooltip 的装配、provider 页签、热力图的格数(371 − 今天之后的那几天)与分档、官方价格表内容、两个开关(前缀回退与未匹配模型计价:开/关两种状态各自渲染出的说明与动作、default 行的来源标注、以及「哪些模型没价格」的提示)、**覆盖行编辑器**(每行的编辑/删除动作、倍率与峰谷列恒定存在、表单用被编辑行的真实数值预填而不是空模板、自定义规则才展开规则字段、新增行不给删除按钮、**兜底行同样可编辑可删除且不提供那些不生效的字段**、**重名键与「改过的行」会被保存拦下**、**旧宿主不会被说成「兜底行还没有」**、以及「保留未显示字段」与拒绝写 NaN 的源码断言)、节假日徽标与日历区块、展开/收起两种行形态、**弹窗那一段 CSS 里不许出现任何宿主颜色令牌(`--dsw-`),必须自带浅色/深色两套 `--dub-*` 调色板**,以及空数据 / 稀疏 provider 两个边界不崩 |
402
+ | `test/cache.verify.mjs` | 缓存存储层:**两个后端跑同一套契约断言**(会话游标与桶的往返、`hourly` 槽位与逐请求记录、重写会话即替换它的日期集合、`years()` 的年份列表与有界区间读),以及只有跨重启才看得见的行为 —— **删掉会话日志后历史仍在**(新挂载的宿主照旧报出同样的金额)、**没有变化的一轮刷新不写文件**、**超出加载窗口的那天留在库里只是不读进来**(换个更宽的窗口还能取到)、旧 `cache.json` **迁移一次**并把旧文件改名为 `.migrated`(且 `.migrated` 之后**不再被读**)、**读不出来的缓存文件被改名保留并新建一个**、**更新版本的库只读使用且绝不改名**、加载窗口必须大于快照窗口这条不变量、**增量折叠**(重启只读新追加的字节且读的字节数正好等于追加量、两天相加不翻倍、无变化时读 0、游标是日志大小而不是最早那天的戳、半行不算而补全后只算一次),以及 `按年` 路由(三年以前那一年照样读得到、12 个月恒定、`daily` 带节假日标记与 provider 拆分、没有数据的年份是 12 个空月而不是报错、非法年份 400)。 |
403
+ | `test/client.render.mjs` | 用替身 Loader 加载浏览器 bundle,检查 slot 注册形状与 `id`,再用 `react-dom/server` 渲染两个面板,断言金额格式、区间页签(含 `按年`:下拉只列出宿主报的那几年、选中的那一年被选中、月份轴恒为 12、点数是 12 × 序列数、四张卡片是那一年的合计、这一档不画热力图、没有年份时不渲染空下拉)、四序列折线的条数与配色、图例开关(单独显示某一条时的折线条数,以及面积填充有没有跟着走)、悬停 tooltip 的装配、provider 页签、热力图的格数(371 − 今天之后的那几天)与分档、官方价格表内容、两个开关(前缀回退与未匹配模型计价:开/关两种状态各自渲染出的说明与动作、default 行的来源标注、以及「哪些模型没价格」的提示)、**覆盖行编辑器**(每行的编辑/删除动作、倍率与峰谷列恒定存在、表单用被编辑行的真实数值预填而不是空模板、自定义规则才展开规则字段、新增行不给删除按钮、**兜底行同样可编辑可删除且不提供那些不生效的字段**、**重名键与「改过的行」会被保存拦下**、**旧宿主不会被说成「兜底行还没有」**、以及「保留未显示字段」与拒绝写 NaN 的源码断言)、节假日徽标与日历区块、展开/收起两种行形态、**没有用量的那天金额仍贴右**(token 槽位渲染但为空)、**弹窗那一段 CSS 里不许出现任何宿主颜色令牌(`--dsw-`),必须自带浅色/深色两套 `--dub-*` 调色板**,以及空数据 / 稀疏 provider 两个边界不崩 |
378
404
  | `test/smoke.mjs` | 对着**本机真实**的 `$DSH_HOME` 跑一遍,打印徽标金额、今日 token 构成、当前峰谷档位、节假日日历状态、最近 7 天,以及滚动热力图那 371 天的分档分布(有用量的天数与四档各多少天,都挤在一档说明这张图会显得很平) |
379
405
  | `test/official-pricing.live.mjs` | **联网**核对:抓真实页面并与 `test/fixtures/` 的快照逐字段比对,确认官方页面仍能解析、价格未变 |
380
406
 
@@ -824,6 +850,7 @@ storages/usage-badge/host.lock # { pid, startedAt, instanceId, hostname, at
824
850
  - **首次启动要冷扫一遍全部日志**(实测 209 份日志约 10 秒,分片让出事件循环,不会卡住宿主)。之后走增量:实测热启动 237ms、折叠 0 份。缓存文件丢失才会再次冷扫。
825
851
  - **历史依赖日志仍在盘上,但「变短」不再吃掉历史**:已删除的日志靠缓存里保留的贡献继续计入;缓存也丢了,那段历史就没了。日志**变短或还原成更早的备份**时不再删缓存里的天:折叠结果变小只会写进 `fallback` 槽位,`current`(读的时候认的那份)仍是更完整的那份。只有日志**更全**、却不再包含某一天时,那天才会被丢掉(section 8 有正反两个方向的断言)。**例外是改名**:会话身份是日志所在目录名,改名会让同一份用量各算一份。
826
852
  - **缓存只把最近 430 天装进内存**(`CACHE_WINDOW_DAYS`,比快照要的 400 天宽):更早的行留在库里,`按年` 视图能直接查它们,但**日粒度**的区间(`近 N 日`)与热力图仍只覆盖最近一年。
853
+ - **第一次折叠(或缓存被删掉之后)仍要把全部会话日志读一遍**:本机 348 MB / 338 个日志约 21 秒。它是按帧让出事件循环的(实测最长一次阻塞 203 ms),所以不会拖住宿主启动,但徽标会先显示「…」再填上数字;之后每次启动只读增量(同一台机器实测:1 个日志、2 KB、57 ms)。
827
854
  - **没有 `node:sqlite` 的运行时会退回旧的 JSON 缓存**:功能一样,但保存代价与常驻内存随历史长度增长(就是换 SQLite 之前的实测数字)。面板的 ⓘ 会写明当前用的是哪一种。
828
855
  - **云同步是手动触发的**:面板上的「立即同步」+「测试连接」是目前唯一的入口(价格表变更后的自动推送、启动后延迟一次都还没接)。数据仍然只在一台机器上直到你点它 —— 换机器、重装、或删掉缓存目录,都会丢掉「日志里已经没有」的那段历史(日志还在的部分能重新折出来)。设计见上面「云同步」一节。
829
856
  - **折叠结果是日志所在目录 × 日期**:缓存按 `(会话目录, 日期)` 存,所以把某个会话目录**改名**会让同一份用量出现两个会话,**各算一份**(实测 `¥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
@@ -75,6 +75,10 @@ window.__ModuleLoader__.load({
75
75
  peak chip into white on white. */
76
76
  .dub-dot{flex:none;width:6px;height:6px;border-radius:3px;background:var(--dsw-alias-label-tertiary,#999)}
77
77
  .dub-dot-peak{background:var(--dsw-static-amber-500,#f59e0b)}
78
+ /* This span owns the auto margin that pushes the amount to the right edge, so it is
79
+ rendered even when the day has no token text (an empty flex item still absorbs the
80
+ free space). Keep the amount's own margin a fixed gap, never auto: two auto margins
81
+ would split the free space and park the token text in the middle of the row. */
78
82
  .dub-pill-tokens{margin-left:auto;flex:none;color:var(--dsw-alias-label-tertiary,#999);font-size:11px;font-variant-numeric:tabular-nums}
79
83
  .dub-pill-amount{flex:none;margin-left:6px;color:var(--dsw-alias-label-primary,#111);font-variant-numeric:tabular-nums;font-weight:500}
80
84
  /* ── 弹窗自带一套样式,不读宿主的颜色令牌 ─────────────────────────────────────────────────────
@@ -2817,7 +2821,10 @@ html[data-theme="dark"] .dub-mask{
2817
2821
  // The marker is compact, so it carries the band text as its own tooltip:
2818
2822
  // hovering the dot alone explains 峰时 ×2 / 谷时 ×1 / 节假日.
2819
2823
  chip ? h('span', { className: `dub-dot${chip.peak ? ' dub-dot-peak' : ''}`, title: chip.text }) : null,
2820
- tokenTotal > 0 ? h('span', { className: 'dub-pill-tokens' }, `${tokens(tokenTotal)} tokens`) : null,
2824
+ // Always rendered, even when a day has no tokens: this span owns the row's
2825
+ // `margin-left:auto`, so dropping it on an idle day pulled the amount left and
2826
+ // sat it right beside the label — two different alignments for one row.
2827
+ h('span', { className: 'dub-pill-tokens' }, tokenTotal > 0 ? `${tokens(tokenTotal)} tokens` : ''),
2821
2828
  h('span', { className: 'dub-pill-amount' }, text),
2822
2829
  ),
2823
2830
  open && snapshot
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
+ }
180
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
+ }
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
 
@@ -351,12 +507,14 @@ export function createAggregator({ sessionsRoot, store, log = () => {} }) {
351
507
  * reason the store is not a single document, and it means there is no save timer to lose
352
508
  * work in and no moment where a burst of edits has to be batched.
353
509
  *
354
- * @returns {Promise<{files:number, folded:number, ms:number}>} what this pass did.
510
+ * @returns {Promise<{files:number, folded:number, tailed:number, bytesRead:number, ms:number}>} what this pass did.
355
511
  */
356
512
  async function refresh() {
357
513
  const started = Date.now()
358
514
  const files = listLogFiles(sessionsRoot)
359
515
  let folded = 0
516
+ let tailed = 0
517
+ let bytesRead = 0
360
518
  let sinceYield = 0
361
519
  for (const file of files) {
362
520
  const sessionId = sessionIdFromLogPath(file)
@@ -371,7 +529,35 @@ export function createAggregator({ sessionsRoot, store, log = () => {} }) {
371
529
  continue
372
530
  }
373
531
  const previous = records.get(sessionId)
374
- const record = foldSession(file, previous)
532
+ // The cursor's `fileSize` is the byte offset the last fold consumed, so a log that only grew
533
+ // is folded from there. Everything else — a shorter log, a different path, no cursor, no
534
+ // record to fold into — is a full re-fold, because a tail read assumes the prefix is intact.
535
+ const tailFrom =
536
+ previous && cursor && cursor.path === file && cursor.fileSize > 0 && stat.size > cursor.fileSize
537
+ ? cursor.fileSize
538
+ : 0
539
+ let record = await foldSession(file, tailFrom > 0 ? previous : null, { fromByte: tailFrom })
540
+ let tail = tailFrom > 0 && Boolean(record)
541
+ if (tail && record !== previous) {
542
+ // A tail fold may add a day — a session running past midnight does exactly that — but the
543
+ // days it adds cannot be *older* than the newest one already folded: an append only moves
544
+ // forward. An older day means the file was rewritten in place, so the prefix this pass did
545
+ // not look at is not the prefix the record was built from; fold that session from the start.
546
+ let newest = ''
547
+ for (const date of previous.days?.keys() ?? []) if (date > newest) newest = date
548
+ const stale = newest !== '' && record.touchedDates?.some((date) => date < newest)
549
+ if (stale) {
550
+ record = await foldSession(file, previous, {})
551
+ tail = false
552
+ }
553
+ } else if (!record) {
554
+ tail = false
555
+ }
556
+ if (record) {
557
+ bytesRead += tail && tailFrom > 0 ? Math.max(0, (record.fileSize ?? 0) - tailFrom) : (record.fileSize ?? 0)
558
+ delete record.touchedDates
559
+ delete record.foldedBytes
560
+ }
375
561
  // `foldSession` hands the previous record straight back when it could not read the log, and
376
562
  // that record is already in the store — writing it again would replace the session's rows
377
563
  // with only the days this process happens to hold (the loading window), deleting the older
@@ -379,6 +565,7 @@ export function createAggregator({ sessionsRoot, store, log = () => {} }) {
379
565
  // fold, which is what a transient read failure wants.
380
566
  if (record && record !== previous) {
381
567
  folded++
568
+ if (tail) tailed++
382
569
  // Written back one session at a time, right here: a save costs the changed session's
383
570
  // days, not the whole history, so there is nothing to batch and no timer to lose it in.
384
571
  //
@@ -386,9 +573,13 @@ export function createAggregator({ sessionsRoot, store, log = () => {} }) {
386
573
  // the fold just produced, but when the log got shorter the store keeps the fuller copy
387
574
  // it already had — and then *that* is the truth, because the whole point of the row
388
575
  // model is that a shortened log cannot make history disappear.
576
+ //
577
+ // `keepOtherDays` is what makes a tail fold safe to write: the record it carries covers
578
+ // the loading window, not the session's whole history, so the days outside the window are
579
+ // this process's blind spot rather than rows the log no longer has.
389
580
  let effective = record
390
581
  try {
391
- effective = store.put(record, { source: LOCAL_SOURCE }) ?? record
582
+ effective = store.put(record, { source: LOCAL_SOURCE, keepOtherDays: tail }) ?? record
392
583
  } catch (error) {
393
584
  log(`cache write failed for ${sessionId}: ${error?.message ?? error}`)
394
585
  }
@@ -411,7 +602,7 @@ export function createAggregator({ sessionsRoot, store, log = () => {} }) {
411
602
  // being refreshed — and, because nothing writes it again, that history stays in the store
412
603
  // for good rather than being erased by the log's absence.
413
604
  if (folded > 0) mergeDays()
414
- lastRefresh = { files: files.length, folded, ms: Date.now() - started, at: Date.now() }
605
+ lastRefresh = { files: files.length, folded, tailed, bytesRead, ms: Date.now() - started, at: Date.now() }
415
606
  return lastRefresh
416
607
  }
417
608
 
package/lib/index.js CHANGED
@@ -1068,6 +1068,7 @@ export function apply(ctx) {
1068
1068
  aggregator.flush()
1069
1069
  log(
1070
1070
  `ready: ${aggregator.sessionCount} session(s) over ${status.files} log(s), folded ${status.folded} in ${status.ms}ms` +
1071
+ ` (${status.tailed ?? 0} of them from the tail only, ${Math.round((status.bytesRead ?? 0) / 1024)} KB read)` +
1071
1072
  `; cache: ${store.kind}` +
1072
1073
  (rules.enabled ? `; holiday calendar on (${rules.holidays.size} holidays)` : ''),
1073
1074
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-usage-badge",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
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",