dsh-peak-hours 1.11.0 → 1.12.1

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 CHANGED
@@ -1,5 +1,23 @@
1
1
  # 更新日志
2
2
 
3
+ ## 1.12.1 — 2026-10-30
4
+
5
+ ### 修正
6
+
7
+ - **侧栏角标只剩一行文字、圆点和日程条消失**:根节点上的 `aria-label` 会被 `sidebar.footer.action` 的所有者当成「行文本」投影出来,整个组件子树被替换成一行纯文本。现已移除该属性(无障碍名交给可见文本 + `title`),并在源码里留下注释,避免回归。
8
+
9
+ ## 1.12.0 — 2026-10-29
10
+
11
+ ### 新增
12
+
13
+ - **设置页「高峰/空闲用量与费用对比」卡片**(`settings.general.item`,排在「性能与用量」之后):把本机会话日志里的每个请求按**发生时刻的北京时间**归到高峰或空闲,按模型价目表折算成钱,给出近 7 天 / 近 30 天 / 全部三个区间的「实际花费、全高峰、全空闲、省下的钱与比例、高峰占比、分模型明细」。想查「我到底因为错峰省了多少」时,不用再手算。
14
+ - **host 半**:`index.js` 注册 `GET /dsh-peak-hours/api/usage`,60 秒 TTL + `?fresh=1` 强制刷新 + in-flight 去重;`lib/zstd.js` 自己按帧切开多帧 zstd 会话日志(`zlib.zstdDecompressSync` 只能解首帧),`lib/usage.js` 增量扫描并按文件大小/修改时间缓存到 `~/.dsh/storages/peak-hours/cache.json`。
15
+ - 新增 10 个用例覆盖 host 半(窗口边界、周末/节假日、模型分类、价格公式、`turn+step` 替换、分桶、费用对比、区间过滤),测试总数 15 → 24。
16
+
17
+ ### 修正
18
+
19
+ - 分模型明细里同名模型(如 `deepseek-account/deepseek-flash` 与 `deepseek-official/deepseek-flash`)不再显示成两行一模一样,第二个起会带提供商后缀(`· personal` / `· official`)。
20
+
3
21
  ## 1.11.0 — 2026-10-29
4
22
 
5
23
  ### 修正
package/README.md CHANGED
@@ -1,6 +1,9 @@
1
1
  # dsh-peak-hours
2
2
 
3
- 一个 DeepSeek Harness 的侧栏角标:**现在是不是高峰计价时段**。
3
+ 一个 DeepSeek Harness 插件,做两件事:
4
+
5
+ 1. **侧栏角标**:现在是不是高峰计价时段;
6
+ 2. **设置页卡片**:把你自己的用量按高峰/空闲拆开,并算出「实际花了多少」对比「全在高峰 / 全在空闲要花多少」。
4
7
 
5
8
  ![侧栏角标](screenshots/screenshot-1.png)
6
9
 
@@ -36,6 +39,24 @@
36
39
 
37
40
  时间一律按北京时间(UTC+8)算,和你浏览器的时区无关。
38
41
 
42
+ ## 用量与费用对比(设置 → 通用)
43
+
44
+ 除了角标,插件还在「设置 → 通用」里加了一行 **高峰/空闲用量与费用对比**(`settings.general.item`,排在「性能与用量」后面)。它读取本机 DeepSeek Harness 的会话日志,把每个请求按它发生的**北京时间**归到高峰或空闲,然后按模型价目表折算成钱:
45
+
46
+ - **三个区间**:近 7 天 / 近 30 天 / 全部
47
+ - **三笔钱**:实际花费(高峰按 ×2、空闲按 ×1)、假如全部落在高峰、假如全部落在空闲
48
+ - **省下的钱与比例**:`¥68.49 · 35.5%` 这样
49
+ - **用量拆分**:高峰 token、空闲 token、合计、高峰占比,配一条橙/蓝比例条
50
+ - **分模型**:前四个模型各自的 token 与费用
51
+
52
+ 口径与限制(重要,也是诚实的地方):
53
+
54
+ - 只统计**能对上 DeepSeek 价目表**的模型(`deepseek-flash` 按 flash 价、`deepseek-v4-pro` / reasoner 按 pro 价);其他供应商(claude、gpt、qmodel 等)只计 token、不计入费用,并在说明里单独标注
55
+ - 价目表同样内置在 `lib/schedule.js`(元/百万 tokens,空闲→高峰):flash 命中 0.02→0.04、未命中 1→2、输出 4→8;pro 命中 0.15→0.30、未命中 4.5→9、输出 13.5→27
56
+ - 同一 `turn`+`step` 的多次采样按**最后一次**算(会话流式输出会反复覆盖同一步的用量),不会重复累加
57
+ - 数据来自本机会话日志 `~/.dsh/sessions/<工作区>/<会话>/session.v4.jsonl.zstd`,**只在本地读、不外发**;解析结果按文件大小与修改时间缓存到 `~/.dsh/storages/peak-hours/cache.json`,第二次打开是毫秒级
58
+ - 用量接口也有 60 秒 TTL,卡片上的「刷新」会跳过缓存重新扫一遍
59
+
39
60
  ## 安装
40
61
 
41
62
  从 npm:
@@ -61,17 +82,23 @@ dsh plugin --profile web remove dsh-peak-hours
61
82
  ## 兼容性
62
83
 
63
84
  - 需要带客户端 UI 的 DeepSeek Harness Web GUI(`dsh.client` 声明为 `platform: web`)
64
- - 纯客户端插件:不注册 host 工具、不写文件、不发网络请求、不需要任何配置或凭据
85
+ - 客户端 + 一个只读的 host 半:host 半注册一条 `GET /dsh-peak-hours/api/usage`,读取本机会话日志算峰谷账,**不发任何网络请求**;除 `~/.dsh/storages/peak-hours/cache.json` 这一份解析缓存外不写文件,不需要任何配置或凭据
86
+ - 角标部分纯客户端,不依赖 host 半;即使 host 半取数失败,角标照常工作
65
87
  - 颜色是固定值(高峰 `#f59e0b`、空闲 `#4d6bfe`,即 DeepSeek 品牌蓝),不跟随主题 `brand-primary`,以免换皮肤后蓝色被改成别的色
66
88
 
67
89
  ## 开发
68
90
 
69
91
  ```sh
70
- npm test # 15 个单元测试(node --test,零依赖)
92
+ npm test # 24 个单元测试(node --test,零依赖)
71
93
  npm run verify # npm pack --dry-run,检查发布清单
72
94
  ```
73
95
 
