@ipv9/tokentracker-cli 0.39.37 → 0.39.39
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 +41 -9
- package/dashboard/dist/assets/{Card-DKmo-v8a.js → Card-BbRYRVXf.js} +1 -1
- package/dashboard/dist/assets/DashboardPage-DYKHv0m3.js +60 -0
- package/dashboard/dist/assets/{FadeIn-Cf30rPTW.js → FadeIn-CjAacJ5t.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-D3CL8XP0.js → IpCheckPage-BGALK8Vy.js} +1 -1
- package/dashboard/dist/assets/{LimitsPage-BqST7DxD.js → LimitsPage-LkZGPYvg.js} +2 -2
- package/dashboard/dist/assets/{LocalOnlyNotice-CqHzPooT.js → LocalOnlyNotice-Dxo2vFMQ.js} +1 -1
- package/dashboard/dist/assets/PopoverPopup-DP9fwdtl.js +1 -0
- package/dashboard/dist/assets/Select-DPE53NmM.js +1 -0
- package/dashboard/dist/assets/SelectItemText-D69v77is.js +12 -0
- package/dashboard/dist/assets/SettingsPage-Dxzqpi02.js +1 -0
- package/dashboard/dist/assets/SkillsPage-Bo7e39AB.js +1 -0
- package/dashboard/dist/assets/WidgetsPage-HHcIr_Eq.js +1 -0
- package/dashboard/dist/assets/WrappedPage-C-_jLXXU.js +1 -0
- package/dashboard/dist/assets/{arrow-up-right-BJzUcPig.js → arrow-up-right-BJvJHUGj.js} +1 -1
- package/dashboard/dist/assets/{download-CsXweDLj.js → download-CnzY_EgN.js} +1 -1
- package/dashboard/dist/assets/format-1w-q5n2f.js +1 -0
- package/dashboard/dist/assets/main-1kxAVM3m.css +1 -0
- package/dashboard/dist/assets/main-3gBEI6Jl.js +694 -0
- package/dashboard/dist/assets/mock-data-CJOMGcL9.js +1 -0
- package/dashboard/dist/assets/use-limits-display-prefs-D1nT9-mr.js +1 -0
- package/dashboard/dist/assets/use-native-settings-Dh6NzqjY.js +1 -0
- package/dashboard/dist/assets/use-usage-limits-BXR6lzGf.js +1 -0
- package/dashboard/dist/assets/useCurrency-Hhs5jbWw.js +1 -0
- package/dashboard/dist/index.html +16 -28
- package/dashboard/dist/llms.txt +0 -2
- package/dashboard/dist/sitemap.xml +0 -12
- package/package.json +7 -4
- package/src/cli.js +4 -13
- package/src/commands/doctor.js +1 -3
- package/src/commands/init.js +63 -53
- package/src/commands/serve.js +2 -50
- package/src/commands/status.js +1 -55
- package/src/commands/sync.js +134 -227
- package/src/lib/{browser-auth.js → browser-open.js} +3 -149
- package/src/lib/claude-categorizer.js +204 -32
- package/src/lib/codex-context-breakdown.js +83 -7
- package/src/lib/codex-rollout-parser.js +52 -10
- package/src/lib/diagnostics.js +1 -49
- package/src/lib/doctor.js +12 -87
- package/src/lib/file-identity.js +69 -0
- package/src/lib/init-flow.js +6 -3
- package/src/lib/local-api.js +6 -364
- package/src/lib/pricing/curated-overrides.json +1 -1
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/rollout.js +392 -144
- package/src/lib/runtime-config.js +0 -32
- package/src/lib/sqlite-reader.js +83 -8
- package/dashboard/dist/assets/ActivityHeatmap-DPZ_Aj6E.js +0 -42
- package/dashboard/dist/assets/DashboardPage-C9Q5HCDh.js +0 -19
- package/dashboard/dist/assets/DevicePage-DhQFtROo.js +0 -1
- package/dashboard/dist/assets/DialogTitle-DduJCLiL.js +0 -1
- package/dashboard/dist/assets/InsforgeUserHeaderControls-CDqYVWBQ.js +0 -1
- package/dashboard/dist/assets/LandingPage-9345o0vr.js +0 -4356
- package/dashboard/dist/assets/LeaderboardAvatar-DNORVh3n.js +0 -1
- package/dashboard/dist/assets/LeaderboardPage-DBsdCCzY.js +0 -6
- package/dashboard/dist/assets/LeaderboardProfileModal-BuYdBVh_.js +0 -72
- package/dashboard/dist/assets/LeaderboardProfilePage-D4Fe1LOk.js +0 -1
- package/dashboard/dist/assets/LoginPage-BmcybmeK.js +0 -1
- package/dashboard/dist/assets/PopoverPopup-CDL7kqy5.js +0 -1
- package/dashboard/dist/assets/Select-BkzhG41t.js +0 -1
- package/dashboard/dist/assets/SelectItemText-BmRzs9ud.js +0 -1
- package/dashboard/dist/assets/SettingsPage-gXQl_o6s.js +0 -1
- package/dashboard/dist/assets/SkillsPage-BRXYEsKm.js +0 -1
- package/dashboard/dist/assets/WidgetsPage-CLHLz2UW.js +0 -1
- package/dashboard/dist/assets/WrappedPage-p4ZFwQjO.js +0 -1
- package/dashboard/dist/assets/index-BeoRn2gJ.js +0 -2
- package/dashboard/dist/assets/info-CuLr7ZIa.js +0 -1
- package/dashboard/dist/assets/main-CPGI-smk.js +0 -1004
- package/dashboard/dist/assets/main-DAcSUW2t.css +0 -1
- package/dashboard/dist/assets/use-limits-display-prefs-DOB09__q.js +0 -1
- package/dashboard/dist/assets/use-native-settings-JSylS2xS.js +0 -1
- package/dashboard/dist/assets/use-spring-qjUEvMQX.js +0 -1
- package/dashboard/dist/assets/use-usage-limits-B0bzPxNR.js +0 -1
- package/dashboard/dist/assets/useCurrency-BRorShq7.js +0 -1
- package/dashboard/dist/assets/useOpenInteractionType-DPP-6ZsL.js +0 -12
- package/dashboard/dist/share.html +0 -140
- package/src/commands/device-login.js +0 -161
- package/src/lib/upload-throttle.js +0 -148
|
@@ -1,138 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
// Opens a URL in the user's browser. This module used to also run the cloud
|
|
2
|
+
// sign-in flow (a local callback server plus a redirect dance); that went away
|
|
3
|
+
// with the cloud, and the file was renamed so its name stops implying auth.
|
|
3
4
|
const cp = require("node:child_process");
|
|
4
5
|
|
|
5
|
-
const { DEFAULT_BASE_URL } = require("./runtime-config");
|
|
6
|
-
|
|
7
|
-
async function beginBrowserAuth({ baseUrl, dashboardUrl, timeoutMs, open }) {
|
|
8
|
-
const nonce = crypto.randomBytes(16).toString("hex");
|
|
9
|
-
const callbackPath = `/tokentracker/callback/${nonce}`;
|
|
10
|
-
const authUrl = dashboardUrl ? new URL("/", dashboardUrl) : new URL("/auth/sign-up", baseUrl);
|
|
11
|
-
const postAuthRedirect = resolvePostAuthRedirect({ dashboardUrl, authUrl });
|
|
12
|
-
const { callbackUrl, waitForCallback } = await startLocalCallbackServer({
|
|
13
|
-
callbackPath,
|
|
14
|
-
timeoutMs,
|
|
15
|
-
redirectUrl: postAuthRedirect,
|
|
16
|
-
});
|
|
17
|
-
authUrl.searchParams.set("redirect", callbackUrl);
|
|
18
|
-
if (dashboardUrl && baseUrl && baseUrl !== DEFAULT_BASE_URL) {
|
|
19
|
-
authUrl.searchParams.set("base_url", baseUrl);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
if (open !== false) openInBrowser(authUrl.toString());
|
|
23
|
-
|
|
24
|
-
return { authUrl: authUrl.toString(), waitForCallback };
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
async function startLocalCallbackServer({ callbackPath, timeoutMs, redirectUrl }) {
|
|
28
|
-
let resolved = false;
|
|
29
|
-
let resolveResult;
|
|
30
|
-
let rejectResult;
|
|
31
|
-
|
|
32
|
-
const resultPromise = new Promise((resolve, reject) => {
|
|
33
|
-
resolveResult = resolve;
|
|
34
|
-
rejectResult = reject;
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
const server = http.createServer((req, res) => {
|
|
38
|
-
if (resolved) {
|
|
39
|
-
res.writeHead(409, { "Content-Type": "text/plain; charset=utf-8" });
|
|
40
|
-
res.end("Already authenticated.\n");
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const method = req.method || "GET";
|
|
45
|
-
if (method !== "GET") {
|
|
46
|
-
res.writeHead(405, { "Content-Type": "text/plain; charset=utf-8" });
|
|
47
|
-
res.end("Method not allowed.\n");
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
52
|
-
if (url.pathname !== callbackPath) {
|
|
53
|
-
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
54
|
-
res.end("Not found.\n");
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const accessToken = url.searchParams.get("access_token") || "";
|
|
59
|
-
if (!accessToken) {
|
|
60
|
-
res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
|
|
61
|
-
res.end("Missing access_token.\n");
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
resolved = true;
|
|
66
|
-
if (redirectUrl) {
|
|
67
|
-
res.writeHead(302, {
|
|
68
|
-
Location: redirectUrl,
|
|
69
|
-
"Content-Type": "text/html; charset=utf-8",
|
|
70
|
-
});
|
|
71
|
-
res.end(
|
|
72
|
-
[
|
|
73
|
-
"<!doctype html>",
|
|
74
|
-
'<html><head><meta charset="utf-8"><title>TokenTracker</title></head>',
|
|
75
|
-
"<body>",
|
|
76
|
-
"<h2>Login succeeded</h2>",
|
|
77
|
-
`<p>Redirecting to <a href="${redirectUrl}">dashboard</a>...</p>`,
|
|
78
|
-
"</body></html>",
|
|
79
|
-
].join(""),
|
|
80
|
-
);
|
|
81
|
-
} else {
|
|
82
|
-
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
83
|
-
res.end(
|
|
84
|
-
[
|
|
85
|
-
"<!doctype html>",
|
|
86
|
-
'<html><head><meta charset="utf-8"><title>TokenTracker</title></head>',
|
|
87
|
-
"<body>",
|
|
88
|
-
"<h2>Login succeeded</h2>",
|
|
89
|
-
"<p>You can close this tab and return to the CLI.</p>",
|
|
90
|
-
"</body></html>",
|
|
91
|
-
].join(""),
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
resolveResult({
|
|
96
|
-
accessToken,
|
|
97
|
-
userId: url.searchParams.get("user_id") || null,
|
|
98
|
-
email: url.searchParams.get("email") || null,
|
|
99
|
-
name: url.searchParams.get("name") || null,
|
|
100
|
-
});
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
await new Promise((resolve, reject) => {
|
|
104
|
-
server.once("error", reject);
|
|
105
|
-
server.listen(0, "127.0.0.1", resolve);
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
const addr = server.address();
|
|
109
|
-
const port = typeof addr === "object" && addr ? addr.port : null;
|
|
110
|
-
if (!port) {
|
|
111
|
-
server.close();
|
|
112
|
-
throw new Error("Failed to bind local callback server");
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const callbackUrl = `http://127.0.0.1:${port}${callbackPath}`;
|
|
116
|
-
|
|
117
|
-
const timer = setTimeout(() => {
|
|
118
|
-
if (resolved) return;
|
|
119
|
-
resolved = true;
|
|
120
|
-
rejectResult(new Error("Authentication timed out"));
|
|
121
|
-
server.close();
|
|
122
|
-
}, timeoutMs);
|
|
123
|
-
|
|
124
|
-
async function waitForCallback() {
|
|
125
|
-
try {
|
|
126
|
-
return await resultPromise;
|
|
127
|
-
} finally {
|
|
128
|
-
clearTimeout(timer);
|
|
129
|
-
server.close();
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
return { callbackUrl, waitForCallback };
|
|
134
|
-
}
|
|
135
|
-
|
|
136
6
|
function detectDefaultBrowser() {
|
|
137
7
|
try {
|
|
138
8
|
const raw = cp.execFileSync("defaults", [
|
|
@@ -285,23 +155,7 @@ end if
|
|
|
285
155
|
spawnBrowserCommand(cmd, args, { url, platform, stderr });
|
|
286
156
|
}
|
|
287
157
|
|
|
288
|
-
function resolvePostAuthRedirect({ dashboardUrl, authUrl }) {
|
|
289
|
-
try {
|
|
290
|
-
if (dashboardUrl) {
|
|
291
|
-
const target = new URL("/", dashboardUrl);
|
|
292
|
-
if (target.protocol === "http:" || target.protocol === "https:") {
|
|
293
|
-
return target.toString();
|
|
294
|
-
}
|
|
295
|
-
return null;
|
|
296
|
-
}
|
|
297
|
-
} catch (_e) {
|
|
298
|
-
return null;
|
|
299
|
-
}
|
|
300
|
-
return null;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
158
|
module.exports = {
|
|
304
|
-
beginBrowserAuth,
|
|
305
159
|
openInBrowser,
|
|
306
160
|
buildBrowserOpenErrorMessage,
|
|
307
161
|
spawnBrowserCommand,
|
|
@@ -28,6 +28,7 @@ const {
|
|
|
28
28
|
allocateByLargestRemainder,
|
|
29
29
|
} = require("./categorizer-utils");
|
|
30
30
|
const { claudeMessageDedupKey } = require("./rollout");
|
|
31
|
+
const { fileIdentity, isUnchanged } = require("./file-identity");
|
|
31
32
|
|
|
32
33
|
const CATEGORY_KEYS = [
|
|
33
34
|
"system_prefix",
|
|
@@ -494,12 +495,40 @@ function classifyOneMessage(obj, sessionState, breakdown, toolLedger = null, ski
|
|
|
494
495
|
}
|
|
495
496
|
|
|
496
497
|
// Read one session jsonl streaming, in timestamp range, dedup by msgId+reqId.
|
|
497
|
-
|
|
498
|
+
// Parses one file into its OWN accumulators and returns them, rather than
|
|
499
|
+
// mutating shared ones. That is what makes a per-file cache possible: the
|
|
500
|
+
// result depends only on (file contents, fromIso, toIso), so it can be reused
|
|
501
|
+
// whenever the file has not changed.
|
|
502
|
+
//
|
|
503
|
+
// The dedup set is now file-local. Cross-file duplicates are handled by the
|
|
504
|
+
// caller, which detects them and falls back to a full scan — see
|
|
505
|
+
// mergeFileResult. Intra-file dedup, including the deferred-add behavior
|
|
506
|
+
// below, is unchanged and must stay that way.
|
|
507
|
+
// `sharedSeen` is used only by the collision fallback, which needs the old
|
|
508
|
+
// cross-file dedup semantics. When it is passed, the returned `hashes` list is
|
|
509
|
+
// empty because dedup has already been applied globally.
|
|
510
|
+
async function categorizeSessionFile(filePath, { fromIso, toIso, sharedSeen = null }) {
|
|
511
|
+
const breakdown = emptyCategoryMap();
|
|
512
|
+
const toolLedger = {
|
|
513
|
+
tool_calls: { total_calls: 0, by_name: new Map() },
|
|
514
|
+
subagents: { total_calls: 0, by_name: new Map() },
|
|
515
|
+
};
|
|
516
|
+
const skillLedger = { total_calls: 0, by_name: new Map() };
|
|
517
|
+
const execLedger = {
|
|
518
|
+
total_calls: 0,
|
|
519
|
+
by_type: new Map(),
|
|
520
|
+
by_executable: new Map(),
|
|
521
|
+
by_command: new Map(),
|
|
522
|
+
by_exit: new Map(),
|
|
523
|
+
};
|
|
524
|
+
const seenHashes = sharedSeen || new Set();
|
|
525
|
+
const empty = () => ({ breakdown, toolLedger, skillLedger, execLedger, counted: 0, hashes: [] });
|
|
526
|
+
|
|
498
527
|
let stream;
|
|
499
528
|
try {
|
|
500
529
|
stream = fssync.createReadStream(filePath, { encoding: "utf8" });
|
|
501
530
|
} catch (_e) {
|
|
502
|
-
return
|
|
531
|
+
return empty();
|
|
503
532
|
}
|
|
504
533
|
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
505
534
|
const sessionState = { systemPrefixSeen: false };
|
|
@@ -538,7 +567,14 @@ async function categorizeSessionFile(filePath, { fromIso, toIso, seenHashes }, b
|
|
|
538
567
|
}
|
|
539
568
|
rl.close();
|
|
540
569
|
stream.close?.();
|
|
541
|
-
return
|
|
570
|
+
return {
|
|
571
|
+
breakdown,
|
|
572
|
+
toolLedger,
|
|
573
|
+
skillLedger,
|
|
574
|
+
execLedger,
|
|
575
|
+
counted,
|
|
576
|
+
hashes: sharedSeen ? [] : [...seenHashes],
|
|
577
|
+
};
|
|
542
578
|
}
|
|
543
579
|
|
|
544
580
|
// Convert a YYYY-MM-DD day key (already in the user's tz from the API call)
|
|
@@ -624,6 +660,99 @@ function maxMtimeMs(files) {
|
|
|
624
660
|
return max;
|
|
625
661
|
}
|
|
626
662
|
|
|
663
|
+
// Per-file parse cache. See the equivalent in codex-context-breakdown.js —
|
|
664
|
+
// same problem (#62), same two-layer shape: the aggregate cache above stays
|
|
665
|
+
// the fast path, and this only decides how expensive a MISS is.
|
|
666
|
+
const FILE_CACHE = new Map();
|
|
667
|
+
const FILE_CACHE_SCHEMA_VERSION = "claude-category-file-v1";
|
|
668
|
+
const FILE_CACHE_MAX_ENTRIES = 8_000;
|
|
669
|
+
|
|
670
|
+
// Instrumentation is opt-in: without it a long-running server would grow one
|
|
671
|
+
// Map entry per session file forever, purely to serve tests.
|
|
672
|
+
let trackParses = false;
|
|
673
|
+
const parseCounts = new Map();
|
|
674
|
+
|
|
675
|
+
function fileCacheKey(fromIso, toIso, filePath) {
|
|
676
|
+
return `${FILE_CACHE_SCHEMA_VERSION}|${fromIso || ""}|${toIso || ""}|${filePath}`;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function pruneFileCache() {
|
|
680
|
+
if (FILE_CACHE.size <= FILE_CACHE_MAX_ENTRIES) return;
|
|
681
|
+
let excess = FILE_CACHE.size - FILE_CACHE_MAX_ENTRIES;
|
|
682
|
+
for (const key of FILE_CACHE.keys()) {
|
|
683
|
+
FILE_CACHE.delete(key);
|
|
684
|
+
if (--excess <= 0) break;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// These fields are high-water marks, not running sums. Adding them would
|
|
689
|
+
// silently inflate the reported worst case.
|
|
690
|
+
const MAX_MERGE_FIELDS = new Set(["max_duration_ms"]);
|
|
691
|
+
|
|
692
|
+
// Folds one cached row into an accumulator map. Always builds a fresh target
|
|
693
|
+
// object on first insert: putting a cached row in by reference would let a
|
|
694
|
+
// later merge mutate the cache in place, which is the aliasing hazard that
|
|
695
|
+
// per-file caching introduces.
|
|
696
|
+
function mergeRowInto(targetMap, key, row) {
|
|
697
|
+
const existing = targetMap.get(key);
|
|
698
|
+
if (!existing) {
|
|
699
|
+
targetMap.set(key, { ...row, totals: { ...row.totals } });
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
for (const [field, value] of Object.entries(row)) {
|
|
703
|
+
if (field === "totals") {
|
|
704
|
+
addInto(existing.totals, value);
|
|
705
|
+
} else if (typeof value === "number") {
|
|
706
|
+
existing[field] = MAX_MERGE_FIELDS.has(field)
|
|
707
|
+
? Math.max(existing[field] || 0, value)
|
|
708
|
+
: (existing[field] || 0) + value;
|
|
709
|
+
} else if (existing[field] === undefined) {
|
|
710
|
+
existing[field] = value;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function mergeNamedLedger(target, source) {
|
|
716
|
+
target.total_calls += source.total_calls || 0;
|
|
717
|
+
for (const [name, row] of source.by_name) mergeRowInto(target.by_name, name, row);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Returns false when this file shares a dedup hash with one already merged.
|
|
721
|
+
// Such a collision cannot be repaired from aggregates: the deduped message's
|
|
722
|
+
// tokens are missing, and worse, systemPrefixSeen may have latched onto a
|
|
723
|
+
// different message, invalidating the whole per-file result rather than one
|
|
724
|
+
// row. The caller re-runs a full sequential scan instead of patching.
|
|
725
|
+
function mergeFileResult(acc, result, globalHashes) {
|
|
726
|
+
for (const hash of result.hashes) {
|
|
727
|
+
if (globalHashes.has(hash)) return false;
|
|
728
|
+
globalHashes.add(hash);
|
|
729
|
+
}
|
|
730
|
+
for (const key of CATEGORY_KEYS) addInto(acc.breakdown[key], result.breakdown[key]);
|
|
731
|
+
mergeNamedLedger(acc.toolLedger.tool_calls, result.toolLedger.tool_calls);
|
|
732
|
+
mergeNamedLedger(acc.toolLedger.subagents, result.toolLedger.subagents);
|
|
733
|
+
mergeNamedLedger(acc.skillLedger, result.skillLedger);
|
|
734
|
+
acc.execLedger.total_calls += result.execLedger.total_calls || 0;
|
|
735
|
+
for (const bucket of ["by_type", "by_executable", "by_command", "by_exit"]) {
|
|
736
|
+
for (const [name, row] of result.execLedger[bucket]) {
|
|
737
|
+
mergeRowInto(acc.execLedger[bucket], name, row);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
acc.messageCount += result.counted;
|
|
741
|
+
if (result.counted > 0) acc.sessionCount += 1;
|
|
742
|
+
return true;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function __resetCategoryCachesForTests() {
|
|
746
|
+
trackParses = true;
|
|
747
|
+
CACHE.clear();
|
|
748
|
+
FILE_CACHE.clear();
|
|
749
|
+
parseCounts.clear();
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function __getCategoryParseCountsForTests() {
|
|
753
|
+
return new Map(parseCounts);
|
|
754
|
+
}
|
|
755
|
+
|
|
627
756
|
async function computeClaudeCategoryBreakdown({ from = null, to = null, rootDir = null, projectDir = null } = {}) {
|
|
628
757
|
const root = rootDir || defaultClaudeProjectsDir();
|
|
629
758
|
let files = [];
|
|
@@ -656,49 +785,90 @@ async function computeClaudeCategoryBreakdown({ from = null, to = null, rootDir
|
|
|
656
785
|
}
|
|
657
786
|
|
|
658
787
|
const { fromIso, toIso } = dayKeyToIsoBounds(from, to);
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
788
|
+
function newAccumulator() {
|
|
789
|
+
return {
|
|
790
|
+
breakdown: emptyCategoryMap(),
|
|
791
|
+
toolLedger: {
|
|
792
|
+
tool_calls: { total_calls: 0, by_name: new Map() },
|
|
793
|
+
subagents: { total_calls: 0, by_name: new Map() },
|
|
794
|
+
},
|
|
795
|
+
skillLedger: { total_calls: 0, by_name: new Map() },
|
|
796
|
+
execLedger: {
|
|
797
|
+
total_calls: 0,
|
|
798
|
+
by_type: new Map(),
|
|
799
|
+
by_executable: new Map(),
|
|
800
|
+
by_command: new Map(),
|
|
801
|
+
by_exit: new Map(),
|
|
802
|
+
},
|
|
803
|
+
messageCount: 0,
|
|
804
|
+
sessionCount: 0,
|
|
805
|
+
};
|
|
806
|
+
}
|
|
675
807
|
|
|
676
808
|
// Process files with bounded parallelism. CPU-bound (JSON.parse per line)
|
|
677
809
|
// limits the win, but overlapping the per-file fs.open + first-block read
|
|
678
810
|
// I/O behind the previous file's parsing still shaves ~15% off cold scans.
|
|
679
|
-
//
|
|
680
|
-
//
|
|
681
|
-
// only yields between lines, so workers can't tear shared state.
|
|
811
|
+
// Each parse now owns its accumulators, so there is no shared state to tear
|
|
812
|
+
// — and an unchanged file skips the parse entirely (#62).
|
|
682
813
|
const SCAN_CONCURRENCY = 4;
|
|
814
|
+
const results = new Array(files.length);
|
|
683
815
|
let cursor = 0;
|
|
684
816
|
async function worker() {
|
|
685
817
|
while (cursor < files.length) {
|
|
686
818
|
const idx = cursor++;
|
|
687
819
|
if (idx >= files.length) return;
|
|
688
|
-
const
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
if (
|
|
697
|
-
|
|
820
|
+
const filePath = files[idx];
|
|
821
|
+
const key = fileCacheKey(fromIso, toIso, filePath);
|
|
822
|
+
const identity = fileIdentity(filePath);
|
|
823
|
+
const hit = FILE_CACHE.get(key);
|
|
824
|
+
if (hit && isUnchanged(hit.identity, identity)) {
|
|
825
|
+
results[idx] = hit.result;
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
if (trackParses) parseCounts.set(filePath, (parseCounts.get(filePath) || 0) + 1);
|
|
829
|
+
const result = await categorizeSessionFile(filePath, { fromIso, toIso });
|
|
830
|
+
results[idx] = result;
|
|
831
|
+
if (identity) {
|
|
832
|
+
FILE_CACHE.set(key, { identity, result });
|
|
833
|
+
pruneFileCache();
|
|
834
|
+
}
|
|
698
835
|
}
|
|
699
836
|
}
|
|
700
837
|
await Promise.all(Array.from({ length: SCAN_CONCURRENCY }, () => worker()));
|
|
701
838
|
|
|
839
|
+
// Merge in `files` order rather than completion order, so the result no
|
|
840
|
+
// longer depends on how the four workers happened to interleave.
|
|
841
|
+
let acc = newAccumulator();
|
|
842
|
+
const globalHashes = new Set();
|
|
843
|
+
let composable = true;
|
|
844
|
+
for (const result of results) {
|
|
845
|
+
if (!result) continue;
|
|
846
|
+
if (!mergeFileResult(acc, result, globalHashes)) {
|
|
847
|
+
composable = false;
|
|
848
|
+
break;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
if (!composable) {
|
|
853
|
+
// Two files claimed the same dedup hash, so per-file results overlap and
|
|
854
|
+
// cannot be summed. Redo the scan sequentially with one shared dedup set,
|
|
855
|
+
// which is exactly the pre-cache behavior. Expected never to happen: a
|
|
856
|
+
// scan of 6,529 real session files found 0 cross-file duplicates. Kept so
|
|
857
|
+
// correctness does not rest on that measurement.
|
|
858
|
+
acc = newAccumulator();
|
|
859
|
+
const sharedSeen = new Set();
|
|
860
|
+
const fallbackHashes = new Set();
|
|
861
|
+
for (const filePath of files) {
|
|
862
|
+
const result = await categorizeSessionFile(filePath, { fromIso, toIso, sharedSeen });
|
|
863
|
+
if (trackParses) parseCounts.set(filePath, (parseCounts.get(filePath) || 0) + 1);
|
|
864
|
+
mergeFileResult(acc, result, fallbackHashes);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const { breakdown, toolLedger, skillLedger, execLedger } = acc;
|
|
869
|
+
const messageCount = acc.messageCount;
|
|
870
|
+
const sessionCount = acc.sessionCount;
|
|
871
|
+
|
|
702
872
|
const totals = emptyTotals();
|
|
703
873
|
for (const key of CATEGORY_KEYS) addInto(totals, breakdown[key]);
|
|
704
874
|
|
|
@@ -1306,6 +1476,8 @@ async function computeClaudeGroundTruthBuckets({ rootDir = null } = {}) {
|
|
|
1306
1476
|
}
|
|
1307
1477
|
|
|
1308
1478
|
module.exports = {
|
|
1479
|
+
__resetCategoryCachesForTests,
|
|
1480
|
+
__getCategoryParseCountsForTests,
|
|
1309
1481
|
CATEGORY_KEYS,
|
|
1310
1482
|
computeClaudeCategoryBreakdown,
|
|
1311
1483
|
computeClaudeGroundTruthBuckets,
|
|
@@ -14,6 +14,8 @@ const fs = require("node:fs");
|
|
|
14
14
|
const os = require("node:os");
|
|
15
15
|
const path = require("node:path");
|
|
16
16
|
|
|
17
|
+
const { fileIdentity, isUnchanged } = require("./file-identity");
|
|
18
|
+
|
|
17
19
|
const {
|
|
18
20
|
emptyTotals,
|
|
19
21
|
addInto,
|
|
@@ -139,6 +141,59 @@ function cacheTimeZoneKey(timeZoneContext) {
|
|
|
139
141
|
return `${timeZoneContext.timeZone || ""}|${Number.isFinite(timeZoneContext.offsetMinutes) ? timeZoneContext.offsetMinutes : ""}`;
|
|
140
142
|
}
|
|
141
143
|
|
|
144
|
+
// Per-file parse cache.
|
|
145
|
+
//
|
|
146
|
+
// The aggregate cache above keys on the GLOBAL maximum mtime, so appending to
|
|
147
|
+
// the one session that is currently active invalidates the aggregate for every
|
|
148
|
+
// unchanged historical file and forces a full rescan (issue #62). This layer
|
|
149
|
+
// makes that miss cost one file instead of all of them.
|
|
150
|
+
//
|
|
151
|
+
// `top` is deliberately absent from the key: truncation to the top N happens
|
|
152
|
+
// at merge time, so a single cached per-file parse serves every `top` value.
|
|
153
|
+
const FILE_CACHE = new Map();
|
|
154
|
+
const FILE_CACHE_SCHEMA_VERSION = "codex-context-file-v1";
|
|
155
|
+
// Roughly one entry per session file per (range, timezone) combination the
|
|
156
|
+
// dashboard asks for. Entries are small — one parsed rollup, no line data.
|
|
157
|
+
const FILE_CACHE_MAX_ENTRIES = 8_000;
|
|
158
|
+
|
|
159
|
+
// Counts parses per file so tests can assert an unchanged file was NOT
|
|
160
|
+
// reparsed. Observing the actual claim beats spying on the filesystem, which
|
|
161
|
+
// would also catch the identity check's own 256-byte read.
|
|
162
|
+
let trackParses = false;
|
|
163
|
+
const parseCounts = new Map();
|
|
164
|
+
|
|
165
|
+
function fileCacheKey({ fromKey, toKey, timeZoneContext, filePath }) {
|
|
166
|
+
return [
|
|
167
|
+
FILE_CACHE_SCHEMA_VERSION,
|
|
168
|
+
fromKey || "",
|
|
169
|
+
toKey || "",
|
|
170
|
+
cacheTimeZoneKey(timeZoneContext),
|
|
171
|
+
filePath,
|
|
172
|
+
].join("|");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function pruneFileCache() {
|
|
176
|
+
if (FILE_CACHE.size <= FILE_CACHE_MAX_ENTRIES) return;
|
|
177
|
+
const excess = FILE_CACHE.size - FILE_CACHE_MAX_ENTRIES;
|
|
178
|
+
let removed = 0;
|
|
179
|
+
// Map iterates in insertion order, so this drops the least recently stored.
|
|
180
|
+
for (const key of FILE_CACHE.keys()) {
|
|
181
|
+
FILE_CACHE.delete(key);
|
|
182
|
+
if (++removed >= excess) break;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function __resetContextCachesForTests() {
|
|
187
|
+
trackParses = true;
|
|
188
|
+
CACHE.clear();
|
|
189
|
+
FILE_CACHE.clear();
|
|
190
|
+
parseCounts.clear();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function __getParseCountsForTests() {
|
|
194
|
+
return new Map(parseCounts);
|
|
195
|
+
}
|
|
196
|
+
|
|
142
197
|
// ---------------------------------------------------------------------------
|
|
143
198
|
// Main entry
|
|
144
199
|
// ---------------------------------------------------------------------------
|
|
@@ -182,13 +237,32 @@ async function computeCodexContextBreakdown({
|
|
|
182
237
|
const sessions = [];
|
|
183
238
|
|
|
184
239
|
for (const filePath of files) {
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
240
|
+
const key = fileCacheKey({ fromKey, toKey, timeZoneContext, filePath });
|
|
241
|
+
const identity = fileIdentity(filePath);
|
|
242
|
+
const hit = FILE_CACHE.get(key);
|
|
243
|
+
|
|
244
|
+
let parsed;
|
|
245
|
+
if (hit && isUnchanged(hit.identity, identity)) {
|
|
246
|
+
parsed = hit.parsed;
|
|
247
|
+
} else {
|
|
248
|
+
if (trackParses) parseCounts.set(filePath, (parseCounts.get(filePath) || 0) + 1);
|
|
249
|
+
parsed = await parseCodexRolloutFile(filePath, {
|
|
250
|
+
fromIso,
|
|
251
|
+
toIso,
|
|
252
|
+
from: fromKey,
|
|
253
|
+
to: toKey,
|
|
254
|
+
timeZoneContext,
|
|
255
|
+
});
|
|
256
|
+
// A null identity means the path is not a readable regular file right
|
|
257
|
+
// now; caching against it could never be invalidated correctly.
|
|
258
|
+
if (identity) {
|
|
259
|
+
FILE_CACHE.set(key, { identity, parsed });
|
|
260
|
+
pruneFileCache();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Re-applied on every pass rather than baked into the cached entry, so a
|
|
265
|
+
// zero-token file has one representation instead of two.
|
|
192
266
|
if (!parsed || !parsed.totals || !parsed.totals.total_tokens) continue;
|
|
193
267
|
sessions.push(parsed);
|
|
194
268
|
}
|
|
@@ -397,4 +471,6 @@ async function computeCodexContextBreakdown({
|
|
|
397
471
|
|
|
398
472
|
module.exports = {
|
|
399
473
|
computeCodexContextBreakdown,
|
|
474
|
+
__resetContextCachesForTests,
|
|
475
|
+
__getParseCountsForTests,
|
|
400
476
|
};
|
|
@@ -349,6 +349,15 @@ async function parseCodexRolloutFile(filePath, { fromIso, toIso, from = null, to
|
|
|
349
349
|
let pendingSkills = [];
|
|
350
350
|
let pendingExecEnds = []; // exec_command_end payloads since last token_count
|
|
351
351
|
|
|
352
|
+
// Codex subagent-fork replay handling (issue #75). A rollout spawned via
|
|
353
|
+
// session_meta.source.subagent.thread_spawn replays the parent thread's
|
|
354
|
+
// history before an "inter_agent_communication_metadata" boundary. While in
|
|
355
|
+
// replay we advance the delta baseline but do not attribute anything, so the
|
|
356
|
+
// context breakdown reflects only the genuine child turns. Whole-file read,
|
|
357
|
+
// so no cross-call cursor is needed — a boundary-less (partial) fork simply
|
|
358
|
+
// stays in replay and contributes nothing.
|
|
359
|
+
let accounting = true;
|
|
360
|
+
|
|
352
361
|
const totals = emptyTotals();
|
|
353
362
|
const byTool = new Map(); // tool_name -> {name,calls,totals}
|
|
354
363
|
const bySkill = new Map(); // skill_name -> {name,calls,totals}
|
|
@@ -525,20 +534,36 @@ async function parseCodexRolloutFile(filePath, { fromIso, toIso, from = null, to
|
|
|
525
534
|
} catch {
|
|
526
535
|
continue;
|
|
527
536
|
}
|
|
528
|
-
|
|
529
|
-
if
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
537
|
+
// Stateful fork-control records are handled BEFORE any timestamp/date
|
|
538
|
+
// filtering. They must never be dropped by a query window: if a date filter
|
|
539
|
+
// removed the boundary while keeping replay records on one side of it, the
|
|
540
|
+
// replayed parent history would leak back into the child's totals (the exact
|
|
541
|
+
// #75 overcount). This mirrors the pre-gate ordering in rollout.js.
|
|
534
542
|
if (obj.type === "session_meta") {
|
|
535
543
|
const p = obj.payload || {};
|
|
536
544
|
sessionId = p.id || sessionId;
|
|
537
545
|
cwd = p.cwd || cwd;
|
|
538
546
|
cliVersion = p.cli_version || cliVersion;
|
|
539
547
|
provider = p.model_provider || provider;
|
|
548
|
+
if (p.source?.subagent?.thread_spawn) accounting = false;
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Boundary between replayed parent history and genuine child turns.
|
|
553
|
+
if (obj.type === "inter_agent_communication_metadata") {
|
|
554
|
+
accounting = true;
|
|
555
|
+
pendingCalls = [];
|
|
556
|
+
pendingSkills = [];
|
|
557
|
+
pendingExecEnds = [];
|
|
558
|
+
continue;
|
|
540
559
|
}
|
|
541
560
|
|
|
561
|
+
const ts = typeof obj?.timestamp === "string" ? obj.timestamp : null;
|
|
562
|
+
if (!ts) continue;
|
|
563
|
+
if (fromIso && ts < fromIso) continue;
|
|
564
|
+
if (toIso && ts > toIso) continue;
|
|
565
|
+
if (!isTimestampInRequestedDayRange(ts, { from, to, timeZoneContext })) continue;
|
|
566
|
+
|
|
542
567
|
if (obj.type === "turn_context") {
|
|
543
568
|
const p = obj.payload || {};
|
|
544
569
|
if (typeof p.cwd === "string") cwd = p.cwd;
|
|
@@ -547,14 +572,17 @@ async function parseCodexRolloutFile(filePath, { fromIso, toIso, from = null, to
|
|
|
547
572
|
}
|
|
548
573
|
|
|
549
574
|
if (obj.type === "response_item" && obj.payload?.type === "function_call") {
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
575
|
+
// Skip replayed parent tool calls; only child turns are attributed.
|
|
576
|
+
if (accounting) {
|
|
577
|
+
pendingCalls.push(obj.payload);
|
|
578
|
+
const skill = extractSkillNameFromFunctionCall(obj.payload);
|
|
579
|
+
if (skill) pendingSkills.push(skill);
|
|
580
|
+
}
|
|
553
581
|
continue;
|
|
554
582
|
}
|
|
555
583
|
|
|
556
584
|
if (obj.type === "event_msg" && obj.payload?.type === "exec_command_end") {
|
|
557
|
-
pendingExecEnds.push(obj.payload);
|
|
585
|
+
if (accounting) pendingExecEnds.push(obj.payload);
|
|
558
586
|
continue;
|
|
559
587
|
}
|
|
560
588
|
|
|
@@ -564,7 +592,11 @@ async function parseCodexRolloutFile(filePath, { fromIso, toIso, from = null, to
|
|
|
564
592
|
const lastUsage = info?.last_token_usage;
|
|
565
593
|
const totalUsage = info?.total_token_usage;
|
|
566
594
|
const delta = pickDelta(lastUsage, totalUsage, prevTotals);
|
|
595
|
+
// Advance the delta baseline through replay so the first child delta is
|
|
596
|
+
// measured from the last replayed cumulative, but do not attribute the
|
|
597
|
+
// replayed parent history to this child.
|
|
567
598
|
if (totalUsage && typeof totalUsage === "object") prevTotals = totalUsage;
|
|
599
|
+
if (!accounting) continue;
|
|
568
600
|
if (delta) attributeTurn(delta);
|
|
569
601
|
continue;
|
|
570
602
|
}
|
|
@@ -573,6 +605,16 @@ async function parseCodexRolloutFile(filePath, { fromIso, toIso, from = null, to
|
|
|
573
605
|
rl.close();
|
|
574
606
|
stream.close?.();
|
|
575
607
|
|
|
608
|
+
// Saw a subagent fork but never its boundary: the whole file was treated as
|
|
609
|
+
// replay and contributes nothing. Safe (never over-count) but silent —
|
|
610
|
+
// surface it under debug so a malformed/renamed boundary is diagnosable.
|
|
611
|
+
if (!accounting && process.env.TOKENTRACKER_DEBUG) {
|
|
612
|
+
process.stderr.write(
|
|
613
|
+
`[codex-fork] ${filePath}: subagent fork with no ` +
|
|
614
|
+
`inter_agent_communication_metadata boundary; context breakdown empty\n`,
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
|
|
576
618
|
return {
|
|
577
619
|
sessionId,
|
|
578
620
|
cwd,
|