@thewaifu/dsh-bot 1.1.1

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