@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
@@ -44,6 +54,7 @@
44
54
  import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session';
45
55
  import type { SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence';
46
56
  import type { ResolvedBilling } from './billing.ts';
57
+ import type { BillingFoldState } from './billing.ts';
47
58
  import type { DeepSeekTodaySessionSpend, DeepSeekTodaySessionsSpend, DeepSeekTodaySpend } from './types.ts';
48
59
  import { BILLING_UNIT_KEY, type BillingUnitFold, type BillingUnitState } from './projection.ts';
49
60
  /**
@@ -55,6 +66,77 @@ import { BILLING_UNIT_KEY, type BillingUnitFold, type BillingUnitState } from '.
55
66
  * @returns the session's current title, or `null` when untitled.
56
67
  */
57
68
  export declare function foldSessionTitle(events: readonly SessionEvent[]): string | null;
69
+ /**
70
+ * Structural slice of a session's durable lineage: the header fields DSH
71
+ * stamps on a delegation child (`origin: 'subagent'`, the delegating session's
72
+ * id, and the depth that survives persistence). All three are read
73
+ * structurally, so the scanner works on every runtime family — a log written
74
+ * before the fields existed simply carries none of them and reads as a
75
+ * top-level session.
76
+ */
77
+ export interface SessionLineage {
78
+ /** The session this one was forked from or delegated by; absent for a top-level session. */
79
+ readonly parentSession?: SessionId;
80
+ /** DSH's subagent-child classification (`childSessionMeta` stamps it). */
81
+ readonly origin?: 'subagent';
82
+ /** Delegation depth: absent (zero) at the top level, parent depth + 1 for a subagent child. */
83
+ readonly delegationDepth?: number;
84
+ }
85
+ /**
86
+ * Whether one session's durable header marks it as a subagent child. Either
87
+ * marker is enough: `origin` is DSH's navigation classification and
88
+ * `delegationDepth` is its persisted recursion budget, so a header carrying
89
+ * only the depth (or only the origin) is still a delegation child. A session
90
+ * created without either — an ordinary session, a user fork, or a cold resume
91
+ * — is top-level.
92
+ * @param header - the session's lineage slice; `undefined` reads as top-level.
93
+ * @returns true when the session was created as a subagent child.
94
+ */
95
+ export declare function isSubagentSession(header: SessionLineage | undefined): boolean;
96
+ /**
97
+ * The top-level session one session's ranking row belongs to: the session
98
+ * itself for a top-level session, and for a subagent child the first ancestor
99
+ * up the `parentSession` chain that is not itself a subagent child. A
100
+ * multi-generation delegation (a subagent that spawned subagents) therefore
101
+ * lands on the same root row as its parent, and a child whose parent header is
102
+ * unknown is attributed to the parent id its own header names — the parent is
103
+ * authoritative even when its log is not part of this scan.
104
+ * @param id - the session whose row is being attributed.
105
+ * @param lineage - lineage of every session this scan saw, by id.
106
+ * @returns the session id whose ranking row the input belongs to.
107
+ */
108
+ export declare function topLevelSessionOf(id: SessionId, lineage: ReadonlyMap<SessionId, SessionLineage>): SessionId;
109
+ /**
110
+ * Fold every subagent child's row into the top-level row it belongs to
111
+ * ({@link topLevelSessionOf}), so the ranking lists conversations rather than
112
+ * every delegation a conversation started. A child's spend is added to its
113
+ * ancestor's `total`; the ancestor's `ownTotal` keeps its own spend only. A
114
+ * top-level session whose own day was empty but whose subagents priced
115
+ * something still gets a row (with `ownTotal` 0), carrying the title the scan
116
+ * resolved for it in `titles`.
117
+ * @param rows - one row per session that priced something today (own spends).
118
+ * @param lineage - lineage of every session this scan saw, by id.
119
+ * @param titles - resolved display titles by session id; a session absent from
120
+ * the map has no resolved title and its created row reports `null`.
121
+ * @returns the merged rows, sorted by `total` descending.
122
+ */
123
+ export declare function rollUpSubagentSpend(rows: readonly DeepSeekTodaySessionSpend[], lineage: ReadonlyMap<SessionId, SessionLineage>, titles?: ReadonlyMap<SessionId, string | null>): DeepSeekTodaySessionSpend[];
124
+ /**
125
+ * Structural slice of a live session's header: the fork boundary of both DSH
126
+ * runtime families plus the delegation lineage the ranking roll-up reads.
127
+ */
128
+ export interface SessionHeaderSlice extends SessionLineage {
129
+ /** ≤ 0.1.1-rc.2: the durable fork boundary carried by the header; absent for an unseeded session. */
130
+ readonly seedLength?: number;
131
+ /** 0.1.2-alpha.4+: whether the session has a fork-inherited prefix. */
132
+ readonly isSeeded?: boolean;
133
+ /**
134
+ * Durable creation instant (epoch ms). The conversation read uses it to tell
135
+ * whether a session's spend can span more than the queried day; it is also
136
+ * part of the projection-cache record identity.
137
+ */
138
+ readonly createdAt?: number;
139
+ }
58
140
  /**
59
141
  * Structural slice of a live session the scanner reads, accepting both DSH
60
142
  * runtime families: the ≤ 0.1.1-rc.2 baseline exposes the log as
@@ -69,11 +151,8 @@ export interface ScannerSession {
69
151
  snapshotEvents?(fromSeq?: number, toSeqExclusive?: number): readonly SessionEvent[];
70
152
  /** 0.1.2-alpha.4+: the durable inherited-prefix length (0 for an unseeded session). */
71
153
  readonly inheritedEventCount?: number;
72
- /** Durable header slice: `seedLength` (older runtime) or `isSeeded` (newer runtime). */
73
- readonly header?: {
74
- readonly seedLength?: number;
75
- readonly isSeeded?: boolean;
76
- };
154
+ /** Durable header slice: `seedLength` (older runtime) or `isSeeded` (newer runtime), plus lineage. */
155
+ readonly header?: SessionHeaderSlice;
77
156
  }
