@ipv9/tokentracker-cli 0.39.43 → 0.39.45

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.
Files changed (43) hide show
  1. package/README.md +18 -10
  2. package/dashboard/dist/assets/{Card-LPizs_gs.js → Card-2-TQg7P7.js} +1 -1
  3. package/dashboard/dist/assets/DashboardPage-yb9ss9uE.js +60 -0
  4. package/dashboard/dist/assets/{FadeIn-B8aDegoD.js → FadeIn-BOc6XtOK.js} +1 -1
  5. package/dashboard/dist/assets/{IpCheckPage-Vo2ZZXov.js → IpCheckPage-C2tIb68P.js} +1 -1
  6. package/dashboard/dist/assets/{LimitsPage-C5-Q9Q30.js → LimitsPage-BsuLQ9co.js} +1 -1
  7. package/dashboard/dist/assets/LocalOnlyNotice-C8R9KLef.js +1 -0
  8. package/dashboard/dist/assets/{PopoverPopup-CJf61ahu.js → PopoverPopup-BpfseoI7.js} +1 -1
  9. package/dashboard/dist/assets/{Select-BLGoaqgw.js → Select-Ctk8zze5.js} +1 -1
  10. package/dashboard/dist/assets/{SelectItemText-Bt02Fgwf.js → SelectItemText-BKZlFyFs.js} +1 -1
  11. package/dashboard/dist/assets/{SettingsPage-BnUJew-8.js → SettingsPage-CQXM8qGU.js} +1 -1
  12. package/dashboard/dist/assets/{SkillsPage-ImHg3Puy.js → SkillsPage-4EMm0eBX.js} +1 -1
  13. package/dashboard/dist/assets/{WidgetsPage-qscVE2nO.js → WidgetsPage-C2sdX5g6.js} +1 -1
  14. package/dashboard/dist/assets/{WrappedPage-qM_7aClE.js → WrappedPage-CLiuEcQZ.js} +1 -1
  15. package/dashboard/dist/assets/{arrow-up-right-CByq3BPT.js → arrow-up-right-BDkp93DX.js} +1 -1
  16. package/dashboard/dist/assets/{download-CTwO-YeA.js → download-DZ6SoCSn.js} +1 -1
  17. package/dashboard/dist/assets/{format-4chvNBjF.js → format-CaW9kvsA.js} +1 -1
  18. package/dashboard/dist/assets/limitDisplay-DNU_w4O7.js +1 -0
  19. package/dashboard/dist/assets/{main-CCPcJ7ti.js → main-DgyymGht.js} +16 -3
  20. package/dashboard/dist/assets/main-t7dbBL4x.css +1 -0
  21. package/dashboard/dist/assets/{mock-data-DSiJ-9lr.js → mock-data-D6C7Fba3.js} +1 -1
  22. package/dashboard/dist/assets/{use-limits-display-prefs-Dgd-bQBC.js → use-limits-display-prefs-B7cHBa7Y.js} +1 -1
  23. package/dashboard/dist/assets/{use-native-settings-CjZRLdFT.js → use-native-settings-BKAzGuxw.js} +1 -1
  24. package/dashboard/dist/assets/{useCurrency-BJRU0syn.js → useCurrency-BVr6Ajuu.js} +1 -1
  25. package/dashboard/dist/index.html +2 -2
  26. package/package.json +5 -3
  27. package/src/commands/doctor.js +8 -0
  28. package/src/commands/init.js +1 -1
  29. package/src/commands/sync.js +67 -0
  30. package/src/lib/doctor.js +227 -1
  31. package/src/lib/local-api.js +385 -113
  32. package/src/lib/pricing/seed-snapshot.json +1 -1
  33. package/src/lib/process-list.js +91 -0
  34. package/src/lib/queue-compact.js +220 -0
  35. package/src/lib/rollout.js +81 -14
  36. package/src/lib/single-flight.js +59 -0
  37. package/src/lib/skills-manager.js +2 -2
  38. package/src/lib/transcript-suppression.js +133 -0
  39. package/src/lib/usage-limits.js +99 -22
  40. package/dashboard/dist/assets/DashboardPage-CkhqD3x3.js +0 -60
  41. package/dashboard/dist/assets/LocalOnlyNotice-DXVmRcyV.js +0 -1
  42. package/dashboard/dist/assets/limitDisplay-CXlkWhjp.js +0 -1
  43. package/dashboard/dist/assets/main-ZrWkoMlr.css +0 -1
