tokentracker-cli 0.29.2 → 0.31.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/README.ja.md +3 -1
- package/README.ko.md +3 -1
- package/README.md +3 -1
- package/README.zh-CN.md +3 -1
- package/dashboard/dist/assets/ActivityHeatmap-D8lXQtTi.js +42 -0
- package/dashboard/dist/assets/{Card-DSgpCS30.js → Card-CzL-eInd.js} +1 -1
- package/dashboard/dist/assets/DashboardPage-CsR8XWxj.js +19 -0
- package/dashboard/dist/assets/{DevicePage-CvrCNfjD.js → DevicePage-XTngLOwj.js} +1 -1
- package/dashboard/dist/assets/DialogTitle-Csg36lAf.js +12 -0
- package/dashboard/dist/assets/FadeIn-Db32waF2.js +1 -0
- package/dashboard/dist/assets/{HeaderGithubStar-B98VaHbR.js → HeaderGithubStar-DliHDaOa.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-CJxv_Sd8.js → IpCheckPage-F5CeRlub.js} +1 -1
- package/dashboard/dist/assets/LandingPage-2_sEF9mN.js +4356 -0
- package/dashboard/dist/assets/{LeaderboardAvatar-D6qRteef.js → LeaderboardAvatar-C9VP8195.js} +1 -1
- package/dashboard/dist/assets/{LeaderboardPage-CgtoFmMi.js → LeaderboardPage-Za_4Qq0N.js} +3 -3
- package/dashboard/dist/assets/{LeaderboardProfileModal-OTLhvzV6.js → LeaderboardProfileModal-BGDd6bjd.js} +2 -2
- package/dashboard/dist/assets/LeaderboardProfilePage-CuGbG8_n.js +1 -0
- package/dashboard/dist/assets/LimitsPage-DlcSiBHP.js +2 -0
- package/dashboard/dist/assets/{LocalOnlyNotice-3LPVUJ2h.js → LocalOnlyNotice-BNFZWAB2.js} +1 -1
- package/dashboard/dist/assets/LoginPage-UkyGRtTz.js +1 -0
- package/dashboard/dist/assets/PopoverPopup-Bg0LqxYF.js +1 -0
- package/dashboard/dist/assets/SettingsPage-D3gNWvI6.js +1 -0
- package/dashboard/dist/assets/SkillsPage-ChO2jmQM.js +1 -0
- package/dashboard/dist/assets/WidgetsPage-BzPEEeI3.js +1 -0
- package/dashboard/dist/assets/{WrappedPage-Cp8uyLbu.js → WrappedPage-DpP5X0ug.js} +1 -1
- package/dashboard/dist/assets/agent-logos-BgjfCDVs.js +1 -0
- package/dashboard/dist/assets/{arrow-up-right-CBzDSqD7.js → arrow-up-right-BTb5Q4za.js} +1 -1
- package/dashboard/dist/assets/check-L6OoQyFg.js +1 -0
- package/dashboard/dist/assets/{chevron-down--Hb5e71i.js → chevron-down-DaLDjB50.js} +1 -1
- package/dashboard/dist/assets/{download-BaVXaxbw.js → download-B3izgOD2.js} +1 -1
- package/dashboard/dist/assets/{info-WGRGGnfx.js → info-D9m3SBry.js} +1 -1
- package/dashboard/dist/assets/main-CvZdsRCc.css +1 -0
- package/dashboard/dist/assets/main-y2PTR9Ni.js +959 -0
- package/dashboard/dist/assets/{use-limits-display-prefs-C1AkRZcO.js → use-limits-display-prefs--sTkiVYR.js} +1 -1
- package/dashboard/dist/assets/{use-native-settings-DdooJHwm.js → use-native-settings-CI8nzpC4.js} +1 -1
- package/dashboard/dist/assets/{use-usage-limits-D1mepldk.js → use-usage-limits-DHoQOqOL.js} +1 -1
- package/dashboard/dist/assets/useCurrency-DyV36y25.js +1 -0
- package/dashboard/dist/dashboard-dark.png +0 -0
- package/dashboard/dist/index.html +2 -2
- package/dashboard/dist/share.html +2 -2
- package/package.json +1 -1
- package/src/lib/local-api.js +67 -0
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/skill-usage.js +267 -0
- package/src/lib/skills-manager.js +371 -13
- package/dashboard/dist/assets/ActivityHeatmap-C7-tnMYf.js +0 -42
- package/dashboard/dist/assets/DashboardPage-CB6VzM2W.js +0 -1
- package/dashboard/dist/assets/DialogTitle-B-afswln.js +0 -12
- package/dashboard/dist/assets/FadeIn-D1-QPLIW.js +0 -1
- package/dashboard/dist/assets/LandingPage-CIOqWNDm.js +0 -4356
- package/dashboard/dist/assets/LeaderboardProfilePage-C7OaZcf2.js +0 -1
- package/dashboard/dist/assets/LimitsPage-pyyJbh3F.js +0 -2
- package/dashboard/dist/assets/LoginPage-CDLz8PnP.js +0 -1
- package/dashboard/dist/assets/PopoverPopup-97Rik7mb.js +0 -1
- package/dashboard/dist/assets/ProviderIcon-XOgnf2pw.js +0 -1
- package/dashboard/dist/assets/SettingsPage-KVjKUepK.js +0 -1
- package/dashboard/dist/assets/SkillsPage-DyIY1U9O.js +0 -1
- package/dashboard/dist/assets/WidgetsPage-BU7ejAYd.js +0 -1
- package/dashboard/dist/assets/check-Bsgzg2T8.js +0 -1
- package/dashboard/dist/assets/main-ClqhPPd3.css +0 -1
- package/dashboard/dist/assets/main-DDTfmaoo.js +0 -854
- package/dashboard/dist/assets/use-reduced-motion-wvh7iFTi.js +0 -1
- package/dashboard/dist/assets/useCurrency-C3szwbkj.js +0 -1
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// Skill usage analytics — the one angle a token tracker uniquely owns that a
|
|
2
|
+
// dedicated skill manager structurally cannot: "which installed skills do I
|
|
3
|
+
// actually invoke, and what do they cost?"
|
|
4
|
+
//
|
|
5
|
+
// Design constraints (deliberate):
|
|
6
|
+
// * READ-ONLY and fully DECOUPLED from the token parser. We do NOT touch
|
|
7
|
+
// rollout.js / queue.jsonl — editing the incremental parser is this repo's
|
|
8
|
+
// documented #1 footgun (it silently shifts token totals). This scanner
|
|
9
|
+
// reads ~/.claude transcripts on demand and never writes to the queue.
|
|
10
|
+
// * Privacy: only the skill NAME + token counts leave a transcript. Never
|
|
11
|
+
// prompts, args, file contents, or message bodies.
|
|
12
|
+
// * Claude-only v1: the {type:"tool_use",name:"Skill",input:{skill}} signal is
|
|
13
|
+
// a Claude-transcript structure. Other providers have different log shapes;
|
|
14
|
+
// generalizing is explicitly out of scope.
|
|
15
|
+
// * De-dup invocations by tool_use `id` so a turn duplicated across the main
|
|
16
|
+
// session + subagent files is counted once.
|
|
17
|
+
// * A turn's usage is split evenly across the Skill blocks it invoked, so the
|
|
18
|
+
// sum of per-skill cost == the cost of the invoking turns (no double count).
|
|
19
|
+
// This is an approximate "cost of invoking turns", surfaced as such.
|
|
20
|
+
|
|
21
|
+
const fs = require("node:fs");
|
|
22
|
+
const fssync = require("node:fs");
|
|
23
|
+
const os = require("node:os");
|
|
24
|
+
const path = require("node:path");
|
|
25
|
+
const crypto = require("node:crypto");
|
|
26
|
+
const readline = require("node:readline");
|
|
27
|
+
|
|
28
|
+
const USAGE_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
29
|
+
const SKILL_TOKEN_KEYS = [
|
|
30
|
+
"input_tokens",
|
|
31
|
+
"output_tokens",
|
|
32
|
+
"cached_input_tokens",
|
|
33
|
+
"cache_creation_input_tokens",
|
|
34
|
+
"reasoning_output_tokens",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
function claudeProjectsDir(home) {
|
|
38
|
+
return path.join(home || os.homedir(), ".claude", "projects");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function dataDir(home) {
|
|
42
|
+
return path.join(home || os.homedir(), ".tokentracker", "skills");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function usageCachePath(home) {
|
|
46
|
+
return path.join(dataDir(home), "usage-cache.json");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function toInt(value) {
|
|
50
|
+
const n = Number(value);
|
|
51
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Same column mapping as the Claude parser's normalizeClaudeUsage, kept local so
|
|
55
|
+
// this module never imports the heavy rollout parser.
|
|
56
|
+
function normalizeUsage(usage) {
|
|
57
|
+
return {
|
|
58
|
+
input_tokens: toInt(usage?.input_tokens),
|
|
59
|
+
output_tokens: toInt(usage?.output_tokens),
|
|
60
|
+
cached_input_tokens: toInt(usage?.cache_read_input_tokens),
|
|
61
|
+
cache_creation_input_tokens: toInt(usage?.cache_creation_input_tokens),
|
|
62
|
+
reasoning_output_tokens: 0,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function emptyTokens() {
|
|
67
|
+
return {
|
|
68
|
+
input_tokens: 0,
|
|
69
|
+
output_tokens: 0,
|
|
70
|
+
cached_input_tokens: 0,
|
|
71
|
+
cache_creation_input_tokens: 0,
|
|
72
|
+
reasoning_output_tokens: 0,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function addScaledTokens(target, delta, scale) {
|
|
77
|
+
for (const key of SKILL_TOKEN_KEYS) {
|
|
78
|
+
target[key] += (delta[key] || 0) * scale;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function listTranscriptFiles(rootDir) {
|
|
83
|
+
const out = [];
|
|
84
|
+
async function walk(dir) {
|
|
85
|
+
let entries;
|
|
86
|
+
try {
|
|
87
|
+
entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
88
|
+
} catch (_e) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
const full = path.join(dir, entry.name);
|
|
93
|
+
if (entry.isDirectory()) {
|
|
94
|
+
await walk(full);
|
|
95
|
+
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
96
|
+
let stat;
|
|
97
|
+
try {
|
|
98
|
+
stat = await fs.promises.stat(full);
|
|
99
|
+
} catch (_e) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
out.push({ path: full, size: stat.size, mtimeMs: Math.floor(stat.mtimeMs) });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
await walk(rootDir);
|
|
107
|
+
out.sort((a, b) => a.path.localeCompare(b.path));
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function fingerprintFiles(files) {
|
|
112
|
+
const hash = crypto.createHash("sha256");
|
|
113
|
+
for (const file of files) hash.update(`${file.path}:${file.size}:${file.mtimeMs}\n`);
|
|
114
|
+
return `${files.length}:${hash.digest("hex")}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function ensureSkill(map, name) {
|
|
118
|
+
let entry = map.get(name);
|
|
119
|
+
if (!entry) {
|
|
120
|
+
entry = {
|
|
121
|
+
skill: name,
|
|
122
|
+
invocations: 0,
|
|
123
|
+
lastUsedAt: null,
|
|
124
|
+
tokens: emptyTokens(),
|
|
125
|
+
models: {}, // model -> token columns (for per-model pricing downstream)
|
|
126
|
+
};
|
|
127
|
+
map.set(name, entry);
|
|
128
|
+
}
|
|
129
|
+
return entry;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Scan a single transcript for Skill tool_use blocks. Mutates `skillMap` and the
|
|
133
|
+
// shared `seenBlockIds` set (for cross-file de-dup). String pre-filter keeps this
|
|
134
|
+
// cheap — most lines never get JSON.parsed.
|
|
135
|
+
async function scanFile(filePath, skillMap, seenBlockIds) {
|
|
136
|
+
const stream = fssync.createReadStream(filePath, { encoding: "utf8" });
|
|
137
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
138
|
+
try {
|
|
139
|
+
for await (const line of rl) {
|
|
140
|
+
if (!line || line.indexOf('"name":"Skill"') === -1) continue;
|
|
141
|
+
let obj;
|
|
142
|
+
try {
|
|
143
|
+
obj = JSON.parse(line);
|
|
144
|
+
} catch (_e) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const message = obj?.message;
|
|
148
|
+
const content = message?.content;
|
|
149
|
+
if (!Array.isArray(content)) continue;
|
|
150
|
+
|
|
151
|
+
// Collect this turn's fresh (not-yet-seen) Skill invocations first so we
|
|
152
|
+
// can split the turn's usage evenly across them.
|
|
153
|
+
const blocks = [];
|
|
154
|
+
for (const block of content) {
|
|
155
|
+
if (!block || block.type !== "tool_use" || block.name !== "Skill") continue;
|
|
156
|
+
const id = typeof block.id === "string" ? block.id : null;
|
|
157
|
+
if (id && seenBlockIds.has(id)) continue;
|
|
158
|
+
const skillName = String(block?.input?.skill || "").trim();
|
|
159
|
+
if (!skillName) continue;
|
|
160
|
+
if (id) seenBlockIds.add(id);
|
|
161
|
+
blocks.push({ id, skillName });
|
|
162
|
+
}
|
|
163
|
+
if (!blocks.length) continue;
|
|
164
|
+
|
|
165
|
+
const ts = typeof obj?.timestamp === "string" ? obj.timestamp : null;
|
|
166
|
+
const model = String(message?.model || "").trim() || "unknown";
|
|
167
|
+
const turnTokens = normalizeUsage(message?.usage);
|
|
168
|
+
const share = 1 / blocks.length;
|
|
169
|
+
|
|
170
|
+
for (const block of blocks) {
|
|
171
|
+
const entry = ensureSkill(skillMap, block.skillName);
|
|
172
|
+
entry.invocations += 1;
|
|
173
|
+
if (ts && (!entry.lastUsedAt || ts > entry.lastUsedAt)) entry.lastUsedAt = ts;
|
|
174
|
+
addScaledTokens(entry.tokens, turnTokens, share);
|
|
175
|
+
if (!entry.models[model]) entry.models[model] = emptyTokens();
|
|
176
|
+
addScaledTokens(entry.models[model], turnTokens, share);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
} finally {
|
|
180
|
+
rl.close();
|
|
181
|
+
stream.close?.();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function roundTokens(tokens) {
|
|
186
|
+
const out = {};
|
|
187
|
+
for (const key of SKILL_TOKEN_KEYS) out[key] = Math.round(tokens[key] || 0);
|
|
188
|
+
out.total_tokens =
|
|
189
|
+
out.input_tokens +
|
|
190
|
+
out.output_tokens +
|
|
191
|
+
out.cached_input_tokens +
|
|
192
|
+
out.cache_creation_input_tokens +
|
|
193
|
+
out.reasoning_output_tokens;
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function serialize(skillMap, meta) {
|
|
198
|
+
const skills = Array.from(skillMap.values())
|
|
199
|
+
.map((entry) => ({
|
|
200
|
+
skill: entry.skill,
|
|
201
|
+
invocations: entry.invocations,
|
|
202
|
+
lastUsedAt: entry.lastUsedAt,
|
|
203
|
+
tokens: roundTokens(entry.tokens),
|
|
204
|
+
models: Object.fromEntries(
|
|
205
|
+
Object.entries(entry.models).map(([model, tokens]) => [model, roundTokens(tokens)]),
|
|
206
|
+
),
|
|
207
|
+
}))
|
|
208
|
+
.sort((a, b) => b.invocations - a.invocations);
|
|
209
|
+
return { ...meta, skills };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Public entry: scan (or return cached) per-skill invocation + token aggregates.
|
|
213
|
+
// `home` override is for tests/sandboxing. Returns raw aggregates keyed by the
|
|
214
|
+
// skill name exactly as logged — the caller joins against installed skills and
|
|
215
|
+
// applies pricing.
|
|
216
|
+
async function scanSkillUsage({ home, force = false } = {}) {
|
|
217
|
+
const root = claudeProjectsDir(home);
|
|
218
|
+
const files = await listTranscriptFiles(root);
|
|
219
|
+
const fingerprint = fingerprintFiles(files);
|
|
220
|
+
|
|
221
|
+
if (!force) {
|
|
222
|
+
let cached = null;
|
|
223
|
+
try {
|
|
224
|
+
cached = JSON.parse(fssync.readFileSync(usageCachePath(home), "utf8"));
|
|
225
|
+
} catch (_e) {
|
|
226
|
+
cached = null;
|
|
227
|
+
}
|
|
228
|
+
if (
|
|
229
|
+
cached &&
|
|
230
|
+
cached.fingerprint === fingerprint &&
|
|
231
|
+
Number.isFinite(cached.generatedAt) &&
|
|
232
|
+
Date.now() - cached.generatedAt < USAGE_CACHE_TTL_MS &&
|
|
233
|
+
Array.isArray(cached.skills)
|
|
234
|
+
) {
|
|
235
|
+
return { ...cached, cached: true };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const skillMap = new Map();
|
|
240
|
+
const seenBlockIds = new Set();
|
|
241
|
+
for (const file of files) {
|
|
242
|
+
await scanFile(file.path, skillMap, seenBlockIds);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const result = serialize(skillMap, {
|
|
246
|
+
fingerprint,
|
|
247
|
+
generatedAt: Date.now(),
|
|
248
|
+
scannedFiles: files.length,
|
|
249
|
+
totalInvocations: Array.from(skillMap.values()).reduce((sum, s) => sum + s.invocations, 0),
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
fssync.mkdirSync(dataDir(home), { recursive: true });
|
|
254
|
+
fssync.writeFileSync(usageCachePath(home), `${JSON.stringify(result)}\n`, { mode: 0o600 });
|
|
255
|
+
} catch (_e) {
|
|
256
|
+
// best-effort cache write
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return { ...result, cached: false };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
module.exports = {
|
|
263
|
+
scanSkillUsage,
|
|
264
|
+
// exported for tests
|
|
265
|
+
normalizeUsage,
|
|
266
|
+
fingerprintFiles,
|
|
267
|
+
};
|