dsh-token-total 1.1.2 → 1.1.4

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.
@@ -1,48 +1,12 @@
1
- /**
2
- * Per-session usage aggregation, INCLUDING subagent sessions.
3
- *
4
- * Why this exists: dsh's native `tokenUsage` projection covers only the main
5
- * agent of the current session — a session that delegates to subagents (whose
6
- * own logs are separate persisted sessions, linked by `header.parentSession`)
7
- * shows only a fraction of what it actually spent. This module answers "what
8
- * did this whole session tree cost".
9
- *
10
- * The store has no per-session dimension (it accumulates per day x model), so
11
- * this is computed ON DEMAND from the session logs: the root session plus every
12
- * descendant whose `parentSession` chain leads back to it, each replayed
13
- * through a fresh {@link UsageFold} (the same dedupe rules the backfill uses).
14
- *
15
- * Two correctness rules carried over from the backfill engine:
16
- * - a forked log begins with the events it copied from its parent
17
- * (`handle.inheritedEventCount`); that prefix is skipped, the parent owns it;
18
- * - the fold dedupes per (turn, step) within one session, so a streaming
19
- * sample followed by the final message counts once.
20
- *
21
- * Results are cached briefly: the composer pill polls, and replaying a long log
22
- * per poll would be wasteful. The cache is invalidated for a session as soon as
23
- * a new event lands (see the collector's live subscription).
24
- */
25
-
26
- import type {
27
- UsageSessionEvent,
28
- UsageSessionHandle,
29
- UsageSessionHeader,
30
- UsageSessionPersistence,
31
- UsageSessionSnapshot,
32
- UsageSessionStore,
33
- } from './context-types.ts'
1
+ /** Per-session owned usage, including every readable descendant. */
2
+ import type { UsageSessionPersistence, UsageSessionStore } from './context-types.ts'
34
3
  import { UsageFold } from './collector.ts'
4
+ import { readUsageTree, type UsageSessionLog } from './session-tree.ts'
35
5
  import type { SessionUsageTotals, SessionUsageTree } from './wire.ts'
36
6
 
37
7
  export type { SessionUsageTotals, SessionUsageTree } from './wire.ts'
38
8
 
