usagemax 0.1.1 → 0.2.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.md CHANGED
@@ -1,43 +1,88 @@
1
1
  # UsageMax CLI
2
2
 
3
- Connect aggregate coding-agent usage from your computer to a private UsageMax workspace.
3
+ Connect aggregate coding-agent usage from every computer to one private UsageMax workspace.
4
4
 
5
5
  ## Quick start
6
6
 
7
7
  1. Sign in at [usagemax.com/account](https://usagemax.com/account).
8
8
  2. Choose **Link a computer** and copy the one-time command.
9
- 3. Run it locally:
9
+ 3. Run it in each operating-system environment that contains usage history:
10
10
 
11
11
  ```bash
12
- bunx usagemax link UMX-XXXX-XXXX-XXXX-XXXX
12
+ bunx usagemax@latest link UMX-XXXX-XXXX-XXXX-XXXX
13
13
  ```
14
14
 
15
- The link code expires after ten minutes and can be used once. The CLI stores the resulting collector key in a user-only config file, then runs a one-shot sync.
15
+ The link code expires after ten minutes and can be used once. Create a new code
16
+ for each Mac, Windows PC, Linux computer, and WSL distribution. All linked
17
+ collectors roll up into the same profile. Windows and WSL have separate home
18
+ directories, so run UsageMax once in Windows and once inside WSL when both have
19
+ agent history.
20
+
21
+ The CLI stores the resulting collector key in a user-only config file, then runs
22
+ a one-shot full-history sync. Running it again, changing the display name, or
23
+ relinking an installation does not create a second device: the private random
24
+ installation identity remains stable.
16
25
 
17
26
  ## Commands
18
27
 
19
28
  ```bash
20
- bunx usagemax # sync changed usage
21
- bunx usagemax sync # same as above
22
- bunx usagemax sync --full # inspect all available local history
23
- bunx usagemax link UMX-… --no-sync # link without uploading yet
24
- bunx usagemax status # show link and last-sync state
25
- bunx usagemax doctor # metadata-only source check; does not parse logs
26
- bunx usagemax doctor --deep # opt into a full local parser check
27
- bunx usagemax report # open ccusage's local daily report
29
+ bunx usagemax # sync changed usage
30
+ bunx usagemax sync # same as above
31
+ bunx usagemax sync --full # reconcile all retained local history
32
+ bunx usagemax link UMX-… --no-sync # link without uploading yet
33
+ bunx usagemax status # show link and last-sync state
34
+ bunx usagemax doctor # metadata-only source check
35
+ bunx usagemax doctor --deep # parse and verify all retained history
36
+ bunx usagemax report # open ccusage's local daily report
28
37
  bunx usagemax report session --breakdown
29
- bunx usagemax unlink # remove the local collector key
38
+ bunx usagemax unlink # remove the local collector key
30
39
  ```
31
40
 
32
- UsageMax uses [ccusage](https://github.com/ccusage/ccusage) for local source detection, responsive reports, cached pricing, model breakdowns, date handling, and support for popular coding-agent CLIs. `report` passes its remaining arguments to ccusage.
41
+ UsageMax pins [ccusage v20.0.20](https://github.com/ccusage/ccusage/releases/tag/v20.0.20)
42
+ and supports all 16 adapters shipped in that release: Amp, Claude Code, Codebuff,
43
+ Codex, GitHub Copilot CLI, Factory Droid, Gemini CLI, Goose, Grok Build, Hermes,
44
+ Kilo Code, Kimi CLI, OpenClaw, OpenCode, Pi, and Qwen Code. Named Pi-format
45
+ stores configured through ccusage are also discovered.
46
+
47
+ The source inventory follows ccusage's environment overrides, including
48
+ `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `OPENCODE_DATA_DIR`, `AMP_DATA_DIR`,
49
+ `DROID_SESSIONS_DIR`, `CODEBUFF_DATA_DIR`, `HERMES_HOME`, `PI_AGENT_DIR`,
50
+ `GOOSE_PATH_ROOT`, `OPENCLAW_DIR`, `KILO_DATA_DIR`, `KIMI_DATA_DIR`,
51
+ `QWEN_DATA_DIR`, `COPILOT_OTEL_FILE_EXPORTER_PATH`, `GEMINI_DATA_DIR`, and
52
+ `GROK_HOME`. It also follows XDG Claude configuration and Windows Goose storage.
33
53
 
34
- ## Privacy and load
54
+ Local files are only one coverage layer. Cursor, Windsurf, Aider, Continue,
55
+ Cline, Roo Code, direct provider API traffic, hosted agents, and enterprise
56
+ billing systems do not all expose a stable local token ledger. Capture those
57
+ through UsageMax's native or OTLP endpoint, or through a future provider billing
58
+ connector. UsageMax never invents usage that the source did not retain.
35
59
 
36
- - The sync payload contains aggregate token counts, model/provider names, costs, source names, and dates.
37
- - It does not upload prompts, completions, source code, file contents, project paths, or provider credentials.
60
+ ## Privacy, correctness, and load
61
+
62
+ - The sync payload contains aggregate token counts, model/provider names, costs,
63
+ source names, and dates.
64
+ - It does not upload prompts, completions, source code, file contents, project
65
+ paths, or provider credentials.
38
66
  - Sync is one-shot. There is no resident scanner or high-frequency polling loop.
39
- - After the first import, normal syncs inspect only the current and previous local day; use `sync --full` to reconcile older history.
40
- - The collector key is written with user-only permissions where the operating system supports them.
41
- - A separate private random installation ID survives collector rotation, relinking, and display-name changes. It is not a hardware fingerprint; the server stores only its SHA-256 hash. Concurrent or repeated syncs remain idempotent.
67
+ - A metadata inventory exits without parsing logs or using the network when
68
+ nothing changed.
69
+ - Normal changed syncs parse today or today plus yesterday. A bounded weekly
70
+ full reconciliation catches restored files, parser changes, and older logs.
71
+ - Full history means all retained local history from 2024 onward. Deleted or
72
+ never-persisted usage requires a provider export; no local tool can reconstruct it.
73
+ - Source totals that cannot be assigned to a model are retained as
74
+ `unattributed` rather than silently discarded.
75
+ - The collector key is written with user-only permissions where the operating
76
+ system supports them.
77
+ - A private random installation ID survives collector rotation, relinking, and
78
+ display-name changes. It is not a hardware fingerprint; the server stores only
79
+ its SHA-256 hash. Concurrent and repeated syncs are idempotent.
80
+ - Do not point two different installations at the same copied or network-mounted
81
+ log tree. Cross-installation copied-history deduplication is inherently
82
+ ambiguous and intentionally not guessed.
83
+
84
+ Use `USAGEMAX_CONFIG_DIR` to select another config directory. Development and
85
+ self-hosted installations may set `USAGEMAX_LINK_ENDPOINT` before linking.
42
86
 
43
- Use `USAGEMAX_CONFIG_DIR` to select another config directory. Development/self-hosted installations may set `USAGEMAX_LINK_ENDPOINT` before linking.
87
+ See the [collector coverage audit](../../docs/collector-coverage-audit.md) for
88
+ the full support matrix and known boundaries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "usagemax",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Link local coding-agent usage to your UsageMax profile",
5
5
  "license": "MIT",
6
6
  "author": "UsageMax",
@@ -21,6 +21,7 @@
21
21
  "src/cli.js",
22
22
  "src/core.js",
23
23
  "src/installation.js",
24
+ "src/sources.js",
24
25
  "README.md",
25
26
  "LICENSE"
26
27
  ],
package/src/cli.js CHANGED
@@ -1,25 +1,25 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { execFile, spawn } from "node:child_process";
4
- import { createHash, randomUUID } from "node:crypto";
5
- import { chmod, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
4
+ import { randomUUID } from "node:crypto";
5
+ import { chmod, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
6
6
  import { createRequire } from "node:module";
7
7
  import { homedir, platform } from "node:os";
8
- import { delimiter, dirname, extname, join } from "node:path";
8
+ import { dirname, join } from "node:path";
9
9
  import process from "node:process";
10
10
  import { promisify } from "node:util";
11
11
 
12
12
  import { batchId, buildDeltaPlan, normalizeLinkCode, sourceSummary, validHttpsUrl } from "./core.js";
13
13
  import { stableInstallationId } from "./installation.js";
14
+ import { CCUSAGE_VERSION, ccusageEnvironment, SOURCE_INVENTORY_VERSION, sourceInventory, SUPPORTED_SOURCES } from "./sources.js";
14
15
 
15
16
  const require = createRequire(import.meta.url);
16
17
  const executeFile = promisify(execFile);
17
- const VERSION = "0.1.1";
18
+ const VERSION = "0.2.0";
18
19
  const DEFAULT_LINK_ENDPOINT = "https://terrific-bobcat-522.convex.site/v1/devices/link";
19
20
  const CONFIG_FILE = "config.json";
20
21
  const MAX_REPORT_BYTES = 100 * 1024 * 1024;
21
- const MAX_FINGERPRINT_FILES = 50_000;
22
- const USAGE_EXTENSIONS = new Set([".db", ".json", ".jsonl", ".sqlite", ".sqlite3"]);
22
+ const FULL_RECONCILE_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000;
23
23
 
24
24
  function configDirectory() {
25
25
  if (process.env.USAGEMAX_CONFIG_DIR) return process.env.USAGEMAX_CONFIG_DIR;
@@ -67,6 +67,7 @@ function option(args, name) {
67
67
  function deviceLabel() {
68
68
  if (platform() === "darwin") return "Mac";
69
69
  if (platform() === "win32") return "Windows PC";
70
+ if (platform() === "linux" && process.env.WSL_DISTRO_NAME) return `WSL · ${process.env.WSL_DISTRO_NAME}`;
70
71
  if (platform() === "linux") return "Linux computer";
71
72
  return "Computer";
72
73
  }
@@ -80,7 +81,7 @@ function help() {
80
81
  process.stdout.write(" [--no-sync] [--name <name>]\n");
81
82
  process.stdout.write(" usagemax sync [--full] [--json] Sync usage once, then exit\n");
82
83
  process.stdout.write(" usagemax status Show local link status\n");
83
- process.stdout.write(" usagemax doctor [--deep] Check sources without parsing logs\n");
84
+ process.stdout.write(" usagemax doctor [--deep] Check source coverage; --deep parses full history\n");
84
85
  process.stdout.write(" usagemax report [...args] Run a local ccusage report\n");
85
86
  process.stdout.write(" usagemax unlink Remove the local collector key\n");
86
87
  }
@@ -89,91 +90,21 @@ function ccusageCliPath() {
89
90
  return join(dirname(require.resolve("ccusage/package.json")), "src", "cli.js");
90
91
  }
91
92
 
92
- function configuredPaths(variable, fallbacks) {
93
- const configured = process.env[variable];
94
- return configured
95
- ? configured.split(",").flatMap((group) => group.split(delimiter)).map((value) => value.trim()).filter(Boolean)
96
- : fallbacks;
97
- }
98
-
99
- function usageRoots() {
100
- const home = homedir();
101
- return [
102
- ["claude", configuredPaths("CLAUDE_CONFIG_DIR", [join(home, ".config", "claude", "projects"), join(home, ".claude", "projects")])],
103
- ["codex", configuredPaths("CODEX_HOME", [join(home, ".codex", "sessions"), join(home, ".codex", "archived_sessions")])],
104
- ["opencode", configuredPaths("OPENCODE_DATA_DIR", [join(home, ".local", "share", "opencode")])],
105
- ["hermes", configuredPaths("HERMES_HOME", [join(home, ".hermes", "sessions")])],
106
- ["pi", configuredPaths("PI_AGENT_DIR", [join(home, ".pi", "agent", "sessions")])],
107
- ["copilot", configuredPaths("COPILOT_HOME", [join(home, ".copilot")])],
108
- ["gemini", configuredPaths("GEMINI_DATA_DIR", [join(home, ".gemini", "tmp")])],
109
- ].flatMap(([source, paths]) => paths.map((path) => ({ source, path })));
110
- }
111
-
112
- async function sourceInventory() {
113
- const hash = createHash("sha256");
114
- const sources = new Set();
115
- const roots = usageRoots();
116
- let files = 0;
117
- let truncated = false;
118
- for (const root of roots) {
119
- try {
120
- const rootStat = await stat(root.path);
121
- if (!rootStat.isDirectory() && !rootStat.isFile()) continue;
122
- sources.add(root.source);
123
- } catch {
124
- continue;
125
- }
126
- const stack = [root.path];
127
- while (stack.length && files < MAX_FINGERPRINT_FILES) {
128
- const current = stack.pop();
129
- let entries;
130
- try {
131
- entries = await readdir(current, { withFileTypes: true });
132
- } catch {
133
- try {
134
- const metadata = await stat(current);
135
- if (metadata.isFile() && USAGE_EXTENSIONS.has(extname(current).toLowerCase())) {
136
- hash.update(`${root.source}\u0000${current}\u0000${metadata.size}\u0000${metadata.mtimeMs}\n`);
137
- files += 1;
138
- }
139
- } catch {}
140
- continue;
141
- }
142
- for (const entry of entries) {
143
- const path = join(current, entry.name);
144
- if (entry.isDirectory()) {
145
- if (!["node_modules", ".git", "cache", "tmp"].includes(entry.name)) stack.push(path);
146
- continue;
147
- }
148
- if (!entry.isFile() || !USAGE_EXTENSIONS.has(extname(entry.name).toLowerCase())) continue;
149
- try {
150
- const metadata = await stat(path);
151
- hash.update(`${root.source}\u0000${path}\u0000${metadata.size}\u0000${metadata.mtimeMs}\n`);
152
- files += 1;
153
- } catch {}
154
- if (files >= MAX_FINGERPRINT_FILES) {
155
- truncated = true;
156
- break;
157
- }
158
- }
159
- }
160
- }
161
- return { fingerprint: hash.digest("hex"), sources: [...sources].sort(), files, truncated };
162
- }
163
-
164
93
  async function ccusageJson(config, { full = false } = {}) {
165
- const args = [ccusageCliPath(), "daily", "--json", "--offline", "--by-agent", "--order", "asc"];
94
+ const args = [ccusageCliPath(), "daily", "--json", "--offline", "--mode", "calculate", "--timezone", "UTC", "--by-agent", "--order", "asc"];
166
95
  // Reconcile yesterday once after the UTC date changes. All other incremental
167
96
  // scans parse only today; a metadata fingerprint avoids invoking ccusage when
168
97
  // no supported local source changed at all.
169
- if (!full && config?.lastSyncAt) {
98
+ if (full) {
99
+ args.push("--since", "2024-01-01", "--until", new Date().toISOString().slice(0, 10));
100
+ } else if (config?.lastSyncAt) {
170
101
  const today = new Date().toISOString().slice(0, 10);
171
102
  args.push("--last", config.lastReconciledDay === today ? "1" : "2");
172
103
  }
173
104
  const { stdout } = await executeFile(process.execPath, args, {
174
105
  encoding: "utf8",
175
106
  maxBuffer: MAX_REPORT_BYTES,
176
- env: { ...process.env, NO_COLOR: "1" },
107
+ env: { ...await ccusageEnvironment(), NO_COLOR: "1" },
177
108
  });
178
109
  return JSON.parse(stdout);
179
110
  }
@@ -226,17 +157,27 @@ async function sync(args, suppliedConfig) {
226
157
  const config = suppliedConfig || await readConfig();
227
158
  if (!config) throw new Error("This computer is not linked. Open https://usagemax.com/account and create a link code.");
228
159
  config.deviceId = await stableInstallationId(configDirectory(), config.deviceId);
229
- const full = args.includes("--full");
160
+ const requestedFull = args.includes("--full");
230
161
  const inventory = await sourceInventory();
231
162
  const today = new Date().toISOString().slice(0, 10);
232
- if (!full && config.lastReconciledDay === today && config.sourceFingerprint === inventory.fingerprint) {
233
- const result = { accepted: 0, changedRows: 0, sources: inventory.sources, regressions: 0, scanned: false };
163
+ const knownSources = Array.isArray(config.knownSources) ? config.knownSources : [];
164
+ const foundNewSource = inventory.sources.some((source) => !knownSources.includes(source));
165
+ const lastFullSync = Date.parse(config.lastFullSyncAt || "");
166
+ const fullDue = config.sourceInventoryVersion !== SOURCE_INVENTORY_VERSION
167
+ || !Number.isFinite(lastFullSync)
168
+ || Date.now() - lastFullSync >= FULL_RECONCILE_INTERVAL_MS
169
+ || foundNewSource;
170
+ const full = requestedFull || fullDue;
171
+ if (!full && inventory.complete && config.lastSyncComplete && config.lastReconciledDay === today && config.sourceFingerprint === inventory.fingerprint) {
172
+ const result = { accepted: 0, changedRows: 0, sources: inventory.sources, regressions: 0, scanned: false, full: false };
234
173
  if (args.includes("--json")) process.stdout.write(`${JSON.stringify(result)}\n`);
235
174
  else process.stdout.write("Already up to date. Local usage files have not changed; no logs were parsed or uploaded.\n");
236
175
  return;
237
176
  }
238
177
  const report = await ccusageJson(config, { full });
239
- const { plan, regressions } = buildDeltaPlan(report, config.snapshots, config.deviceId, "ccusage@20.0.20");
178
+ const { plan, regressions } = buildDeltaPlan(report, config.snapshots, config.deviceId, `ccusage@${CCUSAGE_VERSION}`);
179
+ config.lastSyncComplete = false;
180
+ await writeConfig(config);
240
181
  let accepted = 0;
241
182
  for (let offset = 0; offset < plan.length; offset += 100) {
242
183
  const batch = plan.slice(offset, offset + 100);
@@ -257,20 +198,21 @@ async function sync(args, suppliedConfig) {
257
198
  accepted += Number(body.accepted || 0);
258
199
  for (const item of batch) config.snapshots[item.snapshotKey] = item.snapshot;
259
200
  config.lastSyncAt = new Date().toISOString();
260
- config.lastReconciledDay = today;
261
- config.sourceFingerprint = inventory.fingerprint;
262
- await writeConfig(config);
263
- }
264
- if (!plan.length) {
265
- config.lastSyncAt = new Date().toISOString();
266
- config.lastReconciledDay = today;
267
- config.sourceFingerprint = inventory.fingerprint;
268
201
  await writeConfig(config);
269
202
  }
270
- const result = { accepted, changedRows: plan.length, sources: sourceSummary(report), regressions: regressions.length, scanned: true };
203
+ config.lastSyncAt = new Date().toISOString();
204
+ config.lastReconciledDay = today;
205
+ config.lastSyncComplete = true;
206
+ config.sourceInventoryVersion = SOURCE_INVENTORY_VERSION;
207
+ config.knownSources = [...new Set([...knownSources, ...inventory.sources, ...sourceSummary(report)])].sort();
208
+ if (inventory.complete) config.sourceFingerprint = inventory.fingerprint;
209
+ else delete config.sourceFingerprint;
210
+ if (full) config.lastFullSyncAt = config.lastSyncAt;
211
+ await writeConfig(config);
212
+ const result = { accepted, changedRows: plan.length, sources: sourceSummary(report), regressions: regressions.length, scanned: true, full };
271
213
  if (args.includes("--json")) process.stdout.write(`${JSON.stringify(result)}\n`);
272
214
  else {
273
- process.stdout.write(plan.length ? `Synced ${accepted} changed usage rows from ${result.sources.join(", ") || "local agents"}.\n` : "Already up to date. No usage rows were uploaded.\n");
215
+ process.stdout.write(plan.length ? `Synced ${accepted} changed usage rows from ${result.sources.join(", ") || "local agents"}${full ? " (full history)" : ""}.\n` : `Already up to date. No usage rows were uploaded${full ? " after a full-history reconciliation" : ""}.\n`);
274
216
  if (regressions.length) process.stdout.write(`${regressions.length} local row(s) moved backward; UsageMax kept the prior high-water mark to prevent double counting.\n`);
275
217
  }
276
218
  }
@@ -288,6 +230,7 @@ async function status() {
288
230
  }
289
231
  process.stdout.write(`Linked: ${config.deviceName || deviceLabel()}${config.profileHandle ? ` → @${config.profileHandle}` : ""}\n`);
290
232
  process.stdout.write(`Last sync: ${config.lastSyncAt || "never"}\n`);
233
+ process.stdout.write(`Last full reconciliation: ${config.lastFullSyncAt || "never"}\n`);
291
234
  process.stdout.write(`Profile: ${config.profileUrl || "https://usagemax.com/account"}\n`);
292
235
  }
293
236
 
@@ -296,8 +239,13 @@ async function doctor(args = []) {
296
239
  const inventory = await sourceInventory();
297
240
  process.stdout.write(`Collector: ${config ? "linked" : "not linked"}\n`);
298
241
  process.stdout.write(`Detected sources: ${inventory.sources.join(", ") || "none"} (${inventory.files}${inventory.truncated ? "+" : ""} data files)\n`);
242
+ process.stdout.write(`Supported sources: ${SUPPORTED_SOURCES.join(", ")} (+ named pi-format stores)\n`);
243
+ if (platform() === "linux" && process.env.WSL_DISTRO_NAME) {
244
+ process.stdout.write(`Environment: WSL ${process.env.WSL_DISTRO_NAME}; its Linux home is collected separately from Windows\n`);
245
+ }
246
+ if (!inventory.complete) process.stdout.write(`Inventory: incomplete (${inventory.errors} read error(s)${inventory.truncated ? ", file limit reached" : ""}); no-change shortcut disabled\n`);
299
247
  if (args.includes("--deep")) {
300
- const report = await ccusageJson(config, { full: false });
248
+ const report = await ccusageJson(config, { full: true });
301
249
  process.stdout.write(`Parsed sources: ${sourceSummary(report).join(", ") || "none"}\n`);
302
250
  }
303
251
  process.stdout.write(`Mode: one-shot, metadata no-op check, ${args.includes("--deep") ? "deep local parse" : "no log parsing"}\n`);
@@ -305,7 +253,10 @@ async function doctor(args = []) {
305
253
 
306
254
  async function report(args) {
307
255
  const forwarded = args.length ? args : ["daily"];
308
- const child = spawn(process.execPath, [ccusageCliPath(), ...forwarded], { stdio: "inherit", env: process.env });
256
+ const child = spawn(process.execPath, [ccusageCliPath(), ...forwarded], {
257
+ stdio: "inherit",
258
+ env: await ccusageEnvironment(),
259
+ });
309
260
  const code = await new Promise((resolve, reject) => {
310
261
  child.once("error", reject);
311
262
  child.once("exit", (status) => resolve(status ?? 1));
package/src/core.js CHANGED
@@ -22,12 +22,24 @@ function text(value, fallback, maximum = 120) {
22
22
  }
23
23
 
24
24
  function providerFor(model, source) {
25
- const name = model.toLowerCase();
25
+ const displayName = model.toLowerCase().replace(/^\[[^\]]+\]\s*/, "");
26
+ const name = displayName.replace(/^api[-_:]/, "");
27
+ if (name.startsWith("openrouter/")) return "openrouter";
28
+ if (name.startsWith("azure/") || name.startsWith("azure-openai/")) return "azure-openai";
29
+ if (name.startsWith("bedrock/") || name.startsWith("aws/")) return "aws-bedrock";
30
+ if (name.startsWith("vertex/") || name.startsWith("vertex_ai/") || name.startsWith("google/")) return "google";
26
31
  if (name.includes("claude")) return "anthropic";
27
32
  if (/^(?:gpt|o[1345]|codex)/.test(name) || name.includes("openai")) return "openai";
28
33
  if (name.includes("gemini")) return "google";
29
34
  if (name.includes("grok")) return "xai";
30
35
  if (name.includes("deepseek")) return "deepseek";
36
+ if (name.includes("kimi") || name.includes("moonshot")) return "moonshot";
37
+ if (name.includes("qwen")) return "alibaba";
38
+ if (name.includes("glm") || name.includes("zai") || name.includes("z.ai")) return "zai";
39
+ if (name.includes("mistral") || name.includes("codestral")) return "mistral";
40
+ if (name.includes("llama") || name.includes("meta/")) return "meta";
41
+ if (name.includes("minimax")) return "minimax";
42
+ if (name.includes("command-r") || name.includes("cohere")) return "cohere";
31
43
  return source;
32
44
  }
33
45
 
@@ -74,6 +86,8 @@ function currentRows(report) {
74
86
  cacheReadTokens: rawAgent.cacheReadTokens,
75
87
  cost: rawAgent.totalCost,
76
88
  }];
89
+ let allocatedTokens = 0;
90
+ let allocatedCostMicros = 0;
77
91
  for (const rawBreakdown of breakdowns) {
78
92
  if (!rawBreakdown || typeof rawBreakdown !== "object") continue;
79
93
  const model = text(rawBreakdown.modelName, "unknown", 120);
@@ -82,6 +96,10 @@ function currentRows(report) {
82
96
  const cacheWriteTokens = number(rawBreakdown.cacheCreationTokens);
83
97
  const cacheReadTokens = number(rawBreakdown.cacheReadTokens);
84
98
  const costMicros = moneyMicros(rawBreakdown.cost);
99
+ const classifiedTokens = inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens;
100
+ const totalTokens = Math.max(classifiedTokens, number(rawBreakdown.totalTokens));
101
+ allocatedTokens += totalTokens;
102
+ allocatedCostMicros += costMicros;
85
103
  rows.push({
86
104
  key: `${source}\u001f${period}\u001f${model}`,
87
105
  source,
@@ -92,11 +110,29 @@ function currentRows(report) {
92
110
  outputTokens,
93
111
  cacheReadTokens,
94
112
  cacheWriteTokens,
95
- totalTokens: inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens,
113
+ totalTokens,
96
114
  costMicros,
97
115
  },
98
116
  });
99
117
  }
118
+ const residualTokens = Math.max(0, number(rawAgent.totalTokens) - allocatedTokens);
119
+ const residualCostMicros = Math.max(0, moneyMicros(rawAgent.totalCost) - allocatedCostMicros);
120
+ if (residualTokens > 0 || residualCostMicros > 0) {
121
+ rows.push({
122
+ key: `${source}\u001f${period}\u001funattributed`,
123
+ source,
124
+ period,
125
+ model: "unattributed",
126
+ current: {
127
+ inputTokens: 0,
128
+ outputTokens: 0,
129
+ cacheReadTokens: 0,
130
+ cacheWriteTokens: 0,
131
+ totalTokens: residualTokens,
132
+ costMicros: residualCostMicros,
133
+ },
134
+ });
135
+ }
100
136
  }
101
137
  }
102
138
  return rows;
package/src/sources.js ADDED
@@ -0,0 +1,331 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile, readdir, stat } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import path from "node:path";
5
+
6
+ export const CCUSAGE_VERSION = "20.0.20";
7
+ export const SOURCE_INVENTORY_VERSION = 2;
8
+ export const SUPPORTED_SOURCES = [
9
+ "amp",
10
+ "claude",
11
+ "codebuff",
12
+ "codex",
13
+ "copilot",
14
+ "droid",
15
+ "gemini",
16
+ "goose",
17
+ "grok",
18
+ "hermes",
19
+ "kilo",
20
+ "kimi",
21
+ "openclaw",
22
+ "opencode",
23
+ "pi",
24
+ "qwen",
25
+ ];
26
+
27
+ const MAX_FINGERPRINT_FILES = 50_000;
28
+
29
+ function has(env, name) {
30
+ return Object.prototype.hasOwnProperty.call(env, name);
31
+ }
32
+
33
+ function commaList(value) {
34
+ return String(value ?? "").split(",").map((item) => item.trim()).filter(Boolean);
35
+ }
36
+
37
+ function envList(env, name, fallback, { blankUsesFallback = false } = {}) {
38
+ if (!has(env, name) || (blankUsesFallback && !String(env[name] ?? "").trim())) return fallback;
39
+ return commaList(env[name]);
40
+ }
41
+
42
+ function expandTilde(value, home, pathApi) {
43
+ if (value === "~") return home;
44
+ if (value.startsWith("~/") || value.startsWith("~\\")) return pathApi.join(home, value.slice(2));
45
+ return value;
46
+ }
47
+
48
+ export function ccusageHome(env = process.env, fallback = homedir()) {
49
+ for (const value of [env.HOME, env.USERPROFILE]) {
50
+ if (typeof value === "string" && value.trim()) return value;
51
+ }
52
+ const windowsHome = `${env.HOMEDRIVE ?? ""}${env.HOMEPATH ?? ""}`;
53
+ return windowsHome.trim() || fallback;
54
+ }
55
+
56
+ function tree(source, root, matcher) {
57
+ return { source, path: root, kind: "tree", matcher };
58
+ }
59
+
60
+ function file(source, filePath) {
61
+ return { source, path: filePath, kind: "file", matcher: "any" };
62
+ }
63
+
64
+ function db(source, filePath) {
65
+ return [file(source, filePath), file(source, `${filePath}-wal`), file(source, `${filePath}-shm`)];
66
+ }
67
+
68
+ export function sourceDefinitions({ env = process.env, home = ccusageHome(env), cwd = process.cwd(), pathApi = path } = {}) {
69
+ const join = (...parts) => pathApi.join(...parts);
70
+ const definitions = [];
71
+
72
+ const claudeRoots = has(env, "CLAUDE_CONFIG_DIR")
73
+ ? commaList(env.CLAUDE_CONFIG_DIR).map((item) => expandTilde(item, home, pathApi))
74
+ : [join(has(env, "XDG_CONFIG_HOME") ? String(env.XDG_CONFIG_HOME) : join(home, ".config"), "claude"), join(home, ".claude")];
75
+ for (const root of claudeRoots) {
76
+ const projects = pathApi.basename(root) === "projects" ? root : join(root, "projects");
77
+ definitions.push(tree("claude", projects, "jsonl"));
78
+ }
79
+
80
+ for (const root of envList(env, "CODEX_HOME", [join(home, ".codex")])) {
81
+ definitions.push({ source: "codex", path: root, kind: "codex-home", matcher: "jsonl" });
82
+ }
83
+ for (const root of envList(env, "OPENCODE_DATA_DIR", [join(home, ".local", "share", "opencode")])) {
84
+ definitions.push(tree("opencode", root, "opencode"));
85
+ }
86
+ for (const root of envList(env, "AMP_DATA_DIR", [join(home, ".local", "share", "amp")])) {
87
+ definitions.push(tree("amp", join(root, "threads"), "json"));
88
+ }
89
+ for (const root of envList(env, "DROID_SESSIONS_DIR", [join(home, ".factory", "sessions")])) {
90
+ definitions.push(tree("droid", root, "droid"));
91
+ }
92
+
93
+ const codebuffRoots = has(env, "CODEBUFF_DATA_DIR")
94
+ ? commaList(env.CODEBUFF_DATA_DIR)
95
+ : ["manicode", "manicode-dev", "manicode-staging"].map((channel) => join(home, ".config", channel));
96
+ for (const root of codebuffRoots) {
97
+ definitions.push(tree("codebuff", pathApi.basename(root) === "projects" ? root : join(root, "projects"), "codebuff"));
98
+ }
99
+
100
+ for (const root of envList(env, "HERMES_HOME", [join(home, ".hermes")])) {
101
+ definitions.push(...db("hermes", join(root, "state.db")));
102
+ }
103
+ for (const root of envList(env, "PI_AGENT_DIR", [join(home, ".pi", "agent", "sessions")], { blankUsesFallback: true })) {
104
+ definitions.push(tree("pi", root, "jsonl"));
105
+ }
106
+
107
+ const gooseRoot = String(env.GOOSE_PATH_ROOT ?? "").trim();
108
+ const gooseDatabases = gooseRoot
109
+ ? [join(gooseRoot, "data", "sessions", "sessions.db")]
110
+ : [
111
+ join(home, ".local", "share", "goose", "sessions", "sessions.db"),
112
+ join(home, "Library", "Application Support", "goose", "sessions", "sessions.db"),
113
+ join(home, ".local", "share", "Block", "goose", "sessions", "sessions.db"),
114
+ ];
115
+ if (!gooseRoot && String(env.APPDATA ?? "").trim()) {
116
+ gooseDatabases.push(join(String(env.APPDATA).trim(), "Block", "goose", "data", "sessions", "sessions.db"));
117
+ }
118
+ for (const database of gooseDatabases) definitions.push(...db("goose", database));
119
+
120
+ const openClawRoots = envList(env, "OPENCLAW_DIR", [
121
+ join(home, ".openclaw"),
122
+ join(home, ".clawdbot"),
123
+ join(home, ".moltbot"),
124
+ join(home, ".moldbot"),
125
+ ], { blankUsesFallback: true });
126
+ for (const root of openClawRoots) definitions.push(tree("openclaw", root, "openclaw"));
127
+
128
+ for (const root of envList(env, "KILO_DATA_DIR", [join(home, ".local", "share", "kilo")])) {
129
+ definitions.push(...db("kilo", join(root, "kilo.db")));
130
+ }
131
+ for (const root of envList(env, "KIMI_DATA_DIR", [join(home, ".kimi"), join(home, ".kimi-code")])) {
132
+ definitions.push(tree("kimi", join(root, "sessions"), "jsonl"), file("kimi", join(root, "config.json")));
133
+ }
134
+ for (const root of envList(env, "QWEN_DATA_DIR", [join(home, ".qwen")])) {
135
+ definitions.push(tree("qwen", join(root, "projects"), "jsonl"));
136
+ }
137
+
138
+ definitions.push(tree("copilot", join(home, ".copilot", "otel"), "jsonl"));
139
+ if (String(env.COPILOT_OTEL_FILE_EXPORTER_PATH ?? "").trim()) {
140
+ definitions.push(file("copilot", String(env.COPILOT_OTEL_FILE_EXPORTER_PATH).trim()));
141
+ }
142
+ for (const root of envList(env, "GEMINI_DATA_DIR", [join(home, ".gemini", "tmp")])) {
143
+ definitions.push(tree("gemini", root, "gemini"));
144
+ }
145
+ const grokRoot = String(env.GROK_HOME ?? "").trim() || join(home, ".grok");
146
+ definitions.push(tree("grok", join(grokRoot, "sessions"), "grok"));
147
+
148
+ const configRoots = has(env, "CLAUDE_CONFIG_DIR")
149
+ ? commaList(env.CLAUDE_CONFIG_DIR)
150
+ : [join(home, ".config", "claude"), join(home, ".claude")];
151
+ const configPaths = [join(cwd, ".ccusage", "ccusage.json"), ...configRoots.map((root) => join(root, "ccusage.json"))];
152
+ for (const configPath of configPaths) definitions.push(file("ccusage-config", configPath));
153
+
154
+ return definitions;
155
+ }
156
+
157
+ export async function ccusageEnvironment({ env = process.env, platform = process.platform, pathApi = path } = {}) {
158
+ const effective = { ...env };
159
+ if (platform !== "win32" || String(effective.GOOSE_PATH_ROOT ?? "").trim() || !String(effective.APPDATA ?? "").trim()) {
160
+ return effective;
161
+ }
162
+ const gooseRoot = pathApi.join(String(effective.APPDATA).trim(), "Block", "goose");
163
+ try {
164
+ if ((await stat(pathApi.join(gooseRoot, "data", "sessions", "sessions.db"))).isFile()) effective.GOOSE_PATH_ROOT = gooseRoot;
165
+ } catch {}
166
+ return effective;
167
+ }
168
+
169
+ function accepts(matcher, filePath, pathApi) {
170
+ const name = pathApi.basename(filePath);
171
+ if (matcher === "any") return true;
172
+ if (matcher === "jsonl") return name.endsWith(".jsonl");
173
+ if (matcher === "json") return name.endsWith(".json");
174
+ if (matcher === "gemini") return name.endsWith(".json") || name.endsWith(".jsonl");
175
+ if (matcher === "codebuff") return name === "chat-messages.json";
176
+ if (matcher === "droid") return name.endsWith(".settings.json") || name.endsWith(".jsonl");
177
+ if (matcher === "openclaw") return name.includes(".jsonl");
178
+ if (matcher === "grok") return name === "updates.jsonl" || name === "summary.json";
179
+ if (matcher === "opencode") return name.endsWith(".db") || name.includes(".db-") || name.endsWith(".json");
180
+ return false;
181
+ }
182
+
183
+ async function existsDirectory(value) {
184
+ try {
185
+ return (await stat(value)).isDirectory();
186
+ } catch {
187
+ return false;
188
+ }
189
+ }
190
+
191
+ async function expandCodexRoots(definitions, pathApi) {
192
+ const expanded = [];
193
+ for (const definition of definitions) {
194
+ if (definition.kind !== "codex-home") {
195
+ expanded.push(definition);
196
+ continue;
197
+ }
198
+ const sessions = pathApi.join(definition.path, "sessions");
199
+ const archived = pathApi.join(definition.path, "archived_sessions");
200
+ const [hasSessions, hasArchived] = await Promise.all([existsDirectory(sessions), existsDirectory(archived)]);
201
+ if (hasSessions) expanded.push(tree("codex", sessions, "jsonl"));
202
+ if (hasArchived) expanded.push(tree("codex", archived, "jsonl"));
203
+ if (!hasSessions && !hasArchived) expanded.push(tree("codex", definition.path, "jsonl"));
204
+ }
205
+ return expanded;
206
+ }
207
+
208
+ function configCandidates(definitions) {
209
+ return definitions.filter((item) => item.source === "ccusage-config").map((item) => item.path);
210
+ }
211
+
212
+ async function namedPiStores(definitions, home, pathApi) {
213
+ for (const configPath of configCandidates(definitions)) {
214
+ try {
215
+ const parsed = JSON.parse(await readFile(configPath, "utf8"));
216
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
217
+ const stores = Array.isArray(parsed.pi?.stores) ? parsed.pi.stores : [];
218
+ return stores.flatMap((store) => {
219
+ if (!store || typeof store.name !== "string" || typeof store.path !== "string" || !store.path.trim()) return [];
220
+ return [tree(store.name.trim().toLowerCase(), expandTilde(store.path.trim(), home, pathApi), "jsonl")];
221
+ });
222
+ } catch {}
223
+ }
224
+ return [];
225
+ }
226
+
227
+ function fileIdentity(source, filePath, metadata) {
228
+ return `${source}\u0000${filePath}\u0000${metadata.size}\u0000${metadata.mtimeMs}\u0000${metadata.ctimeMs}\n`;
229
+ }
230
+
231
+ export async function sourceInventory({ env = process.env, cwd = process.cwd(), home = ccusageHome(env), pathApi = path, maxFiles = MAX_FINGERPRINT_FILES } = {}) {
232
+ const hash = createHash("sha256");
233
+ hash.update(`inventory:${SOURCE_INVENTORY_VERSION}\nccusage:${CCUSAGE_VERSION}\naliases:${env.CCUSAGE_MODEL_ALIASES ?? ""}\n`);
234
+ let definitions = sourceDefinitions({ env, cwd, home, pathApi });
235
+ definitions.push(...await namedPiStores(definitions, home, pathApi));
236
+ definitions = await expandCodexRoots(definitions, pathApi);
237
+ definitions.sort((left, right) => `${left.source}\u0000${left.path}`.localeCompare(`${right.source}\u0000${right.path}`));
238
+
239
+ const sources = new Set();
240
+ const seenRoots = new Set();
241
+ const seenFiles = new Set();
242
+ let files = 0;
243
+ let truncated = false;
244
+ let complete = true;
245
+ let errors = 0;
246
+
247
+ async function addFile(definition, filePath) {
248
+ const identity = `${definition.source}\u0000${filePath}`;
249
+ if (seenFiles.has(identity)) return;
250
+ if (files >= maxFiles) {
251
+ truncated = true;
252
+ complete = false;
253
+ return;
254
+ }
255
+ try {
256
+ const metadata = await stat(filePath);
257
+ if (!metadata.isFile()) return;
258
+ seenFiles.add(identity);
259
+ hash.update(fileIdentity(definition.source, filePath, metadata));
260
+ if (definition.source === "ccusage-config") {
261
+ try {
262
+ hash.update(await readFile(filePath));
263
+ } catch {
264
+ complete = false;
265
+ errors += 1;
266
+ }
267
+ } else {
268
+ sources.add(definition.source);
269
+ }
270
+ files += 1;
271
+ } catch (error) {
272
+ if (error?.code !== "ENOENT") {
273
+ complete = false;
274
+ errors += 1;
275
+ }
276
+ }
277
+ }
278
+
279
+ for (const definition of definitions) {
280
+ const rootIdentity = `${definition.source}\u0000${definition.kind}\u0000${definition.path}`;
281
+ if (seenRoots.has(rootIdentity)) continue;
282
+ seenRoots.add(rootIdentity);
283
+ hash.update(`root:${rootIdentity}\n`);
284
+ if (definition.kind === "file") {
285
+ await addFile(definition, definition.path);
286
+ continue;
287
+ }
288
+ let rootMetadata;
289
+ try {
290
+ rootMetadata = await stat(definition.path);
291
+ } catch (error) {
292
+ if (error?.code !== "ENOENT") {
293
+ complete = false;
294
+ errors += 1;
295
+ }
296
+ continue;
297
+ }
298
+ if (!rootMetadata.isDirectory()) continue;
299
+ const stack = [definition.path];
300
+ while (stack.length && !truncated) {
301
+ const current = stack.pop();
302
+ let entries;
303
+ try {
304
+ entries = await readdir(current, { withFileTypes: true });
305
+ } catch {
306
+ complete = false;
307
+ errors += 1;
308
+ continue;
309
+ }
310
+ entries.sort((left, right) => left.name.localeCompare(right.name));
311
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
312
+ const entry = entries[index];
313
+ const filePath = pathApi.join(current, entry.name);
314
+ if (entry.isDirectory()) stack.push(filePath);
315
+ else if (entry.isFile() && accepts(definition.matcher, filePath, pathApi)) await addFile(definition, filePath);
316
+ if (truncated) break;
317
+ }
318
+ }
319
+ }
320
+
321
+ return {
322
+ complete,
323
+ errors,
324
+ files,
325
+ fingerprint: hash.digest("hex"),
326
+ sources: [...sources].sort(),
327
+ supportedSources: SUPPORTED_SOURCES,
328
+ truncated,
329
+ version: SOURCE_INVENTORY_VERSION,
330
+ };
331
+ }