dsh-recent-usage-metrics 0.2.1 → 0.2.3

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,19 @@
1
1
  # 更新记录
2
2
 
3
+ ## 0.2.3
4
+
5
+ - 中断的行现在显示它跑了多久:被手动停止的请求没有 model,总用时回退到 wall
6
+ (endTime - startTime),刷新后仍是同一个数。
7
+ - 「中断」标记移到速度列。首字若已返回就照常显示实测值,总用时也照常显示——
8
+ 缺的只是生成速度,由速度列说明原因。
9
+ - 中断行不计入底部平均值,但标题仍数满十行。
10
+
11
+ ## 0.2.2
12
+
13
+ - 修复:切换模型后抽屉卡在旧的一轮。数据源的 target 曾被缓存,而会话重建
14
+ (换模型、重连、binding 释放)会替换掉它依赖的视图存储,旧闭包于是永远返回
15
+ 最后一份快照。现在每次都取当前 target,并按秒重新绑定。
16
+
3
17
  ## 0.2.1
4
18
 
5
19
  - 发布流程改为 GitHub Actions 的 OIDC trusted publishing:推 `v*` 标签即自动发布,
package/README.en.md CHANGED
@@ -281,14 +281,32 @@ built, and undated rows keep showing `--` — the previous behaviour, with no er
281
281
 
282
282
  ## Install
283
283
 
284
+ Published to npm as `dsh-recent-usage-metrics`:
285
+
284
286
  ```powershell
285
- # from the dsh GUI's Plugin Manager, or:
286
- # plugin_manager install_bundle with target = this directory
287
+ # from the dsh GUI's Plugin Manager, or directly:
288
+ npm install dsh-recent-usage-metrics
287
289
  ```
288
290
 
289
- The bundle links this directory into the profile, so editing `client/client.js` and
290
- reloading the page picks up the change. Host-side code never needs a restart because
291
- there is none.
291
+ It can also be added to the profile's `package.json` dependencies and installed from there.
292
+
293
+ **Development mode**: point the profile dependency at this directory with `link:`, so editing
294
+ `client/client.js` and reloading the page picks up the change. Host-side code never needs a
295
+ restart because there is none.
296
+
297
+ ## Release
298
+
299
+ Pushing a `v*` tag publishes automatically through GitHub Actions, authenticated with OIDC
300
+ trusted publishing — **no npm token is involved**.
301
+
302
+ ```powershell
303
+ npm version patch # bump the version and create the tag
304
+ git push --follow-tags # push, and the release publishes itself
305
+ ```
306
+
307
+ The workflow first checks that the tag matches the `package.json` version and fails on a
308
+ mismatch, so a wrong version cannot slip out. Published tarballs carry a provenance statement,
309
+ visible on the package page.
292
310
 
293
311
  ## Layout
294
312
 
@@ -299,6 +317,8 @@ there is none.
299
317
  | `cordis.patch.yml` | The one inserted Loader row |
300
318
  | `locale/en.json`, `locale/zh.json` | Plugin Manager title and description |
301
319
  | `icon.svg` | Plugin icon |
320
+ | `CHANGELOG.md` | What changed in each version |
321
+ | `.github/workflows/publish.yml` | Publishes to npm when a tag is pushed |
302
322
  | `test/lifecycle.cjs` | Runs the real client against a DOM/hook shim: metrics, ten-row cap, insertion order, click/open, disposal |
303
323
  | `test/repro-coldstart.cjs` | Regression: a cold page must paint Chinese and stay switchable both ways |
304
324
  | `test/repro-hmr.cjs` | Regression: a reload must not collide with the live dictionary claim |
