@shi-yangyang/dsh-token-dashboard 0.0.0-stage → 0.2.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 (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +166 -0
  3. package/README.md +165 -2
  4. package/THIRD_PARTY_NOTICES.md +25 -0
  5. package/client/client.js +22897 -0
  6. package/cordis.patch.yml +7 -0
  7. package/icon.svg +13 -0
  8. package/lib/host/aggregate.d.ts +61 -0
  9. package/lib/host/aggregate.js +472 -0
  10. package/lib/host/fold.d.ts +103 -0
  11. package/lib/host/fold.js +375 -0
  12. package/lib/host/index.d.ts +24 -0
  13. package/lib/host/index.js +116 -0
  14. package/lib/host/prices.d.ts +42 -0
  15. package/lib/host/prices.js +118 -0
  16. package/lib/host/server.d.ts +30 -0
  17. package/lib/host/server.js +177 -0
  18. package/lib/host/source.d.ts +37 -0
  19. package/lib/host/source.js +179 -0
  20. package/lib/host/types.d.ts +133 -0
  21. package/lib/host/types.js +9 -0
  22. package/lib/host/verify.d.ts +14 -0
  23. package/lib/host/verify.js +79 -0
  24. package/lib/shared/builtin-prices.d.ts +27 -0
  25. package/lib/shared/builtin-prices.generated.d.ts +7 -0
  26. package/lib/shared/builtin-prices.generated.js +1433 -0
  27. package/lib/shared/builtin-prices.js +111 -0
  28. package/lib/shared/dates.d.ts +23 -0
  29. package/lib/shared/dates.js +79 -0
  30. package/lib/shared/export.d.ts +13 -0
  31. package/lib/shared/export.js +113 -0
  32. package/lib/shared/format.d.ts +23 -0
  33. package/lib/shared/format.js +108 -0
  34. package/lib/shared/model.d.ts +37 -0
  35. package/lib/shared/model.js +87 -0
  36. package/lib/shared/pricing.d.ts +70 -0
  37. package/lib/shared/pricing.js +192 -0
  38. package/lib/shared/range.d.ts +34 -0
  39. package/lib/shared/range.js +93 -0
  40. package/lib/shared/types.d.ts +168 -0
  41. package/lib/shared/types.js +10 -0
  42. package/locale/en.json +6 -0
  43. package/locale/zh.json +6 -0
  44. package/package.json +93 -4
@@ -0,0 +1,7 @@
1
+ # dsh-token-dashboard bundle patch.
2
+ # Installs the plugin as one Loader entry. The plugin reads the durable session
3
+ # logs through the official sessionPersistence service and serves its dashboard
4
+ # data over an authenticated same-origin route.
5
+ - insert:
6
+ - id: dsh-token-dashboard
7
+ name: '@shi-yangyang/dsh-token-dashboard'
package/icon.svg ADDED
@@ -0,0 +1,13 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Token usage">
2
+ <defs>
3
+ <linearGradient id="tdb-bg" x1="8" y1="4" x2="56" y2="60" gradientUnits="userSpaceOnUse">
4
+ <stop offset="0" stop-color="#4f6ef7"/>
5
+ <stop offset="1" stop-color="#2b3fb8"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect x="2" y="2" width="60" height="60" rx="16" fill="url(#tdb-bg)"/>
9
+ <rect x="14" y="34" width="8" height="16" rx="3" fill="#ffffff" fill-opacity="0.72"/>
10
+ <rect x="28" y="24" width="8" height="26" rx="3" fill="#ffffff" fill-opacity="0.9"/>
11
+ <rect x="42" y="14" width="8" height="36" rx="3" fill="#ffffff"/>
12
+ <circle cx="46" cy="48" r="4" fill="#7ee2b8"/>
13
+ </svg>
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Incremental, revision-aware aggregation over every local session.
3
+ *
4
+ * The cache is in memory and derived exclusively from the official logs. It is
5
+ * rebuilt from scratch when the Host process restarts, and every entry is
6
+ * keyed by the persistence revision (or the live session's event count), so a
7
+ * dashboard open never rescans an unchanged log.
8
+ */
9
+ import type { RequestLogPage, StatsPayload, TokenBuckets } from '../shared/types.js';
10
+ import type { AggregatorSource } from './source.js';
11
+ import type { RawSessionEvent } from './types.js';
12
+ export interface AggregatorOptions {
13
+ now?: () => number;
14
+ concurrency?: number;
15
+ keepHoursMs?: number;
16
+ }
17
+ /** Revision-aware token aggregator. One instance per Host plugin activation. */
18
+ export declare class TokenAggregator {
19
+ private readonly source;
20
+ private readonly now;
21
+ private readonly concurrency;
22
+ private readonly keepHoursMs;
23
+ private cache;
24
+ private failures;
25
+ private revision;
26
+ private initialized;
27
+ private refreshing;
28
+ private payload;
29
+ private payloadRevision;
30
+ private lastScan;
31
+ constructor(source: AggregatorSource, options?: AggregatorOptions);
32
+ /** Scan once; a repeated call is a no-op until the next refresh. */
33
+ ensure(): Promise<void>;
34
+ /** Reconcile the cache with the current persisted + live corpus. */
35
+ refresh(signal?: AbortSignal): Promise<void>;
36
+ /** Current ETag revision. Bumped only when derived data can have changed. */
37
+ getRevision(): number;
38
+ /**
39
+ * Read-only per-session fold totals for the diagnostic projection check.
40
+ * The returned buckets are detached copies.
41
+ */
42
+ sessionsForVerification(): Array<{
43
+ id: string;
44
+ own: TokenBuckets;
45
+ allLog: TokenBuckets;
46
+ inheritedEventCount: number;
47
+ }>;
48
+ get isInitialized(): boolean;
49
+ /**
50
+ * Fold one live event without listing or rescanning anything.
51
+ * @returns false when the event is out of sequence and a resync is needed.
52
+ */
53
+ applyLiveEvent(sessionId: string, event: RawSessionEvent): boolean;
54
+ /** Build (or reuse) the wire payload for the dashboard. */
55
+ stats(): StatsPayload;
56
+ /** Paginated request log, newest first. */
57
+ requestPage(offset: number, limit: number): RequestLogPage;
58
+ private doRefresh;
59
+ private planFor;
60
+ private markChanged;
61
+ }
@@ -0,0 +1,472 @@
1
+ /**
2
+ * Incremental, revision-aware aggregation over every local session.
3
+ *
4
+ * The cache is in memory and derived exclusively from the official logs. It is
5
+ * rebuilt from scratch when the Host process restarts, and every entry is
6
+ * keyed by the persistence revision (or the live session's event count), so a
7
+ * dashboard open never rescans an unchanged log.
8
+ */
9
+ import { currentTimezone, hourKey, todayKey } from '../shared/dates.js';
10
+ import { addBuckets, cacheHitRate, emptyBuckets, sortModels, totalTokens, } from '../shared/model.js';
11
+ import { API_VERSION } from '../shared/types.js';
12
+ import { createAggregate, foldInto } from './fold.js';
13
+ /** Revision-aware token aggregator. One instance per Host plugin activation. */
14
+ export class TokenAggregator {
15
+ source;
16
+ now;
17
+ concurrency;
18
+ keepHoursMs;
19
+ cache = new Map();
20
+ failures = [];
21
+ revision = 0;
22
+ initialized = false;
23
+ refreshing = null;
24
+ payload = null;
25
+ payloadRevision = -1;
26
+ lastScan = {
27
+ sessionsDiscovered: 0,
28
+ sessionsRead: 0,
29
+ sessionsReused: 0,
30
+ sessionsFailed: 0,
31
+ durationMs: 0,
32
+ startedAt: 0,
33
+ };
34
+ constructor(source, options = {}) {
35
+ this.source = source;
36
+ this.now = options.now ?? Date.now;
37
+ this.concurrency = Math.max(1, Math.trunc(options.concurrency ?? 3));
38
+ this.keepHoursMs = options.keepHoursMs ?? 48 * 60 * 60 * 1000;
39
+ }
40
+ /** Scan once; a repeated call is a no-op until the next refresh. */
41
+ async ensure() {
42
+ if (this.initialized)
43
+ return;
44
+ await this.refresh();
45
+ }
46
+ /** Reconcile the cache with the current persisted + live corpus. */
47
+ async refresh(signal) {
48
+ if (this.refreshing !== null) {
49
+ await this.refreshing;
50
+ return;
51
+ }
52
+ const run = this.doRefresh(signal);
53
+ this.refreshing = run.finally(() => {
54
+ this.refreshing = null;
55
+ });
56
+ await this.refreshing;
57
+ }
58
+ /** Current ETag revision. Bumped only when derived data can have changed. */
59
+ getRevision() {
60
+ return this.revision;
61
+ }
62
+ /**
63
+ * Read-only per-session fold totals for the diagnostic projection check.
64
+ * The returned buckets are detached copies.
65
+ */
66
+ sessionsForVerification() {
67
+ return [...this.cache.values()]
68
+ .map(entry => ({
69
+ id: entry.descriptor.id,
70
+ own: { ...entry.aggregate.totals },
71
+ allLog: { ...entry.aggregate.allTotals },
72
+ inheritedEventCount: entry.aggregate.inheritedEventCount,
73
+ }))
74
+ .sort((left, right) => left.id.localeCompare(right.id));
75
+ }
76
+ get isInitialized() {
77
+ return this.initialized;
78
+ }
79
+ /**
80
+ * Fold one live event without listing or rescanning anything.
81
+ * @returns false when the event is out of sequence and a resync is needed.
82
+ */
83
+ applyLiveEvent(sessionId, event) {
84
+ const entry = this.cache.get(sessionId);
85
+ if (entry === undefined || !entry.descriptor.live)
86
+ return false;
87
+ if (entry.failed !== null)
88
+ return false;
89
+ if (!isEvent(event))
90
+ return true;
91
+ if (event.seq <= entry.aggregate.carry.lastSeq)
92
+ return true;
93
+ if (event.seq !== entry.aggregate.carry.lastSeq + 1)
94
+ return false;
95
+ foldInto(entry.aggregate, [event], { now: this.now(), keepHoursMs: this.keepHoursMs });
96
+ entry.descriptor.seq = Math.max(entry.descriptor.seq, event.seq + 1);
97
+ entry.stamp = `live:${entry.descriptor.seq}`;
98
+ entry.failed = null;
99
+ this.markChanged();
100
+ return true;
101
+ }
102
+ /** Build (or reuse) the wire payload for the dashboard. */
103
+ stats() {
104
+ const now = this.now();
105
+ const today = todayKey(now);
106
+ if (this.payload !== null && this.payloadRevision === this.revision && this.payload.today === today) {
107
+ return this.payload;
108
+ }
109
+ const totals = emptyBuckets();
110
+ let requests = 0;
111
+ let liveCount = 0;
112
+ let subagentCount = 0;
113
+ let seededCount = 0;
114
+ let withUsage = 0;
115
+ const models = new Map();
116
+ const days = new Map();
117
+ const hours = new Map();
118
+ const rows = [];
119
+ const errors = [...this.failures];
120
+ for (const entry of this.cache.values()) {
121
+ const { descriptor, aggregate } = entry;
122
+ addBuckets(totals, aggregate.totals);
123
+ requests += aggregate.requests;
124
+ for (const model of Object.values(aggregate.models)) {
125
+ mergeModel(models, model);
126
+ }
127
+ for (const bucket of Object.values(aggregate.days))
128
+ mergeBucket(days, bucket);
129
+ for (const bucket of Object.values(aggregate.hours))
130
+ mergeBucket(hours, bucket);
131
+ const rowModels = sortModels(Object.values(aggregate.models).map(cloneModel));
132
+ const summary = {
133
+ id: descriptor.id,
134
+ title: aggregate.title,
135
+ project: this.source.projectLabel?.(descriptor.header.cwd) ?? '',
136
+ cwd: descriptor.header.cwd ?? null,
137
+ modelKeys: rowModels.map(model => model.key),
138
+ createdAt: aggregate.createdAt,
139
+ lastActivity: aggregate.lastActivity,
140
+ live: descriptor.live,
141
+ origin: descriptor.header.origin === 'subagent' ? 'subagent' : 'root',
142
+ seeded: descriptor.header.isSeeded === true,
143
+ parentSession: descriptor.header.parentSession ?? null,
144
+ agentPreset: descriptor.header.agentPreset ?? null,
145
+ totals: { ...aggregate.totals },
146
+ requests: aggregate.requests,
147
+ cacheHitRate: cacheHitRate(aggregate.totals),
148
+ models: rowModels,
149
+ eventCount: aggregate.processed,
150
+ inheritedEventCount: aggregate.inheritedEventCount,
151
+ stale: entry.failed !== null,
152
+ };
153
+ rows.push(summary);
154
+ if (descriptor.live)
155
+ liveCount += 1;
156
+ if (summary.origin === 'subagent')
157
+ subagentCount += 1;
158
+ if (summary.seeded)
159
+ seededCount += 1;
160
+ if (summary.requests > 0 || totalTokens(summary.totals) > 0)
161
+ withUsage += 1;
162
+ if (entry.failed !== null) {
163
+ errors.push({ sessionId: descriptor.id, reason: entry.failed });
164
+ }
165
+ }
166
+ rows.sort((left, right) => right.lastActivity - left.lastActivity);
167
+ const uniqueErrors = [...new Map(errors.map(error => [error.sessionId, error])).values()];
168
+ const payload = {
169
+ apiVersion: API_VERSION,
170
+ revision: this.revision,
171
+ generatedAt: now,
172
+ timezone: currentTimezone(),
173
+ today,
174
+ totals,
175
+ requests,
176
+ cacheHitRate: cacheHitRate(totals),
177
+ sessions: {
178
+ total: rows.length,
179
+ withUsage,
180
+ live: liveCount,
181
+ subagents: subagentCount,
182
+ seeded: seededCount,
183
+ },
184
+ models: sortModels([...models.values()].map(cloneModel)),
185
+ days: [...days.values()].sort((left, right) => left.bucket.localeCompare(right.bucket)),
186
+ hours: [...hours.values()]
187
+ .filter(bucket => bucket.bucket >= hourKey(now - this.keepHoursMs))
188
+ .sort((left, right) => left.bucket.localeCompare(right.bucket)),
189
+ sessionRows: rows,
190
+ scan: {
191
+ ...this.lastScan,
192
+ generatedAt: now,
193
+ partial: uniqueErrors.length > 0,
194
+ },
195
+ errors: uniqueErrors,
196
+ };
197
+ this.payload = payload;
198
+ this.payloadRevision = this.revision;
199
+ return payload;
200
+ }
201
+ /** Paginated request log, newest first. */
202
+ requestPage(offset, limit) {
203
+ const rows = [];
204
+ for (const entry of this.cache.values()) {
205
+ const { descriptor, aggregate } = entry;
206
+ if (aggregate.requestSamples.length === 0)
207
+ continue;
208
+ const project = this.source.projectLabel?.(descriptor.header.cwd) ?? '';
209
+ const app = descriptor.header.agentPreset ?? 'DSH';
210
+ for (const sample of aggregate.requestSamples) {
211
+ rows.push({
212
+ id: `${descriptor.id}:${sample.key}`,
213
+ sessionId: descriptor.id,
214
+ sessionTitle: aggregate.title,
215
+ project,
216
+ app,
217
+ time: sample.time,
218
+ provider: sample.provider,
219
+ model: sample.model,
220
+ modelKey: sample.modelKey,
221
+ inputTokens: sample.buckets.uncachedInputTokens,
222
+ cacheReadTokens: sample.buckets.cacheReadTokens,
223
+ cacheWriteTokens: sample.buckets.cacheWriteTokens,
224
+ outputTokens: sample.buckets.outputTokens,
225
+ speed: sample.speed,
226
+ firstToken: sample.firstToken,
227
+ });
228
+ }
229
+ }
230
+ rows.sort((left, right) => right.time - left.time);
231
+ const safeOffset = Number.isFinite(offset) ? Math.max(0, Math.trunc(offset)) : 0;
232
+ const safeLimit = Number.isFinite(limit) ? Math.max(1, Math.min(100, Math.trunc(limit))) : 24;
233
+ return {
234
+ revision: this.revision,
235
+ total: rows.length,
236
+ offset: safeOffset,
237
+ limit: safeLimit,
238
+ generatedAt: this.now(),
239
+ rows: rows.slice(safeOffset, safeOffset + safeLimit),
240
+ };
241
+ }
242
+ async doRefresh(signal) {
243
+ const startedAt = this.now();
244
+ const started = performance.now();
245
+ let descriptors;
246
+ try {
247
+ descriptors = await this.source.list();
248
+ }
249
+ catch (error) {
250
+ this.lastScan = {
251
+ sessionsDiscovered: 0,
252
+ sessionsRead: 0,
253
+ sessionsReused: 0,
254
+ sessionsFailed: 0,
255
+ durationMs: Math.round(performance.now() - started),
256
+ startedAt,
257
+ };
258
+ throw error;
259
+ }
260
+ throwIfAborted(signal);
261
+ const plans = descriptors.map(descriptor => ({
262
+ descriptor,
263
+ plan: this.planFor(descriptor),
264
+ }));
265
+ let sessionsRead = 0;
266
+ let sessionsReused = 0;
267
+ let sessionsFailed = 0;
268
+ let changed = false;
269
+ const next = new Map();
270
+ const failures = [];
271
+ const queue = [...plans];
272
+ let cursor = 0;
273
+ const worker = async () => {
274
+ for (;;) {
275
+ const item = queue[cursor];
276
+ cursor += 1;
277
+ if (item === undefined)
278
+ return;
279
+ throwIfAborted(signal);
280
+ const { descriptor } = item;
281
+ const previous = this.cache.get(descriptor.id);
282
+ let plan = item.plan;
283
+ if (plan.kind === 'reuse') {
284
+ if (previous !== undefined) {
285
+ sessionsReused += 1;
286
+ next.set(descriptor.id, {
287
+ descriptor,
288
+ aggregate: previous.aggregate,
289
+ stamp: stampOf(descriptor),
290
+ failed: null,
291
+ });
292
+ continue;
293
+ }
294
+ plan = { kind: 'read', from: 0 };
295
+ }
296
+ try {
297
+ const read = await this.source.read(descriptor, plan.from);
298
+ const canAppend = plan.from > 0
299
+ && previous !== undefined
300
+ && read.inheritedEventCount === previous.aggregate.inheritedEventCount;
301
+ const aggregate = canAppend
302
+ ? foldTail(previous.aggregate, read, this.now(), this.keepHoursMs)
303
+ : foldFull(read, this.now(), this.keepHoursMs);
304
+ sessionsRead += 1;
305
+ changed = true;
306
+ next.set(descriptor.id, {
307
+ descriptor: { ...descriptor, seq: Math.max(descriptor.seq, aggregate.processed) },
308
+ aggregate,
309
+ stamp: stampOf(descriptor),
310
+ failed: null,
311
+ });
312
+ }
313
+ catch {
314
+ sessionsFailed += 1;
315
+ failures.push({ sessionId: descriptor.id, reason: 'read-failed' });
316
+ if (previous !== undefined) {
317
+ next.set(descriptor.id, {
318
+ descriptor,
319
+ aggregate: previous.aggregate,
320
+ stamp: stampOf(descriptor),
321
+ failed: 'read-failed',
322
+ });
323
+ }
324
+ }
325
+ }
326
+ };
327
+ await Promise.all(Array.from({ length: Math.min(this.concurrency, Math.max(1, queue.length)) }, () => worker()));
328
+ throwIfAborted(signal);
329
+ if (next.size !== this.cache.size)
330
+ changed = true;
331
+ if (!changed) {
332
+ for (const [id, entry] of next) {
333
+ const before = this.cache.get(id);
334
+ if (before !== undefined && before.stamp !== entry.stamp) {
335
+ changed = true;
336
+ break;
337
+ }
338
+ }
339
+ }
340
+ this.cache = next;
341
+ this.failures = failures;
342
+ this.lastScan = {
343
+ sessionsDiscovered: descriptors.length,
344
+ sessionsRead,
345
+ sessionsReused,
346
+ sessionsFailed,
347
+ durationMs: Math.round(performance.now() - started),
348
+ startedAt,
349
+ };
350
+ this.initialized = true;
351
+ if (changed)
352
+ this.markChanged();
353
+ else {
354
+ this.payload = null;
355
+ this.payloadRevision = -1;
356
+ }
357
+ }
358
+ planFor(descriptor) {
359
+ const entry = this.cache.get(descriptor.id);
360
+ if (entry === undefined || entry.failed !== null)
361
+ return { kind: 'read', from: 0 };
362
+ if (!sameSessionIdentity(entry.descriptor.header, descriptor.header))
363
+ return { kind: 'read', from: 0 };
364
+ if (descriptor.live) {
365
+ if (entry.aggregate.processed === descriptor.seq)
366
+ return { kind: 'reuse' };
367
+ if (entry.aggregate.processed < descriptor.seq
368
+ && entry.aggregate.inheritedEventCount === descriptor.inheritedEventCount) {
369
+ return { kind: 'read', from: entry.aggregate.processed };
370
+ }
371
+ return { kind: 'read', from: 0 };
372
+ }
373
+ if (entry.descriptor.live) {
374
+ // Live → persisted transition: the live fold already covers the log.
375
+ if (descriptor.eventCount === undefined || entry.aggregate.processed >= descriptor.eventCount) {
376
+ return { kind: 'reuse' };
377
+ }
378
+ return { kind: 'read', from: entry.aggregate.processed };
379
+ }
380
+ if (entry.stamp === stampOf(descriptor))
381
+ return { kind: 'reuse' };
382
+ return { kind: 'read', from: 0 };
383
+ }
384
+ markChanged() {
385
+ this.revision += 1;
386
+ this.payload = null;
387
+ this.payloadRevision = -1;
388
+ }
389
+ }
390
+ function foldFull(read, now, keepHoursMs) {
391
+ const aggregate = createAggregate(read.header, read.inheritedEventCount);
392
+ foldInto(aggregate, read.events, { now, keepHoursMs });
393
+ return aggregate;
394
+ }
395
+ function foldTail(aggregate, read, now, keepHoursMs) {
396
+ foldInto(aggregate, read.events, { now, keepHoursMs });
397
+ return aggregate;
398
+ }
399
+ function stampOf(descriptor) {
400
+ return descriptor.live ? `live:${descriptor.seq}` : `persisted:${descriptor.revision ?? ''}`;
401
+ }
402
+ function sameSessionIdentity(left, right) {
403
+ return left.id === right.id
404
+ && left.createdAt === right.createdAt
405
+ && left.cwd === right.cwd
406
+ && left.parentSession === right.parentSession
407
+ && left.isSeeded === right.isSeeded;
408
+ }
409
+ function mergeBucket(target, bucket) {
410
+ const existing = target.get(bucket.bucket);
411
+ if (existing === undefined) {
412
+ target.set(bucket.bucket, {
413
+ bucket: bucket.bucket,
414
+ requests: bucket.requests,
415
+ models: bucket.models.map(cloneModel),
416
+ ...cloneBuckets(bucket),
417
+ });
418
+ return;
419
+ }
420
+ addBuckets(existing, bucket);
421
+ existing.requests += bucket.requests;
422
+ for (const model of bucket.models)
423
+ mergeModelInBucket(existing, model);
424
+ }
425
+ function mergeModel(target, model) {
426
+ const existing = target.get(model.key);
427
+ if (existing === undefined) {
428
+ target.set(model.key, cloneModel(model));
429
+ return;
430
+ }
431
+ addBuckets(existing, model);
432
+ existing.requests += model.requests;
433
+ }
434
+ function mergeModelInBucket(bucket, model) {
435
+ const existing = bucket.models.find(item => item.key === model.key);
436
+ if (existing === undefined) {
437
+ bucket.models.push(cloneModel(model));
438
+ return;
439
+ }
440
+ addBuckets(existing, model);
441
+ existing.requests += model.requests;
442
+ }
443
+ function cloneModel(model) {
444
+ return {
445
+ key: model.key,
446
+ provider: model.provider,
447
+ model: model.model,
448
+ requests: model.requests,
449
+ ...cloneBuckets(model),
450
+ };
451
+ }
452
+ function cloneBuckets(buckets) {
453
+ return {
454
+ uncachedInputTokens: buckets.uncachedInputTokens,
455
+ cacheReadTokens: buckets.cacheReadTokens,
456
+ cacheWriteTokens: buckets.cacheWriteTokens,
457
+ outputTokens: buckets.outputTokens,
458
+ };
459
+ }
460
+ function isEvent(value) {
461
+ if (value === null || typeof value !== 'object')
462
+ return false;
463
+ const record = value;
464
+ return typeof record.type === 'string' && typeof record.seq === 'number' && Number.isFinite(record.seq);
465
+ }
466
+ function throwIfAborted(signal) {
467
+ if (signal?.aborted === true) {
468
+ const error = new Error('aborted');
469
+ error.name = 'AbortError';
470
+ throw error;
471
+ }
472
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The pure token fold.
3
+ *
4
+ * It mirrors the official `tokenUsage` projection from
5
+ * `@deepseek-ai/dsh-token-meter`: the four provider buckets
6
+ * (`inputTokens`, `cacheReadTokens`, `cacheWriteTokens`, `outputTokens`) are
7
+ * disjoint, a later sample for the same (turn, step) replaces the previous
8
+ * sample instead of adding to it, and `llm/retry-started` closes that
9
+ * replacement scope so the retried attempt is billed separately. It also
10
+ * attributes each sample to the provider/model route the log records, buckets
11
+ * usage by local day and hour, and derives the session title.
12
+ *
13
+ * Events inherited by a seeded (forked) session are folded into `allTotals`
14
+ * for diagnostics but excluded from the session's own totals and from every
15
+ * global aggregate, so a forked prefix is never counted twice.
16
+ */
17
+ import type { ModelUsage, TokenBuckets, UsageBucket } from '../shared/types.js';
18
+ import type { RawSessionEvent, SessionHeaderLike } from './types.js';
19
+ /** The last usage sample not yet closed by a retry; replacement scope. */
20
+ export interface UsageTiming {
21
+ /** Time from step start to the first streamed token, when derivable. */
22
+ firstTokenMs: number | null;
23
+ /** Time from first streamed token to the final stream record. */
24
+ generationMs: number | null;
25
+ /** Full measured request window (first token plus generation, or fallback). */
26
+ totalMs: number | null;
27
+ }
28
+ export interface RequestSample {
29
+ key: string;
30
+ time: number;
31
+ provider: string;
32
+ model: string;
33
+ modelKey: string;
34
+ buckets: TokenBuckets;
35
+ speed: number | null;
36
+ firstToken: number | null;
37
+ }
38
+ export interface UsageSlot {
39
+ turn: number;
40
+ step: number;
41
+ time: number;
42
+ buckets: TokenBuckets;
43
+ provider: string;
44
+ model: string;
45
+ modelKey: string;
46
+ timing: UsageTiming | null;
47
+ requestKey: string;
48
+ }
49
+ /** Mutable incremental state carried between folds of the same session. */
50
+ export interface FoldCarry {
51
+ /** Highest event seq folded so far; -1 before the first event. */
52
+ lastSeq: number;
53
+ /** Replacement scope for this session's own (non-inherited) events. */
54
+ own: UsageSlot | null;
55
+ /** Replacement scope over the whole log, used only for verification. */
56
+ all: UsageSlot | null;
57
+ currentProvider: string;
58
+ currentModel: string;
59
+ stepStart: {
60
+ turn: number;
61
+ step: number;
62
+ time: number;
63
+ } | null;
64
+ }
65
+ /** One session's derived token state. Plain JSON, no raw event content. */
66
+ export interface SessionAggregate {
67
+ totals: TokenBuckets;
68
+ allTotals: TokenBuckets;
69
+ requests: number;
70
+ allRequests: number;
71
+ models: Record<string, ModelUsage>;
72
+ days: Record<string, UsageBucket>;
73
+ hours: Record<string, UsageBucket>;
74
+ title: string | null;
75
+ lastActivity: number;
76
+ createdAt: number;
77
+ processed: number;
78
+ inheritedEventCount: number;
79
+ /** Billed requests retained for the request log (own events only). */
80
+ requestSamples: RequestSample[];
81
+ carry: FoldCarry;
82
+ }
83
+ export interface FoldOptions {
84
+ /** Wall clock used for hour-bucket retention. */
85
+ now?: number;
86
+ /** Hours retained for the hourly "today" chart. */
87
+ keepHoursMs?: number;
88
+ }
89
+ /** Create an empty aggregate for one session. */
90
+ export declare function createAggregate(header: SessionHeaderLike, inheritedEventCount: number): SessionAggregate;
91
+ /** Fold additional contiguous events into one aggregate in place. */
92
+ export declare function foldInto(aggregate: SessionAggregate, events: readonly RawSessionEvent[], options?: FoldOptions): void;
93
+ /** Fold one complete session log from scratch. */
94
+ export declare function foldSession(header: SessionHeaderLike, events: readonly RawSessionEvent[], inheritedEventCount: number, options?: FoldOptions): SessionAggregate;
95
+ /** Detached, wire-safe session counters for diagnostics and row rendering. */
96
+ export declare function summarizeSession(aggregate: SessionAggregate): {
97
+ totals: TokenBuckets;
98
+ requests: number;
99
+ cacheHitRate: number;
100
+ models: ModelUsage[];
101
+ eventCount: number;
102
+ inheritedEventCount: number;
103
+ };