usagemax 0.1.0 → 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 +66 -20
- package/package.json +4 -2
- package/src/cli.js +69 -104
- package/src/core.js +38 -2
- package/src/installation.js +38 -0
- package/src/sources.js +331 -0
package/README.md
CHANGED
|
@@ -1,42 +1,88 @@
|
|
|
1
1
|
# UsageMax CLI
|
|
2
2
|
|
|
3
|
-
Connect aggregate coding-agent usage from
|
|
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
|
|
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.
|
|
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
|
|
21
|
-
bunx usagemax sync
|
|
22
|
-
bunx usagemax sync --full
|
|
23
|
-
bunx usagemax link UMX-… --no-sync
|
|
24
|
-
bunx usagemax status
|
|
25
|
-
bunx usagemax doctor
|
|
26
|
-
bunx usagemax doctor --deep
|
|
27
|
-
bunx usagemax 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
|
|
38
|
+
bunx usagemax unlink # remove the local collector key
|
|
30
39
|
```
|
|
31
40
|
|
|
32
|
-
UsageMax
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
-
|
|
40
|
-
|
|
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.
|
|
41
86
|
|
|
42
|
-
|
|
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.
|
|
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",
|
|
@@ -15,11 +15,13 @@
|
|
|
15
15
|
},
|
|
16
16
|
"type": "module",
|
|
17
17
|
"bin": {
|
|
18
|
-
"usagemax": "
|
|
18
|
+
"usagemax": "src/cli.js"
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
|
21
21
|
"src/cli.js",
|
|
22
22
|
"src/core.js",
|
|
23
|
+
"src/installation.js",
|
|
24
|
+
"src/sources.js",
|
|
23
25
|
"README.md",
|
|
24
26
|
"LICENSE"
|
|
25
27
|
],
|
package/src/cli.js
CHANGED
|
@@ -1,24 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { execFile, spawn } from "node:child_process";
|
|
4
|
-
import {
|
|
5
|
-
import { chmod, mkdir, readFile,
|
|
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 {
|
|
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
|
+
import { stableInstallationId } from "./installation.js";
|
|
14
|
+
import { CCUSAGE_VERSION, ccusageEnvironment, SOURCE_INVENTORY_VERSION, sourceInventory, SUPPORTED_SOURCES } from "./sources.js";
|
|
13
15
|
|
|
14
16
|
const require = createRequire(import.meta.url);
|
|
15
17
|
const executeFile = promisify(execFile);
|
|
16
|
-
const VERSION = "0.
|
|
18
|
+
const VERSION = "0.2.0";
|
|
17
19
|
const DEFAULT_LINK_ENDPOINT = "https://terrific-bobcat-522.convex.site/v1/devices/link";
|
|
18
20
|
const CONFIG_FILE = "config.json";
|
|
19
21
|
const MAX_REPORT_BYTES = 100 * 1024 * 1024;
|
|
20
|
-
const
|
|
21
|
-
const USAGE_EXTENSIONS = new Set([".db", ".json", ".jsonl", ".sqlite", ".sqlite3"]);
|
|
22
|
+
const FULL_RECONCILE_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
22
23
|
|
|
23
24
|
function configDirectory() {
|
|
24
25
|
if (process.env.USAGEMAX_CONFIG_DIR) return process.env.USAGEMAX_CONFIG_DIR;
|
|
@@ -66,6 +67,7 @@ function option(args, name) {
|
|
|
66
67
|
function deviceLabel() {
|
|
67
68
|
if (platform() === "darwin") return "Mac";
|
|
68
69
|
if (platform() === "win32") return "Windows PC";
|
|
70
|
+
if (platform() === "linux" && process.env.WSL_DISTRO_NAME) return `WSL · ${process.env.WSL_DISTRO_NAME}`;
|
|
69
71
|
if (platform() === "linux") return "Linux computer";
|
|
70
72
|
return "Computer";
|
|
71
73
|
}
|
|
@@ -79,7 +81,7 @@ function help() {
|
|
|
79
81
|
process.stdout.write(" [--no-sync] [--name <name>]\n");
|
|
80
82
|
process.stdout.write(" usagemax sync [--full] [--json] Sync usage once, then exit\n");
|
|
81
83
|
process.stdout.write(" usagemax status Show local link status\n");
|
|
82
|
-
process.stdout.write(" usagemax doctor [--deep] Check
|
|
84
|
+
process.stdout.write(" usagemax doctor [--deep] Check source coverage; --deep parses full history\n");
|
|
83
85
|
process.stdout.write(" usagemax report [...args] Run a local ccusage report\n");
|
|
84
86
|
process.stdout.write(" usagemax unlink Remove the local collector key\n");
|
|
85
87
|
}
|
|
@@ -88,91 +90,21 @@ function ccusageCliPath() {
|
|
|
88
90
|
return join(dirname(require.resolve("ccusage/package.json")), "src", "cli.js");
|
|
89
91
|
}
|
|
90
92
|
|
|
91
|
-
function configuredPaths(variable, fallbacks) {
|
|
92
|
-
const configured = process.env[variable];
|
|
93
|
-
return configured
|
|
94
|
-
? configured.split(",").flatMap((group) => group.split(delimiter)).map((value) => value.trim()).filter(Boolean)
|
|
95
|
-
: fallbacks;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function usageRoots() {
|
|
99
|
-
const home = homedir();
|
|
100
|
-
return [
|
|
101
|
-
["claude", configuredPaths("CLAUDE_CONFIG_DIR", [join(home, ".config", "claude", "projects"), join(home, ".claude", "projects")])],
|
|
102
|
-
["codex", configuredPaths("CODEX_HOME", [join(home, ".codex", "sessions"), join(home, ".codex", "archived_sessions")])],
|
|
103
|
-
["opencode", configuredPaths("OPENCODE_DATA_DIR", [join(home, ".local", "share", "opencode")])],
|
|
104
|
-
["hermes", configuredPaths("HERMES_HOME", [join(home, ".hermes", "sessions")])],
|
|
105
|
-
["pi", configuredPaths("PI_AGENT_DIR", [join(home, ".pi", "agent", "sessions")])],
|
|
106
|
-
["copilot", configuredPaths("COPILOT_HOME", [join(home, ".copilot")])],
|
|
107
|
-
["gemini", configuredPaths("GEMINI_DATA_DIR", [join(home, ".gemini", "tmp")])],
|
|
108
|
-
].flatMap(([source, paths]) => paths.map((path) => ({ source, path })));
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
async function sourceInventory() {
|
|
112
|
-
const hash = createHash("sha256");
|
|
113
|
-
const sources = new Set();
|
|
114
|
-
const roots = usageRoots();
|
|
115
|
-
let files = 0;
|
|
116
|
-
let truncated = false;
|
|
117
|
-
for (const root of roots) {
|
|
118
|
-
try {
|
|
119
|
-
const rootStat = await stat(root.path);
|
|
120
|
-
if (!rootStat.isDirectory() && !rootStat.isFile()) continue;
|
|
121
|
-
sources.add(root.source);
|
|
122
|
-
} catch {
|
|
123
|
-
continue;
|
|
124
|
-
}
|
|
125
|
-
const stack = [root.path];
|
|
126
|
-
while (stack.length && files < MAX_FINGERPRINT_FILES) {
|
|
127
|
-
const current = stack.pop();
|
|
128
|
-
let entries;
|
|
129
|
-
try {
|
|
130
|
-
entries = await readdir(current, { withFileTypes: true });
|
|
131
|
-
} catch {
|
|
132
|
-
try {
|
|
133
|
-
const metadata = await stat(current);
|
|
134
|
-
if (metadata.isFile() && USAGE_EXTENSIONS.has(extname(current).toLowerCase())) {
|
|
135
|
-
hash.update(`${root.source}\u0000${current}\u0000${metadata.size}\u0000${metadata.mtimeMs}\n`);
|
|
136
|
-
files += 1;
|
|
137
|
-
}
|
|
138
|
-
} catch {}
|
|
139
|
-
continue;
|
|
140
|
-
}
|
|
141
|
-
for (const entry of entries) {
|
|
142
|
-
const path = join(current, entry.name);
|
|
143
|
-
if (entry.isDirectory()) {
|
|
144
|
-
if (!["node_modules", ".git", "cache", "tmp"].includes(entry.name)) stack.push(path);
|
|
145
|
-
continue;
|
|
146
|
-
}
|
|
147
|
-
if (!entry.isFile() || !USAGE_EXTENSIONS.has(extname(entry.name).toLowerCase())) continue;
|
|
148
|
-
try {
|
|
149
|
-
const metadata = await stat(path);
|
|
150
|
-
hash.update(`${root.source}\u0000${path}\u0000${metadata.size}\u0000${metadata.mtimeMs}\n`);
|
|
151
|
-
files += 1;
|
|
152
|
-
} catch {}
|
|
153
|
-
if (files >= MAX_FINGERPRINT_FILES) {
|
|
154
|
-
truncated = true;
|
|
155
|
-
break;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
return { fingerprint: hash.digest("hex"), sources: [...sources].sort(), files, truncated };
|
|
161
|
-
}
|
|
162
|
-
|
|
163
93
|
async function ccusageJson(config, { full = false } = {}) {
|
|
164
|
-
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"];
|
|
165
95
|
// Reconcile yesterday once after the UTC date changes. All other incremental
|
|
166
96
|
// scans parse only today; a metadata fingerprint avoids invoking ccusage when
|
|
167
97
|
// no supported local source changed at all.
|
|
168
|
-
if (
|
|
98
|
+
if (full) {
|
|
99
|
+
args.push("--since", "2024-01-01", "--until", new Date().toISOString().slice(0, 10));
|
|
100
|
+
} else if (config?.lastSyncAt) {
|
|
169
101
|
const today = new Date().toISOString().slice(0, 10);
|
|
170
102
|
args.push("--last", config.lastReconciledDay === today ? "1" : "2");
|
|
171
103
|
}
|
|
172
104
|
const { stdout } = await executeFile(process.execPath, args, {
|
|
173
105
|
encoding: "utf8",
|
|
174
106
|
maxBuffer: MAX_REPORT_BYTES,
|
|
175
|
-
env: { ...
|
|
107
|
+
env: { ...await ccusageEnvironment(), NO_COLOR: "1" },
|
|
176
108
|
});
|
|
177
109
|
return JSON.parse(stdout);
|
|
178
110
|
}
|
|
@@ -184,26 +116,32 @@ async function link(args) {
|
|
|
184
116
|
const endpoint = validHttpsUrl(configuredEndpoint, { allowLocalhost: true });
|
|
185
117
|
if (!endpoint) throw new Error("USAGEMAX_LINK_ENDPOINT must use HTTPS, except for localhost development.");
|
|
186
118
|
const name = (option(args, "--name") || deviceLabel()).trim().slice(0, 80);
|
|
119
|
+
const previous = await readConfig();
|
|
120
|
+
const deviceId = await stableInstallationId(configDirectory(), previous?.deviceId);
|
|
121
|
+
const headers = { "content-type": "application/json" };
|
|
122
|
+
if (previous?.token) headers.authorization = `Bearer ${previous.token}`;
|
|
187
123
|
const response = await fetch(endpoint, {
|
|
188
124
|
method: "POST",
|
|
189
|
-
headers
|
|
190
|
-
body: JSON.stringify({ code, name, platform: platform(), cliVersion: VERSION }),
|
|
125
|
+
headers,
|
|
126
|
+
body: JSON.stringify({ code, name, platform: platform(), cliVersion: VERSION, deviceId }),
|
|
191
127
|
signal: AbortSignal.timeout(15_000),
|
|
192
128
|
});
|
|
193
129
|
const body = await response.json().catch(() => ({}));
|
|
194
130
|
if (!response.ok) throw new Error(body?.error === "invalid_or_expired_link_code" ? "That link code is invalid, expired, or already used." : "UsageMax could not link this computer.");
|
|
195
131
|
const ingestUrl = validHttpsUrl(body.ingestUrl, { allowLocalhost: true });
|
|
196
132
|
if (!/^umx_[a-f0-9]{64}$/.test(body.token || "") || !ingestUrl) throw new Error("UsageMax returned an invalid link response.");
|
|
133
|
+
const profileHandle = typeof body.profileHandle === "string" ? body.profileHandle : undefined;
|
|
134
|
+
const sameAccount = Boolean(previous && previous.profileHandle && previous.profileHandle === profileHandle);
|
|
197
135
|
const config = {
|
|
198
136
|
version: 1,
|
|
199
137
|
token: body.token,
|
|
200
138
|
ingestUrl,
|
|
201
139
|
profileUrl: validHttpsUrl(body.profileUrl) || "https://usagemax.com/account",
|
|
202
|
-
profileHandle
|
|
203
|
-
deviceId
|
|
140
|
+
profileHandle,
|
|
141
|
+
deviceId,
|
|
204
142
|
deviceName: name,
|
|
205
143
|
linkedAt: new Date().toISOString(),
|
|
206
|
-
snapshots: {},
|
|
144
|
+
snapshots: sameAccount ? previous.snapshots : {},
|
|
207
145
|
};
|
|
208
146
|
await writeConfig(config);
|
|
209
147
|
process.stdout.write(`Linked ${name} to ${config.profileHandle ? `@${config.profileHandle}` : "UsageMax"}.\n`);
|
|
@@ -218,17 +156,28 @@ async function link(args) {
|
|
|
218
156
|
async function sync(args, suppliedConfig) {
|
|
219
157
|
const config = suppliedConfig || await readConfig();
|
|
220
158
|
if (!config) throw new Error("This computer is not linked. Open https://usagemax.com/account and create a link code.");
|
|
221
|
-
|
|
159
|
+
config.deviceId = await stableInstallationId(configDirectory(), config.deviceId);
|
|
160
|
+
const requestedFull = args.includes("--full");
|
|
222
161
|
const inventory = await sourceInventory();
|
|
223
162
|
const today = new Date().toISOString().slice(0, 10);
|
|
224
|
-
|
|
225
|
-
|
|
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 };
|
|
226
173
|
if (args.includes("--json")) process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
227
174
|
else process.stdout.write("Already up to date. Local usage files have not changed; no logs were parsed or uploaded.\n");
|
|
228
175
|
return;
|
|
229
176
|
}
|
|
230
177
|
const report = await ccusageJson(config, { full });
|
|
231
|
-
const { plan, regressions } = buildDeltaPlan(report, config.snapshots, config.deviceId,
|
|
178
|
+
const { plan, regressions } = buildDeltaPlan(report, config.snapshots, config.deviceId, `ccusage@${CCUSAGE_VERSION}`);
|
|
179
|
+
config.lastSyncComplete = false;
|
|
180
|
+
await writeConfig(config);
|
|
232
181
|
let accepted = 0;
|
|
233
182
|
for (let offset = 0; offset < plan.length; offset += 100) {
|
|
234
183
|
const batch = plan.slice(offset, offset + 100);
|
|
@@ -239,6 +188,7 @@ async function sync(args, suppliedConfig) {
|
|
|
239
188
|
authorization: `Bearer ${config.token}`,
|
|
240
189
|
"content-type": "application/json",
|
|
241
190
|
"idempotency-key": batchId(config.deviceId, events),
|
|
191
|
+
"x-usagemax-device-id": config.deviceId,
|
|
242
192
|
},
|
|
243
193
|
body: JSON.stringify({ events }),
|
|
244
194
|
signal: AbortSignal.timeout(30_000),
|
|
@@ -248,20 +198,21 @@ async function sync(args, suppliedConfig) {
|
|
|
248
198
|
accepted += Number(body.accepted || 0);
|
|
249
199
|
for (const item of batch) config.snapshots[item.snapshotKey] = item.snapshot;
|
|
250
200
|
config.lastSyncAt = new Date().toISOString();
|
|
251
|
-
config.lastReconciledDay = today;
|
|
252
|
-
config.sourceFingerprint = inventory.fingerprint;
|
|
253
201
|
await writeConfig(config);
|
|
254
202
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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 };
|
|
262
213
|
if (args.includes("--json")) process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
263
214
|
else {
|
|
264
|
-
process.stdout.write(plan.length ? `Synced ${accepted} changed usage rows from ${result.sources.join(", ") || "local agents"}.\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`);
|
|
265
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`);
|
|
266
217
|
}
|
|
267
218
|
}
|
|
@@ -272,8 +223,14 @@ async function status() {
|
|
|
272
223
|
process.stdout.write("Not linked. Open https://usagemax.com/account to connect this computer.\n");
|
|
273
224
|
return;
|
|
274
225
|
}
|
|
226
|
+
const stableId = await stableInstallationId(configDirectory(), config.deviceId);
|
|
227
|
+
if (config.deviceId !== stableId) {
|
|
228
|
+
config.deviceId = stableId;
|
|
229
|
+
await writeConfig(config);
|
|
230
|
+
}
|
|
275
231
|
process.stdout.write(`Linked: ${config.deviceName || deviceLabel()}${config.profileHandle ? ` → @${config.profileHandle}` : ""}\n`);
|
|
276
232
|
process.stdout.write(`Last sync: ${config.lastSyncAt || "never"}\n`);
|
|
233
|
+
process.stdout.write(`Last full reconciliation: ${config.lastFullSyncAt || "never"}\n`);
|
|
277
234
|
process.stdout.write(`Profile: ${config.profileUrl || "https://usagemax.com/account"}\n`);
|
|
278
235
|
}
|
|
279
236
|
|
|
@@ -282,8 +239,13 @@ async function doctor(args = []) {
|
|
|
282
239
|
const inventory = await sourceInventory();
|
|
283
240
|
process.stdout.write(`Collector: ${config ? "linked" : "not linked"}\n`);
|
|
284
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`);
|
|
285
247
|
if (args.includes("--deep")) {
|
|
286
|
-
const report = await ccusageJson(config, { full:
|
|
248
|
+
const report = await ccusageJson(config, { full: true });
|
|
287
249
|
process.stdout.write(`Parsed sources: ${sourceSummary(report).join(", ") || "none"}\n`);
|
|
288
250
|
}
|
|
289
251
|
process.stdout.write(`Mode: one-shot, metadata no-op check, ${args.includes("--deep") ? "deep local parse" : "no log parsing"}\n`);
|
|
@@ -291,7 +253,10 @@ async function doctor(args = []) {
|
|
|
291
253
|
|
|
292
254
|
async function report(args) {
|
|
293
255
|
const forwarded = args.length ? args : ["daily"];
|
|
294
|
-
const child = spawn(process.execPath, [ccusageCliPath(), ...forwarded], {
|
|
256
|
+
const child = spawn(process.execPath, [ccusageCliPath(), ...forwarded], {
|
|
257
|
+
stdio: "inherit",
|
|
258
|
+
env: await ccusageEnvironment(),
|
|
259
|
+
});
|
|
295
260
|
const code = await new Promise((resolve, reject) => {
|
|
296
261
|
child.once("error", reject);
|
|
297
262
|
child.once("exit", (status) => resolve(status ?? 1));
|
|
@@ -307,7 +272,7 @@ async function removeLink() {
|
|
|
307
272
|
return;
|
|
308
273
|
}
|
|
309
274
|
await unlink(path);
|
|
310
|
-
process.stdout.write("Removed the local UsageMax collector key. Revoke the collector in your account if this computer is no longer trusted.\n");
|
|
275
|
+
process.stdout.write("Removed the local UsageMax collector key. This computer's private installation identity was retained so relinking cannot duplicate its usage. Revoke the collector in your account if this computer is no longer trusted.\n");
|
|
311
276
|
}
|
|
312
277
|
|
|
313
278
|
async function main() {
|
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
|
|
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
|
|
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;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { platform } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const INSTALLATION_FILE = "installation.json";
|
|
7
|
+
|
|
8
|
+
export function validInstallationId(value) {
|
|
9
|
+
return typeof value === "string" && /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(value);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function stableInstallationId(directory, preferred) {
|
|
13
|
+
const path = join(directory, INSTALLATION_FILE);
|
|
14
|
+
try {
|
|
15
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
16
|
+
if (validInstallationId(parsed?.id)) return parsed.id;
|
|
17
|
+
throw new Error(`UsageMax installation identity is invalid: ${path}`);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (error?.code !== "ENOENT") throw error;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const id = validInstallationId(preferred) ? preferred : randomUUID();
|
|
23
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
24
|
+
try {
|
|
25
|
+
await writeFile(path, `${JSON.stringify({ version: 1, id }, null, 2)}\n`, {
|
|
26
|
+
encoding: "utf8",
|
|
27
|
+
mode: 0o600,
|
|
28
|
+
flag: "wx",
|
|
29
|
+
});
|
|
30
|
+
if (platform() !== "win32") await chmod(path, 0o600);
|
|
31
|
+
return id;
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error?.code !== "EEXIST") throw error;
|
|
34
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
35
|
+
if (validInstallationId(parsed?.id)) return parsed.id;
|
|
36
|
+
throw new Error(`UsageMax installation identity is invalid: ${path}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
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
|
+
}
|