dsh-recent-usage-metrics 0.2.0 → 0.2.2
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 +11 -0
- package/README.en.md +455 -412
- package/README.md +409 -363
- package/client/client.js +127 -59
- package/package.json +9 -1
package/README.en.md
CHANGED
|
@@ -1,412 +1,455 @@
|
|
|
1
|
-
# dsh-recent-usage-metrics
|
|
2
|
-
|
|
3
|
-
**English** · [简体中文](README.md)
|
|
4
|
-
|
|
5
|
-
Geek-level request metrics for DSH. It adds a third level — **Geek / 极客** — to the
|
|
6
|
-
native **Performance & usage** preference in Settings → General, and while that level
|
|
7
|
-
is active it keeps a live reading of the newest request in the composer status row
|
|
8
|
-
plus a glass drawer with the last ten requests.
|
|
9
|
-
|
|
10
|
-

|
|
11
|
-
|
|
12
|
-
The drawer open: the step still streaming sits on top (its first-token figure climbing,
|
|
13
|
-
speed shown as `—`), the last ten requests follow newest first, and their average closes
|
|
14
|
-
the list. The status pill sits below it, its columns aligned with the table's.
|
|
15
|
-
|
|
16
|
-
The UI is bilingual and follows the DSH language setting. The same drawer in Chinese:
|
|
17
|
-
|
|
18
|
-

