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.
- package/cli.js +186 -17
- 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
|
|
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">${
|
|
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">${
|
|
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">${
|
|
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"
|
|
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"
|
|
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)
|
|
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
|
-
|
|
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
|
|
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 {
|