@@ -379,9 +399,45 @@ const target = uiConversation.binding(sessionId).target('stats')
379
399
  ```
380
400
 
381
401
  That data comes from the Host-side `sessionStats` fold over the session log, and each step
382
- carries `ttft`, `model`, `decode`, `tps`, `output` and `wall`. The Statistics page prints
383
- its first-token figure as `firstToken − step/start`, so the two agree row for row instead of
384
- being two parallel calculations kept in step by hand.
402
+ carries `ttft`, `model`, `decode`, `tps`, `output` and `wall`.
403
+
404
+ **How the columns map:**
405
+
406
+ | Drawer column | Field | Note |
407
+ | --- | --- | --- |
408
+ | At | `startTime` | the `step/start` moment, same as the Statistics view |
409
+ | TTFT | `ttft` | `firstToken − startTime` |
410
+ | Speed | `tps` | `output ÷ decode` |
411
+ | Tokens | `output` | the provider's reported usage |
412
+ | **Time** | **`model`** | **model time, not the Statistics view's total** |
413
+ | Amortised | `output ÷ model` | this plugin's own figure |
414
+
415
+ **Interrupted rows:**
416
+
417
+ A request that was stopped never produced an `assistant/message`, so `model`, `decode` and
418
+ `tps` are all null. That row's **total falls back to `wall`** (`endTime − startTime`) — how
419
+ long the request actually ran before it was stopped. It is a log fact, so it survives a reload
420
+ unchanged, which is exactly what that column is for here.
421
+
422
+ The word **"interrupted" sits in the speed column**, not the first-token column: a request that
423
+ already streamed keeps its measured first-token time and still shows its total; the only figure
424
+ missing is the generation rate, so that is the column that says why. Interrupted rows are
425
+ excluded from the footer average — their total is wall-clock time, and folding it into the mean
426
+ would pull it toward a number describing neither case — but the caption still counts all ten.
427
+
428
+ **Why the total is `model` and not `wall`:**
429
+
430
+ The Statistics view's total column is `wall = endTime − startTime`, which **includes the step's
431
+ tool calls**. A step that thinks for 11.5 s and then runs a 31 s command shows `wall` 41.9 s but
432
+ `model` 11.5 s. This plugin measures model output only, so it reports `model` — a figure that
433
+ contains no tool time by construction, and is therefore final the moment the reply lands.
434
+
435
+ **Why the figures carry `~` while streaming:**
436
+
437
+ Until the model finishes, the Host has no `usage` from the provider, so `output` is `null`. The
438
+ plugin falls back to a character estimate (coefficients fitted per content type), which is why
439
+ those figures carry `~`. The moment `usage` arrives — which is essentially the same instant the
440
+ reply completes — the measured value takes over and the `~` disappears.
385
441
 
386
442
  **Why the history is no longer stored locally:**
387
443
 
package/README.md CHANGED
@@ -249,13 +249,30 @@ Chat 存储从实时 token 增量中累积它,而会话日志不存 live chunk
249
249
 
250
250
  ## 安装
251
251
 
252
+ 已发布到 npm,包名 `dsh-recent-usage-metrics`:
253
+
254
+ ```powershell
255
+ # 在 dsh GUI 的插件管理器里搜索并安装,或直接:
256
+ npm install dsh-recent-usage-metrics
257
+ ```
258
+
259
+ 也可以把它加进 profile 的 `package.json` 依赖,再由插件管理器安装。
260
+
261
+ **开发模式**:把 profile 里的依赖改成 `link:` 指向本目录,这样改完 `client/client.js`
262
+ 刷新页面即可生效。宿主侧代码永远不需要重启,因为它本身就存在。
263
+
264
+ ## 发布
265
+
266
+ 推 `v*` 标签即由 GitHub Actions 自动发布,认证走 OIDC trusted publishing,
267
+ **不需要任何 npm token**。
268
+
252
269
  ```powershell
