dsh-token-chart 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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +200 -2
  3. package/cordis.patch.yml +7 -0
  4. package/icon.svg +7 -0
  5. package/lib/buckets.js +115 -0
  6. package/lib/chunk/echarts.entry.js +25 -0
  7. package/lib/client.echarts.js +58 -0
  8. package/lib/client.js +1743 -0
  9. package/lib/constants.js +22 -0
  10. package/lib/index.js +640 -0
  11. package/lib/paths.js +24 -0
  12. package/lib/prefs.js +54 -0
  13. package/lib/store.js +273 -0
  14. package/lib/token-chart.css +568 -0
  15. package/lib/types/buckets.d.ts +24 -0
  16. package/lib/types/chunk/echarts.entry.d.ts +11 -0
  17. package/lib/types/constants.d.ts +22 -0
  18. package/lib/types/index.d.ts +8 -0
  19. package/lib/types/paths.d.ts +3 -0
  20. package/lib/types/prefs.d.ts +8 -0
  21. package/lib/types/store.d.ts +72 -0
  22. package/lib/types/types.d.ts +230 -0
  23. package/lib/types/update.d.ts +24 -0
  24. package/lib/types.js +10 -0
  25. package/lib/update.js +110 -0
  26. package/locale/en.json +4 -0
  27. package/locale/zh.json +4 -0
  28. package/package.json +110 -4
  29. package/src/buckets.ts +120 -0
  30. package/src/chunk/echarts.entry.ts +27 -0
  31. package/src/client/api.ts +126 -0
  32. package/src/client/charts.tsx +255 -0
  33. package/src/client/chrome.tsx +127 -0
  34. package/src/client/contract.ts +62 -0
  35. package/src/client/drawer.tsx +113 -0
  36. package/src/client/format.ts +36 -0
  37. package/src/client/globals.d.ts +21 -0
  38. package/src/client/index.tsx +227 -0
  39. package/src/client/locales.ts +164 -0
  40. package/src/client/models.ts +76 -0
  41. package/src/client/mount.tsx +312 -0
  42. package/src/client/panel.tsx +152 -0
  43. package/src/client/pill.ts +21 -0
  44. package/src/client/react.ts +12 -0
  45. package/src/client/series.tsx +140 -0
  46. package/src/client/settings.tsx +347 -0
  47. package/src/client/styles/components.less +191 -0
  48. package/src/client/styles/index.less +7 -0
  49. package/src/client/styles/tailwind.css +21 -0
  50. package/src/client/styles/tokens.less +25 -0
  51. package/src/client/table.tsx +153 -0
  52. package/src/client/ui.tsx +87 -0
  53. package/src/constants.ts +26 -0
  54. package/src/index.ts +673 -0
  55. package/src/paths.ts +28 -0
  56. package/src/prefs.ts +61 -0
  57. package/src/store.ts +295 -0
  58. package/src/types.ts +216 -0
  59. package/src/update.ts +106 -0