|
|
19
|
-
|
|
20
|
-
## What you get
|
|
21
|
-
|
|
22
|
-
**Settings → General → Performance & usage** now offers three levels:
|
|
23
|
-
|
|
24
|
-
| Level | Behaviour |
|
|
25
|
-
| --- | --- |
|
|
26
|
-
| Compact / 简洁 | Shipped behaviour, unchanged |
|
|
27
|
-
| Detailed / 详细 | Shipped behaviour, unchanged |
|
|
28
|
-
| **Geek / 极客** | Everything below |
|
|
29
|
-
|
|
30
|
-
Compact and Detailed keep writing the shipped `ui-chat` → `performanceUsage` field, so
|
|
31
|
-
they behave exactly as before and stay in sync with the shipped row's data. Geek is a
|
|
32
|
-
separate durable flag, because the shipped Chat schema only accepts two values.
|
|
33
|
-
|
|
34
|
-
**Composer status row** (left of the shipped `55 轮 2307 步` pill):
|
|
35
|
-
|
|
36
|
-
```
|
|
37
|
-
⚡ 2.3 s 445 tok/s 1024 tok 4.6 s 223 tok/s
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
time to first token · live generation speed · output tokens · total time · amortised
|
|
41
|
-
speed (output ÷ total), separated by whitespace only. The pill reproduces the shipped
|
|
42
|
-
statistics pill: the same `--dsw-alias-label-tertiary` foreground for both the text and
|
|
43
|
-
the icon (the native pills never tint their glyph), and the same type scale. Height comes
|
|
44
|
-
from padding + line-height, so it matches the native row.
|
|
45
|
-
|
|
46
|
-
Each figure's digits are reserved with a **fixed width on the number element only** —
|
|
47
|
-
never on the wrapping cell, which also carries the unit and would otherwise clip it. The
|
|
48
|
-
estimate marker is a permanently reserved 1ch span that only toggles its paint. A value
|
|
49
|
-
growing from `3.1` to `57.8`, a speed dropping from `328` to `9`, a `--` placeholder, and
|
|
50
|
-
a `~` appearing or disappearing all leave the pill's width byte-identical — the centred
|
|
51
|
-
composer row never shifts.
|
|
52
|
-
|
|
53
|
-
The budgets come from the real distribution of a 433-reading session log, and each
|
|
54
|
-
formatter is written to stay inside its budget:
|
|
55
|
-
|
|
56
|
-
| Slot | Digits | Why that is enough |
|
|
57
|
-
| --- | --- | --- |
|
|
58
|
-
| first token / total | `4.5ch` | `57.8` normally; from 100 s up it prints whole seconds (`126`) |
|
|
59
|
-
| speed / amortised | `4.5ch` | p50 `85`, max `328` in the log |
|
|
60
|
-
| output tokens | `5.5ch` | p50 `219`; `123456` collapses to `123k` |
|
|
61
|
-
|
|
62
|
-
The pill and the drawer use **disjoint class names** (`dsh-rum-part` vs `dsh-rum-col`).
|
|
63
|
-
Sharing one cell class let the drawer's ellipsis rule clip the pill's units — the regression
|
|
64
|
-
that produced digits with a truncated ` s` / ` tok/s`.
|
|
65
|
-
|
|
66
|
-
Drawer columns are **measured from the pill at runtime**, not derived from `ch`. `ch` is
|
|
67
|
-
the advance of `0`, while `tok/s` and the thin `s` are narrower, so an arithmetic column
|
|
68
|
-
can never sit exactly under the rendered cell. On open the plugin reads each
|
|
69
|
-
`[data-metric]` cell's `getBoundingClientRect().width` and publishes one custom property
|
|
70
|
-
per metric (`--dsh-rum-col-ttft`, `--dsh-rum-col-speed`, `--dsh-rum-col-tokens`,
|
|
71
|
-
`--dsh-rum-col-total`, `--dsh-rum-col-amortized`), which the row grid consumes. A
|
|
72
|
-
`ResizeObserver` on the pill re-measures after a font-scale change or a metric swap, and a
|
|
73
|
-
`ch` expression stays as the fallback for the frame before measurement lands.
|
|
74
|
-
|
|
75
|
-
| Drawer column | Consumes | Sits under |
|
|
76
|
-
| --- | --- | --- |
|
|
77
|
-
| first token | `--dsh-rum-col-ttft` | the pill's first-token cell |
|
|
78
|
-
| speed | `--dsh-rum-col-speed` | the pill's speed cell |
|
|
79
|
-
| output tokens | `--dsh-rum-col-tokens` | the pill's token cell |
|
|
80
|
-
| total | `--dsh-rum-col-total` | the pill's total-time cell |
|
|
81
|
-
| amortised | `--dsh-rum-col-amortized` | the pill's amortised cell |
|
|
82
|
-
|
|
83
|
-
Two further details make the edges actually coincide:
|
|
84
|
-
|
|
85
|
-
- **Content edges, not box edges.** The pill pads its content by 8px, while the drawer
|
|
86
|
-
stacks 16px of its own padding with a row's 6px. Aligning the two *boxes* therefore
|
|
87
|
-
leaves the numbers visibly inset. Both insets are measured
|
|
88
|
-
(`drawerContentInset` / `pillContentInset`) and subtracted, so the last figure in the
|
|
89
|
-
drawer lines up with the last figure in the pill.
|
|
90
|
-
- **Spare width goes left.** The drawer keeps a `min-width` so its own headers are not
|
|
91
|
-
truncated; each row uses `justify-content: end`, pushing any surplus to the left so the
|
|
92
|
-
final column stays flush. The drawer is also re-placed on the next animation frame,
|
|
93
|
-
because the first pass can run before the measured columns have been applied.
|
|
94
|
-
|
|
95
|
-
Click the pill to open the drawer; click it again, or press Escape, or click outside, to
|
|
96
|
-
close it. There is no hover-open and no tooltip.
|
|
97
|
-
|
|
98
|
-
## Three states
|
|
99
|
-
|
|
100
|
-
This plugin has two kinds of busy and one kind of idle, and all three look different:
|
|
101
|
-
|
|
102
|
-
| State | Decided by | Pill icon | Drawer header | Live row |
|
|
103
|
-
| --- | --- | --- | --- | --- |
|
|
104
|
-
| **Model writing** | `model` is `null` | ⚡ pulsing bolt | `Streaming` | yes (11 rows) |
|
|
105
|
-
| **Tools running** | `model` set, `endTime` `null` | **pulsing T** | **`Calling tools`** | yes (11 rows) |
|
|
106
|
-
| **Idle** | `endTime` set, or no running step | still | `last N` | no (10 rows) |
|
|
107
|
-
|
|
108
|
-
### Why the tool phase is its own state
|
|
109
|
-
|
|
110
|
-
This plugin measures **model output**, and `step/end` only arrives after the step's tool calls
|
|
111
|
-
have all returned. Keying off `running` (which `step/end` clears) therefore kept the plugin in
|
|
112
|
-
"streaming" for the entire tool phase after the reply had already finished: the bolt kept
|
|
113
|
-
pulsing and the live row kept occupying an eleventh row.
|
|
114
|
-
|
|
115
|
-
The field that separates the first two is `model`, which is `messageTime − startTime`:
|
|
116
|
-
|
|
117
|
-
- the model is still writing → `null`
|
|
118
|
-
- `assistant/message` has landed → a fixed value, **even while tools run**
|
|
119
|
-
|
|
120
|
-
`endTime` then separates the tool phase from idle. Both fields come from the Host's
|
|
121
|
-
`sessionStats` fold — the same data the Statistics page renders.
|
|
122
|
-
|
|
123
|
-
### The live row stays through the tool phase
|
|
124
|
-
|
|
125
|
-
The request is genuinely still in flight, so it keeps its row; only the marker changes from the
|
|
126
|
-
bolt to a T, and the drawer header switches from "Streaming" to "Calling tools". That makes all
|
|
127
|
-
three states distinguishable at a glance.
|
|
128
|
-
|
|
129
|
-
Both glyphs share **the same 14×14 svg box** (`.dsh-rum-trigger svg`) and the same `pulse`
|
|
130
|
-
animation. Switching changes the shape and colour inside the box, never the box, so the status
|
|
131
|
-
row does not shift. The T is drawn narrower on purpose so it does not read heavier than the bolt.
|
|
132
|
-
|
|
133
|
-
### Fallback
|
|
134
|
-
|
|
135
|
-
A composition without the statistics fold has no `model` to read. There, `partial` and a
|
|
136
|
-
Trajectory request can only mean the model is writing, so the state is always "model writing" —
|
|
137
|
-
it never degrades to idle just because the fold is missing.
|
|
138
|
-
|
|
139
|
-
**Drawer** — slides up from the status row, right-aligned to that pill, `position: fixed`
|
|
140
|
-
on `document.body` so no scroll container can clip it. Its material is the shipped
|
|
141
|
-
statistics dialog's own, copied token for token: the `--dsw-specific-menu` fill
|
|
142
|
-
(`#303136f0` in dark mode, i.e. nearly opaque, the same surface behind the native
|
|
143
|
-
numbers), the `--dsw-menu-backdrop-filter` 40px/150% blur, `--dsw-elevation-prominent`
|
|
144
|
-
stroke and shadow, `--dsw-radius-lg`, and 16px padding. It lists the last ten requests,
|
|
145
|
-
newest first, with no explanatory footer:
|
|
146
|
-
|
|
147
|
-
| # | At | TTFT | Speed | Tokens | Time | Amortised |
|
|
148
|
-
| --- | --- | --- | --- | --- | --- | --- |
|
|
149
|
-
| 1 | 18:42:07 | 2.3 s | 445 tok/s | 1024 tok | 4.6 s | 223 tok/s |
|
|
150
|
-
|
|
151
|
-
The `At` column is when the request was **issued** (`step/start`), matching the Statistics
|
|
152
|
-
view. It is not the end time: a row stamped 18:42:07 with a 4.6 s total actually finished
|
|
153
|
-
at 18:42:12, so an end stamp would read as "it finished then" and would also order the list
|
|
154
|
-
by completion rather than by issue.
|
|
155
|
-
|
|
156
|
-
A new request slides in at the top; the row that falls off the ten-row cap is removed in
|
|
157
|
-
the **same frame** rather than fading out on its own. An exit phase would keep an
|
|
158
|
-
eleventh row laid out for the animation's duration, growing the drawer by a row and
|
|
159
|
-
shrinking it back — a visible height jump on every insertion. Opening the drawer also
|
|
160
|
-
establishes a baseline instead of animating, so the whole list never slides in at once.
|
|
161
|
-
|
|
162
|
-
While a step is streaming, the drawer shows it as a live row above the history, and the
|
|
163
|
-
status reading switches to a live style. Under a rule below the history sits a summary
|
|
164
|
-
row — the average of the visible requests, in the same grid so each mean lands under its
|
|
165
|
-
column. A column with no samples (say no TTFT on an interrupted request) prints a dash
|
|
166
|
-
rather than a misleading zero.
|
|
167
|
-
|
|
168
|
-
## How the numbers are measured
|
|
169
|
-
|
|
170
|
-
Everything is derived in the browser from the Chat snapshot the Conversation layer
|
|
171
|
-
already maintains — the plugin adds no Host service, session event, or projection.
|
|
172
|
-
|
|
173
|
-
- **Time to first token** — `timing.firstTokenTime − timing.stepStartTime`, the recorded
|
|
174
|
-
moment the request was issued and the moment the first visible delta arrived.
|
|
175
|
-
- **Total time** — `timing.completedTime − timing.stepStartTime`.
|
|
176
|
-
- **Live speed** — output tokens ÷ decode time (`completedTime − firstTokenTime`).
|
|
177
|
-
- **Output tokens** — the provider's own `usage.outputTokens`, which is the authority.
|
|
178
|
-
It is read from `assistant/message.data.usage` (verified against a real session log:
|
|
179
|
-
262 of 262 settled steps carried it). `reasoningTokens` is already included in that
|
|
180
|
-
count and is never added on top. OpenAI-shaped `completionTokens` spellings are also
|
|
181
|
-
accepted for adapters that forward their usage verbatim.
|
|
182
|
-
- **Amortised speed** — output tokens ÷ total time.
|
|
183
|
-
|
|
184
|
-
The character estimate is a genuine fallback, not the normal path: it is used only for a
|
|
185
|
-
step whose provider returned no usage at all (an interrupted or failed request), and such
|
|
186
|
-
a row is marked `~`.
|
|
187
|
-
|
|
188
|
-
Its coefficients are calibrated, not guessed. The content is not homogeneous: prose and
|
|
189
|
-
reasoning tokenize near 4 ASCII characters per token, while tool-call arguments are JSON —
|
|
190
|
-
short keys, punctuation, quoted values — and tokenize markedly worse, near 2.5. Measured
|
|
191
|
-
against the provider's own `outputTokens` over 729 settled steps of a real session log,
|
|
192
|
-
using the median absolute log error:
|
|
193
|
-
|
|
194
|
-
| Rule | Median error | Estimate ÷ actual (p50) |
|
|
195
|
-
| --- | --- | --- |
|
|
196
|
-
| one shared `/4` for everything | 0.361 | 0.70 |
|
|
197
|
-
| prose `/4`, tool args `/2.5` (shipped) | **0.128** | **1.00** |
|
|
198
|
-
|
|
199
|
-
Splitting the two is what removed the bias. The old shared divisor systematically
|
|
200
|
-
under-counted by about 30%, which is why the live amortised speed read low during
|
|
201
|
-
generation; a fine grid search put the optimum at 0.125, so the shipped rule is within
|
|
202
|
-
three thousandths of it. CJK stays at the conventional one token per character: the search
|
|
203
|
-
suggested a lower cost, but only 45% of those steps contain any CJK, so that term is
|
|
204
|
-
poorly identified in this sample and a fitted value would be noise.
|
|
205
|
-
|
|
206
|
-
### Tool arguments while they stream
|
|
207
|
-
|
|
208
|
-
`legacy.partial.blocks` does **not** carry argument text during a call. The store's
|
|
209
|
-
`tool-call-delta` branch copies `argsRaw` unchanged, so it stays an empty string until
|
|
210
|
-
`block-end` fills it in one go. Counting only `blocks` therefore froze the live figure for
|
|
211
|
-
the whole write phase — and that phase dominates real output: across 874 settled steps of
|
|
212
|
-
a session log, tool arguments were a median **93%** of the estimated tokens, and 98% of
|
|
213
|
-
steps had them.
|
|
214
|
-
|
|
215
|
-
The fragments do exist during the call. They accumulate on the call's own `args.chunks`
|
|
216
|
-
view, which `legacy.runningCalls` exposes for a call still in its `preparing` phase, so the
|
|
217
|
-
live count reads them there and weights them at the tool coefficient. This is what makes
|
|
218
|
-
the number climb while a file is being written instead of jumping when it finishes.
|
|
219
|
-
|
|
220
|
-
The streaming row is approximate by nature — the Chat snapshot exposes the running step
|
|
221
|
-
only as `legacy.partial` (turn, step, blocks), with no per-step start time. Two things keep
|
|
222
|
-
that approximation honest:
|
|
223
|
-
|
|
224
|
-
- **A step-level origin.** The settled figure is `firstToken − stepStart`, so the live one
|
|
225
|
-
must use the same origin. Subtracting the *turn* start instead overstates TTFT by
|
|
226
|
-
everything earlier in the turn: in a real session log the gap from turn start to a later
|
|
227
|
-
step's start has a **174 s median** and reaches **2720 s**. That is why a live reading
|
|
228
|
-
could show tens of seconds while reasoning yet a few seconds once the step settled.
|
|
229
|
-
- **A tool result, not `completedTime`.** `timing.completedTime` is the `assistant/message`
|
|
230
|
-
time, which lands *before* the step's tools run. Using it as the next step's origin counts
|
|
231
|
-
the whole tool execution into that step's TTFT — measured, that gap has a 243 ms median but
|
|
232
|
-
reaches **120 s**, which is exactly the "six seconds while streaming, two once it settles"
|
|
233
|
-
symptom. A step's last `tool/result` sits within 1 ms of its own `step/end` and a median
|
|
234
|
-
9 ms before the next `step/start`, making it the tightest origin a browser-only plugin can
|
|
235
|
-
reach. Attribution runs on the **seq window**, not timestamps: a resumed log replays steps
|
|
236
|
-
and times repeat. Over 1113 steps the origin error fell from p50 100 ms / p99 20.8 s to
|
|
237
|
-
**p50 9 ms / p99 486 ms**, and steps off by more than a second from 114 to **1**. A step
|
|
238
|
-
that ran no tools falls back to its own `completedTime` (it was the turn's final answer, so
|
|
239
|
-
nothing follows it), and a turn's first step to the turn's start (26 ms earlier).
|
|
240
|
-
- **First delta, not first visible text.** The settled `firstTokenTime` counts reasoning and
|
|
241
|
-
tool-call deltas too, and `legacy.partial` only appears once the step has visible content —
|
|
242
|
-
so the moment a new partial key appears *is* the first-token moment.
|
|
243
|
-
- **Counting from `step/start`, not from the first visible block.** Chat's `legacy.partial`
|
|
244
|
-
waits for a visible block — a **1.9 s median** (p90 2.9 s) after the step begins, which is
|
|
245
|
-
most of what a first-token latency measures. Reading only Chat therefore made the figure
|
|
246
|
-
appear at its final value with no visible growth. The Trajectory domain instead opens its
|
|
247
|
-
assistant record on `step/start` itself, and that record reaches the snapshot's `requests[]`
|
|
248
|
-
with `status: "running"` and an exact `startedAt` — `step/start` is a location boundary, so
|
|
249
|
-
the conversation assembler raises the publication priority from the definition's declared
|
|
250
|
-
`none` to `immediate`. The plugin uses that to know the step is live and where it started,
|
|
251
|
-
so the figure climbs as `now − startedAt` and then locks to `firstTokenTime − startedAt`,
|
|
252
|
-
the same subtraction the settled row performs.
|
|
253
|
-
|
|
254
|
-
## First-token time for replayed history
|
|
255
|
-
|
|
256
|
-
Seeding the drawer from an existing conversation used to leave the first-token column
|
|
257
|
-
empty, even though the Trajectory panel showed a value for the same steps. The reason is
|
|
258
|
-
a data-flow fact, not a missing calculation: `timing.firstTokenTime` is *streamed* state.
|
|
259
|
-
The Chat store accumulates it from live token deltas, and the session log stores no live
|
|
260
|
-
chunks — a real log held 787 settled messages and **zero** `assistant/live-chunk` events.
|
|
261
|
-
A step replayed from that log therefore has no such field.
|
|
262
|
-
|
|
263
|
-
The value is still recoverable. Every settled message carries `data.stream`, the whole
|
|
264
|
-
chunk timeline with per-fragment timestamps, and the Trajectory domain rebuilds the mark
|
|
265
|
-
from it (`firstTokenTime ?? assistantStreamFirstTokenTime(event.data.stream)`), summing
|
|
266
|
-
`time0 + dt` across each packed run. That is why its panel can date history that the Chat
|
|
267
|
-
snapshot cannot.
|
|
268
|
-
|
|
269
|
-
The plugin reads those times from the Trajectory snapshot, which a slot entry reaches
|
|
270
|
-
through the standard `useTrajectory` hook, and dates its rows with them. Two consequences
|
|
271
|
-
worth knowing:
|
|
272
|
-
|
|
273
|
-
- The Trajectory domain is another plugin's projection, so its times can arrive *after*
|
|
274
|
-
the plugin seeded. Rows that were stored undated are repaired in place when that happens
|
|
275
|
-
(`backfill`), instead of staying at `--` for the rest of the session.
|
|
276
|
-
- A value that streamed live is never overwritten: the repair only touches rows whose
|
|
277
|
-
first-token time is still absent.
|
|
278
|
-
|
|
279
|
-
If a composition has no Trajectory domain, `useTrajectory` is simply absent, no index is
|
|
280
|
-
built, and undated rows keep showing `--` — the previous behaviour, with no error.
|
|
281
|
-
|
|
282
|
-
## Install
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
`test/
|
|
323
|
-
`
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
the
|
|
355
|
-
`
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
1
|
+
# dsh-recent-usage-metrics
|
|
2
|
+
|
|
3
|
+
**English** · [简体中文](README.md)
|
|
4
|
+
|
|
5
|
+
Geek-level request metrics for DSH. It adds a third level — **Geek / 极客** — to the
|
|
6
|
+
native **Performance & usage** preference in Settings → General, and while that level
|
|
7
|
+
is active it keeps a live reading of the newest request in the composer status row
|
|
8
|
+
plus a glass drawer with the last ten requests.
|
|
9
|
+
|
|
10
|
+

|
|
11
|
+
|
|
12
|
+
The drawer open: the step still streaming sits on top (its first-token figure climbing,
|
|
13
|
+
speed shown as `—`), the last ten requests follow newest first, and their average closes
|
|
14
|
+
the list. The status pill sits below it, its columns aligned with the table's.
|
|
15
|
+
|
|
16
|
+
The UI is bilingual and follows the DSH language setting. The same drawer in Chinese:
|
|
17
|
+
|
|
18
|
+

|
|
19
|
+
|
|
20
|
+
## What you get
|
|
21
|
+
|
|
22
|
+
**Settings → General → Performance & usage** now offers three levels:
|
|
23
|
+
|
|
24
|
+
| Level | Behaviour |
|
|
25
|
+
| --- | --- |
|
|
26
|
+
| Compact / 简洁 | Shipped behaviour, unchanged |
|
|
27
|
+
| Detailed / 详细 | Shipped behaviour, unchanged |
|
|
28
|
+
| **Geek / 极客** | Everything below |
|
|
29
|
+
|
|
30
|
+
Compact and Detailed keep writing the shipped `ui-chat` → `performanceUsage` field, so
|
|
31
|
+
they behave exactly as before and stay in sync with the shipped row's data. Geek is a
|
|
32
|
+
separate durable flag, because the shipped Chat schema only accepts two values.
|
|
33
|
+
|
|
34
|
+
**Composer status row** (left of the shipped `55 轮 2307 步` pill):
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
⚡ 2.3 s 445 tok/s 1024 tok 4.6 s 223 tok/s
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
time to first token · live generation speed · output tokens · total time · amortised
|
|
41
|
+
speed (output ÷ total), separated by whitespace only. The pill reproduces the shipped
|
|
42
|
+
statistics pill: the same `--dsw-alias-label-tertiary` foreground for both the text and
|
|
43
|
+
the icon (the native pills never tint their glyph), and the same type scale. Height comes
|
|
44
|
+
from padding + line-height, so it matches the native row.
|
|
45
|
+
|
|
46
|
+
Each figure's digits are reserved with a **fixed width on the number element only** —
|
|
47
|
+
never on the wrapping cell, which also carries the unit and would otherwise clip it. The
|
|
48
|
+
estimate marker is a permanently reserved 1ch span that only toggles its paint. A value
|
|
49
|
+
growing from `3.1` to `57.8`, a speed dropping from `328` to `9`, a `--` placeholder, and
|
|
50
|
+
a `~` appearing or disappearing all leave the pill's width byte-identical — the centred
|
|
51
|
+
composer row never shifts.
|
|
52
|
+
|
|
53
|
+
The budgets come from the real distribution of a 433-reading session log, and each
|
|
54
|
+
formatter is written to stay inside its budget:
|
|
55
|
+
|
|
56
|
+
| Slot | Digits | Why that is enough |
|
|
57
|
+
| --- | --- | --- |
|
|
58
|
+
| first token / total | `4.5ch` | `57.8` normally; from 100 s up it prints whole seconds (`126`) |
|
|
59
|
+
| speed / amortised | `4.5ch` | p50 `85`, max `328` in the log |
|
|
60
|
+
| output tokens | `5.5ch` | p50 `219`; `123456` collapses to `123k` |
|
|
61
|
+
|
|
62
|
+
The pill and the drawer use **disjoint class names** (`dsh-rum-part` vs `dsh-rum-col`).
|
|
63
|
+
Sharing one cell class let the drawer's ellipsis rule clip the pill's units — the regression
|
|
64
|
+
that produced digits with a truncated ` s` / ` tok/s`.
|
|
65
|
+
|
|
66
|
+
Drawer columns are **measured from the pill at runtime**, not derived from `ch`. `ch` is
|
|
67
|
+
the advance of `0`, while `tok/s` and the thin `s` are narrower, so an arithmetic column
|
|
68
|
+
can never sit exactly under the rendered cell. On open the plugin reads each
|
|
69
|
+
`[data-metric]` cell's `getBoundingClientRect().width` and publishes one custom property
|
|
70
|
+
per metric (`--dsh-rum-col-ttft`, `--dsh-rum-col-speed`, `--dsh-rum-col-tokens`,
|
|
71
|
+
`--dsh-rum-col-total`, `--dsh-rum-col-amortized`), which the row grid consumes. A
|
|
72
|
+
`ResizeObserver` on the pill re-measures after a font-scale change or a metric swap, and a
|
|
73
|
+
`ch` expression stays as the fallback for the frame before measurement lands.
|
|
74
|
+
|
|
75
|
+
| Drawer column | Consumes | Sits under |
|
|
76
|
+
| --- | --- | --- |
|
|
77
|
+
| first token | `--dsh-rum-col-ttft` | the pill's first-token cell |
|
|
78
|
+
| speed | `--dsh-rum-col-speed` | the pill's speed cell |
|
|
79
|
+
| output tokens | `--dsh-rum-col-tokens` | the pill's token cell |
|
|
80
|
+
| total | `--dsh-rum-col-total` | the pill's total-time cell |
|
|
81
|
+
| amortised | `--dsh-rum-col-amortized` | the pill's amortised cell |
|
|
82
|
+
|
|
83
|
+
Two further details make the edges actually coincide:
|
|
84
|
+
|
|
85
|
+
- **Content edges, not box edges.** The pill pads its content by 8px, while the drawer
|
|
86
|
+
stacks 16px of its own padding with a row's 6px. Aligning the two *boxes* therefore
|
|
87
|
+
leaves the numbers visibly inset. Both insets are measured
|
|
88
|
+
(`drawerContentInset` / `pillContentInset`) and subtracted, so the last figure in the
|
|
89
|
+
drawer lines up with the last figure in the pill.
|
|
90
|
+
- **Spare width goes left.** The drawer keeps a `min-width` so its own headers are not
|
|
91
|
+
truncated; each row uses `justify-content: end`, pushing any surplus to the left so the
|
|
92
|
+
final column stays flush. The drawer is also re-placed on the next animation frame,
|
|
93
|
+
because the first pass can run before the measured columns have been applied.
|
|
94
|
+
|
|
95
|
+
Click the pill to open the drawer; click it again, or press Escape, or click outside, to
|
|
96
|
+
close it. There is no hover-open and no tooltip.
|
|
97
|
+
|
|
98
|
+
## Three states
|
|
99
|
+
|
|
100
|
+
This plugin has two kinds of busy and one kind of idle, and all three look different:
|
|
101
|
+
|
|
102
|
+
| State | Decided by | Pill icon | Drawer header | Live row |
|
|
103
|
+
| --- | --- | --- | --- | --- |
|
|
104
|
+
| **Model writing** | `model` is `null` | ⚡ pulsing bolt | `Streaming` | yes (11 rows) |
|
|
105
|
+
| **Tools running** | `model` set, `endTime` `null` | **pulsing T** | **`Calling tools`** | yes (11 rows) |
|
|
106
|
+
| **Idle** | `endTime` set, or no running step | still | `last N` | no (10 rows) |
|
|
107
|
+
|
|
108
|
+
### Why the tool phase is its own state
|
|
109
|
+
|
|
110
|
+
This plugin measures **model output**, and `step/end` only arrives after the step's tool calls
|
|
111
|
+
have all returned. Keying off `running` (which `step/end` clears) therefore kept the plugin in
|
|
112
|
+
"streaming" for the entire tool phase after the reply had already finished: the bolt kept
|
|
113
|
+
pulsing and the live row kept occupying an eleventh row.
|
|
114
|
+
|
|
115
|
+
The field that separates the first two is `model`, which is `messageTime − startTime`:
|
|
116
|
+
|
|
117
|
+
- the model is still writing → `null`
|
|
118
|
+
- `assistant/message` has landed → a fixed value, **even while tools run**
|
|
119
|
+
|
|
120
|
+
`endTime` then separates the tool phase from idle. Both fields come from the Host's
|
|
121
|
+
`sessionStats` fold — the same data the Statistics page renders.
|
|
122
|
+
|
|
123
|
+
### The live row stays through the tool phase
|
|
124
|
+
|
|
125
|
+
The request is genuinely still in flight, so it keeps its row; only the marker changes from the
|
|
126
|
+
bolt to a T, and the drawer header switches from "Streaming" to "Calling tools". That makes all
|
|
127
|
+
three states distinguishable at a glance.
|
|
128
|
+
|
|
129
|
+
Both glyphs share **the same 14×14 svg box** (`.dsh-rum-trigger svg`) and the same `pulse`
|
|
130
|
+
animation. Switching changes the shape and colour inside the box, never the box, so the status
|
|
131
|
+
row does not shift. The T is drawn narrower on purpose so it does not read heavier than the bolt.
|
|
132
|
+
|
|
133
|
+
### Fallback
|
|
134
|
+
|
|
135
|
+
A composition without the statistics fold has no `model` to read. There, `partial` and a
|
|
136
|
+
Trajectory request can only mean the model is writing, so the state is always "model writing" —
|
|
137
|
+
it never degrades to idle just because the fold is missing.
|
|
138
|
+
|
|
139
|
+
**Drawer** — slides up from the status row, right-aligned to that pill, `position: fixed`
|
|
140
|
+
on `document.body` so no scroll container can clip it. Its material is the shipped
|
|
141
|
+
statistics dialog's own, copied token for token: the `--dsw-specific-menu` fill
|
|
142
|
+
(`#303136f0` in dark mode, i.e. nearly opaque, the same surface behind the native
|
|
143
|
+
numbers), the `--dsw-menu-backdrop-filter` 40px/150% blur, `--dsw-elevation-prominent`
|
|
144
|
+
stroke and shadow, `--dsw-radius-lg`, and 16px padding. It lists the last ten requests,
|
|
145
|
+
newest first, with no explanatory footer:
|
|
146
|
+
|
|
147
|
+
| # | At | TTFT | Speed | Tokens | Time | Amortised |
|
|
148
|
+
| --- | --- | --- | --- | --- | --- | --- |
|
|
149
|
+
| 1 | 18:42:07 | 2.3 s | 445 tok/s | 1024 tok | 4.6 s | 223 tok/s |
|
|
150
|
+
|
|
151
|
+
The `At` column is when the request was **issued** (`step/start`), matching the Statistics
|
|
152
|
+
view. It is not the end time: a row stamped 18:42:07 with a 4.6 s total actually finished
|
|
153
|
+
at 18:42:12, so an end stamp would read as "it finished then" and would also order the list
|
|
154
|
+
by completion rather than by issue.
|
|
155
|
+
|
|
156
|
+
A new request slides in at the top; the row that falls off the ten-row cap is removed in
|
|
157
|
+
the **same frame** rather than fading out on its own. An exit phase would keep an
|
|
158
|
+
eleventh row laid out for the animation's duration, growing the drawer by a row and
|
|
159
|
+
shrinking it back — a visible height jump on every insertion. Opening the drawer also
|
|
160
|
+
establishes a baseline instead of animating, so the whole list never slides in at once.
|
|
161
|
+
|
|
162
|
+
While a step is streaming, the drawer shows it as a live row above the history, and the
|
|
163
|
+
status reading switches to a live style. Under a rule below the history sits a summary
|
|
164
|
+
row — the average of the visible requests, in the same grid so each mean lands under its
|
|
165
|
+
column. A column with no samples (say no TTFT on an interrupted request) prints a dash
|
|
166
|
+
rather than a misleading zero.
|
|
167
|
+
|
|
168
|
+
## How the numbers are measured
|
|
169
|
+
|
|
170
|
+
Everything is derived in the browser from the Chat snapshot the Conversation layer
|
|
171
|
+
already maintains — the plugin adds no Host service, session event, or projection.
|
|
172
|
+
|
|
173
|
+
- **Time to first token** — `timing.firstTokenTime − timing.stepStartTime`, the recorded
|
|
174
|
+
moment the request was issued and the moment the first visible delta arrived.
|
|
175
|
+
- **Total time** — `timing.completedTime − timing.stepStartTime`.
|
|
176
|
+
- **Live speed** — output tokens ÷ decode time (`completedTime − firstTokenTime`).
|
|
177
|
+
- **Output tokens** — the provider's own `usage.outputTokens`, which is the authority.
|
|
178
|
+
It is read from `assistant/message.data.usage` (verified against a real session log:
|
|
179
|
+
262 of 262 settled steps carried it). `reasoningTokens` is already included in that
|
|
180
|
+
count and is never added on top. OpenAI-shaped `completionTokens` spellings are also
|
|
181
|
+
accepted for adapters that forward their usage verbatim.
|
|
182
|
+
- **Amortised speed** — output tokens ÷ total time.
|
|
183
|
+
|
|
184
|
+
The character estimate is a genuine fallback, not the normal path: it is used only for a
|
|
185
|
+
step whose provider returned no usage at all (an interrupted or failed request), and such
|
|
186
|
+
a row is marked `~`.
|
|
187
|
+
|
|
188
|
+
Its coefficients are calibrated, not guessed. The content is not homogeneous: prose and
|
|
189
|
+
reasoning tokenize near 4 ASCII characters per token, while tool-call arguments are JSON —
|
|
190
|
+
short keys, punctuation, quoted values — and tokenize markedly worse, near 2.5. Measured
|
|
191
|
+
against the provider's own `outputTokens` over 729 settled steps of a real session log,
|
|
192
|
+
using the median absolute log error:
|
|
193
|
+
|
|
194
|
+
| Rule | Median error | Estimate ÷ actual (p50) |
|
|
195
|
+
| --- | --- | --- |
|
|
196
|
+
| one shared `/4` for everything | 0.361 | 0.70 |
|
|
197
|
+
| prose `/4`, tool args `/2.5` (shipped) | **0.128** | **1.00** |
|
|
198
|
+
|
|
199
|
+
Splitting the two is what removed the bias. The old shared divisor systematically
|
|
200
|
+
under-counted by about 30%, which is why the live amortised speed read low during
|
|
201
|
+
generation; a fine grid search put the optimum at 0.125, so the shipped rule is within
|
|
202
|
+
three thousandths of it. CJK stays at the conventional one token per character: the search
|
|
203
|
+
suggested a lower cost, but only 45% of those steps contain any CJK, so that term is
|
|
204
|
+
poorly identified in this sample and a fitted value would be noise.
|
|
205
|
+
|
|
206
|
+
### Tool arguments while they stream
|
|
207
|
+
|
|
208
|
+
`legacy.partial.blocks` does **not** carry argument text during a call. The store's
|
|
209
|
+
`tool-call-delta` branch copies `argsRaw` unchanged, so it stays an empty string until
|
|
210
|
+
`block-end` fills it in one go. Counting only `blocks` therefore froze the live figure for
|
|
211
|
+
the whole write phase — and that phase dominates real output: across 874 settled steps of
|
|
212
|
+
a session log, tool arguments were a median **93%** of the estimated tokens, and 98% of
|
|
213
|
+
steps had them.
|
|
214
|
+
|
|
215
|
+
The fragments do exist during the call. They accumulate on the call's own `args.chunks`
|
|
216
|
+
view, which `legacy.runningCalls` exposes for a call still in its `preparing` phase, so the
|
|
217
|
+
live count reads them there and weights them at the tool coefficient. This is what makes
|
|
218
|
+
the number climb while a file is being written instead of jumping when it finishes.
|
|
219
|
+
|
|
220
|
+
The streaming row is approximate by nature — the Chat snapshot exposes the running step
|
|
221
|
+
only as `legacy.partial` (turn, step, blocks), with no per-step start time. Two things keep
|
|
222
|
+
that approximation honest:
|
|
223
|
+
|
|
224
|
+
- **A step-level origin.** The settled figure is `firstToken − stepStart`, so the live one
|
|
225
|
+
must use the same origin. Subtracting the *turn* start instead overstates TTFT by
|
|
226
|
+
everything earlier in the turn: in a real session log the gap from turn start to a later
|
|
227
|
+
step's start has a **174 s median** and reaches **2720 s**. That is why a live reading
|
|
228
|
+
could show tens of seconds while reasoning yet a few seconds once the step settled.
|
|
229
|
+
- **A tool result, not `completedTime`.** `timing.completedTime` is the `assistant/message`
|
|
230
|
+
time, which lands *before* the step's tools run. Using it as the next step's origin counts
|
|
231
|
+
the whole tool execution into that step's TTFT — measured, that gap has a 243 ms median but
|
|
232
|
+
reaches **120 s**, which is exactly the "six seconds while streaming, two once it settles"
|
|
233
|
+
symptom. A step's last `tool/result` sits within 1 ms of its own `step/end` and a median
|
|
234
|
+
9 ms before the next `step/start`, making it the tightest origin a browser-only plugin can
|
|
235
|
+
reach. Attribution runs on the **seq window**, not timestamps: a resumed log replays steps
|
|
236
|
+
and times repeat. Over 1113 steps the origin error fell from p50 100 ms / p99 20.8 s to
|
|
237
|
+
**p50 9 ms / p99 486 ms**, and steps off by more than a second from 114 to **1**. A step
|
|
238
|
+
that ran no tools falls back to its own `completedTime` (it was the turn's final answer, so
|
|
239
|
+
nothing follows it), and a turn's first step to the turn's start (26 ms earlier).
|
|
240
|
+
- **First delta, not first visible text.** The settled `firstTokenTime` counts reasoning and
|
|
241
|
+
tool-call deltas too, and `legacy.partial` only appears once the step has visible content —
|
|
242
|
+
so the moment a new partial key appears *is* the first-token moment.
|
|
243
|
+
- **Counting from `step/start`, not from the first visible block.** Chat's `legacy.partial`
|
|
244
|
+
waits for a visible block — a **1.9 s median** (p90 2.9 s) after the step begins, which is
|
|
245
|
+
most of what a first-token latency measures. Reading only Chat therefore made the figure
|
|
246
|
+
appear at its final value with no visible growth. The Trajectory domain instead opens its
|
|
247
|
+
assistant record on `step/start` itself, and that record reaches the snapshot's `requests[]`
|
|
248
|
+
with `status: "running"` and an exact `startedAt` — `step/start` is a location boundary, so
|
|
249
|
+
the conversation assembler raises the publication priority from the definition's declared
|
|
250
|
+
`none` to `immediate`. The plugin uses that to know the step is live and where it started,
|
|
251
|
+
so the figure climbs as `now − startedAt` and then locks to `firstTokenTime − startedAt`,
|
|
252
|
+
the same subtraction the settled row performs.
|
|
253
|
+
|
|
254
|
+
## First-token time for replayed history
|
|
255
|
+
|
|
256
|
+
Seeding the drawer from an existing conversation used to leave the first-token column
|
|
257
|
+
empty, even though the Trajectory panel showed a value for the same steps. The reason is
|
|
258
|
+
a data-flow fact, not a missing calculation: `timing.firstTokenTime` is *streamed* state.
|
|
259
|
+
The Chat store accumulates it from live token deltas, and the session log stores no live
|
|
260
|
+
chunks — a real log held 787 settled messages and **zero** `assistant/live-chunk` events.
|
|
261
|
+
A step replayed from that log therefore has no such field.
|
|
262
|
+
|
|
263
|
+
The value is still recoverable. Every settled message carries `data.stream`, the whole
|
|
264
|
+
chunk timeline with per-fragment timestamps, and the Trajectory domain rebuilds the mark
|
|
265
|
+
from it (`firstTokenTime ?? assistantStreamFirstTokenTime(event.data.stream)`), summing
|
|
266
|
+
`time0 + dt` across each packed run. That is why its panel can date history that the Chat
|
|
267
|
+
snapshot cannot.
|
|
268
|
+
|
|
269
|
+
The plugin reads those times from the Trajectory snapshot, which a slot entry reaches
|
|
270
|
+
through the standard `useTrajectory` hook, and dates its rows with them. Two consequences
|
|
271
|
+
worth knowing:
|
|
272
|
+
|
|
273
|
+
- The Trajectory domain is another plugin's projection, so its times can arrive *after*
|
|
274
|
+
the plugin seeded. Rows that were stored undated are repaired in place when that happens
|
|
275
|
+
(`backfill`), instead of staying at `--` for the rest of the session.
|
|
276
|
+
- A value that streamed live is never overwritten: the repair only touches rows whose
|
|
277
|
+
first-token time is still absent.
|
|
278
|
+
|
|
279
|
+
If a composition has no Trajectory domain, `useTrajectory` is simply absent, no index is
|
|
280
|
+
built, and undated rows keep showing `--` — the previous behaviour, with no error.
|
|
281
|
+
|
|
282
|
+
## Install
|
|
283
|
+
|
|
284
|
+
Published to npm as `dsh-recent-usage-metrics`:
|
|
285
|
+
|
|
286
|
+
```powershell
|
|
287
|
+
# from the dsh GUI's Plugin Manager, or directly:
|
|
288
|
+
npm install dsh-recent-usage-metrics
|
|
289
|
+
```
|
|
290
|
+
|
|
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.
|
|
310
|
+
|
|
311
|
+
## Layout
|
|
312
|
+
|
|
313
|
+
| Path | Purpose |
|
|
314
|
+
| --- | --- |
|
|
315
|
+
| `client/client.js` | The whole feature: settings row, status reading, drawer |
|
|
316
|
+
| `lib/index.js` | Empty Host half — makes the package a Loader entry so `./client` is published |
|
|
317
|
+
| `cordis.patch.yml` | The one inserted Loader row |
|
|
318
|
+
| `locale/en.json`, `locale/zh.json` | Plugin Manager title and description |
|
|
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 |
|
|
322
|
+
| `test/lifecycle.cjs` | Runs the real client against a DOM/hook shim: metrics, ten-row cap, insertion order, click/open, disposal |
|
|
323
|
+
| `test/repro-coldstart.cjs` | Regression: a cold page must paint Chinese and stay switchable both ways |
|
|
324
|
+
| `test/repro-hmr.cjs` | Regression: a reload must not collide with the live dictionary claim |
|
|
325
|
+
| `test/repro-log.cjs` | Verifies against a real session log (see below); skips itself when none exists |
|
|
326
|
+
|
|
327
|
+
```powershell
|
|
328
|
+
node test/lifecycle.cjs
|
|
329
|
+
node test/repro-coldstart.cjs
|
|
330
|
+
node test/repro-hmr.cjs
|
|
331
|
+
$env:ELECTRON_RUN_AS_NODE='1'; & "C:\Cinstall\ProgramTools\DSH\DeepSeek Harness.exe" test\repro-log.cjs
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
## Verifying against a real session log
|
|
335
|
+
|
|
336
|
+
The Statistics page's figures come from the durable session log on disk:
|
|
337
|
+
|
|
338
|
+
```
|
|
339
|
+
~/.dsh/sessions/<workspace>/<session-id>/session.v4.jsonl.zstd
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
`test/repro-log.cjs` finds the newest one, folds it with the Statistics view's own
|
|
343
|
+
`foldEvent` / `stepMetrics` algorithm, then checks the properties the drawer depends on:
|
|
344
|
+
every step has a start time, first-token latency is non-negative and never exceeds the step's
|
|
345
|
+
wall time, settled steps have a total, running steps do not, and the drawer selects the newest
|
|
346
|
+
ten rows in descending order.
|
|
347
|
+
|
|
348
|
+
That is far more reliable than comparing a few rows on screen: one run covers thousands of
|
|
349
|
+
steps.
|
|
350
|
+
|
|
351
|
+
Two traps the script handles:
|
|
352
|
+
|
|
353
|
+
- **The log is many concatenated zstd frames.** A single `zstdDecompressSync` call returns
|
|
354
|
+
only the first frame — a few hundred bytes. The frames must be split at the magic
|
|
355
|
+
`28 B5 2F FD` and decompressed one by one, otherwise all you get is the `session` header.
|
|
356
|
+
- **The first-token time lives in packed records.** The log contains no
|
|
357
|
+
`assistant/live-chunk` events (client-only, never persisted); the moment is inside the
|
|
358
|
+
`reasoning-chunks` / `tool-call-chunks` records of `assistant/message.data.stream`, as
|
|
359
|
+
`time0` plus the cumulative `dt` offsets. Reading only loose `chunk` records leaves the
|
|
360
|
+
first-token time at `--` for nearly every step (measured: 0% → 96%).
|
|
361
|
+
|
|
362
|
+
## Language after a restart
|
|
363
|
+
|
|
364
|
+
`locale` is declared as a **hard dependency** (`inject: ['slots', 'locale']`) and `apply`
|
|
365
|
+
reads `ctx.locale`. Probing with `ctx.get('locale')` was a real defect with a confusing
|
|
366
|
+
symptom: the locale plugin itself waits on the connection, settings and remotes services,
|
|
367
|
+
so on a cold page this plugin could apply first, read `undefined`, skip registering its
|
|
368
|
+
dictionaries, and then render through a dictionary-less `t` — English, or raw keys, that
|
|
369
|
+
stayed English even after switching the language back.
|
|
370
|
+
|
|
371
|
+
`configForms` is optional (a profile may compose this bundle without the settings domain),
|
|
372
|
+
so it is awaited with `ctx.inject(['configForms'], …)` rather than probed once. A single
|
|
373
|
+
probe would latch the settings row onto the fallback preference for the whole session if
|
|
374
|
+
the domain arrived late. Both choices follow the plugin guidance: required services go in
|
|
375
|
+
`inject`, optional ones go in `ctx.inject`.
|
|
376
|
+
|
|
377
|
+
The dictionary claim is parked on `globalThis` so a hot reload can release the previous
|
|
378
|
+
generation's claim before registering; otherwise the second `register` for the namespace
|
|
379
|
+
would throw and leave the UI dictionary-less — the same visible symptom by another route.
|
|
380
|
+
|
|
381
|
+
## Notes
|
|
382
|
+
|
|
383
|
+
- The row is registered into the shipped `performance-usage` cell with `priority: -1`
|
|
384
|
+
and the shipped `order: 30`, so it renders in the same place and the shipped cell is
|
|
385
|
+
shadowed rather than duplicated.
|
|
386
|
+
- Everything that outlives a render is created inside `ctx.effect`, so disabling the
|
|
387
|
+
plugin restores the shipped row and removes every listener and style.
|
|
388
|
+
- **Only the Geek flag is kept in `localStorage`** (`dsh.recentUsageMetrics.mode.v1`).
|
|
389
|
+
The drawer's history is no longer persisted; it is derived from the session's own
|
|
390
|
+
statistics, explained below.
|
|
391
|
+
|
|
392
|
+
## Where the drawer's rows come from
|
|
393
|
+
|
|
394
|
+
Each row reads the **same data the shipped Statistics page renders** (the `stats` view
|
|
395
|
+
target) instead of computing its own copy:
|
|
396
|
+
|
|
397
|
+
```js
|
|
398
|
+
const target = uiConversation.binding(sessionId).target('stats')
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
That data comes from the Host-side `sessionStats` fold over the session log, and each step
|
|
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
|
+
**Why the total is `model` and not `wall`:**
|
|
416
|
+
|
|
417
|
+
The Statistics view's total column is `wall = endTime − startTime`, which **includes the step's
|
|
418
|
+
tool calls**. A step that thinks for 11.5 s and then runs a 31 s command shows `wall` 41.9 s but
|
|
419
|
+
`model` 11.5 s. This plugin measures model output only, so it reports `model` — a figure that
|
|
420
|
+
contains no tool time by construction, and is therefore final the moment the reply lands.
|
|
421
|
+
|
|
422
|
+
**Why the figures carry `~` while streaming:**
|
|
423
|
+
|
|
424
|
+
Until the model finishes, the Host has no `usage` from the provider, so `output` is `null`. The
|
|
425
|
+
plugin falls back to a character estimate (coefficients fitted per content type), which is why
|
|
426
|
+
those figures carry `~`. The moment `usage` arrives — which is essentially the same instant the
|
|
427
|
+
reply completes — the measured value takes over and the `~` disappears.
|
|
428
|
+
|
|
429
|
+
**Why the history is no longer stored locally:**
|
|
430
|
+
|
|
431
|
+
The drawer used to write its history to `localStorage` under one global key, with row keys
|
|
432
|
+
of the form `${turn}:${step}`. **Every conversation numbers its turns and steps from 1**, so
|
|
433
|
+
one conversation's `1:1` collided with another's — switching conversations showed the wrong
|
|
434
|
+
figures and switching back showed them again. That was the reported symptom.
|
|
435
|
+
|
|
436
|
+
Now:
|
|
437
|
+
|
|
438
|
+
- Rows are rebuilt from that conversation's own statistics on every render, so there is no
|
|
439
|
+
local copy that can go stale.
|
|
440
|
+
- A reported session change clears them immediately, so the previous conversation's rows
|
|
441
|
+
never appear under the new one while its data is still arriving.
|
|
442
|
+
- A page reload rebuilds from the log; nothing extra needs persisting.
|
|
443
|
+
|
|
444
|
+
**A view target only exists once something subscribes** (`getSnapshot` returns `undefined`
|
|
445
|
+
before that). The plugin subscribes once per session at apply time. Activation is permanent
|
|
446
|
+
and shared, so this does not start a second fold alongside the Statistics view.
|
|
447
|
+
|
|
448
|
+
**Fallback**: in a composition without that data (`uiConversation` absent), the drawer
|
|
449
|
+
returns to the Chat-snapshot plus Trajectory-domain calculation and marks estimates with `~`.
|
|
450
|
+
That path only runs when the statistics data is missing.
|
|
451
|
+
|
|
452
|
+
**The live reading does not touch the statistics during streaming**: its fold rebuilds the
|
|
453
|
+
whole table on every chunk. The status pill uses a lighter path instead — the Trajectory
|
|
454
|
+
domain's `step/start` time plus a 150 ms local tick — and reads the statistics once each
|
|
455
|
+
step settles. That keeps the counting-up effect without adding fold work on every chunk.
|