253
- # 在 dsh GUI 的插件管理器中安装,或:
254
- # plugin_manager install_bundle,target 指向本目录
270
+ npm version patch # 改版本号并打 tag
271
+ git push --follow-tags # 推送后自动发布
255
272
  ```
256
273
 
257
- 该 bundle 把本目录链接进 profile,因此改完 `client/client.js` 刷新页面即可生效。
258
- 宿主侧代码永远不需要重启,因为它本身就存在。
274
+ 工作流会先校验标签与 `package.json` 的版本一致,不一致直接失败,避免发错版本。
275
+ 发布产物带 provenance 签名,npm 上可在包页面看到来源证明。
259
276
 
260
277
  ## 目录结构
261
278
 
@@ -266,6 +283,8 @@ Chat 存储从实时 token 增量中累积它,而会话日志不存 live chunk
266
283
  | `cordis.patch.yml` | 唯一插入的那一行 Loader 条目 |
267
284
  | `locale/en.json`、`locale/zh.json` | 插件管理器里显示的标题与说明 |
268
285
  | `icon.svg` | 插件图标 |
286
+ | `CHANGELOG.md` | 各版本改动 |
287
+ | `.github/workflows/publish.yml` | 推标签时自动发布到 npm |
269
288
  | `test/lifecycle.cjs` | 在 DOM/hook shim 上跑真实的客户端:指标、十行上限、插入顺序、点击开合、清理 |
270
289
  | `test/repro-coldstart.cjs` | 回归:冷页面必须首屏中文,且双向可切换 |
271
290
  | `test/repro-hmr.cjs` | 回归:重载不得与存活的字典占用冲突 |
@@ -278,6 +297,8 @@ node test/repro-hmr.cjs
278
297
  $env:ELECTRON_RUN_AS_NODE='1'; & "C:\Cinstall\ProgramTools\DSH\DeepSeek Harness.exe" test\repro-log.cjs
279
298
  ```
280
299
 
300
+ 四套共 227 项检查。
301
+
281
302
  ## 用真实会话日志复核
282
303
 
283
304
  统计页的数字来自磁盘上的会话日志:
