clanker-stats 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/cli.js +204 -32
- package/package.json +1 -1
package/cli.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { readFile, writeFile } from "node:fs/promises"
|
|
4
|
+
import { createReadStream } from "node:fs"
|
|
5
|
+
import { createInterface } from "node:readline"
|
|
4
6
|
import { homedir } from "node:os"
|
|
5
7
|
import { join } from "node:path"
|
|
6
8
|
import { execFileSync } from "node:child_process"
|
|
@@ -42,21 +44,23 @@ async function collectCodex() {
|
|
|
42
44
|
const counts = new Map()
|
|
43
45
|
const dir = join(home, ".codex", "sessions")
|
|
44
46
|
for (const path of await fg("**/*.jsonl", { cwd: dir })) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
47
|
+
try {
|
|
48
|
+
const rl = createInterface({ input: createReadStream(join(dir, path)), crlfDelay: Infinity })
|
|
49
|
+
let prevCumulative = null
|
|
50
|
+
for await (const line of rl) {
|
|
51
|
+
if (!line.includes('"token_count"')) continue
|
|
52
|
+
try {
|
|
53
|
+
const obj = JSON.parse(line)
|
|
54
|
+
if (obj.payload?.type !== "token_count") continue
|
|
55
|
+
const cumTotal = obj.payload.info?.total_token_usage?.total_tokens
|
|
56
|
+
if (cumTotal != null && cumTotal === prevCumulative) continue
|
|
57
|
+
let tokens = obj.payload.info?.last_token_usage?.total_tokens
|
|
58
|
+
if (!tokens && cumTotal != null && prevCumulative != null) tokens = cumTotal - prevCumulative
|
|
59
|
+
if (cumTotal != null) prevCumulative = cumTotal
|
|
60
|
+
if (tokens > 0 && obj.timestamp) add(counts, isoToDate(obj.timestamp), tokens)
|
|
61
|
+
} catch {}
|
|
62
|
+
}
|
|
63
|
+
} catch {}
|
|
60
64
|
}
|
|
61
65
|
return counts
|
|
62
66
|
}
|
|
@@ -139,13 +143,168 @@ async function collectPi() {
|
|
|
139
143
|
return counts
|
|
140
144
|
}
|
|
141
145
|
|
|
146
|
+
// --hours: collect hours per day from user→last assistant message time diffs
|
|
147
|
+
function addTurnHours(map, turnStart, turnEnd) {
|
|
148
|
+
if (!turnStart || !turnEnd || turnEnd <= turnStart) return
|
|
149
|
+
const hours = (turnEnd - turnStart) / 3_600_000
|
|
150
|
+
add(map, new Date(turnStart).toISOString().slice(0, 10), hours)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function collectTimeClaude() {
|
|
154
|
+
const counts = new Map()
|
|
155
|
+
const dir = join(home, ".claude", "projects")
|
|
156
|
+
for (const path of await fg("*/*.jsonl", { cwd: dir })) {
|
|
157
|
+
if (path.includes("/subagents/")) continue
|
|
158
|
+
const text = await readFile(join(dir, path), "utf-8")
|
|
159
|
+
let turnStart = null, turnEnd = null
|
|
160
|
+
for (const line of text.split("\n")) {
|
|
161
|
+
if (!line.includes('"timestamp"')) continue
|
|
162
|
+
try {
|
|
163
|
+
const obj = JSON.parse(line)
|
|
164
|
+
if (obj.isSidechain) continue
|
|
165
|
+
const ts = obj.timestamp ? new Date(obj.timestamp).getTime() : null
|
|
166
|
+
if (!ts) continue
|
|
167
|
+
if (obj.type === "user") {
|
|
168
|
+
addTurnHours(counts, turnStart, turnEnd)
|
|
169
|
+
turnStart = ts
|
|
170
|
+
turnEnd = null
|
|
171
|
+
} else if (obj.type === "assistant" && turnStart) {
|
|
172
|
+
turnEnd = ts
|
|
173
|
+
}
|
|
174
|
+
} catch {}
|
|
175
|
+
}
|
|
176
|
+
addTurnHours(counts, turnStart, turnEnd)
|
|
177
|
+
}
|
|
178
|
+
return counts
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function collectTimeCodex() {
|
|
182
|
+
const counts = new Map()
|
|
183
|
+
const dir = join(home, ".codex", "sessions")
|
|
184
|
+
for (const path of await fg("**/*.jsonl", { cwd: dir })) {
|
|
185
|
+
try {
|
|
186
|
+
const rl = createInterface({ input: createReadStream(join(dir, path)), crlfDelay: Infinity })
|
|
187
|
+
let turnStart = null, turnEnd = null
|
|
188
|
+
for await (const line of rl) {
|
|
189
|
+
if (!line.includes('"timestamp"')) continue
|
|
190
|
+
try {
|
|
191
|
+
const obj = JSON.parse(line)
|
|
192
|
+
const ts = obj.timestamp ? new Date(obj.timestamp).getTime() : null
|
|
193
|
+
if (!ts) continue
|
|
194
|
+
const pt = obj.payload?.type
|
|
195
|
+
if (pt === "user_message") {
|
|
196
|
+
addTurnHours(counts, turnStart, turnEnd)
|
|
197
|
+
turnStart = ts
|
|
198
|
+
turnEnd = null
|
|
199
|
+
} else if (obj.type === "response_item" && turnStart) {
|
|
200
|
+
turnEnd = ts
|
|
201
|
+
}
|
|
202
|
+
} catch {}
|
|
203
|
+
}
|
|
204
|
+
addTurnHours(counts, turnStart, turnEnd)
|
|
205
|
+
} catch {}
|
|
206
|
+
}
|
|
207
|
+
return counts
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function collectTimeOpenCode() {
|
|
211
|
+
const counts = new Map()
|
|
212
|
+
const dirs = [
|
|
213
|
+
join(home, ".local", "share", "opencode", "storage", "message"),
|
|
214
|
+
...(process.platform === "darwin" ? [join(home, "Library", "Application Support", "opencode", "storage", "message")] : []),
|
|
215
|
+
]
|
|
216
|
+
for (const dir of dirs) {
|
|
217
|
+
const sessions = new Map()
|
|
218
|
+
for (const path of await fg("ses_*/msg_*.json", { cwd: dir, suppressErrors: true })) {
|
|
219
|
+
try {
|
|
220
|
+
const obj = JSON.parse(await readFile(join(dir, path), "utf-8"))
|
|
221
|
+
const sid = obj.sessionID
|
|
222
|
+
if (!sid || !obj.time?.created) continue
|
|
223
|
+
if (!sessions.has(sid)) sessions.set(sid, [])
|
|
224
|
+
sessions.get(sid).push({ role: obj.role, ts: obj.time.created })
|
|
225
|
+
} catch {}
|
|
226
|
+
}
|
|
227
|
+
for (const msgs of sessions.values()) {
|
|
228
|
+
msgs.sort((a, b) => a.ts - b.ts)
|
|
229
|
+
let turnStart = null, turnEnd = null
|
|
230
|
+
for (const m of msgs) {
|
|
231
|
+
if (m.role === "user") {
|
|
232
|
+
addTurnHours(counts, turnStart, turnEnd)
|
|
233
|
+
turnStart = m.ts
|
|
234
|
+
turnEnd = null
|
|
235
|
+
} else if (m.role === "assistant" && turnStart) {
|
|
236
|
+
turnEnd = m.ts
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
addTurnHours(counts, turnStart, turnEnd)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return counts
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function collectTimeGemini() {
|
|
246
|
+
const counts = new Map()
|
|
247
|
+
const dir = join(home, ".gemini", "tmp")
|
|
248
|
+
for (const path of await fg("*/chats/*.json", { cwd: dir })) {
|
|
249
|
+
try {
|
|
250
|
+
const obj = JSON.parse(await readFile(join(dir, path), "utf-8"))
|
|
251
|
+
if (!obj.messages) continue
|
|
252
|
+
let turnStart = null, turnEnd = null
|
|
253
|
+
for (const msg of obj.messages) {
|
|
254
|
+
if (!msg.timestamp) continue
|
|
255
|
+
const ts = new Date(msg.timestamp).getTime()
|
|
256
|
+
if (msg.type === "user") {
|
|
257
|
+
addTurnHours(counts, turnStart, turnEnd)
|
|
258
|
+
turnStart = ts
|
|
259
|
+
turnEnd = null
|
|
260
|
+
} else if (msg.type === "gemini" && turnStart) {
|
|
261
|
+
turnEnd = ts
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
addTurnHours(counts, turnStart, turnEnd)
|
|
265
|
+
} catch {}
|
|
266
|
+
}
|
|
267
|
+
return counts
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function collectTimeAmp() {
|
|
271
|
+
return new Map() // no per-message timestamps on assistant messages
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function collectTimePi() {
|
|
275
|
+
const counts = new Map()
|
|
276
|
+
const dir = join(home, ".pi", "agent", "sessions")
|
|
277
|
+
for (const path of await fg("**/*.jsonl", { cwd: dir })) {
|
|
278
|
+
const text = await readFile(join(dir, path), "utf-8")
|
|
279
|
+
let turnStart = null, turnEnd = null
|
|
280
|
+
for (const line of text.split("\n")) {
|
|
281
|
+
if (!line.includes('"message"')) continue
|
|
282
|
+
try {
|
|
283
|
+
const obj = JSON.parse(line)
|
|
284
|
+
if (obj.type !== "message") continue
|
|
285
|
+
const ts = obj.timestamp ? new Date(obj.timestamp).getTime() : null
|
|
286
|
+
if (!ts) continue
|
|
287
|
+
if (obj.message?.role === "user") {
|
|
288
|
+
addTurnHours(counts, turnStart, turnEnd)
|
|
289
|
+
turnStart = ts
|
|
290
|
+
turnEnd = null
|
|
291
|
+
} else if (obj.message?.role === "assistant" && turnStart) {
|
|
292
|
+
turnEnd = ts
|
|
293
|
+
}
|
|
294
|
+
} catch {}
|
|
295
|
+
}
|
|
296
|
+
addTurnHours(counts, turnStart, turnEnd)
|
|
297
|
+
}
|
|
298
|
+
return counts
|
|
299
|
+
}
|
|
300
|
+
|
|
142
301
|
const tools = [
|
|
143
|
-
{ name: "Claude Code", collect: collectClaude, color: "#f97316" },
|
|
144
|
-
{ name: "Codex", collect: collectCodex, color: "#22c55e" },
|
|
145
|
-
{ name: "OpenCode", collect: collectOpenCode, color: "#3b82f6" },
|
|
146
|
-
{ name: "Gemini CLI", collect: collectGemini, color: "#eab308" },
|
|
147
|
-
{ name: "Amp", collect: collectAmp, color: "#a855f7" },
|
|
148
|
-
{ name: "Pi", collect: collectPi, color: "#ec4899" },
|
|
302
|
+
{ name: "Claude Code", collect: collectClaude, collectTime: collectTimeClaude, color: "#f97316" },
|
|
303
|
+
{ name: "Codex", collect: collectCodex, collectTime: collectTimeCodex, color: "#22c55e" },
|
|
304
|
+
{ name: "OpenCode", collect: collectOpenCode, collectTime: collectTimeOpenCode, color: "#3b82f6" },
|
|
305
|
+
{ name: "Gemini CLI", collect: collectGemini, collectTime: collectTimeGemini, color: "#eab308" },
|
|
306
|
+
{ name: "Amp", collect: collectAmp, collectTime: collectTimeAmp, color: "#a855f7" },
|
|
307
|
+
{ name: "Pi", collect: collectPi, collectTime: collectTimePi, color: "#ec4899" },
|
|
149
308
|
]
|
|
150
309
|
|
|
151
310
|
function catmullRomPath(points, tension = 0.3, yFloor) {
|
|
@@ -171,7 +330,14 @@ function formatTotal(n) {
|
|
|
171
330
|
return String(n)
|
|
172
331
|
}
|
|
173
332
|
|
|
174
|
-
function
|
|
333
|
+
function formatHours(h) {
|
|
334
|
+
if (h >= 100) return Math.round(h) + "h"
|
|
335
|
+
if (h >= 10) return h.toFixed(1).replace(/\.0$/, "") + "h"
|
|
336
|
+
if (h >= 1) return h.toFixed(1) + "h"
|
|
337
|
+
return Math.round(h * 60) + "m"
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function renderChart(allDays, results, total, { unit = "TOKENS", cmd = "npx clanker-stats --share", formatVal = formatTotal } = {}) {
|
|
175
341
|
const W = 1500, H = 560
|
|
176
342
|
const pad = { top: 130, right: 50, bottom: 60, left: 50 }
|
|
177
343
|
const chartW = W - pad.left - pad.right
|
|
@@ -230,7 +396,7 @@ function renderChart(allDays, results, total) {
|
|
|
230
396
|
const val = (maxY / gridCount) * i
|
|
231
397
|
const y = pad.top + chartH - (i / gridCount) * chartH
|
|
232
398
|
gridLines += `<line x1="${pad.left}" y1="${y}" x2="${pad.left + chartW}" y2="${y}" stroke="#21262d" stroke-width="1"/>\n`
|
|
233
|
-
gridLines += `<text x="${pad.left + chartW + 8}" y="${y + 4}" fill="#3b434b" font-family="${font}" font-size="11">${
|
|
399
|
+
gridLines += `<text x="${pad.left + chartW + 8}" y="${y + 4}" fill="#3b434b" font-family="${font}" font-size="11">${formatVal(val)}</text>\n`
|
|
234
400
|
}
|
|
235
401
|
|
|
236
402
|
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
|
@@ -267,7 +433,7 @@ function renderChart(allDays, results, total) {
|
|
|
267
433
|
const count = [...visible[i].counts.values()].reduce((a, b) => a + b, 0)
|
|
268
434
|
legend += `<rect x="${x}" y="${vcenter - 14}" width="14" height="14" rx="3" fill="${visible[i].color}" opacity="0.8"/>`
|
|
269
435
|
legend += `<text x="${x + 20}" y="${vcenter}" fill="#8b949e" font-family="${font}" font-size="18">${visible[i].name}</text>`
|
|
270
|
-
legend += `<text x="${x + 20}" y="${vcenter + 20}" fill="#8b949e" font-family="${font}" font-size="17">${
|
|
436
|
+
legend += `<text x="${x + 20}" y="${vcenter + 20}" fill="#8b949e" font-family="${font}" font-size="17">${formatVal(count)}</text>\n`
|
|
271
437
|
}
|
|
272
438
|
|
|
273
439
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
|
|
@@ -277,10 +443,10 @@ function renderChart(allDays, results, total) {
|
|
|
277
443
|
</clipPath>
|
|
278
444
|
</defs>
|
|
279
445
|
<rect width="${W}" height="${H}" fill="#0d1117"/>
|
|
280
|
-
<text x="${W - pad.right}" y="${vcenter + 6}" text-anchor="end" fill="#f0f6fc" font-family="${font}" font-size="48" font-weight="800">${
|
|
281
|
-
<text x="${W - pad.right}" y="${vcenter + 26}" text-anchor="end" fill="#484f58" font-family="${font}" font-size="16" letter-spacing="2" font-weight="600"
|
|
446
|
+
<text x="${W - pad.right}" y="${vcenter + 6}" text-anchor="end" fill="#f0f6fc" font-family="${font}" font-size="48" font-weight="800">${formatVal(total)}</text>
|
|
447
|
+
<text x="${W - pad.right}" y="${vcenter + 26}" text-anchor="end" fill="#484f58" font-family="${font}" font-size="16" letter-spacing="2" font-weight="600">${unit}</text>
|
|
282
448
|
<rect x="${midX - 170}" y="${vcenter - 20}" width="340" height="40" rx="8" fill="#161b22"/>
|
|
283
|
-
<text x="${midX}" y="${vcenter + 5}" text-anchor="middle" fill="#8b949e" font-family="${mono}" font-size="22"
|
|
449
|
+
<text x="${midX}" y="${vcenter + 5}" text-anchor="middle" fill="#8b949e" font-family="${mono}" font-size="22">${cmd}</text>
|
|
284
450
|
${legend}
|
|
285
451
|
${gridLines}
|
|
286
452
|
${areas}
|
|
@@ -299,14 +465,15 @@ function openPath(target) {
|
|
|
299
465
|
|
|
300
466
|
async function main() {
|
|
301
467
|
const share = process.argv.includes("--share")
|
|
468
|
+
const hours = process.argv.includes("--hours")
|
|
302
469
|
const results = []
|
|
303
470
|
|
|
304
471
|
for (const tool of tools) {
|
|
305
472
|
try {
|
|
306
|
-
const counts = await tool.collect()
|
|
473
|
+
const counts = hours ? await tool.collectTime() : await tool.collect()
|
|
307
474
|
results.push({ name: tool.name, color: tool.color, counts })
|
|
308
475
|
const t = [...counts.values()].reduce((a, b) => a + b, 0)
|
|
309
|
-
console.log(`${tool.name}: ${formatTotal(t)
|
|
476
|
+
console.log(`${tool.name}: ${hours ? formatHours(t) : formatTotal(t) + " tokens"}`)
|
|
310
477
|
} catch (e) {
|
|
311
478
|
console.warn(`${tool.name}: skipped (${e?.message || e})`)
|
|
312
479
|
results.push({ name: tool.name, color: tool.color, counts: new Map() })
|
|
@@ -330,9 +497,12 @@ async function main() {
|
|
|
330
497
|
}
|
|
331
498
|
|
|
332
499
|
const total = results.reduce((sum, r) => sum + [...r.counts.values()].reduce((a, b) => a + b, 0), 0)
|
|
333
|
-
|
|
500
|
+
const chartOpts = hours
|
|
501
|
+
? { unit: "HOURS", cmd: `npx clanker-stats --hours${share ? " --share" : ""}`, formatVal: formatHours }
|
|
502
|
+
: share ? {} : { cmd: "npx clanker-stats" }
|
|
503
|
+
console.log(`\n${allDays.length} days, ${hours ? formatHours(total) : formatTotal(total) + " total tokens"}`)
|
|
334
504
|
|
|
335
|
-
const svg = renderChart(allDays, results, total)
|
|
505
|
+
const svg = renderChart(allDays, results, total, chartOpts)
|
|
336
506
|
const resvg = new Resvg(svg, { fitTo: { mode: "width", value: 1500 } })
|
|
337
507
|
const png = resvg.render().asPng()
|
|
338
508
|
|
|
@@ -349,7 +519,9 @@ async function main() {
|
|
|
349
519
|
console.log("Copy the image manually: " + outPath)
|
|
350
520
|
}
|
|
351
521
|
const visible = results.filter(r => [...r.counts.values()].reduce((a, b) => a + b, 0) > 0)
|
|
352
|
-
const
|
|
522
|
+
const label = hours ? `${Math.round(total)} hours` : `${formatTotal(total)} tokens`
|
|
523
|
+
const flag = hours ? " --hours" : ""
|
|
524
|
+
const text = `${label} across ${visible.length} AI coding tools\n\nnpx clanker-stats${flag}`
|
|
353
525
|
openPath(`https://x.com/intent/post?text=${encodeURIComponent(text)}`)
|
|
354
526
|
console.log("Paste the image from your clipboard into the post")
|
|
355
527
|
} else {
|