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.
Files changed (2) hide show
  1. package/cli.js +204 -32
  2. 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
- const text = await readFile(join(dir, path), "utf-8")
46
- let prevCumulative = null
47
- for (const line of text.split("\n")) {
48
- if (!line.includes('"token_count"')) continue
49
- try {
50
- const obj = JSON.parse(line)
51
- if (obj.payload?.type !== "token_count") continue
52
- const cumTotal = obj.payload.info?.total_token_usage?.total_tokens
53
- if (cumTotal != null && cumTotal === prevCumulative) continue
54
- let tokens = obj.payload.info?.last_token_usage?.total_tokens
55
- if (!tokens && cumTotal != null && prevCumulative != null) tokens = cumTotal - prevCumulative
56
- if (cumTotal != null) prevCumulative = cumTotal
57
- if (tokens > 0 && obj.timestamp) add(counts, isoToDate(obj.timestamp), tokens)
58
- } catch {}
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 renderChart(allDays, results, total) {
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">${formatTotal(val)}</text>\n`
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">${formatTotal(count)}</text>\n`
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">${formatTotal(total)}</text>
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">TOKENS</text>
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">npx clanker-stats --share</text>
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)} tokens`)
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
- console.log(`\n${allDays.length} days, ${formatTotal(total)} total tokens`)
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 text = `${formatTotal(total)} tokens across ${visible.length} AI coding tools\n\nnpx clanker-stats`
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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clanker-stats",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "See how many tokens you've burned across AI coding tools",
5
5
  "type": "module",
6
6
  "bin": {