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.
- package/.claude/skills/k0-changelog/SKILL.md +2 -2
- package/.claude/skills/k0-discuss/SKILL.md +105 -0
- package/.claude/skills/k0-discuss/references/rounds.md +280 -0
- package/.claude/skills/k0-epic/SKILL.md +196 -0
- package/.claude/skills/k0-links/.claude-plugin/plugin.json +6 -0
- package/.claude/skills/k0-links/hooks/hooks.json +1 -0
- package/.claude/skills/k0-links/hooks/links.js +128 -0
- package/.claude/skills/k0-links/hooks/register.js +204 -0
- package/.claude/skills/k0-next/SKILL.md +82 -0
- package/.claude/skills/k0-order/SKILL.md +123 -0
- package/.claude/skills/k0-plan/SKILL.md +156 -0
- package/.claude/skills/k0-split/SKILL.md +169 -0
- package/.claude/skills/k0-story/SKILL.md +108 -0
- package/.claude/skills/k0-ultracode/SKILL.md +251 -0
- package/.claude/skills/k0-ultracode/references/agent-brief.md +71 -0
- package/.claude/skills/k0-ultracode/references/modes.md +49 -0
- package/.claude/skills/k0-verify/SKILL.md +219 -0
- package/.claude/skills/k0-whatsnew/SKILL.md +95 -0
- package/.claude/skills/k0-work/SKILL.md +212 -0
- package/CHANGELOG.md +361 -1
- package/README.md +931 -111
- package/bin/k0-board.js +79 -11
- package/menubar/K0MenuBar.swift +35 -5
- package/package.json +13 -1
- package/platform/contract.js +30 -5
- package/platform/darwin/terminal.js +230 -56
- package/platform/index.js +2 -1
- package/platform/linux/index.js +3 -0
- package/platform/linux/tray.py +10 -10
- package/platform/shared/command.js +19 -3
- package/platform/shared/tmux.js +32 -14
- package/platform/win32/index.js +5 -2
- package/platform/win32/terminal.js +24 -8
- package/platform/win32/tray.ps1 +13 -13
- package/server/backlog.js +618 -0
- package/server/changelog.js +13 -13
- package/server/db.js +1800 -146
- package/server/files.js +60 -21
- package/server/git.js +53 -14
- package/server/idle.js +15 -13
- package/server/index.js +1251 -186
- package/server/launcher.js +122 -14
- package/server/machine.js +10 -1
- package/server/mirror.js +1048 -0
- package/server/prices.js +59 -0
- package/server/projects.js +57 -12
- package/server/servers.js +22 -1
- package/server/sessions.js +5 -5
- package/server/settings.js +25 -2
- package/server/update.js +324 -0
- package/server/usage.js +642 -0
- package/server/watcher.js +67 -13
- package/server/worktree.js +600 -0
- package/server/writer.js +98 -26
- package/web/awake.js +62 -0
- package/web/base.css +4 -0
- package/web/board.css +396 -16
- package/web/board.js +1182 -152
- package/web/changelog.js +3 -3
- package/web/files.js +73 -16
- package/web/index.html +109 -12
- package/web/list.css +536 -0
- package/web/list.js +1015 -0
- package/web/mentions.js +27 -9
- package/web/recency.js +10 -10
- package/web/units.js +1 -1
- package/web/usage.css +169 -0
- package/web/usage.html +78 -0
- package/web/usage.js +447 -0
- package/web/view.js +22 -3
- package/web/whatsnew.css +211 -0
- package/web/whatsnew.html +49 -0
- package/web/whatsnew.js +339 -0
package/server/usage.js
ADDED
|
@@ -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
|
+
}
|