usagemax 0.2.0 → 0.3.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 +36 -7
- package/package.json +2 -1
- package/src/archives.js +86 -0
- package/src/cli.js +235 -57
- package/src/core.js +188 -0
- package/src/sources.js +171 -7
package/README.md
CHANGED
|
@@ -14,9 +14,10 @@ bunx usagemax@latest link UMX-XXXX-XXXX-XXXX-XXXX
|
|
|
14
14
|
|
|
15
15
|
The link code expires after ten minutes and can be used once. Create a new code
|
|
16
16
|
for each Mac, Windows PC, Linux computer, and WSL distribution. All linked
|
|
17
|
-
collectors roll up into the same profile.
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
collectors roll up into the same profile. When run inside WSL, UsageMax includes
|
|
18
|
+
readable supported-provider homes under `/mnt/c/Users` automatically. Use the
|
|
19
|
+
WSL collector as the single collector for that Windows PC instead of linking the
|
|
20
|
+
same host history again from Windows.
|
|
20
21
|
|
|
21
22
|
The CLI stores the resulting collector key in a user-only config file, then runs
|
|
22
23
|
a one-shot full-history sync. Running it again, changing the display name, or
|
|
@@ -29,13 +30,17 @@ installation identity remains stable.
|
|
|
29
30
|
bunx usagemax # sync changed usage
|
|
30
31
|
bunx usagemax sync # same as above
|
|
31
32
|
bunx usagemax sync --full # reconcile all retained local history
|
|
33
|
+
bunx usagemax sync --archives # one-time compressed-history recovery
|
|
34
|
+
bunx usagemax sync --dry-run --explain
|
|
35
|
+
# inspect the exact bounded plan; upload nothing
|
|
32
36
|
bunx usagemax link UMX-… --no-sync # link without uploading yet
|
|
33
37
|
bunx usagemax status # show link and last-sync state
|
|
34
38
|
bunx usagemax doctor # metadata-only source check
|
|
35
|
-
bunx usagemax doctor --deep
|
|
39
|
+
bunx usagemax doctor --deep --json # machine-readable retained-history audit
|
|
36
40
|
bunx usagemax report # open ccusage's local daily report
|
|
37
41
|
bunx usagemax report session --breakdown
|
|
38
42
|
bunx usagemax unlink # remove the local collector key
|
|
43
|
+
bunx usagemax unlink --revoke # disable future uploads, then remove locally
|
|
39
44
|
```
|
|
40
45
|
|
|
41
46
|
UsageMax pins [ccusage v20.0.20](https://github.com/ccusage/ccusage/releases/tag/v20.0.20)
|
|
@@ -51,6 +56,20 @@ The source inventory follows ccusage's environment overrides, including
|
|
|
51
56
|
`QWEN_DATA_DIR`, `COPILOT_OTEL_FILE_EXPORTER_PATH`, `GEMINI_DATA_DIR`, and
|
|
52
57
|
`GROK_HOME`. It also follows XDG Claude configuration and Windows Goose storage.
|
|
53
58
|
|
|
59
|
+
UsageMax also discovers Claude Desktop local-agent sessions, `.cc-mirror`,
|
|
60
|
+
recognizable renamed Claude/Codex backup folders, and supported Windows homes
|
|
61
|
+
from WSL. Discovery is bounded to known locations and immediate home entries;
|
|
62
|
+
normal syncs do not crawl the whole disk.
|
|
63
|
+
On a multi-user WSL host, automatic Windows-home discovery stays off unless
|
|
64
|
+
there is exactly one provider-bearing profile; set `USAGEMAX_ADDITIONAL_HOME`
|
|
65
|
+
to the intended mounted home explicitly.
|
|
66
|
+
|
|
67
|
+
If `doctor` reports compressed provider archives, run `sync --archives` once.
|
|
68
|
+
Recovery extracts only safe Claude `projects/*.jsonl` members into a private
|
|
69
|
+
temporary directory, performs one full deduplicated reconciliation, and removes
|
|
70
|
+
the temporary files before exit. Weekly and incremental syncs never unpack
|
|
71
|
+
archives.
|
|
72
|
+
|
|
54
73
|
Local files are only one coverage layer. Cursor, Windsurf, Aider, Continue,
|
|
55
74
|
Cline, Roo Code, direct provider API traffic, hosted agents, and enterprise
|
|
56
75
|
billing systems do not all expose a stable local token ledger. Capture those
|
|
@@ -59,8 +78,9 @@ connector. UsageMax never invents usage that the source did not retain.
|
|
|
59
78
|
|
|
60
79
|
## Privacy, correctness, and load
|
|
61
80
|
|
|
62
|
-
- The sync payload contains aggregate token counts,
|
|
63
|
-
source names, and
|
|
81
|
+
- The sync payload contains authoritative aggregate token counts,
|
|
82
|
+
model/provider names, costs, source names, dates, coverage state, and opaque
|
|
83
|
+
SHA-256 session identities.
|
|
64
84
|
- It does not upload prompts, completions, source code, file contents, project
|
|
65
85
|
paths, or provider credentials.
|
|
66
86
|
- Sync is one-shot. There is no resident scanner or high-frequency polling loop.
|
|
@@ -68,15 +88,24 @@ connector. UsageMax never invents usage that the source did not retain.
|
|
|
68
88
|
nothing changed.
|
|
69
89
|
- Normal changed syncs parse today or today plus yesterday. A bounded weekly
|
|
70
90
|
full reconciliation catches restored files, parser changes, and older logs.
|
|
71
|
-
- Full history means all retained local history from 2024 onward.
|
|
91
|
+
- Full history means all retained local history from 2024 onward. UsageMax
|
|
92
|
+
publishes complete source/day partitions, so decreased or removed local rows
|
|
93
|
+
correct the server-owned contribution instead of being silently retained.
|
|
94
|
+
Deleted or
|
|
72
95
|
never-persisted usage requires a provider export; no local tool can reconstruct it.
|
|
73
96
|
- Source totals that cannot be assigned to a model are retained as
|
|
74
97
|
`unattributed` rather than silently discarded.
|
|
75
98
|
- The collector key is written with user-only permissions where the operating
|
|
76
99
|
system supports them.
|
|
100
|
+
- The package contains no shared service credential or private deployment URL.
|
|
101
|
+
It talks to the versioned `https://usagemax.com/api` contract. The random
|
|
102
|
+
per-installation write token is the only local secret; the service stores only
|
|
103
|
+
its hash and applies device binding, replay checks, payload caps, and quotas.
|
|
77
104
|
- A private random installation ID survives collector rotation, relinking, and
|
|
78
105
|
display-name changes. It is not a hardware fingerprint; the server stores only
|
|
79
106
|
its SHA-256 hash. Concurrent and repeated syncs are idempotent.
|
|
107
|
+
- The server, not the local checkpoint, owns the accounting baseline. A lost
|
|
108
|
+
response or interrupted run can be retried without adding the same partition twice.
|
|
80
109
|
- Do not point two different installations at the same copied or network-mounted
|
|
81
110
|
log tree. Cross-installation copied-history deduplication is inherently
|
|
82
111
|
ambiguous and intentionally not guessed.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "usagemax",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Link local coding-agent usage to your UsageMax profile",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "UsageMax",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"usagemax": "src/cli.js"
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
|
21
|
+
"src/archives.js",
|
|
21
22
|
"src/cli.js",
|
|
22
23
|
"src/core.js",
|
|
23
24
|
"src/installation.js",
|
package/src/archives.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdir, mkdtemp, readdir, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
|
|
7
|
+
import { ccusageHome, discoverProviderArchives } from "./sources.js";
|
|
8
|
+
|
|
9
|
+
const executeFile = promisify(execFile);
|
|
10
|
+
const MAX_ARCHIVE_LIST_BYTES = 100 * 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
function archiveFlags(filePath, mode) {
|
|
13
|
+
const compressed = /(?:\.tar\.gz|\.tgz)$/i.test(filePath);
|
|
14
|
+
return mode === "list" ? (compressed ? "-tzf" : "-tf") : (compressed ? "-xzf" : "-xf");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function safeArchiveMember(value, source = "claude") {
|
|
18
|
+
const normalized = String(value || "").replaceAll("\\", "/");
|
|
19
|
+
const parts = normalized.split("/").filter((item) => item && item !== ".");
|
|
20
|
+
const providerDirectory = source === "codex"
|
|
21
|
+
? parts.includes("sessions") || parts.includes("archived_sessions")
|
|
22
|
+
: parts.includes("projects");
|
|
23
|
+
return !normalized.startsWith("/") && !parts.includes("..") && providerDirectory && normalized.endsWith(".jsonl");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function findProviderRoots(root, maxDirectories = 10_000) {
|
|
27
|
+
const claude = [];
|
|
28
|
+
const codex = [];
|
|
29
|
+
const stack = [root];
|
|
30
|
+
let visited = 0;
|
|
31
|
+
while (stack.length && visited < maxDirectories) {
|
|
32
|
+
const current = stack.pop();
|
|
33
|
+
visited += 1;
|
|
34
|
+
let children;
|
|
35
|
+
try {
|
|
36
|
+
children = await readdir(current, { withFileTypes: true });
|
|
37
|
+
} catch {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
for (const child of children) {
|
|
41
|
+
if (!child.isDirectory()) continue;
|
|
42
|
+
const childPath = join(current, child.name);
|
|
43
|
+
if (child.name === "projects") claude.push(childPath);
|
|
44
|
+
else if (child.name === "sessions" || child.name === "archived_sessions") codex.push(current);
|
|
45
|
+
else stack.push(childPath);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { claude, codex };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function prepareArchiveRecovery(baseEnv) {
|
|
52
|
+
const detected = await discoverProviderArchives({ env: baseEnv, home: ccusageHome(baseEnv) });
|
|
53
|
+
const archives = detected.filter((item) => /(?:\.tar(?:\.gz)?|\.tgz)$/i.test(item.path));
|
|
54
|
+
const unsupported = detected.length - archives.length;
|
|
55
|
+
if (!archives.length) return { archives: 0, cleanup: async () => undefined, env: baseEnv, unsupported };
|
|
56
|
+
|
|
57
|
+
const temporary = await mkdtemp(join(tmpdir(), "usagemax-recover-"));
|
|
58
|
+
try {
|
|
59
|
+
for (let index = 0; index < archives.length; index += 1) {
|
|
60
|
+
const archive = archives[index];
|
|
61
|
+
const destination = join(temporary, `archive-${index + 1}`);
|
|
62
|
+
await mkdir(destination, { recursive: true, mode: 0o700 });
|
|
63
|
+
const { stdout } = await executeFile("tar", [archiveFlags(archive.path, "list"), archive.path], { encoding: "utf8", maxBuffer: MAX_ARCHIVE_LIST_BYTES });
|
|
64
|
+
const members = stdout.split(/\r?\n/).filter((member) => safeArchiveMember(member, archive.source));
|
|
65
|
+
for (let offset = 0; offset < members.length; offset += 80) {
|
|
66
|
+
await executeFile("tar", [archiveFlags(archive.path, "extract"), archive.path, "-C", destination, "--", ...members.slice(offset, offset + 80)], { maxBuffer: 4 * 1024 * 1024 });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const roots = await findProviderRoots(temporary);
|
|
70
|
+
const existingClaude = String(baseEnv.CLAUDE_CONFIG_DIR || "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
71
|
+
const existingCodex = String(baseEnv.CODEX_HOME || "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
72
|
+
return {
|
|
73
|
+
archives: archives.length,
|
|
74
|
+
cleanup: async () => rm(temporary, { force: true, recursive: true }),
|
|
75
|
+
env: {
|
|
76
|
+
...baseEnv,
|
|
77
|
+
CLAUDE_CONFIG_DIR: [...new Set([...existingClaude, ...roots.claude])].join(","),
|
|
78
|
+
CODEX_HOME: [...new Set([...existingCodex, ...roots.codex])].join(","),
|
|
79
|
+
},
|
|
80
|
+
unsupported,
|
|
81
|
+
};
|
|
82
|
+
} catch (error) {
|
|
83
|
+
await rm(temporary, { force: true, recursive: true });
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -9,14 +9,16 @@ import { dirname, join } from "node:path";
|
|
|
9
9
|
import process from "node:process";
|
|
10
10
|
import { promisify } from "node:util";
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import { prepareArchiveRecovery } from "./archives.js";
|
|
13
|
+
import { buildSessionPlan, buildSnapshotPlan, normalizeLinkCode, sourceSummary, validHttpsUrl } from "./core.js";
|
|
13
14
|
import { stableInstallationId } from "./installation.js";
|
|
14
|
-
import { CCUSAGE_VERSION, ccusageEnvironment, SOURCE_INVENTORY_VERSION, sourceInventory, SUPPORTED_SOURCES } from "./sources.js";
|
|
15
|
+
import { CCUSAGE_VERSION, ccusageEnvironment, ccusageHome, discoverProviderArchives, SOURCE_INVENTORY_VERSION, sourceInventory, SUPPORTED_SOURCES } from "./sources.js";
|
|
15
16
|
|
|
16
17
|
const require = createRequire(import.meta.url);
|
|
17
18
|
const executeFile = promisify(execFile);
|
|
18
|
-
const VERSION = "0.
|
|
19
|
-
const
|
|
19
|
+
const VERSION = "0.3.0";
|
|
20
|
+
const PUBLIC_API_ORIGIN = "https://usagemax.com/api";
|
|
21
|
+
const DEFAULT_LINK_ENDPOINT = `${PUBLIC_API_ORIGIN}/v1/devices/link`;
|
|
20
22
|
const CONFIG_FILE = "config.json";
|
|
21
23
|
const MAX_REPORT_BYTES = 100 * 1024 * 1024;
|
|
22
24
|
const FULL_RECONCILE_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
@@ -31,6 +33,18 @@ function configPath() {
|
|
|
31
33
|
return join(configDirectory(), CONFIG_FILE);
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
function isLegacyDirectApi(config) {
|
|
37
|
+
try {
|
|
38
|
+
const ingest = new URL(config.ingestUrl);
|
|
39
|
+
const profile = new URL(config.profileUrl || "https://usagemax.com");
|
|
40
|
+
return profile.hostname === "usagemax.com"
|
|
41
|
+
&& ingest.hostname.endsWith(".convex.site")
|
|
42
|
+
&& ingest.pathname === "/v1/telemetry/llm";
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
34
48
|
async function readConfig() {
|
|
35
49
|
try {
|
|
36
50
|
const parsed = JSON.parse(await readFile(configPath(), "utf8"));
|
|
@@ -38,6 +52,16 @@ async function readConfig() {
|
|
|
38
52
|
if (!validHttpsUrl(parsed.ingestUrl, { allowLocalhost: true })) return null;
|
|
39
53
|
if (typeof parsed.deviceId !== "string" || !parsed.deviceId) return null;
|
|
40
54
|
parsed.snapshots = parsed.snapshots && typeof parsed.snapshots === "object" ? parsed.snapshots : {};
|
|
55
|
+
if (isLegacyDirectApi(parsed)) {
|
|
56
|
+
parsed.ingestUrl = `${PUBLIC_API_ORIGIN}/v1/telemetry/llm`;
|
|
57
|
+
parsed.snapshotUrl = `${PUBLIC_API_ORIGIN}/v2/usage/snapshots`;
|
|
58
|
+
parsed.revokeUrl = `${PUBLIC_API_ORIGIN}/v1/devices/revoke`;
|
|
59
|
+
await writeConfig(parsed);
|
|
60
|
+
}
|
|
61
|
+
parsed.snapshotUrl = validHttpsUrl(parsed.snapshotUrl, { allowLocalhost: true })
|
|
62
|
+
|| parsed.ingestUrl.replace(/\/v1\/telemetry\/llm$/, "/v2/usage/snapshots");
|
|
63
|
+
parsed.revokeUrl = validHttpsUrl(parsed.revokeUrl, { allowLocalhost: true })
|
|
64
|
+
|| parsed.ingestUrl.replace(/\/v1\/telemetry\/llm$/, "/v1/devices/revoke");
|
|
41
65
|
return parsed;
|
|
42
66
|
} catch {
|
|
43
67
|
return null;
|
|
@@ -79,19 +103,22 @@ function help() {
|
|
|
79
103
|
process.stdout.write(" usagemax Sync changed local usage\n");
|
|
80
104
|
process.stdout.write(" usagemax link <one-use-code> Link and sync this computer\n");
|
|
81
105
|
process.stdout.write(" [--no-sync] [--name <name>]\n");
|
|
82
|
-
process.stdout.write(" usagemax sync [--full] [--
|
|
106
|
+
process.stdout.write(" usagemax sync [--full] [--archives] [--dry-run] [--explain] [--json]\n");
|
|
107
|
+
process.stdout.write(" Reconcile once; --archives performs one-time recovery\n");
|
|
83
108
|
process.stdout.write(" usagemax status Show local link status\n");
|
|
84
|
-
process.stdout.write(" usagemax doctor [--deep]
|
|
109
|
+
process.stdout.write(" usagemax doctor [--deep] [--json]\n");
|
|
110
|
+
process.stdout.write(" Check source coverage; --deep parses full history\n");
|
|
85
111
|
process.stdout.write(" usagemax report [...args] Run a local ccusage report\n");
|
|
86
|
-
process.stdout.write(" usagemax unlink
|
|
112
|
+
process.stdout.write(" usagemax unlink [--revoke] Remove locally; --revoke also disables uploads\n");
|
|
87
113
|
}
|
|
88
114
|
|
|
89
115
|
function ccusageCliPath() {
|
|
90
116
|
return join(dirname(require.resolve("ccusage/package.json")), "src", "cli.js");
|
|
91
117
|
}
|
|
92
118
|
|
|
93
|
-
async function ccusageJson(config, { full = false } = {}) {
|
|
119
|
+
async function ccusageJson(config, { full = false, env } = {}) {
|
|
94
120
|
const args = [ccusageCliPath(), "daily", "--json", "--offline", "--mode", "calculate", "--timezone", "UTC", "--by-agent", "--order", "asc"];
|
|
121
|
+
args.push("--sections", "daily,session");
|
|
95
122
|
// Reconcile yesterday once after the UTC date changes. All other incremental
|
|
96
123
|
// scans parse only today; a metadata fingerprint avoids invoking ccusage when
|
|
97
124
|
// no supported local source changed at all.
|
|
@@ -104,11 +131,48 @@ async function ccusageJson(config, { full = false } = {}) {
|
|
|
104
131
|
const { stdout } = await executeFile(process.execPath, args, {
|
|
105
132
|
encoding: "utf8",
|
|
106
133
|
maxBuffer: MAX_REPORT_BYTES,
|
|
107
|
-
env: { ...await ccusageEnvironment(), NO_COLOR: "1" },
|
|
134
|
+
env: { ...(env || await ccusageEnvironment()), NO_COLOR: "1" },
|
|
108
135
|
});
|
|
109
136
|
return JSON.parse(stdout);
|
|
110
137
|
}
|
|
111
138
|
|
|
139
|
+
function snapshotEndpoint(config) {
|
|
140
|
+
return validHttpsUrl(config.snapshotUrl, { allowLocalhost: true })
|
|
141
|
+
|| config.ingestUrl.replace(/\/v1\/telemetry\/llm$/, "/v2/usage/snapshots");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function snapshotRequest(config, operation, payload, timeout = 30_000) {
|
|
145
|
+
const response = await fetch(snapshotEndpoint(config), {
|
|
146
|
+
method: "POST",
|
|
147
|
+
headers: {
|
|
148
|
+
authorization: `Bearer ${config.token}`,
|
|
149
|
+
"content-type": "application/json",
|
|
150
|
+
"x-usagemax-device-id": config.deviceId,
|
|
151
|
+
},
|
|
152
|
+
body: JSON.stringify({ operation, ...payload }),
|
|
153
|
+
signal: AbortSignal.timeout(timeout),
|
|
154
|
+
});
|
|
155
|
+
const body = await response.json().catch(() => ({}));
|
|
156
|
+
if (!response.ok) {
|
|
157
|
+
if (body?.error === "unauthorized") throw new Error("This collector key is no longer valid. Link the computer again.");
|
|
158
|
+
throw new Error(`UsageMax rejected ${operation} (${response.status}${body?.error ? `: ${body.error}` : ""}).`);
|
|
159
|
+
}
|
|
160
|
+
return body;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function newerVersion(recommended) {
|
|
164
|
+
if (!/^\d+\.\d+\.\d+$/.test(recommended || "")) return false;
|
|
165
|
+
const current = VERSION.split(".").map(Number);
|
|
166
|
+
const next = recommended.split(".").map(Number);
|
|
167
|
+
return next.some((value, index) => value > current[index] && next.slice(0, index).every((prior, priorIndex) => prior === current[priorIndex]));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function warnVersion(body) {
|
|
171
|
+
if (newerVersion(body?.recommendedCliVersion)) {
|
|
172
|
+
process.stderr.write(`UsageMax ${body.recommendedCliVersion} is available. Run \`bunx usagemax@latest\` for current coverage fixes.\n`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
112
176
|
async function link(args) {
|
|
113
177
|
const code = normalizeLinkCode(args[0]);
|
|
114
178
|
if (!code) throw new Error("Paste the one-use UMX link code shown at usagemax.com/account.");
|
|
@@ -129,13 +193,17 @@ async function link(args) {
|
|
|
129
193
|
const body = await response.json().catch(() => ({}));
|
|
130
194
|
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.");
|
|
131
195
|
const ingestUrl = validHttpsUrl(body.ingestUrl, { allowLocalhost: true });
|
|
132
|
-
|
|
196
|
+
const snapshotUrl = validHttpsUrl(body.snapshotUrl, { allowLocalhost: true }) || ingestUrl?.replace(/\/v1\/telemetry\/llm$/, "/v2/usage/snapshots");
|
|
197
|
+
const revokeUrl = validHttpsUrl(body.revokeUrl, { allowLocalhost: true }) || ingestUrl?.replace(/\/v1\/telemetry\/llm$/, "/v1/devices/revoke");
|
|
198
|
+
if (!/^umx_[a-f0-9]{64}$/.test(body.token || "") || !ingestUrl || !snapshotUrl || !revokeUrl) throw new Error("UsageMax returned an invalid link response.");
|
|
133
199
|
const profileHandle = typeof body.profileHandle === "string" ? body.profileHandle : undefined;
|
|
134
200
|
const sameAccount = Boolean(previous && previous.profileHandle && previous.profileHandle === profileHandle);
|
|
135
201
|
const config = {
|
|
136
202
|
version: 1,
|
|
137
203
|
token: body.token,
|
|
138
204
|
ingestUrl,
|
|
205
|
+
snapshotUrl,
|
|
206
|
+
revokeUrl,
|
|
139
207
|
profileUrl: validHttpsUrl(body.profileUrl) || "https://usagemax.com/account",
|
|
140
208
|
profileHandle,
|
|
141
209
|
deviceId,
|
|
@@ -144,6 +212,7 @@ async function link(args) {
|
|
|
144
212
|
snapshots: sameAccount ? previous.snapshots : {},
|
|
145
213
|
};
|
|
146
214
|
await writeConfig(config);
|
|
215
|
+
warnVersion(body);
|
|
147
216
|
process.stdout.write(`Linked ${name} to ${config.profileHandle ? `@${config.profileHandle}` : "UsageMax"}.\n`);
|
|
148
217
|
if (args.includes("--no-sync")) {
|
|
149
218
|
process.stdout.write("No usage was uploaded. Run `bunx usagemax sync --full` when you are ready.\n");
|
|
@@ -154,67 +223,139 @@ async function link(args) {
|
|
|
154
223
|
}
|
|
155
224
|
|
|
156
225
|
async function sync(args, suppliedConfig) {
|
|
226
|
+
const baseEnv = await ccusageEnvironment();
|
|
227
|
+
const recovery = args.includes("--archives")
|
|
228
|
+
? await prepareArchiveRecovery(baseEnv)
|
|
229
|
+
: { archives: 0, cleanup: async () => undefined, env: baseEnv, unsupported: 0 };
|
|
230
|
+
try {
|
|
231
|
+
return await syncPrepared(args, suppliedConfig, recovery);
|
|
232
|
+
} finally {
|
|
233
|
+
await recovery.cleanup();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function syncPrepared(args, suppliedConfig, recovery) {
|
|
157
238
|
const config = suppliedConfig || await readConfig();
|
|
158
239
|
if (!config) throw new Error("This computer is not linked. Open https://usagemax.com/account and create a link code.");
|
|
159
240
|
config.deviceId = await stableInstallationId(configDirectory(), config.deviceId);
|
|
160
241
|
const requestedFull = args.includes("--full");
|
|
161
|
-
const
|
|
242
|
+
const requestedArchives = args.includes("--archives");
|
|
243
|
+
const dryRun = args.includes("--dry-run");
|
|
244
|
+
const explain = args.includes("--explain");
|
|
245
|
+
const json = args.includes("--json");
|
|
246
|
+
const inventory = await sourceInventory({ env: recovery.env, home: ccusageHome(recovery.env) });
|
|
162
247
|
const today = new Date().toISOString().slice(0, 10);
|
|
163
248
|
const knownSources = Array.isArray(config.knownSources) ? config.knownSources : [];
|
|
164
249
|
const foundNewSource = inventory.sources.some((source) => !knownSources.includes(source));
|
|
165
250
|
const lastFullSync = Date.parse(config.lastFullSyncAt || "");
|
|
166
|
-
const fullDue = config.
|
|
251
|
+
const fullDue = config.snapshotProtocolVersion !== 2
|
|
252
|
+
|| config.sourceInventoryVersion !== SOURCE_INVENTORY_VERSION
|
|
167
253
|
|| !Number.isFinite(lastFullSync)
|
|
168
254
|
|| Date.now() - lastFullSync >= FULL_RECONCILE_INTERVAL_MS
|
|
169
255
|
|| foundNewSource;
|
|
170
|
-
const full = requestedFull || fullDue;
|
|
256
|
+
const full = requestedFull || requestedArchives || fullDue;
|
|
171
257
|
if (!full && inventory.complete && config.lastSyncComplete && config.lastReconciledDay === today && config.sourceFingerprint === inventory.fingerprint) {
|
|
172
|
-
const result = { accepted: 0, changedRows: 0, sources: inventory.sources,
|
|
173
|
-
if (
|
|
258
|
+
const result = { accepted: 0, changedRows: 0, sessions: 0, sources: inventory.sources, corrections: 0, scanned: false, full: false, coverage: inventory.complete ? "complete" : "partial" };
|
|
259
|
+
if (json) process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
174
260
|
else process.stdout.write("Already up to date. Local usage files have not changed; no logs were parsed or uploaded.\n");
|
|
175
261
|
return;
|
|
176
262
|
}
|
|
177
|
-
const report = await ccusageJson(config, { full });
|
|
178
|
-
const
|
|
263
|
+
const report = await ccusageJson(config, { env: recovery.env, full });
|
|
264
|
+
const legacySnapshotBootstrap = config.snapshotProtocolVersion !== 2
|
|
265
|
+
&& Object.keys(config.snapshots || {}).length > 0;
|
|
266
|
+
const runId = randomUUID();
|
|
267
|
+
const revision = Date.now();
|
|
268
|
+
const pricingVersion = `ccusage@${CCUSAGE_VERSION}`;
|
|
269
|
+
const { partitions, nextSnapshots, regressions } = buildSnapshotPlan(report, config.snapshots, {
|
|
270
|
+
bootstrap: config.snapshotProtocolVersion !== 2,
|
|
271
|
+
complete: inventory.complete,
|
|
272
|
+
full,
|
|
273
|
+
pricingVersion,
|
|
274
|
+
revision,
|
|
275
|
+
runId,
|
|
276
|
+
});
|
|
277
|
+
const sessions = buildSessionPlan(report, config.deviceId);
|
|
278
|
+
const sources = sourceSummary(report);
|
|
279
|
+
const days = Array.isArray(report?.daily)
|
|
280
|
+
? report.daily.map((row) => row?.period).filter((day) => /^\d{4}-\d{2}-\d{2}$/.test(day || "")).sort()
|
|
281
|
+
: [];
|
|
282
|
+
const result = {
|
|
283
|
+
accepted: 0,
|
|
284
|
+
changedRows: partitions.reduce((sum, partition) => sum + partition.rows.filter((row) => JSON.stringify(row.previous) !== JSON.stringify(row.current)).length, 0),
|
|
285
|
+
sessions: sessions.length,
|
|
286
|
+
sources,
|
|
287
|
+
corrections: regressions.length,
|
|
288
|
+
partitions: partitions.length,
|
|
289
|
+
scanned: true,
|
|
290
|
+
full,
|
|
291
|
+
coverage: inventory.complete && !inventory.truncated && inventory.errors === 0 ? "complete" : "partial",
|
|
292
|
+
range: { from: days[0], to: days.at(-1) },
|
|
293
|
+
};
|
|
294
|
+
if (requestedArchives) {
|
|
295
|
+
result.archives = recovery.archives;
|
|
296
|
+
result.unsupportedArchives = recovery.unsupported;
|
|
297
|
+
}
|
|
298
|
+
if (dryRun) {
|
|
299
|
+
if (json) process.stdout.write(`${JSON.stringify({ ...result, dryRun: true })}\n`);
|
|
300
|
+
else {
|
|
301
|
+
process.stdout.write(`Dry run: ${partitions.length} partition(s), ${result.changedRows} changed row(s), ${sessions.length} private session identifiers, no upload.\n`);
|
|
302
|
+
if (explain) process.stdout.write(`Coverage ${result.coverage}; ${sources.length} source(s); ${days[0] || "unknown"} to ${days.at(-1) || "unknown"}; ${regressions.length} downward correction(s).\n`);
|
|
303
|
+
}
|
|
304
|
+
return result;
|
|
305
|
+
}
|
|
179
306
|
config.lastSyncComplete = false;
|
|
180
307
|
await writeConfig(config);
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
body: JSON.stringify({ events }),
|
|
194
|
-
signal: AbortSignal.timeout(30_000),
|
|
308
|
+
try {
|
|
309
|
+
const begin = await snapshotRequest(config, "begin", {
|
|
310
|
+
runId,
|
|
311
|
+
mode: requestedArchives ? "archives" : full ? "full" : "incremental",
|
|
312
|
+
baselineMode: legacySnapshotBootstrap ? "adopt-current" : "apply",
|
|
313
|
+
sourceCount: sources.length,
|
|
314
|
+
partitionCount: partitions.length,
|
|
315
|
+
inventoryComplete: inventory.complete,
|
|
316
|
+
inventoryErrors: inventory.errors,
|
|
317
|
+
inventoryTruncated: inventory.truncated,
|
|
318
|
+
coverageStartDay: days[0],
|
|
319
|
+
coverageEndDay: days.at(-1),
|
|
195
320
|
});
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
321
|
+
warnVersion(begin);
|
|
322
|
+
for (let offset = 0; offset < sessions.length; offset += 100) {
|
|
323
|
+
await snapshotRequest(config, "sessions", { runId, sessions: sessions.slice(offset, offset + 100) });
|
|
324
|
+
}
|
|
325
|
+
for (let offset = 0; offset < partitions.length; offset += 10) {
|
|
326
|
+
const response = await snapshotRequest(config, "partitions", { runId, partitions: partitions.slice(offset, offset + 10) }, 60_000);
|
|
327
|
+
result.accepted += Number(response.changedRows || 0);
|
|
328
|
+
if (!json && process.stderr.isTTY && partitions.length > 10) {
|
|
329
|
+
process.stderr.write(`UsageMax: uploaded ${Math.min(offset + 10, partitions.length)}/${partitions.length} history partitions\r`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (!json && process.stderr.isTTY && partitions.length > 10) process.stderr.write("\n");
|
|
333
|
+
const completed = await snapshotRequest(config, "complete", { runId });
|
|
334
|
+
warnVersion(completed);
|
|
335
|
+
config.snapshots = nextSnapshots;
|
|
336
|
+
config.snapshotProtocolVersion = 2;
|
|
200
337
|
config.lastSyncAt = new Date().toISOString();
|
|
338
|
+
config.lastReconciledDay = today;
|
|
339
|
+
config.lastSyncComplete = true;
|
|
340
|
+
config.sourceInventoryVersion = SOURCE_INVENTORY_VERSION;
|
|
341
|
+
config.knownSources = [...new Set([...knownSources, ...inventory.sources, ...sources])].sort();
|
|
342
|
+
if (inventory.complete) config.sourceFingerprint = inventory.fingerprint;
|
|
343
|
+
else delete config.sourceFingerprint;
|
|
344
|
+
if (full) config.lastFullSyncAt = config.lastSyncAt;
|
|
201
345
|
await writeConfig(config);
|
|
346
|
+
} catch (error) {
|
|
347
|
+
await snapshotRequest(config, "fail", { runId, failureCode: error instanceof Error ? error.message.slice(0, 80) : "sync_failed" }).catch(() => undefined);
|
|
348
|
+
throw error;
|
|
202
349
|
}
|
|
203
|
-
|
|
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 };
|
|
213
|
-
if (args.includes("--json")) process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
350
|
+
if (json) process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
214
351
|
else {
|
|
215
|
-
process.stdout.write(
|
|
216
|
-
|
|
352
|
+
process.stdout.write(partitions.length || sessions.length
|
|
353
|
+
? `Reconciled ${result.accepted} changed usage row(s) and ${sessions.length} session identifier(s) from ${sources.join(", ") || "local agents"}${full ? " across retained history" : ""}.\n`
|
|
354
|
+
: `Already up to date. No usage rows changed${full ? " after a full-history reconciliation" : ""}.\n`);
|
|
355
|
+
if (regressions.length) process.stdout.write(`${regressions.length} local row(s) moved backward and were submitted as authoritative corrections.\n`);
|
|
356
|
+
if (explain) process.stdout.write(`Coverage ${result.coverage}; ${sources.length} source(s); ${days[0] || "unknown"} to ${days.at(-1) || "unknown"}; ${partitions.length} atomic partition(s).\n`);
|
|
217
357
|
}
|
|
358
|
+
return result;
|
|
218
359
|
}
|
|
219
360
|
|
|
220
361
|
async function status() {
|
|
@@ -236,18 +377,42 @@ async function status() {
|
|
|
236
377
|
|
|
237
378
|
async function doctor(args = []) {
|
|
238
379
|
const config = await readConfig();
|
|
239
|
-
const
|
|
380
|
+
const env = await ccusageEnvironment();
|
|
381
|
+
const inventory = await sourceInventory({ env, home: ccusageHome(env) });
|
|
382
|
+
const archives = await discoverProviderArchives({ env, home: ccusageHome(env) });
|
|
383
|
+
const homes = String(env.USAGEMAX_DISCOVERED_HOMES || ccusageHome(env)).split(",").filter(Boolean);
|
|
384
|
+
const result = {
|
|
385
|
+
linked: Boolean(config),
|
|
386
|
+
homes,
|
|
387
|
+
detectedSources: inventory.sources,
|
|
388
|
+
files: inventory.files,
|
|
389
|
+
inventoryComplete: inventory.complete,
|
|
390
|
+
inventoryErrors: inventory.errors,
|
|
391
|
+
inventoryTruncated: inventory.truncated,
|
|
392
|
+
supportedSources: SUPPORTED_SOURCES,
|
|
393
|
+
archives: archives.length,
|
|
394
|
+
environment: platform() === "linux" && process.env.WSL_DISTRO_NAME ? `WSL ${process.env.WSL_DISTRO_NAME}` : platform(),
|
|
395
|
+
mode: args.includes("--deep") ? "deep" : "metadata-only",
|
|
396
|
+
};
|
|
397
|
+
if (args.includes("--deep")) {
|
|
398
|
+
const report = await ccusageJson(config, { env, full: true });
|
|
399
|
+
result.parsedSources = sourceSummary(report);
|
|
400
|
+
result.sessions = buildSessionPlan(report, config?.deviceId || "unlinked").length;
|
|
401
|
+
}
|
|
402
|
+
if (args.includes("--json")) {
|
|
403
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
240
406
|
process.stdout.write(`Collector: ${config ? "linked" : "not linked"}\n`);
|
|
407
|
+
process.stdout.write(`Discovered homes: ${homes.length} (${homes.join(", ")})\n`);
|
|
241
408
|
process.stdout.write(`Detected sources: ${inventory.sources.join(", ") || "none"} (${inventory.files}${inventory.truncated ? "+" : ""} data files)\n`);
|
|
242
409
|
process.stdout.write(`Supported sources: ${SUPPORTED_SOURCES.join(", ")} (+ named pi-format stores)\n`);
|
|
243
410
|
if (platform() === "linux" && process.env.WSL_DISTRO_NAME) {
|
|
244
|
-
process.stdout.write(`Environment: WSL ${process.env.WSL_DISTRO_NAME};
|
|
411
|
+
process.stdout.write(`Environment: WSL ${process.env.WSL_DISTRO_NAME}; readable Windows provider homes are included automatically\n`);
|
|
245
412
|
}
|
|
413
|
+
if (archives.length) process.stdout.write(`Recovery: ${archives.length} compressed provider archive(s) detected; run \`bunx usagemax sync --archives\` once to reconcile them\n`);
|
|
246
414
|
if (!inventory.complete) process.stdout.write(`Inventory: incomplete (${inventory.errors} read error(s)${inventory.truncated ? ", file limit reached" : ""}); no-change shortcut disabled\n`);
|
|
247
|
-
if (
|
|
248
|
-
const report = await ccusageJson(config, { full: true });
|
|
249
|
-
process.stdout.write(`Parsed sources: ${sourceSummary(report).join(", ") || "none"}\n`);
|
|
250
|
-
}
|
|
415
|
+
if (result.parsedSources) process.stdout.write(`Parsed sources: ${result.parsedSources.join(", ") || "none"}; ${result.sessions} private session identifiers\n`);
|
|
251
416
|
process.stdout.write(`Mode: one-shot, metadata no-op check, ${args.includes("--deep") ? "deep local parse" : "no log parsing"}\n`);
|
|
252
417
|
}
|
|
253
418
|
|
|
@@ -264,15 +429,28 @@ async function report(args) {
|
|
|
264
429
|
if (code !== 0) process.exitCode = code;
|
|
265
430
|
}
|
|
266
431
|
|
|
267
|
-
async function removeLink() {
|
|
432
|
+
async function removeLink(args = []) {
|
|
268
433
|
const path = configPath();
|
|
269
434
|
const config = await readConfig();
|
|
270
435
|
if (!config) {
|
|
271
436
|
process.stdout.write("This computer is not linked.\n");
|
|
272
437
|
return;
|
|
273
438
|
}
|
|
439
|
+
if (args.includes("--revoke")) {
|
|
440
|
+
const endpoint = validHttpsUrl(config.revokeUrl, { allowLocalhost: true });
|
|
441
|
+
if (!endpoint) throw new Error("This collector does not have a valid revoke endpoint. Revoke it at https://usagemax.com/account.");
|
|
442
|
+
const response = await fetch(endpoint, {
|
|
443
|
+
method: "POST",
|
|
444
|
+
headers: { authorization: `Bearer ${config.token}`, "content-type": "application/json" },
|
|
445
|
+
body: JSON.stringify({ deviceId: config.deviceId }),
|
|
446
|
+
signal: AbortSignal.timeout(15_000),
|
|
447
|
+
});
|
|
448
|
+
if (!response.ok) throw new Error("UsageMax could not revoke this collector. It remains linked locally.");
|
|
449
|
+
}
|
|
274
450
|
await unlink(path);
|
|
275
|
-
process.stdout.write(
|
|
451
|
+
process.stdout.write(args.includes("--revoke")
|
|
452
|
+
? "Revoked this collector and removed its local key. Existing usage totals were retained.\n"
|
|
453
|
+
: "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");
|
|
276
454
|
}
|
|
277
455
|
|
|
278
456
|
async function main() {
|
|
@@ -285,7 +463,7 @@ async function main() {
|
|
|
285
463
|
if (command === "status") return status();
|
|
286
464
|
if (command === "doctor") return doctor(args.slice(1));
|
|
287
465
|
if (command === "report") return report(args.slice(1));
|
|
288
|
-
if (command === "unlink") return removeLink();
|
|
466
|
+
if (command === "unlink") return removeLink(args.slice(1));
|
|
289
467
|
throw new Error(`Unknown command: ${command}. Run usagemax --help.`);
|
|
290
468
|
}
|
|
291
469
|
|
package/src/core.js
CHANGED
|
@@ -43,6 +43,82 @@ function providerFor(model, source) {
|
|
|
43
43
|
return source;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
const snapshotCounterFields = [
|
|
47
|
+
"inputTokens",
|
|
48
|
+
"outputTokens",
|
|
49
|
+
"cacheReadTokens",
|
|
50
|
+
"cacheWriteTokens",
|
|
51
|
+
"reasoningTokens",
|
|
52
|
+
"unclassifiedTokens",
|
|
53
|
+
"totalTokens",
|
|
54
|
+
"costMicros",
|
|
55
|
+
"requests",
|
|
56
|
+
"errors",
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
function snapshotCounters(value = {}) {
|
|
60
|
+
const inputTokens = number(value.inputTokens);
|
|
61
|
+
const outputTokens = number(value.outputTokens);
|
|
62
|
+
const cacheReadTokens = number(value.cacheReadTokens);
|
|
63
|
+
const cacheWriteTokens = number(value.cacheWriteTokens);
|
|
64
|
+
const reasoningTokens = number(value.reasoningTokens);
|
|
65
|
+
const suppliedTotal = number(value.totalTokens);
|
|
66
|
+
const classified = inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens + reasoningTokens;
|
|
67
|
+
const unclassifiedTokens = value.unclassifiedTokens === undefined
|
|
68
|
+
? Math.max(0, suppliedTotal - classified)
|
|
69
|
+
: number(value.unclassifiedTokens);
|
|
70
|
+
return {
|
|
71
|
+
inputTokens,
|
|
72
|
+
outputTokens,
|
|
73
|
+
cacheReadTokens,
|
|
74
|
+
cacheWriteTokens,
|
|
75
|
+
reasoningTokens,
|
|
76
|
+
unclassifiedTokens,
|
|
77
|
+
totalTokens: classified + unclassifiedTokens,
|
|
78
|
+
costMicros: number(value.costMicros, MAX_SAFE_COST_MICROS),
|
|
79
|
+
requests: number(value.requests),
|
|
80
|
+
errors: number(value.errors),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function sameCounters(left, right) {
|
|
85
|
+
return snapshotCounterFields.every((field) => left[field] === right[field]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function snapshotKey(source, period, provider, model) {
|
|
89
|
+
return `${source}\u001f${period}\u001f${provider}\u001f${model}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readSnapshotKey(key) {
|
|
93
|
+
const parts = String(key).split("\u001f");
|
|
94
|
+
if (parts.length === 4) return { source: parts[0], period: parts[1], provider: parts[2], model: parts[3] };
|
|
95
|
+
if (parts.length === 3) return { source: parts[0], period: parts[1], provider: providerFor(parts[2], parts[0]), model: parts[2] };
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function normalizedSnapshotRows(report) {
|
|
100
|
+
const rows = new Map();
|
|
101
|
+
for (const row of currentRows(report)) {
|
|
102
|
+
const provider = providerFor(row.model, row.source);
|
|
103
|
+
const key = snapshotKey(row.source, row.period, provider, row.model);
|
|
104
|
+
const current = snapshotCounters(row.current);
|
|
105
|
+
const existing = rows.get(key);
|
|
106
|
+
if (existing) {
|
|
107
|
+
for (const field of snapshotCounterFields) existing.current[field] += current[field];
|
|
108
|
+
} else {
|
|
109
|
+
rows.set(key, {
|
|
110
|
+
key,
|
|
111
|
+
source: row.source,
|
|
112
|
+
period: row.period,
|
|
113
|
+
provider,
|
|
114
|
+
model: row.model,
|
|
115
|
+
current,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return rows;
|
|
120
|
+
}
|
|
121
|
+
|
|
46
122
|
function sha256(value) {
|
|
47
123
|
return createHash("sha256").update(value).digest("hex");
|
|
48
124
|
}
|
|
@@ -195,6 +271,118 @@ export function buildDeltaPlan(report, priorSnapshots, deviceId, pricingVersion
|
|
|
195
271
|
return { plan, regressions };
|
|
196
272
|
}
|
|
197
273
|
|
|
274
|
+
export function buildSnapshotPlan(report, priorSnapshots, {
|
|
275
|
+
bootstrap = false,
|
|
276
|
+
complete = true,
|
|
277
|
+
full = false,
|
|
278
|
+
pricingVersion = "ccusage",
|
|
279
|
+
revision = Date.now(),
|
|
280
|
+
runId = randomPlanId(),
|
|
281
|
+
} = {}) {
|
|
282
|
+
const rows = normalizedSnapshotRows(report);
|
|
283
|
+
const priorRows = new Map();
|
|
284
|
+
const snapshots = priorSnapshots && typeof priorSnapshots === "object" ? priorSnapshots : {};
|
|
285
|
+
for (const [rawKey, rawValue] of Object.entries(snapshots)) {
|
|
286
|
+
const identity = readSnapshotKey(rawKey);
|
|
287
|
+
if (!identity || !/^\d{4}-\d{2}-\d{2}$/.test(identity.period)) continue;
|
|
288
|
+
const key = snapshotKey(identity.source, identity.period, identity.provider, identity.model);
|
|
289
|
+
priorRows.set(key, {
|
|
290
|
+
key,
|
|
291
|
+
...identity,
|
|
292
|
+
current: snapshotCounters(rawValue),
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const currentPartitions = new Set([...rows.values()].map((row) => `${row.source}\u001f${row.period}`));
|
|
297
|
+
const allKeys = new Set(rows.keys());
|
|
298
|
+
for (const [key, prior] of priorRows) {
|
|
299
|
+
if (full || currentPartitions.has(`${prior.source}\u001f${prior.period}`)) allKeys.add(key);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const grouped = new Map();
|
|
303
|
+
const nextSnapshots = {};
|
|
304
|
+
const regressions = [];
|
|
305
|
+
for (const key of allKeys) {
|
|
306
|
+
const currentRow = rows.get(key);
|
|
307
|
+
const priorRow = priorRows.get(key);
|
|
308
|
+
const identity = currentRow || priorRow;
|
|
309
|
+
if (!identity) continue;
|
|
310
|
+
const previous = priorRow?.current ?? snapshotCounters();
|
|
311
|
+
const current = currentRow?.current ?? snapshotCounters();
|
|
312
|
+
if (snapshotCounterFields.some((field) => current[field] < previous[field])) regressions.push(key);
|
|
313
|
+
if (!sameCounters(current, snapshotCounters())) nextSnapshots[key] = current;
|
|
314
|
+
const partitionKey = `${identity.source}\u001f${identity.period}`;
|
|
315
|
+
const rowsForPartition = grouped.get(partitionKey) ?? [];
|
|
316
|
+
rowsForPartition.push({
|
|
317
|
+
snapshotKey: key,
|
|
318
|
+
provider: identity.provider,
|
|
319
|
+
model: identity.model,
|
|
320
|
+
previous,
|
|
321
|
+
current,
|
|
322
|
+
costBasis: "estimated",
|
|
323
|
+
contentHash: sha256(`${key}\u001f${JSON.stringify(current)}`),
|
|
324
|
+
lastUsedAt: Date.parse(`${identity.period}T12:00:00.000Z`),
|
|
325
|
+
});
|
|
326
|
+
grouped.set(partitionKey, rowsForPartition);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const partitions = [];
|
|
330
|
+
for (const [partitionKey, partitionRows] of grouped) {
|
|
331
|
+
const [source, day] = partitionKey.split("\u001f");
|
|
332
|
+
const changed = partitionRows.some((row) => !sameCounters(row.previous, row.current));
|
|
333
|
+
if (!bootstrap && !full && !changed) continue;
|
|
334
|
+
partitionRows.sort((left, right) => `${left.provider}/${left.model}`.localeCompare(`${right.provider}/${right.model}`));
|
|
335
|
+
const wireRows = partitionRows.map(({ provider, model, previous, current, costBasis, contentHash, lastUsedAt }) => ({
|
|
336
|
+
provider,
|
|
337
|
+
model,
|
|
338
|
+
previous,
|
|
339
|
+
current,
|
|
340
|
+
costBasis,
|
|
341
|
+
contentHash,
|
|
342
|
+
lastUsedAt,
|
|
343
|
+
}));
|
|
344
|
+
const payloadHash = sha256(JSON.stringify({ source, day, complete, pricingVersion, rows: wireRows }));
|
|
345
|
+
partitions.push({
|
|
346
|
+
partitionId: `${runId}:${sha256(partitionKey).slice(0, 24)}`,
|
|
347
|
+
payloadHash,
|
|
348
|
+
revision,
|
|
349
|
+
source,
|
|
350
|
+
day,
|
|
351
|
+
complete,
|
|
352
|
+
pricingVersion,
|
|
353
|
+
rows: wireRows,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
partitions.sort((left, right) => left.day === right.day ? left.source.localeCompare(right.source) : left.day.localeCompare(right.day));
|
|
357
|
+
return { partitions, nextSnapshots, regressions };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function randomPlanId() {
|
|
361
|
+
return `run-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function buildSessionPlan(report, deviceId) {
|
|
365
|
+
const sessionRows = Array.isArray(report?.session) ? report.session : [];
|
|
366
|
+
const sessions = new Map();
|
|
367
|
+
for (const row of sessionRows) {
|
|
368
|
+
if (!row || typeof row !== "object") continue;
|
|
369
|
+
const source = text(row.agent, "unknown", 60).toLowerCase();
|
|
370
|
+
const stableIdentity = text(row.period ?? row.sessionId ?? row.id, "", 1000);
|
|
371
|
+
if (!stableIdentity) continue;
|
|
372
|
+
const metadata = row.metadata && typeof row.metadata === "object" ? row.metadata : {};
|
|
373
|
+
const firstActivityAt = Date.parse(metadata.firstActivity ?? metadata.createdAt ?? "");
|
|
374
|
+
const lastActivityAt = Date.parse(metadata.lastActivity ?? row.lastActivity ?? "");
|
|
375
|
+
const sessionKey = sha256(`${deviceId}\u001f${source}\u001f${stableIdentity}`);
|
|
376
|
+
sessions.set(`${source}\u001f${sessionKey}`, {
|
|
377
|
+
source,
|
|
378
|
+
sessionKey,
|
|
379
|
+
...(Number.isFinite(firstActivityAt) ? { firstActivityAt } : {}),
|
|
380
|
+
...(Number.isFinite(lastActivityAt) ? { lastActivityAt } : {}),
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
return [...sessions.values()].sort((left, right) => `${left.source}/${left.sessionKey}`.localeCompare(`${right.source}/${right.sessionKey}`));
|
|
384
|
+
}
|
|
385
|
+
|
|
198
386
|
export function batchId(deviceId, events) {
|
|
199
387
|
const identity = events.map((event) => event.eventKey).join("\u001f");
|
|
200
388
|
return `cli:${deviceId}:${sha256(identity).slice(0, 32)}`;
|
package/src/sources.js
CHANGED
|
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
|
|
6
6
|
export const CCUSAGE_VERSION = "20.0.20";
|
|
7
|
-
export const SOURCE_INVENTORY_VERSION =
|
|
7
|
+
export const SOURCE_INVENTORY_VERSION = 3;
|
|
8
8
|
export const SUPPORTED_SOURCES = [
|
|
9
9
|
"amp",
|
|
10
10
|
"claude",
|
|
@@ -25,6 +25,10 @@ export const SUPPORTED_SOURCES = [
|
|
|
25
25
|
];
|
|
26
26
|
|
|
27
27
|
const MAX_FINGERPRINT_FILES = 50_000;
|
|
28
|
+
const WINDOWS_SYSTEM_PROFILES = /^(?:all users|default(?: user)?|defaultuser0|public|temp(?:\.|$)|umfd-)/i;
|
|
29
|
+
const BACKUP_DIRECTORY = /(?:claude|codex).*(?:backup|archive|old|copy|mirror)|(?:backup|archive|old|copy|mirror).*(?:claude|codex)|superclaude/i;
|
|
30
|
+
const ARCHIVE_EXTENSION = /(?:\.tar(?:\.gz)?|\.tgz|\.zip|\.7z)$/i;
|
|
31
|
+
const ARCHIVE_FILE = /(?:claude|codex).*(?:\.tar(?:\.gz)?|\.tgz|\.zip|\.7z)$/i;
|
|
28
32
|
|
|
29
33
|
function has(env, name) {
|
|
30
34
|
return Object.prototype.hasOwnProperty.call(env, name);
|
|
@@ -45,6 +49,127 @@ function expandTilde(value, home, pathApi) {
|
|
|
45
49
|
return value;
|
|
46
50
|
}
|
|
47
51
|
|
|
52
|
+
function uniquePaths(values, pathApi) {
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
return values.filter((value) => {
|
|
55
|
+
if (typeof value !== "string" || !value.trim()) return false;
|
|
56
|
+
const normalized = pathApi.normalize(value.trim());
|
|
57
|
+
if (seen.has(normalized)) return false;
|
|
58
|
+
seen.add(normalized);
|
|
59
|
+
return true;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function entries(value) {
|
|
64
|
+
try {
|
|
65
|
+
return await readdir(value, { withFileTypes: true });
|
|
66
|
+
} catch {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function providerBearingHome(home, pathApi) {
|
|
72
|
+
const markers = [".claude", ".codex", ".factory", ".gemini", ".openclaw", ".hermes", ".grok"];
|
|
73
|
+
const checks = await Promise.all(markers.map((marker) => existsDirectory(pathApi.join(home, marker))));
|
|
74
|
+
return checks.some(Boolean);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function discoverWslWindowsHomes(env, platform, pathApi) {
|
|
78
|
+
if (platform !== "linux" || !String(env.WSL_DISTRO_NAME ?? "").trim()) return [];
|
|
79
|
+
const usersRoot = String(env.USAGEMAX_WSL_USERS_DIR ?? "/mnt/c/Users").trim();
|
|
80
|
+
const candidates = [];
|
|
81
|
+
for (const entry of await entries(usersRoot)) {
|
|
82
|
+
if (!entry.isDirectory() || WINDOWS_SYSTEM_PROFILES.test(entry.name)) continue;
|
|
83
|
+
const candidate = pathApi.join(usersRoot, entry.name);
|
|
84
|
+
if (await providerBearingHome(candidate, pathApi)) candidates.push(candidate);
|
|
85
|
+
}
|
|
86
|
+
// A WSL distro can see every Windows profile. Auto-select only when there is
|
|
87
|
+
// one unambiguous provider-bearing profile; multi-user systems opt in with
|
|
88
|
+
// USAGEMAX_ADDITIONAL_HOME so one employee never absorbs another's usage.
|
|
89
|
+
return candidates.length === 1 ? candidates : [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function discoverBackupRoots(home, pathApi) {
|
|
93
|
+
const claude = [];
|
|
94
|
+
const codex = [];
|
|
95
|
+
const containers = [
|
|
96
|
+
{ path: home, requireProviderName: true },
|
|
97
|
+
{ path: pathApi.join(home, ".claude", "backups"), requireProviderName: false },
|
|
98
|
+
{ path: pathApi.join(home, ".codex", "backups"), requireProviderName: false },
|
|
99
|
+
];
|
|
100
|
+
for (const container of containers) {
|
|
101
|
+
for (const entry of await entries(container.path)) {
|
|
102
|
+
if (!entry.isDirectory() || (container.requireProviderName && !BACKUP_DIRECTORY.test(entry.name))) continue;
|
|
103
|
+
const candidate = pathApi.join(container.path, entry.name);
|
|
104
|
+
const claudeRoots = [candidate, pathApi.join(candidate, ".claude"), pathApi.join(candidate, "config")];
|
|
105
|
+
for (const root of claudeRoots) {
|
|
106
|
+
if (await existsDirectory(pathApi.join(root, "projects"))) claude.push(root);
|
|
107
|
+
}
|
|
108
|
+
const codexRoots = [candidate, pathApi.join(candidate, ".codex")];
|
|
109
|
+
for (const root of codexRoots) {
|
|
110
|
+
if (await existsDirectory(pathApi.join(root, "sessions")) || await existsDirectory(pathApi.join(root, "archived_sessions"))) codex.push(root);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { claude, codex };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function discoverNestedClaudeRoots(home, pathApi, maxDirectories = 4_096) {
|
|
118
|
+
const bases = [
|
|
119
|
+
pathApi.join(home, "Library", "Application Support", "Claude", "local-agent-mode-sessions"),
|
|
120
|
+
pathApi.join(home, "AppData", "Roaming", "Claude", "local-agent-mode-sessions"),
|
|
121
|
+
];
|
|
122
|
+
const roots = [];
|
|
123
|
+
for (const base of bases) {
|
|
124
|
+
if (!await existsDirectory(base)) continue;
|
|
125
|
+
const stack = [{ directory: base, depth: 0 }];
|
|
126
|
+
let visited = 0;
|
|
127
|
+
while (stack.length && visited < maxDirectories) {
|
|
128
|
+
const { directory, depth } = stack.pop();
|
|
129
|
+
visited += 1;
|
|
130
|
+
for (const entry of await entries(directory)) {
|
|
131
|
+
if (!entry.isDirectory() || entry.name === "node_modules" || entry.name === ".git") continue;
|
|
132
|
+
const child = pathApi.join(directory, entry.name);
|
|
133
|
+
if (entry.name === ".claude") {
|
|
134
|
+
if (await existsDirectory(pathApi.join(child, "projects"))) roots.push(child);
|
|
135
|
+
} else if (depth < 7) {
|
|
136
|
+
stack.push({ directory: child, depth: depth + 1 });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return roots;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function setDiscoveredList(effective, name, defaults, additions, pathApi) {
|
|
145
|
+
if (has(effective, name) && !String(effective[name] ?? "").trim()) return;
|
|
146
|
+
const configured = has(effective, name) ? commaList(effective[name]) : defaults;
|
|
147
|
+
effective[name] = uniquePaths([...configured, ...additions], pathApi).join(",");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function discoverProviderArchives({ env = process.env, home = ccusageHome(env), pathApi = path } = {}) {
|
|
151
|
+
const homes = uniquePaths([home, ...commaList(env.USAGEMAX_DISCOVERED_HOMES), ...commaList(env.USAGEMAX_ADDITIONAL_HOME)], pathApi);
|
|
152
|
+
const archives = [];
|
|
153
|
+
for (const candidateHome of homes) {
|
|
154
|
+
const roots = [
|
|
155
|
+
{ path: candidateHome, source: null },
|
|
156
|
+
{ path: pathApi.join(candidateHome, ".claude", "backups"), source: "claude" },
|
|
157
|
+
{ path: pathApi.join(candidateHome, ".codex", "backups"), source: "codex" },
|
|
158
|
+
];
|
|
159
|
+
for (const root of roots) {
|
|
160
|
+
for (const entry of await entries(root.path)) {
|
|
161
|
+
if (!entry.isFile() || !ARCHIVE_EXTENSION.test(entry.name) || (!root.source && !ARCHIVE_FILE.test(entry.name))) continue;
|
|
162
|
+
const filePath = pathApi.join(root.path, entry.name);
|
|
163
|
+
try {
|
|
164
|
+
const metadata = await stat(filePath);
|
|
165
|
+
archives.push({ path: filePath, size: metadata.size, source: root.source || (/codex/i.test(entry.name) ? "codex" : "claude") });
|
|
166
|
+
} catch {}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return archives.filter((archive, index) => archives.findIndex((item) => item.path === archive.path) === index);
|
|
171
|
+
}
|
|
172
|
+
|
|
48
173
|
export function ccusageHome(env = process.env, fallback = homedir()) {
|
|
49
174
|
for (const value of [env.HOME, env.USERPROFILE]) {
|
|
50
175
|
if (typeof value === "string" && value.trim()) return value;
|
|
@@ -156,13 +281,52 @@ export function sourceDefinitions({ env = process.env, home = ccusageHome(env),
|
|
|
156
281
|
|
|
157
282
|
export async function ccusageEnvironment({ env = process.env, platform = process.platform, pathApi = path } = {}) {
|
|
158
283
|
const effective = { ...env };
|
|
159
|
-
|
|
160
|
-
|
|
284
|
+
const home = ccusageHome(effective);
|
|
285
|
+
const configuredHomes = commaList(effective.USAGEMAX_ADDITIONAL_HOME).map((item) => expandTilde(item, home, pathApi));
|
|
286
|
+
const wslHomes = await discoverWslWindowsHomes(effective, platform, pathApi);
|
|
287
|
+
const additionalHomes = uniquePaths([...configuredHomes, ...wslHomes], pathApi).filter((item) => pathApi.normalize(item) !== pathApi.normalize(home));
|
|
288
|
+
const homes = [home, ...additionalHomes];
|
|
289
|
+
const backups = await Promise.all(homes.map((candidate) => discoverBackupRoots(candidate, pathApi)));
|
|
290
|
+
const nestedClaude = (await Promise.all(homes.map((candidate) => discoverNestedClaudeRoots(candidate, pathApi)))).flat();
|
|
291
|
+
const mirroredClaude = [];
|
|
292
|
+
for (const candidate of homes) {
|
|
293
|
+
const mirror = pathApi.join(candidate, ".cc-mirror", "mclaude", "config");
|
|
294
|
+
if (await existsDirectory(pathApi.join(mirror, "projects"))) mirroredClaude.push(mirror);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const xdgClaude = pathApi.join(has(effective, "XDG_CONFIG_HOME") ? String(effective.XDG_CONFIG_HOME) : pathApi.join(home, ".config"), "claude");
|
|
298
|
+
setDiscoveredList(effective, "CLAUDE_CONFIG_DIR", [xdgClaude, pathApi.join(home, ".claude")], [
|
|
299
|
+
...additionalHomes.flatMap((candidate) => [pathApi.join(candidate, ".config", "claude"), pathApi.join(candidate, ".claude")]),
|
|
300
|
+
...mirroredClaude,
|
|
301
|
+
...backups.flatMap((item) => item.claude),
|
|
302
|
+
...nestedClaude,
|
|
303
|
+
], pathApi);
|
|
304
|
+
setDiscoveredList(effective, "CODEX_HOME", [pathApi.join(home, ".codex")], [
|
|
305
|
+
...additionalHomes.map((candidate) => pathApi.join(candidate, ".codex")),
|
|
306
|
+
...backups.flatMap((item) => item.codex),
|
|
307
|
+
], pathApi);
|
|
308
|
+
|
|
309
|
+
const additions = (segments) => additionalHomes.map((candidate) => pathApi.join(candidate, ...segments));
|
|
310
|
+
setDiscoveredList(effective, "OPENCODE_DATA_DIR", [pathApi.join(home, ".local", "share", "opencode")], additions([".local", "share", "opencode"]), pathApi);
|
|
311
|
+
setDiscoveredList(effective, "AMP_DATA_DIR", [pathApi.join(home, ".local", "share", "amp")], additions([".local", "share", "amp"]), pathApi);
|
|
312
|
+
setDiscoveredList(effective, "DROID_SESSIONS_DIR", [pathApi.join(home, ".factory", "sessions")], additions([".factory", "sessions"]), pathApi);
|
|
313
|
+
setDiscoveredList(effective, "CODEBUFF_DATA_DIR", ["manicode", "manicode-dev", "manicode-staging"].map((channel) => pathApi.join(home, ".config", channel)), additionalHomes.flatMap((candidate) => ["manicode", "manicode-dev", "manicode-staging"].map((channel) => pathApi.join(candidate, ".config", channel))), pathApi);
|
|
314
|
+
setDiscoveredList(effective, "HERMES_HOME", [pathApi.join(home, ".hermes")], additions([".hermes"]), pathApi);
|
|
315
|
+
setDiscoveredList(effective, "PI_AGENT_DIR", [pathApi.join(home, ".pi", "agent", "sessions")], additions([".pi", "agent", "sessions"]), pathApi);
|
|
316
|
+
setDiscoveredList(effective, "OPENCLAW_DIR", [".openclaw", ".clawdbot", ".moltbot", ".moldbot"].map((name) => pathApi.join(home, name)), additionalHomes.flatMap((candidate) => [".openclaw", ".clawdbot", ".moltbot", ".moldbot"].map((name) => pathApi.join(candidate, name))), pathApi);
|
|
317
|
+
setDiscoveredList(effective, "KILO_DATA_DIR", [pathApi.join(home, ".local", "share", "kilo")], additions([".local", "share", "kilo"]), pathApi);
|
|
318
|
+
setDiscoveredList(effective, "KIMI_DATA_DIR", [pathApi.join(home, ".kimi"), pathApi.join(home, ".kimi-code")], additionalHomes.flatMap((candidate) => [pathApi.join(candidate, ".kimi"), pathApi.join(candidate, ".kimi-code")]), pathApi);
|
|
319
|
+
setDiscoveredList(effective, "QWEN_DATA_DIR", [pathApi.join(home, ".qwen")], additions([".qwen"]), pathApi);
|
|
320
|
+
setDiscoveredList(effective, "GEMINI_DATA_DIR", [pathApi.join(home, ".gemini", "tmp")], additions([".gemini", "tmp"]), pathApi);
|
|
321
|
+
|
|
322
|
+
effective.USAGEMAX_DISCOVERED_HOMES = homes.join(",");
|
|
323
|
+
|
|
324
|
+
if (platform === "win32" && !String(effective.GOOSE_PATH_ROOT ?? "").trim() && String(effective.APPDATA ?? "").trim()) {
|
|
325
|
+
const gooseRoot = pathApi.join(String(effective.APPDATA).trim(), "Block", "goose");
|
|
326
|
+
try {
|
|
327
|
+
if ((await stat(pathApi.join(gooseRoot, "data", "sessions", "sessions.db"))).isFile()) effective.GOOSE_PATH_ROOT = gooseRoot;
|
|
328
|
+
} catch {}
|
|
161
329
|
}
|
|
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
330
|
return effective;
|
|
167
331
|
}
|
|
168
332
|
|