@ipv9/tokentracker-cli 0.39.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/LICENSE +21 -0
- package/README.ja.md +464 -0
- package/README.ko.md +464 -0
- package/README.md +476 -0
- package/README.zh-CN.md +469 -0
- package/bin/tracker.js +52 -0
- package/dashboard/dist/app-icon.png +0 -0
- package/dashboard/dist/apple-touch-icon.png +0 -0
- package/dashboard/dist/assets/ActivityHeatmap-BxBGHY1z.js +42 -0
- package/dashboard/dist/assets/Card-GROxUc_6.js +1 -0
- package/dashboard/dist/assets/DashboardPage-BZ5VFoBk.js +19 -0
- package/dashboard/dist/assets/DevicePage-CQts7ivB.js +1 -0
- package/dashboard/dist/assets/DialogTitle-DqFsk26U.js +1 -0
- package/dashboard/dist/assets/FadeIn-HBBXW_3q.js +1 -0
- package/dashboard/dist/assets/HeaderGithubStar-COD2HfaF.js +1 -0
- package/dashboard/dist/assets/IpCheckPage-Cn0l8-BQ.js +15 -0
- package/dashboard/dist/assets/LandingPage-CmV1BMU4.js +4356 -0
- package/dashboard/dist/assets/LeaderboardAvatar-CiIpWZwb.js +1 -0
- package/dashboard/dist/assets/LeaderboardPage-BU0mlR3g.js +6 -0
- package/dashboard/dist/assets/LeaderboardProfileModal-C49_3Xm9.js +72 -0
- package/dashboard/dist/assets/LeaderboardProfilePage-Bh1n0BAM.js +1 -0
- package/dashboard/dist/assets/LimitsPage-fZAmwLWT.js +2 -0
- package/dashboard/dist/assets/LocalOnlyNotice-D3rQdA-P.js +1 -0
- package/dashboard/dist/assets/LoginPage-enO4XP-c.js +1 -0
- package/dashboard/dist/assets/PopoverPopup-pitJEpN1.js +1 -0
- package/dashboard/dist/assets/Select-tT9x4ntD.js +1 -0
- package/dashboard/dist/assets/SelectItemText-C7F7eIm0.js +1 -0
- package/dashboard/dist/assets/SettingsPage-pDasZNU2.js +1 -0
- package/dashboard/dist/assets/SkillsPage-DEu7FY-2.js +1 -0
- package/dashboard/dist/assets/WidgetsPage-DGcO7rbp.js +1 -0
- package/dashboard/dist/assets/WrappedPage-D_1ID213.js +1 -0
- package/dashboard/dist/assets/agent-logos-DXQVg1xy.js +1 -0
- package/dashboard/dist/assets/arrow-up-right-DNLo92Ba.js +1 -0
- package/dashboard/dist/assets/download-DbBBBpJF.js +1 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-400-normal-BPBWmzPh.woff +0 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-400-normal-Ce5q_31Z.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-500-normal-CJBLNVQT.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-500-normal-mNhfPmgl.woff +0 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-700-normal-DH5Q319x.woff +0 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-700-normal-VCNRadI3.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-900-normal-BKC3PZkM.woff +0 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-900-normal-DdrQ8KBw.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-400-normal-CoULgQGM.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-400-normal-LC9RFr9I.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-500-normal-D3o2eNa9.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-500-normal-DOxI7kZ4.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-700-normal-D6izGJRP.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-700-normal-QGw08Lff.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-900-normal-CmoKXrdK.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-900-normal-Cu5MFKsu.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-400-normal-Cgks_Qgx.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-400-normal-CxNRRMGd.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-500-normal-CQcGuCNt.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-500-normal-diTenJ8L.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-700-normal-BX9f1BHp.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-700-normal-YOllDaLV.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-900-normal-Br6WNUGb.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-900-normal-CrmBTzU2.woff2 +0 -0
- package/dashboard/dist/assets/index-BeoRn2gJ.js +2 -0
- package/dashboard/dist/assets/info-Bh5qTz6k.js +1 -0
- package/dashboard/dist/assets/main-BIY3Nj6I.js +969 -0
- package/dashboard/dist/assets/main-G_F9Iu_x.css +1 -0
- package/dashboard/dist/assets/use-limits-display-prefs-B4iJ-bBc.js +1 -0
- package/dashboard/dist/assets/use-native-settings-DHjPFDv8.js +1 -0
- package/dashboard/dist/assets/use-usage-limits-odqe9OY7.js +1 -0
- package/dashboard/dist/assets/useCurrency-BsSZiZMP.js +1 -0
- package/dashboard/dist/assets/useOpenInteractionType-BQhgq4rv.js +12 -0
- package/dashboard/dist/brand-logos/antigravity.svg +1 -0
- package/dashboard/dist/brand-logos/claude-code.svg +1 -0
- package/dashboard/dist/brand-logos/codebuddy.svg +1 -0
- package/dashboard/dist/brand-logos/codex.svg +15 -0
- package/dashboard/dist/brand-logos/copilot.svg +1 -0
- package/dashboard/dist/brand-logos/cursor.svg +1 -0
- package/dashboard/dist/brand-logos/gemini.svg +1 -0
- package/dashboard/dist/brand-logos/grok.svg +1 -0
- package/dashboard/dist/brand-logos/hermes.svg +1 -0
- package/dashboard/dist/brand-logos/kilo.svg +4 -0
- package/dashboard/dist/brand-logos/kimi.svg +1 -0
- package/dashboard/dist/brand-logos/kiro.svg +1 -0
- package/dashboard/dist/brand-logos/openclaw.svg +22 -0
- package/dashboard/dist/brand-logos/opencode.svg +3 -0
- package/dashboard/dist/clawd/happy.svg +161 -0
- package/dashboard/dist/clawd/idle/collapse.svg +101 -0
- package/dashboard/dist/clawd/idle/doze.svg +72 -0
- package/dashboard/dist/clawd/idle/follow.svg +64 -0
- package/dashboard/dist/clawd/idle/living.svg +196 -0
- package/dashboard/dist/clawd/idle/look.svg +115 -0
- package/dashboard/dist/clawd/idle/yawn.svg +158 -0
- package/dashboard/dist/clawd/mini/alert.svg +129 -0
- package/dashboard/dist/clawd/mini/crabwalk.svg +76 -0
- package/dashboard/dist/clawd/mini/enter-sleep.svg +65 -0
- package/dashboard/dist/clawd/mini/enter.svg +115 -0
- package/dashboard/dist/clawd/mini/happy.svg +124 -0
- package/dashboard/dist/clawd/mini/idle-tight.svg +15 -0
- package/dashboard/dist/clawd/mini/idle.svg +83 -0
- package/dashboard/dist/clawd/mini/peek.svg +82 -0
- package/dashboard/dist/clawd/mini/sleep.svg +112 -0
- package/dashboard/dist/clawd/react/double.svg +108 -0
- package/dashboard/dist/clawd/react/drag.svg +102 -0
- package/dashboard/dist/clawd/react/left.svg +102 -0
- package/dashboard/dist/clawd/react/right.svg +102 -0
- package/dashboard/dist/clawd/sleep/collapse-sleep.svg +247 -0
- package/dashboard/dist/clawd/sleep/sleeping.svg +118 -0
- package/dashboard/dist/clawd/sleep/wake.svg +277 -0
- package/dashboard/dist/clawd/static-base.svg +21 -0
- package/dashboard/dist/clawd/status/disconnected.svg +136 -0
- package/dashboard/dist/clawd/status/error.svg +94 -0
- package/dashboard/dist/clawd/status/notification.svg +134 -0
- package/dashboard/dist/clawd/working/building.svg +231 -0
- package/dashboard/dist/clawd/working/carrying.svg +107 -0
- package/dashboard/dist/clawd/working/conducting.svg +118 -0
- package/dashboard/dist/clawd/working/confused.svg +113 -0
- package/dashboard/dist/clawd/working/debugger.svg +91 -0
- package/dashboard/dist/clawd/working/juggling.svg +82 -0
- package/dashboard/dist/clawd/working/overheated.svg +75 -0
- package/dashboard/dist/clawd/working/pushing.svg +125 -0
- package/dashboard/dist/clawd/working/sweeping.svg +79 -0
- package/dashboard/dist/clawd/working/thinking.svg +119 -0
- package/dashboard/dist/clawd/working/typing.svg +153 -0
- package/dashboard/dist/clawd/working/ultrathink.svg +166 -0
- package/dashboard/dist/clawd/working/wizard.svg +98 -0
- package/dashboard/dist/dashboard-dark.png +0 -0
- package/dashboard/dist/favicon-16.png +0 -0
- package/dashboard/dist/favicon-32.png +0 -0
- package/dashboard/dist/favicon.ico +0 -0
- package/dashboard/dist/feed.xml +34 -0
- package/dashboard/dist/icon-192.png +0 -0
- package/dashboard/dist/icon-512.png +0 -0
- package/dashboard/dist/index.html +355 -0
- package/dashboard/dist/llms.txt +53 -0
- package/dashboard/dist/neon_bg.png +0 -0
- package/dashboard/dist/robots.txt +7 -0
- package/dashboard/dist/share.html +140 -0
- package/dashboard/dist/sitemap.xml +27 -0
- package/dashboard/dist/widgets-overview.png +0 -0
- package/package.json +78 -0
- package/scripts/install-local-service.sh +140 -0
- package/scripts/uninstall-local-service.sh +21 -0
- package/src/cli.js +95 -0
- package/src/commands/device-login.js +161 -0
- package/src/commands/diagnostics.js +38 -0
- package/src/commands/doctor.js +96 -0
- package/src/commands/init.js +1195 -0
- package/src/commands/serve.js +351 -0
- package/src/commands/status.js +609 -0
- package/src/commands/sync.js +2285 -0
- package/src/commands/uninstall.js +189 -0
- package/src/commands/wrapped.js +150 -0
- package/src/lib/antigravity-paths.js +48 -0
- package/src/lib/browser-auth.js +281 -0
- package/src/lib/categorizer-utils.js +232 -0
- package/src/lib/claude-categorizer.js +1319 -0
- package/src/lib/claude-config.js +204 -0
- package/src/lib/cli-ui.js +179 -0
- package/src/lib/codex-config.js +350 -0
- package/src/lib/codex-context-breakdown.js +400 -0
- package/src/lib/codex-rollout-parser.js +623 -0
- package/src/lib/codex-token-refresh.js +112 -0
- package/src/lib/cursor-config.js +425 -0
- package/src/lib/debug-flags.js +7 -0
- package/src/lib/diagnostics.js +272 -0
- package/src/lib/doctor.js +338 -0
- package/src/lib/fs.js +91 -0
- package/src/lib/gemini-config.js +288 -0
- package/src/lib/grok-hook.js +351 -0
- package/src/lib/init-flow.js +42 -0
- package/src/lib/local-api.js +1861 -0
- package/src/lib/openclaw-hook.js +420 -0
- package/src/lib/openclaw-session-plugin.js +520 -0
- package/src/lib/opencode-config.js +99 -0
- package/src/lib/passive-mode.js +185 -0
- package/src/lib/pricing/curated-overrides.json +79 -0
- package/src/lib/pricing/index.js +143 -0
- package/src/lib/pricing/litellm-fetcher.js +172 -0
- package/src/lib/pricing/matcher.js +255 -0
- package/src/lib/pricing/seed-snapshot.json +1 -0
- package/src/lib/progress.js +76 -0
- package/src/lib/project-usage-purge.js +106 -0
- package/src/lib/prompt.js +24 -0
- package/src/lib/proxy-env.js +131 -0
- package/src/lib/rollout.js +8442 -0
- package/src/lib/runtime-config.js +122 -0
- package/src/lib/skill-usage.js +267 -0
- package/src/lib/skills-manager.js +1127 -0
- package/src/lib/source-metadata.js +48 -0
- package/src/lib/sqlite-reader.js +136 -0
- package/src/lib/static-server.js +59 -0
- package/src/lib/subscriptions.js +453 -0
- package/src/lib/tracker-paths.js +16 -0
- package/src/lib/upload-throttle.js +148 -0
- package/src/lib/usage-limits.js +1806 -0
- package/src/lib/wrapped-aggregator.js +225 -0
|
@@ -0,0 +1,1319 @@
|
|
|
1
|
+
// Claude Code "Context Breakdown" categorizer.
|
|
2
|
+
//
|
|
3
|
+
// Reads ~/.claude/projects/**/*.jsonl and splits each assistant message's
|
|
4
|
+
// usage into seven semantic buckets, mirroring (approximately) the Claude
|
|
5
|
+
// Code in-CLI /context view but as a historical aggregate. Computes on
|
|
6
|
+
// demand — no queue schema changes, no parser changes, no sync changes.
|
|
7
|
+
//
|
|
8
|
+
// Why these seven and not the screenshot's eight: the raw system prompt
|
|
9
|
+
// (which contains tools schema, skills, rules, MCP descriptions) is sent
|
|
10
|
+
// once per session as a 1h-ephemeral cache prefix and is never logged
|
|
11
|
+
// verbatim in the jsonl. So at the token-accounting layer those four are
|
|
12
|
+
// indistinguishable — they all collapse into `system_prefix`. UI says so.
|
|
13
|
+
const fssync = require("node:fs");
|
|
14
|
+
const os = require("node:os");
|
|
15
|
+
const path = require("node:path");
|
|
16
|
+
const crypto = require("node:crypto");
|
|
17
|
+
const readline = require("node:readline");
|
|
18
|
+
|
|
19
|
+
const {
|
|
20
|
+
categorizeTool,
|
|
21
|
+
inferExecCommandKind,
|
|
22
|
+
sanitizeCommandSignature,
|
|
23
|
+
getExecutableName,
|
|
24
|
+
emptyTotals,
|
|
25
|
+
addInto,
|
|
26
|
+
roundTotals,
|
|
27
|
+
buildExecStatsEntry,
|
|
28
|
+
allocateByLargestRemainder,
|
|
29
|
+
} = require("./categorizer-utils");
|
|
30
|
+
const { claudeMessageDedupKey } = require("./rollout");
|
|
31
|
+
|
|
32
|
+
const CATEGORY_KEYS = [
|
|
33
|
+
"system_prefix",
|
|
34
|
+
"conversation_history",
|
|
35
|
+
"user_input",
|
|
36
|
+
"tool_calls",
|
|
37
|
+
"subagents",
|
|
38
|
+
"reasoning",
|
|
39
|
+
"assistant_response",
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const SUBAGENT_TOOL_NAMES = new Set(["Agent", "Task"]);
|
|
43
|
+
|
|
44
|
+
function emptyToolBreakdown() {
|
|
45
|
+
return {
|
|
46
|
+
total_calls: 0,
|
|
47
|
+
tools: [],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function emptyCategoryMap() {
|
|
52
|
+
const out = {};
|
|
53
|
+
for (const key of CATEGORY_KEYS) out[key] = emptyTotals();
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function toNonNegativeNumber(value) {
|
|
58
|
+
const n = Number(value || 0);
|
|
59
|
+
return Number.isFinite(n) ? Math.max(0, n) : 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readClaudeUsageTotals(usage) {
|
|
63
|
+
if (!usage || typeof usage !== "object") return null;
|
|
64
|
+
const input_tokens = toNonNegativeNumber(usage.input_tokens);
|
|
65
|
+
const cached_input_tokens = toNonNegativeNumber(usage.cache_read_input_tokens);
|
|
66
|
+
const cache_creation_input_tokens = toNonNegativeNumber(usage.cache_creation_input_tokens);
|
|
67
|
+
const output_tokens = toNonNegativeNumber(usage.output_tokens);
|
|
68
|
+
const reasoning_output_tokens = toNonNegativeNumber(usage.reasoning_output_tokens);
|
|
69
|
+
const total_tokens =
|
|
70
|
+
input_tokens + cached_input_tokens + cache_creation_input_tokens + output_tokens;
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
input_tokens,
|
|
74
|
+
cached_input_tokens,
|
|
75
|
+
cache_creation_input_tokens,
|
|
76
|
+
output_tokens,
|
|
77
|
+
reasoning_output_tokens,
|
|
78
|
+
total_tokens,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function hasNonZeroClaudeUsage(usage) {
|
|
83
|
+
const totals = readClaudeUsageTotals(usage);
|
|
84
|
+
if (!totals) return false;
|
|
85
|
+
return (
|
|
86
|
+
totals.input_tokens +
|
|
87
|
+
totals.cached_input_tokens +
|
|
88
|
+
totals.cache_creation_input_tokens +
|
|
89
|
+
totals.output_tokens +
|
|
90
|
+
totals.reasoning_output_tokens >
|
|
91
|
+
0
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function extractExecCommands(content) {
|
|
96
|
+
const commands = [];
|
|
97
|
+
for (const block of Array.isArray(content) ? content : []) {
|
|
98
|
+
if (!block || typeof block !== "object") continue;
|
|
99
|
+
if (block.type !== "tool_use") continue;
|
|
100
|
+
if (block.name !== "Bash" && block.name !== "exec_command") continue;
|
|
101
|
+
const input = block.input || {};
|
|
102
|
+
const command =
|
|
103
|
+
typeof input.command === "string" ? input.command
|
|
104
|
+
: typeof input.cmd === "string" ? input.cmd
|
|
105
|
+
: "";
|
|
106
|
+
if (command.trim()) commands.push(command.trim());
|
|
107
|
+
}
|
|
108
|
+
return commands;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function ensureExecRow(map, key) {
|
|
112
|
+
const safeKey = key || "unknown";
|
|
113
|
+
if (!map.has(safeKey)) map.set(safeKey, { name: safeKey, ...buildExecStatsEntry() });
|
|
114
|
+
return map.get(safeKey);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function recordExecCommandUsage(execLedger, commands, totals) {
|
|
118
|
+
if (!execLedger || !Array.isArray(commands) || commands.length === 0) return;
|
|
119
|
+
const perCommandRows = new Map();
|
|
120
|
+
for (const command of commands) {
|
|
121
|
+
if (!perCommandRows.has(command)) perCommandRows.set(command, { calls: 0 });
|
|
122
|
+
perCommandRows.get(command).calls += 1;
|
|
123
|
+
}
|
|
124
|
+
const totalsByCommand = allocateTotalsAcrossRows(totals, perCommandRows);
|
|
125
|
+
|
|
126
|
+
for (const [command, row] of perCommandRows.entries()) {
|
|
127
|
+
const commandTotals = totalsByCommand.get(command) || {};
|
|
128
|
+
const kind = inferExecCommandKind(command);
|
|
129
|
+
const executable = getExecutableName(command);
|
|
130
|
+
const signature = sanitizeCommandSignature(command);
|
|
131
|
+
const exitKey = "unknown:unknown";
|
|
132
|
+
|
|
133
|
+
const targets = [
|
|
134
|
+
[execLedger.by_type, kind],
|
|
135
|
+
[execLedger.by_executable, executable],
|
|
136
|
+
[execLedger.by_command, signature],
|
|
137
|
+
[execLedger.by_exit, exitKey],
|
|
138
|
+
];
|
|
139
|
+
for (const [map, key] of targets) {
|
|
140
|
+
const target = ensureExecRow(map, key);
|
|
141
|
+
target.calls += Math.max(1, Number(row.calls || 0));
|
|
142
|
+
addInto(target.totals, commandTotals);
|
|
143
|
+
}
|
|
144
|
+
execLedger.total_calls += Math.max(1, Number(row.calls || 0));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function allocateTotalsAcrossRows(totals, rows) {
|
|
149
|
+
const entries = Array.from(rows?.entries?.() || []);
|
|
150
|
+
if (entries.length === 0) return new Map();
|
|
151
|
+
const weights = {};
|
|
152
|
+
const order = entries.map(([name]) => name).sort();
|
|
153
|
+
for (const name of order) {
|
|
154
|
+
const row = rows.get(name) || {};
|
|
155
|
+
weights[name] = Math.max(0, Number(row.output_tokens || row.calls || 0));
|
|
156
|
+
}
|
|
157
|
+
if (order.every((name) => !weights[name])) {
|
|
158
|
+
for (const name of order) weights[name] = 1;
|
|
159
|
+
}
|
|
160
|
+
const out = new Map();
|
|
161
|
+
for (const name of order) out.set(name, emptyTotals());
|
|
162
|
+
for (const key of [
|
|
163
|
+
"input_tokens",
|
|
164
|
+
"cached_input_tokens",
|
|
165
|
+
"cache_creation_input_tokens",
|
|
166
|
+
"output_tokens",
|
|
167
|
+
"reasoning_output_tokens",
|
|
168
|
+
"total_tokens",
|
|
169
|
+
]) {
|
|
170
|
+
const alloc = allocateByLargestRemainder(Math.max(0, Number(totals?.[key] || 0)), weights, order);
|
|
171
|
+
for (const name of order) {
|
|
172
|
+
out.get(name)[key] = alloc[name] || 0;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function extractSkillNames(content) {
|
|
179
|
+
const names = [];
|
|
180
|
+
for (const block of Array.isArray(content) ? content : []) {
|
|
181
|
+
if (!block || typeof block !== "object") continue;
|
|
182
|
+
if (block.type !== "tool_use" || block.name !== "Skill") continue;
|
|
183
|
+
const skill = typeof block.input?.skill === "string" ? block.input.skill.trim() : "";
|
|
184
|
+
if (skill) names.push(skill);
|
|
185
|
+
}
|
|
186
|
+
return names;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function recordSkillUsage(skillLedger, skillNames, totals) {
|
|
190
|
+
if (!skillLedger || !Array.isArray(skillNames) || skillNames.length === 0) return;
|
|
191
|
+
const perMessageRows = new Map();
|
|
192
|
+
for (const name of skillNames) {
|
|
193
|
+
if (!perMessageRows.has(name)) perMessageRows.set(name, { calls: 0 });
|
|
194
|
+
perMessageRows.get(name).calls += 1;
|
|
195
|
+
}
|
|
196
|
+
const totalsByName = allocateTotalsAcrossRows(totals, perMessageRows);
|
|
197
|
+
for (const [name, row] of perMessageRows.entries()) {
|
|
198
|
+
if (!skillLedger.by_name.has(name)) {
|
|
199
|
+
skillLedger.by_name.set(name, { name, calls: 0, totals: emptyTotals() });
|
|
200
|
+
}
|
|
201
|
+
const target = skillLedger.by_name.get(name);
|
|
202
|
+
target.calls += row.calls || 0;
|
|
203
|
+
addInto(target.totals, totalsByName.get(name) || {});
|
|
204
|
+
}
|
|
205
|
+
skillLedger.total_calls += skillNames.length;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function computeOutputTokenBreakdown(usage, content) {
|
|
209
|
+
const total = Math.max(0, Number(usage.output_tokens || 0));
|
|
210
|
+
const reasoningExplicit = Math.max(0, Number(usage.reasoning_output_tokens || 0));
|
|
211
|
+
const blocks = Array.isArray(content) ? content : [];
|
|
212
|
+
|
|
213
|
+
if (total === 0) {
|
|
214
|
+
return {
|
|
215
|
+
bucket_tokens: { reasoning: 0, tool_calls: 0, subagents: 0, assistant_response: 0 },
|
|
216
|
+
tool_calls: { total_calls: 0, by_name: new Map() },
|
|
217
|
+
subagents: { total_calls: 0, by_name: new Map() },
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const bucketChars = { reasoning: 0, tool_calls: 0, subagents: 0, assistant_response: 0 };
|
|
222
|
+
const toolCallChars = new Map();
|
|
223
|
+
const subagentChars = new Map();
|
|
224
|
+
const toolCallCounts = new Map();
|
|
225
|
+
const subagentCounts = new Map();
|
|
226
|
+
|
|
227
|
+
let totalChars = 0;
|
|
228
|
+
for (const block of blocks) {
|
|
229
|
+
if (!block || typeof block !== "object") continue;
|
|
230
|
+
const type = block.type;
|
|
231
|
+
let chars = 0;
|
|
232
|
+
|
|
233
|
+
if (type === "thinking") {
|
|
234
|
+
chars = String(block.thinking || block.text || "").length || 1;
|
|
235
|
+
bucketChars.reasoning += chars;
|
|
236
|
+
} else if (type === "text") {
|
|
237
|
+
chars = String(block.text || "").length || 1;
|
|
238
|
+
bucketChars.assistant_response += chars;
|
|
239
|
+
} else if (type === "tool_use") {
|
|
240
|
+
const inputJson = block.input ? JSON.stringify(block.input) : "";
|
|
241
|
+
chars = (block.name || "").length + inputJson.length + 1;
|
|
242
|
+
if (SUBAGENT_TOOL_NAMES.has(block.name)) {
|
|
243
|
+
bucketChars.subagents += chars;
|
|
244
|
+
subagentChars.set(block.name, (subagentChars.get(block.name) || 0) + chars);
|
|
245
|
+
subagentCounts.set(block.name, (subagentCounts.get(block.name) || 0) + 1);
|
|
246
|
+
} else {
|
|
247
|
+
bucketChars.tool_calls += chars;
|
|
248
|
+
toolCallChars.set(block.name, (toolCallChars.get(block.name) || 0) + chars);
|
|
249
|
+
toolCallCounts.set(block.name, (toolCallCounts.get(block.name) || 0) + 1);
|
|
250
|
+
}
|
|
251
|
+
} else {
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
totalChars += chars;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (totalChars === 0) {
|
|
259
|
+
return {
|
|
260
|
+
bucket_tokens: { reasoning: 0, tool_calls: 0, subagents: 0, assistant_response: total },
|
|
261
|
+
tool_calls: { total_calls: 0, by_name: new Map() },
|
|
262
|
+
subagents: { total_calls: 0, by_name: new Map() },
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const explicitReasoning = reasoningExplicit > 0 ? Math.min(reasoningExplicit, total) : 0;
|
|
267
|
+
const nonReasoningOutput = total - explicitReasoning;
|
|
268
|
+
|
|
269
|
+
const allocChars = { ...bucketChars };
|
|
270
|
+
let allocTotalChars = totalChars;
|
|
271
|
+
if (explicitReasoning > 0) {
|
|
272
|
+
allocTotalChars -= allocChars.reasoning;
|
|
273
|
+
allocChars.reasoning = 0;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const order = ["reasoning", "tool_calls", "subagents", "assistant_response"];
|
|
277
|
+
const prorated = allocateByLargestRemainder(Math.max(0, nonReasoningOutput), allocChars, order);
|
|
278
|
+
|
|
279
|
+
const bucketTokens = {
|
|
280
|
+
reasoning: explicitReasoning + (prorated.reasoning || 0),
|
|
281
|
+
tool_calls: prorated.tool_calls || 0,
|
|
282
|
+
subagents: prorated.subagents || 0,
|
|
283
|
+
assistant_response: prorated.assistant_response || 0,
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
if (allocTotalChars <= 0 && nonReasoningOutput > 0) {
|
|
287
|
+
bucketTokens.reasoning = explicitReasoning;
|
|
288
|
+
bucketTokens.tool_calls = 0;
|
|
289
|
+
bucketTokens.subagents = 0;
|
|
290
|
+
bucketTokens.assistant_response = nonReasoningOutput;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const toolTokensByName = new Map();
|
|
294
|
+
if (bucketTokens.tool_calls > 0 && toolCallChars.size > 0) {
|
|
295
|
+
const keys = [...toolCallChars.keys()].sort();
|
|
296
|
+
const weights = {};
|
|
297
|
+
for (const k of keys) weights[k] = toolCallChars.get(k) || 0;
|
|
298
|
+
const alloc = allocateByLargestRemainder(bucketTokens.tool_calls, weights, keys);
|
|
299
|
+
for (const k of keys) toolTokensByName.set(k, alloc[k] || 0);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const subagentTokensByName = new Map();
|
|
303
|
+
if (bucketTokens.subagents > 0 && subagentChars.size > 0) {
|
|
304
|
+
const keys = [...subagentChars.keys()].sort();
|
|
305
|
+
const weights = {};
|
|
306
|
+
for (const k of keys) weights[k] = subagentChars.get(k) || 0;
|
|
307
|
+
const alloc = allocateByLargestRemainder(bucketTokens.subagents, weights, keys);
|
|
308
|
+
for (const k of keys) subagentTokensByName.set(k, alloc[k] || 0);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
bucket_tokens: bucketTokens,
|
|
313
|
+
tool_calls: {
|
|
314
|
+
total_calls: [...toolCallCounts.values()].reduce((a, b) => a + b, 0),
|
|
315
|
+
by_name: new Map(
|
|
316
|
+
[...toolCallCounts.entries()].map(([name, calls]) => [
|
|
317
|
+
name,
|
|
318
|
+
{ name, calls, output_tokens: toolTokensByName.get(name) || 0 },
|
|
319
|
+
]),
|
|
320
|
+
),
|
|
321
|
+
},
|
|
322
|
+
subagents: {
|
|
323
|
+
total_calls: [...subagentCounts.values()].reduce((a, b) => a + b, 0),
|
|
324
|
+
by_name: new Map(
|
|
325
|
+
[...subagentCounts.entries()].map(([name, calls]) => [
|
|
326
|
+
name,
|
|
327
|
+
{ name, calls, output_tokens: subagentTokensByName.get(name) || 0 },
|
|
328
|
+
]),
|
|
329
|
+
),
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function defaultClaudeProjectsDir() {
|
|
335
|
+
return path.join(os.homedir(), ".claude", "projects");
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function listSessionFiles(rootDir) {
|
|
339
|
+
const out = [];
|
|
340
|
+
const stack = [rootDir];
|
|
341
|
+
while (stack.length > 0) {
|
|
342
|
+
const dir = stack.pop();
|
|
343
|
+
let entries;
|
|
344
|
+
try {
|
|
345
|
+
entries = fssync.readdirSync(dir, { withFileTypes: true });
|
|
346
|
+
} catch (_e) {
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
for (const entry of entries) {
|
|
350
|
+
const fp = path.join(dir, entry.name);
|
|
351
|
+
if (entry.isDirectory()) {
|
|
352
|
+
stack.push(fp);
|
|
353
|
+
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
354
|
+
out.push(fp);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return out;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Distribute one assistant message's output tokens across categories by the
|
|
362
|
+
// character-length ratio of each content block. Thinking goes to reasoning,
|
|
363
|
+
// tool_use(Agent|Task) → subagents, tool_use(other) → tool_calls, text →
|
|
364
|
+
// assistant_response. If reasoning_output_tokens is reported separately, use
|
|
365
|
+
// that exact figure for reasoning instead of pro-rating.
|
|
366
|
+
function splitOutputByContent(usage, content, breakdown) {
|
|
367
|
+
const out = computeOutputTokenBreakdown(usage, content);
|
|
368
|
+
for (const key of ["reasoning", "tool_calls", "subagents", "assistant_response"]) {
|
|
369
|
+
const tok = out.bucket_tokens[key] || 0;
|
|
370
|
+
if (tok === 0) continue;
|
|
371
|
+
breakdown[key].output_tokens += tok;
|
|
372
|
+
breakdown[key].total_tokens += tok;
|
|
373
|
+
if (key === "reasoning") breakdown[key].reasoning_output_tokens += tok;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Per-session state lets us pick out the *first* meaningful cache_creation
|
|
378
|
+
// chunk and call that the system_prefix. Subsequent cache_creations are
|
|
379
|
+
// incremental — we attribute them to conversation_history.
|
|
380
|
+
function classifyOneMessage(obj, sessionState, breakdown, toolLedger = null, skillLedger = null, execLedger = null) {
|
|
381
|
+
const usage = obj?.message?.usage;
|
|
382
|
+
if (!usage || typeof usage !== "object") return;
|
|
383
|
+
|
|
384
|
+
const cacheCreate = Math.max(0, Number(usage.cache_creation_input_tokens || 0));
|
|
385
|
+
const cacheRead = Math.max(0, Number(usage.cache_read_input_tokens || 0));
|
|
386
|
+
const inputNonCached = Math.max(0, Number(usage.input_tokens || 0));
|
|
387
|
+
const output = Math.max(0, Number(usage.output_tokens || 0));
|
|
388
|
+
|
|
389
|
+
// input_tokens (pure non-cached) → user_input
|
|
390
|
+
if (inputNonCached > 0) {
|
|
391
|
+
breakdown.user_input.input_tokens += inputNonCached;
|
|
392
|
+
breakdown.user_input.total_tokens += inputNonCached;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// cache_read_input_tokens → conversation_history (replaying earlier turns)
|
|
396
|
+
if (cacheRead > 0) {
|
|
397
|
+
breakdown.conversation_history.cached_input_tokens += cacheRead;
|
|
398
|
+
breakdown.conversation_history.total_tokens += cacheRead;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// cache_creation_input_tokens: first big block of a session = system_prefix,
|
|
402
|
+
// everything after = incremental conversation history.
|
|
403
|
+
if (cacheCreate > 0) {
|
|
404
|
+
if (!sessionState.systemPrefixSeen) {
|
|
405
|
+
breakdown.system_prefix.cache_creation_input_tokens += cacheCreate;
|
|
406
|
+
breakdown.system_prefix.total_tokens += cacheCreate;
|
|
407
|
+
sessionState.systemPrefixSeen = true;
|
|
408
|
+
} else {
|
|
409
|
+
breakdown.conversation_history.cache_creation_input_tokens += cacheCreate;
|
|
410
|
+
breakdown.conversation_history.total_tokens += cacheCreate;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
recordSkillUsage(
|
|
415
|
+
skillLedger,
|
|
416
|
+
extractSkillNames(obj?.message?.content),
|
|
417
|
+
{
|
|
418
|
+
input_tokens: inputNonCached,
|
|
419
|
+
cached_input_tokens: cacheRead,
|
|
420
|
+
cache_creation_input_tokens: cacheCreate,
|
|
421
|
+
output_tokens: output,
|
|
422
|
+
reasoning_output_tokens: 0,
|
|
423
|
+
total_tokens: inputNonCached + cacheRead + cacheCreate + output,
|
|
424
|
+
},
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
// Split output across reasoning / tool_calls / subagents / assistant_response.
|
|
428
|
+
if (output > 0) {
|
|
429
|
+
const out = computeOutputTokenBreakdown(
|
|
430
|
+
{ output_tokens: output, reasoning_output_tokens: usage.reasoning_output_tokens },
|
|
431
|
+
obj?.message?.content,
|
|
432
|
+
);
|
|
433
|
+
for (const key of ["reasoning", "tool_calls", "subagents", "assistant_response"]) {
|
|
434
|
+
const tok = out.bucket_tokens[key] || 0;
|
|
435
|
+
if (tok === 0) continue;
|
|
436
|
+
breakdown[key].output_tokens += tok;
|
|
437
|
+
breakdown[key].total_tokens += tok;
|
|
438
|
+
if (key === "reasoning") breakdown[key].reasoning_output_tokens += tok;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
recordExecCommandUsage(
|
|
442
|
+
execLedger,
|
|
443
|
+
extractExecCommands(obj?.message?.content),
|
|
444
|
+
{
|
|
445
|
+
input_tokens: inputNonCached,
|
|
446
|
+
cached_input_tokens: cacheRead,
|
|
447
|
+
cache_creation_input_tokens: cacheCreate,
|
|
448
|
+
output_tokens: out.bucket_tokens.tool_calls || 0,
|
|
449
|
+
reasoning_output_tokens: 0,
|
|
450
|
+
total_tokens: inputNonCached + cacheRead + cacheCreate + (out.bucket_tokens.tool_calls || 0),
|
|
451
|
+
},
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
if (toolLedger) {
|
|
455
|
+
const ledgerTotals = {
|
|
456
|
+
input_tokens: inputNonCached,
|
|
457
|
+
cached_input_tokens: cacheRead,
|
|
458
|
+
cache_creation_input_tokens: cacheCreate,
|
|
459
|
+
output_tokens: out.bucket_tokens.tool_calls || 0,
|
|
460
|
+
reasoning_output_tokens: 0,
|
|
461
|
+
total_tokens: inputNonCached + cacheRead + cacheCreate + (out.bucket_tokens.tool_calls || 0),
|
|
462
|
+
};
|
|
463
|
+
const toolTotalsByName = allocateTotalsAcrossRows(ledgerTotals, out.tool_calls.by_name);
|
|
464
|
+
for (const [name, row] of out.tool_calls.by_name.entries()) {
|
|
465
|
+
if (!toolLedger.tool_calls.by_name.has(name)) {
|
|
466
|
+
toolLedger.tool_calls.by_name.set(name, { name, calls: 0, totals: emptyTotals() });
|
|
467
|
+
}
|
|
468
|
+
const target = toolLedger.tool_calls.by_name.get(name);
|
|
469
|
+
target.calls += row.calls || 0;
|
|
470
|
+
addInto(target.totals, toolTotalsByName.get(name) || {});
|
|
471
|
+
}
|
|
472
|
+
toolLedger.tool_calls.total_calls += out.tool_calls.total_calls || 0;
|
|
473
|
+
|
|
474
|
+
const subagentTotals = {
|
|
475
|
+
input_tokens: inputNonCached,
|
|
476
|
+
cached_input_tokens: cacheRead,
|
|
477
|
+
cache_creation_input_tokens: cacheCreate,
|
|
478
|
+
output_tokens: out.bucket_tokens.subagents || 0,
|
|
479
|
+
reasoning_output_tokens: 0,
|
|
480
|
+
total_tokens: inputNonCached + cacheRead + cacheCreate + (out.bucket_tokens.subagents || 0),
|
|
481
|
+
};
|
|
482
|
+
const subagentTotalsByName = allocateTotalsAcrossRows(subagentTotals, out.subagents.by_name);
|
|
483
|
+
for (const [name, row] of out.subagents.by_name.entries()) {
|
|
484
|
+
if (!toolLedger.subagents.by_name.has(name)) {
|
|
485
|
+
toolLedger.subagents.by_name.set(name, { name, calls: 0, totals: emptyTotals() });
|
|
486
|
+
}
|
|
487
|
+
const target = toolLedger.subagents.by_name.get(name);
|
|
488
|
+
target.calls += row.calls || 0;
|
|
489
|
+
addInto(target.totals, subagentTotalsByName.get(name) || {});
|
|
490
|
+
}
|
|
491
|
+
toolLedger.subagents.total_calls += out.subagents.total_calls || 0;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Read one session jsonl streaming, in timestamp range, dedup by msgId+reqId.
|
|
497
|
+
async function categorizeSessionFile(filePath, { fromIso, toIso, seenHashes }, breakdown, toolLedger = null, skillLedger = null, execLedger = null) {
|
|
498
|
+
let stream;
|
|
499
|
+
try {
|
|
500
|
+
stream = fssync.createReadStream(filePath, { encoding: "utf8" });
|
|
501
|
+
} catch (_e) {
|
|
502
|
+
return 0;
|
|
503
|
+
}
|
|
504
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
505
|
+
const sessionState = { systemPrefixSeen: false };
|
|
506
|
+
let counted = 0;
|
|
507
|
+
|
|
508
|
+
for await (const line of rl) {
|
|
509
|
+
if (!line || !line.includes('"usage"')) continue;
|
|
510
|
+
let obj;
|
|
511
|
+
try {
|
|
512
|
+
obj = JSON.parse(line);
|
|
513
|
+
} catch (_e) {
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
const ts = typeof obj?.timestamp === "string" ? obj.timestamp : null;
|
|
517
|
+
if (!ts) continue;
|
|
518
|
+
if (fromIso && ts < fromIso) continue;
|
|
519
|
+
if (toIso && ts > toIso) continue;
|
|
520
|
+
|
|
521
|
+
const hash = claudeMessageDedupKey(obj);
|
|
522
|
+
if (hash && seenHashes.has(hash)) continue;
|
|
523
|
+
|
|
524
|
+
// Defer the hash-add until we've confirmed this entry carries non-zero
|
|
525
|
+
// usage. Mirrors the same fix in rollout.js parseClaudeFile. Without it,
|
|
526
|
+
// a zero-token streaming snapshot (e.g. Mimo's pre-response thinking
|
|
527
|
+
// entry that shares the final response's message.id) would claim the
|
|
528
|
+
// bare-msgId dedup key — Mimo has no requestId, so claudeMessageDedupKey
|
|
529
|
+
// falls back to bare msgId — and pre-empt the real token-bearing entry,
|
|
530
|
+
// collapsing Mimo into a tiny fraction of its real total.
|
|
531
|
+
const usage = obj?.message?.usage;
|
|
532
|
+
if (!hasNonZeroClaudeUsage(usage)) continue;
|
|
533
|
+
|
|
534
|
+
if (hash) seenHashes.add(hash);
|
|
535
|
+
|
|
536
|
+
classifyOneMessage(obj, sessionState, breakdown, toolLedger, skillLedger, execLedger);
|
|
537
|
+
counted += 1;
|
|
538
|
+
}
|
|
539
|
+
rl.close();
|
|
540
|
+
stream.close?.();
|
|
541
|
+
return counted;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Convert a YYYY-MM-DD day key (already in the user's tz from the API call)
|
|
545
|
+
// into an inclusive ISO range. We still match against UTC timestamps in the
|
|
546
|
+
// jsonl, so we widen by ±14h to be safe across timezones — totals are
|
|
547
|
+
// post-filtered against the queue's authoritative UTC totals anyway, this
|
|
548
|
+
// view is approximate by design.
|
|
549
|
+
function dayKeyToIsoBounds(from, to) {
|
|
550
|
+
if (!from && !to) return { fromIso: null, toIso: null };
|
|
551
|
+
const fromDate = from ? new Date(`${from}T00:00:00Z`) : null;
|
|
552
|
+
const toDate = to ? new Date(`${to}T23:59:59Z`) : null;
|
|
553
|
+
if (fromDate && Number.isFinite(fromDate.getTime())) {
|
|
554
|
+
fromDate.setUTCHours(fromDate.getUTCHours() - 14);
|
|
555
|
+
}
|
|
556
|
+
if (toDate && Number.isFinite(toDate.getTime())) {
|
|
557
|
+
toDate.setUTCHours(toDate.getUTCHours() + 14);
|
|
558
|
+
}
|
|
559
|
+
return {
|
|
560
|
+
fromIso: fromDate ? fromDate.toISOString() : null,
|
|
561
|
+
toIso: toDate ? toDate.toISOString() : null,
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Cache: keyed on (rootDir|from|to|files.length|maxMtime). The key already
|
|
566
|
+
// changes the moment any session file is touched, so a long TTL is safe — we
|
|
567
|
+
// don't need a short window as a "safety net". Mirror the in-memory cache
|
|
568
|
+
// to disk so a fresh tracker process (e.g. menu bar app restart) doesn't pay
|
|
569
|
+
// the cold-scan cost again.
|
|
570
|
+
const CACHE = new Map();
|
|
571
|
+
const CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6h
|
|
572
|
+
const CACHE_SCHEMA_VERSION = "skills-exec-v3";
|
|
573
|
+
const DISK_CACHE_DIR = path.join(os.homedir(), ".tokentracker", "cache", "claude-categorizer");
|
|
574
|
+
|
|
575
|
+
function cacheKeyHash(key) {
|
|
576
|
+
return crypto.createHash("sha1").update(key).digest("hex").slice(0, 32);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function readDiskCache(key) {
|
|
580
|
+
try {
|
|
581
|
+
const fp = path.join(DISK_CACHE_DIR, `${cacheKeyHash(key)}.json`);
|
|
582
|
+
const raw = fssync.readFileSync(fp, "utf8");
|
|
583
|
+
const obj = JSON.parse(raw);
|
|
584
|
+
if (!obj || obj.schemaVersion !== CACHE_SCHEMA_VERSION) return null;
|
|
585
|
+
if (Date.now() - Number(obj.at || 0) >= CACHE_TTL_MS) return null;
|
|
586
|
+
return obj.value;
|
|
587
|
+
} catch (_e) {
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function writeDiskCache(key, value) {
|
|
593
|
+
try {
|
|
594
|
+
fssync.mkdirSync(DISK_CACHE_DIR, { recursive: true });
|
|
595
|
+
const fp = path.join(DISK_CACHE_DIR, `${cacheKeyHash(key)}.json`);
|
|
596
|
+
const payload = { schemaVersion: CACHE_SCHEMA_VERSION, at: Date.now(), value };
|
|
597
|
+
fssync.writeFileSync(fp, JSON.stringify(payload));
|
|
598
|
+
// Bound on-disk size; categorizer is cheap to recompute when miss.
|
|
599
|
+
try {
|
|
600
|
+
const entries = fssync.readdirSync(DISK_CACHE_DIR)
|
|
601
|
+
.filter((n) => n.endsWith(".json"))
|
|
602
|
+
.map((n) => {
|
|
603
|
+
const p = path.join(DISK_CACHE_DIR, n);
|
|
604
|
+
let mtime = 0;
|
|
605
|
+
try { mtime = fssync.statSync(p).mtimeMs; } catch (_e) {}
|
|
606
|
+
return { p, mtime };
|
|
607
|
+
})
|
|
608
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
609
|
+
for (const e of entries.slice(16)) {
|
|
610
|
+
try { fssync.unlinkSync(e.p); } catch (_e) {}
|
|
611
|
+
}
|
|
612
|
+
} catch (_e) {}
|
|
613
|
+
} catch (_e) {}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function maxMtimeMs(files) {
|
|
617
|
+
let max = 0;
|
|
618
|
+
for (const fp of files) {
|
|
619
|
+
try {
|
|
620
|
+
const st = fssync.statSync(fp);
|
|
621
|
+
if (st.mtimeMs > max) max = st.mtimeMs;
|
|
622
|
+
} catch (_e) {}
|
|
623
|
+
}
|
|
624
|
+
return max;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async function computeClaudeCategoryBreakdown({ from = null, to = null, rootDir = null, projectDir = null } = {}) {
|
|
628
|
+
const root = rootDir || defaultClaudeProjectsDir();
|
|
629
|
+
let files = [];
|
|
630
|
+
try {
|
|
631
|
+
files = listSessionFiles(root);
|
|
632
|
+
} catch (_e) {
|
|
633
|
+
return {
|
|
634
|
+
source: "claude",
|
|
635
|
+
scope: "supported",
|
|
636
|
+
totals: emptyTotals(),
|
|
637
|
+
categories: CATEGORY_KEYS.map((key) => ({
|
|
638
|
+
key,
|
|
639
|
+
totals: emptyTotals(),
|
|
640
|
+
percent: 0,
|
|
641
|
+
})),
|
|
642
|
+
session_count: 0,
|
|
643
|
+
message_count: 0,
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const cacheKey = `${CACHE_SCHEMA_VERSION}|${root}|${from || ""}|${to || ""}|${files.length}|${maxMtimeMs(files)}`;
|
|
648
|
+
const cached = CACHE.get(cacheKey);
|
|
649
|
+
if (cached && Date.now() - cached.at < CACHE_TTL_MS) {
|
|
650
|
+
return cached.value;
|
|
651
|
+
}
|
|
652
|
+
const onDisk = readDiskCache(cacheKey);
|
|
653
|
+
if (onDisk) {
|
|
654
|
+
CACHE.set(cacheKey, { at: Date.now(), value: onDisk });
|
|
655
|
+
return onDisk;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const { fromIso, toIso } = dayKeyToIsoBounds(from, to);
|
|
659
|
+
const breakdown = emptyCategoryMap();
|
|
660
|
+
const seenHashes = new Set();
|
|
661
|
+
let messageCount = 0;
|
|
662
|
+
let sessionCount = 0;
|
|
663
|
+
const toolLedger = {
|
|
664
|
+
tool_calls: { total_calls: 0, by_name: new Map() },
|
|
665
|
+
subagents: { total_calls: 0, by_name: new Map() },
|
|
666
|
+
};
|
|
667
|
+
const skillLedger = { total_calls: 0, by_name: new Map() };
|
|
668
|
+
const execLedger = {
|
|
669
|
+
total_calls: 0,
|
|
670
|
+
by_type: new Map(),
|
|
671
|
+
by_executable: new Map(),
|
|
672
|
+
by_command: new Map(),
|
|
673
|
+
by_exit: new Map(),
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
// Process files with bounded parallelism. CPU-bound (JSON.parse per line)
|
|
677
|
+
// limits the win, but overlapping the per-file fs.open + first-block read
|
|
678
|
+
// I/O behind the previous file's parsing still shaves ~15% off cold scans.
|
|
679
|
+
// `seenHashes`/`breakdown`/ledgers are mutated only inside synchronous
|
|
680
|
+
// sections of `classifyOneMessage`; `for await` in `categorizeSessionFile`
|
|
681
|
+
// only yields between lines, so workers can't tear shared state.
|
|
682
|
+
const SCAN_CONCURRENCY = 4;
|
|
683
|
+
let cursor = 0;
|
|
684
|
+
async function worker() {
|
|
685
|
+
while (cursor < files.length) {
|
|
686
|
+
const idx = cursor++;
|
|
687
|
+
if (idx >= files.length) return;
|
|
688
|
+
const counted = await categorizeSessionFile(
|
|
689
|
+
files[idx],
|
|
690
|
+
{ fromIso, toIso, seenHashes },
|
|
691
|
+
breakdown,
|
|
692
|
+
toolLedger,
|
|
693
|
+
skillLedger,
|
|
694
|
+
execLedger,
|
|
695
|
+
);
|
|
696
|
+
if (counted > 0) sessionCount += 1;
|
|
697
|
+
messageCount += counted;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
await Promise.all(Array.from({ length: SCAN_CONCURRENCY }, () => worker()));
|
|
701
|
+
|
|
702
|
+
const totals = emptyTotals();
|
|
703
|
+
for (const key of CATEGORY_KEYS) addInto(totals, breakdown[key]);
|
|
704
|
+
|
|
705
|
+
const result = {
|
|
706
|
+
source: "claude",
|
|
707
|
+
scope: "supported",
|
|
708
|
+
totals,
|
|
709
|
+
categories: CATEGORY_KEYS.map((key) => {
|
|
710
|
+
const t = breakdown[key];
|
|
711
|
+
const percent = totals.total_tokens > 0
|
|
712
|
+
? Number(((t.total_tokens / totals.total_tokens) * 100).toFixed(2))
|
|
713
|
+
: 0;
|
|
714
|
+
return { key, totals: t, percent };
|
|
715
|
+
}),
|
|
716
|
+
session_count: sessionCount,
|
|
717
|
+
message_count: messageCount,
|
|
718
|
+
message_breakdown: buildMessageBreakdown(breakdown),
|
|
719
|
+
tool_calls_breakdown: buildToolCallsBreakdown(toolLedger),
|
|
720
|
+
skills_breakdown: buildSkillsBreakdown(skillLedger),
|
|
721
|
+
exec_command_breakdown: buildExecCommandBreakdown(execLedger),
|
|
722
|
+
configured_resources: getConfiguredResources({ projectDir }),
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
CACHE.set(cacheKey, { at: Date.now(), value: result });
|
|
726
|
+
writeDiskCache(cacheKey, result);
|
|
727
|
+
// Bound cache size — categorizer is cheap to recompute, no point hoarding.
|
|
728
|
+
while (CACHE.size > 32) {
|
|
729
|
+
const oldest = [...CACHE.entries()].sort((a, b) => a[1].at - b[1].at)[0];
|
|
730
|
+
if (oldest) CACHE.delete(oldest[0]);
|
|
731
|
+
}
|
|
732
|
+
return result;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function buildToolCallsBreakdown(toolLedger) {
|
|
736
|
+
if (!toolLedger || !toolLedger.tool_calls || !toolLedger.subagents) {
|
|
737
|
+
return {
|
|
738
|
+
tool_calls: emptyToolBreakdown(),
|
|
739
|
+
subagents: emptyToolBreakdown(),
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function mapToSortedRows(map) {
|
|
744
|
+
const rows = Array.from(map.values()).map((row) => ({
|
|
745
|
+
name: row.name,
|
|
746
|
+
calls: row.calls,
|
|
747
|
+
totals: row.totals || {
|
|
748
|
+
...emptyTotals(),
|
|
749
|
+
output_tokens: row.output_tokens || 0,
|
|
750
|
+
total_tokens: row.total_tokens || row.output_tokens || 0,
|
|
751
|
+
},
|
|
752
|
+
}));
|
|
753
|
+
rows.sort((a, b) => (b.totals?.total_tokens || 0) - (a.totals?.total_tokens || 0));
|
|
754
|
+
return rows;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const toolCallsRows = mapToSortedRows(toolLedger.tool_calls.by_name || new Map());
|
|
758
|
+
const subagentRows = mapToSortedRows(toolLedger.subagents.by_name || new Map());
|
|
759
|
+
|
|
760
|
+
function groupIntoCategories(rows) {
|
|
761
|
+
const byCategory = new Map(); // name -> {name,calls,totals,tools:[]}
|
|
762
|
+
for (const row of rows) {
|
|
763
|
+
const toolName = String(row?.name || "");
|
|
764
|
+
if (!toolName) continue;
|
|
765
|
+
const cat = categorizeTool(toolName);
|
|
766
|
+
if (!byCategory.has(cat)) {
|
|
767
|
+
byCategory.set(cat, {
|
|
768
|
+
name: cat,
|
|
769
|
+
calls: 0,
|
|
770
|
+
totals: emptyTotals(),
|
|
771
|
+
tools: [],
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
const bucket = byCategory.get(cat);
|
|
775
|
+
bucket.calls += Number(row.calls || 0);
|
|
776
|
+
addInto(bucket.totals, row.totals || {});
|
|
777
|
+
bucket.tools.push({
|
|
778
|
+
name: toolName,
|
|
779
|
+
calls: Number(row.calls || 0),
|
|
780
|
+
totals: row.totals || emptyTotals(),
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
const categories = Array.from(byCategory.values())
|
|
784
|
+
.map((c) => ({
|
|
785
|
+
name: c.name,
|
|
786
|
+
calls: Math.round(c.calls || 0),
|
|
787
|
+
totals: {
|
|
788
|
+
input_tokens: Math.round(c.totals.input_tokens || 0),
|
|
789
|
+
cached_input_tokens: Math.round(c.totals.cached_input_tokens || 0),
|
|
790
|
+
cache_creation_input_tokens: Math.round(c.totals.cache_creation_input_tokens || 0),
|
|
791
|
+
output_tokens: Math.round(c.totals.output_tokens || 0),
|
|
792
|
+
reasoning_output_tokens: Math.round(c.totals.reasoning_output_tokens || 0),
|
|
793
|
+
total_tokens: Math.round(c.totals.total_tokens || 0),
|
|
794
|
+
},
|
|
795
|
+
tools: c.tools
|
|
796
|
+
.sort((a, b) => (b.totals?.total_tokens || 0) - (a.totals?.total_tokens || 0))
|
|
797
|
+
.map((t) => ({
|
|
798
|
+
name: t.name,
|
|
799
|
+
calls: Math.round(t.calls || 0),
|
|
800
|
+
totals: {
|
|
801
|
+
input_tokens: Math.round(t.totals.input_tokens || 0),
|
|
802
|
+
cached_input_tokens: Math.round(t.totals.cached_input_tokens || 0),
|
|
803
|
+
cache_creation_input_tokens: Math.round(t.totals.cache_creation_input_tokens || 0),
|
|
804
|
+
output_tokens: Math.round(t.totals.output_tokens || 0),
|
|
805
|
+
reasoning_output_tokens: Math.round(t.totals.reasoning_output_tokens || 0),
|
|
806
|
+
total_tokens: Math.round(t.totals.total_tokens || 0),
|
|
807
|
+
},
|
|
808
|
+
})),
|
|
809
|
+
}))
|
|
810
|
+
.sort((a, b) => (b.totals?.total_tokens || 0) - (a.totals?.total_tokens || 0));
|
|
811
|
+
return categories;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
return {
|
|
815
|
+
tool_calls: {
|
|
816
|
+
total_calls: toolLedger.tool_calls.total_calls || 0,
|
|
817
|
+
tools: toolCallsRows,
|
|
818
|
+
categories: groupIntoCategories(toolCallsRows),
|
|
819
|
+
},
|
|
820
|
+
subagents: {
|
|
821
|
+
total_calls: toolLedger.subagents.total_calls || 0,
|
|
822
|
+
tools: subagentRows,
|
|
823
|
+
categories: groupIntoCategories(subagentRows),
|
|
824
|
+
},
|
|
825
|
+
privacy: {
|
|
826
|
+
includes_inputs: false,
|
|
827
|
+
note: "Aggregated tool names only; tool inputs are never recorded.",
|
|
828
|
+
},
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function buildSkillsBreakdown(skillLedger) {
|
|
833
|
+
const rows = Array.from(skillLedger?.by_name?.values?.() || [])
|
|
834
|
+
.map((row) => ({
|
|
835
|
+
name: row.name,
|
|
836
|
+
calls: Math.round(row.calls || 0),
|
|
837
|
+
totals: roundTotals(row.totals || emptyTotals()),
|
|
838
|
+
}))
|
|
839
|
+
.sort((a, b) => (b.totals?.total_tokens || 0) - (a.totals?.total_tokens || 0));
|
|
840
|
+
|
|
841
|
+
return {
|
|
842
|
+
total_calls: Math.round(skillLedger?.total_calls || 0),
|
|
843
|
+
skills: rows,
|
|
844
|
+
privacy: {
|
|
845
|
+
includes_inputs: false,
|
|
846
|
+
note: "Aggregated skill names only; skill inputs are never returned.",
|
|
847
|
+
},
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function serializeExecRows(map) {
|
|
852
|
+
return Array.from(map?.values?.() || [])
|
|
853
|
+
.map((row) => ({
|
|
854
|
+
name: row.name,
|
|
855
|
+
calls: Math.round(row.calls || 0),
|
|
856
|
+
failures: Math.round(row.failures || 0),
|
|
857
|
+
duration_ms: Math.round(row.duration_ms || 0),
|
|
858
|
+
max_duration_ms: Math.round(row.max_duration_ms || 0),
|
|
859
|
+
output_chars: Math.round(row.output_chars || 0),
|
|
860
|
+
output_lines: Math.round(row.output_lines || 0),
|
|
861
|
+
totals: roundTotals(row.totals || emptyTotals()),
|
|
862
|
+
}))
|
|
863
|
+
.sort((a, b) => (b.totals?.total_tokens || 0) - (a.totals?.total_tokens || 0));
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function buildExecCommandBreakdown(execLedger) {
|
|
867
|
+
return {
|
|
868
|
+
total_calls: Math.round(execLedger?.total_calls || 0),
|
|
869
|
+
by_type: serializeExecRows(execLedger?.by_type),
|
|
870
|
+
by_executable: serializeExecRows(execLedger?.by_executable),
|
|
871
|
+
by_command: serializeExecRows(execLedger?.by_command),
|
|
872
|
+
by_duration: [],
|
|
873
|
+
by_output: [],
|
|
874
|
+
by_exit: serializeExecRows(execLedger?.by_exit),
|
|
875
|
+
privacy: {
|
|
876
|
+
includes_commands: false,
|
|
877
|
+
note: "Claude Bash commands are grouped into sanitized signatures; raw commands are not returned.",
|
|
878
|
+
},
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function buildMessageBreakdown(breakdown) {
|
|
883
|
+
const rows = [
|
|
884
|
+
{
|
|
885
|
+
key: "user_input",
|
|
886
|
+
name: "User input",
|
|
887
|
+
totals: breakdown.user_input || emptyTotals(),
|
|
888
|
+
},
|
|
889
|
+
{
|
|
890
|
+
key: "conversation_history",
|
|
891
|
+
name: "Conversation history",
|
|
892
|
+
totals: breakdown.conversation_history || emptyTotals(),
|
|
893
|
+
},
|
|
894
|
+
{
|
|
895
|
+
key: "assistant_response",
|
|
896
|
+
name: "Assistant response",
|
|
897
|
+
totals: breakdown.assistant_response || emptyTotals(),
|
|
898
|
+
},
|
|
899
|
+
];
|
|
900
|
+
|
|
901
|
+
return {
|
|
902
|
+
categories: rows
|
|
903
|
+
.map((row) => ({
|
|
904
|
+
key: row.key,
|
|
905
|
+
name: row.name,
|
|
906
|
+
totals: {
|
|
907
|
+
input_tokens: Math.round(row.totals.input_tokens || 0),
|
|
908
|
+
cached_input_tokens: Math.round(row.totals.cached_input_tokens || 0),
|
|
909
|
+
cache_creation_input_tokens: Math.round(row.totals.cache_creation_input_tokens || 0),
|
|
910
|
+
output_tokens: Math.round(row.totals.output_tokens || 0),
|
|
911
|
+
reasoning_output_tokens: Math.round(row.totals.reasoning_output_tokens || 0),
|
|
912
|
+
total_tokens: Math.round(row.totals.total_tokens || 0),
|
|
913
|
+
},
|
|
914
|
+
}))
|
|
915
|
+
.sort((a, b) => (b.totals?.total_tokens || 0) - (a.totals?.total_tokens || 0)),
|
|
916
|
+
privacy: {
|
|
917
|
+
includes_content: false,
|
|
918
|
+
note: "Aggregated message token categories only; prompt and assistant text are never returned.",
|
|
919
|
+
},
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Lightweight on-disk count of static resources Claude Code's /context UI
|
|
924
|
+
// also surfaces (Skills, MCP servers, Memory files, Custom agents). These are
|
|
925
|
+
// counts of what's *installed*, not historical token usage — the same way
|
|
926
|
+
// /context shows "MCP tools 0 (115)" with the install count in parens. Lets
|
|
927
|
+
// the dashboard match that vocabulary even though token-level separation
|
|
928
|
+
// from the system prompt isn't possible from the rollout logs alone.
|
|
929
|
+
function countDirEntries(dir, predicate) {
|
|
930
|
+
let entries;
|
|
931
|
+
try {
|
|
932
|
+
entries = fssync.readdirSync(dir, { withFileTypes: true });
|
|
933
|
+
} catch (_e) {
|
|
934
|
+
return 0;
|
|
935
|
+
}
|
|
936
|
+
return entries.filter(predicate).length;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function fileExists(fp) {
|
|
940
|
+
try {
|
|
941
|
+
return fssync.statSync(fp).isFile();
|
|
942
|
+
} catch (_e) {
|
|
943
|
+
return false;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function safeReadJson(fp) {
|
|
948
|
+
try {
|
|
949
|
+
return JSON.parse(fssync.readFileSync(fp, "utf8"));
|
|
950
|
+
} catch (_e) {
|
|
951
|
+
return null;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// Walk @./path imports recursively. Claude Code expands @file references
|
|
956
|
+
// inside CLAUDE.md into separate memory entries; /context counts them.
|
|
957
|
+
function collectMemoryImports(filePath, seen) {
|
|
958
|
+
if (!filePath || seen.has(filePath)) return;
|
|
959
|
+
seen.add(filePath);
|
|
960
|
+
let raw;
|
|
961
|
+
try {
|
|
962
|
+
raw = fssync.readFileSync(filePath, "utf8");
|
|
963
|
+
} catch (_e) {
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
const dir = path.dirname(filePath);
|
|
967
|
+
// Match `@path/to/file.md` (CC's import syntax), but skip `@user@host` and
|
|
968
|
+
// `email@host` patterns by requiring a path-like suffix.
|
|
969
|
+
const re = /(?:^|\s)@([./~][^\s)]+\.md)\b/g;
|
|
970
|
+
let m;
|
|
971
|
+
while ((m = re.exec(raw)) !== null) {
|
|
972
|
+
let target = m[1];
|
|
973
|
+
if (target.startsWith("~")) target = path.join(os.homedir(), target.slice(1).replace(/^\//, ""));
|
|
974
|
+
else if (!path.isAbsolute(target)) target = path.resolve(dir, target);
|
|
975
|
+
if (fileExists(target)) collectMemoryImports(target, seen);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function findLatestPluginVersionDir(pluginCacheRoot) {
|
|
980
|
+
let entries;
|
|
981
|
+
try {
|
|
982
|
+
entries = fssync.readdirSync(pluginCacheRoot, { withFileTypes: true });
|
|
983
|
+
} catch (_e) {
|
|
984
|
+
return null;
|
|
985
|
+
}
|
|
986
|
+
const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
987
|
+
if (dirs.length === 0) return null;
|
|
988
|
+
// Pick highest semver-ish lex sort fallback. CC keeps the active version
|
|
989
|
+
// path under the plugin's cache; if multiple versions linger, the lexically
|
|
990
|
+
// largest is usually the latest installed.
|
|
991
|
+
dirs.sort();
|
|
992
|
+
return path.join(pluginCacheRoot, dirs[dirs.length - 1]);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function countSkillsInDir(rootDir) {
|
|
996
|
+
// Walk subdirs once looking for SKILL.md / skill.md.
|
|
997
|
+
let count = 0;
|
|
998
|
+
const stack = [rootDir];
|
|
999
|
+
while (stack.length > 0) {
|
|
1000
|
+
const dir = stack.pop();
|
|
1001
|
+
let entries;
|
|
1002
|
+
try {
|
|
1003
|
+
entries = fssync.readdirSync(dir, { withFileTypes: true });
|
|
1004
|
+
} catch (_e) {
|
|
1005
|
+
continue;
|
|
1006
|
+
}
|
|
1007
|
+
for (const e of entries) {
|
|
1008
|
+
if (!e.isDirectory()) continue;
|
|
1009
|
+
const sub = path.join(dir, e.name);
|
|
1010
|
+
if (fileExists(path.join(sub, "SKILL.md")) || fileExists(path.join(sub, "skill.md"))) {
|
|
1011
|
+
count += 1;
|
|
1012
|
+
} else {
|
|
1013
|
+
stack.push(sub);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
return count;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function countAgentMarkdowns(rootDir) {
|
|
1021
|
+
let count = 0;
|
|
1022
|
+
const stack = [rootDir];
|
|
1023
|
+
while (stack.length > 0) {
|
|
1024
|
+
const dir = stack.pop();
|
|
1025
|
+
let entries;
|
|
1026
|
+
try {
|
|
1027
|
+
entries = fssync.readdirSync(dir, { withFileTypes: true });
|
|
1028
|
+
} catch (_e) {
|
|
1029
|
+
continue;
|
|
1030
|
+
}
|
|
1031
|
+
for (const e of entries) {
|
|
1032
|
+
const fp = path.join(dir, e.name);
|
|
1033
|
+
if (e.isDirectory()) stack.push(fp);
|
|
1034
|
+
else if (e.isFile() && e.name.endsWith(".md")) count += 1;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
return count;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function listEnabledPlugins() {
|
|
1041
|
+
const home = os.homedir();
|
|
1042
|
+
// settings.local.json overrides settings.json (CC's normal precedence).
|
|
1043
|
+
const baseMap = safeReadJson(path.join(home, ".claude", "settings.json"))?.enabledPlugins || {};
|
|
1044
|
+
const localMap = safeReadJson(path.join(home, ".claude", "settings.local.json"))?.enabledPlugins || {};
|
|
1045
|
+
const merged = { ...baseMap, ...localMap };
|
|
1046
|
+
return Object.entries(merged)
|
|
1047
|
+
.filter(([, enabled]) => enabled === true)
|
|
1048
|
+
.map(([key]) => key);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function getConfiguredResources({ projectDir = null } = {}) {
|
|
1052
|
+
const home = os.homedir();
|
|
1053
|
+
const claudeRoot = path.join(home, ".claude");
|
|
1054
|
+
const cacheRoot = path.join(claudeRoot, "plugins", "cache");
|
|
1055
|
+
|
|
1056
|
+
// --- Skills ------------------------------------------------------------
|
|
1057
|
+
let skillsCount = countSkillsInDir(path.join(claudeRoot, "skills"));
|
|
1058
|
+
if (projectDir) {
|
|
1059
|
+
skillsCount += countSkillsInDir(path.join(projectDir, ".claude", "skills"));
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// --- Custom agents -----------------------------------------------------
|
|
1063
|
+
let agentsCount = countAgentMarkdowns(path.join(claudeRoot, "agents"));
|
|
1064
|
+
if (projectDir) {
|
|
1065
|
+
agentsCount += countAgentMarkdowns(path.join(projectDir, ".claude", "agents"));
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// --- MCP servers -------------------------------------------------------
|
|
1069
|
+
// Primary: ~/.claude.json (single dot-json — CC's main config), NOT
|
|
1070
|
+
// ~/.claude/settings.json (which holds GUI toggles, not MCP).
|
|
1071
|
+
let mcpCount = 0;
|
|
1072
|
+
const claudeJson = safeReadJson(path.join(home, ".claude.json"));
|
|
1073
|
+
if (claudeJson?.mcpServers && typeof claudeJson.mcpServers === "object") {
|
|
1074
|
+
mcpCount += Object.keys(claudeJson.mcpServers).length;
|
|
1075
|
+
}
|
|
1076
|
+
if (projectDir) {
|
|
1077
|
+
const projectMcp = safeReadJson(path.join(projectDir, ".mcp.json"));
|
|
1078
|
+
if (projectMcp?.mcpServers && typeof projectMcp.mcpServers === "object") {
|
|
1079
|
+
mcpCount += Object.keys(projectMcp.mcpServers).length;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// --- Plugin contributions (enabled plugins only) -----------------------
|
|
1084
|
+
// Plugin caches live at ~/.claude/plugins/cache/<owner>/<plugin>/<version>/
|
|
1085
|
+
// and contribute skills, agents, and mcpServers (declared in plugin.json).
|
|
1086
|
+
for (const pluginKey of listEnabledPlugins()) {
|
|
1087
|
+
// pluginKey is "name@marketplace" (e.g., "claude-mem@thedotmack").
|
|
1088
|
+
const [name, marketplace] = pluginKey.split("@");
|
|
1089
|
+
if (!name || !marketplace) continue;
|
|
1090
|
+
const pluginRoot = path.join(cacheRoot, marketplace, name);
|
|
1091
|
+
const versionDir = findLatestPluginVersionDir(pluginRoot);
|
|
1092
|
+
if (!versionDir) continue;
|
|
1093
|
+
skillsCount += countSkillsInDir(path.join(versionDir, "skills"));
|
|
1094
|
+
agentsCount += countAgentMarkdowns(path.join(versionDir, "agents"));
|
|
1095
|
+
const pluginManifest = safeReadJson(path.join(versionDir, ".claude-plugin", "plugin.json"));
|
|
1096
|
+
if (pluginManifest?.mcpServers && typeof pluginManifest.mcpServers === "object") {
|
|
1097
|
+
mcpCount += Object.keys(pluginManifest.mcpServers).length;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// --- Memory files (CLAUDE.md + transitive @-imports) -------------------
|
|
1102
|
+
const memorySeen = new Set();
|
|
1103
|
+
const userMd = path.join(claudeRoot, "CLAUDE.md");
|
|
1104
|
+
const homeMd = path.join(home, "CLAUDE.md");
|
|
1105
|
+
if (fileExists(userMd)) collectMemoryImports(userMd, memorySeen);
|
|
1106
|
+
if (fileExists(homeMd) && fssync.statSync(homeMd).size > 0) collectMemoryImports(homeMd, memorySeen);
|
|
1107
|
+
// Walk up from projectDir to find the closest CLAUDE.md (CC walks up too).
|
|
1108
|
+
// Handles dev servers running from a subdir (e.g. vite from dashboard/).
|
|
1109
|
+
if (projectDir) {
|
|
1110
|
+
let cursor = projectDir;
|
|
1111
|
+
for (let i = 0; i < 8; i++) {
|
|
1112
|
+
const candidate = path.join(cursor, "CLAUDE.md");
|
|
1113
|
+
if (fileExists(candidate)) {
|
|
1114
|
+
collectMemoryImports(candidate, memorySeen);
|
|
1115
|
+
break;
|
|
1116
|
+
}
|
|
1117
|
+
const parent = path.dirname(cursor);
|
|
1118
|
+
if (parent === cursor) break;
|
|
1119
|
+
cursor = parent;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
return {
|
|
1124
|
+
skills_count: skillsCount,
|
|
1125
|
+
custom_agents_count: agentsCount,
|
|
1126
|
+
memory_files_count: memorySeen.size,
|
|
1127
|
+
mcp_servers_count: mcpCount,
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
function unsupportedSourcePayload(source) {
|
|
1132
|
+
return {
|
|
1133
|
+
source,
|
|
1134
|
+
scope: "unsupported",
|
|
1135
|
+
totals: emptyTotals(),
|
|
1136
|
+
categories: CATEGORY_KEYS.map((key) => ({
|
|
1137
|
+
key,
|
|
1138
|
+
totals: emptyTotals(),
|
|
1139
|
+
percent: 0,
|
|
1140
|
+
})),
|
|
1141
|
+
session_count: 0,
|
|
1142
|
+
message_count: 0,
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// ---------------------------------------------------------------------------
|
|
1147
|
+
// Ground-truth bucket aggregator for queue.jsonl repair.
|
|
1148
|
+
//
|
|
1149
|
+
// `sync.js` historically used a stateful incremental pipeline (cursor offsets
|
|
1150
|
+
// + persisted hash set) and the `reincludeClaudeMemObserverFiles` migration
|
|
1151
|
+
// shipped 3 versions, each of which reset the hash set and re-read observer
|
|
1152
|
+
// jsonls. Result: queue.jsonl ended up with ~+40% extra Claude tokens that
|
|
1153
|
+
// never actually existed.
|
|
1154
|
+
//
|
|
1155
|
+
// This function is the source-of-truth replacement: scan every Claude jsonl,
|
|
1156
|
+
// dedup messages by (msgId, requestId) globally — same algorithm ccusage
|
|
1157
|
+
// uses — and emit one record per (model, hour_start) bucket. Callers (sync's
|
|
1158
|
+
// repair migration) write these as authoritative rows to queue.jsonl,
|
|
1159
|
+
// overwriting whatever was there for source=claude.
|
|
1160
|
+
// ---------------------------------------------------------------------------
|
|
1161
|
+
|
|
1162
|
+
function bucketAccumulator() {
|
|
1163
|
+
return {
|
|
1164
|
+
input_tokens: 0,
|
|
1165
|
+
cached_input_tokens: 0,
|
|
1166
|
+
cache_creation_input_tokens: 0,
|
|
1167
|
+
output_tokens: 0,
|
|
1168
|
+
reasoning_output_tokens: 0,
|
|
1169
|
+
total_tokens: 0,
|
|
1170
|
+
conversation_count: 0,
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function toUtcHalfHourStart(ts) {
|
|
1175
|
+
const dt = new Date(ts);
|
|
1176
|
+
if (!Number.isFinite(dt.getTime())) return null;
|
|
1177
|
+
const minutes = dt.getUTCMinutes();
|
|
1178
|
+
const halfMinute = minutes >= 30 ? 30 : 0;
|
|
1179
|
+
return new Date(
|
|
1180
|
+
Date.UTC(
|
|
1181
|
+
dt.getUTCFullYear(),
|
|
1182
|
+
dt.getUTCMonth(),
|
|
1183
|
+
dt.getUTCDate(),
|
|
1184
|
+
dt.getUTCHours(),
|
|
1185
|
+
halfMinute,
|
|
1186
|
+
0,
|
|
1187
|
+
0,
|
|
1188
|
+
),
|
|
1189
|
+
).toISOString();
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
async function computeClaudeGroundTruthBuckets({ rootDir = null } = {}) {
|
|
1193
|
+
const root = rootDir || defaultClaudeProjectsDir();
|
|
1194
|
+
const files = listSessionFiles(root);
|
|
1195
|
+
const buckets = new Map(); // `${model}|${hourStart}` → totals
|
|
1196
|
+
const seenHashes = new Set();
|
|
1197
|
+
const userMessageBuckets = new Map(); // for conversation_count tracking
|
|
1198
|
+
|
|
1199
|
+
for (const fp of files) {
|
|
1200
|
+
let stream;
|
|
1201
|
+
try {
|
|
1202
|
+
stream = fssync.createReadStream(fp, { encoding: "utf8" });
|
|
1203
|
+
} catch (_e) {
|
|
1204
|
+
continue;
|
|
1205
|
+
}
|
|
1206
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
1207
|
+
const isMainSession = !fp.includes("/subagents/");
|
|
1208
|
+
|
|
1209
|
+
for await (const line of rl) {
|
|
1210
|
+
if (!line) continue;
|
|
1211
|
+
|
|
1212
|
+
// Conversation count = main-session user messages with text content
|
|
1213
|
+
// (matches what parseClaudeFile in rollout.js does).
|
|
1214
|
+
if (isMainSession && line.includes('"type":"user"')) {
|
|
1215
|
+
let userObj;
|
|
1216
|
+
try {
|
|
1217
|
+
userObj = JSON.parse(line);
|
|
1218
|
+
} catch (_e) {
|
|
1219
|
+
/* skip */
|
|
1220
|
+
}
|
|
1221
|
+
if (userObj?.type === "user") {
|
|
1222
|
+
const content = userObj?.message?.content;
|
|
1223
|
+
const hasText =
|
|
1224
|
+
typeof content === "string" ||
|
|
1225
|
+
(Array.isArray(content) && content.some((b) => b?.type === "text"));
|
|
1226
|
+
if (hasText) {
|
|
1227
|
+
const ts = typeof userObj?.timestamp === "string" ? userObj.timestamp : null;
|
|
1228
|
+
const hourStart = ts ? toUtcHalfHourStart(ts) : null;
|
|
1229
|
+
if (hourStart) {
|
|
1230
|
+
const k = `unknown|${hourStart}`;
|
|
1231
|
+
userMessageBuckets.set(k, (userMessageBuckets.get(k) || 0) + 1);
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
if (!line.includes('"usage"')) continue;
|
|
1238
|
+
let obj;
|
|
1239
|
+
try {
|
|
1240
|
+
obj = JSON.parse(line);
|
|
1241
|
+
} catch (_e) {
|
|
1242
|
+
continue;
|
|
1243
|
+
}
|
|
1244
|
+
const usage = obj?.message?.usage;
|
|
1245
|
+
const totals = readClaudeUsageTotals(usage);
|
|
1246
|
+
if (!totals || !hasNonZeroClaudeUsage(usage)) continue;
|
|
1247
|
+
|
|
1248
|
+
const hash = claudeMessageDedupKey(obj);
|
|
1249
|
+
if (hash) {
|
|
1250
|
+
if (seenHashes.has(hash)) continue;
|
|
1251
|
+
seenHashes.add(hash);
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
const model = (obj?.message?.model || obj?.model || "unknown").trim() || "unknown";
|
|
1255
|
+
const ts = typeof obj?.timestamp === "string" ? obj.timestamp : null;
|
|
1256
|
+
const hourStart = ts ? toUtcHalfHourStart(ts) : null;
|
|
1257
|
+
if (!hourStart) continue;
|
|
1258
|
+
|
|
1259
|
+
const key = `${model}|${hourStart}`;
|
|
1260
|
+
let acc = buckets.get(key);
|
|
1261
|
+
if (!acc) {
|
|
1262
|
+
acc = bucketAccumulator();
|
|
1263
|
+
buckets.set(key, acc);
|
|
1264
|
+
}
|
|
1265
|
+
acc.input_tokens += totals.input_tokens;
|
|
1266
|
+
acc.cached_input_tokens += totals.cached_input_tokens;
|
|
1267
|
+
acc.cache_creation_input_tokens += totals.cache_creation_input_tokens;
|
|
1268
|
+
acc.output_tokens += totals.output_tokens;
|
|
1269
|
+
acc.reasoning_output_tokens += totals.reasoning_output_tokens;
|
|
1270
|
+
acc.total_tokens += totals.total_tokens;
|
|
1271
|
+
}
|
|
1272
|
+
rl.close();
|
|
1273
|
+
stream.close?.();
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// Stitch user-message conversation counts onto the unknown-model bucket
|
|
1277
|
+
// for the same hour (matches rollout.js behavior — user messages are
|
|
1278
|
+
// counted under DEFAULT_MODEL because they have no model field).
|
|
1279
|
+
for (const [key, count] of userMessageBuckets) {
|
|
1280
|
+
let acc = buckets.get(key);
|
|
1281
|
+
if (!acc) {
|
|
1282
|
+
acc = bucketAccumulator();
|
|
1283
|
+
buckets.set(key, acc);
|
|
1284
|
+
}
|
|
1285
|
+
acc.conversation_count += count;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
const out = [];
|
|
1289
|
+
for (const [key, totals] of buckets) {
|
|
1290
|
+
const sep = key.indexOf("|");
|
|
1291
|
+
const model = key.slice(0, sep);
|
|
1292
|
+
const hourStart = key.slice(sep + 1);
|
|
1293
|
+
out.push({
|
|
1294
|
+
source: "claude",
|
|
1295
|
+
model,
|
|
1296
|
+
hour_start: hourStart,
|
|
1297
|
+
...totals,
|
|
1298
|
+
billable_total_tokens: totals.total_tokens,
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
1301
|
+
return {
|
|
1302
|
+
rows: out,
|
|
1303
|
+
seenHashes: Array.from(seenHashes),
|
|
1304
|
+
fileList: files,
|
|
1305
|
+
};
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
module.exports = {
|
|
1309
|
+
CATEGORY_KEYS,
|
|
1310
|
+
computeClaudeCategoryBreakdown,
|
|
1311
|
+
computeClaudeGroundTruthBuckets,
|
|
1312
|
+
unsupportedSourcePayload,
|
|
1313
|
+
getConfiguredResources,
|
|
1314
|
+
// Exported for tests
|
|
1315
|
+
splitOutputByContent,
|
|
1316
|
+
classifyOneMessage,
|
|
1317
|
+
emptyTotals,
|
|
1318
|
+
emptyCategoryMap,
|
|
1319
|
+
};
|