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.
- package/LICENSE +201 -0
- package/README.md +234 -3
- package/client.js +12176 -0
- package/cordis.patch.yml +17 -0
- package/index.js +52 -0
- package/package.json +64 -6
- package/src/host/agents.js +186 -0
- package/src/host/browser.js +258 -0
- package/src/host/channel.js +313 -0
- package/src/host/chat-index.js +293 -0
- package/src/host/connectors.js +169 -0
- package/src/host/constants.js +62 -0
- package/src/host/delivery.js +59 -0
- package/src/host/diagnostics.js +247 -0
- package/src/host/groups.js +442 -0
- package/src/host/home-lock.js +91 -0
- package/src/host/images.js +174 -0
- package/src/host/memory.js +1351 -0
- package/src/host/models.js +145 -0
- package/src/host/parts.js +886 -0
- package/src/host/prompt.js +67 -0
- package/src/host/roster.js +397 -0
- package/src/host/schedules.js +151 -0
- package/src/host/secrets.js +530 -0
- package/src/host/steward.js +164 -0
- package/src/host/store.js +259 -0
- package/src/host/text.js +47 -0
- package/src/host/tools.js +374 -0
- package/src/host/turns.js +250 -0
- package/src/host/update.js +205 -0
- package/src/host/usage.js +572 -0
- package/web-search.js +748 -0
|
@@ -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
|
+
}
|