@ipv9/tokentracker-cli 0.39.43 → 0.39.44
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 +18 -10
- package/dashboard/dist/assets/{Card-LPizs_gs.js → Card-C_H8B1rI.js} +1 -1
- package/dashboard/dist/assets/DashboardPage-DOa3N76u.js +60 -0
- package/dashboard/dist/assets/{FadeIn-B8aDegoD.js → FadeIn-BE8I9B4-.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-Vo2ZZXov.js → IpCheckPage-DQ9gR343.js} +1 -1
- package/dashboard/dist/assets/{LimitsPage-C5-Q9Q30.js → LimitsPage-Balfhw3-.js} +1 -1
- package/dashboard/dist/assets/LocalOnlyNotice-hxi161Wl.js +1 -0
- package/dashboard/dist/assets/{PopoverPopup-CJf61ahu.js → PopoverPopup-B4zbh-jA.js} +1 -1
- package/dashboard/dist/assets/{Select-BLGoaqgw.js → Select-DLYT4NCO.js} +1 -1
- package/dashboard/dist/assets/{SelectItemText-Bt02Fgwf.js → SelectItemText-DUyZrDnS.js} +1 -1
- package/dashboard/dist/assets/{SettingsPage-BnUJew-8.js → SettingsPage-BySJoulk.js} +1 -1
- package/dashboard/dist/assets/{SkillsPage-ImHg3Puy.js → SkillsPage-DpiMLTPo.js} +1 -1
- package/dashboard/dist/assets/{WidgetsPage-qscVE2nO.js → WidgetsPage-C6je8upG.js} +1 -1
- package/dashboard/dist/assets/{WrappedPage-qM_7aClE.js → WrappedPage-ByarX-vY.js} +1 -1
- package/dashboard/dist/assets/{arrow-up-right-CByq3BPT.js → arrow-up-right-C-WAJGZ8.js} +1 -1
- package/dashboard/dist/assets/{download-CTwO-YeA.js → download-BhOg_qb4.js} +1 -1
- package/dashboard/dist/assets/{format-4chvNBjF.js → format-Co2okzG-.js} +1 -1
- package/dashboard/dist/assets/limitDisplay-CPMQA7lm.js +1 -0
- package/dashboard/dist/assets/{main-CCPcJ7ti.js → main-q9UoIq7C.js} +12 -3
- package/dashboard/dist/assets/main-t7dbBL4x.css +1 -0
- package/dashboard/dist/assets/{mock-data-DSiJ-9lr.js → mock-data-0XiGBeUV.js} +1 -1
- package/dashboard/dist/assets/{use-limits-display-prefs-Dgd-bQBC.js → use-limits-display-prefs-DmEIJPtg.js} +1 -1
- package/dashboard/dist/assets/{use-native-settings-CjZRLdFT.js → use-native-settings-QLM3Sd2C.js} +1 -1
- package/dashboard/dist/assets/{useCurrency-BJRU0syn.js → useCurrency-Dtize6Tx.js} +1 -1
- package/dashboard/dist/index.html +2 -2
- package/package.json +5 -3
- package/src/commands/doctor.js +2 -0
- package/src/commands/init.js +1 -1
- package/src/commands/sync.js +67 -0
- package/src/lib/doctor.js +72 -0
- package/src/lib/local-api.js +306 -93
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/queue-compact.js +220 -0
- package/src/lib/rollout.js +76 -12
- package/src/lib/skills-manager.js +2 -2
- package/src/lib/usage-limits.js +20 -3
- package/dashboard/dist/assets/DashboardPage-CkhqD3x3.js +0 -60
- package/dashboard/dist/assets/LocalOnlyNotice-DXVmRcyV.js +0 -1
- package/dashboard/dist/assets/limitDisplay-CXlkWhjp.js +0 -1
- package/dashboard/dist/assets/main-ZrWkoMlr.css +0 -1
|
@@ -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
|
+
};
|
package/src/lib/rollout.js
CHANGED
|
@@ -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
|
-
|
|
1771
|
-
version:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
2726
|
+
projectTouchedBuckets.add(
|
|
2727
|
+
projectBucketKey(projectKey, defaultSource, model, bucketStart),
|
|
2728
|
+
);
|
|
2671
2729
|
}
|
|
2672
2730
|
}
|
|
2673
2731
|
|
|
@@ -8608,11 +8666,12 @@ async function parseAntigravityFile({
|
|
|
8608
8666
|
projectState,
|
|
8609
8667
|
projectKey,
|
|
8610
8668
|
source,
|
|
8669
|
+
model,
|
|
8611
8670
|
bucketStart,
|
|
8612
8671
|
projectRef,
|
|
8613
8672
|
);
|
|
8614
8673
|
addTotals(projectBucket.totals, delta);
|
|
8615
|
-
projectTouchedBuckets.add(projectBucketKey(projectKey, source, bucketStart));
|
|
8674
|
+
projectTouchedBuckets.add(projectBucketKey(projectKey, source, model, bucketStart));
|
|
8616
8675
|
}
|
|
8617
8676
|
eventsAggregated += 1;
|
|
8618
8677
|
// Snapshot the pre-planner context first. The planner's own content+tool_calls
|
|
@@ -8707,6 +8766,11 @@ function isCjkCodePoint(code) {
|
|
|
8707
8766
|
}
|
|
8708
8767
|
|
|
8709
8768
|
module.exports = {
|
|
8769
|
+
// Exported for tests: the mixed-era rule is the risky part of this change.
|
|
8770
|
+
projectBucketKey,
|
|
8771
|
+
migrateProjectBucketsToModelKey,
|
|
8772
|
+
normalizeProjectState,
|
|
8773
|
+
PROJECT_MODEL_UNATTRIBUTED,
|
|
8710
8774
|
listRolloutFiles,
|
|
8711
8775
|
listClaudeProjectFiles,
|
|
8712
8776
|
listGeminiSessionFiles,
|
|
@@ -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}
|
|
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
|
};
|
package/src/lib/usage-limits.js
CHANGED
|
@@ -218,10 +218,23 @@ async function fetchCodexUsageLimits(
|
|
|
218
218
|
method: "GET",
|
|
219
219
|
headers,
|
|
220
220
|
});
|
|
221
|
-
// 401/403/404 from wham means "no usage data available for this auth state" —
|
|
222
|
-
// a
|
|
221
|
+
// 401/403/404 from wham means "no usage data available for this auth state" —
|
|
222
|
+
// a free or multi-account user, or a token that went stale. #52 asked for a
|
|
223
|
+
// neutral state here rather than a red "Fetch failed".
|
|
224
|
+
//
|
|
225
|
+
// It got NO state. With no error and no windows, LimitChips falls through to
|
|
226
|
+
// `windows.length === 0 -> return null` and the chip disappears entirely,
|
|
227
|
+
// which is the outcome #105 calls worse than never having had a chip: the user
|
|
228
|
+
// has been trained to look there and now reads absence as "plenty left".
|
|
229
|
+
//
|
|
230
|
+
// `notice` is the third state the UI was missing. Visible and subtle, so #52's
|
|
231
|
+
// intent survives without #105's failure mode.
|
|
223
232
|
if (res.status === 401 || res.status === 403 || res.status === 404) {
|
|
224
|
-
return {
|
|
233
|
+
return {
|
|
234
|
+
primary_window: null,
|
|
235
|
+
secondary_window: null,
|
|
236
|
+
notice: "No usage data for this sign-in. Run `codex` to sign in again.",
|
|
237
|
+
};
|
|
225
238
|
}
|
|
226
239
|
if (!res.ok) {
|
|
227
240
|
throw new Error(`Codex API returned ${res.status}`);
|
|
@@ -2013,6 +2026,10 @@ async function getUsageLimits({
|
|
|
2013
2026
|
codex = {
|
|
2014
2027
|
configured: true,
|
|
2015
2028
|
error: null,
|
|
2029
|
+
// A 4xx from wham produces no windows and no error. Carrying its `notice`
|
|
2030
|
+
// through is what keeps the chip on screen instead of falling through to
|
|
2031
|
+
// LimitChips' `windows.length === 0 -> return null`.
|
|
2032
|
+
notice: codexResult.value.notice || null,
|
|
2016
2033
|
plan_type: codexPlanType || null,
|
|
2017
2034
|
primary_window: codexResult.value.primary_window,
|
|
2018
2035
|
secondary_window: codexResult.value.secondary_window,
|