@rayadesu/dsh-llm-billing 0.3.12 → 0.3.13

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.
@@ -13,13 +13,23 @@
13
13
  * every scan.
14
14
  * - events path (plans A2/A3): collect and price only today's events in one
15
15
  * pass (per-event Beijing-day filter during collection) with a hard cap,
16
- * skipping sessions whose persisted revision is unchanged since the last
17
- * scan.
16
+ * adopting the fold it already priced for a session whose persisted revision
17
+ * is unchanged since the last pass.
18
18
  *
19
19
  * Both strategies run behind the same {@link TodaySpendCache}, so a miss
20
20
  * happens at most once per 60 seconds per process, and a manual refresh
21
21
  * (`force`) bypasses the time window but keeps the revision caches — an
22
- * unchanged log provably cannot change the aggregate.
22
+ * unchanged log provably cannot change the aggregate. That proof is what makes
23
+ * a revision gate a CACHE: both strategies therefore adopt the resolution they
24
+ * remember for an unchanged revision instead of skipping the session, so an
25
+ * unchanged log costs no I/O and still contributes its full spend (and title)
26
+ * to the aggregate and the ranking on every scan.
27
+ *
28
+ * The per-session ranking is a per-CONVERSATION ranking: a subagent child is
29
+ * work the delegating conversation paid for, not a session the user opened, so
30
+ * every subagent row is folded into the row of the top-level session at the
31
+ * root of its `parentSession` chain (see {@link rollUpSubagentSpend}). The
32
+ * aggregate is unaffected — it sums the same sessions either way.
23
33
  *
24
34
  * Forked sessions never double-count: a fork child's log opens with a
25
35
  * verbatim copy of its source session's events (its inherited boundary), so
@@ -64,6 +74,80 @@ export function foldSessionTitle(events) {
64
74
  }
65
75
  return null;
66
76
  }