74
- 测试通过 `vm` 加载 `client.js`,用最小的 React / locale / document 替身渲染组件,因此不用起 GUI 也能验证时段判定、分段宽度、折叠态与复制逻辑。
96
+ 测试分两块,全部零依赖、不用起 GUI:
97
+
98
+ - `test/schedule.test.mjs`:通过 `vm` 加载 `client.js`,用最小的 React / locale / document 替身渲染角标,验证时段边界、日程条分段与宽度、游标位置、折叠态、复制与中英文 key 对齐
99
+ - `test/usage.test.mjs`:直接测 host 半的 `lib/schedule.js` 与 `lib/usage.js`,用合成事件验证窗口判定、节假日、模型分类、价格公式、`turn+step` 替换语义、按日+小时+模型分桶、峰谷费用对比与区间过滤
100
+
101
+ 设置页卡片还有一个独立的 headless 渲染检查(真实接口数据 + 最小 hooks 替身),确认取数成功后不会渲染崩溃。
75
102
 
76
103
  ## 许可
77
104
 
package/client.js CHANGED
@@ -334,7 +334,53 @@ window.__ModuleLoader__.load({
334
334
  }
335
335
  .dshph-rail .dshph-track { width: 26px; height: 6px; }
336
336
  .dshph-rail .dshph-cursor { width: 1.5px; }
337
- @media (prefers-reduced-motion: reduce) {
337
+
338
+ /* ---- 峰谷用量与费用卡片(设置页) ---- */
339
+ .dshphu-card {
340
+ --dshphu-peak: #f59e0b;
341
+ --dshphu-off: #4d6bfe;
342
+ display: flex; flex-direction: column; gap: 10px;
343
+ box-sizing: border-box; width: 100%; min-width: 0;
344
+ padding: 12px 14px; border-radius: 10px;
345
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, .18));
346
+ background: var(--dsw-alias-bg-layer-1, transparent);
347
+ font-family: inherit; font-size: 14px; font-size: .875rem;
348
+ line-height: 1.45; color: var(--dsw-alias-label-primary);
349
+ }
350
+ .dshphu-head { display: flex; align-items: flex-start; gap: 10px; min-width: 0; }
351
+ .dshphu-titles { flex: 1 1 auto; min-width: 0; }
352
+ .dshphu-title { font-weight: 600; }
353
+ .dshphu-desc { color: var(--dsw-alias-label-secondary); font-size: .92em; }
354
+ .dshphu-seg { flex: none; display: flex; gap: 4px; }
355
+ .dshphu-segBtn {
356
+ font: inherit; font-size: .92em; cursor: pointer;
357
+ padding: 2px 9px; border-radius: 999px;
358
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, .18));
359
+ background: transparent; color: var(--dsw-alias-label-secondary);
360
+ }
361
+ .dshphu-segBtn[data-on='1'] {
362
+ color: #fff; border-color: transparent; background: var(--dshphu-off);
363
+ }
364
+ .dshphu-kpis { display: flex; flex-wrap: wrap; gap: 14px; }
365
+ .dshphu-kpi { min-width: 96px; }
366
+ .dshphu-kpiLabel { color: var(--dsw-alias-label-secondary); font-size: .92em; }
367
+ .dshphu-kpiValue { font-size: 1.18em; font-weight: 600; }
368
+ .dshphu-kpiValue[data-tone='peak'] { color: var(--dshphu-peak); }
369
+ .dshphu-kpiValue[data-tone='save'] { color: var(--dshphu-off); }
370
+ .dshphu-bar {
371
+ display: flex; height: 8px; border-radius: 999px; overflow: hidden;
372
+ background: var(--dsw-alias-bg-layer-2);
373
+ }
374
+ .dshphu-barPeak { background: var(--dshphu-peak); }
375
+ .dshphu-barOff { background: var(--dshphu-off); }
376
+ .dshphu-line { color: var(--dsw-alias-label-secondary); font-size: .92em; }
377
+ .dshphu-models { display: flex; flex-direction: column; gap: 3px; }
378
+ .dshphu-model { display: flex; gap: 8px; align-items: baseline; font-size: .92em; }
379
+ .dshphu-modelName { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
380
+ .dshphu-modelNum { flex: none; color: var(--dsw-alias-label-secondary); }
381
+ .dshphu-note { color: var(--dsw-alias-label-secondary); font-size: .88em; }
382
+ .dshphu-err { color: var(--dsw-alias-state-error-primary, #d33); font-size: .92em; }
383
+ @media (prefers-reduced-motion: reduce) {
338
384
  .dshph-root, .dshph-root * {
339
385
  animation: none !important; transition: none !important;
340
386
  }
@@ -388,6 +434,27 @@ window.__ModuleLoader__.load({
388
434
  'copy.ok': '已复制',
389
435
  'copy.fail': '复制失败',
390
436
  'copy.suffix': '(北京时间)',
437
+ 'usage.title': '峰谷用量与费用',
438
+ 'usage.desc': '来自本地会话日志:按高峰/空闲拆分 tokens,并按峰谷价折算费用',
439
+ 'usage.range': '统计区间',
440
+ 'usage.range.7': '近 7 天',
441
+ 'usage.range.30': '近 30 天',
442
+ 'usage.range.all': '全部',
443
+ 'usage.actual': '实际花费',
444
+ 'usage.allPeak': '全在高峰',
445
+ 'usage.saved': '比全高峰省',
446
+ 'usage.peakTokens': '高峰',
447
+ 'usage.offTokens': '空闲',
448
+ 'usage.totalTokens': '合计',
449
+ 'usage.peakShare': '高峰占比',
450
+ 'usage.refresh': '重新统计',
451
+ 'usage.refreshHint': '点击右上角区间可切换',
452
+ 'usage.loading': '统计中…(首次需扫描本地会话日志)',
453
+ 'usage.error': '读取失败:',
454
+ 'usage.empty': '暂无用量记录',
455
+ 'usage.note': '仅 deepseek 系模型按峰谷价折算;其他供应商 {tokens} tokens 计入用量、不计费',
456
+ 'usage.meta': '扫描 {sessions} 个会话 · 活跃 {days} 天',
457
+
391
458
  };
392
459
 
393
460
  const EN = {
@@ -424,6 +491,27 @@ window.__ModuleLoader__.load({
424
491
  'copy.ok': 'Copied',
425
492
  'copy.fail': 'Copy failed',
426
493
  'copy.suffix': '(Beijing time)',
494
+ 'usage.title': 'Peak / off-peak usage and cost',
495
+ 'usage.desc': 'From local session logs: tokens split by peak/off-peak, priced at the peak/off-peak rates',
496
+ 'usage.range': 'Range',
497
+ 'usage.range.7': '7 days',
498
+ 'usage.range.30': '30 days',
499
+ 'usage.range.all': 'All',
500
+ 'usage.actual': 'Actual spend',
501
+ 'usage.allPeak': 'All at peak',
502
+ 'usage.saved': 'Saved vs all-peak',
503
+ 'usage.peakTokens': 'Peak',
504
+ 'usage.offTokens': 'Off-peak',
505
+ 'usage.totalTokens': 'Total',
506
+ 'usage.peakShare': 'Peak share',
507
+ 'usage.refresh': 'Rescan',
508
+ 'usage.refreshHint': 'Switch ranges above',
509
+ 'usage.loading': 'Scanning… (first run reads local session logs)',
510
+ 'usage.error': 'Failed to load: ',
511
+ 'usage.empty': 'No usage yet',
512
+ 'usage.note': 'Only DeepSeek models are priced here; {tokens} tokens from other providers count as usage, not money',
513
+ 'usage.meta': '{sessions} sessions scanned · {days} active days',
514
+
427
515
  };
428
516
 
429
517
  let translate = (key) => (EN[key] !== undefined ? EN[key] : key);
@@ -495,14 +583,14 @@ window.__ModuleLoader__.load({
495
583
  h('span', { key: 'cursor', className: 'dshph-cursor', style: { left: cursorLeft } }),
496
584
  );
497
585
 
586
+ // 根节点**不要**放 aria-label / aria-labelledby:sidebar.footer.action 的所有者
587
+ // 会把这个属性当行文本投影出来,整个子树被换成一行纯文本(圆点、日程条都会消失,
588
+ // 1.11.0 起踩过这个坑)。无障碍名交给可见文本 + title。
498
589
  const common = {
499
590
  title,
500
- role: 'button',
501
591
  tabIndex: 0,
502
592
  onClick: onCopy,
503
593
  onKeyDown,
504
- 'aria-label':
505
- t('label') + ': ' + stateText + ', ' + remaining + '. ' + t('tip.hint'),
506
594
  };
507
595
 
508
596
  if (!wide) {
@@ -538,6 +626,241 @@ window.__ModuleLoader__.load({
538
626
  );
539
627
  }
540
628
 
629
+
630
+ // ---------------------------------------------------------------------
631
+ // 峰谷用量与费用(设置页卡片)
632
+ // ---------------------------------------------------------------------
633
+ const USAGE_API = '/dsh-peak-hours/api/usage';
634
+ const RANGES = ['7', '30', 'all'];
635
+ let usageCache = null;
636
+
637
+ function fmtTokens(n) {
638
+ const v = Number(n) || 0;
639
+ if (v >= 1e8) return (v / 1e8).toFixed(2) + ' 亿';
640
+ if (v >= 1e4) return (v / 1e4).toFixed(1) + ' 万';
641
+ return String(Math.round(v));
642
+ }
643
+
644
+ function fmtMoney(n) {
645
+ const v = Number(n) || 0;
646
+ if (v > 0 && v < 0.01) return '¥' + v.toFixed(4);
647
+ if (v < 1) return '¥' + v.toFixed(3);
648
+ return '¥' + v.toFixed(2);
649
+ }
650
+
651
+ function fmtPct(x) {
652
+ return ((Number(x) || 0) * 100).toFixed(1) + '%';
653
+ }
654
+
655
+ function modelParts(name) {
656
+ const s = String(name || '');
657
+ const i = s.indexOf('/');
658
+ return i >= 0 ? { provider: s.slice(0, i), model: s.slice(i + 1) } : { provider: '', model: s };
659
+ }
660
+
661
+ /** Short name; when two providers expose the same model, keep the provider tag. */
662
+ function shortModel(name, duplicated) {
663
+ const { provider, model } = modelParts(name);
664
+ if (!duplicated) return model;
665
+ const tag = provider.replace(/^deepseek-/, '').replace(/-?account$/, 'personal');
666
+ return tag ? model + ' · ' + tag : model;
667
+ }
668
+
669
+ function UsageCard() {
670
+ const t = translate;
671
+ const rangeState = React.useState('30');
672
+ const range = rangeState[0];
673
+ const setRange = rangeState[1];
674
+ const dataState = React.useState(usageCache);
675
+ const data = dataState[0];
676
+ const setData = dataState[1];
677
+ const errState = React.useState('');
678
+ const err = errState[0];
679
+ const setErr = errState[1];
680
+ const loadState = React.useState(!usageCache);
681
+ const loading = loadState[0];
682
+ const setLoading = loadState[1];
683
+ const tickState = React.useState(0);
684
+ const tick = tickState[0];
685
+ const reload = tickState[1];
686
+
687
+ React.useEffect(() => {
688
+ let alive = true;
689
+ setLoading(true);
690
+ fetch(USAGE_API + (tick > 0 ? '?fresh=1' : ''), { cache: 'no-store' })
691
+ .then((r) =>
692
+ r.json().then(
693
+ (j) => {
694
+ if (!r.ok) throw new Error((j && j.error) || 'HTTP ' + r.status);
695
+ return j;
696
+ },
697
+ () => {
698
+ throw new Error('HTTP ' + r.status);
699
+ },
700
+ ),
701
+ )
702
+ .then((j) => {
703
+ if (!alive) return;
704
+ usageCache = j;
705
+ setData(j);
706
+ setErr('');
707
+ })
708
+ .catch((e) => {
709
+ if (!alive) return;
710
+ setErr(String((e && e.message) || e));
711
+ })
712
+ .then(() => {
713
+ if (alive) setLoading(false);
714
+ });
715
+ return () => {
716
+ alive = false;
717
+ };
718
+ }, [tick]);
719
+
720
+ const rep = data && data.ranges ? data.ranges[range] : null;
721
+ const seg = h(
722
+ 'div',
723
+ { className: 'dshphu-seg', role: 'group', 'aria-label': t('usage.range') },
724
+ ...RANGES.map((key) =>
725
+ h(
726
+ 'button',
727
+ {
728
+ key,
729
+ type: 'button',
730
+ className: 'dshphu-segBtn',
731
+ 'data-on': range === key ? '1' : '0',
732
+ 'aria-pressed': range === key ? 'true' : 'false',
733
+ onClick: () => setRange(key),
734
+ },
735
+ t('usage.range.' + key),
736
+ ),
737
+ ),
738
+ );
739
+
740
+ const head = h(
741
+ 'div',
742
+ { className: 'dshphu-head' },
743
+ h(
744
+ 'div',
745
+ { className: 'dshphu-titles' },
746
+ h('div', { className: 'dshphu-title' }, t('usage.title')),
747
+ h('div', { className: 'dshphu-desc' }, t('usage.desc')),
748
+ ),
749
+ seg,
750
+ );
751
+
752
+ if (err) {
753
+ return h(
754
+ 'div',
755
+ { className: 'dshphu-card' },
756
+ head,
757
+ h('div', { className: 'dshphu-err' }, t('usage.error') + err),
758
+ h(
759
+ 'div',
760
+ null,
761
+ h('button', { type: 'button', className: 'dshphu-segBtn', onClick: () => reload((n) => n + 1) }, t('usage.refresh')),
762
+ ),
763
+ );
764
+ }
765
+
766
+ if (!rep) {
767
+ return h(
768
+ 'div',
769
+ { className: 'dshphu-card' },
770
+ head,
771
+ h('div', { className: 'dshphu-line' }, loading ? t('usage.loading') : t('usage.empty')),
772
+ );
773
+ }
774
+
775
+ const tokens = rep.tokens;
776
+ const cost = rep.cost;
777
+ const peakPct = Math.max(0, Math.min(1, tokens.peakShare));
778
+ const modelRows = rep.classByModel || [];
779
+ const modelNames = new Map();
780
+ for (const row of modelRows) {
781
+ const base = modelParts(row.model).model;
782
+ modelNames.set(base, (modelNames.get(base) || 0) + 1);
783
+ }
784
+ const models = modelRows.slice(0, 4);
785
+
786
+ return h(
787
+ 'div',
788
+ { className: 'dshphu-card' },
789
+ head,
790
+ h(
791
+ 'div',
792
+ { className: 'dshphu-kpis' },
793
+ h(
794
+ 'div',
795
+ { className: 'dshphu-kpi' },
796
+ h('div', { className: 'dshphu-kpiLabel' }, t('usage.actual')),
797
+ h('div', { className: 'dshphu-kpiValue' }, fmtMoney(cost.actual)),
798
+ ),
799
+ h(
800
+ 'div',
801
+ { className: 'dshphu-kpi' },
802
+ h('div', { className: 'dshphu-kpiLabel' }, t('usage.allPeak')),
803
+ h('div', { className: 'dshphu-kpiValue', 'data-tone': 'peak' }, fmtMoney(cost.allPeak)),
804
+ ),
805
+ h(
806
+ 'div',
807
+ { className: 'dshphu-kpi' },
808
+ h('div', { className: 'dshphu-kpiLabel' }, t('usage.saved')),
809
+ h(
810
+ 'div',
811
+ { className: 'dshphu-kpiValue', 'data-tone': 'save' },
812
+ fmtMoney(cost.saved) + ' · ' + fmtPct(cost.savedPct),
813
+ ),
814
+ ),
815
+ ),
816
+ h(
817
+ 'div',
818
+ { className: 'dshphu-bar', role: 'img', 'aria-label': t('usage.peakShare') + ' ' + fmtPct(peakPct) },
819
+ h('span', { className: 'dshphu-barPeak', style: { width: fmtPct(peakPct) } }),
820
+ h('span', { className: 'dshphu-barOff', style: { width: fmtPct(1 - peakPct) } }),
821
+ ),
822
+ h(
823
+ 'div',
824
+ { className: 'dshphu-line' },
825
+ t('usage.peakTokens') + ' ' + fmtTokens(tokens.peak.total) +
826
+ ' · ' + t('usage.offTokens') + ' ' + fmtTokens(tokens.off.total) +
827
+ ' · ' + t('usage.totalTokens') + ' ' + fmtTokens(tokens.total) +
828
+ ' · ' + t('usage.peakShare') + ' ' + fmtPct(peakPct),
829
+ ),
830
+ models.length
831
+ ? h(
832
+ 'div',
833
+ { className: 'dshphu-models' },
834
+ ...models.map((m) =>
835
+ h(
836
+ 'div',
837
+ { key: m.model, className: 'dshphu-model' },
838
+ h(
839
+ 'span',
840
+ { className: 'dshphu-modelName' },
841
+ shortModel(m.model, (modelNames.get(modelParts(m.model).model) || 0) > 1),
842
+ ),
843
+ h(
844
+ 'span',
845
+ { className: 'dshphu-modelNum' },
846
+ fmtTokens(m.peak.total + m.off.total) + ' · ' + fmtMoney(m.cost || 0),
847
+ ),
848
+ ),
849
+ ),
850
+ )
851
+ : null,
852
+ h('div', { className: 'dshphu-note' }, t('usage.note').replace('{tokens}', fmtTokens(rep.other.tokens))),
853
+ h(
854
+ 'div',
855
+ { className: 'dshphu-line' },
856
+ t('usage.meta')
857
+ .replace('{sessions}', String((data.stats && data.stats.sessions) || 0))
858
+ .replace('{days}', String(rep.activeDays || 0)) +
859
+ ' · ' +
860
+ (loading ? t('usage.loading') : t('usage.refreshHint')),
861
+ ),
862
+ );
863
+ }
541
864
  return {
542
865
  inject: ['slots'],
543
866
  apply(ctx) {
@@ -572,6 +895,19 @@ window.__ModuleLoader__.load({
572
895
  PeakBadge,
573
896
  ),
574
897
  );
898
+ ctx.slots.inject('settings.general.item', () =>
899
+ ctx.slots.register(
900
+ {
901
+ name: 'settings.general.item',
902
+ id: 'peak-hours-usage',
903
+ order: 31,
904
+ locale: NS,
905
+ label: () => translate('usage.title'),
906
+ },
907
+ UsageCard,
908
+ ),
909
+ );
910
+
575
911
  },
576
912
  };
577
913
  },
package/index.js CHANGED
@@ -1,8 +1,73 @@
1
1
  /**
2
- * Host half of the peak-hours badge.
2
+ * Host half of dsh-peak-hours.
3
3
  *
4
- * The visible UI is a Client module (`./client`), which the Web runtime loads on
5
- * its own. The Host entry exists so the bundle has a valid plugin row; it needs
6
- * no services, tools or config.
4
+ * Serves the peak / off-peak usage report on `/dsh-peak-hours/api/usage` so the
5
+ * client half (a settings card) can show tokens and money without touching the
6
+ * filesystem itself.
7
7
  */
8
- export function apply() {}
8
+ import { buildReport } from './lib/usage.js'
9
+
10
+ export const name = 'dsh-peak-hours'
11
+ export const inject = ['webServer']
12
+
13
+ const API = '/dsh-peak-hours/api'
14
+ /** The scan walks every session log; a short TTL keeps repeated UI loads cheap. */
15
+ const TTL_MS = 60_000
16
+
17
+ export function apply(ctx) {
18
+ let payload = null
19
+ let payloadAt = 0
20
+ let inflight = null
21
+
22
+ async function getReport(fresh) {
23
+ const now = Date.now()
24
+ if (!fresh && payload && now - payloadAt < TTL_MS) return payload
25
+ if (inflight) return inflight
26
+ inflight = (async () => {
27
+ try {
28
+ const report = await buildReport({})
29
+ payload = report
30
+ payloadAt = Date.now()
31
+ return report
32
+ } finally {
33
+ inflight = null
34
+ }
35
+ })()
36
+ return inflight
37
+ }
38
+
39
+ ctx.effect(() =>
40
+ ctx.webServer.register({
41
+ kind: 'prefix',
42
+ path: API,
43
+ handler: async (req, res) => {
44
+ const url = req.url || '/'
45
+ const pathname = url.split('?')[0]
46
+ const fresh = /[?&]fresh=1(?:&|$)/.test(url)
47
+ const sub = pathname.startsWith(API) ? pathname.slice(API.length) : pathname
48
+ try {
49
+ if (sub === '/usage') {
50
+ const report = await getReport(fresh)
51
+ res.writeHead(200, {
52
+ 'content-type': 'application/json; charset=utf-8',
53
+ 'cache-control': 'no-store',
54
+ })
55
+ res.end(JSON.stringify(report))
56
+ return
57
+ }
58
+ res.writeHead(200, {
59
+ 'content-type': 'application/json; charset=utf-8',
60
+ 'cache-control': 'no-store',
61
+ })
62
+ res.end(JSON.stringify({ ok: true, service: name, endpoints: ['/usage', '/usage?fresh=1'] }))
63
+ } catch (err) {
64
+ res.writeHead(500, {
65
+ 'content-type': 'application/json; charset=utf-8',
66
+ 'cache-control': 'no-store',
67
+ })
68
+ res.end(JSON.stringify({ ok: false, error: String((err && err.message) || err) }))
69
+ }
70
+ },
71
+ }),
72
+ )
73
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Peak / off-peak schedule and price table (host half).
3
+ *
4
+ * Kept in sync with `client.js` by hand: the client half is a standalone
5
+ * `window.__ModuleLoader__` bundle and cannot import package files, so the two
6
+ * copies are cross-checked by `test/schedule.test.mjs` against the published
7
+ * rules (09:00–12:00 and 14:00–18:00 Beijing time; weekends, Chinese public
8
+ * holidays and make-up rest days are all off-peak; off-peak is half price).
9
+ */
10
+
11
+ export const MINUTE = 60_000
12
+ export const HOUR = 60 * MINUTE
13
+ /** Beijing is UTC+8 with no DST: shifting the instant is exact. */
14
+ export const BEIJING_OFFSET_MS = 8 * 3_600_000
15
+
16
+ /** Peak windows as minutes-of-day, Beijing time. */
17
+ export const PEAK_WINDOWS = [
18
+ [540, 720], // 09:00–12:00
19
+ [840, 1080], // 14:00–18:00
20
+ ]
21
+
22
+ /** Chinese public holidays, keyed by year → MM-DD. Extend as the calendar is announced. */
23
+ export const HOLIDAYS = {
24
+ 2026: [
25
+ '01-01', // 元旦
26
+ '02-17', '02-18', '02-19', '02-20', '02-21', '02-22', '02-23', // 春节
27
+ '04-04', '04-05', '04-06', // 清明
28
+ '05-01', '05-02', '05-03', '05-04', '05-05', // 劳动节
29
+ '06-19', '06-20', '06-21', // 端午
30
+ '09-25', '09-26', '09-27', // 中秋
31
+ '10-01', '10-02', '10-03', '10-04', '10-05', '10-06', '10-07', // 国庆
32
+ ],
33
+ }
34
+
35
+ /** Beijing calendar fields of an instant. */
36
+ export function bjParts(ms) {
37
+ const d = new Date(ms + BEIJING_OFFSET_MS)
38
+ return {
39
+ y: d.getUTCFullYear(),
40
+ mo: String(d.getUTCMonth() + 1).padStart(2, '0'),
41
+ d: String(d.getUTCDate()).padStart(2, '0'),
42
+ h: d.getUTCHours(),
43
+ mi: d.getUTCMinutes(),
44
+ dow: d.getUTCDay(),
45
+ }
46
+ }
47
+
48
+ export function bjDayKey(ms) {
49
+ const p = bjParts(ms)
50
+ return `${p.y}-${p.mo}-${p.d}`
51
+ }
52
+
53
+ export function isHoliday(ms) {
54
+ const p = bjParts(ms)
55
+ const list = HOLIDAYS[p.y]
56
+ return Array.isArray(list) && list.includes(`${p.mo}-${p.d}`)
57
+ }
58
+
59
+ export function isWeekend(ms) {
60
+ const dow = bjParts(ms).dow
61
+ return dow === 0 || dow === 6
62
+ }
63
+
64
+ /** Whole day is off-peak (weekend or public holiday). */
65
+ export function isOffDay(ms) {
66
+ return isWeekend(ms) || isHoliday(ms)
67
+ }
68
+
69
+ export function minuteOfDay(ms) {
70
+ const p = bjParts(ms)
71
+ return p.h * 60 + p.mi
72
+ }
73
+
74
+ export function isPeakInstant(ms) {
75
+ if (isOffDay(ms)) return false
76
+ const m = minuteOfDay(ms)
77
+ return PEAK_WINDOWS.some(([a, b]) => m >= a && m < b)
78
+ }
79
+
80
+ /**
81
+ * Prices in CNY per million tokens.
82
+ * `miss` also prices cache writes (a cache write is billed as uncached input).
83
+ */
84
+ export const PRICES = {
85
+ flash: {
86
+ peak: { hit: 0.04, miss: 2, out: 8 },
87
+ off: { hit: 0.02, miss: 1, out: 4 },
88
+ },
89
+ pro: {
90
+ peak: { hit: 0.3, miss: 9, out: 27 },
91
+ off: { hit: 0.15, miss: 4.5, out: 13.5 },
92
+ },
93
+ }
94
+
95
+ /**
96
+ * Which price class a model falls into, or `null` when the model is not billed
97
+ * on the DeepSeek peak/off-peak table (third-party providers routed through the
98
+ * harness are still counted as usage, but not as money).
99
+ */
100
+ export function classifyModel(model) {
101
+ const m = String(model || '').toLowerCase()
102
+ if (!m.includes('deepseek')) return null
103
+ if (m.includes('pro') || m.includes('reasoner')) return 'pro'
104
+ return 'flash'
105
+ }
106
+
107
+ /** `[input, output, cacheRead, cacheWrite]` → CNY. */
108
+ export function bucketCost(price, b) {
109
+ const miss = (b[0] || 0) + (b[3] || 0)
110
+ const hit = b[2] || 0
111
+ const out = b[1] || 0
112
+ return (miss * price.miss + hit * price.hit + out * price.out) / 1e6
113
+ }
114
+
115
+ export function fmtYuan(n) {
116
+ const v = Number(n) || 0
117
+ if (v > 0 && v < 0.01) return '¥' + v.toFixed(4)
118
+ if (v < 1) return '¥' + v.toFixed(3)
119
+ return '¥' + v.toFixed(2)
120
+ }
package/lib/usage.js ADDED
@@ -0,0 +1,386 @@
1
+ /**
2
+ * Session-log usage scanner: turns durable session logs into peak / off-peak
3
+ * token totals and money.
4
+ *
5
+ * Data source (same one token-meter keeps): `~/.dsh/sessions/<cwd>/<id>/session.v<N>.jsonl.zstd`,
6
+ * a multi-frame zstd stream of JSONL events. Each assistant event carries a
7
+ * top-level `time` (epoch ms) and `data.usage`
8
+ * `{ inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens }`.
9
+ * Repeated samples for the same `turn`/`step` REPLACE the previous one, so the
10
+ * folder subtracts the previous sample before adding the newer one.
11
+ */
12
+ import fs from 'node:fs'
13
+ import os from 'node:os'
14
+ import path from 'node:path'
15
+ import { decodeAllFrames } from './zstd.js'
16
+ import { PRICES, bjDayKey, bjParts, bucketCost, classifyModel, isPeakInstant } from './schedule.js'
17
+
18
+ const LOG_RE = /^session\.v\d+\.jsonl\.zstd$/
19
+ const CACHE_VERSION = 3
20
+ const DAY_MS = 86_400_000
21
+
22
+ export function sessionsRoot() {
23
+ return path.join(os.homedir(), '.dsh', 'sessions')
24
+ }
25
+
26
+ export function cacheFile() {
27
+ return path.join(os.homedir(), '.dsh', 'storages', 'peak-hours', 'cache.json')
28
+ }
29
+
30
+ function bucketsFrom(u) {
31
+ return [
32
+ Number(u.inputTokens) || 0,
33
+ Number(u.outputTokens) || 0,
34
+ Number(u.cacheReadTokens) || 0,
35
+ Number(u.cacheWriteTokens) || 0,
36
+ ]
37
+ }
38
+
39
+ function lastStreamUsage(stream) {
40
+ if (!Array.isArray(stream)) return undefined
41
+ for (let i = stream.length - 1; i >= 0; i--) {
42
+ const r = stream[i]
43
+ if (r && r.type === 'chunk' && r.chunk && r.chunk.type === 'usage' && r.chunk.usage != null) {
44
+ return r.chunk.usage
45
+ }
46
+ }
47
+ return undefined
48
+ }
49
+
50
+ function usageOf(ev) {
51
+ if (ev.data && ev.data.usage != null) return ev.data.usage
52
+ if (ev.type !== 'assistant/message' && ev.type !== 'assistant/attempt') return undefined
53
+ return lastStreamUsage(ev.data && ev.data.stream)
54
+ }
55
+
56
+ function modelOfEvent(ev) {
57
+ const s = ev && ev.data && ev.data.message && ev.data.message.source
58
+ if (!s || typeof s !== 'object') return ''
59
+ const prov = typeof s.provider === 'string' ? s.provider : ''
60
+ const mod = typeof s.model === 'string' ? s.model : ''
61
+ return prov && mod ? prov + '/' + mod : mod || prov
62
+ }
63
+
64
+ /**
65
+ * Fold one session log's decoded text into `map`.
66
+ * Key: `${dayKey}|${hour}|${model}` → `[input, output, cacheRead, cacheWrite]`.
67
+ * @returns {{ samples: number, parsed: number }}
68
+ */
69
+ export function foldSessionText(text, map) {
70
+ let last = null
71
+ let samples = 0
72
+ let parsed = 0
73
+ const add = (key, b, sign) => {
74
+ const cur = map.get(key) || [0, 0, 0, 0]
75
+ cur[0] += sign * b[0]
76
+ cur[1] += sign * b[1]
77
+ cur[2] += sign * b[2]
78
+ cur[3] += sign * b[3]
79
+ map.set(key, cur)
80
+ }
81
+ for (const line of text.split('\n')) {
82
+ if (!line) continue
83
+ let ev
84
+ try {
85
+ ev = JSON.parse(line)
86
+ } catch {
87
+ continue
88
+ }
89
+ parsed++
90
+ const t = Number(ev.time)
91
+ if (!(t > 0)) continue
92
+ const u = usageOf(ev)
93
+ if (u == null) continue
94
+ const b = bucketsFrom(u)
95
+ const data = ev.data || {}
96
+ const turn = data.turn == null ? null : data.turn
97
+ const step = data.step == null ? null : data.step
98
+ const model = modelOfEvent(ev)
99
+ const p = bjParts(t)
100
+ if (last && last.turn === turn && last.step === step && turn !== null) {
101
+ add(last.key, last.b, -1)
102
+ }
103
+ const key = `${p.y}-${String(p.mo).padStart(2, '0')}-${String(p.d).padStart(2, '0')}|${p.h}|${model}`
104
+ add(key, b, 1)
105
+ last = { turn, step, key, b }
106
+ samples++
107
+ }
108
+ return { samples, parsed }
109
+ }
110
+
111
+ function listSessionLogs(root) {
112
+ const out = []
113
+ let cwds
114
+ try {
115
+ cwds = fs.readdirSync(root, { withFileTypes: true })
116
+ } catch {
117
+ return out
118
+ }
119
+ for (const cwd of cwds) {
120
+ if (!cwd.isDirectory()) continue
121
+ const cwdDir = path.join(root, cwd.name)
122
+ let sessions
123
+ try {
124
+ sessions = fs.readdirSync(cwdDir, { withFileTypes: true })
125
+ } catch {
126
+ continue
127
+ }
128
+ for (const s of sessions) {
129
+ if (!s.isDirectory()) continue
130
+ const dir = path.join(cwdDir, s.name)
131
+ let files
132
+ try {
133
+ files = fs.readdirSync(dir)
134
+ } catch {
135
+ continue
136
+ }
137
+ for (const f of files) {
138
+ if (LOG_RE.test(f)) out.push(path.join(dir, f))
139
+ }
140
+ }
141
+ }
142
+ return out
143
+ }
144
+
145
+ function readCache(file) {
146
+ try {
147
+ const json = JSON.parse(fs.readFileSync(file, 'utf8'))
148
+ if (json && json.version === CACHE_VERSION && json.files && typeof json.files === 'object') return json
149
+ } catch {
150
+ /* missing or corrupt → cold scan */
151
+ }
152
+ return { version: CACHE_VERSION, files: {} }
153
+ }
154
+
155
+ function writeCache(file, data) {
156
+ try {
157
+ fs.mkdirSync(path.dirname(file), { recursive: true })
158
+ const tmp = file + '.tmp'
159
+ fs.writeFileSync(tmp, JSON.stringify(data))
160
+ fs.renameSync(tmp, file)
161
+ } catch {
162
+ /* cache is an optimisation only */
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Read every session log, reusing per-file aggregates for unchanged logs.
168
+ * Yields to the event loop between files so a cold scan never freezes the host.
169
+ * @returns {Promise<{ buckets: Array<[string, number, string, number, number, number, number]>, stats: object }>}
170
+ */
171
+ export async function scanSessions(opts = {}) {
172
+ const root = opts.root || sessionsRoot()
173
+ const cachePath = opts.cacheFile || cacheFile()
174
+ const started = Date.now()
175
+ const cache = readCache(cachePath)
176
+ const files = listSessionLogs(root)
177
+ const stats = { sessions: files.length, scanned: 0, cacheHits: 0, dropped: 0, frames: 0 }
178
+ const buckets = []
179
+ const nextCache = { version: CACHE_VERSION, files: {} }
180
+ const tick = () => new Promise((resolve) => setImmediate(resolve))
181
+
182
+ for (const file of files) {
183
+ await tick()
184
+ let st
185
+ try {
186
+ st = fs.statSync(file)
187
+ } catch {
188
+ continue
189
+ }
190
+ const prev = cache.files[file]
191
+ if (prev && prev.size === st.size && prev.mtimeMs === Math.trunc(st.mtimeMs)) {
192
+ stats.cacheHits++
193
+ nextCache.files[file] = prev
194
+ for (const row of prev.buckets) buckets.push(row)
195
+ continue
196
+ }
197
+ let buf
198
+ try {
199
+ buf = fs.readFileSync(file)
200
+ } catch {
201
+ continue
202
+ }
203
+ const decoded = decodeAllFrames(buf)
204
+ const map = new Map()
205
+ foldSessionText(decoded.text, map)
206
+ stats.scanned++
207
+ stats.dropped += decoded.dropped
208
+ stats.frames += decoded.frames
209
+ const rows = []
210
+ for (const [key, b] of map) {
211
+ if (b[0] === 0 && b[1] === 0 && b[2] === 0 && b[3] === 0) continue
212
+ const [day, hour, model] = key.split('|')
213
+ rows.push([day, Number(hour), model, b[0], b[1], b[2], b[3]])
214
+ buckets.push([day, Number(hour), model, b[0], b[1], b[2], b[3]])
215
+ }
216
+ nextCache.files[file] = {
217
+ size: st.size,
218
+ mtimeMs: Math.trunc(st.mtimeMs),
219
+ frames: decoded.frames,
220
+ dropped: decoded.dropped,
221
+ buckets: rows,
222
+ }
223
+ }
224
+
225
+ writeCache(cachePath, nextCache)
226
+ stats.scanMs = Date.now() - started
227
+ return { buckets, stats }
228
+ }
229
+
230
+ const EMPTY = () => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 })
231
+
232
+ function addTo(acc, b) {
233
+ acc.input += b[0]
234
+ acc.output += b[1]
235
+ acc.cacheRead += b[2]
236
+ acc.cacheWrite += b[3]
237
+ acc.total += b[0] + b[1] + b[2] + b[3]
238
+ }
239
+
240
+ /** Instant at the start of `dayKey` + `hour` in Beijing time. */
241
+ function instantOf(day, hour) {
242
+ const [y, mo, d] = day.split('-').map(Number)
243
+ return Date.UTC(y, mo - 1, d, hour, 0, 0) - 8 * 3_600_000
244
+ }
245
+
246
+ /**
247
+ * Aggregate bucket rows into one range.
248
+ * @param {Array} buckets rows from `scanSessions`
249
+ * @param {{ days?: number|null, nowMs?: number }} opts `days = null` means all time
250
+ */
251
+ export function aggregate(buckets, opts = {}) {
252
+ const nowMs = opts.nowMs || Date.now()
253
+ const days = opts.days == null ? null : Number(opts.days)
254
+ const cutoff = days == null ? null : nowMs - days * DAY_MS
255
+ const out = {
256
+ days,
257
+ peak: EMPTY(),
258
+ off: EMPTY(),
259
+ classes: { flash: EMPTY(), pro: EMPTY() },
260
+ peakClasses: { flash: EMPTY(), pro: EMPTY() },
261
+ offClasses: { flash: EMPTY(), pro: EMPTY() },
262
+ classByModel: {},
263
+ other: { tokens: 0, models: {} },
264
+ byModel: {},
265
+ days_seen: new Set(),
266
+ }
267
+ for (const row of buckets) {
268
+ const [day, hour, model, i, o, cr, cw] = row
269
+ const b = [i, o, cr, cw]
270
+ const inst = instantOf(day, hour)
271
+ if (cutoff != null && inst < cutoff) continue
272
+ const peak = isPeakInstant(inst)
273
+ const cls = classifyModel(model)
274
+ out.days_seen.add(day)
275
+ addTo(peak ? out.peak : out.off, b)
276
+ const modelTot = (out.byModel[model] ||= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, peak: 0, off: 0 })
277
+ addTo(modelTot, b)
278
+ if (peak) modelTot.peak += i + o + cr + cw
279
+ else modelTot.off += i + o + cr + cw
280
+ if (cls) {
281
+ addTo(out.classes[cls], b)
282
+ addTo(peak ? out.peakClasses[cls] : out.offClasses[cls], b)
283
+ const cm = (out.classByModel[model] ||= { class: cls, peak: EMPTY(), off: EMPTY() })
284
+ addTo(peak ? cm.peak : cm.off, b)
285
+ } else {
286
+ out.other.tokens += i + o + cr + cw
287
+ out.other.models[model] = (out.other.models[model] || 0) + i + o + cr + cw
288
+ }
289
+ }
290
+
291
+ const costActual = {}
292
+ const costAllPeak = {}
293
+ const costAllOff = {}
294
+ let actual = 0
295
+ let allPeak = 0
296
+ let allOff = 0
297
+ for (const cls of ['flash', 'pro']) {
298
+ const price = PRICES[cls]
299
+ const a = bucketCost(price.peak, toB(out.peakClasses[cls])) + bucketCost(price.off, toB(out.offClasses[cls]))
300
+ const p = bucketCost(price.peak, toB(out.classes[cls]))
301
+ const o = bucketCost(price.off, toB(out.classes[cls]))
302
+ costActual[cls] = a
303
+ costAllPeak[cls] = p
304
+ costAllOff[cls] = o
305
+ actual += a
306
+ allPeak += p
307
+ allOff += o
308
+ }
309
+
310
+ const peakTotal = out.peak.total
311
+ const offTotal = out.off.total
312
+ const total = peakTotal + offTotal
313
+ const priced = out.classes.flash.total + out.classes.pro.total
314
+
315
+ return {
316
+ days,
317
+ since: cutoff == null ? null : bjDayKey(cutoff),
318
+ activeDays: out.days_seen.size,
319
+ tokens: {
320
+ peak: out.peak,
321
+ off: out.off,
322
+ total,
323
+ peakShare: total > 0 ? peakTotal / total : 0,
324
+ offShare: total > 0 ? offTotal / total : 0,
325
+ priced,
326
+ },
327
+ classes: out.classes,
328
+ models: Object.entries(out.byModel)
329
+ .map(([model, v]) => ({ model, cls: classifyModel(model), ...v }))
330
+ .sort((a, b) => b.total - a.total),
331
+ classByModel: Object.entries(out.classByModel)
332
+ .map(([model, v]) => {
333
+ const price = PRICES[v.class]
334
+ const peakCost = bucketCost(price.peak, toB(v.peak))
335
+ const offCost = bucketCost(price.off, toB(v.off))
336
+ return {
337
+ model,
338
+ cls: v.class,
339
+ peak: v.peak,
340
+ off: v.off,
341
+ peakCost,
342
+ offCost,
343
+ allPeakCost: bucketCost(price.peak, toB(v.peak)) + bucketCost(price.peak, toB(v.off)),
344
+ cost: peakCost + offCost,
345
+ }
346
+ })
347
+ .sort((a, b) => b.cost - a.cost),
348
+ other: {
349
+ tokens: out.other.tokens,
350
+ models: Object.entries(out.other.models)
351
+ .map(([model, tokens]) => ({ model, tokens }))
352
+ .sort((a, b) => b.tokens - a.tokens),
353
+ },
354
+ cost: {
355
+ actual,
356
+ allPeak,
357
+ allOff,
358
+ byClass: costActual,
359
+ allPeakByClass: costAllPeak,
360
+ allOffByClass: costAllOff,
361
+ saved: allPeak - actual,
362
+ savedPct: allPeak > 0 ? (allPeak - actual) / allPeak : 0,
363
+ },
364
+ }
365
+ }
366
+
367
+ function toB(acc) {
368
+ return [acc.input, acc.output, acc.cacheRead, acc.cacheWrite]
369
+ }
370
+
371
+ /** One payload for the client: the three ranges it offers. */
372
+ export async function buildReport(opts = {}) {
373
+ const nowMs = opts.nowMs || Date.now()
374
+ const { buckets, stats } = await scanSessions(opts)
375
+ return {
376
+ ok: true,
377
+ generatedAt: new Date(nowMs).toISOString(),
378
+ stats,
379
+ ranges: {
380
+ '7': aggregate(buckets, { days: 7, nowMs }),
381
+ '30': aggregate(buckets, { days: 30, nowMs }),
382
+ all: aggregate(buckets, { days: null, nowMs }),
383
+ },
384
+ cache: { file: cacheFile(), raw: false },
385
+ }
386
+ }
package/lib/zstd.js ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Multi-frame zstd decoder.
3
+ *
4
+ * A durable session log (`session.v<N>.jsonl.zstd`) is a *sequence* of zstd
5
+ * frames — one frame appended per batched write. `zlib.zstdDecompressSync`
6
+ * decodes only the FIRST frame and silently drops the rest, so we have to walk
7
+ * the physical frame boundaries ourselves: parse a frame header, walk its
8
+ * blocks to find the frame end, then decompress exactly that slice.
9
+ *
10
+ * A torn tail (a frame still being written) is expected: we stop at the last
11
+ * complete frame and report how many frames were dropped, which lets callers
12
+ * resync on the next scan.
13
+ */
14
+ import zlib from 'node:zlib'
15
+
16
+ const ZSTD_MAGIC = 0xfd2fb528
17
+
18
+ function isSkippableMagic(m) {
19
+ return m >= 0x184d2a50 && m <= 0x184d2a5f
20
+ }
21
+
22
+ function findMagic(buf, from) {
23
+ for (let i = from; i + 4 <= buf.length; i++) {
24
+ if (buf.readUInt32LE(i) === ZSTD_MAGIC) return i
25
+ }
26
+ return -1
27
+ }
28
+
29
+ /** Physical end offset of the frame starting at `off` (throws on a torn frame). */
30
+ function parseFrameEnd(buf, off) {
31
+ let p = off + 4
32
+ const fhd = buf[p]
33
+ p += 1
34
+ const fcsFlag = fhd >> 6
35
+ const single = (fhd >> 5) & 1
36
+ const checksum = (fhd >> 2) & 1
37
+ const dictId = fhd & 3
38
+ p += [0, 1, 2, 4][dictId]
39
+ if (!single) p += 1 // window descriptor
40
+ p += fcsFlag === 0 ? (single ? 1 : 0) : fcsFlag === 1 ? 2 : fcsFlag === 2 ? 4 : 8
41
+ let last = false
42
+ let guard = 0
43
+ while (!last) {
44
+ if (p + 3 > buf.length) throw new Error('torn block header')
45
+ const bh = buf.readUIntLE(p, 3)
46
+ p += 3
47
+ last = (bh & 1) === 1
48
+ const type = (bh >>> 1) & 3
49
+ const size = bh >>> 3
50
+ if (type === 3) throw new Error('reserved block type')
51
+ const payload = type === 1 ? 1 : size
52
+ if (p + payload > buf.length) throw new Error('torn block payload')
53
+ p += payload
54
+ if (++guard > 1e7) throw new Error('runaway blocks')
55
+ }
56
+ if (checksum) {
57
+ if (p + 4 > buf.length) throw new Error('torn checksum')
58
+ p += 4
59
+ }
60
+ return p
61
+ }
62
+
63
+ /**
64
+ * Decode every complete frame in `buf`.
65
+ * @param {Buffer} buf
66
+ * @param {(text: string) => void} [onFrame] per-frame UTF-8 text (optional)
67
+ * @returns {{ text: string, frames: number, dropped: number }}
68
+ */
69
+ export function decodeAllFrames(buf, onFrame) {
70
+ const parts = []
71
+ let off = 0
72
+ let frames = 0
73
+ let dropped = 0
74
+ while (off < buf.length) {
75
+ if (buf.length - off < 4) {
76
+ if (off < buf.length) dropped += 1
77
+ break
78
+ }
79
+ const magic = buf.readUInt32LE(off)
80
+ if (isSkippableMagic(magic)) {
81
+ // Skippable frame: 4-byte magic + 4-byte LE size + payload.
82
+ if (off + 8 > buf.length) {
83
+ dropped += 1
84
+ break
85
+ }
86
+ const size = buf.readUInt32LE(off + 4)
87
+ if (off + 8 + size > buf.length) {
88
+ dropped += 1
89
+ break
90
+ }
91
+ off += 8 + size
92
+ continue
93
+ }
94
+ if (magic !== ZSTD_MAGIC) {
95
+ const next = findMagic(buf, off + 1)
96
+ if (next < 0) break
97
+ off = next
98
+ continue
99
+ }
100
+ let end
101
+ try {
102
+ end = parseFrameEnd(buf, off)
103
+ } catch {
104
+ dropped += 1
105
+ break
106
+ }
107
+ const slice = buf.subarray(off, end)
108
+ let text
109
+ try {
110
+ text = zlib.zstdDecompressSync(slice).toString('utf8')
111
+ } catch {
112
+ dropped += 1
113
+ off = end
114
+ continue
115
+ }
116
+ frames += 1
117
+ if (onFrame) onFrame(text)
118
+ else parts.push(text)
119
+ off = end
120
+ }
121
+ return { text: parts.join(''), frames, dropped }
122
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "dsh-peak-hours",
3
- "version": "1.11.0",
3
+ "version": "1.12.1",
4
4
  "type": "module",
5
- "description": "Sidebar badge that marks the current DeepSeek peak-pricing window: 24-hour schedule bar, now-cursor, countdown to the next switch, click to copy the state.",
5
+ "description": "Sidebar badge that marks the current DeepSeek peak-pricing window (24-hour schedule bar, countdown, click to copy) plus a settings card that splits your logged usage into peak / off-peak tokens and compares what it actually cost against all-peak and all-off-peak.",
6
6
  "license": "MIT",
7
7
  "author": "run666888 <zhirunli6@gmail.com>",
8
8
  "homepage": "https://github.com/run666888/dsh-peak-hours#readme",
@@ -27,6 +27,8 @@
27
27
  "peak-hours",
28
28
  "off-peak",
29
29
  "pricing",
30
+ "cost",
31
+ "usage",
30
32
  "sidebar"
31
33
  ],
32
34
  "publishConfig": {
@@ -42,6 +44,7 @@
42
44
  "files": [
43
45
  "index.js",
44
46
  "client.js",
47
+ "lib",
45
48
  "cordis.patch.yml",
46
49
  "locale/*.json",
47
50
  "icon.svg",