k0-board 0.4.0 → 0.5.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.
Files changed (73) hide show
  1. package/.claude/skills/k0-changelog/SKILL.md +2 -2
  2. package/.claude/skills/k0-discuss/SKILL.md +105 -0
  3. package/.claude/skills/k0-discuss/references/rounds.md +280 -0
  4. package/.claude/skills/k0-epic/SKILL.md +196 -0
  5. package/.claude/skills/k0-links/.claude-plugin/plugin.json +6 -0
  6. package/.claude/skills/k0-links/hooks/hooks.json +1 -0
  7. package/.claude/skills/k0-links/hooks/links.js +128 -0
  8. package/.claude/skills/k0-links/hooks/register.js +204 -0
  9. package/.claude/skills/k0-next/SKILL.md +82 -0
  10. package/.claude/skills/k0-order/SKILL.md +123 -0
  11. package/.claude/skills/k0-plan/SKILL.md +156 -0
  12. package/.claude/skills/k0-split/SKILL.md +169 -0
  13. package/.claude/skills/k0-story/SKILL.md +108 -0
  14. package/.claude/skills/k0-ultracode/SKILL.md +251 -0
  15. package/.claude/skills/k0-ultracode/references/agent-brief.md +71 -0
  16. package/.claude/skills/k0-ultracode/references/modes.md +49 -0
  17. package/.claude/skills/k0-verify/SKILL.md +219 -0
  18. package/.claude/skills/k0-whatsnew/SKILL.md +95 -0
  19. package/.claude/skills/k0-work/SKILL.md +212 -0
  20. package/CHANGELOG.md +361 -1
  21. package/README.md +931 -111
  22. package/bin/k0-board.js +79 -11
  23. package/menubar/K0MenuBar.swift +35 -5
  24. package/package.json +13 -1
  25. package/platform/contract.js +30 -5
  26. package/platform/darwin/terminal.js +230 -56
  27. package/platform/index.js +2 -1
  28. package/platform/linux/index.js +3 -0
  29. package/platform/linux/tray.py +10 -10
  30. package/platform/shared/command.js +19 -3
  31. package/platform/shared/tmux.js +32 -14
  32. package/platform/win32/index.js +5 -2
  33. package/platform/win32/terminal.js +24 -8
  34. package/platform/win32/tray.ps1 +13 -13
  35. package/server/backlog.js +618 -0
  36. package/server/changelog.js +13 -13
  37. package/server/db.js +1800 -146
  38. package/server/files.js +60 -21
  39. package/server/git.js +53 -14
  40. package/server/idle.js +15 -13
  41. package/server/index.js +1251 -186
  42. package/server/launcher.js +122 -14
  43. package/server/machine.js +10 -1
  44. package/server/mirror.js +1048 -0
  45. package/server/prices.js +59 -0
  46. package/server/projects.js +57 -12
  47. package/server/servers.js +22 -1
  48. package/server/sessions.js +5 -5
  49. package/server/settings.js +25 -2
  50. package/server/update.js +324 -0
  51. package/server/usage.js +642 -0
  52. package/server/watcher.js +67 -13
  53. package/server/worktree.js +600 -0
  54. package/server/writer.js +98 -26
  55. package/web/awake.js +62 -0
  56. package/web/base.css +4 -0
  57. package/web/board.css +396 -16
  58. package/web/board.js +1182 -152
  59. package/web/changelog.js +3 -3
  60. package/web/files.js +73 -16
  61. package/web/index.html +109 -12
  62. package/web/list.css +536 -0
  63. package/web/list.js +1015 -0
  64. package/web/mentions.js +27 -9
  65. package/web/recency.js +10 -10
  66. package/web/units.js +1 -1
  67. package/web/usage.css +169 -0
  68. package/web/usage.html +78 -0
  69. package/web/usage.js +447 -0
  70. package/web/view.js +22 -3
  71. package/web/whatsnew.css +211 -0
  72. package/web/whatsnew.html +49 -0
  73. package/web/whatsnew.js +339 -0