package/lib/prefs.js ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Preference defaults, shared by the Host half and the browser half.
3
+ *
4
+ * @module dsh-token-chart/prefs
5
+ */
6
+ import { CHART_IDS, METRIC_IDS } from './constants.js';
7
+ /** Preference defaults — the single definition shared by Host and browser halves. */
8
+ export const DEFAULT_PREFS = {
9
+ /** Session drawer chart: bar | line | pie | table | multi-bar | multi-line | bar-line */
10
+ sessionChart: 'multi-bar',
11
+ /** Daily panel chart, same vocabulary. */
12
+ dailyChart: 'multi-bar',
13
+ /** Metrics plotted in the session drawer. */
14
+ sessionMetrics: ['input', 'output', 'cache', 'total'],
15
+ /** Metrics plotted in the daily panel. */
16
+ dailyMetrics: ['input', 'output', 'cache', 'total'],
17
+ /** Also render the data table under the chart. */
18
+ showTable: true,
19
+ /** Cache-hit-rate line in the combined chart. */
20
+ showCacheHitRate: true,
21
+ /** Clicking the product's own "用量" pill opens the drawer instead of its dialog. */
22
+ interceptUsageButton: true,
23
+ };
24
+ /** Narrow one chart id, falling back to the default. */
25
+ function chartOf(value, fallback) {
26
+ return typeof value === 'string' && CHART_IDS.includes(value) ? value : fallback;
27
+ }
28
+ /** Narrow the metric list: unknown ids are dropped, an empty list falls back. */
29
+ function metricsOf(value, fallback) {
30
+ const list = Array.isArray(value)
31
+ ? value.filter((item) => typeof item === 'string' && METRIC_IDS.includes(item))
32
+ : [];
33
+ return list.length > 0 ? [...new Set(list)] : [...fallback];
34
+ }
35
+ /** Narrow one boolean flag. */
36
+ function flagOf(value, fallback) {
37
+ return typeof value === 'boolean' ? value : fallback;
38
+ }
39
+ /**
40
+ * Coerce one stored preference document into the accepted shape.
41
+ * @param raw - whatever `prefs.json` (or a client patch) held.
42
+ */
43
+ export function normalizePrefs(raw) {
44
+ const source = (raw !== null && typeof raw === 'object' ? raw : {});
45
+ return {
46
+ sessionChart: chartOf(source.sessionChart, DEFAULT_PREFS.sessionChart),
47
+ dailyChart: chartOf(source.dailyChart, DEFAULT_PREFS.dailyChart),
48
+ sessionMetrics: metricsOf(source.sessionMetrics, DEFAULT_PREFS.sessionMetrics),
49
+ dailyMetrics: metricsOf(source.dailyMetrics, DEFAULT_PREFS.dailyMetrics),
50
+ showTable: flagOf(source.showTable, DEFAULT_PREFS.showTable),
51
+ showCacheHitRate: flagOf(source.showCacheHitRate, DEFAULT_PREFS.showCacheHitRate),
52
+ interceptUsageButton: flagOf(source.interceptUsageButton, DEFAULT_PREFS.interceptUsageButton),
53
+ };
54
+ }
package/lib/store.js ADDED
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Rolling usage store: session → turn → bucket totals, with one slot per
3
+ * settled (turn, step, attempt) so the fold is idempotent.
4
+ *
5
+ * @module dsh-token-chart/store
6
+ */
7
+ import { RETENTION_DAYS, STORE_VERSION } from './constants.js';
8
+ import { addInto, bucketsEqual, bucketsFrom, isCount, subtract, startOfLocalDay, usageOf, zeroBuckets, } from './buckets.js';
9
+ export class UsageStore {
10
+ /** Session id → its turns. */
11
+ sessions = new Map();
12
+ /** One session record, created on demand. */
13
+ session(sessionId) {
14
+ let record = this.sessions.get(sessionId);
15
+ if (record === undefined) {
16
+ record = { turns: new Map() };
17
+ this.sessions.set(sessionId, record);
18
+ }
19
+ return record;
20
+ }
21
+ #turn(sessionId, turn, time) {
22
+ const session = this.session(sessionId);
23
+ let record = session.turns.get(turn);
24
+ if (record === undefined) {
25
+ record = {
26
+ turn,
27
+ time,
28
+ lastTime: time,
29
+ // One slot per settled (step, attempt): folded in seq order the store is
30
+ // a pure function of the log, so replaying a session (backfill after a
31
+ // reload, a second read of the same log) can never add the same
32
+ // settlement twice.
33
+ slots: new Map(),
34
+ // Step → seq of the `llm/retry-started` that opened its current
35
+ // attempt. Using the event seq rather than a counter keeps the slot
36
+ // keys identical when the same log is folded again.
37
+ attempts: new Map(),
38
+ totals: zeroBuckets(),
39
+ routes: [],
40
+ };
41
+ session.turns.set(turn, record);
42
+ }
43
+ return record;
44
+ }
45
+ /**
46
+ * Fold one committed session event into the store.
47
+ *
48
+ * Mirrors `@deepseek-ai/dsh-token-meter`'s `tokenUsage` projection — each
49
+ * settlement contributes the last usage sample in its stream, a resample of
50
+ * the same slot replaces rather than adds, and `llm/retry-started` opens a new
51
+ * slot so a retried attempt bills again — but stores one bucket set per
52
+ * `(step, attempt)` instead of a single running slot. That difference is what
53
+ * makes the fold idempotent: folding the same log twice yields the same
54
+ * totals, which the product's single-slot fold does not survive.
55
+ * @param sessionId - the session the event was committed to.
56
+ * @param event - one durable session event.
57
+ * @returns whether the store changed.
58
+ */
59
+ apply(sessionId, event) {
60
+ if (event === null || typeof event !== 'object')
61
+ return false;
62
+ const data = event.data;
63
+ if (data === null || typeof data !== 'object')
64
+ return false;
65
+ if (event.type === 'llm/retry-started' || event.type === 'llm/retry') {
66
+ const turn = data.turn;
67
+ const step = data.step;
68
+ if (!isCount(turn) || !isCount(step))
69
+ return false;
70
+ const record = this.sessions.get(sessionId)?.turns.get(turn);
71
+ if (record === undefined)
72
+ return false;
73
+ const seq = isCount(event.seq) ? event.seq : 0;
74
+ if ((record.attempts.get(step) ?? -1) === seq)
75
+ return false;
76
+ record.attempts.set(step, seq);
77
+ return false;
78
+ }
79
+ if (event.type !== 'assistant/message' && event.type !== 'assistant/attempt')
80
+ return false;
81
+ const usage = usageOf(event);
82
+ if (usage === undefined)
83
+ return false;
84
+ const buckets = bucketsFrom(usage);
85
+ if (buckets === undefined)
86
+ return false;
87
+ const turn = data.turn;
88
+ const step = data.step;
89
+ if (!isCount(turn) || !isCount(step))
90
+ return false;
91
+ const time = isCount(event.time) ? event.time : Date.now();
92
+ const record = this.#turn(sessionId, turn, time);
93
+ const slot = `${step}#${record.attempts.get(step) ?? 0}`;
94
+ const previous = record.slots.get(slot) ?? null;
95
+ if (previous !== null && bucketsEqual(previous, buckets)) {
96
+ record.lastTime = Math.max(record.lastTime, time);
97
+ return false;
98
+ }
99
+ if (previous !== null)
100
+ subtract(record.totals, previous);
101
+ addInto(record.totals, buckets);
102
+ record.slots.set(slot, buckets);
103
+ record.time = Math.min(record.time, time);
104
+ record.lastTime = Math.max(record.lastTime, time);
105
+ const source = data.message?.source;
106
+ if (source !== undefined && typeof source.provider === 'string' && typeof source.model === 'string') {
107
+ const route = `${source.provider}/${source.model}`;
108
+ if (!record.routes.includes(route))
109
+ record.routes.push(route);
110
+ }
111
+ return true;
112
+ }
113
+ /**
114
+ * The session's whole-window totals, folded from the per-slot buckets.
115
+ *
116
+ * This is deliberately not "the sum of the turn rows a reader sees": it is
117
+ * one fold over every settled (turn, step, attempt) of the session, which is
118
+ * the same rule the product's own `tokenUsage` projection applies. The two
119
+ * agree by construction, but the intent — and any future change — belongs
120
+ * here, not in the presentation layer.
121
+ * @param sessionId - the session to total.
122
+ */
123
+ sessionTotals(sessionId) {
124
+ const totals = zeroBuckets();
125
+ const session = this.sessions.get(sessionId);
126
+ if (session === undefined)
127
+ return totals;
128
+ for (const record of session.turns.values()) {
129
+ for (const buckets of record.slots.values())
130
+ addInto(totals, buckets);
131
+ }
132
+ return totals;
133
+ }
134
+ /** Drop turns older than the retention window and empty sessions. */
135
+ prune(now = Date.now()) {
136
+ const floor = startOfLocalDay(RETENTION_DAYS - 1);
137
+ let changed = false;
138
+ for (const [sessionId, session] of this.sessions) {
139
+ for (const [turn, record] of session.turns) {
140
+ if (record.lastTime < floor) {
141
+ session.turns.delete(turn);
142
+ changed = true;
143
+ }
144
+ }
145
+ if (session.turns.size === 0 && floor > 0) {
146
+ this.sessions.delete(sessionId);
147
+ changed = true;
148
+ }
149
+ }
150
+ void now;
151
+ return changed;
152
+ }
153
+ /** Every turn of every session inside the retention window, oldest first. */
154
+ turns() {
155
+ const rows = [];
156
+ for (const [sessionId, session] of this.sessions) {
157
+ for (const record of session.turns.values()) {
158
+ rows.push({
159
+ sessionId,
160
+ turn: record.turn,
161
+ time: record.time,
162
+ lastTime: record.lastTime,
163
+ totals: { ...record.totals },
164
+ routes: [...record.routes],
165
+ });
166
+ }
167
+ }
168
+ rows.sort((left, right) => left.time - right.time || left.turn - right.turn);
169
+ return rows;
170
+ }
171
+ /** Per-turn rows of one session, ordered by turn. */
172
+ sessionTurns(sessionId) {
173
+ const session = this.sessions.get(sessionId);
174
+ if (session === undefined)
175
+ return [];
176
+ return [...session.turns.values()]
177
+ .sort((left, right) => left.turn - right.turn)
178
+ .map((record) => ({
179
+ sessionId,
180
+ turn: record.turn,
181
+ time: record.time,
182
+ lastTime: record.lastTime,
183
+ totals: { ...record.totals },
184
+ routes: [...record.routes],
185
+ }));
186
+ }
187
+ hasSession(sessionId) {
188
+ return this.sessions.has(sessionId);
189
+ }
190
+ /** Serialize the retention window to plain JSON. */
191
+ toJSON() {
192
+ const sessions = [];
193
+ for (const [id, session] of this.sessions) {
194
+ if (session.turns.size === 0)
195
+ continue;
196
+ sessions.push({
197
+ id,
198
+ turns: [...session.turns.values()]
199
+ .sort((left, right) => left.turn - right.turn)
200
+ .map((record) => ({
201
+ turn: record.turn,
202
+ time: record.time,
203
+ lastTime: record.lastTime,
204
+ // The per-slot buckets are the source of truth; `totals` is kept for
205
+ // readability and is recomputed on load.
206
+ slots: [...record.slots.entries()].map(([slot, buckets]) => [slot, { ...buckets }]),
207
+ attempts: [...record.attempts.entries()],
208
+ routes: [...record.routes],
209
+ })),
210
+ });
211
+ }
212
+ return { version: STORE_VERSION, savedAt: Date.now(), retentionDays: RETENTION_DAYS, sessions };
213
+ }
214
+ /**
215
+ * Restore a serialized store, dropping anything outside the window.
216
+ *
217
+ * A document from an older schema is discarded rather than interpreted: the
218
+ * activation-time backfill rebuilds it from the session logs, and guessing at
219
+ * a superseded layout is exactly how a total drifts.
220
+ * @param document - the parsed store file.
221
+ * @returns whether the document was accepted.
222
+ */
223
+ load(document) {
224
+ if (document === null || typeof document !== 'object')
225
+ return false;
226
+ const candidate = document;
227
+ if (!Array.isArray(candidate.sessions))
228
+ return false;
229
+ if (candidate.version !== STORE_VERSION)
230
+ return false;
231
+ for (const entry of candidate.sessions) {
232
+ if (entry === null || typeof entry !== 'object' || typeof entry.id !== 'string')
233
+ continue;
234
+ if (!Array.isArray(entry.turns))
235
+ continue;
236
+ const session = this.session(entry.id);
237
+ for (const turn of entry.turns) {
238
+ if (turn === null || typeof turn !== 'object')
239
+ continue;
240
+ if (!isCount(turn.turn) || !isCount(turn.time))
241
+ continue;
242
+ const slots = new Map();
243
+ const totals = zeroBuckets();
244
+ for (const pair of Array.isArray(turn.slots) ? turn.slots : []) {
245
+ if (!Array.isArray(pair) || pair.length !== 2 || typeof pair[0] !== 'string')
246
+ continue;
247
+ const buckets = bucketsFrom({
248
+ inputTokens: pair[1]?.input,
249
+ outputTokens: pair[1]?.output,
250
+ cacheReadTokens: pair[1]?.cacheRead,
251
+ cacheWriteTokens: pair[1]?.cacheWrite,
252
+ });
253
+ if (buckets === undefined)
254
+ continue;
255
+ slots.set(pair[0], buckets);
256
+ addInto(totals, buckets);
257
+ }
258
+ session.turns.set(turn.turn, {
259
+ turn: turn.turn,
260
+ time: turn.time,
261
+ lastTime: isCount(turn.lastTime) ? turn.lastTime : turn.time,
262
+ slots,
263
+ attempts: new Map((Array.isArray(turn.attempts) ? turn.attempts : []).filter((pair) => Array.isArray(pair) && pair.length === 2 && isCount(pair[0]) && isCount(pair[1]))),
264
+ totals,
265
+ routes: Array.isArray(turn.routes) ? turn.routes.filter((route) => typeof route === 'string') : [],
266
+ });
267
+ }
268
+ }
269
+ this.prune();
270
+ return true;
271
+ }
272
+ }
273
+ /* ------------------------------------------------------------------- prefs */