@v1nvn/tokens 0.14.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/dist/index.js ADDED
@@ -0,0 +1,269 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ //#region ../core/dist/index.js
6
+ /**
7
+ * Print the UserPromptExpansion decision for a zero-token command: `block`
8
+ * keeps the command from reaching the model, with the output as the `reason`.
9
+ */
10
+ function emitHookBlock(reason) {
11
+ process.stdout.write(`${JSON.stringify({
12
+ decision: "block",
13
+ reason
14
+ })}\n`);
15
+ }
16
+ /**
17
+ * Plain-text rendering primitives shared by the zai and tokens report
18
+ * renderers. Output targets a monospace terminal / hook-block `reason`, so
19
+ * everything here is fixed-width: padding, block-glyph bars, and compact
20
+ * number formatting.
21
+ */
22
+ var MONTHS = [
23
+ "Jan",
24
+ "Feb",
25
+ "Mar",
26
+ "Apr",
27
+ "May",
28
+ "Jun",
29
+ "Jul",
30
+ "Aug",
31
+ "Sep",
32
+ "Oct",
33
+ "Nov",
34
+ "Dec"
35
+ ];
36
+ var EIGHTHS = [
37
+ "",
38
+ "▏",
39
+ "▎",
40
+ "▍",
41
+ "▌",
42
+ "▋",
43
+ "▊",
44
+ "▉"
45
+ ];
46
+ function fmtTokens(n) {
47
+ if (n == null || Number.isNaN(n)) return "—";
48
+ if (n >= 1e9) return (n / 1e9).toFixed(1) + "B";
49
+ if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
50
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + "K";
51
+ return String(n);
52
+ }
53
+ function fmtNum(n) {
54
+ return (n || 0).toLocaleString("en-US");
55
+ }
56
+ function padR(s, n) {
57
+ return s.length >= n ? s : s + " ".repeat(n - s.length);
58
+ }
59
+ function padL(s, n) {
60
+ return s.length >= n ? s : " ".repeat(n - s.length) + s;
61
+ }
62
+ /** Fixed-width bar field (width cols): █ blocks + an eighth-fraction + trailing spaces. */
63
+ function barField(v, max, width) {
64
+ if (!v || v <= 0 || max <= 0) return " ".repeat(width);
65
+ const scaled = v / max * width;
66
+ let full = Math.floor(scaled);
67
+ let fi = Math.round((scaled - full) * 8);
68
+ if (fi === 8) {
69
+ full += 1;
70
+ fi = 0;
71
+ }
72
+ if (full === 0 && fi === 0) fi = 1;
73
+ let s = "█".repeat(Math.min(full, width));
74
+ if (full < width && fi > 0) s += EIGHTHS[fi];
75
+ if (s.length < width) s += " ".repeat(width - s.length);
76
+ return s.slice(0, width);
77
+ }
78
+ //#endregion
79
+ //#region src/format.ts
80
+ /**
81
+ * Plain-text token-usage renderer.
82
+ *
83
+ * Rendered for a monospace terminal / hook-block `reason`, so it must NOT rely on
84
+ * markdown. Alignment comes from fixed-width columns and unicode block glyphs.
85
+ * Input is the aggregate JSON produced by scan.ts.
86
+ */
87
+ var W = 68;
88
+ /** cacheRead / modeled context; input_tokens is uncached input only. */
89
+ function hitRate({ input = 0, cacheRead = 0, cacheCreation = 0 } = {}) {
90
+ const denom = input + cacheRead + cacheCreation;
91
+ return denom > 0 ? cacheRead / denom * 100 : 0;
92
+ }
93
+ function totalTokens(a) {
94
+ return (a.input || 0) + (a.output || 0) + (a.cacheRead || 0) + (a.cacheCreation || 0);
95
+ }
96
+ /** '2026-08-15' → 'Aug 15'. */
97
+ function dayLabel(day) {
98
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day);
99
+ return m ? `${MONTHS[+m[2] - 1]} ${m[3]}` : day;
100
+ }
101
+ function fmtClock(date) {
102
+ return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
103
+ }
104
+ /** Local-time 'YYYY-MM-DD' for a Date, without depending on toLocaleString. */
105
+ function localKey(date) {
106
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
107
+ }
108
+ function render(scanResult, { now = /* @__PURE__ */ new Date() } = {}) {
109
+ const out = [];
110
+ function rule() {
111
+ return "─".repeat(W);
112
+ }
113
+ const rows = scanResult.last24.filter((r) => totalTokens(r) > 0);
114
+ const sum = rows.reduce((a, r) => {
115
+ a.input += r.input;
116
+ a.output += r.output;
117
+ a.cacheRead += r.cacheRead;
118
+ a.cacheCreation += r.cacheCreation;
119
+ a.calls += r.calls;
120
+ return a;
121
+ }, {
122
+ input: 0,
123
+ output: 0,
124
+ cacheRead: 0,
125
+ cacheCreation: 0,
126
+ calls: 0
127
+ });
128
+ const pctHit = Math.round(hitRate(sum));
129
+ const left = " Token usage · transcripts";
130
+ const winStart = /* @__PURE__ */ new Date(now.getTime() - 864e5);
131
+ const win = `${dayLabel(localKey(winStart))} ${fmtClock(winStart)} → ${dayLabel(localKey(now))} ${fmtClock(now)} · 24h`;
132
+ out.push(rule());
133
+ out.push(left + padL(win, 42));
134
+ out.push(rule());
135
+ out.push("");
136
+ out.push(` ${fmtTokens(totalTokens(sum))} tokens across ${fmtNum(sum.calls)} model calls — ${pctHit}% cache hit rate.`);
137
+ out.push("");
138
+ out.push(" Model mix · last 24h " + "─".repeat(Math.max(0, 46)));
139
+ if (rows.length === 0) out.push(" (no usage recorded in the last 24 hours)");
140
+ for (const r of rows) {
141
+ const pct = Math.round(hitRate(r));
142
+ out.push(` ${padR(r.model, 14)}${padL(fmtTokens(r.input), 8)} in · ${padL(fmtTokens(r.output), 8)} out · ${padL(fmtTokens(r.cacheRead), 8)} read · ${padL(fmtTokens(r.cacheCreation), 8)} created ${padL(`${pct}%`, 4)} ${barField(pct, 100, 14)}`);
143
+ }
144
+ let days = scanResult.days;
145
+ const firstDay = localKey(/* @__PURE__ */ new Date(now.getTime() - 6048e5));
146
+ if (days.length && days[0].day === firstDay && firstDay !== days[days.length - 1].day) days = days.slice(1);
147
+ out.push("");
148
+ out.push(" Daily · last 7 days " + "─".repeat(Math.max(0, 47)));
149
+ const maxDay = Math.max(0, ...days.map(totalTokens));
150
+ for (const d of [...days].reverse()) {
151
+ const pct = Math.round(hitRate(d));
152
+ out.push(` ${dayLabel(d.day)} ${padL(fmtTokens(totalTokens(d)), 8)} ${barField(totalTokens(d), maxDay, 24)} ${padL(`${pct}%`, 4)}`);
153
+ }
154
+ if (days.length === 0) out.push(" (no usage recorded in the last 7 days)");
155
+ out.push("");
156
+ out.push(` Covers every profile writing to ~/.claude/projects — hit rate = read / (in + read + created).`);
157
+ out.push(rule());
158
+ return out.join("\n");
159
+ }
160
+ //#endregion
161
+ //#region src/scan.ts
162
+ /**
163
+ * Transcript token scanner.
164
+ *
165
+ * Claude Code persists every assistant message's `usage` block to session
166
+ * transcripts at ~/.claude/projects/<project-dir>/<session>.jsonl — for every
167
+ * profile (default claude, claudez, …), interactive and headless alike. This
168
+ * scans those files and aggregates token usage per model and per local day:
169
+ * input (uncached), output, cacheRead, cacheCreation, call count.
170
+ */
171
+ function zero() {
172
+ return {
173
+ input: 0,
174
+ output: 0,
175
+ cacheRead: 0,
176
+ cacheCreation: 0,
177
+ calls: 0
178
+ };
179
+ }
180
+ function add(acc, u, n = 1) {
181
+ acc.input += n * (u.input_tokens ?? 0);
182
+ acc.output += n * (u.output_tokens ?? 0);
183
+ acc.cacheRead += n * (u.cache_read_input_tokens ?? 0);
184
+ acc.cacheCreation += n * (u.cache_creation_input_tokens ?? 0);
185
+ acc.calls += n;
186
+ }
187
+ function byTotalDesc(a, b) {
188
+ return b.input + b.output + b.cacheRead + b.cacheCreation - (a.input + a.output + a.cacheRead + a.cacheCreation);
189
+ }
190
+ /** Get-or-create the map entry, so callers never hold a missing accumulator. */
191
+ function bucket(map, key) {
192
+ let acc = map.get(key);
193
+ if (acc === void 0) {
194
+ acc = zero();
195
+ map.set(key, acc);
196
+ }
197
+ return acc;
198
+ }
199
+ function toModelRows(m) {
200
+ return [...m.entries()].map(([model, acc]) => ({
201
+ model,
202
+ ...acc
203
+ })).sort(byTotalDesc);
204
+ }
205
+ function scan({ projectsDir, now = /* @__PURE__ */ new Date() } = {}) {
206
+ const dir = projectsDir ?? join(homedir(), ".claude", "projects");
207
+ if (!existsSync(dir)) throw new Error(`no transcripts directory at ${dir}`);
208
+ const windowStart = now.getTime() - 6048e5;
209
+ const last24Start = now.getTime() - 864e5;
210
+ const days = /* @__PURE__ */ new Map();
211
+ const last24 = /* @__PURE__ */ new Map();
212
+ const models = /* @__PURE__ */ new Map();
213
+ const projectDirs = readdirSync(dir).map((d) => join(dir, d)).filter((d) => statSync(d).isDirectory());
214
+ for (const pdir of projectDirs) for (const f of readdirSync(pdir)) {
215
+ if (!f.endsWith(".jsonl")) continue;
216
+ const fp = join(pdir, f);
217
+ if (statSync(fp).mtimeMs < windowStart) continue;
218
+ for (const line of readFileSync(fp, "utf8").split("\n")) {
219
+ if (!line.includes("\"usage\"")) continue;
220
+ let j;
221
+ try {
222
+ j = JSON.parse(line);
223
+ } catch {
224
+ continue;
225
+ }
226
+ const u = j.message?.usage;
227
+ const model = j.message?.model;
228
+ if (!u || !model || !j.timestamp) continue;
229
+ const ts = new Date(j.timestamp).getTime();
230
+ if (!(ts >= windowStart)) continue;
231
+ const local = new Date(ts);
232
+ add(bucket(days, `${local.getFullYear()}-${String(local.getMonth() + 1).padStart(2, "0")}-${String(local.getDate()).padStart(2, "0")}`), u);
233
+ add(bucket(models, model), u);
234
+ if (ts >= last24Start) add(bucket(last24, model), u);
235
+ }
236
+ }
237
+ return {
238
+ now: now.toISOString(),
239
+ days: [...days.entries()].sort(([a], [b]) => a < b ? -1 : 1).map(([day, acc]) => ({
240
+ day,
241
+ ...acc
242
+ })),
243
+ models: toModelRows(models),
244
+ last24: toModelRows(last24)
245
+ };
246
+ }
247
+ //#endregion
248
+ //#region src/index.ts
249
+ function report() {
250
+ return render(scan());
251
+ }
252
+ if (process.argv.includes("--hook")) {
253
+ let reason;
254
+ try {
255
+ reason = report();
256
+ } catch (e) {
257
+ reason = `query failed: ${e.message}`;
258
+ }
259
+ emitHookBlock(reason);
260
+ } else try {
261
+ console.log(report());
262
+ } catch (e) {
263
+ console.error(e.message);
264
+ process.exit(1);
265
+ }
266
+ //#endregion
267
+ export {};
268
+
269
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../core/dist/index.js","../src/format.ts","../src/scan.ts","../src/index.ts"],"sourcesContent":["import { existsSync, readFileSync, readdirSync, statSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\n//#region src/hook.ts\n/**\n* Read the Claude Code hook event JSON from stdin; empty or unparseable input\n* yields an empty event, never a thrown error — a hook must always answer.\n*/\nfunction readHookEvent(stream = process.stdin) {\n\treturn new Promise((resolve) => {\n\t\tlet data = \"\";\n\t\tstream.setEncoding(\"utf8\");\n\t\tstream.on(\"data\", (chunk) => {\n\t\t\tdata += chunk;\n\t\t});\n\t\tstream.on(\"end\", () => {\n\t\t\ttry {\n\t\t\t\tresolve(JSON.parse(data));\n\t\t\t} catch {\n\t\t\t\tresolve({});\n\t\t\t}\n\t\t});\n\t\tstream.on(\"error\", () => {\n\t\t\tresolve({});\n\t\t});\n\t});\n}\n/**\n* Which transcript lastReply should read, per the hook event: transcript_path\n* when it names a real file, else session_id, else nothing (lastReply then\n* falls back to the newest session for the current project).\n*/\nfunction replyTarget(event) {\n\tif (event.transcript_path !== void 0 && isFile$1(event.transcript_path)) return event.transcript_path;\n\treturn event.session_id || void 0;\n}\nfunction isFile$1(path) {\n\ttry {\n\t\treturn statSync(path).isFile();\n\t} catch {\n\t\treturn false;\n\t}\n}\n/**\n* Print the UserPromptExpansion decision for a zero-token command: `block`\n* keeps the command from reaching the model, with the output as the `reason`.\n*/\nfunction emitHookBlock(reason) {\n\tprocess.stdout.write(`${JSON.stringify({\n\t\tdecision: \"block\",\n\t\treason\n\t})}\\n`);\n}\n//#endregion\n//#region src/last-reply.ts\nfunction isFile(path) {\n\ttry {\n\t\treturn statSync(path).isFile();\n\t} catch {\n\t\treturn false;\n\t}\n}\nfunction textBlocks(entry) {\n\tconst content = entry.message?.content;\n\tif (entry.type !== \"assistant\" || !Array.isArray(content)) return;\n\tconst blocks = content.filter((b) => typeof b === \"object\" && b !== null && b.type === \"text\");\n\treturn blocks.length > 0 ? blocks : void 0;\n}\n/**\n* The last assistant text reply from a Claude Code session, reproducing Claude\n* Code's `/copy` byte-for-byte: the last assistant entry that contains a\n* `text` block, only its `text` block(s) (tool_use / thinking dropped), blocks\n* joined with a blank line, and no trailing newline.\n*\n* @param arg a transcript file, a session UUID under the project dir, or\n* nothing to use the newest session for the current project.\n*/\nfunction lastReply(arg) {\n\tconst claudeDir = process.env.CLAUDE_DIR ?? join(homedir(), \".claude\");\n\tconst proj = (process.env.CLAUDE_PROJECT_DIR ?? process.cwd()).replaceAll(\"/\", \"-\");\n\tconst projDir = join(claudeDir, \"projects\", proj);\n\tlet file;\n\tif (arg !== void 0) file = isFile(arg) ? arg : join(projDir, `${arg}.jsonl`);\n\telse if (existsSync(projDir)) {\n\t\tconst newest = readdirSync(projDir).filter((f) => f.endsWith(\".jsonl\")).map((f) => ({\n\t\t\tf,\n\t\t\tmtimeMs: statSync(join(projDir, f)).mtimeMs\n\t\t})).sort((a, b) => b.mtimeMs - a.mtimeMs).at(0);\n\t\tif (newest) file = join(projDir, newest.f);\n\t}\n\tif (file === void 0 || !isFile(file)) throw new Error(`no session transcript found in ${projDir}`);\n\tlet last;\n\tfor (const line of readFileSync(file, \"utf8\").split(\"\\n\")) {\n\t\tif (!line.includes(\"\\\"assistant\\\"\")) continue;\n\t\tlet entry;\n\t\ttry {\n\t\t\tentry = JSON.parse(line);\n\t\t} catch {\n\t\t\tcontinue;\n\t\t}\n\t\tconst blocks = textBlocks(entry);\n\t\tif (blocks) last = blocks;\n\t}\n\treturn (last ?? []).map((b) => b.text ?? \"\").join(\"\\n\\n\");\n}\n//#endregion\n//#region src/text-format.ts\n/**\n* Plain-text rendering primitives shared by the zai and tokens report\n* renderers. Output targets a monospace terminal / hook-block `reason`, so\n* everything here is fixed-width: padding, block-glyph bars, and compact\n* number formatting.\n*/\nvar MONTHS = [\n\t\"Jan\",\n\t\"Feb\",\n\t\"Mar\",\n\t\"Apr\",\n\t\"May\",\n\t\"Jun\",\n\t\"Jul\",\n\t\"Aug\",\n\t\"Sep\",\n\t\"Oct\",\n\t\"Nov\",\n\t\"Dec\"\n];\nvar EIGHTHS = [\n\t\"\",\n\t\"▏\",\n\t\"▎\",\n\t\"▍\",\n\t\"▌\",\n\t\"▋\",\n\t\"▊\",\n\t\"▉\"\n];\nfunction fmtTokens(n) {\n\tif (n == null || Number.isNaN(n)) return \"—\";\n\tif (n >= 1e9) return (n / 1e9).toFixed(1) + \"B\";\n\tif (n >= 1e6) return (n / 1e6).toFixed(1) + \"M\";\n\tif (n >= 1e3) return (n / 1e3).toFixed(1) + \"K\";\n\treturn String(n);\n}\nfunction fmtNum(n) {\n\treturn (n || 0).toLocaleString(\"en-US\");\n}\nfunction padR(s, n) {\n\treturn s.length >= n ? s : s + \" \".repeat(n - s.length);\n}\nfunction padL(s, n) {\n\treturn s.length >= n ? s : \" \".repeat(n - s.length) + s;\n}\n/** Fixed-width bar field (width cols): █ blocks + an eighth-fraction + trailing spaces. */\nfunction barField(v, max, width) {\n\tif (!v || v <= 0 || max <= 0) return \" \".repeat(width);\n\tconst scaled = v / max * width;\n\tlet full = Math.floor(scaled);\n\tlet fi = Math.round((scaled - full) * 8);\n\tif (fi === 8) {\n\t\tfull += 1;\n\t\tfi = 0;\n\t}\n\tif (full === 0 && fi === 0) fi = 1;\n\tlet s = \"█\".repeat(Math.min(full, width));\n\tif (full < width && fi > 0) s += EIGHTHS[fi];\n\tif (s.length < width) s += \" \".repeat(width - s.length);\n\treturn s.slice(0, width);\n}\n/** Filled/empty meter: █ for used, ░ for remaining. */\nfunction meter(pct, width) {\n\tlet filled = Math.round((pct || 0) / 100 * width);\n\tfilled = Math.max(0, Math.min(width, filled));\n\treturn \"█\".repeat(filled) + \"░\".repeat(width - filled);\n}\n//#endregion\nexport { MONTHS, barField, emitHookBlock, fmtNum, fmtTokens, lastReply, meter, padL, padR, readHookEvent, replyTarget };\n\n//# sourceMappingURL=index.js.map","/**\n * Plain-text token-usage renderer.\n *\n * Rendered for a monospace terminal / hook-block `reason`, so it must NOT rely on\n * markdown. Alignment comes from fixed-width columns and unicode block glyphs.\n * Input is the aggregate JSON produced by scan.ts.\n */\n\nimport {\n barField,\n fmtNum,\n fmtTokens,\n MONTHS,\n padL,\n padR,\n} from '@v1nvn/agentic-core';\n\nimport type { DayRow, ModelRow, ScanResult, UsageAcc } from './scan.js';\n\nconst W = 68; // overall rule width\n\n/** cacheRead / modeled context; input_tokens is uncached input only. */\nexport function hitRate({\n input = 0,\n cacheRead = 0,\n cacheCreation = 0,\n}: Partial<UsageAcc> = {}): number {\n const denom = input + cacheRead + cacheCreation;\n return denom > 0 ? (cacheRead / denom) * 100 : 0;\n}\n\nfunction totalTokens(a: UsageAcc): number {\n return (\n (a.input || 0) +\n (a.output || 0) +\n (a.cacheRead || 0) +\n (a.cacheCreation || 0)\n );\n}\n\n/** '2026-08-15' → 'Aug 15'. */\nfunction dayLabel(day: string): string {\n const m = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(day);\n return m ? `${MONTHS[+m[2] - 1]} ${m[3]}` : day;\n}\n\nfunction fmtClock(date: Date): string {\n return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;\n}\n\n/** Local-time 'YYYY-MM-DD' for a Date, without depending on toLocaleString. */\nfunction localKey(date: Date): string {\n return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;\n}\n\nexport function render(\n scanResult: ScanResult,\n { now = new Date() } = {},\n): string {\n const out: string[] = [];\n function rule(): string {\n return '─'.repeat(W);\n }\n\n const allRows: ModelRow[] = scanResult.last24;\n const rows = allRows.filter(r => totalTokens(r) > 0); // <synthetic> etc. carry no tokens\n const sum = rows.reduce<UsageAcc>(\n (a, r) => {\n a.input += r.input;\n a.output += r.output;\n a.cacheRead += r.cacheRead;\n a.cacheCreation += r.cacheCreation;\n a.calls += r.calls;\n return a;\n },\n { input: 0, output: 0, cacheRead: 0, cacheCreation: 0, calls: 0 },\n );\n const pctHit = Math.round(hitRate(sum));\n\n // ---- header ----\n const left = ' Token usage · transcripts';\n const winStart = new Date(now.getTime() - 24 * 3600 * 1000);\n const win = `${dayLabel(localKey(winStart))} ${fmtClock(winStart)} → ${dayLabel(localKey(now))} ${fmtClock(now)} · 24h`;\n out.push(rule());\n out.push(left + padL(win, W - left.length));\n out.push(rule());\n\n // ---- lead ----\n out.push('');\n out.push(\n ` ${fmtTokens(totalTokens(sum))} tokens across ${fmtNum(sum.calls)} model calls — ${pctHit}% cache hit rate.`,\n );\n\n // ---- model mix · last 24h ----\n out.push('');\n out.push(' Model mix · last 24h ' + '─'.repeat(Math.max(0, W - 22)));\n if (rows.length === 0) {\n out.push(' (no usage recorded in the last 24 hours)');\n }\n for (const r of rows) {\n const pct = Math.round(hitRate(r));\n out.push(\n ` ${padR(r.model, 14)}${padL(fmtTokens(r.input), 8)} in · ${padL(fmtTokens(r.output), 8)} out ·` +\n ` ${padL(fmtTokens(r.cacheRead), 8)} read · ${padL(fmtTokens(r.cacheCreation), 8)} created ${padL(`${pct}%`, 4)} ${barField(pct, 100, 14)}`,\n );\n }\n\n // ---- daily · last 7 days ----\n // The bucket holding the window-start day covers only part of that calendar\n // day — drop it (unless the window began at midnight, which scan would have\n // bucketed as a full day).\n let days: DayRow[] = scanResult.days;\n const firstDay = localKey(new Date(now.getTime() - 7 * 24 * 3600 * 1000));\n if (\n days.length &&\n days[0].day === firstDay &&\n firstDay !== days[days.length - 1].day\n ) {\n days = days.slice(1);\n }\n out.push('');\n out.push(' Daily · last 7 days ' + '─'.repeat(Math.max(0, W - 21)));\n const maxDay = Math.max(0, ...days.map(totalTokens));\n for (const d of [...days].reverse()) {\n const pct = Math.round(hitRate(d));\n out.push(\n ` ${dayLabel(d.day)} ${padL(fmtTokens(totalTokens(d)), 8)} ${barField(totalTokens(d), maxDay, 24)} ${padL(`${pct}%`, 4)}`,\n );\n }\n if (days.length === 0) {\n out.push(' (no usage recorded in the last 7 days)');\n }\n\n out.push('');\n out.push(\n ` Covers every profile writing to ~/.claude/projects — hit rate = read / (in + read + created).`,\n );\n out.push(rule());\n return out.join('\\n');\n}\n","/**\n * Transcript token scanner.\n *\n * Claude Code persists every assistant message's `usage` block to session\n * transcripts at ~/.claude/projects/<project-dir>/<session>.jsonl — for every\n * profile (default claude, claudez, …), interactive and headless alike. This\n * scans those files and aggregates token usage per model and per local day:\n * input (uncached), output, cacheRead, cacheCreation, call count.\n */\n\nimport { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\n\nconst DAYS = 7;\n\nexport interface UsageAcc {\n cacheCreation: number;\n cacheRead: number;\n calls: number;\n input: number;\n output: number;\n}\n\nexport interface DayRow extends UsageAcc {\n day: string;\n}\n\nexport interface ModelRow extends UsageAcc {\n model: string;\n}\n\nexport interface ScanResult {\n days: DayRow[];\n last24: ModelRow[];\n models: ModelRow[];\n now: string;\n}\n\ninterface UsageBlock {\n cache_creation_input_tokens?: number;\n cache_read_input_tokens?: number;\n input_tokens?: number;\n output_tokens?: number;\n}\n\ninterface TranscriptEntry {\n message?: { model?: string; usage?: UsageBlock };\n timestamp?: string;\n}\n\nfunction zero(): UsageAcc {\n return { input: 0, output: 0, cacheRead: 0, cacheCreation: 0, calls: 0 };\n}\n\nfunction add(acc: UsageAcc, u: UsageBlock, n = 1): void {\n acc.input += n * (u.input_tokens ?? 0);\n acc.output += n * (u.output_tokens ?? 0);\n acc.cacheRead += n * (u.cache_read_input_tokens ?? 0);\n acc.cacheCreation += n * (u.cache_creation_input_tokens ?? 0);\n acc.calls += n;\n}\n\nfunction byTotalDesc(a: UsageAcc, b: UsageAcc): number {\n return (\n b.input +\n b.output +\n b.cacheRead +\n b.cacheCreation -\n (a.input + a.output + a.cacheRead + a.cacheCreation)\n );\n}\n\n/** Get-or-create the map entry, so callers never hold a missing accumulator. */\nfunction bucket<K>(map: Map<K, UsageAcc>, key: K): UsageAcc {\n let acc = map.get(key);\n if (acc === undefined) {\n acc = zero();\n map.set(key, acc);\n }\n return acc;\n}\n\nfunction toModelRows(m: Map<string, UsageAcc>): ModelRow[] {\n return [...m.entries()]\n .map(([model, acc]) => ({ model, ...acc }))\n .sort(byTotalDesc);\n}\n\nexport function scan({\n projectsDir,\n now = new Date(),\n}: { now?: Date; projectsDir?: string } = {}): ScanResult {\n const dir = projectsDir ?? join(homedir(), '.claude', 'projects');\n if (!existsSync(dir)) {\n throw new Error(`no transcripts directory at ${dir}`);\n }\n\n const windowStart = now.getTime() - DAYS * 24 * 3600 * 1000;\n const last24Start = now.getTime() - 24 * 3600 * 1000;\n\n const days = new Map<string, UsageAcc>(); // 'YYYY-MM-DD' → acc\n const last24 = new Map<string, UsageAcc>(); // model → acc\n const models = new Map<string, UsageAcc>(); // model → acc (whole 7d window, for the model mix)\n\n const projectDirs = readdirSync(dir)\n .map(d => join(dir, d))\n .filter(d => statSync(d).isDirectory());\n\n for (const pdir of projectDirs) {\n for (const f of readdirSync(pdir)) {\n if (!f.endsWith('.jsonl')) {\n continue;\n }\n const fp = join(pdir, f);\n if (statSync(fp).mtimeMs < windowStart) {\n continue;\n }\n\n for (const line of readFileSync(fp, 'utf8').split('\\n')) {\n if (!line.includes('\"usage\"')) {\n continue;\n }\n let j: TranscriptEntry;\n try {\n j = JSON.parse(line) as TranscriptEntry;\n } catch {\n continue;\n }\n const u = j.message?.usage;\n const model = j.message?.model;\n if (!u || !model || !j.timestamp) {\n continue;\n }\n\n const ts = new Date(j.timestamp).getTime();\n if (!(ts >= windowStart)) {\n continue;\n }\n\n const local = new Date(ts);\n const dayKey = `${local.getFullYear()}-${String(local.getMonth() + 1).padStart(2, '0')}-${String(local.getDate()).padStart(2, '0')}`;\n add(bucket(days, dayKey), u);\n add(bucket(models, model), u);\n if (ts >= last24Start) {\n add(bucket(last24, model), u);\n }\n }\n }\n }\n\n return {\n now: now.toISOString(),\n days: [...days.entries()]\n .sort(([a], [b]) => (a < b ? -1 : 1))\n .map(([day, acc]) => ({ day, ...acc })),\n models: toModelRows(models),\n last24: toModelRows(last24),\n };\n}\n","import { emitHookBlock } from '@v1nvn/agentic-core';\n\nimport { render } from './format.js';\nimport { scan } from './scan.js';\n\nfunction report(): string {\n return render(scan());\n}\n\nif (process.argv.includes('--hook')) {\n let reason: string;\n try {\n reason = report();\n } catch (e) {\n reason = `query failed: ${(e as Error).message}`;\n }\n emitHookBlock(reason);\n} else {\n try {\n console.log(report());\n } catch (e) {\n console.error((e as Error).message);\n // CLIs report failure through the exit code; the rule targets libraries.\n // eslint-disable-next-line n/no-process-exit\n process.exit(1);\n }\n}\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@v1nvn/tokens",
3
+ "version": "0.14.0",
4
+ "description": "Per-model token usage and cache hit rate from local Claude Code transcripts — the tokens-report CLI behind the tokens plugin.",
5
+ "type": "module",
6
+ "bin": {
7
+ "tokens-report": "dist/index.js"
8
+ },
9
+ "scripts": {
10
+ "build": "vite build",
11
+ "test": "vitest run",
12
+ "test:watch": "vitest"
13
+ },
14
+ "keywords": [
15
+ "claude-code",
16
+ "tokens",
17
+ "cache",
18
+ "transcripts"
19
+ ],
20
+ "author": "v1nvn",
21
+ "license": "MIT",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/v1nvn/agentic.git",
25
+ "directory": "packages/tokens"
26
+ },
27
+ "files": [
28
+ "dist"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "engines": {
34
+ "node": ">=22"
35
+ },
36
+ "dependencies": {
37
+ "@v1nvn/agentic-core": "^0.14.0"
38
+ },
39
+ "devDependencies": {
40
+ "vite": "^8.1.4",
41
+ "vitest": "^4.1.10"
42
+ }
43
+ }