@@ -0,0 +1,91 @@
1
+ const cp = require("node:child_process");
2
+
3
+ // Shared process-listing primitives. Extracted from usage-limits.js so that a
4
+ // second caller (transcript-suppression.js) can reuse the parser instead of
5
+ // keeping a second copy of the same regex in the tree.
6
+ //
7
+ // PRIVACY: callers get raw command lines here, and a command line can contain a
8
+ // user's file paths. Nothing in this module writes, logs, caches, or returns a
9
+ // command line beyond the synchronous call — that constraint belongs to every
10
+ // caller, and CONTRIBUTING.md's rule ("never log, store, transmit, or print ...
11
+ // file paths from user code") is what it exists to satisfy.
12
+
13
+ const PS_BINARY = "/bin/ps";
14
+ // `-x` (own user, including processes with no controlling terminal) rather than
15
+ // `-ax` (every user on the box). The suppression check reports its findings over
16
+ // an unauthenticated loopback endpoint, and on a multi-user host `-a` would make
17
+ // that endpoint answer questions about other people's sessions. Scoping the scan
18
+ // itself is the narrow fix: a session TokenTracker could not have recorded
19
+ // anyway is one this user is not running.
20
+ //
21
+ // Verified on both supported platforms rather than assumed from documented
22
+ // semantics, because Linux `ps` is procps and parses dash-prefixed options as
23
+ // UNIX-style, where `-x` is not an option at all. It does accept this as the BSD
24
+ // `x`: on Debian 12 / procps-ng 4.0.2, `ps -x -o pid=,command=` exits 0 and
25
+ // lists one user, while `-ax` on the same box lists seven. macOS/BSD `ps` is the
26
+ // native case. Had procps rejected it, every Linux host would have fallen into
27
+ // `process_list_failed` — a permanent non-advisory warn, which would pin
28
+ // `degraded` for a whole platform.
29
+ //
30
+ // Frozen because two modules now share this array. Importing one constant stops
31
+ // the two scans from drifting apart editorially; freezing is what stops a caller
32
+ // pushing `-a` onto it at runtime.
33
+ const PS_ARGS = Object.freeze(["-x", "-o", "pid=,command="]);
34
+ const PS_TIMEOUT_MS = 4000;
35
+ const PS_MAX_BUFFER = 10 * 1024 * 1024;
36
+
37
+ function parseProcessLine(line) {
38
+ const match = String(line || "")
39
+ .trim()
40
+ .match(/^(\d+)\s+(.*)$/);
41
+ if (!match) return null;
42
+ return {
43
+ pid: Number(match[1]),
44
+ command: match[2],
45
+ };
46
+ }
47
+
48
+ // `/bin/ps` with these flags is a POSIX assumption. Windows has no equivalent
49
+ // at this path, and guessing at `tasklist` output would be an untested code
50
+ // path, so the honest answer there is "not supported" rather than "no problems
51
+ // found" — the caller must not turn this into a passing check.
52
+ function isProcessListSupported(platform = process.platform) {
53
+ return platform !== "win32";
54
+ }
55
+
56
+ // Returns { supported, ok, lines, reason }. `ok: false` never throws: a machine
57
+ // that refuses `ps` (sandbox, hardened runtime) is a normal condition here, and
58
+ // the caller reports it as "could not check" rather than as "nothing found".
59
+ function listProcessLines({ commandRunner, platform = process.platform } = {}) {
60
+ if (!isProcessListSupported(platform)) {
61
+ return { supported: false, ok: false, lines: [], reason: "unsupported_platform" };
62
+ }
63
+
64
+ const runner = typeof commandRunner === "function" ? commandRunner : cp.spawnSync;
65
+ let result;
66
+ try {
67
+ result = runner(PS_BINARY, PS_ARGS, {
68
+ encoding: "utf8",
69
+ maxBuffer: PS_MAX_BUFFER,
70
+ timeout: PS_TIMEOUT_MS,
71
+ });
72
+ } catch {
73
+ return { supported: true, ok: false, lines: [], reason: "process_list_failed" };
74
+ }
75
+
76
+ if (result?.error || result?.status !== 0) {
77
+ return { supported: true, ok: false, lines: [], reason: "process_list_failed" };
78
+ }
79
+
80
+ const stdout = typeof result?.stdout === "string" ? result.stdout : "";
81
+ return { supported: true, ok: true, lines: stdout.split("\n"), reason: null };
82
+ }
83
+
84
+ module.exports = {
85
+ PS_ARGS,
86
+ PS_BINARY,
87
+ PS_TIMEOUT_MS,
88
+ isProcessListSupported,
89
+ listProcessLines,
90
+ parseProcessLine,
91
+ };
@@ -0,0 +1,220 @@
1
+ "use strict";
2
+
3
+ // Compaction for the append-only queue, and the row invariant that lived in
4
+ // prose.
5
+ //
6
+ // Measured on a real install: 34,492 lines, 5,595 unique keys, 28,897
7
+ // superseded (83.8%), 11 MB. Five sixths of the file is dead weight, and
8
+ // `readQueueData` in local-api.js re-reads and re-dedups ALL of it on every
9
+ // endpoint call — a dashboard refresh hits 6-8 endpoints, auto-refresh defaults
10
+ // to 30s. Nothing ever reclaims it; the only rewrite in the codebase is a
11
+ // one-off migration.
12
+ //
13
+ // The design risk here is close to zero for one reason: THE READERS ALREADY
14
+ // DEFINE THE OUTPUT. `readQueueData` keeps the last row per
15
+ // `source|model|hour_start`. Compaction only has to produce what every reader
16
+ // already computes, so it keeps the last RAW LINE per key — not a re-serialised
17
+ // row. Byte-identical API responses then follow by construction rather than by
18
+ // luck, because the surviving bytes are the exact bytes the reader would have
19
+ // kept.
20
+
21
+ const fs = require("node:fs");
22
+ const path = require("node:path");
23
+
24
+ // Must match readQueueData in src/lib/local-api.js. If that key ever changes,
25
+ // this one has to change with it — a test asserts they agree on real rows.
26
+ function queueRowKey(row) {
27
+ return `${row.source || ""}|${row.model || ""}|${row.hour_start || ""}`;
28
+ }
29
+
30
+ const TOKEN_COLUMNS = [
31
+ "input_tokens",
32
+ "output_tokens",
33
+ "cache_creation_input_tokens",
34
+ "cached_input_tokens",
35
+ "reasoning_output_tokens",
36
+ ];
37
+
38
+ const THIRTY_MINUTES_MS = 30 * 60 * 1000;
39
+
40
+ // Codex and every-code report reasoning tokens that are ALREADY COUNTED inside
41
+ // output_tokens, so their total_tokens correctly excludes the reasoning column —
42
+ // adding it would count those tokens twice.
43
+ //
44
+ // This is not a special case invented here. `computeRowCost` in
45
+ // src/lib/pricing/index.js:309 makes exactly the same distinction, and charges
46
+ // reasoning at zero for these two sources for the same reason.
47
+ //
48
+ // Found by running this check against a real 34,922-row queue: 8,236 rows
49
+ // "violated" the invariant, every one of them source=codex, and in every case
50
+ // the difference was exactly reasoning_output_tokens. The rows were right, the
51
+ // check was wrong, and so was the prose it came from — CLAUDE.md said "sum of
52
+ // all columns" with no exception. Corrected there too.
53
+ const REASONING_FOLDED_INTO_OUTPUT = new Set(["codex", "every-code"]);
54
+
55
+ function expectedTotal(row) {
56
+ const folded = REASONING_FOLDED_INTO_OUTPUT.has(String(row.source || "").toLowerCase());
57
+ return TOKEN_COLUMNS.reduce((acc, column) => {
58
+ if (folded && column === "reasoning_output_tokens") return acc;
59
+ const value = Number(row[column] ?? 0);
60
+ return acc + (Number.isFinite(value) ? value : 0);
61
+ }, 0);
62
+ }
63
+
64
+ // Decides which lines survive, without touching the disk. Split out so the
65
+ // decision is testable on its own and so `analyze` and `compact` cannot drift.
66
+ //
67
+ // Malformed lines are KEPT. They are invisible to every reader already, so
68
+ // dropping them would not change a single API response — but it would destroy
69
+ // bytes nobody has looked at, and a partial write worth investigating is
70
+ // exactly the kind of thing that should survive a routine maintenance command.
71
+ function planCompaction(raw) {
72
+ const lines = raw.split("\n");
73
+ const keep = new Set();
74
+ const lastForKey = new Map();
75
+ let parseable = 0;
76
+ let malformed = 0;
77
+
78
+ lines.forEach((line, index) => {
79
+ if (!line.trim()) return;
80
+ let row;
81
+ try {
82
+ row = JSON.parse(line);
83
+ } catch {
84
+ malformed += 1;
85
+ keep.add(index);
86
+ return;
87
+ }
88
+ parseable += 1;
89
+ lastForKey.set(queueRowKey(row), index);
90
+ });
91
+
92
+ for (const index of lastForKey.values()) keep.add(index);
93
+
94
+ const kept = [...keep].sort((a, b) => a - b);
95
+ return {
96
+ lines: kept.map((index) => lines[index]),
97
+ stats: {
98
+ totalLines: parseable + malformed,
99
+ parseable,
100
+ malformed,
101
+ uniqueKeys: lastForKey.size,
102
+ superseded: parseable - lastForKey.size,
103
+ keptLines: kept.length,
104
+ },
105
+ };
106
+ }
107
+
108
+ function analyzeQueue(queuePath) {
109
+ let raw;
110
+ try {
111
+ raw = fs.readFileSync(queuePath, "utf8");
112
+ } catch (e) {
113
+ if (e?.code === "ENOENT") {
114
+ return { totalLines: 0, parseable: 0, malformed: 0, uniqueKeys: 0, superseded: 0, keptLines: 0, ratio: 0, bytes: 0 };
115
+ }
116
+ throw e;
117
+ }
118
+ const { stats } = planCompaction(raw);
119
+ return {
120
+ ...stats,
121
+ ratio: stats.parseable > 0 ? stats.superseded / stats.parseable : 0,
122
+ bytes: Buffer.byteLength(raw, "utf8"),
123
+ };
124
+ }
125
+
126
+ // Writes to a temp file in the same directory and renames over the original.
127
+ // Same atomic-replace pattern the project-queue rewrite already uses. An
128
+ // interrupt between write and rename leaves the original untouched — the temp
129
+ // file is the only casualty.
130
+ //
131
+ // The CALLER holds the sync lock. This does not take it, because the lock is
132
+ // per-invocation state owned by the sync command, and a second acquisition
133
+ // inside would deadlock against the first.
134
+ function compactQueue(queuePath) {
135
+ let raw;
136
+ try {
137
+ raw = fs.readFileSync(queuePath, "utf8");
138
+ } catch (e) {
139
+ if (e?.code === "ENOENT") return { changed: false, reason: "no queue file" };
140
+ throw e;
141
+ }
142
+
143
+ const before = Buffer.byteLength(raw, "utf8");
144
+ const { lines, stats } = planCompaction(raw);
145
+ if (stats.superseded === 0) {
146
+ return { changed: false, reason: "nothing superseded", ...stats, bytesBefore: before, bytesAfter: before };
147
+ }
148
+
149
+ const out = lines.join("\n") + "\n";
150
+ const tmp = path.join(
151
+ path.dirname(queuePath),
152
+ `${path.basename(queuePath)}.compact.${process.pid}.tmp`,
153
+ );
154
+ fs.writeFileSync(tmp, out, "utf8");
155
+ try {
156
+ fs.renameSync(tmp, queuePath);
157
+ } catch (e) {
158
+ fs.unlinkSync(tmp);
159
+ throw e;
160
+ }
161
+
162
+ return {
163
+ changed: true,
164
+ ...stats,
165
+ bytesBefore: before,
166
+ bytesAfter: Buffer.byteLength(out, "utf8"),
167
+ };
168
+ }
169
+
170
+ // CLAUDE.md states the column invariant in prose:
171
+ //
172
+ // total = input + output + cache_creation + cache_read + reasoning
173
+ //
174
+ // Nothing enforced it at runtime. A miswritten or corrupt row was aggregated and
175
+ // rendered, not flagged — and a parser bug of exactly this shape is the class
176
+ // CLAUDE.md records at 1.6-7x magnitude. Same conversion as the curated-expiry
177
+ // and version-lockstep checks: a rule that lived in a document starts running.
178
+ //
179
+ // Returns one finding per violating row, capped by the caller.
180
+ function findRowViolations(rows) {
181
+ const findings = [];
182
+ rows.forEach((row, index) => {
183
+ const where = `row ${index + 1} (${row.source || "?"}|${row.model || "?"}|${row.hour_start || "?"})`;
184
+
185
+ for (const column of TOKEN_COLUMNS) {
186
+ const value = Number(row[column] ?? 0);
187
+ if (!Number.isFinite(value)) {
188
+ findings.push(`${where}: ${column} is not a number (${JSON.stringify(row[column])})`);
189
+ } else if (value < 0) {
190
+ findings.push(`${where}: ${column} is negative (${value})`);
191
+ }
192
+ }
193
+
194
+ const sum = expectedTotal(row);
195
+ const total = Number(row.total_tokens ?? 0);
196
+ if (Number.isFinite(total) && total !== sum) {
197
+ findings.push(`${where}: total_tokens ${total} != expected ${sum}`);
198
+ }
199
+
200
+ const bucket = Date.parse(row.hour_start);
201
+ if (!Number.isFinite(bucket)) {
202
+ findings.push(`${where}: hour_start is not a timestamp`);
203
+ } else if (bucket % THIRTY_MINUTES_MS !== 0) {
204
+ findings.push(`${where}: hour_start is not on a 30-minute UTC boundary`);
205
+ }
206
+ });
207
+ return findings;
208
+ }
209
+
210
+ module.exports = {
211
+ queueRowKey,
212
+ expectedTotal,
213
+ REASONING_FOLDED_INTO_OUTPUT,
214
+ planCompaction,
215
+ analyzeQueue,
216
+ compactQueue,
217
+ findRowViolations,
218
+ TOKEN_COLUMNS,
219
+ THIRTY_MINUTES_MS,
220
+ };
@@ -937,11 +937,12 @@ async function parseRolloutFile({
937
937
  projectState,
938
938
  currentProjectKey,
939
939
  source,
940
+ model,
940
941
  bucketStart,
941
942
  currentProjectRef,
942
943
  );
943
944
  addTotals(projectBucket.totals, delta);
944
- projectTouchedBuckets.add(projectBucketKey(currentProjectKey, source, bucketStart));
945
+ projectTouchedBuckets.add(projectBucketKey(currentProjectKey, source, model, bucketStart));
945
946
  }
