ds-bot 0.0.0-stage → 0.1.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.
@@ -0,0 +1,572 @@
1
+ // Usage ledger: the tokens each model call in this DSH spent, for the Usage page.
2
+ //
3
+ // A call that a Session logs (one assistant settlement per attempt) is read from that
4
+ // log, so calls made before this ledger existed count too. A call outside every log (a
5
+ // checkpoint, a handoff note, the steward, an image reading, DSH titles and compaction)
6
+ // is metered while it streams, from the time DS Bot runs. Rows go to usage.sqlite; like
7
+ // the chat index it is derived from the logs, and deleting it loses only the metered
8
+ // calls and the names of deleted Bots.
9
+ //
10
+ // A call belongs to a Bot (its own chat, its group Sessions, their earlier parts, and
11
+ // any Session forked or spawned under them), to a group chat's own Session, or to the
12
+ // rest of DSH ("other").
13
+ import { randomUUID } from 'node:crypto'
14
+ import { mkdir, unlink } from 'node:fs/promises'
15
+ import { join } from 'node:path'
16
+
17
+ const LEDGER_VERSION = 2
18
+ const PAGE_MESSAGES = 400
19
+ const PAGE_TIMEOUT_MS = 20_000
20
+ const SCAN_TIMEOUT_MS = 180_000
21
+ const SCAN_PARALLEL = 4
22
+ // A view waits this long for a scan, then answers with what it has.
23
+ const VIEW_WAIT_MS = 6_000
24
+ const RESCAN_AFTER_MS = 5_000
25
+ // An ended part still gets a few events (a title, a late reply) just after it ends.
26
+ const SEAL_AFTER_MS = 60_000
27
+ // Calls are summed per quarter hour, which every time zone offset divides.
28
+ const SLOT_MS = 15 * 60_000
29
+
30
+ const count = value => (Number.isSafeInteger(value) && value > 0 ? value : 0)
31
+
32
+ function usageChunk(stream) {
33
+ if (!Array.isArray(stream)) return undefined
34
+ for (let index = stream.length - 1; index >= 0; index -= 1) {
35
+ const record = stream[index]
36
+ if (record?.type === 'chunk' && record.chunk?.type === 'usage') return record.chunk.usage
37
+ }
38
+ return undefined
39
+ }
40
+
41
+ const tokensOf = usage => ({
42
+ input: count(usage.inputTokens),
43
+ cacheRead: count(usage.cacheReadTokens),
44
+ cacheWrite: count(usage.cacheWriteTokens),
45
+ output: count(usage.outputTokens),
46
+ })
47
+
48
+ // Folds Session events, oldest first, into one row per billed attempt, as DSH's own
49
+ // tokenUsage projection counts them: a later sample of one attempt replaces the earlier
50
+ // one, and `llm/retry-started` begins a new attempt. The model is the one the latest
51
+ // request header named. A fork's events up to its inherited cut are the parent's, so
52
+ // the cut drops the rows before it (`reset`).
53
+ export function foldCalls(events, from = {}) {
54
+ const state = { provider: from.provider ?? '', model: from.model ?? '', retry: from.retry ?? null }
55
+ const rows = new Map()
56
+ let reset = false
57
+ for (const event of events) {
58
+ const data = event?.data ?? {}
59
+ switch (event?.type) {
60
+ case 'request/header': {
61
+ const config = data.header?.config
62
+ if (config?.provider && config?.model) Object.assign(state, { provider: config.provider, model: config.model })
63
+ break
64
+ }
65
+ case 'request/context':
66
+ if (data.provider && data.model) Object.assign(state, { provider: data.provider, model: data.model })
67
+ break
68
+ case 'llm/retry-started':
69
+ state.retry = state.retry?.turn === data.turn && state.retry.step === data.step
70
+ ? { ...state.retry, count: state.retry.count + 1 }
71
+ : { turn: data.turn, step: data.step, count: 1 }
72
+ break
73
+ case 'session/end-seed':
74
+ if (data.inherited === true) {
75
+ rows.clear()
76
+ reset = true
77
+ }
78
+ break
79
+ case 'assistant/message':
80
+ case 'assistant/attempt': {
81
+ const usage = (event.type === 'assistant/message' ? data.usage : undefined) ?? usageChunk(data.stream)
82
+ if (usage === undefined || usage === null) break
83
+ const attempt = state.retry?.turn === data.turn && state.retry.step === data.step ? state.retry.count : 0
84
+ const key = `${data.turn}:${data.step}:${attempt}`
85
+ rows.set(key, { key, time: Number.isFinite(event.time) ? event.time : 0, provider: state.provider, model: state.model, ...tokensOf(usage) })
86
+ break
87
+ }
88
+ default:
89
+ break
90
+ }
91
+ }
92
+ return { rows: [...rows.values()], state, reset }
93
+ }
94
+
95
+ // One aggregate per Session, model, use, and quarter hour.
96
+ function addTo(groups, row) {
97
+ const slot = Math.floor(row.time / SLOT_MS)
98
+ const id = `${row.session}\n${row.provider}\n${row.model}\n${row.kind}\n${slot}`
99
+ const group = groups.get(id) ?? { session: row.session, provider: row.provider, model: row.model, kind: row.kind, slot, input: 0, cacheRead: 0, cacheWrite: 0, output: 0, calls: 0 }
100
+ group.input += row.input
101
+ group.cacheRead += row.cacheRead
102
+ group.cacheWrite += row.cacheWrite
103
+ group.output += row.output
104
+ group.calls += row.calls ?? 1
105
+ groups.set(id, group)
106
+ }
107
+
108
+ function memoryLedger() {
109
+ const calls = new Map()
110
+ const marks = new Map()
111
+ const owners = new Map()
112
+ const kinds = new Map()
113
+ return {
114
+ mark: session => marks.get(session),
115
+ commit(session, { rows, reset, mark }) {
116
+ if (reset) for (const [key, row] of calls) if (row.session === session && !row.live) calls.delete(key)
117
+ for (const row of rows) calls.set(row.key, row)
118
+ marks.set(session, mark)
119
+ },
120
+ add: row => calls.set(row.key, { ...row, live: true }),
121
+ owners: () => new Map(owners),
122
+ setOwner: (session, owner) => owners.set(session, owner),
123
+ kinds: () => new Map(kinds),
124
+ setKind: (session, kind) => kinds.set(session, kind),
125
+ groups() {
126
+ const groups = new Map()
127
+ for (const row of calls.values()) addTo(groups, row)
128
+ return [...groups.values()]
129
+ },
130
+ close() {},
131
+ }
132
+ }
133
+
134
+ function sqliteLedger(DatabaseSync, path) {
135
+ const db = new DatabaseSync(path)
136
+ try {
137
+ // Desktop and Web may share one DSH home; the other writer holds the lock
138
+ // briefly, and the timeout must be armed before the WAL pragma can block.
139
+ db.exec('PRAGMA busy_timeout = 5000')
140
+ db.exec('PRAGMA journal_mode = WAL')
141
+ if (db.prepare('PRAGMA user_version').get().user_version !== LEDGER_VERSION) {
142
+ db.exec(`
143
+ DROP TABLE IF EXISTS calls;
144
+ DROP TABLE IF EXISTS marks;
145
+ DROP TABLE IF EXISTS owners;
146
+ DROP TABLE IF EXISTS kinds;
147
+ CREATE TABLE calls (key TEXT PRIMARY KEY, session TEXT NOT NULL, time INTEGER NOT NULL, provider TEXT NOT NULL, model TEXT NOT NULL, kind TEXT NOT NULL, live INTEGER NOT NULL, input INTEGER NOT NULL, cache_read INTEGER NOT NULL, cache_write INTEGER NOT NULL, output INTEGER NOT NULL);
148
+ CREATE INDEX calls_session ON calls (session);
149
+ CREATE TABLE marks (session TEXT PRIMARY KEY, through INTEGER NOT NULL, fold TEXT NOT NULL, sealed INTEGER NOT NULL);
150
+ CREATE TABLE owners (session TEXT PRIMARY KEY, owner TEXT NOT NULL, name TEXT NOT NULL);
151
+ CREATE TABLE kinds (session TEXT PRIMARY KEY, kind TEXT NOT NULL);
152
+ PRAGMA user_version = ${LEDGER_VERSION};`)
153
+ }
154
+ const put = db.prepare('INSERT OR REPLACE INTO calls (key, session, time, provider, model, kind, live, input, cache_read, cache_write, output) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
155
+ const dropLogged = db.prepare('DELETE FROM calls WHERE session = ? AND live = 0')
156
+ const getMark = db.prepare('SELECT through, fold, sealed FROM marks WHERE session = ?')
157
+ const setMark = db.prepare('INSERT OR REPLACE INTO marks (session, through, fold, sealed) VALUES (?, ?, ?, ?)')
158
+ const allOwners = db.prepare('SELECT session, owner, name FROM owners')
159
+ const putOwner = db.prepare('INSERT OR REPLACE INTO owners (session, owner, name) VALUES (?, ?, ?)')
160
+ const allKinds = db.prepare('SELECT session, kind FROM kinds')
161
+ const putKind = db.prepare('INSERT OR REPLACE INTO kinds (session, kind) VALUES (?, ?)')
162
+ const grouped = db.prepare(`SELECT session, provider, model, kind, time / ${SLOT_MS} AS slot, SUM(input) AS input, SUM(cache_read) AS cacheRead, SUM(cache_write) AS cacheWrite, SUM(output) AS output, COUNT(*) AS calls FROM calls GROUP BY session, provider, model, kind, slot`)
163
+ const insert = (row, live) => put.run(row.key, row.session, row.time, row.provider, row.model, row.kind, live ? 1 : 0, row.input, row.cacheRead, row.cacheWrite, row.output)
164
+ return {
165
+ mark(session) {
166
+ const row = getMark.get(session)
167
+ return row === undefined ? undefined : { through: row.through, fold: JSON.parse(row.fold), sealed: row.sealed === 1 }
168
+ },
169
+ commit(session, { rows, reset, mark }) {
170
+ db.exec('BEGIN')
171
+ try {
172
+ if (reset) dropLogged.run(session)
173
+ for (const row of rows) insert(row, false)
174
+ setMark.run(session, mark.through, JSON.stringify(mark.fold), mark.sealed ? 1 : 0)
175
+ db.exec('COMMIT')
176
+ } catch (error) {
177
+ db.exec('ROLLBACK')
178
+ throw error
179
+ }
180
+ },
181
+ add: row => insert(row, true),
182
+ owners: () => new Map(allOwners.all().map(row => [row.session, { owner: row.owner, name: row.name }])),
183
+ setOwner: (session, owner) => putOwner.run(session, owner.owner, owner.name),
184
+ kinds: () => new Map(allKinds.all().map(row => [row.session, row.kind])),
185
+ setKind: (session, kind) => putKind.run(session, kind),
186
+ groups: () => grouped.all().map(row => ({ ...row, slot: Number(row.slot) })),
187
+ close: () => db.close(),
188
+ }
189
+ } catch (error) {
190
+ db.close()
191
+ throw error
192
+ }
193
+ }
194
+
195
+ // Every hour bucket in one time zone, as `YYYY-MM-DD HH`.
196
+ function hourKeys(timeZone) {
197
+ let format
198
+ try {
199
+ format = new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', hourCycle: 'h23' })
200
+ } catch {
201
+ format = new Intl.DateTimeFormat('en-CA', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', hourCycle: 'h23' })
202
+ }
203
+ const cache = new Map()
204
+ return (time) => {
205
+ const slot = Math.floor(time / SLOT_MS)
206
+ let key = cache.get(slot)
207
+ if (key === undefined) {
208
+ const part = Object.fromEntries(format.formatToParts(new Date(slot * SLOT_MS)).map(({ type, value }) => [type, value]))
209
+ key = `${part.year}-${part.month}-${part.day} ${part.hour}`
210
+ cache.set(slot, key)
211
+ }
212
+ return key
213
+ }
214
+ }
215
+
216
+ async function eachLimited(items, limit, work) {
217
+ let next = 0
218
+ const lane = async () => {
219
+ while (next < items.length) {
220
+ const item = items[next]
221
+ next += 1
222
+ await work(item)
223
+ }
224
+ }
225
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, lane))
226
+ }
227
+
228
+ export function install(rt) {
229
+ const { ctx, home, state } = rt
230
+ const path = join(home, 'usage.sqlite')
231
+
232
+ let ledger
233
+ let opening
234
+ // Metered calls that finish before the ledger opens.
235
+ const early = []
236
+ function openLedger() {
237
+ if (ledger !== undefined) return Promise.resolve(ledger)
238
+ const job = opening ??= (async () => {
239
+ let DatabaseSync
240
+ try {
241
+ ({ DatabaseSync } = await import('node:sqlite'))
242
+ await mkdir(home, { recursive: true })
243
+ } catch (error) {
244
+ rt.warn('ds-bot: usage is kept in memory only: %s', error)
245
+ return memoryLedger()
246
+ }
247
+ // Only proven damage warrants a rebuild: Desktop and Web share this file,
248
+ // and a busy lock must never read as corruption, or the other writer's
249
+ // ledger would be deleted under it.
250
+ const damaged = error => /malformed|not a database|corrupt/i.test(String(error?.message ?? error))
251
+ for (const fresh of [false, true]) {
252
+ try {
253
+ if (fresh) await unlink(path).catch(() => {})
254
+ return sqliteLedger(DatabaseSync, path)
255
+ } catch (error) {
256
+ if (fresh || !damaged(error)) {
257
+ rt.warn('ds-bot: usage is kept in memory only: %s', error)
258
+ return memoryLedger()
259
+ }
260
+ }
261
+ }
262
+ return memoryLedger()
263
+ })().then((opened) => {
264
+ // The plugin may have been disposed while the file opened.
265
+ if (opening !== job) {
266
+ opened.close()
267
+ return memoryLedger()
268
+ }
269
+ for (const row of early.splice(0)) opened.add(row)
270
+ return ledger = opened
271
+ })
272
+ return job
273
+ }
274
+ ctx.effect(() => () => {
275
+ ledger?.close()
276
+ ledger = undefined
277
+ opening = undefined
278
+ }, 'ds-bot: usage ledger')
279
+
280
+ // Who each Session's calls belong to, from the team as it is now. Sessions forked or
281
+ // spawned under one belong to the same owner, and a parent the team has lost
282
+ // (a deleted Bot) still passes its journaled owner down the chain.
283
+ function liveOwners(listed, stored) {
284
+ const owners = new Map()
285
+ const botOwner = bot => ({ owner: bot.id, name: bot.name })
286
+ for (const bot of Object.values(state.bots)) {
287
+ for (const sessionId of [bot.id, rt.chatOf(bot.id), ...(bot.segments ?? []).map(segment => segment.sessionId)]) owners.set(sessionId, botOwner(bot))
288
+ }
289
+ for (const room of Object.values(state.rooms)) {
290
+ owners.set(room.id, { owner: `room:${room.id}`, name: room.name })
291
+ const memberOwner = botId => ({ owner: botId, name: rt.botOf(botId)?.name ?? '' })
292
+ for (const [botId, sessionId] of Object.entries(room.sessions ?? {})) owners.set(sessionId, memberOwner(botId))
293
+ for (const [botId, segments] of Object.entries(room.segments ?? {})) {
294
+ for (const segment of segments) owners.set(segment.sessionId, memberOwner(botId))
295
+ }
296
+ }
297
+ // The DSH Agent is part of the team: its Sessions count beside the Bots, not
298
+ // with the other sessions.
299
+ for (const agent of Object.values(state.agents ?? {})) {
300
+ for (const sessionId of agent.sessions ?? []) owners.set(sessionId, { owner: `agent:${agent.id}`, name: agent.name })
301
+ }
302
+ const parents = new Map((listed ?? []).filter(item => item.parentSessionId).map(item => [item.sessionId, item.parentSessionId]))
303
+ for (const sessionId of parents.keys()) {
304
+ let root = sessionId
305
+ for (let depth = 0; parents.has(root) && depth < 32; depth += 1) root = parents.get(root)
306
+ if (owners.has(sessionId)) continue
307
+ const found = owners.get(root) ?? stored?.get(root)
308
+ if (found !== undefined) owners.set(sessionId, found)
309
+ }
310
+ return owners
311
+ }
312
+
313
+ let listed = []
314
+ let scanning
315
+ let scannedAt = 0
316
+
317
+ // Sessions that speak inside a group chat, from the rooms as they are now.
318
+ const inGroup = () => new Set(Object.values(state.rooms).flatMap(room => [
319
+ ...Object.values(room.sessions ?? {}),
320
+ ...Object.values(room.segments ?? {}).flat().map(segment => segment.sessionId),
321
+ ]))
322
+
323
+ async function scanSession(ledger, sessionId, asOfSeq, sealable, signal) {
324
+ const mark = ledger.mark(sessionId)
325
+ if (mark?.sealed) return
326
+ let through = asOfSeq
327
+ if (through === undefined) {
328
+ const projections = await ctx.sessionController.projections({ sessionId }, signal)
329
+ if (projections === null || projections === undefined) return
330
+ through = projections.asOfSeq
331
+ }
332
+ const from = mark?.through ?? -1
333
+ if (!Number.isSafeInteger(through) || through <= from) {
334
+ if (mark !== undefined && sealable) ledger.commit(sessionId, { rows: [], reset: false, mark: { ...mark, sealed: true } })
335
+ return
336
+ }
337
+ const events = []
338
+ let beforeSeq
339
+ for (let reached = false; !reached;) {
340
+ const page = await ctx.sessionController.page({
341
+ address: { kind: 'session', sessionId },
342
+ throughSeq: through,
343
+ ...(beforeSeq === undefined ? {} : { beforeSeq }),
344
+ maxMessages: PAGE_MESSAGES,
345
+ }, AbortSignal.any([signal, AbortSignal.timeout(PAGE_TIMEOUT_MS)]))
346
+ const got = page.records.map(record => record.event)
347
+ for (let index = got.length - 1; index >= 0; index -= 1) {
348
+ if (got[index].seq <= from) {
349
+ reached = true
350
+ break
351
+ }
352
+ events.push(got[index])
353
+ }
354
+ if (!page.hasMore || got.length === 0) break
355
+ beforeSeq = got[0].seq
356
+ }
357
+ events.reverse()
358
+ if (events.length > 0 && events[0].seq !== from + 1) throw new Error(`the history of ${sessionId} came back with a gap`)
359
+ const fold = foldCalls(events, mark?.fold)
360
+ ledger.commit(sessionId, {
361
+ rows: fold.rows.map(row => ({ ...row, key: `${sessionId}#${row.key}`, session: sessionId, kind: 'turn' })),
362
+ reset: fold.reset,
363
+ mark: { through, fold: fold.state, sealed: sealable },
364
+ })
365
+ }
366
+
367
+ // Catches every known Session up to its log. A part that ended a while ago is
368
+ // complete, so it is sealed and never read again.
369
+ function scan() {
370
+ if (scanning !== undefined) return scanning
371
+ if (Date.now() - scannedAt < RESCAN_AFTER_MS) return Promise.resolve()
372
+ const job = (async () => {
373
+ const ledger = await openLedger()
374
+ await rt.load()
375
+ const signal = AbortSignal.timeout(SCAN_TIMEOUT_MS)
376
+ try {
377
+ listed = (await ctx.sessionController.list({}, signal)).items ?? []
378
+ } catch (error) {
379
+ rt.warn('ds-bot: usage could not list the Sessions: %s', error)
380
+ }
381
+ const stored = ledger.owners()
382
+ const owners = liveOwners(listed, stored)
383
+ for (const [sessionId, owner] of owners) {
384
+ const before = stored.get(sessionId)
385
+ if (owner.name !== '' && (before?.owner !== owner.owner || before?.name !== owner.name)) ledger.setOwner(sessionId, owner)
386
+ }
387
+ // Group membership vanishes with the room, but a call that happened in a
388
+ // group chat must stay one, so the kind is remembered like the owner is.
389
+ const storedKinds = ledger.kinds()
390
+ for (const sessionId of inGroup()) if (storedKinds.get(sessionId) !== 'group') ledger.setKind(sessionId, 'group')
391
+ const ended = new Set()
392
+ const longAgo = Date.now() - SEAL_AFTER_MS
393
+ for (const bot of Object.values(state.bots)) for (const segment of bot.segments ?? []) if (segment.endedAt < longAgo) ended.add(segment.sessionId)
394
+ for (const room of Object.values(state.rooms)) {
395
+ for (const segments of Object.values(room.segments ?? {})) for (const segment of segments) if (segment.endedAt < longAgo) ended.add(segment.sessionId)
396
+ }
397
+ const targets = new Map()
398
+ for (const item of listed) if (item.blank !== true) targets.set(item.sessionId, item.projections?.asOfSeq)
399
+ for (const sessionId of owners.keys()) if (!targets.has(sessionId)) targets.set(sessionId, undefined)
400
+ await eachLimited([...targets], SCAN_PARALLEL, ([sessionId, asOfSeq]) => scanSession(ledger, sessionId, asOfSeq, ended.has(sessionId), signal)
401
+ .catch(error => rt.warn('ds-bot: usage could not read %s: %s', sessionId, error)))
402
+ })().finally(() => {
403
+ scannedAt = Date.now()
404
+ scanning = undefined
405
+ })
406
+ scanning = job
407
+ return job
408
+ }
409
+
410
+ // Tokens per hour, owner, model, and use, for the browser to sum by day, week, or month.
411
+ async function usageView({ timeZone } = {}) {
412
+ const job = scan().catch(error => rt.warn('ds-bot: usage scan failed: %s', error))
413
+ let timer
414
+ await Promise.race([job, new Promise((resolve) => { timer = setTimeout(resolve, VIEW_WAIT_MS) })])
415
+ clearTimeout(timer)
416
+ const ledger = await openLedger()
417
+ const stored = ledger.owners()
418
+ const live = liveOwners(listed, stored)
419
+ const hourOf = hourKeys(typeof timeZone === 'string' ? timeZone : undefined)
420
+ // A deleted Bot or group keeps the name it had when its calls were last read.
421
+ const describe = (key, name) => {
422
+ if (key === 'other') return { key, kind: 'other' }
423
+ if (key.startsWith('room:')) {
424
+ const id = key.slice('room:'.length)
425
+ const room = rt.roomOf(id)
426
+ return { key, kind: 'room', id, name: room?.name ?? name, ...(room ? {} : { deleted: true }) }
427
+ }
428
+ if (key.startsWith('agent:')) {
429
+ const id = key.slice('agent:'.length)
430
+ const agent = state.agents?.[id]
431
+ return { key, kind: 'agent', id, name: agent?.name ?? name, ...(agent ? {} : { deleted: true }) }
432
+ }
433
+ const bot = rt.botOf(key)
434
+ return { key, kind: 'bot', id: key, name: bot?.name ?? name, ...(bot ? {} : { deleted: true }) }
435
+ }
436
+ const owners = []
437
+ const ownerIndex = new Map()
438
+ const ownerOf = (sessionId) => {
439
+ const found = live.get(sessionId) ?? stored.get(sessionId)
440
+ const key = found?.owner ?? 'other'
441
+ if (!ownerIndex.has(key)) {
442
+ ownerIndex.set(key, owners.length)
443
+ owners.push(describe(key, found?.name ?? ''))
444
+ }
445
+ return ownerIndex.get(key)
446
+ }
447
+ const catalog = rt.cachedCatalog()
448
+ const models = []
449
+ const modelIndex = new Map()
450
+ const modelOf = (provider, model) => {
451
+ const key = `${provider}/${model}`
452
+ if (!modelIndex.has(key)) {
453
+ modelIndex.set(key, models.length)
454
+ const entry = catalog.find(item => item.ref === key)
455
+ models.push({ key, provider, model, name: entry?.name ?? model, ...(entry?.providerName ? { providerName: entry.providerName } : {}) })
456
+ }
457
+ return modelIndex.get(key)
458
+ }
459
+ const kinds = []
460
+ const kindIndex = new Map()
461
+ const kindOf = (kind) => {
462
+ if (!kindIndex.has(kind)) {
463
+ kindIndex.set(kind, kinds.length)
464
+ kinds.push(kind)
465
+ }
466
+ return kindIndex.get(kind)
467
+ }
468
+ const groupSessions = inGroup()
469
+ const buckets = new Map()
470
+ const storedKinds = ledger.kinds()
471
+ for (const group of ledger.groups()) {
472
+ const kind = group.kind === 'turn' ? (groupSessions.has(group.session) || storedKinds.get(group.session) === 'group' ? 'group' : 'chat') : group.kind
473
+ const row = [hourOf(group.slot * SLOT_MS), ownerOf(group.session), modelOf(group.provider, group.model), kindOf(kind)]
474
+ const id = row.join('\n')
475
+ const bucket = buckets.get(id) ?? [...row, 0, 0, 0, 0, 0]
476
+ bucket[4] += group.input
477
+ bucket[5] += group.cacheRead
478
+ bucket[6] += group.cacheWrite
479
+ bucket[7] += group.output
480
+ bucket[8] += group.calls
481
+ buckets.set(id, bucket)
482
+ }
483
+ return {
484
+ owners,
485
+ models,
486
+ kinds,
487
+ // [hour, owner, model, kind, input, cacheRead, cacheWrite, output, calls]
488
+ rows: [...buckets.values()].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)),
489
+ now: hourOf(Date.now()),
490
+ scanning: scanning !== undefined,
491
+ }
492
+ }
493
+
494
+ // DS Bot tags the requests it makes outside a Session with the Session they serve.
495
+ const tags = new WeakMap()
496
+ const tagUsage = (messages, tag) => {
497
+ if (messages !== null && typeof messages === 'object') tags.set(messages, tag)
498
+ return messages
499
+ }
500
+ const carryUsageTag = (from, to) => {
501
+ const tag = from !== null && typeof from === 'object' ? tags.get(from) : undefined
502
+ if (tag !== undefined) tagUsage(to, tag)
503
+ }
504
+ // Owner and group use are journaled with the call itself: a Bot or room that
505
+ // is deleted before the first scan still leaves its calls under its own name.
506
+ const journaled = new Map()
507
+ const journal = (book, sessionId) => {
508
+ if (typeof sessionId !== 'string' || sessionId === '') return
509
+ const owner = liveOwners(listed, book.owners()).get(sessionId)
510
+ if (owner !== undefined && owner.name !== '') {
511
+ const stamp = `${owner.owner}\n${owner.name}`
512
+ if (journaled.get(sessionId) !== stamp) {
513
+ journaled.set(sessionId, stamp)
514
+ book.setOwner(sessionId, owner)
515
+ }
516
+ }
517
+ if (inGroup().has(sessionId) && journaled.get(`${sessionId}\ngroup`) === undefined) {
518
+ journaled.set(`${sessionId}\ngroup`, true)
519
+ book.setKind(sessionId, 'group')
520
+ }
521
+ }
522
+ // Plain Session requests skip the meter, so every caller notes its Session
523
+ // here. The journal needs the team state and the ledger; both load behind
524
+ // the request, and `journaled` keeps the repeat calls from writing again.
525
+ const noteSession = (sessionId) => {
526
+ if (typeof sessionId !== 'string' || sessionId === '') return
527
+ void (async () => {
528
+ await rt.load()
529
+ const book = ledger ?? await openLedger().catch(() => undefined)
530
+ if (book === undefined) return
531
+ try { journal(book, sessionId) } catch (error) { rt.warn('ds-bot: usage could not journal %s: %s', sessionId, error) }
532
+ })()
533
+ }
534
+ function record({ sessionId, kind, provider, model, usage }) {
535
+ noteSession(sessionId)
536
+ const row = { key: `live:${randomUUID()}`, session: sessionId ?? '', time: Date.now(), provider: provider ?? '', model: model ?? '', kind, ...tokensOf(usage) }
537
+ if (ledger !== undefined) {
538
+ try { ledger.add(row) } catch (error) { rt.warn('ds-bot: usage could not record a call: %s', error) }
539
+ return
540
+ }
541
+ early.push(row)
542
+ void openLedger().catch(() => {})
543
+ }
544
+
545
+ // A request listener that rewrites a request starts it over, so the same usage chunk
546
+ // can pass here twice; it counts once.
547
+ const counted = new WeakSet()
548
+ // A Session's own requests are in its log; every other request is metered here.
549
+ ctx.on('llm/stream', (options, next) => {
550
+ const tag = tags.get(options.messages)
551
+ if (tag === undefined && options.sessionId !== undefined && options.purpose === undefined) {
552
+ noteSession(options.sessionId)
553
+ return next()
554
+ }
555
+ return (async function* meter() {
556
+ let usage
557
+ try {
558
+ for await (const chunk of next()) {
559
+ if (chunk?.type === 'usage' && chunk.usage && typeof chunk === 'object' && !counted.has(chunk)) {
560
+ counted.add(chunk)
561
+ usage = chunk.usage
562
+ }
563
+ yield chunk
564
+ }
565
+ } finally {
566
+ if (usage !== undefined) record({ sessionId: tag?.sessionId ?? options.sessionId, kind: tag?.kind ?? options.purpose ?? 'other', provider: options.provider, model: options.model, usage })
567
+ }
568
+ })()
569
+ })
570
+
571
+ Object.assign(rt, { usageView, tagUsage, carryUsageTag })
572
+ }