@@ -0,0 +1,642 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import * as db from './db.js'
5
+ import { PROJECTS_DIR } from './watcher.js'
6
+ import { repoOf } from './sessions.js'
7
+ import { HOME } from './paths.js'
8
+ import { cost as priceOf, modelId } from './prices.js'
9
+
10
+ /**
11
+ * What the sessions consumed: tokens, the model and effort they ran on, the time Claude spent
12
+ * working, the moments a usage limit stopped them, and — where the statusline has been writing
13
+ * it down — how much of each account's limits went.
14
+ *
15
+ * Nothing here asks anybody anything. Claude Code already writes all of it into the transcripts
16
+ * under ~/.claude/projects, one line per reply with its `usage`; this reads those files as they
17
+ * grow, the way `watcher.js` does for the statuses, and adds it up. No model is called and no
18
+ * socket is opened: it costs no tokens to know what the tokens cost.
19
+ *
20
+ * Two things the transcripts do not say, and where they come from instead:
21
+ *
22
+ * - which account was signed in. Only ~/.claude.json knows, and only for now; so k0 writes down
23
+ * every change it sees, and the statusline writes down the account of the session it is
24
+ * drawing. Anything that happened before either was watching stays "unknown", on purpose.
25
+ * - how much of the five-hour and weekly limits is used. Claude Code hands that to the
26
+ * statusline and keeps it nowhere; the statusline appends it to LIMITS_FILE. See README.
27
+ */
28
+
29
+ export const LIMITS_FILE = path.join(HOME, 'usage', 'limits.jsonl')
30
+ const CLAUDE_JSON = () => path.join(os.homedir(), '.claude.json')
31
+
32
+ const HOUR = 3_600_000
33
+ const DAY = 86_400_000
34
+
35
+ /** How much is read per step. A step blocks the server while it parses, so it stays small. */
36
+ const STEP = 8 << 20
37
+ /** A line bigger than a step is still a line: the read grows until it has one whole. */
38
+ const MAX_LINE = 256 << 20
39
+ /** How many request ids a file remembers, to count each reply once. */
40
+ const RECENT = 64
41
+ /** A statusline sample this close to a request says whose request it was. */
42
+ const NEAR = 30 * 60_000
43
+
44
+ // ── Whose account ────────────────────────────────────────────────────────────
45
+ let sightings = null
46
+ let bySession = null
47
+
48
+ function loadSightings() {
49
+ sightings = db.accountSightings()
50
+ bySession = new Map()
51
+ for (const s of sightings) {
52
+ if (!s.session_id) continue
53
+ if (!bySession.has(s.session_id)) bySession.set(s.session_id, [])
54
+ bySession.get(s.session_id).push(s)
55
+ }
56
+ }
57
+
58
+ /** Kept in time order: the statusline's file can hand over samples older than the last sighting. */
59
+ function sighted(s) {
60
+ let i = sightings.length
61
+ while (i > 0 && sightings[i - 1].at > s.at) i--
62
+ sightings.splice(i, 0, s)
63
+ if (!s.session_id) return
64
+ if (!bySession.has(s.session_id)) bySession.set(s.session_id, [])
65
+ bySession.get(s.session_id).push(s)
66
+ }
67
+
68
+ /**
69
+ * The account a request at `at` in `sessionId` was made from, or '' when nobody was watching.
70
+ *
71
+ * The session's own statusline samples come first: they were written by that session, so they
72
+ * cannot be somebody else's. Otherwise, the last account seen signed in before the request. There
73
+ * is deliberately no guess backwards: a request older than the first sighting is unknown.
74
+ */
75
+ export function accountAt(sessionId, at) {
76
+ if (!sightings) loadSightings()
77
+ let best = null
78
+ for (const s of bySession.get(sessionId) ?? []) {
79
+ const d = Math.abs(s.at - at)
80
+ if (d <= NEAR && (!best || d < Math.abs(best.at - at))) best = s
81
+ }
82
+ if (best) return best.account_uuid
83
+ let last = ''
84
+ for (const s of sightings) {
85
+ if (s.at > at) break
86
+ last = s.account_uuid
87
+ }
88
+ return last
89
+ }
90
+
91
+ function accountOf(raw) {
92
+ const a = raw?.oauthAccount ?? raw
93
+ if (!a?.accountUuid && !a?.uuid) return null
94
+ return {
95
+ uuid: a.accountUuid ?? a.uuid,
96
+ email: a.emailAddress ?? a.email ?? null,
97
+ org_type: a.organizationType ?? a.org_type ?? null,
98
+ tier: a.organizationRateLimitTier ?? a.tier ?? null,
99
+ }
100
+ }
101
+
102
+ let claudeJsonMtime = 0
103
+ let signedIn = null
104
+
105
+ /** Writes down the account ~/.claude.json says is signed in, when it is not the last one written. */
106
+ export function observeAccount(file = CLAUDE_JSON(), at = Date.now()) {
107
+ let st
108
+ try {
109
+ st = fs.statSync(file)
110
+ } catch {
111
+ return null
112
+ }
113
+ if (st.mtimeMs === claudeJsonMtime) return signedIn
114
+ claudeJsonMtime = st.mtimeMs
115
+ let account = null
116
+ try {
117
+ account = accountOf(JSON.parse(fs.readFileSync(file, 'utf8')))
118
+ } catch {
119
+ return signedIn // half-written by Claude Code: the next look will have it whole
120
+ }
121
+ if (!account) return signedIn
122
+ if (!sightings) loadSightings()
123
+ db.seeAccount(account, at)
124
+ const last = sightings.filter((s) => !s.session_id).at(-1)
125
+ if (last?.account_uuid !== account.uuid) {
126
+ const row = { at, account_uuid: account.uuid, session_id: '' }
127
+ db.addLimit(row)
128
+ sighted(row)
129
+ }
130
+ signedIn = account.uuid
131
+ return signedIn
132
+ }
133
+
134
+ // ── Reading files as they grow ───────────────────────────────────────────────
135
+ /**
136
+ * The bytes from `from` up to the last whole line, reading no more than `want` unless one line
137
+ * alone is longer than that. Returns the text and where the next read starts.
138
+ */
139
+ function readWhole(file, from, size, want) {
140
+ let len = Math.min(size - from, want)
141
+ let fd
142
+ try {
143
+ fd = fs.openSync(file, 'r')
144
+ } catch {
145
+ return null
146
+ }
147
+ try {
148
+ for (;;) {
149
+ const buf = Buffer.alloc(len)
150
+ const n = fs.readSync(fd, buf, 0, len, from)
151
+ const cut = buf.subarray(0, n).lastIndexOf(0x0a)
152
+ if (cut >= 0) return { text: buf.subarray(0, cut).toString('utf8'), next: from + cut + 1 }
153
+ // No newline yet: either Claude Code is still writing this line, or it is a long one.
154
+ if (from + n >= size || len >= MAX_LINE) return { text: '', next: from }
155
+ len = Math.min(size - from, len * 4)
156
+ }
157
+ } finally {
158
+ fs.closeSync(fd)
159
+ }
160
+ }
161
+
162
+ /** Every transcript, the subagents' and the workflows' included, with who wrote it. */
163
+ function transcripts(root) {
164
+ const out = []
165
+ let projects = []
166
+ try {
167
+ projects = fs.readdirSync(root, { withFileTypes: true })
168
+ } catch {
169
+ return out
170
+ }
171
+ for (const p of projects) {
172
+ if (!p.isDirectory()) continue
173
+ const dir = path.join(root, p.name)
174
+ let entries = []
175
+ try {
176
+ entries = fs.readdirSync(dir, { withFileTypes: true })
177
+ } catch {
178
+ continue
179
+ }
180
+ for (const e of entries) {
181
+ if (e.isFile() && e.name.endsWith('.jsonl')) out.push({ file: path.join(dir, e.name), agent: 'main' })
182
+ if (e.isDirectory()) agentFiles(path.join(dir, e.name, 'subagents'), out, 0)
183
+ }
184
+ }
185
+ return out
186
+ }
187
+
188
+ /**
189
+ * A session's agents, however deep. A plain subagent sits in `subagents/` itself; a workflow's
190
+ * agents sit two levels further down, in `subagents/workflows/<run>/` — and those are exactly the
191
+ * ones worth counting, since a workflow is where a lot of agents run at once.
192
+ */
193
+ function agentFiles(dir, out, depth) {
194
+ let entries = []
195
+ try {
196
+ entries = fs.readdirSync(dir, { withFileTypes: true })
197
+ } catch {
198
+ return
199
+ }
200
+ for (const e of entries) {
201
+ const file = path.join(dir, e.name)
202
+ if (e.isFile() && e.name.endsWith('.jsonl')) out.push({ file, agent: null })
203
+ else if (e.isDirectory() && depth < 3) agentFiles(file, out, depth + 1)
204
+ }
205
+ }
206
+
207
+ /** A subagent file is a workflow's when it sits in a workflow's folder or its `.meta.json` says so. */
208
+ function agentKind(file) {
209
+ if (file.includes(`${path.sep}subagents${path.sep}workflows${path.sep}`)) return 'workflow'
210
+ try {
211
+ const meta = JSON.parse(fs.readFileSync(file.replace(/\.jsonl$/, '.meta.json'), 'utf8'))
212
+ return meta.agentType === 'workflow-subagent' ? 'workflow' : 'sub'
213
+ } catch {
214
+ return 'sub'
215
+ }
216
+ }
217
+
218
+ /** Adds one parsed transcript line to what this scan has gathered. */
219
+ function take(o, ctx, acc) {
220
+ const sid = o.sessionId || o.session_id || ctx.session_id
221
+ if (!sid) return
222
+ ctx.session_id ??= sid
223
+ const at = Date.parse(o.timestamp)
224
+ const session = acc.session(sid)
225
+ if (!session.cwd && o.cwd) session.cwd = o.cwd
226
+ if (o.type === 'custom-title' && o.customTitle) session.custom = o.customTitle
227
+ if (o.type === 'ai-title' && o.aiTitle) session.ai = o.aiTitle
228
+ if (!Number.isFinite(at)) return
229
+
230
+ if (o.isApiErrorMessage && o.error === 'rate_limit') {
231
+ acc.events.push({ session_id: sid, at, kind: 'limit' })
232
+ return
233
+ }
234
+ if (o.type === 'system' && o.subtype === 'turn_duration' && ctx.agent === 'main') {
235
+ acc.events.push({ session_id: sid, at, kind: 'work', ms: Math.max(0, Math.round(o.durationMs || 0)) })
236
+ session.first = Math.min(session.first ?? at, at)
237
+ session.last = Math.max(session.last ?? at, at)
238
+ return
239
+ }
240
+ const u = o.message?.usage
241
+ if (o.type !== 'assistant' || !o.requestId || !u) return
242
+ const model = modelId(o.message.model)
243
+ if (!model || model === '<synthetic>') return
244
+ if (ctx.recent.includes(o.requestId)) return
245
+ ctx.recent.push(o.requestId)
246
+ if (ctx.recent.length > RECENT) ctx.recent.shift()
247
+
248
+ const cc = u.cache_creation
249
+ const tokens = {
250
+ input: u.input_tokens || 0,
251
+ output: u.output_tokens || 0,
252
+ cacheRead: u.cache_read_input_tokens || 0,
253
+ write5m: cc ? cc.ephemeral_5m_input_tokens || 0 : u.cache_creation_input_tokens || 0,
254
+ write1h: cc ? cc.ephemeral_1h_input_tokens || 0 : 0,
255
+ }
256
+ const speed = u.speed || 'standard'
257
+ const row = acc.hour({
258
+ session_id: sid,
259
+ hour: Math.floor(at / HOUR) * HOUR,
260
+ model,
261
+ effort: o.effort || '',
262
+ speed,
263
+ agent: ctx.agent,
264
+ account_uuid: accountAt(sid, at),
265
+ })
266
+ const dollars = priceOf(model, tokens, speed)
267
+ row.requests++
268
+ row.input += tokens.input
269
+ row.output += tokens.output
270
+ row.thinking += u.output_tokens_details?.thinking_tokens || 0
271
+ row.cache_read += tokens.cacheRead
272
+ row.write_5m += tokens.write5m
273
+ row.write_1h += tokens.write1h
274
+ row.web_search += u.server_tool_use?.web_search_requests || 0
275
+ row.cost = dollars == null ? null : (row.cost ?? 0) + dollars
276
+ session.first = Math.min(session.first ?? at, at)
277
+ session.last = Math.max(session.last ?? at, at)
278
+ }
279
+
280
+ function gathering() {
281
+ const hours = new Map()
282
+ const sessions = new Map()
283
+ return {
284
+ events: [],
285
+ hours,
286
+ sessions,
287
+ hour(key) {
288
+ const k = [key.session_id, key.hour, key.model, key.effort, key.speed, key.agent, key.account_uuid].join('\u0000')
289
+ let row = hours.get(k)
290
+ if (!row) {
291
+ row = { ...key, requests: 0, input: 0, output: 0, thinking: 0, cache_read: 0, write_5m: 0, write_1h: 0, web_search: 0, cost: 0 }
292
+ hours.set(k, row)
293
+ }
294
+ return row
295
+ },
296
+ session(id) {
297
+ let s = sessions.get(id)
298
+ if (!s) sessions.set(id, (s = { session_id: id }))
299
+ return s
300
+ },
301
+ }
302
+ }
303
+
304
+ /** The statusline's samples, read the same way the transcripts are: only what is new. */
305
+ function readLimits(file, known) {
306
+ let size
307
+ try {
308
+ size = fs.statSync(file).size
309
+ } catch {
310
+ return 0
311
+ }
312
+ const state = known.get(file) ?? { offset: 0 }
313
+ if (size < state.offset) state.offset = 0 // rotated by hand: a sample is only ever added once anyway
314
+ if (size === state.offset) return 0
315
+ const got = readWhole(file, state.offset, size, size - state.offset)
316
+ if (!got) return 0
317
+ if (!sightings) loadSightings()
318
+ for (const line of got.text.split('\n')) {
319
+ let o
320
+ try {
321
+ o = JSON.parse(line)
322
+ } catch {
323
+ continue
324
+ }
325
+ const account = accountOf(o.account)
326
+ const at = Number(o.at)
327
+ if (!account || !Number.isFinite(at)) continue
328
+ db.seeAccount(account, at)
329
+ const row = {
330
+ at,
331
+ account_uuid: account.uuid,
332
+ session_id: o.session_id || '',
333
+ five_pct: o.five_hour?.pct ?? null,
334
+ five_resets: o.five_hour?.resets_at ?? null,
335
+ week_pct: o.seven_day?.pct ?? null,
336
+ week_resets: o.seven_day?.resets_at ?? null,
337
+ }
338
+ db.addLimit(row)
339
+ sighted(row)
340
+ }
341
+ db.saveUsageFile({ path: file, offset: got.next, size, agent: 'limits' })
342
+ return got.next - state.offset
343
+ }
344
+
345
+ let backlog = { files: 0, bytes: 0 }
346
+
347
+ /**
348
+ * One step: whatever has been added to the transcripts since the last one, up to `budget` bytes.
349
+ * Returns how much it read and how much is still waiting, so the caller can come back sooner
350
+ * while there is history to catch up on.
351
+ *
352
+ * A transcript that got shorter is not read again from the start: every reply in it has already
353
+ * been counted, and reading it twice would count it twice. It is taken as it is now.
354
+ */
355
+ export function scan({ root = PROJECTS_DIR, limits = LIMITS_FILE, claudeJson = CLAUDE_JSON(), budget = STEP } = {}) {
356
+ observeAccount(claudeJson)
357
+ const known = db.usageFiles()
358
+ let read = readLimits(limits, known)
359
+ const acc = gathering()
360
+ const touched = []
361
+ let waiting = 0
362
+ let waitingFiles = 0
363
+
364
+ // The least recently read first would be fairer, but the newest first is what matters: a live
365
+ // session should show up on the page within the minute even while last year is being caught up.
366
+ const files = transcripts(root)
367
+ .map((t) => {
368
+ try {
369
+ const st = fs.statSync(t.file)
370
+ return { ...t, size: st.size, mtime: st.mtimeMs }
371
+ } catch {
372
+ return null
373
+ }
374
+ })
375
+ .filter(Boolean)
376
+ .sort((a, b) => b.mtime - a.mtime)
377
+
378
+ for (const t of files) {
379
+ const state = known.get(t.file)
380
+ const offset = state?.offset ?? 0
381
+ if (t.size === offset) continue
382
+ if (t.size < offset) {
383
+ db.saveUsageFile({ ...state, path: t.file, offset: t.size, size: t.size })
384
+ continue
385
+ }
386
+ if (read >= budget) {
387
+ waiting += t.size - offset
388
+ waitingFiles++
389
+ continue
390
+ }
391
+ const got = readWhole(t.file, offset, t.size, budget - read)
392
+ if (!got || got.next === offset) continue
393
+ const ctx = {
394
+ session_id: state?.session_id ?? null,
395
+ agent: state?.agent ?? t.agent ?? agentKind(t.file),
396
+ recent: state?.recent ?? [],
397
+ }
398
+ for (const line of got.text.split('\n')) {
399
+ // Most lines are tool output and prompts. Only the ones that can matter are parsed.
400
+ if (!line.includes('"usage"') && !line.includes('turn_duration') && !line.includes('title"') &&
401
+ !line.includes('"rate_limit"')) continue
402
+ let o
403
+ try {
404
+ o = JSON.parse(line)
405
+ } catch {
406
+ continue
407
+ }
408
+ take(o, ctx, acc)
409
+ }
410
+ read += got.next - offset
411
+ touched.push({ path: t.file, offset: got.next, size: t.size, session_id: ctx.session_id, agent: ctx.agent, recent: ctx.recent })
412
+ if (got.next < t.size) {
413
+ waiting += t.size - got.next
414
+ waitingFiles++
415
+ }
416
+ }
417
+
418
+ const repos = new Map()
419
+ db.addUsageHours([...acc.hours.values()])
420
+ db.addUsageEvents(acc.events)
421
+ for (const s of acc.sessions.values()) {
422
+ db.touchUsageSession({
423
+ session_id: s.session_id,
424
+ cwd: s.cwd ?? null,
425
+ project_path: s.cwd ? repoOf(s.cwd, repos) : null,
426
+ first_at: s.first ?? null,
427
+ last_at: s.last ?? null,
428
+ custom: s.custom ?? null,
429
+ ai: s.ai ?? null,
430
+ })
431
+ }
432
+ // Last, so a crash in the middle reads the same bytes again rather than skipping them.
433
+ for (const f of touched) db.saveUsageFile(f)
434
+
435
+ backlog = { files: waitingFiles, bytes: waiting }
436
+ return { read, waiting, files: files.length }
437
+ }
438
+
439
+ // ── Running by itself ────────────────────────────────────────────────────────
440
+ let timer = null
441
+
442
+ /**
443
+ * A step now, and the next one soon while there is history left to read, once a minute after.
444
+ * `unref` like every other sampler here: this must never be what keeps the server alive.
445
+ */
446
+ export function start() {
447
+ if (timer) return
448
+ const step = () => {
449
+ let wait = 60_000
450
+ try {
451
+ if (scan().waiting > 0) wait = 500
452
+ } catch (err) {
453
+ console.error(`k0 — reading what the sessions consumed stumbled: ${String(err?.message || err)}`)
454
+ }
455
+ timer = setTimeout(step, wait)
456
+ timer.unref()
457
+ }
458
+ timer = setTimeout(step, 1000)
459
+ timer.unref()
460
+ }
461
+
462
+ export const catchingUp = () => backlog
463
+
464
+ // ── The page ─────────────────────────────────────────────────────────────────
465
+ const tokensOf = (r) => r.input + r.output + r.cache_read + r.write_5m + r.write_1h
466
+
467
+ function bucket() {
468
+ return { requests: 0, input: 0, output: 0, thinking: 0, cache_read: 0, write: 0, tokens: 0, cost: 0, unpriced: 0 }
469
+ }
470
+
471
+ function add(b, r) {
472
+ b.requests += r.requests
473
+ b.input += r.input
474
+ b.output += r.output
475
+ b.thinking += r.thinking
476
+ b.cache_read += r.cache_read
477
+ b.write += r.write_5m + r.write_1h
478
+ b.tokens += tokensOf(r)
479
+ if (r.cost == null) b.unpriced += tokensOf(r)
480
+ else b.cost += r.cost
481
+ return b
482
+ }
483
+
484
+ function grouped(rows, keyOf, extra = () => ({})) {
485
+ const out = new Map()
486
+ for (const r of rows) {
487
+ const k = keyOf(r)
488
+ if (!out.has(k)) out.set(k, { ...extra(r), ...bucket() })
489
+ add(out.get(k), r)
490
+ }
491
+ return [...out.values()].sort((a, b) => b.cost - a.cost || b.tokens - a.tokens)
492
+ }
493
+
494
+ /**
495
+ * How much of the weekly limit each session is likely to have used.
496
+ *
497
+ * The statusline says how full the week is, not who filled it. So every rise in an account's
498
+ * weekly percentage is shared among the sessions that account ran in the same hour, in proportion
499
+ * to what each would have cost at the API price. It is an estimate and the page calls it one: two
500
+ * sessions running side by side in the same hour cannot be told apart any better than that.
501
+ */
502
+ function weekShares(samples, hours) {
503
+ const rises = new Map() // account + hour → percentage points
504
+ const byAccount = new Map()
505
+ for (const s of samples) {
506
+ if (!byAccount.has(s.account_uuid)) byAccount.set(s.account_uuid, [])
507
+ byAccount.get(s.account_uuid).push(s)
508
+ }
509
+ for (const [account, list] of byAccount) {
510
+ for (let i = 1; i < list.length; i++) {
511
+ const a = list[i - 1]
512
+ const b = list[i]
513
+ // A new window starts from nothing, so all of its percentage is new.
514
+ const rise = b.week_resets !== a.week_resets ? b.week_pct : b.week_pct - a.week_pct
515
+ if (!(rise > 0)) continue
516
+ const k = `${account}\u0000${Math.floor(b.at / HOUR) * HOUR}`
517
+ rises.set(k, (rises.get(k) ?? 0) + rise)
518
+ }
519
+ }
520
+ const weights = new Map()
521
+ for (const h of hours) {
522
+ const k = `${h.account_uuid}\u0000${h.hour}`
523
+ if (!rises.has(k)) continue
524
+ if (!weights.has(k)) weights.set(k, new Map())
525
+ const w = weights.get(k)
526
+ w.set(h.session_id, (w.get(h.session_id) ?? 0) + (h.cost ?? 0))
527
+ }
528
+ const share = new Map()
529
+ for (const [k, rise] of rises) {
530
+ const w = weights.get(k)
531
+ const total = w ? [...w.values()].reduce((x, y) => x + y, 0) : 0
532
+ if (!total) continue
533
+ for (const [sid, v] of w) share.set(sid, (share.get(sid) ?? 0) + (rise * v) / total)
534
+ }
535
+ return share
536
+ }
537
+
538
+ const nameOf = (p) => (p ? path.basename(p) : null)
539
+
540
+ /**
541
+ * Everything the Usage page shows for one span of time. `account` narrows it to one account;
542
+ * '' is the requests nobody could put a name to.
543
+ */
544
+ export function report({ from, to, account = null }) {
545
+ const accounts = db.usageAccounts()
546
+ const emailOf = new Map(accounts.map((a) => [a.uuid, a.email]))
547
+ const all = db.usageHours(from, to)
548
+ const hours = account == null ? all : all.filter((h) => h.account_uuid === account)
549
+ const sessionsById = new Map(db.usageSessions(from, to).map((s) => [s.session_id, s]))
550
+ const ids = new Set(hours.map((h) => h.session_id))
551
+ const events = db.usageEvents(from, to).filter((e) => ids.has(e.session_id))
552
+ const samples = db.usageLimits(from, to)
553
+ const shares = weekShares(samples.filter((s) => s.at >= from - DAY), all)
554
+
555
+ const totals = hours.reduce(add, bucket())
556
+ totals.sessions = ids.size
557
+ totals.work_ms = events.filter((e) => e.kind === 'work').reduce((x, e) => x + e.ms, 0)
558
+ totals.limits = events.filter((e) => e.kind === 'limit').length
559
+
560
+ const days = []
561
+ for (let at = from; at < to; at += DAY) days.push({ at, ...bucket(), models: {} })
562
+ for (const h of hours) {
563
+ const d = days[Math.floor((h.hour - from) / DAY)]
564
+ if (!d) continue
565
+ add(d, h)
566
+ d.models[h.model] = (d.models[h.model] ?? 0) + (h.cost ?? 0)
567
+ }
568
+
569
+ const perSession = new Map()
570
+ for (const h of hours) {
571
+ let s = perSession.get(h.session_id)
572
+ if (!s) {
573
+ const meta = sessionsById.get(h.session_id) ?? {}
574
+ s = {
575
+ session_id: h.session_id,
576
+ title: meta.story_title || meta.title || null,
577
+ project: nameOf(meta.project_path),
578
+ story: meta.story_id ? { id: meta.story_id, key: meta.story_key, title: meta.story_title } : null,
579
+ first_at: meta.first_at ?? h.hour,
580
+ last_at: meta.last_at ?? h.hour,
581
+ models: {},
582
+ agents: {},
583
+ accounts: new Set(),
584
+ work_ms: 0,
585
+ limits: 0,
586
+ week_pct: shares.get(h.session_id) ?? null,
587
+ ...bucket(),
588
+ }
589
+ perSession.set(h.session_id, s)
590
+ }
591
+ add(s, h)
592
+ const m = h.effort ? `${h.model} · ${h.effort}${h.speed === 'fast' ? ' · fast' : ''}` : h.model
593
+ s.models[m] = (s.models[m] ?? 0) + tokensOf(h)
594
+ s.agents[h.agent] = (s.agents[h.agent] ?? 0) + (h.cost ?? 0)
595
+ s.accounts.add(emailOf.get(h.account_uuid) ?? (h.account_uuid ? h.account_uuid.slice(0, 8) : 'unknown'))
596
+ }
597
+ for (const e of events) {
598
+ const s = perSession.get(e.session_id)
599
+ if (!s) continue
600
+ if (e.kind === 'work') s.work_ms += e.ms
601
+ if (e.kind === 'limit') s.limits++
602
+ }
603
+ const sessions = [...perSession.values()]
604
+ .map((s) => ({ ...s, accounts: [...s.accounts] }))
605
+ .sort((a, b) => b.cost - a.cost || b.tokens - a.tokens)
606
+
607
+ const limitSeries = new Map()
608
+ for (const s of samples) {
609
+ if (account != null && s.account_uuid !== account) continue
610
+ if (!limitSeries.has(s.account_uuid)) {
611
+ limitSeries.set(s.account_uuid, { account: s.account_uuid, email: emailOf.get(s.account_uuid) ?? null, samples: [] })
612
+ }
613
+ limitSeries.get(s.account_uuid).samples.push({ at: s.at, five: s.five_pct, week: s.week_pct, week_resets: s.week_resets })
614
+ }
615
+
616
+ let lastSample = null
617
+ try {
618
+ lastSample = fs.statSync(LIMITS_FILE).mtimeMs
619
+ } catch {
620
+ /* the statusline is not writing: the page says how to make it */
621
+ }
622
+
623
+ return {
624
+ from,
625
+ to,
626
+ account,
627
+ totals,
628
+ days,
629
+ models: grouped(hours, (h) => `${h.model}|${h.effort}|${h.speed}`, (h) => ({ model: h.model, effort: h.effort, speed: h.speed })),
630
+ agents: grouped(hours, (h) => h.agent, (h) => ({ agent: h.agent })),
631
+ repos: grouped(
632
+ hours,
633
+ (h) => sessionsById.get(h.session_id)?.project_path ?? '',
634
+ (h) => ({ project: nameOf(sessionsById.get(h.session_id)?.project_path) })
635
+ ),
636
+ accounts: grouped(all, (h) => h.account_uuid, (h) => ({ uuid: h.account_uuid, email: emailOf.get(h.account_uuid) ?? null })),
637
+ known: accounts,
638
+ limits: [...limitSeries.values()],
639
+ sessions,
640
+ tracking: { statusline: lastSample, reading: backlog },
641
+ }
642
+ }