946
947
  eventsAggregated += 1;
947
948
  }
@@ -1061,11 +1062,12 @@ async function parseClaudeFile({
1061
1062
  projectState,
1062
1063
  projectKey,
1063
1064
  source,
1065
+ model,
1064
1066
  bucketStart,
1065
1067
  projectRef,
1066
1068
  );
1067
1069
  addTotals(projectBucket.totals, delta);
1068
- projectTouchedBuckets.add(projectBucketKey(projectKey, source, bucketStart));
1070
+ projectTouchedBuckets.add(projectBucketKey(projectKey, source, model, bucketStart));
1069
1071
  }
1070
1072
  eventsAggregated += 1;
1071
1073
  }
@@ -1145,11 +1147,12 @@ async function parseGeminiFile({
1145
1147
  projectState,
1146
1148
  projectKey,
1147
1149
  source,
1150
+ model,
1148
1151
  bucketStart,
1149
1152
  projectRef,
1150
1153
  );
1151
1154
  addTotals(projectBucket.totals, delta);
1152
- projectTouchedBuckets.add(projectBucketKey(projectKey, source, bucketStart));
1155
+ projectTouchedBuckets.add(projectBucketKey(projectKey, source, model, bucketStart));
1153
1156
  }
1154
1157
  eventsAggregated += 1;