@@ -336,8 +357,41 @@ const target = uiConversation.binding(sessionId).target('stats')
336
357
  ```
337
358
 
338
359
  那份数据由宿主侧的 `sessionStats` fold 从会话日志算出,每步都带着 `ttft`、`model`、
339
- `decode`、`tps`、`output`、`wall`。统计页自己显示的首 token 就是
340
- `firstToken − step/start`,因此两边逐行一致,不再存在两套口径。
360
+ `decode`、`tps`、`output`、`wall`。
361
+
362
+ **字段对应关系:**
363
+
364
+ | 抽屉的列 | 取自 | 说明 |
365
+ | --- | --- | --- |
366
+ | 时间 | `startTime` | `step/start` 的时刻,与统计页一致 |
367
+ | 首字 | `ttft` | `firstToken − startTime` |
368
+ | 速度 | `tps` | `output ÷ decode` |
369
+ | 输出 Token | `output` | 上游返回的 usage |
370
+ | **总用时** | **`model`** | **模型用时,不是统计页的「总用时」列** |
371
+ | 折算速度 | `output ÷ model` | 本插件独有 |
372
+
373
+ **中断的行:**
374
+
375
+ 被手动停止的请求没有 `assistant/message`,因此 `model`、`decode`、`tps` 全为 null。
376
+ 那一行的**总用时回退到 `wall`**(`endTime − startTime`),也就是这条请求从发出到被
377
+ 停止实际过了多久——它是日志里的事实,刷新多少次都不变,正是这个场合要的数字。
378
+
379
+ **「中断」写在速度列**,不在首字列:首字若已经返回就照样显示实测值,总用时也照常显示;
380
+ 缺的只是生成速度,所以由速度列说明原因。中断行不计入底部的平均值——它的总用时是墙钟
381
+ 时间,混进均值会拉向一个既不代表完成、也不代表中断的数;但平均行的标题仍数满十行。
382
+
383
+ **总用时为何取 `model` 而不是 `wall`:**
384
+
385
+ 统计页的「总用时」列是 `wall = endTime − startTime`,它**把工具调用也算了进去**。
386
+ 一个思考 11.5 秒、随后跑 31 秒工具的步骤,`wall` 是 41.9 秒而 `model` 是 11.5 秒。
387
+ 本插件只统计模型输出性能,所以取 `model`——它天然不含工具时间,回复一落地就是最终值,
388
+ 不再随工具执行增长。
389
+
390
+ **流式期间为什么带 `~`:**
391
+
392
+ 模型正在输出时,宿主还没拿到上游的 `usage`,此时 `output` 为 `null`。
393
+ 插件改用字符估算(按内容类型分别标定系数),所以那段数字带 `~`。
394
+ `usage` 一到(几乎就是回复完成的同一刻),立刻切成实测值,`~` 消失。
341
395
 
342
396
  **为什么不再本地保存:**
343
397
 
@@ -348,16 +402,18 @@ const target = uiConversation.binding(sessionId).target('stats')
348
402
  现在:
349
403
 
350
404
  - 抽屉的行在每次渲染时从该会话自己的统计数据重建,没有本地副本可以过期
351
- - 收到会话切换的消息时立刻清空,避免新数据到达前短暂显示上一个会话的行
405
+ - 快照带会话标记,切换会话的瞬间旧数据即失效,不会短暂显示上一个会话的行
352
406
  - 页面刷新后自动从日志重建,不需要额外的持久化
353
407
 
354
408
  **视图目标需要先订阅才会存在**(`getSnapshot` 在订阅前返回 `undefined`)。插件在
355
409
  `apply` 时按会话订阅一次;激活是永久且共享的,所以不会在统计页之外再跑一份 fold。
356
410
 
357
- **降级路径**:若某个组装没有统计页数据(`uiConversation` 不存在),抽屉退回原先的
358
- Chat 快照 + 轨迹域算法,并按估算标记 `~`。这条路径只在缺少统计数据时生效。
411
+ **拿不到统计视图时**,插件自动退回 Chat 快照 + 轨迹域自行计算(兜底路径)。
412
+ 这条路径不落盘、也按会话隔离,但它读不到 `model`,所以三态会退化为两态。
413
+ 它的字符估算系数与主路径相同。决定走哪条路径的唯一依据是那份数据源在不在,
414
+ 没有任何开关参与。
359
415
 
360
- **流式期间不读统计数据**:那套 fold 每来一个 chunk 就会重建整张表。状态栏的实时读数
361
- 走另一条更轻的路径(轨迹域的 `step/start` 时刻 + 每 150 ms 的本地 tick),
362
- 只在步骤结算后读一次统计数据定稿。这样既保住了「数字从 0 开始爬」的观感,
363
- 也不会在流式期间额外增加 fold 的负担。
416
+ **流式期间不额外催动 fold**:那套 fold 每来一个 chunk 就会重建整张表,而它本来就由
417
+ 统计页或宿主驱动。状态栏的实时读数在回复完成前用字符估算与本地 tick,回复一落地
418
+ (`model` 有值)就切到折叠给出的实测值。这样既保住了「数字从 0 开始爬」的观感,
419
+ 也不会因为插件而多跑一份 fold。
package/client/client.js CHANGED
@@ -880,6 +880,12 @@ window.__ModuleLoader__.load({
880
880
  function readingOfStatsStep(entry) {
881
881
  const stats = entry.metrics
882
882
  const ttftMs = numberOrNull(stats.ttft)
883
+ /*
884
+ * A step the fold marks aborted ended without a settled reply: the user stopped it, or the
885
+ * upstream failed mid-request. Either way there is no `messageTime`, so `model`, `decode`
886
+ * and `tps` are all null and the row would be entirely blank.
887
+ */
888
+ const interrupted = stats.aborted === true || stats.interrupted === true
883
889
  /*
884
890
  * The drawer's "total" is the Statistics view's MODEL time, not its wall time.
885
891
  *
@@ -888,17 +894,15 @@ window.__ModuleLoader__.load({
888
894
  * The drawer answers "how long did this request take to answer", so it reports `model` —
889
895
  * and that figure is final the moment the reply lands, which is why it must not climb
890
896
  * while tools run.
897
+ *
898
+ * An interrupted step has no `model` at all, so it falls back to `wall`. That is the
899
+ * figure the user actually wants there: how long the request ran before it was stopped.
900
+ * It is a log fact — `endTime - startTime` — so it survives a reload unchanged, unlike
901
+ * the live reading, which is recomputed and lost.
891
902
  */
892
- const totalMs = numberOrNull(stats.model)
903
+ const totalMs = numberOrNull(stats.model) ?? (interrupted ? numberOrNull(stats.wall) : null)
893
904
  const tokens = numberOrNull(stats.output)
894
905
  const speed = numberOrNull(stats.tps)
895
- /*
896
- * A step the fold marks interrupted or aborted never produced a settled reply: a
897
- * cancelled request has no first token to subtract from, so its ttft is null and the
898
- * drawer used to print the same dash a not-yet-started step would. The flag lets the
899
- * cell say what actually happened instead.
900
- */
901
- const interrupted = stats.interrupted === true || stats.aborted === true
902
906
  return {
903
907
  key: `${entry.turn}:${entry.step}`,
904
908
  at: numberOrNull(stats.startTime) ?? numberOrNull(stats.endTime),
@@ -2020,13 +2024,19 @@ window.__ModuleLoader__.load({
2020
2024
  * Average of the rows on screen, computed over the ones that actually carry each
2021
2025
  * figure (an interrupted request may have no TTFT, a placeholder no tokens). Returns
2022
2026
  * null for a column with no samples so the row can print a dash instead of a fake 0.
2027
+ *
2028
+ * Interrupted rows are excluded from every mean. Their total is wall-clock time — it
2029
+ * includes however long the request ran before being stopped — so averaging it against
2030
+ * model times would pull the figures toward a number that describes neither case. The
2031
+ * row still counts toward `count`, because the caption names the list, not the sample.
2023
2032
  */
2024
2033
  function averageOf(rows) {
2025
2034
  if (rows.length === 0) return null
2035
+ const measurable = rows.filter((row) => row.interrupted !== true)
2026
2036
  const mean = (pick) => {
2027
2037
  let sum = 0
2028
2038
  let count = 0
2029
- for (const row of rows) {
2039
+ for (const row of measurable) {
2030
2040
  const value = pick(row)
2031
2041
  if (value === null || value === undefined || !Number.isFinite(value)) continue
2032
2042
  sum += value
@@ -2036,6 +2046,7 @@ window.__ModuleLoader__.load({
2036
2046
  }
2037
2047
  return {
2038
2048
  count: rows.length,
2049
+ measured: measurable.length,
2039
2050
  ttftMs: mean((row) => row.ttftMs),
2040
2051
  speed: mean((row) => row.speed),
2041
2052
  tokens: mean((row) => row.tokens),
@@ -2043,8 +2054,8 @@ window.__ModuleLoader__.load({
2043
2054
  amortized: mean((row) => row.amortized),
2044
2055
  // A column is "estimated" when any sample in it was; the tilde then still means
2045
2056
  // the same thing it does on a single row.
2046
- estimated: rows.some((row) => row.estimated === true),
2047
- anyEstimated: rows.some((row) => row.estimated === true),
2057
+ estimated: measurable.some((row) => row.estimated === true),
2058
+ anyEstimated: measurable.some((row) => row.estimated === true),
2048
2059
  }
2049
2060
  }
2050
2061
 
@@ -2155,12 +2166,12 @@ window.__ModuleLoader__.load({
2155
2166
  * subscribes. Reading it inline during render froze the rows at whatever the first
2156
2167
  * render saw — which is why the drawer showed timestamps minutes behind the log.
2157
2168
  */
2158
- /*
2159
- * The shipped Statistics view's data source for this session. `undefined` when that
2160
- * view is absent — a composition without it — which is what routes the drawer to its
2161
- * Chat-snapshot fallback. No switch decides this: the presence of the source does.
2162
- */
2163
- const statsSource = typeof props.statsSource === 'function' ? props.statsSource() : undefined
2169
+ /*
2170
+ * The shipped Statistics view's data source for this session. `undefined` when that
2171
+ * view is absent — a composition without it — which is what routes the drawer to its
2172
+ * Chat-snapshot fallback. No switch decides this: the presence of the source does.
2173
+ */
2174
+ const statsSource = typeof props.statsSource === 'function' ? props.statsSource() : undefined
2164
2175
  const statsSubscribe = useCallback(
2165
2176
  (listener) => {
2166
2177
  if (statsSource === undefined || typeof statsSource.subscribe !== 'function') {
@@ -2183,10 +2194,45 @@ window.__ModuleLoader__.load({
2183
2194
  [statsSource, sessionId],
2184
2195
  )
2185
2196
  const [statsSnapshot, setStatsSnapshot] = useState(statsRead)
2197
+ /*
2198
+ * Re-point the subscription at whatever target is current, then re-read.
2199
+ *
2200
+ * A conversation that is rebuilt underneath the drawer (a model switch, a reconnect, a
2201
+ * released binding) replaces its `BoundConversation`, and with it the view store the old
2202
+ * `target()` closure captured. Refreshing on every render rebinds the listener to the new
2203
+ * target, so the drawer cannot keep rendering a retired snapshot while the Statistics page
2204
+ * has already moved on.
2205
+ */
2206
+ if (statsSource !== undefined && typeof statsSource.refresh === 'function') {
2207
+ statsSource.refresh()
2208
+ }
2186
2209
  useEffect(() => {
2187
2210
  setStatsSnapshot(statsRead())
2188
2211
  return statsSubscribe(() => setStatsSnapshot(statsRead()))
2189
2212
  }, [statsRead, statsSubscribe])
2213
+ /*
2214
+ * Follow a conversation that is rebuilt underneath the drawer.
2215
+ *
2216
+ * A model switch, a reconnect, or a released binding replaces the session's
2217
+ * `BoundConversation`, and with it the view store the old `target()` closure captured.
2218
+ * That stale closure keeps returning its last snapshot forever — which is exactly how the
2219
+ * drawer froze on one turn while the Statistics page moved on.
2220
+ *
2221
+ * On a slow tick this re-points the listener at the target that is current now and
2222
+ * re-reads the snapshot. Both are cheap (a map lookup and a field read) and neither
2223
+ * depends on the stale chain firing.
2224
+ */
2225
+ useEffect(() => {
2226
+ if (statsSource === undefined) return undefined
2227
+ const sync = () => {
2228
+ if (typeof statsSource.refresh === 'function') statsSource.refresh()
2229
+ const next = statsRead()
2230
+ if (next !== undefined) setStatsSnapshot(next)
2231
+ }
2232
+ sync()
2233
+ const timer = setInterval(sync, 1000)
2234
+ return () => clearInterval(timer)
2235
+ }, [statsSource, statsRead])
2190
2236
  /*
2191
2237
  * A snapshot only counts while it belongs to the session being shown.
2192
2238
  *
@@ -2777,18 +2823,24 @@ window.__ModuleLoader__.load({
2777
2823
  'span',
2778
2824
  { className: 'dsh-rum-col', role: 'cell' },
2779
2825
  /*
2780
- * A cancelled step has no first token to report, so the column would
2781
- * otherwise show the same dash as a step that never started. Say what
2782
- * happened instead; it is the one figure the row can honestly give.
2826
+ * The first-token column reports whatever was actually measured. An
2827
+ * interrupted request that had already produced its first token shows
2828
+ * that real figure; one stopped before any token shows the dash.
2783
2829
  */
2784
- entry.row.interrupted === true
2785
- ? t('interrupted')
2786
- : secondsText(entry.row.ttftMs, t),
2830
+ secondsText(entry.row.ttftMs, t),
2787
2831
  ),
2788
2832
  h(
2789
2833
  'span',
2790
2834
  { className: 'dsh-rum-col dsh-rum-primary', role: 'cell' },
2791
- speedText(entry.row.speed, false, t),
2835
+ /*
2836
+ * A cancelled step never settled, so it has no generation speed. The
2837
+ * column says what happened instead of the same dash a step that has
2838
+ * not started would show — the speed column is where "this produced no
2839
+ * rate" belongs, while first-token and total still carry real figures.
2840
+ */
2841
+ entry.row.interrupted === true
2842
+ ? t('interrupted')
2843
+ : speedText(entry.row.speed, false, t),
2792
2844
  ),
2793
2845
  h(
2794
2846
  'span',
@@ -2940,82 +2992,115 @@ window.__ModuleLoader__.load({
2940
2992
  */
2941
2993
  const statsSources = new Map()
2942
2994
  /*
2943
- * The Statistics target for one session, created on first use.
2995
+ * The Statistics target for one session.
2944
2996
  *
2945
- * `uiConversation.binding(id)` throws once that session is released, so a cached source
2946
- * can become stale. The cache is therefore bounded and evicts oldest-first, releasing
2947
- * the evicted target's subscription: without that, a long session that visits many
2948
- * conversations would keep one live subscription per conversation visited, and each of
2949
- * those keeps the conversation's fold active.
2997
+ * The target object is looked up FRESH on every call rather than cached alongside the
2998
+ * source. `uiConversation.binding(id)` keeps a `BoundConversation` per session, and that
2999
+ * object owns the assembler and the view store; when the conversation is rebuilt — a model
3000
+ * switch, a reconnect, a released binding — the old `target()` closure keeps pointing at
3001
+ * the retired store and returns its last snapshot forever. Caching it froze the drawer on
3002
+ * one turn while the Statistics page moved on.
3003
+ *
3004
+ * Only the subscriptions are cached, because those must be released on eviction and
3005
+ * re-subscribing on every render would multiply listeners.
2950
3006
  */
2951
3007
  const STATS_SOURCE_LIMIT = 4
2952
3008
  const statsSourceFor = (sessionId) => {
2953
3009
  if (sessionId === null || sessionId === undefined) return undefined
2954
- if (statsSources.has(sessionId)) {
3010
+ const uiConversation = ctx.get('uiConversation')
3011
+ if (uiConversation === undefined || uiConversation === null) return undefined
3012
+
3013
+ /** The target that is current right now, or undefined when it cannot be resolved. */
3014
+ const targetNow = () => {
3015
+ try {
3016
+ return uiConversation.binding(sessionId).target(STATS_TARGET) ?? undefined
3017
+ } catch {
3018
+ // The session was released (or is inactive) — no statistics for it right now.
3019
+ return undefined
3020
+ }
3021
+ }
3022
+
3023
+ const cached = statsSources.get(sessionId)
3024
+ if (cached !== undefined) {
2955
3025
  // Refresh recency so the active conversation is the last one evicted.
2956
- const existing = statsSources.get(sessionId)
2957
3026
  statsSources.delete(sessionId)
2958
- statsSources.set(sessionId, existing)
2959
- return existing
3027
+ statsSources.set(sessionId, cached)
3028
+ return cached.source
2960
3029
  }
2961
- const uiConversation = ctx.get('uiConversation')
2962
- if (uiConversation === undefined || uiConversation === null) return undefined
2963
- let source
2964
- try {
2965
- const target = uiConversation.binding(sessionId).target(STATS_TARGET)
2966
- if (target === undefined || target === null) return undefined
2967
- /*
2968
- * Track the listener/unsubscribe pairs this source handed to the target, so
2969
- * eviction can drop them. The target's `subscribe` returns its own unsubscribe,
2970
- * but nothing else would call it once the source leaves the cache.
2971
- */
2972
- const subscriptions = new Map()
2973
- source = {
2974
- getSnapshot: () => target.getSnapshot(),
2975
- subscribe: (listener) => {
2976
- const existing = subscriptions.get(listener)
2977
- if (existing !== undefined) return existing.dispose
2978
- const unsubscribe = target.subscribe(listener)
2979
- const dispose = () => {
2980
- if (subscriptions.get(listener)?.unsubscribe !== unsubscribe) return
2981
- subscriptions.delete(listener)
2982
- try {
2983
- unsubscribe()
2984
- } catch {}
2985
- }
2986
- subscriptions.set(listener, { unsubscribe, dispose })
2987
- return dispose
2988
- },
2989
- release: () => {
2990
- for (const [listener, entry] of [...subscriptions]) {
2991
- subscriptions.delete(listener)
2992
- try {
2993
- entry.unsubscribe()
2994
- } catch {}
2995
- }
2996
- },
3030
+
3031
+ /*
3032
+ * Each listener is bound to the target that was current when it subscribed; a listener
3033
+ * whose target has since been replaced is re-bound the next time it asks, which is what
3034
+ * keeps a drawn row tracking the live fold instead of a retired one.
3035
+ */
3036
+ const subscriptions = new Map()
3037
+
3038
+ const bind = (listener) => {
3039
+ const target = targetNow()
3040
+ if (target === undefined) return
3041
+ const entry = subscriptions.get(listener)
3042
+ if (entry !== undefined && entry.target === target) return
3043
+ if (entry !== undefined) {
3044
+ try {
3045
+ entry.unsubscribe()
3046
+ } catch {}
3047
+ subscriptions.delete(listener)
2997
3048
  }
2998
- } catch {
2999
- // The session was released (or is inactive) — no statistics for it.
3000
- return undefined
3049
+ try {
3050
+ subscriptions.set(listener, { target, unsubscribe: target.subscribe(listener) })
3051
+ } catch {}
3052
+ }
3053
+
3054
+ const dispose = (listener) => {
3055
+ const entry = subscriptions.get(listener)
3056
+ if (entry === undefined) return
3057
+ subscriptions.delete(listener)
3058
+ try {
3059
+ entry.unsubscribe()
3060
+ } catch {}
3001
3061
  }
3002
- statsSources.set(sessionId, source)
3062
+
3063
+ const source = {
3064
+ /** Always the newest snapshot the session's own target holds. */
3065
+ getSnapshot: () => {
3066
+ const target = targetNow()
3067
+ return target === undefined ? undefined : target.getSnapshot()
3068
+ },
3069
+ subscribe: (listener) => {
3070
+ bind(listener)
3071
+ return () => dispose(listener)
3072
+ },
3073
+ /**
3074
+ * Re-point every listener at the target that is current now.
3075
+ *
3076
+ * Called on each render: one map lookup per listener, and it is what lets the drawer
3077
+ * follow a conversation that was rebuilt underneath it.
3078
+ */
3079
+ refresh: () => {
3080
+ for (const listener of [...subscriptions.keys()]) bind(listener)
3081
+ },
3082
+ release: () => {
3083
+ for (const listener of [...subscriptions.keys()]) dispose(listener)
3084
+ },
3085
+ }
3086
+
3087
+ statsSources.set(sessionId, { source })
3003
3088
  while (statsSources.size > STATS_SOURCE_LIMIT) {
3004
3089
  const oldest = statsSources.keys().next()
3005
3090
  if (oldest.done === true) break
3006
3091
  const evicted = statsSources.get(oldest.value)
3007
3092
  statsSources.delete(oldest.value)
3008
3093
  try {
3009
- evicted?.release?.()
3094
+ evicted?.source?.release?.()
3010
3095
  } catch {}
3011
3096
  }
3012
3097
  return source
3013
3098
  }
3014
3099
  ctx.effect(
3015
3100
  () => () => {
3016
- for (const source of statsSources.values()) {
3101
+ for (const entry of statsSources.values()) {
3017
3102
  try {
3018
- source.release?.()
3103
+ entry.source.release?.()
3019
3104
  } catch {}
3020
3105
  }
3021
3106
  statsSources.clear()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-recent-usage-metrics",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Geek level for the Performance & usage setting: a live reading of the newest request (first-token latency, speed, tokens, total, amortised) plus a drawer listing the last ten, all derived from the shipped Statistics view data",
5
5
  "repository": {
6
6
  "type": "git",