dsh-token-pulse 0.2.2-beta

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.mjs ADDED
@@ -0,0 +1,1084 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ //#region src/date-bucket.ts
5
+ const TZ = "Asia/Shanghai";
6
+ function toDayKey(ms, tz = TZ) {
7
+ return new Intl.DateTimeFormat("en-CA", {
8
+ timeZone: tz,
9
+ year: "numeric",
10
+ month: "2-digit",
11
+ day: "2-digit"
12
+ }).format(new Date(ms));
13
+ }
14
+ function dayKeyToDate(key, _tz = TZ) {
15
+ return /* @__PURE__ */ new Date(key + "T12:00:00+08:00");
16
+ }
17
+ function weekStartOf(dayKey) {
18
+ const d = dayKeyToDate(dayKey);
19
+ const dow = (d.getDay() + 6) % 7;
20
+ d.setDate(d.getDate() - dow);
21
+ return toDayKey(d.getTime());
22
+ }
23
+ function monthStartOf(dayKey) {
24
+ const d = dayKeyToDate(dayKey);
25
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-01`;
26
+ }
27
+ function yearStartOf(dayKey) {
28
+ return `${dayKeyToDate(dayKey).getFullYear()}-01-01`;
29
+ }
30
+ function addDays(dayKey, n) {
31
+ const d = dayKeyToDate(dayKey);
32
+ d.setDate(d.getDate() + n);
33
+ return toDayKey(d.getTime());
34
+ }
35
+ function listDaysInRange(startKey, endKey) {
36
+ const result = [];
37
+ let cur = startKey;
38
+ while (cur <= endKey) {
39
+ result.push(cur);
40
+ cur = addDays(cur, 1);
41
+ }
42
+ return result;
43
+ }
44
+ function weekRangeFor(dateKey) {
45
+ const s = weekStartOf(dateKey);
46
+ return [s, addDays(s, 6)];
47
+ }
48
+ function monthRangeFor(dateKey) {
49
+ const s = monthStartOf(dateKey);
50
+ const d = dayKeyToDate(s);
51
+ const lastDay = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
52
+ return [s, `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`];
53
+ }
54
+ /** Last ~90 days: sliding window for the "Quarter" tab (not calendar quarter). */
55
+ function quarterRangeFor(dateKey) {
56
+ return [addDays(dateKey, -89), dateKey];
57
+ }
58
+ function yearRangeFor(dateKey) {
59
+ const s = yearStartOf(dateKey);
60
+ return [s, `${dayKeyToDate(s).getFullYear()}-12-31`];
61
+ }
62
+ //#endregion
63
+ //#region src/aggregation.ts
64
+ function emptyDay(dayKey) {
65
+ return {
66
+ dayKey,
67
+ totalTokens: 0,
68
+ uncachedInputTokens: 0,
69
+ cacheReadTokens: 0,
70
+ cacheWriteTokens: 0,
71
+ outputTokens: 0,
72
+ count: 0,
73
+ byModel: /* @__PURE__ */ new Map(),
74
+ byProvider: /* @__PURE__ */ new Map(),
75
+ hourlyTokens: new Array(24).fill(0),
76
+ winnerModel: null,
77
+ winnerProvider: null
78
+ };
79
+ }
80
+ function sumByDay(days) {
81
+ return days.reduce((s, d) => s + d.totalTokens, 0);
82
+ }
83
+ function resolveDay(byDay, key) {
84
+ return byDay.get(key) ?? emptyDay(key);
85
+ }
86
+ function resolveRange(byDay, start, end) {
87
+ return listDaysInRange(start, end).map((k) => resolveDay(byDay, k));
88
+ }
89
+ function hourInShanghai(ms) {
90
+ const s = new Date(ms).toLocaleString("en-GB", {
91
+ timeZone: "Asia/Shanghai",
92
+ hour: "2-digit",
93
+ hour12: false
94
+ });
95
+ const h = Number(s);
96
+ return h === 24 ? 0 : h;
97
+ }
98
+ function aggregate(events, nowMs) {
99
+ const byDay = /* @__PURE__ */ new Map();
100
+ const globalByModel = /* @__PURE__ */ new Map();
101
+ const globalByProvider = /* @__PURE__ */ new Map();
102
+ for (const ev of events) {
103
+ const dayKey = toDayKey(ev.time);
104
+ let agg = byDay.get(dayKey);
105
+ if (!agg) {
106
+ agg = emptyDay(dayKey);
107
+ byDay.set(dayKey, agg);
108
+ }
109
+ const u = ev.usage;
110
+ const total = (u.inputTokens ?? 0) + (u.cacheReadTokens ?? 0) + (u.cacheWriteTokens ?? 0) + (u.outputTokens ?? 0);
111
+ agg.totalTokens += total;
112
+ agg.uncachedInputTokens += u.inputTokens ?? 0;
113
+ agg.cacheReadTokens += u.cacheReadTokens ?? 0;
114
+ agg.cacheWriteTokens += u.cacheWriteTokens ?? 0;
115
+ agg.outputTokens += u.outputTokens ?? 0;
116
+ agg.count += 1;
117
+ const h = hourInShanghai(ev.time);
118
+ if (h >= 0 && h < 24) agg.hourlyTokens[h] += total;
119
+ const modelKey = ev.model || "unknown";
120
+ const providerKey = ev.provider || "unknown";
121
+ agg.byModel.set(modelKey, (agg.byModel.get(modelKey) ?? 0) + total);
122
+ agg.byProvider.set(providerKey, (agg.byProvider.get(providerKey) ?? 0) + total);
123
+ globalByModel.set(modelKey, (globalByModel.get(modelKey) ?? 0) + total);
124
+ globalByProvider.set(providerKey, (globalByProvider.get(providerKey) ?? 0) + total);
125
+ }
126
+ for (const agg of byDay.values()) {
127
+ let bestM = null;
128
+ let bestMVal = -1;
129
+ for (const [m, v] of agg.byModel) if (v > bestMVal) {
130
+ bestMVal = v;
131
+ bestM = m;
132
+ }
133
+ agg.winnerModel = bestM;
134
+ let bestP = null;
135
+ let bestPVal = -1;
136
+ for (const [p, v] of agg.byProvider) if (v > bestPVal) {
137
+ bestPVal = v;
138
+ bestP = p;
139
+ }
140
+ agg.winnerProvider = bestP;
141
+ }
142
+ const todayKey = toDayKey(nowMs);
143
+ const [wStart, wEnd] = weekRangeFor(todayKey);
144
+ const [mStart, mEnd] = monthRangeFor(todayKey);
145
+ const today = byDay.get(todayKey)?.totalTokens ?? 0;
146
+ const thisWeek = sumByDay(resolveRange(byDay, wStart, wEnd));
147
+ const thisMonth = sumByDay(resolveRange(byDay, mStart, mEnd));
148
+ let all = 0;
149
+ for (const v of byDay.values()) all += v.totalTokens;
150
+ const topModels = [...globalByModel.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([model, tokens]) => ({
151
+ model,
152
+ tokens
153
+ }));
154
+ const topProviders = [...globalByProvider.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([provider, tokens]) => ({
155
+ provider,
156
+ tokens
157
+ }));
158
+ function top5InWindow(days) {
159
+ const m = /* @__PURE__ */ new Map();
160
+ for (const d of days) for (const [model, tokens] of d.byModel) m.set(model, (m.get(model) ?? 0) + tokens);
161
+ return [...m.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([model, tokens]) => ({
162
+ model,
163
+ tokens
164
+ }));
165
+ }
166
+ function topProvidersInWindow(days) {
167
+ const m = /* @__PURE__ */ new Map();
168
+ for (const d of days) for (const [provider, tokens] of d.byProvider) m.set(provider, (m.get(provider) ?? 0) + tokens);
169
+ return [...m.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([provider, tokens]) => ({
170
+ provider,
171
+ tokens
172
+ }));
173
+ }
174
+ return {
175
+ byDay,
176
+ totals: {
177
+ today,
178
+ thisWeek,
179
+ thisMonth,
180
+ all
181
+ },
182
+ topModels,
183
+ topProviders,
184
+ top5: topModels,
185
+ weekDays: (k) => {
186
+ const [s, e] = weekRangeFor(k);
187
+ return resolveRange(byDay, s, e);
188
+ },
189
+ monthDays: (k) => {
190
+ const [s, e] = monthRangeFor(k);
191
+ return resolveRange(byDay, s, e);
192
+ },
193
+ quarterDays: (k) => {
194
+ const [s, e] = quarterRangeFor(k);
195
+ return resolveRange(byDay, s, e);
196
+ },
197
+ yearDays: (k) => {
198
+ const [s, e] = yearRangeFor(k);
199
+ return resolveRange(byDay, s, e);
200
+ },
201
+ top5InWindow,
202
+ topProvidersInWindow
203
+ };
204
+ }
205
+ //#endregion
206
+ //#region src/host/session-reader.ts
207
+ /** zstd frame magic: bytes 28 B5 2F FD, read as UInt32LE. */
208
+ const ZSTD_MAGIC = 4247762216;
209
+ /**
210
+ * Locate complete zstd frames inside a concatenated-frame container (the
211
+ * format DSH's session.jsonl.zstd uses: every appended batch is its own
212
+ * compressed frame). Scans the frame structure WITHOUT decompressing, mirroring
213
+ * @deepseek-ai/dsh-session-persistence-jsonl's scanZstdFrames. Returns
214
+ * [start,end) byte ranges, one per complete frame.
215
+ */
216
+ function scanZstdFrames(buffer) {
217
+ const frames = [];
218
+ let offset = 0;
219
+ while (offset < buffer.length) {
220
+ const start = offset;
221
+ if (buffer.length - offset < 4) break;
222
+ if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) break;
223
+ offset += 4;
224
+ if (offset === buffer.length) break;
225
+ const descriptor = buffer.readUInt8(offset);
226
+ offset += 1;
227
+ if ((descriptor & 24) !== 0) break;
228
+ const contentSizeFlag = descriptor >>> 6;
229
+ const singleSegment = (descriptor & 32) !== 0;
230
+ const checksum = (descriptor & 4) !== 0;
231
+ const dictionaryFlag = descriptor & 3;
232
+ const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag;
233
+ const contentSizeBytes = contentSizeFlag === 0 ? singleSegment ? 1 : 0 : 1 << contentSizeFlag;
234
+ const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes;
235
+ if (buffer.length - offset < remainingHeaderBytes) break;
236
+ offset += remainingHeaderBytes;
237
+ for (;;) {
238
+ if (buffer.length - offset < 3) break;
239
+ const blockHeader = buffer.readUIntLE(offset, 3);
240
+ offset += 3;
241
+ const lastBlock = (blockHeader & 1) !== 0;
242
+ const blockType = blockHeader >>> 1 & 3;
243
+ const blockSize = blockHeader >>> 3;
244
+ if (blockType === 3) break;
245
+ const payloadBytes = blockType === 1 ? 1 : blockSize;
246
+ if (buffer.length - offset < payloadBytes) break;
247
+ offset += payloadBytes;
248
+ if (lastBlock) break;
249
+ }
250
+ if (checksum) {
251
+ if (buffer.length - offset < 4) break;
252
+ offset += 4;
253
+ }
254
+ frames.push({
255
+ start,
256
+ end: offset
257
+ });
258
+ }
259
+ return frames;
260
+ }
261
+ /**
262
+ * Decode a concatenated-frame zstd session log to its full UTF-8 text.
263
+ * Node's one-shot `zstdDecompressSync` decodes only the FIRST frame, so we
264
+ * locate every frame and decode each one separately (per
265
+ * dsh-session-persistence-jsonl's zstd-public-decoder). Returns null on any
266
+ * decode failure (callers treat a null text as "no data from this file").
267
+ */
268
+ async function decodeSessionZstd(buffer) {
269
+ const zlib = await import("node:zlib");
270
+ if (typeof zlib.zstdDecompressSync !== "function") return null;
271
+ const frames = scanZstdFrames(buffer);
272
+ if (frames.length === 0) return null;
273
+ const parts = [];
274
+ for (const { start, end } of frames) try {
275
+ parts.push(zlib.zstdDecompressSync(buffer.subarray(start, end)).toString("utf-8"));
276
+ } catch {
277
+ return null;
278
+ }
279
+ return parts.join("");
280
+ }
281
+ /**
282
+ * Parse a batch of raw session events into RawUsageEvents.
283
+ * Rules:
284
+ * - request/header stores provider/model for fallback (per session)
285
+ * - assistant/chunk type=usage is provisional, keyed by sid/turn/step
286
+ * - assistant/message with usage is authoritative, overwrites provisional for same sid/turn/step
287
+ *
288
+ * The dedupe key MUST include the session id: turn/step counters restart per
289
+ * session (every session has a turn 1 step 1), so without `sid` different
290
+ * sessions overwrite each other and older sessions' usage silently disappears.
291
+ */
292
+ function parseUsageEvents(events) {
293
+ const fallbackBySid = /* @__PURE__ */ new Map();
294
+ const fallbackFor = (sid) => fallbackBySid.get(sid ?? "") ?? {
295
+ provider: "",
296
+ model: ""
297
+ };
298
+ const byTurnStep = /* @__PURE__ */ new Map();
299
+ for (const ev of events) {
300
+ const sid = ev.sid ?? "";
301
+ if (ev.type === "request/header" && ev.data?.header?.config) {
302
+ const cfg = ev.data.header.config;
303
+ const fb = fallbackBySid.get(sid) ?? {
304
+ provider: "",
305
+ model: ""
306
+ };
307
+ if (cfg.provider) fb.provider = cfg.provider;
308
+ if (cfg.model) fb.model = cfg.model;
309
+ fallbackBySid.set(sid, fb);
310
+ }
311
+ if (ev.type === "request/context" && ev.data) {
312
+ const fb = fallbackBySid.get(sid) ?? {
313
+ provider: "",
314
+ model: ""
315
+ };
316
+ if (ev.data.provider) fb.provider = ev.data.provider;
317
+ if (ev.data.model) fb.model = ev.data.model;
318
+ fallbackBySid.set(sid, fb);
319
+ }
320
+ if (ev.type === "assistant/chunk" && ev.data?.chunk?.type === "usage") {
321
+ const key = `${sid}::${ev.data.turn}:${ev.data.step}`;
322
+ if (!byTurnStep.has(key)) {
323
+ const fb = fallbackFor(sid);
324
+ const u = ev.data.chunk.usage;
325
+ byTurnStep.set(key, {
326
+ time: ev.time ?? Date.now(),
327
+ provider: fb.provider,
328
+ model: fb.model,
329
+ sid,
330
+ usage: {
331
+ inputTokens: u.inputTokens ?? 0,
332
+ cacheReadTokens: u.cacheReadTokens ?? 0,
333
+ cacheWriteTokens: u.cacheWriteTokens ?? 0,
334
+ outputTokens: u.outputTokens ?? 0
335
+ }
336
+ });
337
+ }
338
+ }
339
+ if (ev.type === "assistant/message" && ev.data?.usage) {
340
+ const src = ev.data.message?.source;
341
+ const fb = fallbackFor(sid);
342
+ const provider = src?.provider ?? fb.provider;
343
+ const model = src?.model ?? fb.model;
344
+ const turn = ev.data.turn;
345
+ const step = ev.data.step;
346
+ const key = turn !== void 0 && step !== void 0 ? `${sid}::${turn}:${step}` : `${sid}::msg:${ev.seq ?? Math.random()}`;
347
+ const u = ev.data.usage;
348
+ byTurnStep.set(key, {
349
+ time: ev.time ?? Date.now(),
350
+ provider,
351
+ model,
352
+ sid,
353
+ usage: {
354
+ inputTokens: u.inputTokens ?? 0,
355
+ cacheReadTokens: u.cacheReadTokens ?? 0,
356
+ cacheWriteTokens: u.cacheWriteTokens ?? 0,
357
+ outputTokens: u.outputTokens ?? 0
358
+ }
359
+ });
360
+ const cur = fallbackBySid.get(sid) ?? {
361
+ provider: "",
362
+ model: ""
363
+ };
364
+ if (src?.provider) cur.provider = src.provider;
365
+ if (src?.model) cur.model = src.model;
366
+ fallbackBySid.set(sid, cur);
367
+ }
368
+ }
369
+ return [...byTurnStep.values()];
370
+ }
371
+ /**
372
+ * Read all sessions' usage events: FULL disk history (every session, old + new)
373
+ * merged with live in-memory events that are not yet flushed to disk.
374
+ *
375
+ * NOTE: ctx.sessions.list() returns only LIVE sessions (created/resumed in the
376
+ * current process) — historical sessions from previous processes are NOT in it.
377
+ * The old implementation returned early whenever any live session had events,
378
+ * so it never scanned the disk and old sessions never showed up. Here we ALWAYS
379
+ * read the whole ~/.dsh/sessions tree and merge live events on top, deduping by
380
+ * sessionId:seq (events carry no sessionId; the disk path derives it from the
381
+ * session directory name).
382
+ */
383
+ async function readAllUsageEvents(ctx) {
384
+ const allEvents = [];
385
+ const seen = /* @__PURE__ */ new Set();
386
+ const relevant = (o) => o.type === "request/header" || o.type === "request/context" || o.type === "assistant/chunk" || o.type === "assistant/message";
387
+ const pushUnique = (sid, o) => {
388
+ if (typeof o?.seq === "number") {
389
+ const key = `${sid}:${o.seq}`;
390
+ if (seen.has(key)) return;
391
+ seen.add(key);
392
+ }
393
+ allEvents.push({
394
+ ...o,
395
+ sid
396
+ });
397
+ };
398
+ try {
399
+ const { readdirSync, existsSync } = await import("node:fs");
400
+ const { join, basename, dirname } = await import("node:path");
401
+ const sessionsDir = join(process.env.HOME ?? "/home/dell", ".dsh", "sessions");
402
+ if (existsSync(sessionsDir)) {
403
+ const files = [];
404
+ (function walk(dir) {
405
+ try {
406
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
407
+ const p = join(dir, entry.name);
408
+ if (entry.isDirectory()) walk(p);
409
+ else if (entry.name === "session.jsonl.zstd" || entry.name === "session.jsonl") files.push(p);
410
+ }
411
+ } catch {}
412
+ })(sessionsDir);
413
+ for (const f of files) {
414
+ const sid = basename(dirname(f));
415
+ try {
416
+ let text;
417
+ if (f.endsWith(".zstd")) {
418
+ const { readFileSync } = await import("node:fs");
419
+ const textFromFrames = await decodeSessionZstd(readFileSync(f));
420
+ if (textFromFrames !== null) text = textFromFrames;
421
+ else {
422
+ const { execSync } = await import("node:child_process");
423
+ text = execSync(`zstd -dc ${JSON.stringify(f)}`, {
424
+ encoding: "utf-8",
425
+ maxBuffer: 52428800
426
+ });
427
+ }
428
+ } else {
429
+ const { readFileSync } = await import("node:fs");
430
+ text = readFileSync(f, "utf-8");
431
+ }
432
+ for (const line of text.split("\n")) {
433
+ if (!line.trim()) continue;
434
+ try {
435
+ const obj = JSON.parse(line);
436
+ if (relevant(obj)) pushUnique(sid, obj);
437
+ } catch {}
438
+ }
439
+ } catch {}
440
+ }
441
+ }
442
+ } catch {}
443
+ try {
444
+ if (ctx.sessions?.list) {
445
+ const sessions = await ctx.sessions.list();
446
+ for (const s of sessions ?? []) {
447
+ const sid = s?.id ?? "live";
448
+ for (const event of s?.events ?? []) if (relevant(event)) pushUnique(sid, event);
449
+ }
450
+ }
451
+ } catch {}
452
+ return parseUsageEvents(allEvents);
453
+ }
454
+ /**
455
+ * Read usage from LIVE sessions only (ctx.sessions.list() = sessions created
456
+ * or resumed in the current process). Cheap — no disk scan. Used by
457
+ * store.refresh() after init() has already captured the full disk history,
458
+ * so refreshes do not re-decompress ~/.dsh/sessions on every tick.
459
+ *
460
+ * A live session's `events` includes its full stored log, so the result is a
461
+ * complete snapshot per live session; callers must REPLACE (not append) that
462
+ * session's events to avoid double counting.
463
+ */
464
+ async function readLiveUsageEvents(ctx) {
465
+ const events = [];
466
+ const relevant = (o) => o.type === "request/header" || o.type === "request/context" || o.type === "assistant/chunk" || o.type === "assistant/message";
467
+ try {
468
+ if (ctx.sessions?.list) {
469
+ const sessions = await ctx.sessions.list();
470
+ for (const s of sessions ?? []) {
471
+ const sid = s?.id ?? "live";
472
+ for (const event of s?.events ?? []) if (relevant(event)) events.push({
473
+ ...event,
474
+ sid
475
+ });
476
+ }
477
+ }
478
+ } catch {}
479
+ return parseUsageEvents(events);
480
+ }
481
+ //#endregion
482
+ //#region src/host/persist.ts
483
+ function persistPath() {
484
+ const dir = join(homedir(), ".dsh", "storages", "dsh-token-pulse");
485
+ return join(dir, "daily.json");
486
+ }
487
+ /**
488
+ * One-time migration from the pre-rename identity `dsh-token-heatmap`.
489
+ * The plugin was renamed to `dsh-token-pulse` (to avoid a bare-name collision
490
+ * with an unrelated plugin in the awesome-dsh-plugin catalog). Existing users
491
+ * have their accumulated history under storages/dsh-token-heatmap/ and their
492
+ * settings under localStorage keys dsh-token-heatmap:*; without this they would
493
+ * see an empty heatmap after the update. Copy the persisted file forward the
494
+ * first time the new location is missing but the legacy one exists.
495
+ */
496
+ let migrated = false;
497
+ function migrateLegacyPersist() {
498
+ if (migrated) return;
499
+ migrated = true;
500
+ try {
501
+ const next = persistPath();
502
+ if (existsSync(next)) return;
503
+ const legacy = join(homedir(), ".dsh", "storages", "dsh-token-heatmap", "daily.json");
504
+ if (!existsSync(legacy)) return;
505
+ mkdirSync(dirname(next), { recursive: true });
506
+ copyFileSync(legacy, next);
507
+ } catch {}
508
+ }
509
+ function libDailyPath() {
510
+ try {
511
+ const candidates = [join(homedir(), ".dsh", "profiles", "web", "node_modules", "dsh-token-pulse", "lib", "daily.json"), join("/home/dell/testdsh/dsh-token-pulse/lib/daily.json")];
512
+ for (const c of candidates) try {
513
+ mkdirSync(dirname(c), { recursive: true });
514
+ return c;
515
+ } catch {}
516
+ return candidates[0];
517
+ } catch {
518
+ return null;
519
+ }
520
+ }
521
+ function toPersistedDay(day) {
522
+ return {
523
+ dayKey: day.dayKey,
524
+ totalTokens: day.totalTokens,
525
+ uncachedInputTokens: day.uncachedInputTokens,
526
+ cacheReadTokens: day.cacheReadTokens,
527
+ cacheWriteTokens: day.cacheWriteTokens,
528
+ outputTokens: day.outputTokens,
529
+ count: day.count,
530
+ byModel: Object.fromEntries(day.byModel),
531
+ byProvider: Object.fromEntries(day.byProvider),
532
+ hourlyTokens: [...day.hourlyTokens ?? new Array(24).fill(0)],
533
+ winnerModel: day.winnerModel,
534
+ winnerProvider: day.winnerProvider
535
+ };
536
+ }
537
+ function fromPersistedDay(p) {
538
+ const byModel = new Map(Object.entries(p.byModel ?? {}));
539
+ const byProvider = new Map(Object.entries(p.byProvider ?? {}));
540
+ const hourlyTokens = Array.isArray(p.hourlyTokens) && p.hourlyTokens.length === 24 ? [...p.hourlyTokens] : new Array(24).fill(0);
541
+ return {
542
+ dayKey: p.dayKey,
543
+ totalTokens: p.totalTokens,
544
+ uncachedInputTokens: p.uncachedInputTokens ?? 0,
545
+ cacheReadTokens: p.cacheReadTokens ?? 0,
546
+ cacheWriteTokens: p.cacheWriteTokens ?? 0,
547
+ outputTokens: p.outputTokens ?? 0,
548
+ count: p.count ?? 0,
549
+ byModel,
550
+ byProvider,
551
+ hourlyTokens,
552
+ winnerModel: p.winnerModel ?? null,
553
+ winnerProvider: p.winnerProvider ?? null
554
+ };
555
+ }
556
+ function loadPersisted() {
557
+ try {
558
+ migrateLegacyPersist();
559
+ const p = persistPath();
560
+ if (!existsSync(p)) return /* @__PURE__ */ new Map();
561
+ const raw = readFileSync(p, "utf-8");
562
+ const parsed = JSON.parse(raw);
563
+ if (!parsed || typeof parsed.days !== "object") return /* @__PURE__ */ new Map();
564
+ const m = /* @__PURE__ */ new Map();
565
+ for (const [k, v] of Object.entries(parsed.days)) try {
566
+ m.set(k, fromPersistedDay(v));
567
+ } catch {}
568
+ return m;
569
+ } catch {
570
+ return /* @__PURE__ */ new Map();
571
+ }
572
+ }
573
+ function savePersisted(days) {
574
+ try {
575
+ const out = {
576
+ version: 1,
577
+ days: Object.fromEntries([...days.entries()].map(([k, v]) => [k, toPersistedDay(v)]))
578
+ };
579
+ const json = JSON.stringify(out, null, 2);
580
+ const p = persistPath();
581
+ mkdirSync(dirname(p), { recursive: true });
582
+ const tmp = p + ".tmp";
583
+ writeFileSync(tmp, json, "utf-8");
584
+ renameSync(tmp, p);
585
+ const libPath = libDailyPath();
586
+ if (libPath) {
587
+ try {
588
+ writeFileSync(libPath, json, "utf-8");
589
+ } catch {}
590
+ try {
591
+ const wsLib = join("/home/dell/testdsh/dsh-token-pulse/lib/daily.json");
592
+ if (wsLib !== libPath) writeFileSync(wsLib, json, "utf-8");
593
+ } catch {}
594
+ }
595
+ } catch {}
596
+ }
597
+ /**
598
+ * Merge strategy: persisted is cumulative historical truth that never shrinks.
599
+ * For each dayKey, if live has data, take element-wise MAX for additive fields,
600
+ * and merge byModel/byProvider by taking max per key (or summing? No - live total is sum of remaining sessions, persisted is sum of all-time events. Taking max per day total prevents loss on deletion while allowing growth on new events. Per-model we also take max per model per day? But that would undercount if a new model appears. Better: merge by taking max of total, and for byModel: for each model, max(persisted, live). If a new model appears only in live, it propagates.
601
+ * For count: max as well.
602
+ */
603
+ function mergePersistedAndLive(persisted, live) {
604
+ const result = /* @__PURE__ */ new Map();
605
+ for (const [k, v] of persisted) result.set(k, cloneDay$1(v));
606
+ for (const [k, liveDay] of live) {
607
+ const prev = result.get(k);
608
+ if (!prev) {
609
+ result.set(k, cloneDay$1(liveDay));
610
+ continue;
611
+ }
612
+ const mergedHourly = (prev.hourlyTokens ?? new Array(24).fill(0)).map((v, i) => Math.max(v, liveDay.hourlyTokens?.[i] ?? 0));
613
+ const merged = {
614
+ dayKey: k,
615
+ totalTokens: Math.max(prev.totalTokens, liveDay.totalTokens),
616
+ uncachedInputTokens: Math.max(prev.uncachedInputTokens, liveDay.uncachedInputTokens),
617
+ cacheReadTokens: Math.max(prev.cacheReadTokens, liveDay.cacheReadTokens),
618
+ cacheWriteTokens: Math.max(prev.cacheWriteTokens, liveDay.cacheWriteTokens),
619
+ outputTokens: Math.max(prev.outputTokens, liveDay.outputTokens),
620
+ count: Math.max(prev.count, liveDay.count),
621
+ byModel: mergeMapMax(prev.byModel, liveDay.byModel),
622
+ byProvider: mergeMapMax(prev.byProvider, liveDay.byProvider),
623
+ hourlyTokens: mergedHourly,
624
+ winnerModel: null,
625
+ winnerProvider: null
626
+ };
627
+ let bestM = null;
628
+ let bestMVal = -1;
629
+ for (const [m, v2] of merged.byModel) if (v2 > bestMVal) {
630
+ bestMVal = v2;
631
+ bestM = m;
632
+ }
633
+ merged.winnerModel = bestM;
634
+ let bestP = null;
635
+ let bestPVal = -1;
636
+ for (const [p, v2] of merged.byProvider) if (v2 > bestPVal) {
637
+ bestPVal = v2;
638
+ bestP = p;
639
+ }
640
+ merged.winnerProvider = bestP;
641
+ result.set(k, merged);
642
+ }
643
+ return result;
644
+ }
645
+ function cloneDay$1(d) {
646
+ return {
647
+ dayKey: d.dayKey,
648
+ totalTokens: d.totalTokens,
649
+ uncachedInputTokens: d.uncachedInputTokens,
650
+ cacheReadTokens: d.cacheReadTokens,
651
+ cacheWriteTokens: d.cacheWriteTokens,
652
+ outputTokens: d.outputTokens,
653
+ count: d.count,
654
+ byModel: new Map(d.byModel),
655
+ byProvider: new Map(d.byProvider),
656
+ hourlyTokens: [...d.hourlyTokens ?? new Array(24).fill(0)],
657
+ winnerModel: d.winnerModel,
658
+ winnerProvider: d.winnerProvider
659
+ };
660
+ }
661
+ function mergeMapMax(a, b) {
662
+ const r = new Map(a);
663
+ for (const [k, v] of b) r.set(k, Math.max(r.get(k) ?? 0, v));
664
+ return r;
665
+ }
666
+ //#endregion
667
+ //#region src/host/store.ts
668
+ var HeatmapStore = class HeatmapStore {
669
+ events = [];
670
+ aggregated;
671
+ listeners = /* @__PURE__ */ new Set();
672
+ byTurnStep = /* @__PURE__ */ new Map();
673
+ fallbackBySid = /* @__PURE__ */ new Map();
674
+ ctx;
675
+ persistedDays = /* @__PURE__ */ new Map();
676
+ /** True after init() has finished scanning disk; false until then so
677
+ * getAggregated() does not serve an incomplete (persisted-only) snapshot
678
+ * that makes wide windows like Quarter look empty until a hard refresh. */
679
+ initialized = false;
680
+ constructor(ctx) {
681
+ this.ctx = ctx;
682
+ this.aggregated = aggregate([], Date.now());
683
+ }
684
+ async init() {
685
+ this.persistedDays = loadPersisted();
686
+ const evs = await readAllUsageEvents(this.ctx);
687
+ this.events = evs;
688
+ this.rebuildPersistedAndAggregated();
689
+ this.initialized = true;
690
+ }
691
+ /**
692
+ * Refresh from LIVE sessions only. The full disk scan happened once at
693
+ * init(); afterwards only live (in-memory) sessions change, so re-scanning
694
+ * the whole ~/.dsh/sessions tree on every refresh would be wasted work.
695
+ *
696
+ * A live session's snapshot includes its full stored log, so we REPLACE that
697
+ * session's events in place (never append) — history for sessions that are
698
+ * no longer live is kept as-is from init(). This keeps each event present at
699
+ * most once in this.events, so the max-merge into persistedDays never
700
+ * under- or over-counts.
701
+ */
702
+ async refresh() {
703
+ try {
704
+ const live = await readLiveUsageEvents(this.ctx);
705
+ const liveSids = new Set(live.map((e) => e.sid).filter(Boolean));
706
+ this.events = [...this.events.filter((e) => !liveSids.has(e.sid)), ...live];
707
+ this.persistedDays = loadPersisted();
708
+ this.rebuildPersistedAndAggregated();
709
+ } catch (e) {
710
+ this.ctx?.logger?.warn?.(`dsh-token-pulse refresh failed: ${String(e)}`);
711
+ }
712
+ }
713
+ rebuildPersistedAndAggregated() {
714
+ this.invalidateCache();
715
+ const live = aggregate(this.events, Date.now());
716
+ const mergedDays = mergePersistedAndLive(this.persistedDays, live.byDay);
717
+ if (mergedDays.size !== this.persistedDays.size || hasAnyIncrease(this.persistedDays, mergedDays)) {
718
+ this.persistedDays = mergedDays;
719
+ savePersisted(this.persistedDays);
720
+ } else {
721
+ this.persistedDays = mergedDays;
722
+ if (!mapsEqual(loadPersisted(), mergedDays)) savePersisted(this.persistedDays);
723
+ }
724
+ this.aggregated = aggregateFromPersisted(this.persistedDays, Date.now());
725
+ this.emit();
726
+ }
727
+ rebuildAggregated() {
728
+ this.invalidateCache();
729
+ this.aggregated = aggregateFromPersisted(this.persistedDays, Date.now());
730
+ this.emit();
731
+ }
732
+ cachedAgg = null;
733
+ cachedAt = 0;
734
+ static CACHE_TTL_MS = 3e4;
735
+ static MAX_FALLBACK_SIDS = 2e3;
736
+ getAggregated() {
737
+ if (!this.initialized) return aggregate([], Date.now());
738
+ const now = Date.now();
739
+ if (this.cachedAgg && now - this.cachedAt < HeatmapStore.CACHE_TTL_MS) return this.cachedAgg;
740
+ const live = aggregate(this.events, now);
741
+ const merged = mergePersistedAndLive(this.persistedDays, live.byDay);
742
+ this.cachedAgg = aggregateFromPersisted(merged, now);
743
+ this.cachedAt = now;
744
+ return this.cachedAgg;
745
+ }
746
+ /** True once the full disk scan (init) has completed. Exposed to API consumers
747
+ * so partial data can be distinguished from "still loading". */
748
+ isReady() {
749
+ return this.initialized;
750
+ }
751
+ invalidateCache() {
752
+ this.cachedAgg = null;
753
+ this.cachedAt = 0;
754
+ }
755
+ /** Ingest a single session event incrementally. Keys are scoped per session
756
+ * via `event.sid` (set by the plugin's session/event handler) so sessions
757
+ * with the same turn/step do not overwrite each other. */
758
+ ingest(event) {
759
+ const sid = event?.sid ?? "";
760
+ const fb = () => this.fallbackBySid.get(sid) ?? {
761
+ provider: "",
762
+ model: ""
763
+ };
764
+ const setFb = (provider, model) => {
765
+ const cur = this.fallbackBySid.get(sid) ?? {
766
+ provider: "",
767
+ model: ""
768
+ };
769
+ if (provider) cur.provider = provider;
770
+ if (model) cur.model = model;
771
+ this.fallbackBySid.set(sid, cur);
772
+ if (this.fallbackBySid.size > HeatmapStore.MAX_FALLBACK_SIDS) for (const k of this.fallbackBySid.keys()) {
773
+ if (k === "") continue;
774
+ this.fallbackBySid.delete(k);
775
+ if (this.fallbackBySid.size <= HeatmapStore.MAX_FALLBACK_SIDS - 100) break;
776
+ }
777
+ };
778
+ if (event.type === "request/header" && event.data?.header?.config) {
779
+ const cfg = event.data.header.config;
780
+ setFb(cfg.provider, cfg.model);
781
+ return;
782
+ }
783
+ if (event.type === "request/context" && event.data) {
784
+ setFb(event.data.provider, event.data.model);
785
+ return;
786
+ }
787
+ if (event.type === "assistant/chunk" && event.data?.chunk?.type === "usage") {
788
+ const key = `${sid}::${event.data.turn}:${event.data.step}`;
789
+ if (!this.byTurnStep.has(key)) {
790
+ const f = fb();
791
+ const u = event.data.chunk.usage;
792
+ const ev = {
793
+ time: event.time ?? Date.now(),
794
+ provider: f.provider,
795
+ model: f.model,
796
+ sid,
797
+ usage: {
798
+ inputTokens: u.inputTokens ?? 0,
799
+ cacheReadTokens: u.cacheReadTokens ?? 0,
800
+ cacheWriteTokens: u.cacheWriteTokens ?? 0,
801
+ outputTokens: u.outputTokens ?? 0
802
+ }
803
+ };
804
+ this.byTurnStep.set(key, ev);
805
+ this.events.push(ev);
806
+ this.persistIncremental(ev);
807
+ this.rebuildAggregated();
808
+ }
809
+ return;
810
+ }
811
+ if (event.type === "assistant/message" && event.data?.usage) {
812
+ const src = event.data.message?.source;
813
+ const f = fb();
814
+ const provider = src?.provider ?? f.provider;
815
+ const model = src?.model ?? f.model;
816
+ const turn = event.data.turn;
817
+ const step = event.data.step;
818
+ const key = turn !== void 0 && step !== void 0 ? `${sid}::${turn}:${step}` : `${sid}::msg:${event.seq ?? Math.random()}`;
819
+ const existing = this.byTurnStep.get(key);
820
+ const u = event.data.usage;
821
+ const ev = {
822
+ time: event.time ?? Date.now(),
823
+ provider,
824
+ model,
825
+ sid,
826
+ usage: {
827
+ inputTokens: u.inputTokens ?? 0,
828
+ cacheReadTokens: u.cacheReadTokens ?? 0,
829
+ cacheWriteTokens: u.cacheWriteTokens ?? 0,
830
+ outputTokens: u.outputTokens ?? 0
831
+ }
832
+ };
833
+ if (existing) {
834
+ const idx = this.events.indexOf(existing);
835
+ if (idx !== -1) this.events.splice(idx, 1);
836
+ }
837
+ this.byTurnStep.set(key, ev);
838
+ this.events.push(ev);
839
+ setFb(src?.provider, src?.model);
840
+ this.persistIncremental(ev);
841
+ this.rebuildAggregated();
842
+ }
843
+ }
844
+ persistIncremental(ev) {
845
+ const live = aggregate(this.events, Date.now());
846
+ const dayKey = toDayKey(ev.time);
847
+ const liveDay = live.byDay.get(dayKey);
848
+ if (!liveDay) return;
849
+ const prev = this.persistedDays.get(dayKey);
850
+ const merged = prev ? mergeTwoDays(prev, liveDay) : cloneDay(liveDay);
851
+ this.persistedDays.set(dayKey, merged);
852
+ savePersisted(this.persistedDays);
853
+ }
854
+ onChange(cb) {
855
+ this.listeners.add(cb);
856
+ return () => this.listeners.delete(cb);
857
+ }
858
+ emit() {
859
+ for (const cb of this.listeners) cb();
860
+ }
861
+ dispose() {
862
+ this.listeners.clear();
863
+ }
864
+ /** For testing: inject events directly */
865
+ _injectEvents(evs) {
866
+ this.events.push(...evs);
867
+ this.rebuildAggregated();
868
+ }
869
+ getPersistedDays() {
870
+ return new Map(this.persistedDays);
871
+ }
872
+ clearPersisted() {
873
+ this.persistedDays.clear();
874
+ savePersisted(this.persistedDays);
875
+ this.rebuildAggregated();
876
+ }
877
+ };
878
+ function cloneDay(d) {
879
+ return {
880
+ dayKey: d.dayKey,
881
+ totalTokens: d.totalTokens,
882
+ uncachedInputTokens: d.uncachedInputTokens,
883
+ cacheReadTokens: d.cacheReadTokens,
884
+ cacheWriteTokens: d.cacheWriteTokens,
885
+ outputTokens: d.outputTokens,
886
+ count: d.count,
887
+ byModel: new Map(d.byModel),
888
+ byProvider: new Map(d.byProvider),
889
+ winnerModel: d.winnerModel,
890
+ winnerProvider: d.winnerProvider
891
+ };
892
+ }
893
+ function mergeTwoDays(prev, live) {
894
+ const byModel = new Map(prev.byModel);
895
+ for (const [k, v] of live.byModel) byModel.set(k, Math.max(byModel.get(k) ?? 0, v));
896
+ const byProvider = new Map(prev.byProvider);
897
+ for (const [k, v] of live.byProvider) byProvider.set(k, Math.max(byProvider.get(k) ?? 0, v));
898
+ const merged = {
899
+ dayKey: prev.dayKey,
900
+ totalTokens: Math.max(prev.totalTokens, live.totalTokens),
901
+ uncachedInputTokens: Math.max(prev.uncachedInputTokens, live.uncachedInputTokens),
902
+ cacheReadTokens: Math.max(prev.cacheReadTokens, live.cacheReadTokens),
903
+ cacheWriteTokens: Math.max(prev.cacheWriteTokens, live.cacheWriteTokens),
904
+ outputTokens: Math.max(prev.outputTokens, live.outputTokens),
905
+ count: Math.max(prev.count, live.count),
906
+ byModel,
907
+ byProvider,
908
+ winnerModel: null,
909
+ winnerProvider: null
910
+ };
911
+ let bestM = null;
912
+ let bestMVal = -1;
913
+ for (const [m, v2] of byModel) if (v2 > bestMVal) {
914
+ bestMVal = v2;
915
+ bestM = m;
916
+ }
917
+ merged.winnerModel = bestM;
918
+ let bestP = null;
919
+ let bestPVal = -1;
920
+ for (const [p, v2] of byProvider) if (v2 > bestPVal) {
921
+ bestPVal = v2;
922
+ bestP = p;
923
+ }
924
+ merged.winnerProvider = bestP;
925
+ return merged;
926
+ }
927
+ function hasAnyIncrease(a, b) {
928
+ for (const [k, vb] of b) {
929
+ const va = a.get(k);
930
+ if (!va) return true;
931
+ if (vb.totalTokens > va.totalTokens) return true;
932
+ if (vb.count > va.count) return true;
933
+ }
934
+ return false;
935
+ }
936
+ function mapsEqual(a, b) {
937
+ if (a.size !== b.size) return false;
938
+ for (const [k, va] of a) {
939
+ const vb = b.get(k);
940
+ if (!vb) return false;
941
+ if (va.totalTokens !== vb.totalTokens || va.count !== vb.count) return false;
942
+ }
943
+ return true;
944
+ }
945
+ function aggregateFromPersisted(persistedDays, nowMs) {
946
+ const fakeEvents = [];
947
+ for (const day of persistedDays.values()) {
948
+ for (const [model, tokens] of day.byModel) fakeEvents.push({
949
+ time: (/* @__PURE__ */ new Date(day.dayKey + "T12:00:00+08:00")).getTime(),
950
+ provider: day.winnerProvider ?? "unknown",
951
+ model,
952
+ usage: {
953
+ inputTokens: tokens,
954
+ cacheReadTokens: 0,
955
+ cacheWriteTokens: 0,
956
+ outputTokens: 0
957
+ }
958
+ });
959
+ if (day.byModel.size === 0 && day.totalTokens > 0) fakeEvents.push({
960
+ time: (/* @__PURE__ */ new Date(day.dayKey + "T12:00:00+08:00")).getTime(),
961
+ provider: day.winnerProvider ?? "unknown",
962
+ model: day.winnerModel ?? "unknown",
963
+ usage: {
964
+ inputTokens: day.totalTokens,
965
+ cacheReadTokens: 0,
966
+ cacheWriteTokens: 0,
967
+ outputTokens: 0
968
+ }
969
+ });
970
+ }
971
+ const agg = aggregate(fakeEvents, nowMs);
972
+ const todayKey = toDayKey(nowMs);
973
+ const [wS, wE] = weekRangeFor(todayKey);
974
+ const [mS, mE] = monthRangeFor(todayKey);
975
+ const sumRange = (s, e) => {
976
+ let sum = 0;
977
+ let cur = s;
978
+ while (cur <= e) {
979
+ sum += persistedDays.get(cur)?.totalTokens ?? 0;
980
+ const d = /* @__PURE__ */ new Date(cur + "T12:00:00+08:00");
981
+ d.setDate(d.getDate() + 1);
982
+ cur = toDayKey(d.getTime());
983
+ if (cur > e) break;
984
+ }
985
+ return sum;
986
+ };
987
+ let all = 0;
988
+ for (const v of persistedDays.values()) all += v.totalTokens;
989
+ agg.totals = {
990
+ today: persistedDays.get(todayKey)?.totalTokens ?? 0,
991
+ thisWeek: sumRange(wS, wE),
992
+ thisMonth: sumRange(mS, mE),
993
+ all
994
+ };
995
+ agg.byDay = new Map(persistedDays);
996
+ return agg;
997
+ }
998
+ //#endregion
999
+ //#region src/index.ts
1000
+ const name = "dsh-token-pulse";
1001
+ const inject = ["sessions"];
1002
+ function apply(ctx) {
1003
+ const store = new HeatmapStore(ctx);
1004
+ ctx.provide("heatmapStore", store);
1005
+ ctx.heatmapStore = store;
1006
+ let disposed = false;
1007
+ store.init().catch((e) => {
1008
+ ctx.logger?.warn?.(`dsh-token-pulse init failed: ${String(e)}`);
1009
+ });
1010
+ ctx.on("session/event", (session, event) => {
1011
+ if (disposed) return;
1012
+ store.ingest({
1013
+ ...event,
1014
+ sid: session?.id ?? ""
1015
+ });
1016
+ });
1017
+ ctx.on("session/flush", () => {});
1018
+ let unregisterRoute = null;
1019
+ ctx.inject(["webServer"], (wsCtx) => {
1020
+ try {
1021
+ const handler = (req, res) => {
1022
+ try {
1023
+ const agg = store.getAggregated();
1024
+ const days = [...agg.byDay.values()].map((d) => ({
1025
+ dayKey: d.dayKey,
1026
+ totalTokens: d.totalTokens,
1027
+ uncachedInputTokens: d.uncachedInputTokens,
1028
+ cacheReadTokens: d.cacheReadTokens,
1029
+ cacheWriteTokens: d.cacheWriteTokens,
1030
+ outputTokens: d.outputTokens,
1031
+ count: d.count,
1032
+ byModel: Object.fromEntries(d.byModel),
1033
+ byProvider: Object.fromEntries(d.byProvider),
1034
+ hourlyTokens: d.hourlyTokens,
1035
+ winnerModel: d.winnerModel,
1036
+ winnerProvider: d.winnerProvider
1037
+ }));
1038
+ const body = JSON.stringify({
1039
+ version: 1,
1040
+ ready: store.isReady(),
1041
+ totals: agg.totals,
1042
+ topModels: agg.topModels.map((x) => ({
1043
+ name: x.model,
1044
+ tokens: x.tokens
1045
+ })),
1046
+ topProviders: agg.topProviders.map((x) => ({
1047
+ name: x.provider,
1048
+ tokens: x.tokens
1049
+ })),
1050
+ days
1051
+ });
1052
+ res.writeHead(200, {
1053
+ "Content-Type": "application/json",
1054
+ "Cache-Control": "no-store"
1055
+ });
1056
+ res.end(body);
1057
+ } catch (e) {
1058
+ res.writeHead(500, { "Content-Type": "application/json" });
1059
+ res.end(JSON.stringify({ error: String(e) }));
1060
+ }
1061
+ };
1062
+ unregisterRoute = wsCtx.webServer.register({
1063
+ kind: "exact",
1064
+ path: "/api/dsh-token-pulse/daily.json",
1065
+ handler
1066
+ });
1067
+ ctx.logger.info("dsh-token-pulse: registered /api/dsh-token-pulse/daily.json");
1068
+ } catch (e) {
1069
+ ctx.logger?.warn?.(`dsh-token-pulse route register failed: ${String(e)}`);
1070
+ }
1071
+ });
1072
+ ctx.effect(() => () => {
1073
+ disposed = true;
1074
+ store.dispose();
1075
+ if (unregisterRoute) try {
1076
+ unregisterRoute();
1077
+ } catch {}
1078
+ }, "dsh-token-pulse.dispose");
1079
+ ctx.logger.info("dsh-token-pulse host up");
1080
+ }
1081
+ //#endregion
1082
+ export { apply, inject, name };
1083
+
1084
+ //# sourceMappingURL=index.mjs.map