dsh-speedometer 0.0.0-stage → 0.1.0

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/lib/index.js ADDED
@@ -0,0 +1,1005 @@
1
+ /**
2
+ * DSH Speedometer
3
+ *
4
+ * Measures generation speed at the `llm/stream` layer, so it is provider
5
+ * agnostic: DeepSeek official, any OpenAI-compatible endpoint and local
6
+ * models all pass through the same hook. Completed attempts are grouped by
7
+ * session and served to the in-page widget over SSE.
8
+ */
9
+ import fs from 'node:fs'
10
+ import path from 'node:path'
11
+ import os from 'node:os'
12
+ import { fileURLToPath } from 'node:url'
13
+
14
+ const ROUTE = '/dsh-speed'
15
+ const WIDGET_ROUTE = `${ROUTE}/widget.js`
16
+ const EVENTS_ROUTE = `${ROUTE}/events`
17
+ const STATE_ROUTE = `${ROUTE}/state`
18
+ const VERSION = '0.1.0'
19
+ const MAX_SESSIONS = 40
20
+ const MAX_ATTEMPTS = 1000
21
+ const MAX_SAMPLES = 220
22
+ const TICK_MS = 180
23
+ const WINDOW_MS = 1600
24
+ const DSH_HOME = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
25
+ const HISTORY_FILE = path.join(DSH_HOME, 'dsh-speedometer', 'history.json')
26
+
27
+ const DESKTOP_WIDGET_ROW_TEXT =
28
+ '(function(){try{var d=document.body||document.head||document.documentElement;if(!d)return;' +
29
+ 'if(document.getElementById("dsh-speedometer-script"))return;' +
30
+ 'var s=document.createElement("script");s.id="dsh-speedometer-script";s.src="' + WIDGET_ROUTE + '";' +
31
+ 's.onerror=function(){};d.appendChild(s)}catch(e){}})()'
32
+
33
+ function clamp(value, min, max) {
34
+ return Math.min(max, Math.max(min, value))
35
+ }
36
+
37
+ function finite(value) {
38
+ return typeof value === 'number' && Number.isFinite(value)
39
+ }
40
+
41
+ function count(value) {
42
+ return finite(value) && value >= 0 ? Math.floor(value) : undefined
43
+ }
44
+
45
+ function textOfContent(content) {
46
+ try {
47
+ if (typeof content === 'string') return content
48
+ if (!Array.isArray(content)) return ''
49
+ const parts = []
50
+ for (const block of content) {
51
+ if (!block || typeof block !== 'object') continue
52
+ if (block.type === 'text' && typeof block.text === 'string') parts.push(block.text)
53
+ else if (block.type === 'reasoning' && typeof block.text === 'string') parts.push(block.text)
54
+ }
55
+ return parts.join(' ').trim()
56
+ } catch {
57
+ return ''
58
+ }
59
+ }
60
+
61
+ function shortLabel(text, max = 34) {
62
+ const normalized = String(text || '').replace(/\s+/g, ' ').trim()
63
+ if (!normalized) return ''
64
+ return normalized.length > max ? `${normalized.slice(0, max - 1)}…` : normalized
65
+ }
66
+
67
+ function safeId(value) {
68
+ return typeof value === 'string' && value.length > 0 ? value : ''
69
+ }
70
+
71
+ function isTokenChunk(chunk) {
72
+ if (!chunk || typeof chunk !== 'object') return false
73
+ switch (chunk.type) {
74
+ case 'text-delta':
75
+ case 'reasoning-delta':
76
+ return typeof chunk.text === 'string' && chunk.text.length > 0
77
+ case 'tool-call-delta':
78
+ return (typeof chunk.argumentsDelta === 'string' && chunk.argumentsDelta.length > 0)
79
+ || typeof chunk.name === 'string'
80
+ default:
81
+ return false
82
+ }
83
+ }
84
+
85
+ /** Expand DSH's compact timed stream records without importing host internals. */
86
+ function* expandedRecords(stream) {
87
+ if (!Array.isArray(stream)) return
88
+ for (const record of stream) {
89
+ if (!record || typeof record !== 'object') continue
90
+ if (record.type === 'chunk' && record.chunk && typeof record.chunk === 'object') {
91
+ yield { time: finite(record.time) ? record.time : 0, chunk: record.chunk }
92
+ continue
93
+ }
94
+ const members = record.type === 'tool-call-chunks'
95
+ ? record.args
96
+ : record.type === 'text-chunks' || record.type === 'reasoning-chunks'
97
+ ? record.texts
98
+ : undefined
99
+ if (!Array.isArray(members)) continue
100
+ let time = finite(record.time0) ? record.time0 : 0
101
+ const dt = Array.isArray(record.dt) ? record.dt : []
102
+ for (let index = 0; index < members.length; index += 1) {
103
+ if (index > 0 && finite(dt[index - 1])) time += dt[index - 1]
104
+ yield { time, chunk: null, member: members[index], recordType: record.type }
105
+ }
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Derive one durable attempt from an assistant settlement's compact stream.
111
+ * Historical TTFT is intentionally null: the stream begins after the request
112
+ * is already in flight, so claiming a first-byte latency would be misleading.
113
+ */
114
+ function deriveSamples(tokenTimes, startTime, lastTime, maxPoints = 48) {
115
+ if (!Array.isArray(tokenTimes) || tokenTimes.length === 0 || !startTime) return []
116
+ const endTime = Math.max(lastTime, startTime + 320)
117
+ const span = endTime - startTime
118
+ const step = Math.max(280, Math.ceil(span / maxPoints))
119
+ const points = []
120
+ let cursor = 0
121
+ let windowStart = 0
122
+ for (let t = startTime; t <= endTime + step; t += step) {
123
+ while (cursor < tokenTimes.length && tokenTimes[cursor] <= t) cursor += 1
124
+ while (windowStart < cursor && tokenTimes[windowStart] < t - 1200) windowStart += 1
125
+ const windowTokens = cursor - windowStart
126
+ const elapsed = Math.max(320, Math.min(1200, t - startTime))
127
+ points.push({
128
+ t: Math.max(0, t - startTime),
129
+ v: windowTokens > 0 ? windowTokens / (elapsed / 1000) : 0,
130
+ })
131
+ }
132
+ return points.length > 0 ? points : [{ t: 0, v: 0 }]
133
+ }
134
+
135
+ function deriveFromStream(stream, eventTime) {
136
+ let firstTime = 0
137
+ let lastTime = 0
138
+ let firstTokenTime = 0
139
+ let lastTokenTime = 0
140
+ let tokens = 0
141
+ let usage
142
+ let finishKind = ''
143
+ const tokenTimes = []
144
+ for (const entry of expandedRecords(stream)) {
145
+ if (!firstTime) firstTime = entry.time
146
+ lastTime = Math.max(lastTime, entry.time)
147
+ if (entry.chunk) {
148
+ if ((entry.chunk.type === 'text-delta' || entry.chunk.type === 'reasoning-delta')
149
+ && typeof entry.chunk.text === 'string' && entry.chunk.text.length > 0) {
150
+ tokens += 1
151
+ if (!firstTokenTime) firstTokenTime = entry.time
152
+ lastTokenTime = Math.max(lastTokenTime, entry.time)
153
+ tokenTimes.push(entry.time)
154
+ } else if (entry.chunk.type === 'tool-call-delta'
155
+ && ((typeof entry.chunk.argumentsDelta === 'string' && entry.chunk.argumentsDelta.length > 0)
156
+ || typeof entry.chunk.name === 'string')) {
157
+ tokens += 1
158
+ if (!firstTokenTime) firstTokenTime = entry.time
159
+ lastTokenTime = Math.max(lastTokenTime, entry.time)
160
+ tokenTimes.push(entry.time)
161
+ } else if (entry.chunk.type === 'usage') {
162
+ usage = entry.chunk.usage
163
+ } else if (entry.chunk.type === 'finish') {
164
+ finishKind = entry.chunk.reason && typeof entry.chunk.reason.kind === 'string'
165
+ ? entry.chunk.reason.kind
166
+ : ''
167
+ }
168
+ } else if (typeof entry.member === 'string' && entry.member.length > 0) {
169
+ tokens += 1
170
+ if (!firstTokenTime) firstTokenTime = entry.time
171
+ lastTokenTime = Math.max(lastTokenTime, entry.time)
172
+ tokenTimes.push(entry.time)
173
+ }
174
+ }
175
+ const outputTokens = usage && count(usage.outputTokens)
176
+ const exactTokens = outputTokens !== undefined && outputTokens > 0 ? outputTokens : tokens
177
+ const durationMs = firstTime && lastTime > firstTime ? lastTime - firstTime : 0
178
+ const pointTimes = [...new Set(tokenTimes)].sort((a, b) => a - b)
179
+ const lastToken = pointTimes.length > 0 ? pointTimes[pointTimes.length - 1] : lastTime
180
+ const spanMs = lastToken - firstTime
181
+ const tps = spanMs > 40 && exactTokens > 0 ? exactTokens / (spanMs / 1000) : null
182
+ return {
183
+ tokens: exactTokens,
184
+ inputTokens: usage && count(usage.inputTokens),
185
+ finishKind,
186
+ firstTime,
187
+ lastTime,
188
+ generationMs: spanMs || durationMs,
189
+ tps,
190
+ samples: deriveSamples(pointTimes, firstTime || eventTime, lastTime || eventTime),
191
+ }
192
+ }
193
+
194
+ function uniqueId(prefix) {
195
+ return `${prefix}${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`
196
+ }
197
+
198
+ export default {
199
+ name: 'dsh-speedometer',
200
+
201
+ apply(root) {
202
+ const rowDisposers = []
203
+ try {
204
+ rowDisposers.push(root.on('webserver/index-inject', (table) => {
205
+ try {
206
+ if (!Array.isArray(table)) return
207
+ for (const row of table) {
208
+ if (!row) continue
209
+ if (row.kind === 'script-src' && row.src === WIDGET_ROUTE) return
210
+ if (row.kind === 'script' && typeof row.text === 'string'
211
+ && row.text.indexOf(WIDGET_ROUTE) !== -1) return
212
+ }
213
+ table.push({ kind: 'script', placement: 'body', text: DESKTOP_WIDGET_ROW_TEXT })
214
+ } catch {}
215
+ }))
216
+ } catch {}
217
+
218
+ root.effect(() => () => {
219
+ for (const dispose of rowDisposers) {
220
+ try { dispose() } catch {}
221
+ }
222
+ })
223
+
224
+ root.inject(['webServer', 'sessions', 'llm', 'connection'], (ctx) => {
225
+ const disposers = []
226
+ const clients = new Set()
227
+ const sessions = new Map()
228
+ let activeSessionId = null
229
+ let saveTimer = null
230
+ let lastBroadcastError = 0
231
+
232
+ function ensureSession(sessionId, patch = {}) {
233
+ const id = safeId(sessionId) || 'unknown'
234
+ let session = sessions.get(id)
235
+ if (!session) {
236
+ session = {
237
+ id,
238
+ label: '',
239
+ model: '',
240
+ provider: '',
241
+ createdAt: Date.now(),
242
+ updatedAt: Date.now(),
243
+ attempts: [],
244
+ live: null,
245
+ currentTurn: 0,
246
+ }
247
+ sessions.set(id, session)
248
+ }
249
+ if (patch.label) session.label = patch.label
250
+ if (patch.model) session.model = patch.model
251
+ if (patch.provider) session.provider = patch.provider
252
+ session.updatedAt = Date.now()
253
+ return session
254
+ }
255
+
256
+ function trimSessions() {
257
+ if (sessions.size <= MAX_SESSIONS) return
258
+ const ordered = [...sessions.values()].sort((a, b) => a.updatedAt - b.updatedAt)
259
+ while (ordered.length > MAX_SESSIONS) {
260
+ const victim = ordered.shift()
261
+ if (!victim || victim.id === activeSessionId) continue
262
+ sessions.delete(victim.id)
263
+ }
264
+ }
265
+
266
+ function attemptSignature(attempt) {
267
+ return [
268
+ attempt.sessionId,
269
+ attempt.turn ?? '',
270
+ attempt.step ?? '',
271
+ attempt.tokens,
272
+ Math.round((attempt.endedAt || attempt.startedAt || 0) / 2000),
273
+ ].join('|')
274
+ }
275
+
276
+ function addAttempt(attempt, { broadcast = true } = {}) {
277
+ const session = ensureSession(attempt.sessionId, {
278
+ model: attempt.model,
279
+ provider: attempt.provider,
280
+ })
281
+ const signature = attemptSignature(attempt)
282
+ const existing = session.attempts.find((candidate) => attemptSignature(candidate) === signature)
283
+ if (existing) return existing
284
+ session.attempts.push(attempt)
285
+ if (session.attempts.length > MAX_ATTEMPTS) {
286
+ session.attempts.splice(0, session.attempts.length - MAX_ATTEMPTS)
287
+ }
288
+ session.updatedAt = Date.now()
289
+ if (broadcast) emit('attempt', { attempt: publicAttempt(attempt, true), session: publicSessionMeta(session) })
290
+ scheduleSave()
291
+ return attempt
292
+ }
293
+
294
+ function publicSample(sample) {
295
+ return { t: Math.round(sample.t), v: Math.round(sample.v * 10) / 10 }
296
+ }
297
+
298
+ function publicAttempt(attempt, includeSamples = false) {
299
+ if (!attempt) return null
300
+ const out = {
301
+ id: attempt.id,
302
+ provider: attempt.provider || '',
303
+ model: attempt.model || '',
304
+ startedAt: attempt.startedAt,
305
+ endedAt: attempt.endedAt,
306
+ ttftMs: finite(attempt.ttftMs) ? Math.round(attempt.ttftMs) : null,
307
+ durationMs: finite(attempt.durationMs) ? Math.round(attempt.durationMs) : null,
308
+ tokens: attempt.tokens || 0,
309
+ inputTokens: attempt.inputTokens,
310
+ tps: finite(attempt.tps) ? Math.round(attempt.tps * 10) / 10 : null,
311
+ peakTps: finite(attempt.peakTps) ? Math.round(attempt.peakTps * 10) / 10 : null,
312
+ status: attempt.status || 'done',
313
+ turn: attempt.turn,
314
+ step: attempt.step,
315
+ historical: attempt.historical === true,
316
+ }
317
+ if (includeSamples && Array.isArray(attempt.samples) && attempt.samples.length > 0) {
318
+ out.samples = attempt.samples.map(publicSample)
319
+ }
320
+ return out
321
+ }
322
+
323
+ function publicSessionMeta(session) {
324
+ return {
325
+ id: session.id,
326
+ label: session.label || '',
327
+ model: session.model || '',
328
+ provider: session.provider || '',
329
+ updatedAt: session.updatedAt,
330
+ count: session.attempts.length,
331
+ }
332
+ }
333
+
334
+ function publicLive(live) {
335
+ if (!live) return null
336
+ return {
337
+ id: live.id,
338
+ sessionId: live.sessionId,
339
+ provider: live.provider || '',
340
+ model: live.model || '',
341
+ startedAt: live.startedAt,
342
+ tokens: live.tokens || 0,
343
+ tps: finite(live.tps) ? Math.round(live.tps * 10) / 10 : 0,
344
+ avgTps: finite(live.avgTps) ? Math.round(live.avgTps * 10) / 10 : null,
345
+ peakTps: finite(live.peakTps) ? Math.round(live.peakTps * 10) / 10 : null,
346
+ ttftMs: finite(live.ttftMs) ? Math.round(live.ttftMs) : null,
347
+ elapsedMs: Math.max(0, Date.now() - live.startedAt),
348
+ turn: live.turn,
349
+ samples: Array.isArray(live.samples) ? live.samples.map(publicSample) : [],
350
+ }
351
+ }
352
+
353
+ function publicState() {
354
+ const list = [...sessions.values()]
355
+ .filter((session) => session.attempts.length > 0 || session.live)
356
+ .sort((a, b) => b.updatedAt - a.updatedAt)
357
+ .slice(0, 24)
358
+ .map((session) => {
359
+ const attempts = session.attempts.slice(-MAX_ATTEMPTS)
360
+ const sampleFrom = attempts.length - 120
361
+ const mapped = attempts.map((attempt, index) => publicAttempt(attempt, index >= sampleFrom))
362
+ return {
363
+ ...publicSessionMeta(session),
364
+ attempts: mapped,
365
+ live: publicLive(session.live),
366
+ }
367
+ })
368
+ return {
369
+ type: 'state',
370
+ version: VERSION,
371
+ activeSessionId,
372
+ sessions: list,
373
+ serverTime: Date.now(),
374
+ }
375
+ }
376
+
377
+ function writeSse(res, event, payload) {
378
+ try {
379
+ res.write(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`)
380
+ } catch (error) {
381
+ clients.delete(res)
382
+ }
383
+ }
384
+
385
+ function emit(event, payload) {
386
+ for (const res of [...clients]) {
387
+ try {
388
+ writeSse(res, event, payload)
389
+ } catch (error) {
390
+ clients.delete(res)
391
+ }
392
+ }
393
+ if (clients.size === 0) return
394
+ if (Date.now() - lastBroadcastError < 0) lastBroadcastError = 0
395
+ }
396
+
397
+ function scheduleSave() {
398
+ if (saveTimer) clearTimeout(saveTimer)
399
+ saveTimer = setTimeout(saveHistory, 900)
400
+ if (typeof saveTimer.unref === 'function') saveTimer.unref()
401
+ }
402
+
403
+ function serializableAttempt(attempt) {
404
+ const out = publicAttempt(attempt, true)
405
+ return out
406
+ }
407
+
408
+ function saveHistory() {
409
+ saveTimer = null
410
+ try {
411
+ const payload = {
412
+ version: 1,
413
+ savedAt: Date.now(),
414
+ activeSessionId,
415
+ sessions: [...sessions.values()]
416
+ .filter((session) => session.attempts.length > 0)
417
+ .sort((a, b) => a.updatedAt - b.updatedAt)
418
+ .slice(-MAX_SESSIONS)
419
+ .map((session) => ({
420
+ ...publicSessionMeta(session),
421
+ attempts: session.attempts.slice(-MAX_ATTEMPTS).map(serializableAttempt),
422
+ })),
423
+ }
424
+ fs.mkdirSync(path.dirname(HISTORY_FILE), { recursive: true })
425
+ const temporary = `${HISTORY_FILE}.tmp`
426
+ fs.writeFileSync(temporary, JSON.stringify(payload), 'utf8')
427
+ fs.renameSync(temporary, HISTORY_FILE)
428
+ } catch (error) {
429
+ try { ctx.logger?.warn?.(`[dsh-speedometer] history save failed: ${error.message}`) } catch {}
430
+ }
431
+ }
432
+
433
+ function loadHistory() {
434
+ try {
435
+ const raw = fs.readFileSync(HISTORY_FILE, 'utf8')
436
+ const payload = JSON.parse(raw)
437
+ if (!payload || !Array.isArray(payload.sessions)) return
438
+ for (const item of payload.sessions) {
439
+ if (!item || typeof item.id !== 'string' || !Array.isArray(item.attempts)) continue
440
+ const session = ensureSession(item.id, {
441
+ label: typeof item.label === 'string' ? item.label : '',
442
+ model: typeof item.model === 'string' ? item.model : '',
443
+ provider: typeof item.provider === 'string' ? item.provider : '',
444
+ })
445
+ session.createdAt = finite(item.createdAt) ? item.createdAt : session.createdAt
446
+ session.updatedAt = finite(item.updatedAt) ? item.updatedAt : session.updatedAt
447
+ for (const candidate of item.attempts.slice(-MAX_ATTEMPTS)) {
448
+ if (!candidate || !finite(candidate.startedAt)) continue
449
+ const attempt = {
450
+ id: typeof candidate.id === 'string' ? candidate.id : uniqueId('a'),
451
+ sessionId: item.id,
452
+ provider: typeof candidate.provider === 'string' ? candidate.provider : '',
453
+ model: typeof candidate.model === 'string' ? candidate.model : '',
454
+ startedAt: candidate.startedAt,
455
+ endedAt: finite(candidate.endedAt) ? candidate.endedAt : candidate.startedAt,
456
+ ttftMs: finite(candidate.ttftMs) ? candidate.ttftMs : null,
457
+ durationMs: finite(candidate.durationMs) ? candidate.durationMs : null,
458
+ tokens: count(candidate.tokens) || 0,
459
+ inputTokens: count(candidate.inputTokens),
460
+ tps: finite(candidate.tps) ? candidate.tps : null,
461
+ peakTps: finite(candidate.peakTps) ? candidate.peakTps : null,
462
+ status: typeof candidate.status === 'string' ? candidate.status : 'done',
463
+ turn: finite(candidate.turn) ? candidate.turn : undefined,
464
+ step: finite(candidate.step) ? candidate.step : undefined,
465
+ historical: candidate.historical === true,
466
+ samples: Array.isArray(candidate.samples)
467
+ ? candidate.samples.filter((s) => finite(s?.t) && finite(s?.v)).slice(-MAX_SAMPLES)
468
+ : [],
469
+ }
470
+ addAttempt(attempt, { broadcast: false })
471
+ }
472
+ }
473
+ activeSessionId = safeId(payload.activeSessionId) || activeSessionId
474
+ } catch (error) {
475
+ if (error && error.code !== 'ENOENT') {
476
+ try { ctx.logger?.warn?.(`[dsh-speedometer] history load failed: ${error.message}`) } catch {}
477
+ }
478
+ }
479
+ }
480
+
481
+ function pushSample(live, tps, now) {
482
+ const t = Math.max(0, now - live.startedAt)
483
+ const last = live.samples[live.samples.length - 1]
484
+ if (last && t - last.t < 220) {
485
+ last.v = Math.max(last.v, tps)
486
+ return
487
+ }
488
+ live.samples.push({ t, v: tps })
489
+ if (live.samples.length > MAX_SAMPLES) {
490
+ const compacted = []
491
+ for (let index = 0; index < live.samples.length; index += 2) {
492
+ const a = live.samples[index]
493
+ const b = live.samples[index + 1] || a
494
+ compacted.push({ t: b.t, v: Math.max(a.v, b.v) })
495
+ }
496
+ live.samples = compacted
497
+ }
498
+ }
499
+
500
+ function pruneMarks(live, now) {
501
+ const cutoff = now - WINDOW_MS
502
+ while (live.marks.length > 0 && live.marks[0].t < cutoff) live.marks.shift()
503
+ }
504
+
505
+ function computeWindowTps(live, now) {
506
+ pruneMarks(live, now)
507
+ if (!live.firstTokenAt) return 0
508
+ let tokens = 0
509
+ for (const mark of live.marks) tokens += mark.n
510
+ const windowStart = Math.max(live.firstTokenAt, now - WINDOW_MS)
511
+ const span = Math.max(320, now - windowStart)
512
+ return tokens > 0 ? tokens / (span / 1000) : 0
513
+ }
514
+
515
+ function tickLive(live) {
516
+ if (!live || live.finished) return
517
+ const now = Date.now()
518
+ live.tps = computeWindowTps(live, now)
519
+ if (finite(live.tps)) live.peakTps = Math.max(live.peakTps || 0, live.tps)
520
+ if (live.firstTokenAt && live.lastTokenAt > live.firstTokenAt && live.tokens > 0) {
521
+ const generation = (live.lastTokenAt - live.firstTokenAt) / 1000
522
+ live.avgTps = generation > 0.08 ? live.tokens / generation : null
523
+ }
524
+ live.ttftMs = live.firstTokenAt ? live.firstTokenAt - live.startedAt : null
525
+ pushSample(live, live.tps || 0, now)
526
+ emit('tick', {
527
+ type: 'tick',
528
+ id: live.id,
529
+ sessionId: live.sessionId,
530
+ tokens: live.tokens,
531
+ tps: Math.round((live.tps || 0) * 10) / 10,
532
+ avgTps: finite(live.avgTps) ? Math.round(live.avgTps * 10) / 10 : null,
533
+ peakTps: finite(live.peakTps) ? Math.round(live.peakTps * 10) / 10 : null,
534
+ ttftMs: finite(live.ttftMs) ? Math.round(live.ttftMs) : null,
535
+ elapsedMs: Math.max(0, now - live.startedAt),
536
+ sample: live.samples.length > 0 ? publicSample(live.samples[live.samples.length - 1]) : null,
537
+ })
538
+ }
539
+
540
+ function finishLive(live, status) {
541
+ if (!live || live.finished) return
542
+ live.finished = true
543
+ if (live.timer) {
544
+ clearInterval(live.timer)
545
+ live.timer = null
546
+ }
547
+ const endedAt = Date.now()
548
+ const outputTokens = count(live.usage?.outputTokens)
549
+ const tokens = outputTokens !== undefined && outputTokens > 0 ? outputTokens : live.tokens
550
+ const generationMs = live.firstTokenAt && live.lastTokenAt > live.firstTokenAt
551
+ ? live.lastTokenAt - live.firstTokenAt
552
+ : 0
553
+ const durationMs = Math.max(0, endedAt - live.startedAt)
554
+ const tps = generationMs > 40 && tokens > 0 ? tokens / (generationMs / 1000) : null
555
+ // Keep at least a small visible segment for very short generations.
556
+ const currentTps = live.tps || live.avgTps || tps || 0
557
+ const endT = Math.max(0, endedAt - live.startedAt)
558
+ if (live.samples.length === 0) {
559
+ live.samples.push({ t: 0, v: currentTps })
560
+ live.samples.push({ t: endT, v: currentTps })
561
+ } else {
562
+ const last = live.samples[live.samples.length - 1]
563
+ if (endT - last.t > 40) live.samples.push({ t: endT, v: currentTps })
564
+ else last.v = Math.max(last.v, currentTps)
565
+ if (live.samples.length === 1) live.samples.unshift({ t: 0, v: live.samples[0].v })
566
+ }
567
+ const attempt = {
568
+ id: live.id,
569
+ sessionId: live.sessionId,
570
+ provider: live.provider || '',
571
+ model: live.model || '',
572
+ startedAt: live.startedAt,
573
+ endedAt,
574
+ ttftMs: live.firstTokenAt ? live.firstTokenAt - live.startedAt : null,
575
+ durationMs,
576
+ generationMs: generationMs || durationMs,
577
+ tokens,
578
+ inputTokens: count(live.usage?.inputTokens),
579
+ tps,
580
+ avgTps: tps,
581
+ peakTps: live.peakTps || null,
582
+ status: status || 'done',
583
+ turn: live.turn,
584
+ step: live.step,
585
+ samples: live.samples.slice(-MAX_SAMPLES),
586
+ }
587
+ const session = ensureSession(live.sessionId)
588
+ if (session.live === live) session.live = null
589
+ addAttempt(attempt)
590
+ emit('end', {
591
+ type: 'end',
592
+ id: live.id,
593
+ sessionId: live.sessionId,
594
+ attempt: publicAttempt(attempt, true),
595
+ session: publicSessionMeta(session),
596
+ })
597
+ scheduleSave()
598
+ }
599
+
600
+ function wrapStream(options, next) {
601
+ let iterator
602
+ const sessionId = safeId(options?.sessionId)
603
+ if (!sessionId || options?.purpose) {
604
+ iterator = next()
605
+ return iterator
606
+ }
607
+ return (async function* wrapped() {
608
+ const live = {
609
+ id: uniqueId('s'),
610
+ sessionId,
611
+ provider: typeof options.provider === 'string' ? options.provider : '',
612
+ model: typeof options.model === 'string' ? options.model : '',
613
+ startedAt: Date.now(),
614
+ firstTokenAt: 0,
615
+ lastTokenAt: 0,
616
+ tokens: 0,
617
+ tps: 0,
618
+ avgTps: null,
619
+ peakTps: 0,
620
+ ttftMs: null,
621
+ marks: [],
622
+ samples: [],
623
+ usage: undefined,
624
+ finished: false,
625
+ timer: null,
626
+ }
627
+ const session = ensureSession(sessionId, {
628
+ model: live.model,
629
+ provider: live.provider,
630
+ })
631
+ live.turn = finite(session.currentTurn) ? session.currentTurn : undefined
632
+ if (session.live?.timer) finishLive(session.live, 'replaced')
633
+ session.live = live
634
+ activeSessionId = sessionId
635
+ emit('start', {
636
+ type: 'start',
637
+ sessionId,
638
+ session: publicSessionMeta(session),
639
+ live: publicLive(live),
640
+ activeSessionId,
641
+ })
642
+ live.timer = setInterval(() => tickLive(live), TICK_MS)
643
+ if (typeof live.timer.unref === 'function') live.timer.unref()
644
+ let status = 'done'
645
+ let completed = false
646
+ let source
647
+ try {
648
+ source = next()
649
+ for await (const chunk of source) {
650
+ const now = Date.now()
651
+ if (chunk && isTokenChunk(chunk)) {
652
+ live.tokens += 1
653
+ if (!live.firstTokenAt) live.firstTokenAt = now
654
+ live.lastTokenAt = now
655
+ live.marks.push({ t: now, n: 1 })
656
+ pruneMarks(live, now)
657
+ live.tps = computeWindowTps(live, now)
658
+ if (finite(live.tps)) live.peakTps = Math.max(live.peakTps, live.tps)
659
+ if (live.firstTokenAt && live.lastTokenAt > live.firstTokenAt) {
660
+ const generation = (live.lastTokenAt - live.firstTokenAt) / 1000
661
+ live.avgTps = generation > 0.08 ? live.tokens / generation : null
662
+ }
663
+ } else if (chunk && chunk.type === 'usage') {
664
+ live.usage = chunk.usage
665
+ } else if (chunk && chunk.type === 'finish') {
666
+ completed = true
667
+ status = chunk.reason && typeof chunk.reason.kind === 'string'
668
+ ? chunk.reason.kind
669
+ : 'done'
670
+ }
671
+ yield chunk
672
+ }
673
+ } catch (error) {
674
+ status = 'error'
675
+ throw error
676
+ } finally {
677
+ if (!completed && status === 'done') status = 'aborted'
678
+ finishLive(live, status)
679
+ }
680
+ })()
681
+ }
682
+
683
+ // ---- hydration & live session bookkeeping -----------------------------
684
+ function hydrateSession(session) {
685
+ const sessionId = safeId(session?.id)
686
+ if (!sessionId) return
687
+ let events
688
+ try {
689
+ events = typeof session.snapshotEvents === 'function'
690
+ ? session.snapshotEvents()
691
+ : typeof session.ownEvents === 'function'
692
+ ? session.ownEvents()
693
+ : []
694
+ } catch {
695
+ return
696
+ }
697
+ if (!Array.isArray(events) || events.length === 0) return
698
+ let header = null
699
+ for (const event of events) {
700
+ if (!event || typeof event !== 'object') continue
701
+ const type = event.type
702
+ if (type === 'turn/start') {
703
+ ensureSession(sessionId).currentTurn = finite(event.data?.turn) ? event.data.turn : 0
704
+ continue
705
+ }
706
+ if (type === 'turn/end') {
707
+ ensureSession(sessionId).currentTurn = 0
708
+ continue
709
+ }
710
+ if (type === 'request/header') {
711
+ const config = event.data?.header?.config
712
+ if (config) header = { provider: config.provider, model: config.model }
713
+ continue
714
+ }
715
+ if (type === 'session/title') {
716
+ const title = event.data?.title
717
+ if (typeof title === 'string' && title.trim()) {
718
+ const target = ensureSession(sessionId)
719
+ target.label = shortLabel(title, 42)
720
+ }
721
+ continue
722
+ }
723
+ if (type === 'user/message') {
724
+ const target = ensureSession(sessionId)
725
+ if (!target.label) {
726
+ const text = textOfContent(event.data?.message?.content ?? event.data?.content)
727
+ if (text) target.label = shortLabel(text)
728
+ }
729
+ continue
730
+ }
731
+ if (type !== 'assistant/message' && type !== 'assistant/attempt') continue
732
+ const stream = event.data?.stream
733
+ if (!Array.isArray(stream) || stream.length === 0) continue
734
+ const derived = deriveFromStream(stream, event.time)
735
+ if (!derived || derived.tokens <= 0) continue
736
+ const usage = event.data?.usage
737
+ const outputTokens = usage?.outputTokens
738
+ const tokens = count(outputTokens) > 0 ? outputTokens : derived.tokens
739
+ const exactGeneration = derived.generationMs || 1
740
+ const attempt = {
741
+ id: uniqueId('h'),
742
+ sessionId,
743
+ provider: header?.provider || '',
744
+ model: header?.model || '',
745
+ startedAt: derived.firstTime,
746
+ endedAt: derived.lastTime,
747
+ ttftMs: null,
748
+ durationMs: derived.durationMs,
749
+ generationMs: derived.generationMs,
750
+ tokens,
751
+ inputTokens: count(usage?.inputTokens) ?? derived.inputTokens,
752
+ tps: derived.tps,
753
+ peakTps: null,
754
+ status: type === 'assistant/attempt'
755
+ ? (derived.finishKind || 'failed')
756
+ : (derived.finishKind || 'done'),
757
+ turn: finite(event.data?.turn) ? event.data.turn : undefined,
758
+ step: finite(event.data?.step) ? event.data.step : undefined,
759
+ historical: true,
760
+ samples: derived.samples || [],
761
+ }
762
+ if (attempt.tps === null && tokens > 0 && exactGeneration > 40) {
763
+ attempt.tps = tokens / (exactGeneration / 1000)
764
+ }
765
+ addAttempt(attempt, { broadcast: false })
766
+ }
767
+ trimSessions()
768
+ }
769
+
770
+ function hydrateExistingSessions() {
771
+ try {
772
+ const list = ctx.sessions?.list?.()
773
+ if (!Array.isArray(list)) return
774
+ for (const session of list) {
775
+ try { hydrateSession(session) } catch {}
776
+ }
777
+ if (!activeSessionId) {
778
+ let newest = null
779
+ for (const session of sessions.values()) {
780
+ if (!newest || session.updatedAt > newest.updatedAt) newest = session
781
+ }
782
+ if (newest) activeSessionId = newest.id
783
+ }
784
+ } catch {}
785
+ }
786
+
787
+ // ---- web routes -------------------------------------------------------
788
+ function parseHost(req) {
789
+ const host = String(req.headers?.host || '').toLowerCase()
790
+ const hostname = host.startsWith('[')
791
+ ? host.slice(0, host.indexOf(']') + 1)
792
+ : host.split(':')[0]
793
+ return { host, hostname }
794
+ }
795
+
796
+ function deny(res, status = 403) {
797
+ try {
798
+ res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' })
799
+ res.end('forbidden')
800
+ } catch {}
801
+ return true
802
+ }
803
+
804
+ function rejected(req, res) {
805
+ try {
806
+ const { host, hostname } = parseHost(req)
807
+ if (!host) return deny(res)
808
+ if (!['127.0.0.1', 'localhost', '[::1]', '::1'].includes(hostname)) return deny(res)
809
+ const origin = req.headers?.origin
810
+ if (origin) {
811
+ const parsed = new URL(String(origin))
812
+ if (parsed.host.toLowerCase() !== host) return deny(res)
813
+ }
814
+ if (String(req.headers?.['sec-fetch-site'] || '') === 'cross-site') return deny(res)
815
+ const connection = ctx.get?.('connection')
816
+ if (connection && typeof connection.requestRejection === 'function') {
817
+ const code = connection.requestRejection(req)
818
+ if (code !== undefined && code !== null && code !== false) {
819
+ return deny(res, typeof code === 'number' ? code : 403)
820
+ }
821
+ }
822
+ } catch {
823
+ return deny(res)
824
+ }
825
+ return false
826
+ }
827
+
828
+ function register(route) {
829
+ const inner = route.handler
830
+ const wrapped = {
831
+ ...route,
832
+ handler: async (req, res) => {
833
+ if (rejected(req, res)) return
834
+ try {
835
+ return await inner(req, res)
836
+ } catch (error) {
837
+ try { ctx.logger?.warn?.(`[dsh-speedometer] route ${route.path} failed: ${error.message}`) } catch {}
838
+ if (!res.headersSent) {
839
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' })
840
+ res.end(JSON.stringify({ error: 'internal' }))
841
+ } else {
842
+ try { res.end() } catch {}
843
+ }
844
+ }
845
+ },
846
+ }
847
+ return ctx.webServer.register(wrapped)
848
+ }
849
+
850
+ function json(res, status, payload) {
851
+ const body = JSON.stringify(payload)
852
+ res.writeHead(status, {
853
+ 'Content-Type': 'application/json; charset=utf-8',
854
+ 'Cache-Control': 'no-store',
855
+ 'Content-Length': Buffer.byteLength(body),
856
+ })
857
+ res.end(body)
858
+ }
859
+
860
+ function widgetJs() {
861
+ try {
862
+ return fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), 'widget.js'), 'utf8')
863
+ } catch {
864
+ return '/* dsh-speedometer widget.js not found */'
865
+ }
866
+ }
867
+
868
+ function serveWidget(req, res) {
869
+ const body = widgetJs()
870
+ res.writeHead(200, {
871
+ 'Content-Type': 'application/javascript; charset=utf-8',
872
+ 'Cache-Control': 'no-store',
873
+ })
874
+ res.end(body)
875
+ }
876
+
877
+ function serveState(req, res) {
878
+ if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method' })
879
+ json(res, 200, publicState())
880
+ }
881
+
882
+ function serveEvents(req, res) {
883
+ if (req.method !== 'GET') return json(res, 405, { error: 'method' })
884
+ res.writeHead(200, {
885
+ 'Content-Type': 'text/event-stream; charset=utf-8',
886
+ 'Cache-Control': 'no-cache, no-transform',
887
+ Connection: 'keep-alive',
888
+ 'X-Accel-Buffering': 'no',
889
+ })
890
+ try { res.flushHeaders?.() } catch {}
891
+ res.write('retry: 1800\n\n')
892
+ clients.add(res)
893
+ writeSse(res, 'state', publicState())
894
+ const heartbeat = setInterval(() => {
895
+ try { res.write(': ping\n\n') } catch { clients.delete(res) }
896
+ }, 15000)
897
+ if (typeof heartbeat.unref === 'function') heartbeat.unref()
898
+ const cleanup = () => {
899
+ clearInterval(heartbeat)
900
+ clients.delete(res)
901
+ }
902
+ req.on?.('close', cleanup)
903
+ req.on?.('aborted', cleanup)
904
+ res.on?.('close', cleanup)
905
+ }
906
+
907
+ // ---- wire it up -------------------------------------------------------
908
+ loadHistory()
909
+ hydrateExistingSessions()
910
+
911
+ disposers.push(register({ kind: 'exact', path: WIDGET_ROUTE, handler: serveWidget }))
912
+ disposers.push(register({ kind: 'exact', path: STATE_ROUTE, handler: serveState }))
913
+ disposers.push(register({ kind: 'exact', path: EVENTS_ROUTE, handler: serveEvents }))
914
+ disposers.push(register({
915
+ kind: 'exact',
916
+ path: `${ROUTE}/health`,
917
+ handler: (req, res) => json(res, 200, { ok: true, version: VERSION }),
918
+ }))
919
+
920
+ // Browser form: the index HTML is rendered by the host, so append the
921
+ // script tag here. The desktop shell uses the index-inject row above.
922
+ disposers.push(ctx.webServer.tapIndex((html) => {
923
+ try {
924
+ if (typeof html !== 'string' || html.indexOf(WIDGET_ROUTE) !== -1) return html
925
+ const tag = `<script defer src="${WIDGET_ROUTE}"></script>`
926
+ return html.indexOf('</body>') !== -1
927
+ ? html.replace('</body>', `${tag}</body>`)
928
+ : html + tag
929
+ } catch {
930
+ return html
931
+ }
932
+ }))
933
+
934
+ disposers.push(ctx.on('llm/stream', (options, next) => wrapStream(options, next)))
935
+
936
+ disposers.push(ctx.on('session/event', (session, event) => {
937
+ try {
938
+ const sessionId = safeId(session?.id)
939
+ if (!sessionId) return
940
+ if (event?.type === 'session/title') {
941
+ const title = event.data?.title
942
+ if (typeof title === 'string' && title.trim()) {
943
+ const target = ensureSession(sessionId)
944
+ target.label = shortLabel(title, 42)
945
+ target.updatedAt = Date.now()
946
+ emit('session', { type: 'session', session: publicSessionMeta(target), activeSessionId })
947
+ }
948
+ return
949
+ }
950
+ if (event?.type === 'user/message') {
951
+ const target = ensureSession(sessionId)
952
+ if (!target.label) {
953
+ const text = textOfContent(event.data?.message?.content ?? event.data?.content)
954
+ if (text) target.label = shortLabel(text)
955
+ }
956
+ if (!target.attempts.length && !target.live && sessionId !== activeSessionId) {
957
+ activeSessionId = sessionId
958
+ emit('active', { type: 'active', activeSessionId })
959
+ }
960
+ return
961
+ }
962
+ if (event?.type === 'turn/start') {
963
+ const target = ensureSession(sessionId)
964
+ target.currentTurn = finite(event.data?.turn) ? event.data.turn : 0
965
+ return
966
+ }
967
+ if (event?.type === 'turn/end') {
968
+ const target = ensureSession(sessionId)
969
+ target.currentTurn = 0
970
+ return
971
+ }
972
+ if (event?.type === 'session/disposed') {
973
+ try { sessions.delete(sessionId) } catch {}
974
+ }
975
+ } catch {}
976
+ }))
977
+
978
+ // The profile can add sessions after this plugin starts; hydrate an empty
979
+ // session once so titles and transcript labels are available.
980
+ disposers.push(ctx.on('session/created', (session) => {
981
+ try { hydrateSession(session) } catch {}
982
+ }))
983
+
984
+ disposers.push(ctx.effect(() => () => {
985
+ for (const dispose of disposers) {
986
+ try { dispose() } catch {}
987
+ }
988
+ for (const session of sessions.values()) {
989
+ if (session.live?.timer) clearInterval(session.live.timer)
990
+ }
991
+ for (const res of clients) {
992
+ try { res.end() } catch {}
993
+ }
994
+ clients.clear()
995
+ if (saveTimer) clearTimeout(saveTimer)
996
+ saveHistory()
997
+ }))
998
+
999
+ // Expose a small diagnostic surface for the host log.
1000
+ try {
1001
+ ctx.logger?.info?.(`[dsh-speedometer] v${VERSION} ready (${sessions.size} session(s) hydrated)`)
1002
+ } catch {}
1003
+ })
1004
+ },
1005
+ }