78
157
  /**
79
158
  * Read one live session's complete event log across both runtime families.
@@ -83,16 +162,10 @@ export interface ScannerSession {
83
162
  */
84
163
  export declare function liveSessionEvents(session: ScannerSession): readonly SessionEvent[];
85
164
  /** Structural slice of a listed persisted session (the snapshot header is a full SessionHeader). */
86
- export interface ScannerPersistedHeader {
165
+ export interface ScannerPersistedHeader extends SessionHeaderSlice {
87
166
  readonly id: SessionId;
88
- /** ≤ 0.1.1-rc.2: the durable fork boundary carried by the snapshot header; absent for an unseeded session. */
89
- readonly seedLength?: number;
90
- /** 0.1.2-alpha.4+: whether the session has a fork-inherited prefix (exact cut arrives with the inspect result). */
91
- readonly isSeeded?: boolean;
92
167
  /** Session format generation; part of the projection-cache record identity. */
93
168
  readonly version?: number;
94
- /** Session creation time; part of the projection-cache record identity. */
95
- readonly createdAt?: number;
96
169
  /** Working directory recorded on the header; part of the projection-cache record identity. */
97
170
  readonly cwd?: string;
98
171
  }
@@ -109,10 +182,7 @@ export interface ScannerPersistenceLegacy {
109
182
  revision: SessionPersistenceRevision;
110
183
  }[]>;
111
184
  inspect(id: SessionId): Promise<{
112
- meta?: {
113
- readonly seedLength?: number;
114
- readonly isSeeded?: boolean;
115
- };
185
+ meta?: SessionHeaderSlice;
116
186
  /** 0.1.2-alpha.4+: the exact inherited cut travels beside, not inside, the header. */
117
187
  inheritedEventCount?: number;
118
188
  events: readonly SessionEvent[];
@@ -140,10 +210,7 @@ export interface ScannerPersistenceHandle {
140
210
  revision: SessionPersistenceRevision;
141
211
  }[]>;
142
212
  open(id: SessionId, access: 'read'): Promise<{
143
- readonly header?: {
144
- readonly seedLength?: number;
145
- readonly isSeeded?: boolean;
146
- };
213
+ readonly header?: SessionHeaderSlice;
147
214
  /** 0.1.2-alpha.5+: the handle carries the exact inherited cut beside the header. */
148
215
  readonly inheritedEventCount?: number;
149
216
  read(): Promise<ScannerHandleRead>;
@@ -264,22 +331,86 @@ export interface TodaySpendDetail {
264
331
  aggregate: DeepSeekTodaySpend;
265
332
  /** Today's per-session rows, sorted by cost descending. */
266
333
  sessions: DeepSeekTodaySessionSpend[];
334
+ /** The Beijing day this pass was computed for (the key its day filters used). */
335
+ dayKey: string;
336
+ /**
337
+ * WHOLE-session own spend of every session this pass priced, by id: all days
338
+ * the log covers, the fork prefix already excluded, subagents not yet merged.
339
+ * The ranking only reports the queried day, but the same fold carries the
340
+ * whole-session total, so a conversation read ({@link delegatedSpendOf}) costs
341
+ * no second scan.
342
+ */
343
+ ownSpend: ReadonlyMap<SessionId, DeepSeekTodaySpend>;
344
+ /** Lineage of every session this pass saw, by id: the delegation tree's shape. */
345
+ lineage: ReadonlyMap<SessionId, SessionLineage>;
346
+ /**
347
+ * Durable creation instant of every session this pass saw, by id (absent when
348
+ * the header carried none). The panel's today share is gated on the session
349
+ * having been created BEFORE the queried day, which this answers without
350
+ * another read.
351
+ */
352
+ createdAt: ReadonlyMap<SessionId, number>;
353
+ }
354
+ /**
355
+ * The merged whole-session spend of every subagent session delegated FROM one
356
+ * session, transitively: the subagent subtotal a conversation's own log cannot
357
+ * price. Only sessions DSH marked as delegation children count, so a user fork
358
+ * (which names a `parentSession` too) is never billed into its source. A
359
+ * malformed lineage that points back at the queried session is ignored rather
360
+ * than counted twice.
361
+ * @param id - the session whose delegated subtree to sum.
362
+ * @param ownSpend - whole-session own spend per session, from one scan pass.
363
+ * @param lineage - lineage per session, from the same pass.
364
+ * @returns the merged subtree spend; empty when the session delegated nothing priced.
365
+ */
366
+ export declare function delegatedSpendOf(id: SessionId, ownSpend: ReadonlyMap<SessionId, DeepSeekTodaySpend>, lineage: ReadonlyMap<SessionId, SessionLineage>): DeepSeekTodaySpend;
367
+ /**
368
+ * One cold session's resolution, as {@link TodaySpendScanner} remembers it: the
369
+ * persisted revision the resolution read, the session's OWN-events billing fold
370
+ * (its fork boundary already applied), and the display title folded from the
371
+ * same read. The revision is the adoption gate — a later scan adopts the entry
372
+ * only while the session's persisted revision is unchanged, which is exactly
373
+ * the proof that re-reading the log could not change the fold.
374
+ */
375
+ export interface ColdResolution {
376
+ /** The persisted revision this resolution read. */
377
+ readonly revision: SessionPersistenceRevision;
378
+ /** The session's own-events fold (`BillingUnitState` names the same shape). */
379
+ readonly fold: BillingFoldState;
380
+ /** The session's display title from the same read; `null` when untitled or unresolved. */
381
+ readonly title: string | null;
267
382
  }
268
383
  /**
269
384
  * The aggregate computation behind a cache miss. Chooses the projection path
270
385
  * when the projection registry is composed, the events path otherwise; both
271
- * gate cold reads on persisted revisions so steady-state scans touch only
272
- * sessions whose logs actually changed.
386
+ * gate cold reads on persisted revisions AND adopt the fold they already hold
387
+ * for an unchanged log, so steady-state scans re-read only sessions whose logs
388
+ * actually changed while every unchanged session keeps contributing.
273
389
  */
274
390
  export declare class TodaySpendScanner {
275
391
  private readonly deps;
276
- /** Cold sessions resolved on the projection path: id → revision + unit state + title. */
392
+ /**
393
+ * Cold sessions resolved by either strategy: id → the persisted revision the
394
+ * resolution saw, the session's OWN-events fold, and its folded title.
395
+ *
396
+ * The two strategies differ in how they PRICE a cold log (an eager projection
397
+ * cell plus the cache ladder, or a local fold over the read log), never in
398
+ * what an unchanged log contributes to the day — so one memory serves both.
399
+ * The projection path reuses the resolved unit; the events path reuses the
400
+ * fold it priced on the previous pass. A strategy that skipped an unchanged
401
+ * log WITHOUT adopting its remembered fold would silently drop that session
402
+ * from the aggregate and the ranking on every scan after the first.
403
+ */
277
404
  private readonly coldResolved;
278
405
  /** Cold sessions whose resolution failed: id → revision (retried only when the log changes). */
279
406
  private readonly coldFailed;
280
- /** Cold sessions resolved on the events path: id → revision (events were collected). */
281
- private lastEventsScan;
282
407
  constructor(deps: TodaySpendScannerDeps);
408
+ /**
409
+ * Remember one cold session's resolution, bounded by
410
+ * {@link COLD_RESOLVE_CACHE_LIMIT}: evicting the oldest entry (instead of
411
+ * clearing) keeps the other sessions' resolved state warm across scans.
412
+ */
413
+ private rememberCold;
283
414
  /**
284
415
  * Compute today's aggregate for one Beijing day.
285
416
  * @param dayKey - the Beijing-time calendar-day key to aggregate.
@@ -288,8 +419,11 @@ export declare class TodaySpendScanner {
288
419
  scan(dayKey: string): Promise<DeepSeekTodaySpend>;
289
420
  /**
290
421
  * Compute today's per-session spend for one Beijing day, sorted by cost
291
- * descending. Sessions with no priced usage on the day are omitted; each
292
- * row carries the session's durable title folded from its log.
422
+ * descending. One row per top-level session: sessions with no priced usage
423
+ * on the day are omitted (unless their subagents priced something, which the
424
+ * roll-up merges into their row), every subagent session is folded into the
425
+ * top-level session that delegated it, and each row carries the session's
426
+ * durable title folded from its log.
293
427
  * @param dayKey - the Beijing-time calendar-day key to aggregate.
294
428
  * @returns today's per-session rows, highest first.
295
429
  */
@@ -300,12 +434,15 @@ export declare class TodaySpendScanner {
300
434
  * read, unit fold, and title fold instead of scanning twice. Chooses the
301
435
  * projection path when the projection registry is composed, the events path
302
436
  * otherwise.
437
+ *
438
+ * The aggregate sums every priced session, subagents included — the ranking's
439
+ * subagent roll-up only regroups rows, so neither total moves.
303
440
  * @param dayKey - the Beijing-time calendar-day key to aggregate.
304
441
  * @returns the aggregate plus per-session rows sorted by cost descending.
305
442
  */
306
443
  scanDetail(dayKey: string): Promise<TodaySpendDetail>;
307
444
  /**
308
- * Resolve one cold session's billing unit state and display title.
445
+ * Resolve one cold session's billing fold state and display title.
309
446
  *
310
447
  * The zero-I/O projection-cache row answers the query directly whenever its
311
448
  * own latest priced day is NOT the queried day: the row then proves the
@@ -323,7 +460,7 @@ export declare class TodaySpendScanner {
323
460
  * @param header - the listed session header (the cache identity witness).
324
461
  * @param seeded - whether the session carries a fork-inherited prefix.
325
462
  * @param dayKey - the Beijing-time day being aggregated.
326
- * @returns the resolved state and title, or `undefined` when unreadable.
463
+ * @returns the resolved fold state and title, or `undefined` when unreadable.
327
464
  */
328
465
  private resolveCold;
329
466
  /**
@@ -336,11 +473,11 @@ export declare class TodaySpendScanner {
336
473
  /**
337
474
  * Cold-ladder adopt: for every stored session not live, either the
338
475
  * revision-gated resolution already in {@link coldResolved} is adopted
339
- * (unchanged log costs nothing) or the session is queued behind a bounded
340
- * parallel fan-out, resolved, remembered, and then adopted. A session whose
341
- * resolution failed is remembered too (by revision), so an unreadable log
342
- * is not re-read on every scan; a changed revision retries it. One
343
- * unreadable session never blanks the whole-day aggregate.
476
+ * (unchanged log costs nothing and still counts) or the session is queued
477
+ * behind a bounded parallel fan-out, resolved, remembered, and then adopted.
478
+ * A session whose resolution failed is remembered too (by revision), so an
479
+ * unreadable log is not re-read on every scan; a changed revision retries it.
480
+ * One unreadable session never blanks the whole-day aggregate.
344
481
  * @param liveIds - ids of sessions already folded from the live store.
345
482
  * @param snapshots - stored snapshot list (either runtime family).
346
483
  * @param dayKey - the Beijing-time day being aggregated.
@@ -350,14 +487,17 @@ export declare class TodaySpendScanner {
350
487
  /**
351
488
  * Events-path collection shared by both aggregate and per-session scans:
352
489
  * fold each session's log with the shared pricing fold (attempt samples with
353
- * same-step replacement) and announce the session's latest-day spend, gated
354
- * by revisions — a persisted session whose log did not change since the last
355
- * scan is skipped. A fork child's inherited prefix (`seq < seedLength`) is
356
- * skipped, so each model output is priced only in its source session. The
357
- * hard cap counts the queried day's events; the revision watermark only
358
- * advances on a complete pass.
490
+ * same-step replacement) and announce the session's latest-day spend. A
491
+ * persisted session whose log did not change since it was last resolved is
492
+ * answered from {@link coldResolved} instead of being re-read: it keeps
493
+ * counting toward the aggregate and the ranking at zero cost, which is what
494
+ * makes the revision gate a cache rather than a way to lose sessions. A fork
495
+ * child's inherited prefix (`seq < seedLength`) is skipped, so each model
496
+ * output is priced only in its source session. The hard cap counts the
497
+ * queried day's events; a truncated pass remembers nothing it read, so the
498
+ * next one re-reads whatever this one cut short.
359
499
  * @param dayKey - the Beijing-time calendar-day key to aggregate.
360
- * @param onSession - fold one session's state plus its complete log.
500
+ * @param onSession - adopt one session's fold, title, and lineage.
361
501
  * @returns whether the hard cap truncated the scan.
362
502
  */
363
503
  private collectTodayEvents;
@@ -366,18 +506,23 @@ export declare class TodaySpendScanner {
366
506
  * (title folded from the live log, so a rename is reflected immediately),
367
507
  * revision-gated cold ladder for the rest (title resolved on inspect, `null`
368
508
  * when answered from the projection cache). A fork child's cell covers its
369
- * inherited prefix, so its own-events fold supplies both outputs.
509
+ * inherited prefix, so its own-events fold supplies both outputs. Lineage
510
+ * (which session delegated which) comes from the same headers the boundary
511
+ * does, so the ranking's subagent roll-up costs no extra read.
370
512
  * @param dayKey - the Beijing-time calendar-day key to aggregate.
371
513
  * @returns the aggregate plus per-session rows, sorted by cost descending.
372
514
  */
373
515
  private scanDetailProjections;
374
516
  /**
375
517
  * Events path, one pass for both outputs: price today's events (per-event
376
- * Beijing-day filter during collection, hard cap), gated by revisions. A
518
+ * Beijing-day filter during collection, hard cap), revision-gated so an
519
+ * unchanged log is adopted from {@link coldResolved} instead of re-read. A
377
520
  * fork child's inherited prefix (`seq < seedLength`) is skipped, so each
378
521
  * model output is priced only in its source session. Titles fold from each
379
522
  * session's complete log — a `session/title` event can predate today — so a
380
- * rename is reflected as soon as the session's log is re-read.
523
+ * rename is reflected as soon as the session's log is re-read, and an
524
+ * unchanged session keeps the title its earlier read folded. Lineage comes
525
+ * from the same headers, which the ranking roll-up needs.
381
526
  * @param dayKey - the Beijing-time calendar-day key to aggregate.
382
527
  * @returns the aggregate plus per-session rows, sorted by cost descending.
383
528
  */