1155
1158
  totals = currentTotals;
@@ -1256,11 +1259,12 @@ async function parseOpencodeMessageFile({
1256
1259
  projectState,
1257
1260
  projectKey,
1258
1261
  source,
1262
+ model,
1259
1263
  bucketStart,
1260
1264
  projectRef,
1261
1265
  );
1262
1266
  addTotals(projectBucket.totals, delta);
1263
- projectTouchedBuckets.add(projectBucketKey(projectKey, source, bucketStart));
1267
+ projectTouchedBuckets.add(projectBucketKey(projectKey, source, model, bucketStart));
1264
1268
  }
1265
1269
  return { messageKey, lastTotals: currentTotals, eventsAggregated: 1, shouldUpdate: true };
1266
1270
  }
@@ -1598,6 +1602,7 @@ async function enqueueTouchedProjectBuckets({
1598
1602
  project_ref: projectRef,
1599
1603
  project_key: projectKey,
1600
1604
  source: bucket.source,
1605
+ model: bucket.model || PROJECT_MODEL_UNATTRIBUTED,
1601
1606
  hour_start: bucket.hour_start,
1602
1607
  input_tokens: totals.input_tokens,
1603
1608
  cached_input_tokens: totals.cached_input_tokens,
@@ -1767,12 +1772,16 @@ function normalizeProjectState(raw) {
1767
1772
  projects[key] = { ...value };
1768
1773
  }
1769
1774
 
1770
- return {
1771
- version: 2,
1775
+ const normalized = {
1776
+ version: 3,
1772
1777
  buckets,
1773
1778
  projects,
1774
1779
  updatedAt: typeof state.updatedAt === "string" ? state.updatedAt : null,
1775
1780
  };
1781
+ // Every parser reaches project state through here, so one call covers all of
1782
+ // them. Idempotent: a v3 key already has four segments and is skipped.
1783
+ migrateProjectBucketsToModelKey(normalized);
1784
+ return normalized;
1776
1785
  }
1777
1786
 
1778
1787
  function normalizeOpencodeState(raw) {
@@ -1819,10 +1828,11 @@ function getHourlyBucket(state, source, model, hourStart) {
1819
1828
  return bucket;
1820
1829
  }
1821
1830
 
1822
- function getProjectBucket(state, projectKey, source, hourStart, projectRef) {
1831
+ function getProjectBucket(state, projectKey, source, model, hourStart, projectRef) {
1823
1832
  const buckets = state.buckets;
1824
1833
  const normalizedSource = normalizeSourceInput(source) || DEFAULT_SOURCE;
1825
- const key = projectBucketKey(projectKey, normalizedSource, hourStart);
1834
+ const normalizedModel = normalizeModelInput(model) || PROJECT_MODEL_UNATTRIBUTED;
1835
+ const key = projectBucketKey(projectKey, normalizedSource, normalizedModel, hourStart);
1826
1836
  let bucket = buckets[key];
1827
1837
  if (!bucket || typeof bucket !== "object") {
1828
1838
  bucket = {
@@ -1831,6 +1841,7 @@ function getProjectBucket(state, projectKey, source, hourStart, projectRef) {
1831
1841
  project_key: projectKey,
1832
1842
  project_ref: projectRef,
1833
1843
  source: normalizedSource,
1844
+ model: normalizedModel,
1834
1845
  hour_start: hourStart,
1835
1846
  };
1836
1847
  buckets[key] = bucket;
@@ -1921,9 +1932,53 @@ function bucketKey(source, model, hourStart) {
1921
1932
  return `${safeSource}${BUCKET_SEPARATOR}${safeModel}${BUCKET_SEPARATOR}${hourStart}`;
1922
1933
  }
1923
1934
 
1924
- function projectBucketKey(projectKey, source, hourStart) {
1935
+ // Per-repo COST is per-model, and the key had no model in it, so the repo x model
1936
+ // join this product's README promises ("which repo, which model, and which
1937
+ // hour") had never existed. Adding model is what makes computeRowCost usable
1938
+ // per project.
1939
+ //
1940
+ // The transition hazard is the whole job. A legacy row keyed
1941
+ // project|source|hour and a new row keyed project|source|model|hour describe the
1942
+ // SAME bucket at different granularity, so a reader that keeps both
1943
+ // double-counts. Rows written before this change are keyed with
1944
+ // PROJECT_MODEL_UNATTRIBUTED, so they occupy one slot alongside the model-keyed
1945
+ // rows rather than shadowing them — see migrateProjectBucketsToModelKey.
1946
+ const PROJECT_MODEL_UNATTRIBUTED = "unknown";
1947
+
1948
+ function projectBucketKey(projectKey, source, model, hourStart) {
1925
1949
  const safeSource = normalizeSourceInput(source) || DEFAULT_SOURCE;
1926
- return `${projectKey}${BUCKET_SEPARATOR}${safeSource}${BUCKET_SEPARATOR}${hourStart}`;
1950
+ const safeModel = normalizeModelInput(model) || PROJECT_MODEL_UNATTRIBUTED;
1951
+ return (
1952
+ `${projectKey}${BUCKET_SEPARATOR}${safeSource}` +
1953
+ `${BUCKET_SEPARATOR}${safeModel}${BUCKET_SEPARATOR}${hourStart}`
1954
+ );
1955
+ }
1956
+
1957
+ // Re-keys buckets carried in cursors.json from the pre-model key to the new one,
1958
+ // tagging them PROJECT_MODEL_UNATTRIBUTED.
1959
+ //
1960
+ // Without this the old bucket keeps its old key, never matches again, and its
1961
+ // running total is stranded: new usage in the same hour starts from zero under
1962
+ // the model key while the reader still sees the old row. Sum = double count.
1963
+ //
1964
+ // After it, the stranded bucket becomes the `unknown` slot for that hour. It
1965
+ // stops receiving new usage (that goes to the model-specific bucket), so it is
1966
+ // frozen at its pre-upgrade total and the two sum to the right number — no
1967
+ // double count, and nothing lost.
1968
+ function migrateProjectBucketsToModelKey(projectState) {
1969
+ const buckets = projectState?.buckets;
1970
+ if (!buckets || typeof buckets !== "object") return 0;
1971
+ let migrated = 0;
1972
+ for (const [key, bucket] of Object.entries(buckets)) {
1973
+ if (key.split(BUCKET_SEPARATOR).length !== 3) continue;
1974
+ const [projectKey, source, hourStart] = key.split(BUCKET_SEPARATOR);
1975
+ const nextKey = projectBucketKey(projectKey, source, PROJECT_MODEL_UNATTRIBUTED, hourStart);
1976
+ if (buckets[nextKey]) continue;
1977
+ buckets[nextKey] = { ...bucket, model: PROJECT_MODEL_UNATTRIBUTED };
1978
+ delete buckets[key];
1979
+ migrated += 1;
1980
+ }
1981
+ return migrated;
1927
1982
  }
1928
1983
 
1929
1984
  function groupBucketKey(source, hourStart) {
@@ -2663,11 +2718,14 @@ async function parseOpencodeDbIncremental({
2663
2718
  projectState,
2664
2719
  projectKey,
2665
2720
  defaultSource,
2721
+ model,
2666
2722
  bucketStart,
2667
2723
  projectRef,
2668
2724
  );
2669
2725
  addTotals(projectBucket.totals, delta);
2670
- projectTouchedBuckets.add(projectBucketKey(projectKey, defaultSource, bucketStart));
2726
+ projectTouchedBuckets.add(
2727
+ projectBucketKey(projectKey, defaultSource, model, bucketStart),
2728
+ );
2671
2729
  }
2672
2730
  }
2673
2731
 
@@ -3340,8 +3398,11 @@ async function parseHermesIncremental({ hermesPath, dbPath, cursors, queuePath,
3340
3398
  // Skip if delta is zero (session unchanged since last sync)
3341
3399
  if (dInput === 0 && dOutput === 0 && dCacheRead === 0 && dCacheWrite === 0 && dReasoning === 0) continue;
3342
3400
 
3343
- // Prefer ended_at for bucket placement; fall back to started_at
3344
- const epochSec = endedAt ?? startedAt;
3401
+ // A first observation has only the session start as a usable timestamp.
3402
+ // Attribute later active-session deltas to this sync so cross-day usage
3403
+ // does not keep growing the day on which the session originally started.
3404
+ // Once Hermes records completion, ended_at is authoritative for the final delta.
3405
+ const epochSec = endedAt ?? (prev ? Date.parse(updatedAt) / 1000 : startedAt);
3345
3406
  if (!epochSec || !Number.isFinite(epochSec)) continue;
3346
3407
  const tsIso = new Date(epochSec * 1000).toISOString();
3347
3408
  const bucketStart = toUtcHalfHourStart(tsIso);
@@ -8608,11 +8669,12 @@ async function parseAntigravityFile({
8608
8669
  projectState,
8609
8670
  projectKey,
8610
8671
  source,
8672
+ model,
8611
8673
  bucketStart,
8612
8674
  projectRef,
8613
8675
  );
8614
8676
  addTotals(projectBucket.totals, delta);
8615
- projectTouchedBuckets.add(projectBucketKey(projectKey, source, bucketStart));
8677
+ projectTouchedBuckets.add(projectBucketKey(projectKey, source, model, bucketStart));
8616
8678
  }
8617
8679
  eventsAggregated += 1;
8618
8680
  // Snapshot the pre-planner context first. The planner's own content+tool_calls
@@ -8707,6 +8769,11 @@ function isCjkCodePoint(code) {
8707
8769
  }
8708
8770
 
8709
8771
  module.exports = {
8772
+ // Exported for tests: the mixed-era rule is the risky part of this change.
8773
+ projectBucketKey,
8774
+ migrateProjectBucketsToModelKey,
8775
+ normalizeProjectState,
8776
+ PROJECT_MODEL_UNATTRIBUTED,
8710
8777
  listRolloutFiles,
8711
8778
  listClaudeProjectFiles,
8712
8779
  listGeminiSessionFiles,
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Coalesce concurrent calls to one expensive async producer.
5
+ *
6
+ * The problem it solves is not "the same work runs twice" — it is that the work
7
+ * being duplicated fans out to every configured provider's private endpoint. Two
8
+ * dashboard tabs, a route mount, and a scheduled revalidation can all arrive
9
+ * inside the same second on a cold cache; without this, each one launches its own
10
+ * full sweep.
11
+ *
12
+ * `run(fn)` returns the in-flight promise when one exists, so every caller in a
13
+ * window shares a single execution and a single result object. The slot is
14
+ * released as soon as the work settles — success or failure — so the next call
15
+ * starts fresh work rather than replaying a stale outcome.
16
+ *
17
+ * Deliberately NOT keyed by argument: the single consumer here has one shared
18
+ * result for the whole process. A joining caller therefore receives the FIRST
19
+ * caller's work, arguments included. That is the intended trade for the fan-out,
20
+ * and it is safe only because every production call site passes the same inputs.
21
+ *
22
+ * @returns {(fn: () => Promise<any>) => Promise<any>}
23
+ */
24
+ function createSingleFlight() {
25
+ let inFlight = null;
26
+
27
+ return function run(fn) {
28
+ if (inFlight) return inFlight;
29
+
30
+ // `Promise.resolve().then(fn)` rather than `fn()` so a synchronous throw
31
+ // inside fn becomes a rejection on this path too, instead of escaping past
32
+ // the slot cleanup and wedging `inFlight` forever.
33
+ //
34
+ // Release carries no `inFlight === pending` identity guard, deliberately. A
35
+ // later run can only claim the slot after this one released it — while
36
+ // `pending` is unsettled every arrival joins instead of replacing it — so a
37
+ // stale callback clearing a successor's slot is unreachable, and a mutation
38
+ // test found the guard dead. Adding a way to clear the slot from outside
39
+ // would make it reachable again; there is none, and #141 requires that a
40
+ // cache reset specifically must not do it.
41
+ const pending = Promise.resolve()
42
+ .then(fn)
43
+ .finally(() => {
44
+ inFlight = null;
45
+ });
46
+
47
+ // Module state now holds a promise that real callers may all walk away from.
48
+ // Without a handler of its own, a rejection reaching only this reference is
49
+ // an unhandledRejection raised from state nobody is watching — a failure mode
50
+ // that did not exist while every caller owned its own promise. Callers still
51
+ // receive the rejection; this only marks the stored reference as handled.
52
+ pending.catch(() => {});
53
+
54
+ inFlight = pending;
55
+ return pending;
56
+ };
57
+ }
58
+
59
+ module.exports = { createSingleFlight };
@@ -287,13 +287,13 @@ function hashDirectory(dir) {
287
287
  continue;
288
288
  }
289
289
  const execBit = process.platform === "win32" ? 0 : stat.mode & 0o111 ? 1 : 0;
290
- hash.update(`${rel}${execBit}`);
290
+ hash.update(`${rel}\0${execBit}\0`);
291
291
  try {
292
292
  hash.update(fs.readFileSync(abs));
293
293
  } catch (_e) {
294
294
  // unreadable file — fold its absence in deterministically
295
295
  }
296
- hash.update("");
296
+ hash.update("\0");
297
297
  }
298
298
  }
299
299
  };