77
+ /**
78
+ * Whether one session's durable header marks it as a subagent child. Either
79
+ * marker is enough: `origin` is DSH's navigation classification and
80
+ * `delegationDepth` is its persisted recursion budget, so a header carrying
81
+ * only the depth (or only the origin) is still a delegation child. A session
82
+ * created without either — an ordinary session, a user fork, or a cold resume
83
+ * — is top-level.
84
+ * @param header - the session's lineage slice; `undefined` reads as top-level.
85
+ * @returns true when the session was created as a subagent child.
86
+ */
87
+ export function isSubagentSession(header) {
88
+ if (header === undefined)
89
+ return false;
90
+ return header.origin === 'subagent' || (header.delegationDepth ?? 0) > 0;
91
+ }
92
+ /**
93
+ * The top-level session one session's ranking row belongs to: the session
94
+ * itself for a top-level session, and for a subagent child the first ancestor
95
+ * up the `parentSession` chain that is not itself a subagent child. A
96
+ * multi-generation delegation (a subagent that spawned subagents) therefore
97
+ * lands on the same root row as its parent, and a child whose parent header is
98
+ * unknown is attributed to the parent id its own header names — the parent is
99
+ * authoritative even when its log is not part of this scan.
100
+ * @param id - the session whose row is being attributed.
101
+ * @param lineage - lineage of every session this scan saw, by id.
102
+ * @returns the session id whose ranking row the input belongs to.
103
+ */
104
+ export function topLevelSessionOf(id, lineage) {
105
+ let current = id;
106
+ // A malformed log could claim a delegation cycle; each step visits a
107
+ // distinct ancestor, so a repeat ends the walk instead of spinning.
108
+ const seen = new Set([current]);
109
+ for (;;) {
110
+ const header = lineage.get(current);
111
+ if (!isSubagentSession(header))
112
+ return current;
113
+ const parent = header?.parentSession;
114
+ if (parent === undefined || seen.has(parent))
115
+ return current;
116
+ seen.add(parent);
117
+ current = parent;
118
+ }
119
+ }
120
+ /**
121
+ * Fold every subagent child's row into the top-level row it belongs to
122
+ * ({@link topLevelSessionOf}), so the ranking lists conversations rather than
123
+ * every delegation a conversation started. A child's spend is added to its
124
+ * ancestor's `total`; the ancestor's `ownTotal` keeps its own spend only. A
125
+ * top-level session whose own day was empty but whose subagents priced
126
+ * something still gets a row (with `ownTotal` 0), carrying the title the scan
127
+ * resolved for it in `titles`.
128
+ * @param rows - one row per session that priced something today (own spends).
129
+ * @param lineage - lineage of every session this scan saw, by id.
130
+ * @param titles - resolved display titles by session id; a session absent from
131
+ * the map has no resolved title and its created row reports `null`.
132
+ * @returns the merged rows, sorted by `total` descending.
133
+ */
134
+ export function rollUpSubagentSpend(rows, lineage, titles = new Map()) {
135
+ const merged = new Map();
136
+ for (const row of rows) {
137
+ const target = topLevelSessionOf(row.sessionId, lineage);
138
+ const carried = merged.get(target);
139
+ if (carried === undefined) {
140
+ // A row's own session keeps its own total as `ownTotal`; a row created
141
+ // for an ancestor that priced nothing today carries zero there.
142
+ merged.set(target, target === row.sessionId
143
+ ? row
144
+ : { sessionId: target, title: titles.get(target) ?? null, total: row.total, ownTotal: 0 });
145
+ continue;
146
+ }
147
+ merged.set(target, { ...carried, total: carried.total + row.total });
148
+ }
149
+ return [...merged.values()].sort((left, right) => right.total - left.total);
150
+ }
67
151
  /**
68
152
  * Read one live session's complete event log across both runtime families.
69
153
  * @throws when the session exposes neither the legacy `events` snapshot nor
@@ -218,23 +302,85 @@ function evictOldest(map, limit) {
218
302
  if (oldest !== undefined)
219
303
  map.delete(oldest);
220
304
  }
305
+ /**
306
+ * The merged whole-session spend of every subagent session delegated FROM one
307
+ * session, transitively: the subagent subtotal a conversation's own log cannot
308
+ * price. Only sessions DSH marked as delegation children count, so a user fork
309
+ * (which names a `parentSession` too) is never billed into its source. A
310
+ * malformed lineage that points back at the queried session is ignored rather
311
+ * than counted twice.
312
+ * @param id - the session whose delegated subtree to sum.
313
+ * @param ownSpend - whole-session own spend per session, from one scan pass.
314
+ * @param lineage - lineage per session, from the same pass.
315
+ * @returns the merged subtree spend; empty when the session delegated nothing priced.
316
+ */
317
+ export function delegatedSpendOf(id, ownSpend, lineage) {
318
+ const children = new Map();
319
+ for (const [childId, header] of lineage) {
320
+ const parent = header.parentSession;
321
+ if (parent === undefined || !isSubagentSession(header))
322
+ continue;
323
+ const siblings = children.get(parent);
324
+ if (siblings === undefined)
325
+ children.set(parent, [childId]);
326
+ else
327
+ siblings.push(childId);
328
+ }
329
+ // The queried session is pre-visited: a cycle that leads back to it must not
330
+ // add its own spend to its delegated subtotal.
331
+ const seen = new Set([id]);
332
+ const pending = [...children.get(id) ?? []];
333
+ for (const child of pending)
334
+ seen.add(child);
335
+ let total = emptyTodaySpend();
336
+ while (pending.length > 0) {
337
+ const current = pending.pop();
338
+ total = mergeTodaySpend(total, ownSpend.get(current) ?? emptyTodaySpend());
339
+ for (const child of children.get(current) ?? []) {
340
+ if (seen.has(child))
341
+ continue;
342
+ seen.add(child);
343
+ pending.push(child);
344
+ }
345
+ }
346
+ return total;
347
+ }
221
348
  /**
222
349
  * The aggregate computation behind a cache miss. Chooses the projection path
223
350
  * when the projection registry is composed, the events path otherwise; both
224
- * gate cold reads on persisted revisions so steady-state scans touch only
225
- * sessions whose logs actually changed.
351
+ * gate cold reads on persisted revisions AND adopt the fold they already hold
352
+ * for an unchanged log, so steady-state scans re-read only sessions whose logs
353
+ * actually changed while every unchanged session keeps contributing.
226
354
  */
