dsh-tokenlens 1.7.6 → 1.7.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,17 @@
4
4
 
5
5
  **版本记录约定**:同一发布日只保留一个章节,标题取当日**最后一个**已发布版本号;当日多次发布的改动合并到该章节,并在标题下注明当日实际发布过的版本号(npm 上仍可安装这些独立版本)。若当日已有发布,后续小改动不再单独增加版本号,而是并入下一篇日志。这样日志按天规整、不出现同日多个小版本。
6
6
 
7
+ ## 1.7.7 — 2026-11-10
8
+
9
+ - **feat(ui): TPS 卡片锁定「当前供应商」**——不再跟随上方「当前供应商 / 全部 Harness」切换。这张卡片回答的是「我现在用的模型有多快」,所以始终使用所选供应商自己的日平均序列;没有供应商行时显示空态说明
10
+ - **feat(ui): 点曲线上的点可下钻当日小时级 TPS**——与 token 柱状图的「点某天 → 24 小时」交互一致:点击后曲线切换为该日 24 个本地小时的速率曲线,标题栏显示「2026-11-05 · 小时级」并提供「返回日视图」;再点同一天也会切回日视图
11
+ - 无样本的小时是 `null` 而不是 0,曲线在该处**断开**而不是跌到零(跌到零是假信息)
12
+ - 小时级读数同样是「输出 Token ÷ 模型自身耗时」,悬停显示 `09:00 · 120.0 tok/s(600 输出 ÷ 5.0s)`
13
+ - 该日没有任何小时级样本时显示说明文字,不画空图
14
+ - **feat(stats): Host 侧新增按小时的 TPS 采样**——`foldSession` 在记录每日速率的同时把样本写入按 epoch 小时分桶的 `speedHours`,聚合层再换算成本地 24 槽 `speedDayHours`,随 `speed.byDay[].hours` 下发(仅 provider 维度,合计序列不带)。缓存 schema 升至 7,首次会重扫
15
+ - 文档:`PROTOCOL.md` 补上 `hours` 结构与「null 表示无样本」的约定
16
+ - 测试:373 → 377(TPS 卡片不受作用域切换影响、点柱下钻与返回、无样本下钻的空态、按小时分桶与 null 槽位)
17
+
7
18
  ## 1.7.6 — 2026-11-06
8
19
 
9
20
  (当日已发布版本:v1.6.5 / v1.6.6 / v1.6.7 / v1.7.0 / v1.7.1 / v1.7.2 / v1.7.3 / v1.7.4 / v1.7.5 / v1.7.6)
package/PROTOCOL.md CHANGED
@@ -324,7 +324,8 @@ folder basename, used by the client to match a sidebar hover card; the full
324
324
  `cwd` is Host-only and never sent on the wire), the rolling 5-hour local window
325
325
  (`fiveHours`, DSH-only because external harnesses are day-granular), and the
326
326
  bounded `daily[]` series used by the chart view, the `speed` series behind the
327
- TPS trend (v1.7.0), and the subscription window (`effectiveDay` is the plan's
327
+ TPS trend (v1.7.0, with 24 local-hour slots per day for the drill-down in
328
+ v1.7.7), and the subscription window (`effectiveDay` is the plan's
328
329
  period start when the Host learned it, and `sinceEffective` is summed from the
329
330
  per-day buckets inside `[window start, today]` rather than borrowed from the
330
331
  30-day or all-time bucket — v1.7.2). Absolute local paths are
@@ -392,7 +393,12 @@ with `[path]`.
392
393
  "speed": {
393
394
  "byDay": [
394
395
  { "day": "2026-11-05", "outputTokens": 516951, "seconds": 8493.1,
395
- "tps": 60.9, "steps": 859 }
396
+ "tps": 60.9, "steps": 859,
397
+ // v1.7.7: 24 local-hour slots for the click-to-drill-down view. A slot
398
+ // with no sample is null rather than a zero, so the curve breaks
399
+ // instead of dipping to zero. Only provider-scoped series carry it.
400
+ "hours": [ null, null, …, { "outputTokens": 600, "seconds": 5,
401
+ "tps": 120, "steps": 1 }, … ] }
396
402
  ],
397
403
  "total": { "outputTokens": 939643, "seconds": 14123.9, "tps": 66.5, "steps": 1424 }
398
404
  },
package/README.en.md CHANGED
@@ -144,7 +144,7 @@ Host bridge route: `/api/tokenlens`; agent tool: `tokenlens_usage`.
144
144
 
145
145
  ## Development
146
146
 
147
- Node.js ≥ 24, zero runtime dependencies: `node --test` runs the 373 tests.
147
+ Node.js ≥ 24, zero runtime dependencies: `node --test` runs the 377 tests.
148
148
 
149
149
  Release: `node scripts/bump-version.mjs <version> "<note>"` updates `package.json`, `PLUGIN_VERSION` and `CHANGELOG.md`; see [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md).
150
150
 
package/README.md CHANGED
@@ -445,7 +445,7 @@ Host 半注册 `tokenlens_usage`,参数 `action`(默认 `snapshot`)与可
445
445
 
446
446
  ## 验证
447
447
 
448
- - `node --test`:**373 个自动化用例**,覆盖 Host 数据层、供应商适配器(含小米 MiMo 控制台 Cookie 适配器、Command Code 月度额度反推)、统计折叠、各 harness 读取、Design Plan 周期账本与额度计价、TPS 速度序列、窗口锚点切片、工作区自动识别、插件集成与浏览器端渲染 / 交互。
448
+ - `node --test`:**377 个自动化用例**,覆盖 Host 数据层、供应商适配器(含小米 MiMo 控制台 Cookie 适配器、Command Code 月度额度反推)、统计折叠、各 harness 读取、Design Plan 周期账本与额度计价、TPS 速度序列、窗口锚点切片、工作区自动识别、插件集成与浏览器端渲染 / 交互。
449
449
  - 在真实环境中验证过 **5 个 harness** 的数据源;本机统计与独立的 SQLite 查询、独立的日志解析逐项对账(差额为 0),跨 harness 合并与供应商映射算术自洽。
