clanker-stats 0.4.1 → 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 +186 -17
  2. package/package.json +1 -1
package/cli.js CHANGED
@@ -143,13 +143,168 @@ async function collectPi() {
143
143
  return counts
144
144
  }
145
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
+
146
301
  const tools = [
147
- { name: "Claude Code", collect: collectClaude, color: "#f97316" },
148
- { name: "Codex", collect: collectCodex, color: "#22c55e" },
149
- { name: "OpenCode", collect: collectOpenCode, color: "#3b82f6" },
150
- { name: "Gemini CLI", collect: collectGemini, color: "#eab308" },
151
- { name: "Amp", collect: collectAmp, color: "#a855f7" },
152
- { 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" },
153
308
  ]
154
309
 
155
310
  function catmullRomPath(points, tension = 0.3, yFloor) {
@@ -175,7 +330,14 @@ function formatTotal(n) {
175
330
  return String(n)
176
331
  }
177
332
 
178
- 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 } = {}) {
179
341
  const W = 1500, H = 560
180
342
  const pad = { top: 130, right: 50, bottom: 60, left: 50 }
181
343
  const chartW = W - pad.left - pad.right
@@ -234,7 +396,7 @@ function renderChart(allDays, results, total) {
234
396
  const val = (maxY / gridCount) * i
235
397
  const y = pad.top + chartH - (i / gridCount) * chartH
236
398
  gridLines += `<line x1="${pad.left}" y1="${y}" x2="${pad.left + chartW}" y2="${y}" stroke="#21262d" stroke-width="1"/>\n`
237
- 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`
238
400
  }
239
401
 
240
402
  const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
@@ -271,7 +433,7 @@ function renderChart(allDays, results, total) {
271
433
  const count = [...visible[i].counts.values()].reduce((a, b) => a + b, 0)
272
434
  legend += `<rect x="${x}" y="${vcenter - 14}" width="14" height="14" rx="3" fill="${visible[i].color}" opacity="0.8"/>`
273
435
  legend += `<text x="${x + 20}" y="${vcenter}" fill="#8b949e" font-family="${font}" font-size="18">${visible[i].name}</text>`
274
- 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`
275
437
  }
276
438
 
277
439
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
@@ -281,10 +443,10 @@ function renderChart(allDays, results, total) {
281
443
  </clipPath>
282
444
  </defs>
283
445
  <rect width="${W}" height="${H}" fill="#0d1117"/>
284
- <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>
285
- <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>
286
448
  <rect x="${midX - 170}" y="${vcenter - 20}" width="340" height="40" rx="8" fill="#161b22"/>
287
- <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>
288
450
  ${legend}
289
451
  ${gridLines}
290
452
  ${areas}
@@ -303,13 +465,15 @@ function openPath(target) {
303
465
 
304
466
  async function main() {
305
467
  const share = process.argv.includes("--share")
468
+ const hours = process.argv.includes("--hours")
306
469
  const results = []
470
+
307
471
  for (const tool of tools) {
308
472
  try {
309
- const counts = await tool.collect()
473
+ const counts = hours ? await tool.collectTime() : await tool.collect()
310
474
  results.push({ name: tool.name, color: tool.color, counts })
311
475
  const t = [...counts.values()].reduce((a, b) => a + b, 0)
312
- console.log(`${tool.name}: ${formatTotal(t)} tokens`)
476
+ console.log(`${tool.name}: ${hours ? formatHours(t) : formatTotal(t) + " tokens"}`)
313
477
  } catch (e) {
314
478
  console.warn(`${tool.name}: skipped (${e?.message || e})`)
315
479
  results.push({ name: tool.name, color: tool.color, counts: new Map() })
@@ -333,9 +497,12 @@ async function main() {
333
497
  }
334
498
 
335
499
  const total = results.reduce((sum, r) => sum + [...r.counts.values()].reduce((a, b) => a + b, 0), 0)
336
- 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"}`)
337
504
 
338
- const svg = renderChart(allDays, results, total)
505
+ const svg = renderChart(allDays, results, total, chartOpts)
339
506
  const resvg = new Resvg(svg, { fitTo: { mode: "width", value: 1500 } })
340
507
  const png = resvg.render().asPng()
341
508
 
@@ -352,7 +519,9 @@ async function main() {
352
519
  console.log("Copy the image manually: " + outPath)
353
520
  }
354
521
  const visible = results.filter(r => [...r.counts.values()].reduce((a, b) => a + b, 0) > 0)
355
- 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}`
356
525
  openPath(`https://x.com/intent/post?text=${encodeURIComponent(text)}`)
357
526
  console.log("Paste the image from your clipboard into the post")
358
527
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clanker-stats",
3
- "version": "0.4.1",
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": {