39
- const ZERO: SessionUsageTotals = {
40
- uncachedInputTokens: 0,
41
- outputTokens: 0,
42
- cacheReadTokens: 0,
43
- cacheWriteTokens: 0,
44
- }
45
-
9
+ const zero = (): SessionUsageTotals => ({ uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 })
46
10
  function add(into: SessionUsageTotals, from: SessionUsageTotals): void {
47
11
  into.uncachedInputTokens += from.uncachedInputTokens
48
12
  into.outputTokens += from.outputTokens
@@ -50,81 +14,13 @@ function add(into: SessionUsageTotals, from: SessionUsageTotals): void {
50
14
  into.cacheWriteTokens += from.cacheWriteTokens
51
15
  }
52
16
 
53
- /** How long one computed tree stays hot. The pill refreshes on its own clock,
54
- * and a live session's numbers move at step granularity, not per frame. */
55
- const CACHE_TTL_MS = 4_000
56
- /** Absolute ceiling on cached trees, so a long-lived host with many sessions
57
- * cannot grow this map without bound. */
58
- const CACHE_MAX = 64
59
-
60
- /** Normalize a `list()` row (alpha snapshots or rc.1 header rows). */
61
- function headerOf(item: UsageSessionHeader | UsageSessionSnapshot): UsageSessionHeader | null {
62
- const snap = item as { header?: { id?: unknown } }
63
- if (snap !== null && typeof snap === 'object' && 'header' in snap
64
- && snap.header !== null && typeof snap.header === 'object'
65
- && typeof snap.header.id === 'string') {
66
- return snap.header as UsageSessionHeader
67
- }
68
- const row = item as UsageSessionHeader
69
- return typeof row?.id === 'string' && row.id !== '' ? row : null
70
- }
71
-
72
- function inheritedCount(cut: unknown): number {
73
- return typeof cut === 'number' && Number.isSafeInteger(cut) && cut >= 0 ? cut : 0
74
- }
75
-
76
- /** Read one stored session's events through whichever persistence seam the
77
- * host line exposes (alpha open/read/close wins; rc.1 inspect is the tail). */
78
- async function readEvents(
79
- persistence: UsageSessionPersistence,
80
- id: string,
81
- signal?: AbortSignal,
82
- ): Promise<{ events: readonly UsageSessionEvent[]; inheritedCut: number }> {
83
- if (typeof persistence.open === 'function') {
84
- const handle = await persistence.open(id, 'read', signal === undefined ? undefined : { signal })
85
- try {
86
- const inheritedCut = inheritedCount(handle.inheritedEventCount)
87
- const collected: UsageSessionEvent[] = []
88
- let offset = 0
89
- const PAGE = 500
90
- for (;;) {
91
- signal?.throwIfAborted()
92
- const slice = await handle.read(offset, PAGE, signal === undefined ? undefined : { signal })
93
- const page = ((slice && slice.events) ? slice.events : []) as readonly UsageSessionEvent[]
94
- if (page.length === 0) break
95
- for (const ev of page) collected.push(ev)
96
- offset += page.length
97
- }
98
- return { events: collected, inheritedCut }
99
- } finally {
100
- await handle.close().catch(() => {})
101
- }
102
- }
103
- if (typeof persistence.inspect === 'function') {
104
- const inspection = await persistence.inspect(id, signal)
105
- return { events: inspection.events, inheritedCut: inheritedCount(inspection.inheritedEventCount) }
106
- }
107
- throw new Error('no readable persistence seam (need open or inspect)')
108
- }
109
-
110
- /** Sum one session's own usage (its main agent) from its log. */
111
- async function sumOneSession(
112
- persistence: UsageSessionPersistence,
113
- id: string,
114
- signal?: AbortSignal,
115
- live?: UsageSessionHandle,
116
- ): Promise<{ totals: SessionUsageTotals; compaction: SessionUsageTotals }> {
117
- signal?.throwIfAborted()
118
- const { events, inheritedCut } = typeof live?.snapshotEvents === 'function'
119
- && typeof live.inheritedEventCount === 'number'
120
- ? { events: live.snapshotEvents(), inheritedCut: inheritedCount(live.inheritedEventCount) }
121
- : await readEvents(persistence, id, signal)
17
+ function sumOneSession(log: UsageSessionLog): { totals: SessionUsageTotals; compaction: SessionUsageTotals } {
122
18
  const fold = new UsageFold()
123
- const totals: SessionUsageTotals = { ...ZERO }
124
- const compaction: SessionUsageTotals = { ...ZERO }
125
- for (const ev of events) {
126
- if (inheritedCut > 0 && typeof ev.seq === 'number' && ev.seq < inheritedCut) continue
127
- const sample = fold.fold(ev)
19
+ const totals = zero()
20
+ const compaction = zero()
21
+ for (const event of log.events) {
22
+ if (event.seq < log.inheritedCut) continue
23
+ const sample = fold.fold(event)
128
24
  if (sample === null || sample.turn) continue
129
25
  const buckets: SessionUsageTotals = {
130
26
  uncachedInputTokens: sample.inputTokens,
@@ -133,28 +29,27 @@ async function sumOneSession(
133
29
  cacheWriteTokens: sample.cacheWriteTokens,
134
30
  }
135
31
  add(totals, buckets)
136
- if (ev.type === 'compaction/summary') add(compaction, buckets)
32
+ if (event.type === 'compaction/summary') add(compaction, buckets)
137
33
  }
138
34
  return { totals, compaction }
139
35
  }
140
36
 
37
+ const CACHE_TTL_MS = 4_000
38
+ const CACHE_MAX = 64
39
+
141
40
  export class SessionUsageService {
142
- /** Short-lived tree reads; any live event can invalidate tree membership. */
143
41
  private cache = new Map<string, { at: number; value: SessionUsageTree }>()
144
- /** Any event can introduce a new descendant while an async read is running. */
145
42
  private generation = 0
146
43
 
147
- /** Invalidate conservatively: an unseen session may be a newly-created child
148
- * of ANY cached root, so membership-only invalidation is not safe. */
44
+ /** New events can introduce descendants of any cached root. */
149
45
  invalidate(_sessionId: string): void {
150
46
  this.generation++
151
47
  this.cache.clear()
152
48
  }
153
49
 
154
- /** Resolve the whole session tree rooted at `rootId` and sum its usage.
155
- * `self` is the root's own usage, `subagents` every descendant's, `total`
156
- * the sum. Unreadable logs reject rather than publishing partial totals;
157
- * callers can retain their last good display while surfacing the error. */
50
+ /** A bad descendant leaves the readable totals available with coverage.
51
+ * Never cache incomplete results: a repaired log must recover on the next read.
52
+ * Root, catalog and cancellation failures still reject without publishing zero. */
158
53
  async tree(
159
54
  persistence: UsageSessionPersistence,
160
55
  sessions: UsageSessionStore | undefined,
@@ -165,69 +60,25 @@ export class SessionUsageService {
165
60
  const hit = this.cache.get(rootId)
166
61
  if (hit !== undefined && Date.now() - hit.at < CACHE_TTL_MS) return hit.value
167
62
  const generation = this.generation
168
-
169
- const headers = await this.listHeaders(persistence, signal)
170
- const children = new Map<string, string[]>()
171
- for (const header of headers) {
172
- const parent = header.parentSession
173
- if (typeof parent !== 'string' || parent === '') continue
174
- const bucket = children.get(parent)
175
- if (bucket === undefined) children.set(parent, [header.id])
176
- else bucket.push(header.id)
177
- }
178
- // A brand-new live subagent may not be materialized in persistence yet;
179
- // the live store still knows its parent, so fold those in too.
180
- const live = new Map((sessions?.list() ?? []).map((handle) => [handle.id, handle]))
181
- for (const handle of live.values()) {
182
- const parent = handle.header?.parentSession ?? handle.parentSession
183
- if (typeof parent !== 'string' || parent === '' || parent === handle.id) continue
184
- const bucket = children.get(parent)
185
- if (bucket === undefined) children.set(parent, [handle.id])
186
- else if (!bucket.includes(handle.id)) bucket.push(handle.id)
187
- }
188
-
189
- const descendants: string[] = []
190
- const seen = new Set<string>([rootId])
191
- const queue = [rootId]
192
- while (queue.length > 0) {
193
- const current = queue.shift() as string
194
- for (const child of children.get(current) ?? []) {
195
- if (seen.has(child)) continue
196
- seen.add(child)
197
- descendants.push(child)
198
- queue.push(child)
199
- }
200
- }
201
-
202
- const root = await sumOneSession(persistence, rootId, signal, live.get(rootId))
203
- const self = root.totals
204
- const subagents: SessionUsageTotals = { ...ZERO }
205
- for (const id of descendants) {
206
- const one = await sumOneSession(persistence, id, signal, live.get(id))
207
- add(subagents, one.totals)
208
- }
209
- const total: SessionUsageTotals = { ...self }
63
+ const { root, descendants, coverage } = await readUsageTree(persistence, sessions, rootId, signal)
64
+ const own = sumOneSession(root)
65
+ const subagents = zero()
66
+ for (const log of descendants) add(subagents, sumOneSession(log).totals)
67
+ const total = { ...own.totals }
210
68
  add(total, subagents)
211
- const value: SessionUsageTree = { self, selfCompaction: root.compaction, subagents, total, subagentSessions: descendants.length }
212
-
69
+ const value: SessionUsageTree = {
70
+ self: own.totals,
71
+ selfCompaction: own.compaction,
72
+ subagents,
73
+ total,
74
+ subagentSessions: descendants.length,
75
+ coverage,
76
+ }
213
77
  signal?.throwIfAborted()
214
- if (generation === this.generation) {
78
+ if (coverage.complete && generation === this.generation) {
215
79
  if (this.cache.size >= CACHE_MAX) this.cache.clear()
216
80
  this.cache.set(rootId, { at: Date.now(), value })
217
81
  }
218
82
  return value
219
83
  }
220
-
221
- private async listHeaders(
222
- persistence: UsageSessionPersistence,
223
- signal?: AbortSignal,
224
- ): Promise<UsageSessionHeader[]> {
225
- const useAlphaList = typeof persistence.open === 'function'
226
- const raw = useAlphaList
227
- ? await persistence.list(signal === undefined ? undefined : { signal })
228
- : await persistence.list(signal as never)
229
- return (raw as Array<UsageSessionHeader | UsageSessionSnapshot>)
230
- .map(headerOf)
231
- .filter((h): h is UsageSessionHeader => h !== null)
232
- }
233
84
  }
package/src/wire.ts CHANGED
@@ -114,6 +114,22 @@ export interface SessionUsageTotals {
114
114
  cacheWriteTokens: number
115
115
  }
116
116
 
117
+ /** A descendant that could not be read. Reasons are stable and sanitized;
118
+ * exception messages and filesystem paths never cross the wire. */
119
+ export interface SessionUsageReadIssue {
120
+ sessionId: string
121
+ reason: 'corrupt' | 'missing' | 'unavailable'
122
+ }
123
+
124
+ /** A partial response is usable, but its totals are explicitly not the whole
125
+ * tree. The root is always readable; older hosts may omit this field. */
126
+ export interface SessionUsageCoverage {
127
+ complete: boolean
128
+ totalSessions: number
129
+ readableSessions: number
130
+ unreadableSessions: SessionUsageReadIssue[]
131
+ }
132
+
117
133
  /** What one session tree cost, split into the main agent and its subagents.
118
134
  * The composer stats row renders this so a delegating session shows its whole
119
135
  * spend (the native `tokenUsage` projection only covers the main agent). */
@@ -129,6 +145,7 @@ export interface SessionUsageTree {
129
145
  total: SessionUsageTotals
130
146
  /** How many descendant sessions were counted. */
131
147
  subagentSessions: number
148
+ coverage?: SessionUsageCoverage
132
149
  }
133
150
 
134
151
  /** Request body of POST /token-total/api/session. */
@@ -139,6 +156,7 @@ export interface SessionUsageRequest {
139
156
  /** Detailed per-session usage view used by the Usage tab. */
140
157
  export interface SessionUsageDetail {
141
158
  sessionId: string
159
+ coverage?: SessionUsageCoverage
142
160
  totals: SessionUsageTotals
143
161
  requests: number
144
162
  averageFirstTokenMs: number | null