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.
- package/README.md +5 -1
- package/lib/client.js +73 -26
- package/lib/index.js +166 -180
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/client/session-usage-locales.d.ts +2 -0
- package/lib/types/session-tree.d.ts +16 -0
- package/lib/types/session-usage.d.ts +5 -33
- package/lib/types/wire.d.ts +16 -0
- package/package.json +1 -1
- package/src/client/SessionUsageView.tsx +6 -1
- package/src/client/StatsLineEnhanced.tsx +69 -38
- package/src/client/locales.ts +8 -0
- package/src/client/session-usage-core.ts +1 -0
- package/src/client/session-usage-locales.ts +3 -0
- package/src/session-detail.ts +7 -36
- package/src/session-tree.ts +146 -0
- package/src/session-usage.ts +32 -181
- package/src/wire.ts +18 -0
package/src/session-usage.ts
CHANGED
|
@@ -1,48 +1,12 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
124
|
-
const compaction
|
|
125
|
-
for (const
|
|
126
|
-
if (
|
|
127
|
-
const sample = fold.fold(
|
|
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 (
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
155
|
-
*
|
|
156
|
-
*
|
|
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
|
|
170
|
-
const
|
|
171
|
-
for (const
|
|
172
|
-
|
|
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 = {
|
|
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
|