450
450
  - 供应商接口按各官方文档与参考客户端行为验证;未配置凭据时 chip 静默隐藏,接口异常时按可重试错误降级。
451
451
 
package/client.js CHANGED
@@ -233,6 +233,12 @@ window.__ModuleLoader__.load({
233
233
  statsSpeedTotal: '均值 {tps} tok/s',
234
234
  statsSpeedPeak: '峰值 {tps} tok/s',
235
235
  statsSpeedEmpty: '暂无可计算的速度样本(需要 step 计时数据)',
236
+ statsSpeedHourTitle: '{date} · 小时级',
237
+ statsSpeedHourHint: '同一天按本地小时切分;仍是输出 Token ÷ 模型自身耗时,无样本的小时留空',
238
+ statsSpeedHourEmpty: '该日没有可下钻的小时级样本',
239
+ statsSpeedHourBarTitle: '{hour} · {tps} tok/s({tokens} 输出 ÷ {seconds}s)',
240
+ statsSpeedHourAria: '{date} 的小时级输出速度',
241
+ statsSpeedBack: '返回日视图',
236
242
  statsSpeedBarTitle: '{date} · {tps} tok/s({tokens} 输出 ÷ {seconds}s)',
237
243
  statsSpeedDetail: '{tokens} 输出 ÷ {seconds}s',
238
244
  statsLast7Days: '最近7天',
@@ -510,6 +516,13 @@ window.__ModuleLoader__.load({
510
516
  statsSpeedTotal: 'avg {tps} tok/s',
511
517
  statsSpeedPeak: 'peak {tps} tok/s',
512
518
  statsSpeedEmpty: 'No speed samples yet (needs step timing data)',
519
+ statsSpeedHourTitle: '{date} · hourly',
520
+ statsSpeedHourHint:
521
+ 'The same day split by local hour; still output tokens ÷ model time, hours without samples stay empty',
522
+ statsSpeedHourEmpty: 'No hourly samples to drill into for this day',
523
+ statsSpeedHourBarTitle: '{hour} · {tps} tok/s ({tokens} output ÷ {seconds}s)',
524
+ statsSpeedHourAria: 'Hourly output speed for {date}',
525
+ statsSpeedBack: 'Back to daily',
513
526
  statsSpeedBarTitle: '{date} · {tps} tok/s ({tokens} output ÷ {seconds}s)',
514
527
  statsSpeedDetail: '{tokens} output ÷ {seconds}s',
515
528
  statsLast7Days: 'Last 7 days',
@@ -1611,13 +1624,31 @@ window.__ModuleLoader__.load({
1611
1624
  const byDay = Array.isArray(raw.byDay)
1612
1625
  ? raw.byDay
1613
1626
  .filter((entry) => entry !== null && typeof entry === 'object' && typeof entry.day === 'string')
1614
- .map((entry) => ({
1615
- day: entry.day,
1616
- outputTokens: numberOrNull(entry.outputTokens) ?? 0,
1617
- seconds: numberOrNull(entry.seconds) ?? 0,
1618
- tps: numberOrNull(entry.tps),
1619
- steps: numberOrNull(entry.steps) ?? 0,
1620
- }))
1627
+ .map((entry) => {
1628
+ // v1.7.7: the day's 24 local-hour slots (null where there is no
1629
+ // sample), used by the click-to-drill-down hourly view.
1630
+ const hours = Array.isArray(entry.hours)
1631
+ ? entry.hours.map((cell) => {
1632
+ if (cell === null || typeof cell !== 'object') return null
1633
+ const tps = numberOrNull(cell.tps)
1634
+ if (tps === null || tps < 0) return null
1635
+ return {
1636
+ outputTokens: numberOrNull(cell.outputTokens) ?? 0,
1637
+ seconds: numberOrNull(cell.seconds) ?? 0,
1638
+ tps,
1639
+ steps: numberOrNull(cell.steps) ?? 0,
1640
+ }
1641
+ })
1642
+ : null
1643
+ return {
1644
+ day: entry.day,
1645
+ outputTokens: numberOrNull(entry.outputTokens) ?? 0,
1646
+ seconds: numberOrNull(entry.seconds) ?? 0,
1647
+ tps: numberOrNull(entry.tps),
1648
+ steps: numberOrNull(entry.steps) ?? 0,
1649
+ hours: hours === null ? null : hours.slice(0, 24),
1650
+ }
1651
+ })
1621
1652
  .filter((entry) => entry.tps !== null && entry.tps >= 0)
1622
1653
  : []
1623
1654
  const totalRaw = raw.total !== null && typeof raw.total === 'object' ? raw.total : {}
@@ -2107,6 +2138,9 @@ window.__ModuleLoader__.load({
2107
2138
  chartHover: null,
2108
2139
  // v1.7.2: the hovered point of the output-speed (TPS) curve.
2109
2140
  speedHover: null,
2141
+ // v1.7.7: the day whose 24 local hours the TPS card charts, or null
2142
+ // (daily trend). Clicking a point on the curve toggles it.
2143
+ speedDay: null,
2110
2144
  // v1.2.0 chart drill-down: the clicked day whose 0..23 hour histogram
2111
2145
  // is charted instead of the daily trend, or null (daily trend).
2112
2146
  chartDay: null,
@@ -2219,6 +2253,9 @@ window.__ModuleLoader__.load({
2219
2253
  // v1.7.6: the TPS hover index is view state like the chart's, so it must
2220
2254
  // not survive a provider switch (the series behind it changed).
2221
2255
  speedHover: null,
2256
+ // v1.7.7: an hourly TPS drill-down belongs to one provider's series, so a
2257
+ // provider switch returns to the daily trend.
2258
+ speedDay: null,
2222
2259
  }
2223
2260
  }
2224
2261
 
@@ -3503,6 +3540,10 @@ window.__ModuleLoader__.load({
3503
3540
  .dsh-tokenlens-speed-yaxis{display:flex;flex-direction:column;justify-content:space-between;align-items:flex-end;color:var(--dsw-alias-label-tertiary);font-size:10px;line-height:1;font-variant-numeric:tabular-nums;flex:0 0 auto;padding:1px 0}
3504
3541
  .dsh-tokenlens-speed-plot{position:relative;flex:1 1 auto;min-width:0}
3505
3542
  .dsh-tokenlens-speed-dot{position:absolute;width:6px;height:6px;margin:-3px 0 0 -3px;border-radius:50%;background:#2e9e5b;box-shadow:0 0 0 2px var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.35));pointer-events:none}
3543
+ /* v1.7.7: the daily curve is clickable (drills into that day's hours). */
3544
+ .dsh-tokenlens-speed-svg[data-speed-mode=day] rect[data-speed-day]{cursor:pointer}
3545
+ .dsh-tokenlens-link-button{border:0;background:none;padding:0 0 0 6px;color:var(--dsw-alias-label-secondary,currentColor);font:inherit;font-size:11px;line-height:inherit;text-decoration:underline;cursor:pointer}
3546
+ .dsh-tokenlens-link-button:hover,.dsh-tokenlens-link-button:focus-visible{color:var(--dsw-alias-label-primary,currentColor)}
3506
3547
  .dsh-tokenlens-speed-axis{display:flex;justify-content:space-between;gap:6px;color:var(--dsw-alias-label-tertiary);font-size:10px;line-height:1.2;font-variant-numeric:tabular-nums;min-width:0}
3507
3548
  .dsh-tokenlens-speed-axis>span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
3508
3549
  @media (max-width:680px){.dsh-tokenlens-chart-layout-row>*{flex-basis:100%!important}}
@@ -4729,6 +4770,24 @@ window.__ModuleLoader__.load({
4729
4770
  if (store.get().speedHover !== next) store.set({ speedHover: next })
4730
4771
  }
4731
4772
 
4773
+ /**
4774
+ * Drill the TPS card into one day's 24 local hours (v1.7.7), or back out.
4775
+ *
4776
+ * Toggling mirrors the token chart's `activateChartDay`: clicking the already
4777
+ * active day returns to the daily trend, so no separate "back" button is
4778
+ * needed — but one is still offered in the header for discoverability.
4779
+ */
4780
+ function activateSpeedDay(day) {
4781
+ if (typeof day !== 'string' || day === '') return
4782
+ const next = store.get().speedDay === day ? null : day
4783
+ store.set({ speedDay: next, speedHover: null })
4784
+ }
4785
+
4786
+ /** Leave the hourly TPS drill-down and chart the daily trend again (v1.7.7). */
4787
+ function clearSpeedDay() {
4788
+ if (store.get().speedDay !== null) store.set({ speedDay: null, speedHover: null })
4789
+ }
4790
+
4732
4791
  /** The active today bucket of the current stats scope (v0.27.2). */
4733
4792
  function todayBucketOf(ctx) {
4734
4793
  return ctx.providerScoped ? ctx.currentRow?.today ?? null : ctx.stats?.today ?? null
@@ -6136,17 +6195,33 @@ window.__ModuleLoader__.load({
6136
6195
  }
6137
6196
 
6138
6197
  /**
6139
- * The output-speed (TPS) trend card (v1.7.0), shown under 明细.
6198
+ * The output-speed (TPS) trend card (v1.7.0, reworked in v1.7.7).
6199
+ *
6200
+ * Always the CURRENT provider's series (v1.7.7): the user reads this card as
6201
+ * "how fast is the model I am using right now", so it deliberately ignores
6202
+ * the 当前供应商 / 全部 Harness switch above it.
6140
6203
  *
6141
6204
  * `tps` is output tokens over the model's OWN request time (`step/start` to
6142
- * `assistant/message`), so the step's tool calls do not dilute it — but
6205
+ * `assistant/message`), so a step's tool calls do not dilute it — but
6143
6206
  * reasoning tokens are included in "output", which the hint says.
6207
+ *
6208
+ * Clicking a point drills into that day's 24 local hours (v1.7.7), the same
6209
+ * interaction the token chart uses for its daily bars.
6144
6210
  */
6145
6211
  function buildSpeedSection(state, ctx, t) {
6146
- const stats = ctx.stats
6147
- const scoped = ctx.providerScoped ? ctx.currentRow?.speed ?? null : stats?.speed ?? null
6148
- const series = scoped === null || scoped === undefined ? null : scoped
6212
+ // v1.7.7: always provider-scoped, whatever the scope tabs say.
6213
+ const series = ctx.currentRow?.speed ?? null
6149
6214
  const byDay = series === null ? [] : series.byDay
6215
+ const drilledDay = typeof state.speedDay === 'string' ? state.speedDay : null
6216
+ const drilled = drilledDay === null ? null : byDay.find((entry) => entry.day === drilledDay) ?? null
6217
+ // v1.7.7: being drilled into a day is a MODE, independent of whether that
6218
+ // day actually carries hourly samples — otherwise a day without them would
6219
+ // silently fall back to the daily chart instead of saying so.
6220
+ const hourlyMode = drilledDay !== null && drilled !== null
6221
+ const hourly = hourlyMode && Array.isArray(drilled.hours) ? drilled.hours : null
6222
+ const pointsSeries = hourlyMode ? hourly ?? [] : byDay
6223
+ const dailyMode = !hourlyMode
6224
+
6150
6225
  const head = h(
6151
6226
  'div',
6152
6227
  { key: 'head', className: 'dsh-tokenlens-section-head' },
@@ -6156,205 +6231,266 @@ window.__ModuleLoader__.load({
6156
6231
  Icon('chart', 14),
6157
6232
  t('statsSpeedTitle'),
6158
6233
  ),
6159
- series !== null && series.total.tps !== null
6160
- ? h(
6161
- 'span',
6162
- { className: 'dsh-tokenlens-hint', 'data-speed-total': String(series.total.tps) },
6163
- t('statsSpeedTotal', { tps: series.total.tps.toFixed(1) }),
6164
- )
6165
- : null,
6166
- )
6167
- const body =
6168
- byDay.length === 0
6169
- ? h(
6170
- 'div',
6171
- { key: 'empty', className: 'dsh-tokenlens-chart-empty', 'data-speed-empty': 'true' },
6172
- h('span', { key: 'hint', className: 'dsh-tokenlens-hint' }, t('statsSpeedEmpty')),
6173
- )
6174
- : (() => {
6175
- // v1.7.1: a CURVE, not bars — the trend is a rate, and a line reads
6176
- // it far better than per-day columns. The SVG scales to the card
6177
- // width; `preserveAspectRatio="none"` lets it fill without keeping
6178
- // the viewBox ratio, and the per-day hover targets are transparent
6179
- // columns so nothing is distorted.
6180
- const width = 320
6181
- const height = 90
6182
- const pad = 4
6183
- const max = Math.max(...byDay.map((entry) => entry.tps ?? 0), 1)
6184
- const stepX = byDay.length > 1 ? (width - pad * 2) / (byDay.length - 1) : 0
6185
- const points = byDay.map((entry, index) => {
6186
- const x = byDay.length > 1 ? pad + stepX * index : width / 2
6187
- const y = height - pad - ((entry.tps ?? 0) / max) * (height - pad * 2)
6188
- return { x, y }
6189
- })
6190
- const line = points.map((point) => `${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(' ')
6191
- const area = `${pad.toFixed(1)},${(height - pad).toFixed(1)} ${line} ${(width - pad).toFixed(1)},${(height - pad).toFixed(1)}`
6192
- const columnWidth = byDay.length > 1 ? stepX : width - pad * 2
6193
- const columns = byDay.map((entry, index) =>
6194
- h(
6195
- 'rect',
6196
- {
6197
- key: `hit-${entry.day}-${index}`,
6198
- // v1.7.6: clamp the hit column into the viewBox — a single-day
6199
- // series used to start at a negative x and lose half its area.
6200
- x: Math.max(0, Math.min(width - 1, points[index].x - columnWidth / 2)).toFixed(1),
6201
- y: 0,
6202
- width: Math.max(1, Math.min(columnWidth, width)).toFixed(1),
6203
- height: String(height),
6204
- fill: 'transparent',
6205
- 'data-speed-day': entry.day,
6206
- 'data-speed-tps': String(entry.tps ?? 0),
6207
- },
6208
- h(
6209
- 'title',
6210
- { key: 'tip' },
6211
- t('statsSpeedBarTitle', {
6212
- date: entry.day,
6213
- tps: (entry.tps ?? 0).toFixed(1),
6214
- tokens: formatTokensPrecise(entry.outputTokens),
6215
- seconds: entry.seconds.toFixed(1),
6216
- }),
6217
- ),
6218
- ),
6234
+ // While drilled in, the header says which day and offers a way back.
6235
+ hourly === null
6236
+ ? series !== null && series.total.tps !== null
6237
+ ? h(
6238
+ 'span',
6239
+ { className: 'dsh-tokenlens-hint', 'data-speed-total': String(series.total.tps) },
6240
+ t('statsSpeedTotal', { tps: series.total.tps.toFixed(1) }),
6219
6241
  )
6220
- const svg = h(
6221
- 'svg',
6242
+ : null
6243
+ : h(
6244
+ 'span',
6245
+ { key: 'drill', className: 'dsh-tokenlens-hint', 'data-speed-drill': drilled.day },
6246
+ t('statsSpeedHourTitle', { date: drilled.day }),
6247
+ h(
6248
+ 'button',
6222
6249
  {
6223
- key: 'svg',
6224
- className: 'dsh-tokenlens-speed-svg',
6225
- 'data-speed-plot': 'true',
6226
- viewBox: `0 0 ${width} ${height}`,
6227
- preserveAspectRatio: 'none',
6228
- role: 'img',
6229
- 'aria-label': t('statsSpeedTitle'),
6250
+ key: 'back',
6251
+ type: 'button',
6252
+ className: 'dsh-tokenlens-link-button',
6253
+ 'data-speed-back': 'true',
6254
+ onClick: () => clearSpeedDay(),
6230
6255
  },
6231
- h('polygon', {
6232
- key: 'area',
6256
+ t('statsSpeedBack'),
6257
+ ),
6258
+ ),
6259
+ )
6260
+
6261
+ const plotOf = () => {
6262
+ if (pointsSeries.length === 0) {
6263
+ return h(
6264
+ 'div',
6265
+ { key: 'empty', className: 'dsh-tokenlens-chart-empty', 'data-speed-empty': 'true' },
6266
+ h('span', { key: 'hint', className: 'dsh-tokenlens-hint' }, t('statsSpeedEmpty')),
6267
+ )
6268
+ }
6269
+ // The SVG scales to the card width; `preserveAspectRatio="none"` lets it
6270
+ // fill without keeping the viewBox ratio, and the hover targets are
6271
+ // transparent columns so nothing is distorted.
6272
+ const width = 320
6273
+ const height = 90
6274
+ const pad = 4
6275
+ const real = pointsSeries.filter((entry) => entry !== null)
6276
+ const max = Math.max(...real.map((entry) => entry.tps ?? 0), 1)
6277
+ const count = pointsSeries.length
6278
+ const stepX = count > 1 ? (width - pad * 2) / (count - 1) : 0
6279
+ // A null slot (an hour with no sample) breaks the line instead of being
6280
+ // drawn as a dip to zero, which would be a lie.
6281
+ let segment = []
6282
+ const segments = []
6283
+ pointsSeries.forEach((entry, index) => {
6284
+ if (entry === null) {
6285
+ if (segment.length > 0) segments.push(segment)
6286
+ segment = []
6287
+ return
6288
+ }
6289
+ const x = count > 1 ? pad + stepX * index : width / 2
6290
+ const y = height - pad - ((entry.tps ?? 0) / max) * (height - pad * 2)
6291
+ segment.push({ x, y })
6292
+ })
6293
+ if (segment.length > 0) segments.push(segment)
6294
+ const xOf = (index) => (count > 1 ? pad + stepX * index : width / 2)
6295
+ const yOf = (entry) => height - pad - ((entry.tps ?? 0) / max) * (height - pad * 2)
6296
+ const columnWidth = count > 1 ? stepX : width - pad * 2
6297
+ const columns = pointsSeries.map((entry, index) => {
6298
+ if (entry === null) return null
6299
+ const label = dailyMode
6300
+ ? t('statsSpeedBarTitle', {
6301
+ date: entry.day,
6302
+ tps: (entry.tps ?? 0).toFixed(1),
6303
+ tokens: formatTokensPrecise(entry.outputTokens),
6304
+ seconds: entry.seconds.toFixed(1),
6305
+ })
6306
+ : t('statsSpeedHourBarTitle', {
6307
+ hour: `${String(index).padStart(2, '0')}:00`,
6308
+ tps: (entry.tps ?? 0).toFixed(1),
6309
+ tokens: formatTokensPrecise(entry.outputTokens),
6310
+ seconds: entry.seconds.toFixed(1),
6311
+ })
6312
+ return h(
6313
+ 'rect',
6314
+ {
6315
+ key: `hit-${index}`,
6316
+ x: Math.max(0, Math.min(width - 1, xOf(index) - columnWidth / 2)).toFixed(1),
6317
+ y: 0,
6318
+ width: Math.max(1, Math.min(columnWidth, width)).toFixed(1),
6319
+ height: String(height),
6320
+ fill: 'transparent',
6321
+ // In daily mode the column is also the drill-down target.
6322
+ ...(dailyMode ? { 'data-speed-day': entry.day, onClick: () => activateSpeedDay(entry.day) } : {}),
6323
+ ...(dailyMode ? {} : { 'data-speed-hour': String(index) }),
6324
+ 'data-speed-tps': String(entry.tps ?? 0),
6325
+ },
6326
+ h('title', { key: 'tip' }, label),
6327
+ )
6328
+ })
6329
+ const lineNodes = segments.map((segment, index) => {
6330
+ const line = segment.map((point) => `${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(' ')
6331
+ const area =
6332
+ segment.length > 1
6333
+ ? `${segment[0].x.toFixed(1)},${(height - pad).toFixed(1)} ${line} ${segment[segment.length - 1].x.toFixed(1)},${(height - pad).toFixed(1)}`
6334
+ : null
6335
+ return [
6336
+ area === null
6337
+ ? null
6338
+ : h('polygon', {
6339
+ key: `area-${index}`,
6233
6340
  className: 'dsh-tokenlens-speed-area',
6234
6341
  points: area,
6235
6342
  fill: 'rgba(46,158,91,.18)',
6236
6343
  'aria-hidden': 'true',
6237
6344
  }),
6238
- h('polyline', {
6239
- key: 'line',
6240
- className: 'dsh-tokenlens-speed-line',
6241
- points: line,
6242
- fill: 'none',
6243
- stroke: '#2e9e5b',
6244
- 'stroke-width': '1.6',
6245
- 'stroke-linecap': 'round',
6246
- 'stroke-linejoin': 'round',
6247
- 'vector-effect': 'non-scaling-stroke',
6248
- 'aria-hidden': 'true',
6249
- }),
6250
- columns,
6251
- )
6252
- const hoverIndex =
6253
- Number.isInteger(state.speedHover) && state.speedHover >= 0 && state.speedHover < byDay.length
6254
- ? state.speedHover
6255
- : null
6256
- const hovered = hoverIndex === null ? null : byDay[hoverIndex]
6257
- const handleMove = (event) => {
6258
- const index = chartIndexFromEvent(event, byDay.length)
6259
- if (index !== null) setSpeedHover(index)
6260
- }
6261
- const handleLeave = () => setSpeedHover(null)
6262
- // v1.7.2: a Y axis, like the native charts — the curve alone gives no
6263
- // sense of magnitude.
6264
- const ticks = [max, max / 2, 0]
6265
- const yAxis = h(
6266
- 'div',
6267
- { key: 'y', className: 'dsh-tokenlens-speed-yaxis', 'aria-hidden': 'true' },
6268
- ticks.map((value, index) =>
6269
- h('span', { key: `tick-${index}`, className: 'dsh-tokenlens-chart-tick' }, `${Math.round(value)}`),
6270
- ),
6271
- )
6272
- const crosshair =
6273
- hoverIndex === null
6274
- ? null
6275
- : h('div', {
6276
- key: 'cross',
6277
- className: 'dsh-tokenlens-chart-crosshair',
6278
- 'data-speed-crosshair': String(hoverIndex),
6279
- 'aria-hidden': 'true',
6280
- style: { left: `${(points[hoverIndex].x / width) * 100}%` },
6281
- })
6282
- const marker =
6283
- hoverIndex === null
6284
- ? null
6285
- : h('div', {
6286
- key: 'dot',
6287
- className: 'dsh-tokenlens-speed-dot',
6288
- 'aria-hidden': 'true',
6289
- style: {
6290
- left: `${(points[hoverIndex].x / width) * 100}%`,
6291
- top: `${(points[hoverIndex].y / height) * 100}%`,
6292
- },
6293
- })
6294
- const readout =
6295
- hovered === null
6296
- ? null
6297
- : h(
6298
- 'div',
6299
- {
6300
- key: 'readout',
6301
- className: 'dsh-tokenlens-chart-readout',
6302
- 'data-speed-readout': 'true',
6303
- role: 'status',
6304
- style:
6305
- hoverIndex === 0
6306
- ? { left: '0%' }
6307
- : hoverIndex === byDay.length - 1
6308
- ? { right: '0%' }
6309
- : {
6310
- left: `${(points[hoverIndex].x / width) * 100}%`,
6311
- transform: 'translateX(-50%)',
6312
- },
6313
- },
6314
- `${hovered.day} · ${(hovered.tps ?? 0).toFixed(1)} tok/s`,
6315
- ` · ${t('statsSpeedDetail', {
6316
- tokens: formatTokensPrecise(hovered.outputTokens),
6317
- seconds: hovered.seconds.toFixed(1),
6318
- })}`,
6319
- )
6320
- const axis = h(
6321
- 'div',
6322
- { key: 'axis', className: 'dsh-tokenlens-speed-axis', 'aria-hidden': 'true' },
6323
- h('span', { key: 'first' }, byDay[0].day),
6324
- byDay.length > 1 ? h('span', { key: 'last' }, byDay[byDay.length - 1].day) : null,
6325
- )
6326
- return h(
6345
+ h('polyline', {
6346
+ key: `line-${index}`,
6347
+ className: 'dsh-tokenlens-speed-line',
6348
+ points: line,
6349
+ fill: 'none',
6350
+ stroke: '#2e9e5b',
6351
+ 'stroke-width': '1.6',
6352
+ 'stroke-linecap': 'round',
6353
+ 'stroke-linejoin': 'round',
6354
+ 'vector-effect': 'non-scaling-stroke',
6355
+ 'aria-hidden': 'true',
6356
+ }),
6357
+ ].filter(Boolean)
6358
+ })
6359
+ const svg = h(
6360
+ 'svg',
6361
+ {
6362
+ key: 'svg',
6363
+ className: 'dsh-tokenlens-speed-svg',
6364
+ 'data-speed-mode': dailyMode ? 'day' : 'hour',
6365
+ viewBox: `0 0 ${width} ${height}`,
6366
+ preserveAspectRatio: 'none',
6367
+ role: 'img',
6368
+ 'aria-label': dailyMode ? t('statsSpeedTitle') : t('statsSpeedHourAria', { date: drilled.day }),
6369
+ },
6370
+ lineNodes.flat(),
6371
+ columns.filter(Boolean),
6372
+ )
6373
+ const hoverIndex =
6374
+ Number.isInteger(state.speedHover) && state.speedHover >= 0 && state.speedHover < count
6375
+ ? state.speedHover
6376
+ : null
6377
+ const hovered = hoverIndex === null ? null : pointsSeries[hoverIndex]
6378
+ const handleMove = (event) => {
6379
+ const index = chartIndexFromEvent(event, count)
6380
+ if (index !== null && pointsSeries[index] !== null) setSpeedHover(index)
6381
+ }
6382
+ const handleLeave = () => setSpeedHover(null)
6383
+ const ticks = [max, max / 2, 0]
6384
+ const yAxis = h(
6385
+ 'div',
6386
+ { key: 'y', className: 'dsh-tokenlens-speed-yaxis', 'aria-hidden': 'true' },
6387
+ ticks.map((value, index) =>
6388
+ h('span', { key: `tick-${index}`, className: 'dsh-tokenlens-chart-tick' }, `${Math.round(value)}`),
6389
+ ),
6390
+ )
6391
+ const crosshair =
6392
+ hovered === null || hovered === undefined
6393
+ ? null
6394
+ : h('div', {
6395
+ key: 'cross',
6396
+ className: 'dsh-tokenlens-chart-crosshair',
6397
+ 'data-speed-crosshair': String(hoverIndex),
6398
+ 'aria-hidden': 'true',
6399
+ style: { left: `${(xOf(hoverIndex) / width) * 100}%` },
6400
+ })
6401
+ const marker =
6402
+ hovered === null || hovered === undefined
6403
+ ? null
6404
+ : h('div', {
6405
+ key: 'dot',
6406
+ className: 'dsh-tokenlens-speed-dot',
6407
+ 'aria-hidden': 'true',
6408
+ style: {
6409
+ left: `${(xOf(hoverIndex) / width) * 100}%`,
6410
+ top: `${(yOf(hovered) / height) * 100}%`,
6411
+ },
6412
+ })
6413
+ const readoutLabel = dailyMode ? hovered?.day : `${String(hoverIndex).padStart(2, '0')}:00`
6414
+ const readout =
6415
+ hovered === null || hovered === undefined
6416
+ ? null
6417
+ : h(
6327
6418
  'div',
6328
- { key: 'plot', className: 'dsh-tokenlens-speed-body' },
6329
- yAxis,
6330
- h(
6331
- 'div',
6332
- {
6333
- key: 'plotarea',
6334
- className: 'dsh-tokenlens-speed-plot',
6335
- 'data-speed-plot': 'true',
6336
- onMouseMove: handleMove,
6337
- onMouseLeave: handleLeave,
6338
- onPointerMove: handleMove,
6339
- onPointerLeave: handleLeave,
6340
- },
6341
- svg,
6342
- crosshair,
6343
- marker,
6344
- readout,
6345
- axis,
6346
- ),
6419
+ {
6420
+ key: 'readout',
6421
+ className: 'dsh-tokenlens-chart-readout',
6422
+ 'data-speed-readout': 'true',
6423
+ role: 'status',
6424
+ style:
6425
+ hoverIndex === 0
6426
+ ? { left: '0%' }
6427
+ : hoverIndex === count - 1
6428
+ ? { right: '0%' }
6429
+ : {
6430
+ left: `${(xOf(hoverIndex) / width) * 100}%`,
6431
+ transform: 'translateX(-50%)',
6432
+ },
6433
+ },
6434
+ `${readoutLabel} · ${(hovered.tps ?? 0).toFixed(1)} tok/s`,
6435
+ ` · ${t('statsSpeedDetail', {
6436
+ tokens: formatTokensPrecise(hovered.outputTokens),
6437
+ seconds: hovered.seconds.toFixed(1),
6438
+ })}`,
6347
6439
  )
6348
- })()
6440
+ const axis = h(
6441
+ 'div',
6442
+ { key: 'axis', className: 'dsh-tokenlens-speed-axis', 'aria-hidden': 'true' },
6443
+ h('span', { key: 'first' }, dailyMode ? (pointsSeries[0]?.day ?? '') : '00:00'),
6444
+ h('span', { key: 'last' }, dailyMode ? (pointsSeries[count - 1]?.day ?? '') : '23:00'),
6445
+ )
6446
+ return h(
6447
+ 'div',
6448
+ { key: 'plot', className: 'dsh-tokenlens-speed-body' },
6449
+ yAxis,
6450
+ h(
6451
+ 'div',
6452
+ {
6453
+ key: 'plotarea',
6454
+ className: `dsh-tokenlens-speed-plot${dailyMode ? '' : ' dsh-tokenlens-speed-plot-hourly'}`,
6455
+ 'data-speed-mode': dailyMode ? 'day' : 'hour',
6456
+ onMouseMove: handleMove,
6457
+ onMouseLeave: handleLeave,
6458
+ onPointerMove: handleMove,
6459
+ onPointerLeave: handleLeave,
6460
+ },
6461
+ svg,
6462
+ crosshair,
6463
+ marker,
6464
+ readout,
6465
+ axis,
6466
+ ),
6467
+ )
6468
+ }
6469
+
6470
+ // A drilled-in day with no hourly samples explains itself instead of
6471
+ // silently drawing nothing.
6472
+ const allEmpty =
6473
+ pointsSeries.length === 0 || pointsSeries.every((entry) => entry === null || entry === undefined)
6474
+ const body = allEmpty
6475
+ ? h(
6476
+ 'div',
6477
+ { key: 'empty', className: 'dsh-tokenlens-chart-empty', 'data-speed-empty': 'true' },
6478
+ h('span', { key: 'hint', className: 'dsh-tokenlens-hint' }, hourlyMode ? t('statsSpeedHourEmpty') : t('statsSpeedEmpty')),
6479
+ )
6480
+ : plotOf()
6481
+
6349
6482
  return h(
6350
6483
  'div',
6351
6484
  { key: 'speed', className: 'dsh-tokenlens-section', 'data-speed-section': 'true' },
6352
6485
  head,
6353
- h('div', { key: 'hint', className: 'dsh-tokenlens-subtitle' }, t('statsSpeedHint')),
6486
+ h(
6487
+ 'div',
6488
+ { key: 'hint', className: 'dsh-tokenlens-subtitle' },
6489
+ dailyMode ? t('statsSpeedHint') : t('statsSpeedHourHint'),
6490
+ ),
6354
6491
  body,
6355
6492
  )
6356
6493
  }
6357
-
6358
6494
  function buildStatsDetails(state, ctx, t) {
6359
6495
  const { stats, hasMonth } = ctx
6360
6496
  if (stats === null) return null
package/index.js CHANGED
@@ -84,7 +84,7 @@ export const inject = []
84
84
  export const PLUGIN_NAME = 'dsh-tokenlens'
85
85
 
86
86
  /** Plugin version; mirrors package.json (kept here so diagnostics need no fs read). */
87
- export const PLUGIN_VERSION = '1.7.6'
87
+ export const PLUGIN_VERSION = '1.7.7'
88
88
 
89
89
  /**
90
90
  * Providers whose monthly allowance depends on the SELECTED model (v1.7.6).
@@ -61,7 +61,7 @@ const DECODES_CONCATENATED_FRAMES = (() => {
61
61
  const SESSION_FILE = 'session.v4.jsonl.zstd'
62
62
 
63
63
  /** Cache schema version; a mismatch discards the cache. */
64
- const CACHE_VERSION = 6
64
+ const CACHE_VERSION = 7
65
65
 
66
66
  /**
67
67
  * How many days the daily series (and therefore the 图表 mode and the
@@ -249,6 +249,9 @@ export function foldSession(records) {
249
249
  // `assistant/message.time − step/start.time`: the step's tool calls are emitted
250
250
  // AFTER the message, so they do not dilute it.
251
251
  const speed = Object.create(null)
252
+ // v1.7.7: the same samples bucketed per epoch HOUR, so the TPS curve can be
253
+ // drilled into one day's 24 local hours (mirrors `hours` above).
254
+ const speedHours = Object.create(null)
252
255
  const stepStarts = new Map()
253
256
  let latestTime = 0
254
257
  let sessionCwd = null
@@ -341,6 +344,13 @@ export function foldSession(records) {
341
344
  slot.output += usage.output
342
345
  slot.seconds += elapsedMs / 1000
343
346
  slot.steps += 1
347
+ // v1.7.7: the hour bucket reuses `hour`, already computed for the token
348
+ // histogram, so both drill-downs describe exactly the same slot.
349
+ const hourSpeed = (speedHours[hour] ??= { providers: Object.create(null) })
350
+ const hourSlot = (hourSpeed.providers[attributedProvider] ??= { output: 0, seconds: 0, steps: 0 })
351
+ hourSlot.output += usage.output
352
+ hourSlot.seconds += elapsedMs / 1000
353
+ hourSlot.steps += 1
344
354
  stepStarts.delete(`${turn}:${step}`)
345
355
  }
346
356
  }
@@ -361,7 +371,7 @@ export function foldSession(records) {
361
371
  if (Number(key) < cutoff) delete hours[key]
362
372
  }
363
373
  }
364
- return { days, providers, models, hours, workspaces, session, speed }
374
+ return { days, providers, models, hours, workspaces, session, speed, speedHours }
365
375
  }
366
376
 
367
377
  /** Merge one file's folded contribution into an aggregate being built. */
@@ -382,6 +392,16 @@ function mergeFold(aggregate, fold) {
382
392
  into.steps += slot.steps
383
393
  }
384
394
  }
395
+ // v1.7.7: the hourly speed buckets merge with the hourly token buckets.
396
+ for (const [hour, hourSpeed] of Object.entries(fold.speedHours ?? {})) {
397
+ const target = (aggregate.speedHours[hour] ??= { providers: Object.create(null) })
398
+ for (const [provider, slot] of Object.entries(hourSpeed.providers ?? {})) {
399
+ const into = (target.providers[provider] ??= { output: 0, seconds: 0, steps: 0 })
400
+ into.output += slot.output
401
+ into.seconds += slot.seconds
402
+ into.steps += slot.steps
403
+ }
404
+ }
385
405
  for (const [provider, bucket] of Object.entries(fold.providers ?? {})) {
386
406
  addBucket((aggregate.providers[provider] ??= emptyBucket()), bucket)
387
407
  }
@@ -562,7 +582,18 @@ export function createUsageStats(input) {
562
582
  const workspacesAggregate = Object.create(null)
563
583
  // v1.7.0: `speedAggregate[day].providers[providerId] = {output, seconds, steps}`
564
584
  const speedAggregate = Object.create(null)
585
+ // v1.7.7: the same, per epoch hour (drives the TPS hour drill-down).
586
+ const speedHoursAggregate = Object.create(null)
565
587
  for (const entry of Object.values(cache.files)) {
588
+ for (const [hour, hourSpeed] of Object.entries(entry.speedHours ?? {})) {
589
+ const target = (speedHoursAggregate[hour] ??= { providers: Object.create(null) })
590
+ for (const [provider, slot] of Object.entries(hourSpeed.providers ?? {})) {
591
+ const into = (target.providers[provider] ??= { output: 0, seconds: 0, steps: 0 })
592
+ into.output += typeof slot.output === 'number' ? slot.output : 0
593
+ into.seconds += typeof slot.seconds === 'number' ? slot.seconds : 0
594
+ into.steps += typeof slot.steps === 'number' ? slot.steps : 0
595
+ }
596
+ }
566
597
  for (const [day, daySpeed] of Object.entries(entry.speed ?? {})) {
567
598
  const target = (speedAggregate[day] ??= { providers: Object.create(null) })
568
599
  for (const [provider, slot] of Object.entries(daySpeed.providers ?? {})) {
@@ -626,6 +657,26 @@ export function createUsageStats(input) {
626
657
  }
627
658
  }
628
659
 
660
+ // v1.7.7: local-hour OUTPUT-SPEED series per day, for the TPS drill-down.
661
+ // `speedDayHours[day][providerId]` is 24 `{output, seconds, steps}` slots in
662
+ // local time, built from the same epoch-hour buckets as the token histogram
663
+ // so both drill-downs describe the same slot.
664
+ const daySpeedHours = Object.create(null)
665
+ for (const [epochHour, bucket] of Object.entries(speedHoursAggregate)) {
666
+ const hourStart = Number(epochHour) * 3600_000
667
+ const date = new Date(hourStart)
668
+ if (!Number.isFinite(date.getTime())) continue
669
+ const slot = date.getHours()
670
+ const dayEntry = (daySpeedHours[dayKey(hourStart)] ??= Object.create(null))
671
+ for (const [provider, sample] of Object.entries(bucket.providers ?? {})) {
672
+ if (sample === null || typeof sample !== 'object') continue
673
+ const series = (dayEntry[provider] ??= Array.from({ length: 24 }, () => ({ output: 0, seconds: 0, steps: 0 })))
674
+ series[slot].output += typeof sample.output === 'number' ? sample.output : 0
675
+ series[slot].seconds += typeof sample.seconds === 'number' ? sample.seconds : 0
676
+ series[slot].steps += typeof sample.steps === 'number' ? sample.steps : 0
677
+ }
678
+ }
679
+
629
680
  const allTime = emptyBucket()
630
681
  for (const bucket of Object.values(providersAggregate)) addBucket(allTime, bucket)
631
682
  // Rolling 5-hour window from the hourly DSH buckets.
@@ -791,6 +842,10 @@ export function createUsageStats(input) {
791
842
  daily,
792
843
  // v1.7.0: raw per-day output-speed samples for the wire builder below.
793
844
  speed: speedAggregate,
845
+ // v1.7.7: per-hour samples, converted to 24 local slots by the composer.
846
+ speedHours: speedHoursAggregate,
847
+ // v1.7.7: ready-to-ship 24-slot local-hour speed series per day/provider.
848
+ speedDayHours: daySpeedHours,
794
849
  scan,
795
850
  }
796
851
  }
@@ -857,6 +912,8 @@ export function createUsageStats(input) {
857
912
  workspaces: fold.workspaces,
858
913
  // v1.7.0: per-day output-speed samples (CACHE_VERSION 6).
859
914
  speed: fold.speed,
915
+ // v1.7.7: per-hour samples for the TPS drill-down (CACHE_VERSION 7).
916
+ speedHours: fold.speedHours,
860
917
  }
861
918
  nextFiles[path] = entry
862
919
  scanned += 1
@@ -902,6 +959,8 @@ export function createUsageStats(input) {
902
959
  allTime: emptyBucket(),
903
960
  providers: [],
904
961
  models: [],
962
+ speed: Object.create(null),
963
+ speedHours: Object.create(null),
905
964
  scan: {
906
965
  sessions: 0,
907
966
  scanned: 0,
@@ -1390,13 +1449,32 @@ export function createUsageStats(input) {
1390
1449
  daySteps += slot.steps
1391
1450
  }
1392
1451
  if (daySeconds <= 0 || dayOutput <= 0) continue
1393
- byDay.push({
1452
+ const entry = {
1394
1453
  day,
1395
1454
  outputTokens: dayOutput,
1396
1455
  seconds: Math.round(daySeconds * 10) / 10,
1397
1456
  tps: Math.round((dayOutput / daySeconds) * 10) / 10,
1398
1457
  steps: daySteps,
1399
- })
1458
+ }
1459
+ // v1.7.7: the day's 24 local-hour slots, for the click-to-drill-down
1460
+ // chart. Only a provider-scoped series carries them: the combined series
1461
+ // is not what the TPS card renders any more.
1462
+ if (providerId !== null) {
1463
+ const slots = dsh.speedDayHours?.[day]?.[providerId]
1464
+ if (Array.isArray(slots)) {
1465
+ entry.hours = slots.map((cell) => {
1466
+ if (cell === null || typeof cell !== 'object') return null
1467
+ if (!(cell.seconds > 0) || !(cell.output > 0)) return null
1468
+ return {
1469
+ outputTokens: cell.output,
1470
+ seconds: Math.round(cell.seconds * 10) / 10,
1471
+ tps: Math.round((cell.output / cell.seconds) * 10) / 10,
1472
+ steps: cell.steps,
1473
+ }
1474
+ })
1475
+ }
1476
+ }
1477
+ byDay.push(entry)
1400
1478
  output += dayOutput
1401
1479
  seconds += daySeconds
1402
1480
  steps += daySteps
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tokenlens",
3
- "version": "1.7.6",
3
+ "version": "1.7.7",
4
4
  "type": "module",
5
5
  "description": "dsh-TokenLens (词元透镜) — provider-aware plan capacity plus local token statistics (today/7d/30d/all-time, by harness/provider/model/workspace) for DSH.",
6
6
  "license": "MIT",