227
355
  export class TodaySpendScanner {
228
356
  deps;
229
- /** Cold sessions resolved on the projection path: id → revision + unit state + title. */
357
+ /**
358
+ * Cold sessions resolved by either strategy: id → the persisted revision the
359
+ * resolution saw, the session's OWN-events fold, and its folded title.
360
+ *
361
+ * The two strategies differ in how they PRICE a cold log (an eager projection
362
+ * cell plus the cache ladder, or a local fold over the read log), never in
363
+ * what an unchanged log contributes to the day — so one memory serves both.
364
+ * The projection path reuses the resolved unit; the events path reuses the
365
+ * fold it priced on the previous pass. A strategy that skipped an unchanged
366
+ * log WITHOUT adopting its remembered fold would silently drop that session
367
+ * from the aggregate and the ranking on every scan after the first.
368
+ */
230
369
  coldResolved = new Map();
231
370
  /** Cold sessions whose resolution failed: id → revision (retried only when the log changes). */
232
371
  coldFailed = new Map();
233
- /** Cold sessions resolved on the events path: id → revision (events were collected). */
234
- lastEventsScan;
235
372
  constructor(deps) {
236
373
  this.deps = deps;
237
374
  }
375
+ /**
376
+ * Remember one cold session's resolution, bounded by
377
+ * {@link COLD_RESOLVE_CACHE_LIMIT}: evicting the oldest entry (instead of
378
+ * clearing) keeps the other sessions' resolved state warm across scans.
379
+ */
380
+ rememberCold(id, resolution) {
381
+ evictOldest(this.coldResolved, COLD_RESOLVE_CACHE_LIMIT);
382
+ this.coldResolved.set(id, resolution);
383
+ }
238
384
  /**
239
385
  * Compute today's aggregate for one Beijing day.
240
386
  * @param dayKey - the Beijing-time calendar-day key to aggregate.
@@ -245,8 +391,11 @@ export class TodaySpendScanner {
245
391
  }
246
392
  /**
247
393
  * Compute today's per-session spend for one Beijing day, sorted by cost
248
- * descending. Sessions with no priced usage on the day are omitted; each
249
- * row carries the session's durable title folded from its log.
394
+ * descending. One row per top-level session: sessions with no priced usage
395
+ * on the day are omitted (unless their subagents priced something, which the
396
+ * roll-up merges into their row), every subagent session is folded into the
397
+ * top-level session that delegated it, and each row carries the session's
398
+ * durable title folded from its log.
250
399
  * @param dayKey - the Beijing-time calendar-day key to aggregate.
251
400
  * @returns today's per-session rows, highest first.
252
401
  */
@@ -259,6 +408,9 @@ export class TodaySpendScanner {
259
408
  * read, unit fold, and title fold instead of scanning twice. Chooses the
260
409
  * projection path when the projection registry is composed, the events path
261
410
  * otherwise.
411
+ *
412
+ * The aggregate sums every priced session, subagents included — the ranking's
413
+ * subagent roll-up only regroups rows, so neither total moves.
262
414
  * @param dayKey - the Beijing-time calendar-day key to aggregate.
263
415
  * @returns the aggregate plus per-session rows sorted by cost descending.
264
416
  */
@@ -269,7 +421,7 @@ export class TodaySpendScanner {
269
421
  return this.scanDetailProjections(dayKey);
270
422
  }
271
423
  /**
272
- * Resolve one cold session's billing unit state and display title.
424
+ * Resolve one cold session's billing fold state and display title.
273
425
  *
274
426
  * The zero-I/O projection-cache row answers the query directly whenever its
275
427
  * own latest priced day is NOT the queried day: the row then proves the
@@ -287,7 +439,7 @@ export class TodaySpendScanner {
287
439
  * @param header - the listed session header (the cache identity witness).
288
440
  * @param seeded - whether the session carries a fork-inherited prefix.
289
441
  * @param dayKey - the Beijing-time day being aggregated.
290
- * @returns the resolved state and title, or `undefined` when unreadable.
442
+ * @returns the resolved fold state and title, or `undefined` when unreadable.
291
443
  */
292
444
  async resolveCold(header, seeded, dayKey) {
293
445
  const { persistence, projectionCache, logger } = this.deps;
@@ -297,7 +449,7 @@ export class TodaySpendScanner {
297
449
  try {
298
450
  const value = cache.cachedSnapshot(header, 0, [BILLING_UNIT_KEY])?.values[BILLING_UNIT_KEY];
299
451
  if (value !== undefined && value.dayKey !== dayKey)
300
- return { value, title: null };
452
+ return { fold: value, title: null };
301
453
  }
302
454
  catch (error) {
303
455
  logger.warn(`llm-billing: projection cache read for session ${header.id} failed: ${String(error)}`);
@@ -310,7 +462,7 @@ export class TodaySpendScanner {
310
462
  try {
311
463
  const read = await persistenceInspect(persistenceService, header.id);
312
464
  return {
313
- value: foldOwnBilling(this.deps.unit, read.events, read.seedLength),
465
+ fold: foldOwnBilling(this.deps.unit, read.events, read.seedLength),
314
466
  title: foldSessionTitle(read.events),
315
467
  };
316
468
  }
@@ -334,11 +486,11 @@ export class TodaySpendScanner {
334
486
  /**
335
487
  * Cold-ladder adopt: for every stored session not live, either the
336
488
  * revision-gated resolution already in {@link coldResolved} is adopted
337
- * (unchanged log costs nothing) or the session is queued behind a bounded
338
- * parallel fan-out, resolved, remembered, and then adopted. A session whose
339
- * resolution failed is remembered too (by revision), so an unreadable log
340
- * is not re-read on every scan; a changed revision retries it. One
341
- * unreadable session never blanks the whole-day aggregate.
489
+ * (unchanged log costs nothing and still counts) or the session is queued
490
+ * behind a bounded parallel fan-out, resolved, remembered, and then adopted.
491
+ * A session whose resolution failed is remembered too (by revision), so an
492
+ * unreadable log is not re-read on every scan; a changed revision retries it.
493
+ * One unreadable session never blanks the whole-day aggregate.
342
494
  * @param liveIds - ids of sessions already folded from the live store.
343
495
  * @param snapshots - stored snapshot list (either runtime family).
344
496
  * @param dayKey - the Beijing-time day being aggregated.
@@ -364,8 +516,7 @@ export class TodaySpendScanner {
364
516
  const resolved = await this.resolveCold(header, seeded, dayKey);
365
517
  if (resolved !== undefined) {
366
518
  this.coldFailed.delete(header.id);
367
- evictOldest(this.coldResolved, COLD_RESOLVE_CACHE_LIMIT);
368
- this.coldResolved.set(header.id, { revision, ...resolved });
519
+ this.rememberCold(header.id, { revision, ...resolved });
369
520
  }
370
521
  else if (persistenceAvailable) {
371
522
  evictOldest(this.coldFailed, COLD_FAILED_CACHE_LIMIT);
@@ -381,14 +532,17 @@ export class TodaySpendScanner {
381
532
  /**
382
533
  * Events-path collection shared by both aggregate and per-session scans:
383
534
  * fold each session's log with the shared pricing fold (attempt samples with
384
- * same-step replacement) and announce the session's latest-day spend, gated
385
- * by revisions — a persisted session whose log did not change since the last
386
- * scan is skipped. A fork child's inherited prefix (`seq < seedLength`) is
387
- * skipped, so each model output is priced only in its source session. The
388
- * hard cap counts the queried day's events; the revision watermark only
389
- * advances on a complete pass.
535
+ * same-step replacement) and announce the session's latest-day spend. A
536
+ * persisted session whose log did not change since it was last resolved is
537
+ * answered from {@link coldResolved} instead of being re-read: it keeps
538
+ * counting toward the aggregate and the ranking at zero cost, which is what
539
+ * makes the revision gate a cache rather than a way to lose sessions. A fork
540
+ * child's inherited prefix (`seq < seedLength`) is skipped, so each model
541
+ * output is priced only in its source session. The hard cap counts the
542
+ * queried day's events; a truncated pass remembers nothing it read, so the
543
+ * next one re-reads whatever this one cut short.
390
544
  * @param dayKey - the Beijing-time calendar-day key to aggregate.
391
- * @param onSession - fold one session's state plus its complete log.
545
+ * @param onSession - adopt one session's fold, title, and lineage.
392
546
  * @returns whether the hard cap truncated the scan.
393
547
  */
394
548
  async collectTodayEvents(dayKey, onSession) {
@@ -396,7 +550,8 @@ export class TodaySpendScanner {
396
550
  const liveIds = new Set();
397
551
  let collected = 0;
398
552
  let truncated = false;
399
- const collect = (id, events, seedLength) => {
553
+ /** Price one complete log, announce it, and return what to remember. */
554
+ const collect = (id, events, seedLength, lineage) => {
400
555
  const folder = new BillingFolder(billing, catalog, seedLength);
401
556
  for (const event of events) {
402
557
  // The cap counts the queried day's events; the fold still sees every
@@ -411,14 +566,17 @@ export class TodaySpendScanner {
411
566
  }
412
567
  folder.add(event);
413
568
  }
414
- onSession(id, folder.fold, events);
569
+ const fold = folder.fold;
570
+ const title = foldSessionTitle(events);
571
+ onSession(id, fold, title, lineage);
572
+ return { fold, title };
415
573
  };
416
574
  if (sessions !== undefined) {
417
575
  const store = sessions();
418
576
  if (store !== undefined) {
419
577
  for (const session of store.list()) {
420
578
  liveIds.add(session.id);
421
- collect(session.id, liveSessionEvents(session), forkBoundaryOf(session));
579
+ collect(session.id, liveSessionEvents(session), forkBoundaryOf(session), session.header ?? {});
422
580
  if (truncated)
423
581
  break;
424
582
  }
@@ -430,11 +588,20 @@ export class TodaySpendScanner {
430
588
  for (const { header, revision } of snapshots) {
431
589
  if (liveIds.has(header.id))
432
590
  continue;
433
- if (this.lastEventsScan?.get(header.id) === revision)
591
+ const resolved = this.coldResolved.get(header.id);
592
+ if (resolved !== undefined && resolved.revision === revision) {
593
+ // Unchanged log: adopt the fold (and title) already priced for this
594
+ // exact revision — the session still counts, at zero I/O.
595
+ onSession(header.id, resolved.fold, resolved.title, header);
434
596
  continue;
597
+ }
435
598
  try {
436
599
  const read = await persistenceInspect(persistenceService, header.id);
437
- collect(header.id, read.events, read.seedLength);
600
+ const priced = collect(header.id, read.events, read.seedLength, header);
601
+ // A MAXED pass folds one log only partially: remembering it (or its
602
+ // revision) would pin the partial result, so the next pass re-reads.
603
+ if (!truncated)
604
+ this.rememberCold(header.id, { revision, ...priced });
438
605
  }
439
606
  catch (error) {
440
607
  // One unreadable session must not blank the whole-day aggregate.
@@ -443,12 +610,6 @@ export class TodaySpendScanner {
443
610
  if (truncated)
444
611
  break;
445
612
  }
446
- // Only a complete pass may advance the revision watermark: a truncated
447
- // pass left sessions unread, and recording them would skip their events
448
- // on the next scan.
449
- if (!truncated) {
450
- this.lastEventsScan = new Map(snapshots.map(snapshot => [snapshot.header.id, snapshot.revision]));
451
- }
452
613
  }
453
614
  if (truncated)
454
615
  logger.warn(`llm-billing: today's events exceeded ${maxEvents}; result truncated`);
@@ -459,7 +620,9 @@ export class TodaySpendScanner {
459
620
  * (title folded from the live log, so a rename is reflected immediately),
460
621
  * revision-gated cold ladder for the rest (title resolved on inspect, `null`
461
622
  * when answered from the projection cache). A fork child's cell covers its
462
- * inherited prefix, so its own-events fold supplies both outputs.
623
+ * inherited prefix, so its own-events fold supplies both outputs. Lineage
624
+ * (which session delegated which) comes from the same headers the boundary
625
+ * does, so the ranking's subagent roll-up costs no extra read.
463
626
  * @param dayKey - the Beijing-time calendar-day key to aggregate.
464
627
  * @returns the aggregate plus per-session rows, sorted by cost descending.
465
628
  */
@@ -469,57 +632,106 @@ export class TodaySpendScanner {
469
632
  const projectionsService = projections?.();
470
633
  let aggregate = emptyTodaySpend();
471
634
  const rows = new Map();
635
+ const lineage = new Map();
636
+ const titles = new Map();
637
+ const ownSpend = new Map();
638
+ const createdAt = new Map();
472
639
  const liveIds = new Set();
473
640
  if (sessions !== undefined) {
474
641
  const store = sessions();
475
642
  if (store !== undefined) {
476
643
  for (const { session, state } of this.liveBillingEntries(store, projectionsService)) {
477
644
  liveIds.add(session.id);
478
- if (state === undefined || state.dayKey !== dayKey)
645
+ lineage.set(session.id, session.header ?? {});
646
+ const born = session.header?.createdAt;
647
+ if (born !== undefined)
648
+ createdAt.set(session.id, born);
649
+ // The eager cell carries no title; fold it from the live log for
650
+ // every live session, not just today's payers, so a parent that
651
+ // delegated but priced nothing itself still titles its merged row.
652
+ const title = foldSessionTitle(liveSessionEvents(session));
653
+ titles.set(session.id, title);
654
+ if (state === undefined)
655
+ continue;
656
+ // The cell's whole-session total feeds the conversation read even
657
+ // when the session priced nothing on the queried day.
658
+ ownSpend.set(session.id, state.session);
659
+ if (state.dayKey !== dayKey)
479
660
  continue;
480
661
  aggregate = mergeTodaySpend(aggregate, state.spend);
481
- // The eager cell carries no title; fold it from the live log.
482
- rows.set(session.id, { sessionId: session.id, title: foldSessionTitle(liveSessionEvents(session)), total: state.spend.total });
662
+ rows.set(session.id, {
663
+ sessionId: session.id,
664
+ title,
665
+ total: state.spend.total,
666
+ ownTotal: state.spend.total,
667
+ });
483
668
  }
484
669
  }
485
670
  }
486
671
  const persistenceService = persistence?.();
487
672
  if (persistenceService !== undefined) {
488
673
  const snapshots = await persistenceListSnapshots(persistenceService);
674
+ for (const { header } of snapshots) {
675
+ if (liveIds.has(header.id))
676
+ continue;
677
+ lineage.set(header.id, header);
678
+ if (header.createdAt !== undefined)
679
+ createdAt.set(header.id, header.createdAt);
680
+ }
489
681
  await this.coldAdopt(liveIds, snapshots, dayKey, (id, resolved) => {
490
- if (resolved.value.dayKey !== dayKey)
682
+ // A resolved title is worth keeping even when the session priced
683
+ // nothing today: its subagents' rows merge into this session's row.
684
+ if (resolved.title !== null && !titles.has(id))
685
+ titles.set(id, resolved.title);
686
+ // Same for the whole-session total: it is the conversation read's
687
+ // material whether or not the queried day is the one it last priced.
688
+ ownSpend.set(id, resolved.fold.session);
689
+ if (resolved.fold.dayKey !== dayKey)
491
690
  return;
492
- aggregate = mergeTodaySpend(aggregate, resolved.value.spend);
493
- rows.set(id, { sessionId: id, title: resolved.title, total: resolved.value.spend.total });
691
+ aggregate = mergeTodaySpend(aggregate, resolved.fold.spend);
692
+ rows.set(id, { sessionId: id, title: resolved.title, total: resolved.fold.spend.total, ownTotal: resolved.fold.spend.total });
494
693
  });
495
694
  }
496
- return { aggregate, sessions: sortRows(rows) };
695
+ return { aggregate, sessions: rollUpSubagentSpend([...rows.values()], lineage, titles), dayKey, ownSpend, lineage, createdAt };
497
696
  }
498
697
  /**
499
698
  * Events path, one pass for both outputs: price today's events (per-event
500
- * Beijing-day filter during collection, hard cap), gated by revisions. A
699
+ * Beijing-day filter during collection, hard cap), revision-gated so an
700
+ * unchanged log is adopted from {@link coldResolved} instead of re-read. A
501
701
  * fork child's inherited prefix (`seq < seedLength`) is skipped, so each
502
702
  * model output is priced only in its source session. Titles fold from each
503
703
  * session's complete log — a `session/title` event can predate today — so a
504
- * rename is reflected as soon as the session's log is re-read.
704
+ * rename is reflected as soon as the session's log is re-read, and an
705
+ * unchanged session keeps the title its earlier read folded. Lineage comes
706
+ * from the same headers, which the ranking roll-up needs.
505
707
  * @param dayKey - the Beijing-time calendar-day key to aggregate.
506
708
  * @returns the aggregate plus per-session rows, sorted by cost descending.
507
709
  */
508
710
  async scanDetailEvents(dayKey) {
509
711
  let aggregate = emptyTodaySpend();
510
- const sessions = [];
511
- await this.collectTodayEvents(dayKey, (id, fold, events) => {
712
+ const rows = [];
713
+ const lineage = new Map();
714
+ const titles = new Map();
715
+ const ownSpend = new Map();
716
+ const createdAt = new Map();
717
+ await this.collectTodayEvents(dayKey, (id, fold, title, sessionLineage) => {
718
+ lineage.set(id, sessionLineage);
719
+ // Titles cover every session the pass accounted for (not only today's
720
+ // payers), so a parent that delegated without pricing anything itself is
721
+ // titled even when its own row comes from the roll-up.
722
+ titles.set(id, title);
723
+ const born = sessionLineage.createdAt;
724
+ if (born !== undefined)
725
+ createdAt.set(id, born);
726
+ // The whole-session total is the conversation read's material even when
727
+ // the session priced nothing on the queried day.
728
+ ownSpend.set(id, fold.session);
512
729
  if (fold.dayKey !== dayKey)
513
730
  return;
514
731
  aggregate = mergeTodaySpend(aggregate, fold.spend);
515
- sessions.push({ sessionId: id, title: foldSessionTitle(events), total: fold.spend.total });
732
+ rows.push({ sessionId: id, title, total: fold.spend.total, ownTotal: fold.spend.total });
516
733
  });
517
- sessions.sort((left, right) => right.total - left.total);
518
- return { aggregate, sessions };
734
+ return { aggregate, sessions: rollUpSubagentSpend(rows, lineage, titles), dayKey, ownSpend, lineage, createdAt };
519
735
  }
520
736
  }
521
- /** Per-session rows from the map, highest total first. */
522
- function sortRows(rows) {
523
- return [...rows.values()].sort((left, right) => right.total - left.total);
524
- }
525
737
  //# sourceMappingURL=today-spend.js.map
@@ -82,14 +82,61 @@ export interface DeepSeekTodaySessionSpend {
82
82
  * `null` when the session has no title (or the title could not be resolved).
83
83
  */
84
84
  title: string | null;
85
- /** Billed cost in CNY on the queried Beijing day. */
85
+ /**
86
+ * Billed cost in CNY on the queried Beijing day for the whole conversation:
87
+ * the top-level session's own spend plus every subagent session it delegated
88
+ * (transitively), since a subagent child is the same conversation's work, not
89
+ * a session the user opened. A subagent row is therefore never reported on
90
+ * its own — its spend rides this total.
91
+ */
86
92
  total: number;
93
+ /**
94
+ * The session's own billed cost in CNY on the queried day, before its
95
+ * subagent descendants were merged into {@link total}; they are equal when no
96
+ * descendant priced anything that day. The row's decomposition fact: `total`
97
+ * is the conversation's day, this is the session's own share of it.
98
+ */
99
+ ownTotal: number;
87
100
  }
88
- /** Today's per-session billed spend across every session with a non-zero cost. */
101
+ /**
102
+ * Today's per-session billed spend. One row per top-level session that priced
103
+ * something today, with every subagent session's spend already folded into the
104
+ * row of the session that delegated it (so no subagent appears as its own row).
105
+ */
89
106
  export interface DeepSeekTodaySessionsSpend {
90
- /** Sessions with today's spend, sorted by `total` descending. */
107
+ /** Top-level sessions with today's spend, sorted by `total` descending. */
91
108
  sessions: readonly DeepSeekTodaySessionSpend[];
92
109
  }
110
+ /**
111
+ * The subagent part of one conversation's billed spend: every subagent session
112
+ * the queried session delegated (transitively), summed across every day its log
113
+ * covers. A session's own log cannot price its delegation children, so the
114
+ * browser adds this subtotal to the live own-session value to show what the
115
+ * conversation actually cost.
116
+ */
117
+ export interface DeepSeekDelegatedSpend {
118
+ /** Merged billed cost in CNY across every delegated subagent session. */
119
+ total: number;
120
+ /** One row per model priced in those sessions; empty when the session delegated nothing priced. */
121
+ models: readonly DeepSeekSessionSpendModel[];
122
+ /**
123
+ * Whether the QUERIED session is itself a delegated subagent child. Its own
124
+ * spend then rides the ranking row of the top-level session that delegated
125
+ * it, so the panel has no row of its own to read a today share from.
126
+ */
127
+ isSubagent: boolean;
128
+ /**
129
+ * Whether the queried session was created BEFORE the current Beijing day, so
130
+ * its billed spend can span more than today. This — not a comparison of two
131
+ * amounts — is what decides whether the panel renders the parenthesized today
132
+ * share: the live session amount and the 60-second-cached ranking row drift
133
+ * apart mid-turn, which would otherwise conjure a parenthesis for a session
134
+ * that started today. `false` when the session started on the current day or
135
+ * when its creation instant could not be resolved (an unproven crossing must
136
+ * not conjure one either).
137
+ */
138
+ crossedDay: boolean;
139
+ }
93
140
  /** The billed cost of one completed Turn. */
94
141
  export interface DeepSeekTurnSpend {
95
142
  /** Total billed cost in CNY across every priced model in the Turn. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rayadesu/dsh-llm-billing",
3
3
  "description": "Standalone DeepSeek account-balance and session-spend provider exposed through the billing Remote",
4
- "version": "0.3.12",
4
+ "version": "0.3.13",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -56,29 +56,29 @@
56
56
  },
57
57
  "license": "MIT",
58
58
  "peerDependencies": {
59
- "@deepseek-ai/dsh-credentials": "^0.1.2-alpha.5",
60
- "@deepseek-ai/dsh-launch-environment": "^0.1.2-alpha.5",
61
- "@deepseek-ai/dsh-invariants": "^0.1.2-alpha.5",
62
- "@deepseek-ai/dsh-llm": "^0.1.2-alpha.5",
63
- "@deepseek-ai/dsh-session": "^0.1.2-alpha.5",
64
- "@deepseek-ai/dsh-session-persistence": "^0.1.2-alpha.5",
65
- "@deepseek-ai/dsh-session-projection": "^0.1.2-alpha.5",
66
- "@deepseek-ai/dsh-typert-protocol": "^0.1.2-alpha.5",
67
- "@deepseek-ai/cordis": "^4.0.1"
59
+ "@deepseek-ai/dsh-credentials": "^0.1.6-alpha.1",
60
+ "@deepseek-ai/dsh-launch-environment": "^0.1.6-alpha.1",
61
+ "@deepseek-ai/dsh-invariants": "^0.1.6-alpha.1",
62
+ "@deepseek-ai/dsh-llm": "^0.1.6-alpha.1",
63
+ "@deepseek-ai/dsh-session": "^0.1.6-alpha.1",
64
+ "@deepseek-ai/dsh-session-persistence": "^0.1.6-alpha.1",
65
+ "@deepseek-ai/dsh-session-projection": "^0.1.6-alpha.1",
66
+ "@deepseek-ai/dsh-typert-protocol": "^0.1.6-alpha.1",
67
+ "@deepseek-ai/cordis": "^4.0.2"
68
68
  },
69
69
  "dependencies": {
70
- "@deepseek-ai/schemastery": "^3.18.1",
70
+ "@deepseek-ai/schemastery": "^3.18.2",
71
71
  "zod": "^4.4.3"
72
72
  },
73
73
  "devDependencies": {
74
- "@deepseek-ai/dsh-credentials": "^0.1.2-alpha.5",
75
- "@deepseek-ai/dsh-launch-environment": "^0.1.2-alpha.5",
76
- "@deepseek-ai/dsh-invariants": "^0.1.2-alpha.5",
77
- "@deepseek-ai/dsh-llm": "^0.1.2-alpha.5",
78
- "@deepseek-ai/dsh-session": "^0.1.2-alpha.5",
79
- "@deepseek-ai/dsh-session-persistence": "^0.1.2-alpha.5",
80
- "@deepseek-ai/dsh-session-projection": "^0.1.2-alpha.5",
81
- "@deepseek-ai/dsh-typert-protocol": "^0.1.2-alpha.5",
82
- "@deepseek-ai/cordis": "^4.0.1"
74
+ "@deepseek-ai/dsh-credentials": "^0.1.6-alpha.1",
75
+ "@deepseek-ai/dsh-launch-environment": "^0.1.6-alpha.1",
76
+ "@deepseek-ai/dsh-invariants": "^0.1.6-alpha.1",
77
+ "@deepseek-ai/dsh-llm": "^0.1.6-alpha.1",
78
+ "@deepseek-ai/dsh-session": "^0.1.6-alpha.1",
79
+ "@deepseek-ai/dsh-session-persistence": "^0.1.6-alpha.1",
80
+ "@deepseek-ai/dsh-session-projection": "^0.1.6-alpha.1",
81
+ "@deepseek-ai/dsh-typert-protocol": "^0.1.6-alpha.1",
82
+ "@deepseek-ai/cordis": "^4.0.2"
83
83
  }
84
84
  }