@praeviso/code-env-switch 0.1.4 → 0.1.6
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 +57 -0
- package/README_zh.md +57 -0
- package/bin/cli/args.js +13 -0
- package/bin/cli/help.js +5 -0
- package/bin/cli/index.js +2 -1
- package/bin/commands/index.js +3 -1
- package/bin/commands/list.js +44 -2
- package/bin/commands/usage.js +41 -0
- package/bin/index.js +7 -0
- package/bin/statusline/debug.js +1 -0
- package/bin/statusline/format.js +6 -9
- package/bin/statusline/index.js +46 -8
- package/bin/statusline/input.js +9 -91
- package/bin/statusline/usage/claude.js +181 -0
- package/bin/statusline/usage/codex.js +177 -0
- package/bin/statusline/usage.js +20 -76
- package/bin/usage/index.js +647 -50
- package/bin/usage/pricing.js +303 -0
- package/code-env.example.json +55 -0
- package/package.json +1 -1
- package/src/cli/args.ts +14 -0
- package/src/cli/help.ts +5 -0
- package/src/cli/index.ts +7 -1
- package/src/commands/index.ts +1 -0
- package/src/commands/list.ts +74 -4
- package/src/commands/usage.ts +53 -0
- package/src/index.ts +11 -0
- package/src/statusline/debug.ts +1 -1
- package/src/statusline/format.ts +9 -10
- package/src/statusline/index.ts +74 -24
- package/src/statusline/input.ts +13 -154
- package/src/statusline/types.ts +6 -0
- package/src/statusline/usage/claude.ts +299 -0
- package/src/statusline/usage/codex.ts +258 -0
- package/src/statusline/usage.ts +24 -119
- package/src/types.ts +27 -0
- package/src/usage/index.ts +779 -44
- package/src/usage/pricing.ts +323 -0
- package/PLAN.md +0 -33
package/src/usage/index.ts
CHANGED
|
@@ -4,23 +4,37 @@
|
|
|
4
4
|
import * as fs from "fs";
|
|
5
5
|
import * as path from "path";
|
|
6
6
|
import * as os from "os";
|
|
7
|
-
import type { Config, ProfileType } from "../types";
|
|
7
|
+
import type { Config, Profile, ProfileType } from "../types";
|
|
8
8
|
import { resolvePath } from "../shell/utils";
|
|
9
9
|
import { normalizeType, inferProfileType, getProfileDisplayName } from "../profile/type";
|
|
10
|
+
import { getStatuslineDebugPath } from "../statusline/debug";
|
|
11
|
+
import { calculateUsageCost, resolvePricingForProfile } from "./pricing";
|
|
10
12
|
|
|
11
13
|
interface UsageRecord {
|
|
12
14
|
ts: string;
|
|
13
15
|
type: string;
|
|
14
16
|
profileKey: string | null;
|
|
15
17
|
profileName: string | null;
|
|
18
|
+
model: string | null;
|
|
19
|
+
sessionId?: string | null;
|
|
16
20
|
inputTokens: number;
|
|
17
21
|
outputTokens: number;
|
|
22
|
+
cacheReadTokens: number;
|
|
23
|
+
cacheWriteTokens: number;
|
|
18
24
|
totalTokens: number;
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
interface UsageTotals {
|
|
22
28
|
today: number;
|
|
23
29
|
total: number;
|
|
30
|
+
todayInput: number;
|
|
31
|
+
totalInput: number;
|
|
32
|
+
todayOutput: number;
|
|
33
|
+
totalOutput: number;
|
|
34
|
+
todayCacheRead: number;
|
|
35
|
+
totalCacheRead: number;
|
|
36
|
+
todayCacheWrite: number;
|
|
37
|
+
totalCacheWrite: number;
|
|
24
38
|
}
|
|
25
39
|
|
|
26
40
|
interface UsageTotalsIndex {
|
|
@@ -28,32 +42,63 @@ interface UsageTotalsIndex {
|
|
|
28
42
|
byName: Map<string, UsageTotals>;
|
|
29
43
|
}
|
|
30
44
|
|
|
45
|
+
interface UsageCostTotals {
|
|
46
|
+
today: number;
|
|
47
|
+
total: number;
|
|
48
|
+
todayTokens: number;
|
|
49
|
+
totalTokens: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface UsageCostIndex {
|
|
53
|
+
byKey: Map<string, UsageCostTotals>;
|
|
54
|
+
byName: Map<string, UsageCostTotals>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface UsageCleanupFailure {
|
|
58
|
+
path: string;
|
|
59
|
+
error: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface UsageCleanupResult {
|
|
63
|
+
removed: string[];
|
|
64
|
+
missing: string[];
|
|
65
|
+
failed: UsageCleanupFailure[];
|
|
66
|
+
}
|
|
67
|
+
|
|
31
68
|
interface UsageStateEntry {
|
|
32
69
|
mtimeMs: number;
|
|
33
70
|
size: number;
|
|
34
71
|
type: ProfileType;
|
|
35
72
|
inputTokens: number;
|
|
36
73
|
outputTokens: number;
|
|
74
|
+
cacheReadTokens: number;
|
|
75
|
+
cacheWriteTokens: number;
|
|
37
76
|
totalTokens: number;
|
|
38
77
|
startTs: string | null;
|
|
39
78
|
endTs: string | null;
|
|
40
79
|
cwd: string | null;
|
|
80
|
+
model?: string | null;
|
|
41
81
|
}
|
|
42
82
|
|
|
43
83
|
interface UsageSessionEntry {
|
|
44
84
|
type: ProfileType;
|
|
45
85
|
inputTokens: number;
|
|
46
86
|
outputTokens: number;
|
|
87
|
+
cacheReadTokens: number;
|
|
88
|
+
cacheWriteTokens: number;
|
|
47
89
|
totalTokens: number;
|
|
48
90
|
startTs: string | null;
|
|
49
91
|
endTs: string | null;
|
|
50
92
|
cwd: string | null;
|
|
93
|
+
model?: string | null;
|
|
51
94
|
}
|
|
52
95
|
|
|
53
96
|
interface UsageStateFile {
|
|
54
97
|
version: number;
|
|
55
98
|
files: Record<string, UsageStateEntry>;
|
|
56
99
|
sessions?: Record<string, UsageSessionEntry>;
|
|
100
|
+
usageMtimeMs?: number;
|
|
101
|
+
usageSize?: number;
|
|
57
102
|
}
|
|
58
103
|
|
|
59
104
|
interface ProfileLogEntry {
|
|
@@ -82,19 +127,46 @@ interface ProfileResolveResult {
|
|
|
82
127
|
interface SessionStats {
|
|
83
128
|
inputTokens: number;
|
|
84
129
|
outputTokens: number;
|
|
130
|
+
cacheReadTokens: number;
|
|
131
|
+
cacheWriteTokens: number;
|
|
85
132
|
totalTokens: number;
|
|
86
133
|
startTs: string | null;
|
|
87
134
|
endTs: string | null;
|
|
88
135
|
cwd: string | null;
|
|
89
136
|
sessionId: string | null;
|
|
137
|
+
model: string | null;
|
|
90
138
|
}
|
|
91
139
|
|
|
92
140
|
interface UsageTotalsInput {
|
|
93
141
|
inputTokens: number | null;
|
|
94
142
|
outputTokens: number | null;
|
|
143
|
+
cacheReadTokens: number | null;
|
|
144
|
+
cacheWriteTokens: number | null;
|
|
95
145
|
totalTokens: number | null;
|
|
96
146
|
}
|
|
97
147
|
|
|
148
|
+
function resolveProfileForRecord(
|
|
149
|
+
config: Config,
|
|
150
|
+
type: string | null,
|
|
151
|
+
record: UsageRecord
|
|
152
|
+
): Profile | null {
|
|
153
|
+
if (record.profileKey && config.profiles && config.profiles[record.profileKey]) {
|
|
154
|
+
return config.profiles[record.profileKey];
|
|
155
|
+
}
|
|
156
|
+
if (record.profileName && config.profiles) {
|
|
157
|
+
const matches = Object.entries(config.profiles).find(([key, entry]) => {
|
|
158
|
+
const displayName = getProfileDisplayName(key, entry, type || undefined);
|
|
159
|
+
return (
|
|
160
|
+
displayName === record.profileName ||
|
|
161
|
+
entry.name === record.profileName ||
|
|
162
|
+
key === record.profileName
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
if (matches) return matches[1];
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
98
170
|
function resolveDefaultConfigDir(configPath: string | null): string {
|
|
99
171
|
if (configPath) return path.dirname(configPath);
|
|
100
172
|
return path.join(os.homedir(), ".config", "code-env");
|
|
@@ -141,40 +213,175 @@ export function formatTokenCount(value: number | null | undefined): string {
|
|
|
141
213
|
return `${(value / 1_000_000_000).toFixed(2)}B`;
|
|
142
214
|
}
|
|
143
215
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
216
|
+
function createUsageTotals(): UsageTotals {
|
|
217
|
+
return {
|
|
218
|
+
today: 0,
|
|
219
|
+
total: 0,
|
|
220
|
+
todayInput: 0,
|
|
221
|
+
totalInput: 0,
|
|
222
|
+
todayOutput: 0,
|
|
223
|
+
totalOutput: 0,
|
|
224
|
+
todayCacheRead: 0,
|
|
225
|
+
totalCacheRead: 0,
|
|
226
|
+
todayCacheWrite: 0,
|
|
227
|
+
totalCacheWrite: 0,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function createUsageCostTotals(): UsageCostTotals {
|
|
232
|
+
return { today: 0, total: 0, todayTokens: 0, totalTokens: 0 };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function toUsageNumber(value: number | null | undefined): number {
|
|
236
|
+
const num = Number(value ?? 0);
|
|
237
|
+
return Number.isFinite(num) ? num : 0;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function getTodayWindow(): { startMs: number; endMs: number } {
|
|
147
241
|
const todayStart = new Date();
|
|
148
242
|
todayStart.setHours(0, 0, 0, 0);
|
|
149
|
-
const
|
|
243
|
+
const startMs = todayStart.getTime();
|
|
150
244
|
const tomorrowStart = new Date(todayStart);
|
|
151
245
|
tomorrowStart.setDate(todayStart.getDate() + 1);
|
|
152
|
-
|
|
246
|
+
return { startMs, endMs: tomorrowStart.getTime() };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function isTimestampInWindow(
|
|
250
|
+
ts: string,
|
|
251
|
+
startMs: number,
|
|
252
|
+
endMs: number
|
|
253
|
+
): boolean {
|
|
254
|
+
if (!ts) return false;
|
|
255
|
+
const time = new Date(ts).getTime();
|
|
256
|
+
if (Number.isNaN(time)) return false;
|
|
257
|
+
return time >= startMs && time < endMs;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function buildUsageTotals(records: UsageRecord[]): UsageTotalsIndex {
|
|
261
|
+
const byKey = new Map<string, UsageTotals>();
|
|
262
|
+
const byName = new Map<string, UsageTotals>();
|
|
263
|
+
const { startMs, endMs } = getTodayWindow();
|
|
153
264
|
|
|
154
265
|
const isToday = (ts: string) => {
|
|
155
|
-
|
|
156
|
-
const time = new Date(ts).getTime();
|
|
157
|
-
if (Number.isNaN(time)) return false;
|
|
158
|
-
return time >= todayStartMs && time < tomorrowStartMs;
|
|
266
|
+
return isTimestampInWindow(ts, startMs, endMs);
|
|
159
267
|
};
|
|
160
268
|
|
|
161
|
-
const addTotals = (
|
|
269
|
+
const addTotals = (
|
|
270
|
+
map: Map<string, UsageTotals>,
|
|
271
|
+
key: string,
|
|
272
|
+
amounts: {
|
|
273
|
+
total: number;
|
|
274
|
+
input: number;
|
|
275
|
+
output: number;
|
|
276
|
+
cacheRead: number;
|
|
277
|
+
cacheWrite: number;
|
|
278
|
+
},
|
|
279
|
+
ts: string
|
|
280
|
+
) => {
|
|
162
281
|
if (!key) return;
|
|
163
|
-
const current = map.get(key) ||
|
|
164
|
-
current.total +=
|
|
165
|
-
|
|
282
|
+
const current = map.get(key) || createUsageTotals();
|
|
283
|
+
current.total += amounts.total;
|
|
284
|
+
current.totalInput += amounts.input;
|
|
285
|
+
current.totalOutput += amounts.output;
|
|
286
|
+
current.totalCacheRead += amounts.cacheRead;
|
|
287
|
+
current.totalCacheWrite += amounts.cacheWrite;
|
|
288
|
+
if (isToday(ts)) {
|
|
289
|
+
current.today += amounts.total;
|
|
290
|
+
current.todayInput += amounts.input;
|
|
291
|
+
current.todayOutput += amounts.output;
|
|
292
|
+
current.todayCacheRead += amounts.cacheRead;
|
|
293
|
+
current.todayCacheWrite += amounts.cacheWrite;
|
|
294
|
+
}
|
|
166
295
|
map.set(key, current);
|
|
167
296
|
};
|
|
168
297
|
|
|
169
298
|
for (const record of records) {
|
|
170
299
|
const type = normalizeUsageType(record.type) || "";
|
|
171
|
-
const
|
|
300
|
+
const input = toUsageNumber(record.inputTokens);
|
|
301
|
+
const output = toUsageNumber(record.outputTokens);
|
|
302
|
+
const cacheRead = toUsageNumber(record.cacheReadTokens);
|
|
303
|
+
const cacheWrite = toUsageNumber(record.cacheWriteTokens);
|
|
304
|
+
const computedTotal = input + output + cacheRead + cacheWrite;
|
|
305
|
+
const rawTotal = Number(record.totalTokens ?? computedTotal);
|
|
306
|
+
const total = Number.isFinite(rawTotal)
|
|
307
|
+
? Math.max(rawTotal, computedTotal)
|
|
308
|
+
: computedTotal;
|
|
172
309
|
if (!Number.isFinite(total)) continue;
|
|
173
310
|
if (record.profileKey) {
|
|
174
|
-
addTotals(
|
|
311
|
+
addTotals(
|
|
312
|
+
byKey,
|
|
313
|
+
`${type}||${record.profileKey}`,
|
|
314
|
+
{ total, input, output, cacheRead, cacheWrite },
|
|
315
|
+
record.ts
|
|
316
|
+
);
|
|
175
317
|
}
|
|
176
318
|
if (record.profileName) {
|
|
177
|
-
addTotals(
|
|
319
|
+
addTotals(
|
|
320
|
+
byName,
|
|
321
|
+
`${type}||${record.profileName}`,
|
|
322
|
+
{ total, input, output, cacheRead, cacheWrite },
|
|
323
|
+
record.ts
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return { byKey, byName };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function buildUsageCostIndex(records: UsageRecord[], config: Config): UsageCostIndex {
|
|
332
|
+
const byKey = new Map<string, UsageCostTotals>();
|
|
333
|
+
const byName = new Map<string, UsageCostTotals>();
|
|
334
|
+
const { startMs, endMs } = getTodayWindow();
|
|
335
|
+
|
|
336
|
+
const addCost = (
|
|
337
|
+
map: Map<string, UsageCostTotals>,
|
|
338
|
+
key: string,
|
|
339
|
+
cost: number,
|
|
340
|
+
tokens: number,
|
|
341
|
+
ts: string
|
|
342
|
+
) => {
|
|
343
|
+
if (!key) return;
|
|
344
|
+
const current = map.get(key) || createUsageCostTotals();
|
|
345
|
+
current.total += cost;
|
|
346
|
+
current.totalTokens += tokens;
|
|
347
|
+
if (isTimestampInWindow(ts, startMs, endMs)) {
|
|
348
|
+
current.today += cost;
|
|
349
|
+
current.todayTokens += tokens;
|
|
350
|
+
}
|
|
351
|
+
map.set(key, current);
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
for (const record of records) {
|
|
355
|
+
const model = normalizeModelValue(record.model);
|
|
356
|
+
if (!model) continue;
|
|
357
|
+
const type = normalizeUsageType(record.type) || "";
|
|
358
|
+
const profile =
|
|
359
|
+
record.profileKey && config.profiles ? config.profiles[record.profileKey] : null;
|
|
360
|
+
const pricing = resolvePricingForProfile(config, profile || null, model);
|
|
361
|
+
if (!pricing) continue;
|
|
362
|
+
const cost = calculateUsageCost(
|
|
363
|
+
{
|
|
364
|
+
totalTokens: record.totalTokens,
|
|
365
|
+
inputTokens: record.inputTokens,
|
|
366
|
+
outputTokens: record.outputTokens,
|
|
367
|
+
cacheReadTokens: record.cacheReadTokens,
|
|
368
|
+
cacheWriteTokens: record.cacheWriteTokens,
|
|
369
|
+
},
|
|
370
|
+
pricing
|
|
371
|
+
);
|
|
372
|
+
if (cost === null || !Number.isFinite(cost)) continue;
|
|
373
|
+
const billedTokens =
|
|
374
|
+
toUsageNumber(record.inputTokens) +
|
|
375
|
+
toUsageNumber(record.outputTokens) +
|
|
376
|
+
toUsageNumber(record.cacheReadTokens) +
|
|
377
|
+
toUsageNumber(record.cacheWriteTokens);
|
|
378
|
+
const billedTotal =
|
|
379
|
+
billedTokens > 0 ? billedTokens : toUsageNumber(record.totalTokens);
|
|
380
|
+
if (record.profileKey) {
|
|
381
|
+
addCost(byKey, `${type}||${record.profileKey}`, cost, billedTotal, record.ts);
|
|
382
|
+
}
|
|
383
|
+
if (record.profileName) {
|
|
384
|
+
addCost(byName, `${type}||${record.profileName}`, cost, billedTotal, record.ts);
|
|
178
385
|
}
|
|
179
386
|
}
|
|
180
387
|
|
|
@@ -189,6 +396,12 @@ function normalizeUsageType(type: string | null | undefined): string | null {
|
|
|
189
396
|
return trimmed ? trimmed : null;
|
|
190
397
|
}
|
|
191
398
|
|
|
399
|
+
function normalizeModelValue(value: unknown): string | null {
|
|
400
|
+
if (typeof value !== "string") return null;
|
|
401
|
+
const trimmed = value.trim();
|
|
402
|
+
return trimmed ? trimmed : null;
|
|
403
|
+
}
|
|
404
|
+
|
|
192
405
|
function buildSessionKey(type: ProfileType | null, sessionId: string): string {
|
|
193
406
|
const normalized = normalizeUsageType(type || "");
|
|
194
407
|
return normalized ? `${normalized}::${sessionId}` : sessionId;
|
|
@@ -226,6 +439,72 @@ export function readUsageTotalsIndex(
|
|
|
226
439
|
return buildUsageTotals(records);
|
|
227
440
|
}
|
|
228
441
|
|
|
442
|
+
export function readUsageCostIndex(
|
|
443
|
+
config: Config,
|
|
444
|
+
configPath: string | null,
|
|
445
|
+
syncUsage: boolean
|
|
446
|
+
): UsageCostIndex | null {
|
|
447
|
+
const usagePath = getUsagePath(config, configPath);
|
|
448
|
+
if (!usagePath) return null;
|
|
449
|
+
if (syncUsage) {
|
|
450
|
+
syncUsageFromSessions(config, configPath, usagePath);
|
|
451
|
+
}
|
|
452
|
+
const records = readUsageRecords(usagePath);
|
|
453
|
+
if (records.length === 0) return null;
|
|
454
|
+
const costs = buildUsageCostIndex(records, config);
|
|
455
|
+
if (costs.byKey.size === 0 && costs.byName.size === 0) return null;
|
|
456
|
+
return costs;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export function readUsageSessionCost(
|
|
460
|
+
config: Config,
|
|
461
|
+
configPath: string | null,
|
|
462
|
+
type: string | null,
|
|
463
|
+
sessionId: string | null,
|
|
464
|
+
syncUsage: boolean
|
|
465
|
+
): number | null {
|
|
466
|
+
if (!sessionId) return null;
|
|
467
|
+
const usagePath = getUsagePath(config, configPath);
|
|
468
|
+
if (!usagePath) return null;
|
|
469
|
+
if (syncUsage) {
|
|
470
|
+
syncUsageFromSessions(config, configPath, usagePath);
|
|
471
|
+
}
|
|
472
|
+
const records = readUsageRecords(usagePath);
|
|
473
|
+
if (records.length === 0) return null;
|
|
474
|
+
const normalizedType = normalizeUsageType(type);
|
|
475
|
+
let total = 0;
|
|
476
|
+
let hasCost = false;
|
|
477
|
+
for (const record of records) {
|
|
478
|
+
if (!record.sessionId) continue;
|
|
479
|
+
if (record.sessionId !== sessionId) continue;
|
|
480
|
+
if (
|
|
481
|
+
normalizedType &&
|
|
482
|
+
normalizeUsageType(record.type) !== normalizedType
|
|
483
|
+
) {
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
const model = normalizeModelValue(record.model);
|
|
487
|
+
if (!model) continue;
|
|
488
|
+
const profile = resolveProfileForRecord(config, normalizedType, record);
|
|
489
|
+
const pricing = resolvePricingForProfile(config, profile, model);
|
|
490
|
+
if (!pricing) continue;
|
|
491
|
+
const cost = calculateUsageCost(
|
|
492
|
+
{
|
|
493
|
+
totalTokens: record.totalTokens,
|
|
494
|
+
inputTokens: record.inputTokens,
|
|
495
|
+
outputTokens: record.outputTokens,
|
|
496
|
+
cacheReadTokens: record.cacheReadTokens,
|
|
497
|
+
cacheWriteTokens: record.cacheWriteTokens,
|
|
498
|
+
},
|
|
499
|
+
pricing
|
|
500
|
+
);
|
|
501
|
+
if (cost === null || !Number.isFinite(cost)) continue;
|
|
502
|
+
total += cost;
|
|
503
|
+
hasCost = true;
|
|
504
|
+
}
|
|
505
|
+
return hasCost ? total : null;
|
|
506
|
+
}
|
|
507
|
+
|
|
229
508
|
export function resolveUsageTotalsForProfile(
|
|
230
509
|
totals: UsageTotalsIndex,
|
|
231
510
|
type: string | null,
|
|
@@ -241,6 +520,21 @@ export function resolveUsageTotalsForProfile(
|
|
|
241
520
|
);
|
|
242
521
|
}
|
|
243
522
|
|
|
523
|
+
export function resolveUsageCostForProfile(
|
|
524
|
+
costs: UsageCostIndex,
|
|
525
|
+
type: string | null,
|
|
526
|
+
profileKey: string | null,
|
|
527
|
+
profileName: string | null
|
|
528
|
+
): UsageCostTotals | null {
|
|
529
|
+
const keyLookup = buildUsageLookupKey(type, profileKey);
|
|
530
|
+
const nameLookup = buildUsageLookupKey(type, profileName);
|
|
531
|
+
return (
|
|
532
|
+
(keyLookup && costs.byKey.get(keyLookup)) ||
|
|
533
|
+
(nameLookup && costs.byName.get(nameLookup)) ||
|
|
534
|
+
null
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
|
|
244
538
|
export function syncUsageFromStatuslineInput(
|
|
245
539
|
config: Config,
|
|
246
540
|
configPath: string | null,
|
|
@@ -249,19 +543,28 @@ export function syncUsageFromStatuslineInput(
|
|
|
249
543
|
profileName: string | null,
|
|
250
544
|
sessionId: string | null,
|
|
251
545
|
totals: UsageTotalsInput | null,
|
|
252
|
-
cwd: string | null
|
|
546
|
+
cwd: string | null,
|
|
547
|
+
model: string | null
|
|
253
548
|
): void {
|
|
254
549
|
if (!sessionId) return;
|
|
255
550
|
if (!totals) return;
|
|
256
551
|
if (!profileKey && !profileName) return;
|
|
552
|
+
const resolvedModel = normalizeModelValue(model);
|
|
553
|
+
if (!resolvedModel) return;
|
|
257
554
|
const normalizedType = normalizeType(type || "");
|
|
258
555
|
if (!normalizedType) return;
|
|
259
556
|
const usagePath = getUsagePath(config, configPath);
|
|
260
557
|
if (!usagePath) return;
|
|
261
558
|
const inputTokens = toFiniteNumber(totals.inputTokens) ?? 0;
|
|
262
559
|
const outputTokens = toFiniteNumber(totals.outputTokens) ?? 0;
|
|
560
|
+
const cacheReadTokens = toFiniteNumber(totals.cacheReadTokens) ?? 0;
|
|
561
|
+
const cacheWriteTokens = toFiniteNumber(totals.cacheWriteTokens) ?? 0;
|
|
263
562
|
const totalTokens =
|
|
264
|
-
toFiniteNumber(totals.totalTokens) ??
|
|
563
|
+
toFiniteNumber(totals.totalTokens) ??
|
|
564
|
+
inputTokens +
|
|
565
|
+
outputTokens +
|
|
566
|
+
cacheReadTokens +
|
|
567
|
+
cacheWriteTokens;
|
|
265
568
|
if (!Number.isFinite(totalTokens)) return;
|
|
266
569
|
|
|
267
570
|
const statePath = getUsageStatePath(usagePath, config);
|
|
@@ -273,17 +576,37 @@ export function syncUsageFromStatuslineInput(
|
|
|
273
576
|
const sessions = state.sessions || {};
|
|
274
577
|
const key = buildSessionKey(normalizedType, sessionId);
|
|
275
578
|
const prev = sessions[key];
|
|
276
|
-
const prevInput = prev ? prev.inputTokens : 0;
|
|
277
|
-
const prevOutput = prev ? prev.outputTokens : 0;
|
|
278
|
-
const
|
|
579
|
+
const prevInput = prev ? toUsageNumber(prev.inputTokens) : 0;
|
|
580
|
+
const prevOutput = prev ? toUsageNumber(prev.outputTokens) : 0;
|
|
581
|
+
const prevCacheRead = prev ? toUsageNumber(prev.cacheReadTokens) : 0;
|
|
582
|
+
const prevCacheWrite = prev ? toUsageNumber(prev.cacheWriteTokens) : 0;
|
|
583
|
+
const prevTotal = prev ? toUsageNumber(prev.totalTokens) : 0;
|
|
279
584
|
|
|
280
585
|
let deltaInput = inputTokens - prevInput;
|
|
281
586
|
let deltaOutput = outputTokens - prevOutput;
|
|
587
|
+
let deltaCacheRead = cacheReadTokens - prevCacheRead;
|
|
588
|
+
let deltaCacheWrite = cacheWriteTokens - prevCacheWrite;
|
|
282
589
|
let deltaTotal = totalTokens - prevTotal;
|
|
283
|
-
if (deltaTotal < 0
|
|
590
|
+
if (deltaTotal < 0) {
|
|
591
|
+
// Session reset: treat current totals as fresh usage.
|
|
284
592
|
deltaInput = inputTokens;
|
|
285
593
|
deltaOutput = outputTokens;
|
|
594
|
+
deltaCacheRead = cacheReadTokens;
|
|
595
|
+
deltaCacheWrite = cacheWriteTokens;
|
|
286
596
|
deltaTotal = totalTokens;
|
|
597
|
+
} else {
|
|
598
|
+
// Clamp negatives caused by reclassification (e.g. cache splits).
|
|
599
|
+
if (deltaInput < 0) deltaInput = 0;
|
|
600
|
+
if (deltaOutput < 0) deltaOutput = 0;
|
|
601
|
+
if (deltaCacheRead < 0) deltaCacheRead = 0;
|
|
602
|
+
if (deltaCacheWrite < 0) deltaCacheWrite = 0;
|
|
603
|
+
const breakdownTotal =
|
|
604
|
+
deltaInput + deltaOutput + deltaCacheRead + deltaCacheWrite;
|
|
605
|
+
if (deltaTotal === 0 && breakdownTotal === 0) {
|
|
606
|
+
deltaTotal = 0;
|
|
607
|
+
} else if (breakdownTotal > deltaTotal) {
|
|
608
|
+
deltaTotal = breakdownTotal;
|
|
609
|
+
}
|
|
287
610
|
}
|
|
288
611
|
|
|
289
612
|
if (deltaTotal > 0) {
|
|
@@ -292,8 +615,12 @@ export function syncUsageFromStatuslineInput(
|
|
|
292
615
|
type: normalizedType,
|
|
293
616
|
profileKey: profileKey || null,
|
|
294
617
|
profileName: profileName || null,
|
|
618
|
+
model: resolvedModel,
|
|
619
|
+
sessionId,
|
|
295
620
|
inputTokens: deltaInput,
|
|
296
621
|
outputTokens: deltaOutput,
|
|
622
|
+
cacheReadTokens: deltaCacheRead,
|
|
623
|
+
cacheWriteTokens: deltaCacheWrite,
|
|
297
624
|
totalTokens: deltaTotal,
|
|
298
625
|
};
|
|
299
626
|
appendUsageRecord(usagePath, record);
|
|
@@ -304,12 +631,16 @@ export function syncUsageFromStatuslineInput(
|
|
|
304
631
|
type: normalizedType,
|
|
305
632
|
inputTokens,
|
|
306
633
|
outputTokens,
|
|
634
|
+
cacheReadTokens,
|
|
635
|
+
cacheWriteTokens,
|
|
307
636
|
totalTokens,
|
|
308
637
|
startTs: prev ? prev.startTs : now,
|
|
309
638
|
endTs: now,
|
|
310
639
|
cwd: cwd || (prev ? prev.cwd : null),
|
|
640
|
+
model: resolvedModel,
|
|
311
641
|
};
|
|
312
642
|
state.sessions = sessions;
|
|
643
|
+
updateUsageStateMetadata(state, usagePath);
|
|
313
644
|
writeUsageState(statePath, state);
|
|
314
645
|
} finally {
|
|
315
646
|
releaseLock(lockPath, lockFd);
|
|
@@ -540,7 +871,15 @@ function readUsageState(statePath: string): UsageStateFile {
|
|
|
540
871
|
parsed.files && typeof parsed.files === "object" ? parsed.files : {};
|
|
541
872
|
const sessions =
|
|
542
873
|
parsed.sessions && typeof parsed.sessions === "object" ? parsed.sessions : {};
|
|
543
|
-
|
|
874
|
+
const usageMtimeMs = Number(parsed.usageMtimeMs);
|
|
875
|
+
const usageSize = Number(parsed.usageSize);
|
|
876
|
+
return {
|
|
877
|
+
version: 1,
|
|
878
|
+
files,
|
|
879
|
+
sessions,
|
|
880
|
+
usageMtimeMs: Number.isFinite(usageMtimeMs) ? usageMtimeMs : undefined,
|
|
881
|
+
usageSize: Number.isFinite(usageSize) ? usageSize : undefined,
|
|
882
|
+
};
|
|
544
883
|
} catch {
|
|
545
884
|
return { version: 1, files: {}, sessions: {} };
|
|
546
885
|
}
|
|
@@ -551,7 +890,91 @@ function writeUsageState(statePath: string, state: UsageStateFile) {
|
|
|
551
890
|
if (!fs.existsSync(dir)) {
|
|
552
891
|
fs.mkdirSync(dir, { recursive: true });
|
|
553
892
|
}
|
|
554
|
-
|
|
893
|
+
const payload = `${JSON.stringify(state, null, 2)}\n`;
|
|
894
|
+
const tmpPath = `${statePath}.tmp`;
|
|
895
|
+
try {
|
|
896
|
+
fs.writeFileSync(tmpPath, payload, "utf8");
|
|
897
|
+
fs.renameSync(tmpPath, statePath);
|
|
898
|
+
} catch {
|
|
899
|
+
try {
|
|
900
|
+
if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
|
|
901
|
+
} catch {
|
|
902
|
+
// ignore cleanup failures
|
|
903
|
+
}
|
|
904
|
+
fs.writeFileSync(statePath, payload, "utf8");
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function addSiblingBackupPaths(targets: Set<string>, filePath: string | null) {
|
|
909
|
+
if (!filePath) return;
|
|
910
|
+
const dir = path.dirname(filePath);
|
|
911
|
+
let entries: fs.Dirent[] = [];
|
|
912
|
+
try {
|
|
913
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
914
|
+
} catch {
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
const base = path.basename(filePath);
|
|
918
|
+
for (const entry of entries) {
|
|
919
|
+
if (!entry.isFile()) continue;
|
|
920
|
+
if (entry.name === base) continue;
|
|
921
|
+
if (entry.name.startsWith(`${base}.`)) {
|
|
922
|
+
targets.add(path.join(dir, entry.name));
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
export function clearUsageHistory(
|
|
928
|
+
config: Config,
|
|
929
|
+
configPath: string | null
|
|
930
|
+
): UsageCleanupResult {
|
|
931
|
+
const targets = new Set<string>();
|
|
932
|
+
const usagePath = getUsagePath(config, configPath);
|
|
933
|
+
if (usagePath) {
|
|
934
|
+
targets.add(usagePath);
|
|
935
|
+
addSiblingBackupPaths(targets, usagePath);
|
|
936
|
+
}
|
|
937
|
+
const statePath = usagePath ? getUsageStatePath(usagePath, config) : null;
|
|
938
|
+
if (statePath) {
|
|
939
|
+
targets.add(statePath);
|
|
940
|
+
targets.add(`${statePath}.lock`);
|
|
941
|
+
addSiblingBackupPaths(targets, statePath);
|
|
942
|
+
}
|
|
943
|
+
const profileLogPath = getProfileLogPath(config, configPath);
|
|
944
|
+
if (profileLogPath) {
|
|
945
|
+
targets.add(profileLogPath);
|
|
946
|
+
addSiblingBackupPaths(targets, profileLogPath);
|
|
947
|
+
}
|
|
948
|
+
const debugPath = getStatuslineDebugPath(configPath);
|
|
949
|
+
if (debugPath) {
|
|
950
|
+
targets.add(debugPath);
|
|
951
|
+
addSiblingBackupPaths(targets, debugPath);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const removed: string[] = [];
|
|
955
|
+
const missing: string[] = [];
|
|
956
|
+
const failed: UsageCleanupFailure[] = [];
|
|
957
|
+
|
|
958
|
+
for (const target of targets) {
|
|
959
|
+
if (!target) continue;
|
|
960
|
+
if (!fs.existsSync(target)) {
|
|
961
|
+
missing.push(target);
|
|
962
|
+
continue;
|
|
963
|
+
}
|
|
964
|
+
try {
|
|
965
|
+
const stat = fs.statSync(target);
|
|
966
|
+
if (!stat.isFile()) continue;
|
|
967
|
+
fs.unlinkSync(target);
|
|
968
|
+
removed.push(target);
|
|
969
|
+
} catch (err) {
|
|
970
|
+
failed.push({
|
|
971
|
+
path: target,
|
|
972
|
+
error: err instanceof Error ? err.message : String(err),
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
return { removed, missing, failed };
|
|
555
978
|
}
|
|
556
979
|
|
|
557
980
|
function collectSessionFiles(root: string | null): string[] {
|
|
@@ -595,19 +1018,82 @@ function updateMinMaxTs(
|
|
|
595
1018
|
}
|
|
596
1019
|
}
|
|
597
1020
|
|
|
1021
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
1022
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function coerceModelFromValue(value: unknown): string | null {
|
|
1026
|
+
if (typeof value === "string") return normalizeModelValue(value);
|
|
1027
|
+
if (!isPlainObject(value)) return null;
|
|
1028
|
+
return (
|
|
1029
|
+
normalizeModelValue(value.displayName) ||
|
|
1030
|
+
normalizeModelValue(value.display_name) ||
|
|
1031
|
+
normalizeModelValue(value.name) ||
|
|
1032
|
+
normalizeModelValue(value.id) ||
|
|
1033
|
+
normalizeModelValue(value.model) ||
|
|
1034
|
+
normalizeModelValue(value.model_name) ||
|
|
1035
|
+
normalizeModelValue(value.model_id) ||
|
|
1036
|
+
normalizeModelValue(value.modelId) ||
|
|
1037
|
+
null
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
function pickModelFromObject(record: Record<string, unknown>): string | null {
|
|
1042
|
+
return (
|
|
1043
|
+
coerceModelFromValue(record.model) ||
|
|
1044
|
+
normalizeModelValue(record.model_name) ||
|
|
1045
|
+
normalizeModelValue(record.modelName) ||
|
|
1046
|
+
normalizeModelValue(record.model_id) ||
|
|
1047
|
+
normalizeModelValue(record.modelId) ||
|
|
1048
|
+
normalizeModelValue(record.model_display_name) ||
|
|
1049
|
+
normalizeModelValue(record.modelDisplayName) ||
|
|
1050
|
+
null
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function extractModelFromRecord(record: Record<string, unknown>): string | null {
|
|
1055
|
+
const direct = pickModelFromObject(record);
|
|
1056
|
+
if (direct) return direct;
|
|
1057
|
+
const message = isPlainObject(record.message)
|
|
1058
|
+
? (record.message as Record<string, unknown>)
|
|
1059
|
+
: null;
|
|
1060
|
+
if (message) {
|
|
1061
|
+
const fromMessage = pickModelFromObject(message);
|
|
1062
|
+
if (fromMessage) return fromMessage;
|
|
1063
|
+
}
|
|
1064
|
+
const payload = isPlainObject(record.payload)
|
|
1065
|
+
? (record.payload as Record<string, unknown>)
|
|
1066
|
+
: null;
|
|
1067
|
+
if (payload) {
|
|
1068
|
+
const fromPayload = pickModelFromObject(payload);
|
|
1069
|
+
if (fromPayload) return fromPayload;
|
|
1070
|
+
const info = isPlainObject(payload.info)
|
|
1071
|
+
? (payload.info as Record<string, unknown>)
|
|
1072
|
+
: null;
|
|
1073
|
+
if (info) {
|
|
1074
|
+
const fromInfo = pickModelFromObject(info);
|
|
1075
|
+
if (fromInfo) return fromInfo;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
return null;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
598
1081
|
function parseCodexSessionFile(filePath: string): SessionStats {
|
|
599
1082
|
const raw = fs.readFileSync(filePath, "utf8");
|
|
600
1083
|
const lines = raw.split(/\r?\n/);
|
|
601
1084
|
let maxTotal = 0;
|
|
602
1085
|
let maxInput = 0;
|
|
603
1086
|
let maxOutput = 0;
|
|
1087
|
+
let maxCachedInput = 0;
|
|
604
1088
|
let hasTotal = false;
|
|
605
1089
|
let sumLast = 0;
|
|
606
1090
|
let sumLastInput = 0;
|
|
607
1091
|
let sumLastOutput = 0;
|
|
1092
|
+
let sumLastCachedInput = 0;
|
|
608
1093
|
const tsRange = { start: null as string | null, end: null as string | null };
|
|
609
1094
|
let cwd: string | null = null;
|
|
610
1095
|
let sessionId: string | null = null;
|
|
1096
|
+
let model: string | null = null;
|
|
611
1097
|
|
|
612
1098
|
for (const line of lines) {
|
|
613
1099
|
const trimmed = line.trim();
|
|
@@ -615,6 +1101,10 @@ function parseCodexSessionFile(filePath: string): SessionStats {
|
|
|
615
1101
|
try {
|
|
616
1102
|
const parsed = JSON.parse(trimmed);
|
|
617
1103
|
if (!parsed || typeof parsed !== "object") continue;
|
|
1104
|
+
if (!model) {
|
|
1105
|
+
const candidate = extractModelFromRecord(parsed as Record<string, unknown>);
|
|
1106
|
+
if (candidate) model = candidate;
|
|
1107
|
+
}
|
|
618
1108
|
if (parsed.timestamp) updateMinMaxTs(tsRange, String(parsed.timestamp));
|
|
619
1109
|
if (!cwd && parsed.type === "session_meta") {
|
|
620
1110
|
const payload = parsed.payload || {};
|
|
@@ -639,19 +1129,25 @@ function parseCodexSessionFile(filePath: string): SessionStats {
|
|
|
639
1129
|
if (totalTokens > maxTotal) maxTotal = totalTokens;
|
|
640
1130
|
const totalInput = Number(totalUsage.input_tokens);
|
|
641
1131
|
const totalOutput = Number(totalUsage.output_tokens);
|
|
1132
|
+
const totalCached = Number(totalUsage.cached_input_tokens);
|
|
642
1133
|
if (Number.isFinite(totalInput) && totalInput > maxInput) {
|
|
643
1134
|
maxInput = totalInput;
|
|
644
1135
|
}
|
|
645
1136
|
if (Number.isFinite(totalOutput) && totalOutput > maxOutput) {
|
|
646
1137
|
maxOutput = totalOutput;
|
|
647
1138
|
}
|
|
1139
|
+
if (Number.isFinite(totalCached) && totalCached > maxCachedInput) {
|
|
1140
|
+
maxCachedInput = totalCached;
|
|
1141
|
+
}
|
|
648
1142
|
} else {
|
|
649
1143
|
const lastTokens = Number(lastUsage.total_tokens);
|
|
650
1144
|
if (Number.isFinite(lastTokens)) sumLast += lastTokens;
|
|
651
1145
|
const lastInput = Number(lastUsage.input_tokens);
|
|
652
1146
|
const lastOutput = Number(lastUsage.output_tokens);
|
|
1147
|
+
const lastCached = Number(lastUsage.cached_input_tokens);
|
|
653
1148
|
if (Number.isFinite(lastInput)) sumLastInput += lastInput;
|
|
654
1149
|
if (Number.isFinite(lastOutput)) sumLastOutput += lastOutput;
|
|
1150
|
+
if (Number.isFinite(lastCached)) sumLastCachedInput += lastCached;
|
|
655
1151
|
}
|
|
656
1152
|
} catch {
|
|
657
1153
|
// ignore invalid lines
|
|
@@ -662,16 +1158,26 @@ function parseCodexSessionFile(filePath: string): SessionStats {
|
|
|
662
1158
|
maxTotal = sumLast;
|
|
663
1159
|
maxInput = sumLastInput;
|
|
664
1160
|
maxOutput = sumLastOutput;
|
|
1161
|
+
maxCachedInput = sumLastCachedInput;
|
|
665
1162
|
}
|
|
666
1163
|
|
|
1164
|
+
const cacheReadTokens = Math.max(0, maxCachedInput);
|
|
1165
|
+
const inputTokens =
|
|
1166
|
+
cacheReadTokens > 0 ? Math.max(0, maxInput - cacheReadTokens) : maxInput;
|
|
1167
|
+
const computedTotal = inputTokens + maxOutput + cacheReadTokens;
|
|
1168
|
+
const totalTokens = Math.max(maxTotal, computedTotal);
|
|
1169
|
+
|
|
667
1170
|
return {
|
|
668
|
-
inputTokens
|
|
1171
|
+
inputTokens,
|
|
669
1172
|
outputTokens: maxOutput,
|
|
670
|
-
|
|
1173
|
+
cacheReadTokens,
|
|
1174
|
+
cacheWriteTokens: 0,
|
|
1175
|
+
totalTokens,
|
|
671
1176
|
startTs: tsRange.start,
|
|
672
1177
|
endTs: tsRange.end,
|
|
673
1178
|
cwd,
|
|
674
1179
|
sessionId,
|
|
1180
|
+
model,
|
|
675
1181
|
};
|
|
676
1182
|
}
|
|
677
1183
|
|
|
@@ -681,9 +1187,12 @@ function parseClaudeSessionFile(filePath: string): SessionStats {
|
|
|
681
1187
|
let totalTokens = 0;
|
|
682
1188
|
let inputTokens = 0;
|
|
683
1189
|
let outputTokens = 0;
|
|
1190
|
+
let cacheReadTokens = 0;
|
|
1191
|
+
let cacheWriteTokens = 0;
|
|
684
1192
|
const tsRange = { start: null as string | null, end: null as string | null };
|
|
685
1193
|
let cwd: string | null = null;
|
|
686
1194
|
let sessionId: string | null = null;
|
|
1195
|
+
let model: string | null = null;
|
|
687
1196
|
|
|
688
1197
|
for (const line of lines) {
|
|
689
1198
|
const trimmed = line.trim();
|
|
@@ -691,6 +1200,10 @@ function parseClaudeSessionFile(filePath: string): SessionStats {
|
|
|
691
1200
|
try {
|
|
692
1201
|
const parsed = JSON.parse(trimmed);
|
|
693
1202
|
if (!parsed || typeof parsed !== "object") continue;
|
|
1203
|
+
if (!model) {
|
|
1204
|
+
const candidate = extractModelFromRecord(parsed as Record<string, unknown>);
|
|
1205
|
+
if (candidate) model = candidate;
|
|
1206
|
+
}
|
|
694
1207
|
if (parsed.timestamp) updateMinMaxTs(tsRange, String(parsed.timestamp));
|
|
695
1208
|
if (!cwd && parsed.cwd) cwd = String(parsed.cwd);
|
|
696
1209
|
if (!sessionId && parsed.sessionId) {
|
|
@@ -705,6 +1218,8 @@ function parseClaudeSessionFile(filePath: string): SessionStats {
|
|
|
705
1218
|
const cacheRead = Number(usage.cache_read_input_tokens ?? 0);
|
|
706
1219
|
if (Number.isFinite(input)) inputTokens += input;
|
|
707
1220
|
if (Number.isFinite(output)) outputTokens += output;
|
|
1221
|
+
if (Number.isFinite(cacheCreate)) cacheWriteTokens += cacheCreate;
|
|
1222
|
+
if (Number.isFinite(cacheRead)) cacheReadTokens += cacheRead;
|
|
708
1223
|
totalTokens +=
|
|
709
1224
|
(Number.isFinite(input) ? input : 0) +
|
|
710
1225
|
(Number.isFinite(output) ? output : 0) +
|
|
@@ -718,11 +1233,14 @@ function parseClaudeSessionFile(filePath: string): SessionStats {
|
|
|
718
1233
|
return {
|
|
719
1234
|
inputTokens,
|
|
720
1235
|
outputTokens,
|
|
1236
|
+
cacheReadTokens,
|
|
1237
|
+
cacheWriteTokens,
|
|
721
1238
|
totalTokens,
|
|
722
1239
|
startTs: tsRange.start,
|
|
723
1240
|
endTs: tsRange.end,
|
|
724
1241
|
cwd,
|
|
725
1242
|
sessionId,
|
|
1243
|
+
model,
|
|
726
1244
|
};
|
|
727
1245
|
}
|
|
728
1246
|
|
|
@@ -806,6 +1324,91 @@ function releaseLock(lockPath: string, fd: number | null) {
|
|
|
806
1324
|
}
|
|
807
1325
|
}
|
|
808
1326
|
|
|
1327
|
+
function readUsageFileStat(usagePath: string): fs.Stats | null {
|
|
1328
|
+
if (!usagePath || !fs.existsSync(usagePath)) return null;
|
|
1329
|
+
try {
|
|
1330
|
+
return fs.statSync(usagePath);
|
|
1331
|
+
} catch {
|
|
1332
|
+
return null;
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
function buildUsageRecordKey(record: UsageRecord): string {
|
|
1337
|
+
return JSON.stringify([
|
|
1338
|
+
record.ts,
|
|
1339
|
+
record.type,
|
|
1340
|
+
record.profileKey ?? null,
|
|
1341
|
+
record.profileName ?? null,
|
|
1342
|
+
record.model ?? null,
|
|
1343
|
+
record.sessionId ?? null,
|
|
1344
|
+
toUsageNumber(record.inputTokens),
|
|
1345
|
+
toUsageNumber(record.outputTokens),
|
|
1346
|
+
toUsageNumber(record.cacheReadTokens),
|
|
1347
|
+
toUsageNumber(record.cacheWriteTokens),
|
|
1348
|
+
toUsageNumber(record.totalTokens),
|
|
1349
|
+
]);
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
function buildUsageSessionsFromRecords(
|
|
1353
|
+
records: UsageRecord[]
|
|
1354
|
+
): Record<string, UsageSessionEntry> {
|
|
1355
|
+
const sessions: Record<string, UsageSessionEntry> = {};
|
|
1356
|
+
const seen = new Set<string>();
|
|
1357
|
+
for (const record of records) {
|
|
1358
|
+
if (!record.sessionId) continue;
|
|
1359
|
+
const normalizedType = normalizeUsageType(record.type);
|
|
1360
|
+
if (!normalizedType) continue;
|
|
1361
|
+
const recordKey = buildUsageRecordKey(record);
|
|
1362
|
+
if (seen.has(recordKey)) continue;
|
|
1363
|
+
seen.add(recordKey);
|
|
1364
|
+
|
|
1365
|
+
const sessionKey = buildSessionKey(
|
|
1366
|
+
normalizedType as ProfileType,
|
|
1367
|
+
record.sessionId
|
|
1368
|
+
);
|
|
1369
|
+
let entry = sessions[sessionKey];
|
|
1370
|
+
if (!entry) {
|
|
1371
|
+
entry = {
|
|
1372
|
+
type: normalizedType as ProfileType,
|
|
1373
|
+
inputTokens: 0,
|
|
1374
|
+
outputTokens: 0,
|
|
1375
|
+
cacheReadTokens: 0,
|
|
1376
|
+
cacheWriteTokens: 0,
|
|
1377
|
+
totalTokens: 0,
|
|
1378
|
+
startTs: null,
|
|
1379
|
+
endTs: null,
|
|
1380
|
+
cwd: null,
|
|
1381
|
+
model: record.model || null,
|
|
1382
|
+
};
|
|
1383
|
+
sessions[sessionKey] = entry;
|
|
1384
|
+
}
|
|
1385
|
+
entry.inputTokens += toUsageNumber(record.inputTokens);
|
|
1386
|
+
entry.outputTokens += toUsageNumber(record.outputTokens);
|
|
1387
|
+
entry.cacheReadTokens += toUsageNumber(record.cacheReadTokens);
|
|
1388
|
+
entry.cacheWriteTokens += toUsageNumber(record.cacheWriteTokens);
|
|
1389
|
+
entry.totalTokens += toUsageNumber(record.totalTokens);
|
|
1390
|
+
if (!entry.model && record.model) entry.model = record.model;
|
|
1391
|
+
if (record.ts) {
|
|
1392
|
+
const range = { start: entry.startTs, end: entry.endTs };
|
|
1393
|
+
updateMinMaxTs(range, record.ts);
|
|
1394
|
+
entry.startTs = range.start;
|
|
1395
|
+
entry.endTs = range.end;
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
return sessions;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
function updateUsageStateMetadata(state: UsageStateFile, usagePath: string) {
|
|
1402
|
+
const stat = readUsageFileStat(usagePath);
|
|
1403
|
+
if (!stat || !stat.isFile()) {
|
|
1404
|
+
state.usageMtimeMs = undefined;
|
|
1405
|
+
state.usageSize = undefined;
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
state.usageMtimeMs = stat.mtimeMs;
|
|
1409
|
+
state.usageSize = stat.size;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
809
1412
|
function appendUsageRecord(usagePath: string, record: UsageRecord) {
|
|
810
1413
|
const dir = path.dirname(usagePath);
|
|
811
1414
|
if (!fs.existsSync(dir)) {
|
|
@@ -819,25 +1422,82 @@ export function readUsageRecords(usagePath: string): UsageRecord[] {
|
|
|
819
1422
|
const raw = fs.readFileSync(usagePath, "utf8");
|
|
820
1423
|
const lines = raw.split(/\r?\n/);
|
|
821
1424
|
const records: UsageRecord[] = [];
|
|
1425
|
+
const seen = new Set<string>();
|
|
822
1426
|
for (const line of lines) {
|
|
823
1427
|
const trimmed = line.trim();
|
|
824
1428
|
if (!trimmed) continue;
|
|
825
1429
|
try {
|
|
826
1430
|
const parsed = JSON.parse(trimmed);
|
|
827
1431
|
if (!parsed || typeof parsed !== "object") continue;
|
|
828
|
-
const input = Number(parsed.inputTokens ?? 0);
|
|
829
|
-
const output = Number(parsed.outputTokens ?? 0);
|
|
830
|
-
const total = Number(parsed.totalTokens ?? input + output);
|
|
831
1432
|
const type = normalizeType(parsed.type) || String(parsed.type ?? "unknown");
|
|
832
|
-
|
|
1433
|
+
let input = Number(parsed.inputTokens ?? 0);
|
|
1434
|
+
const output = Number(parsed.outputTokens ?? 0);
|
|
1435
|
+
const cacheRead = Number(
|
|
1436
|
+
parsed.cacheReadTokens ??
|
|
1437
|
+
parsed.cache_read_input_tokens ??
|
|
1438
|
+
parsed.cacheReadInputTokens ??
|
|
1439
|
+
0
|
|
1440
|
+
);
|
|
1441
|
+
const cacheWrite = Number(
|
|
1442
|
+
parsed.cacheWriteTokens ??
|
|
1443
|
+
parsed.cache_creation_input_tokens ??
|
|
1444
|
+
parsed.cache_write_input_tokens ??
|
|
1445
|
+
parsed.cacheWriteInputTokens ??
|
|
1446
|
+
0
|
|
1447
|
+
);
|
|
1448
|
+
if (
|
|
1449
|
+
type === "codex" &&
|
|
1450
|
+
Number.isFinite(cacheRead) &&
|
|
1451
|
+
cacheRead > 0 &&
|
|
1452
|
+
Number.isFinite(input) &&
|
|
1453
|
+
Number.isFinite(output)
|
|
1454
|
+
) {
|
|
1455
|
+
const rawTotal = Number(parsed.totalTokens);
|
|
1456
|
+
if (Number.isFinite(rawTotal) && rawTotal <= input + output) {
|
|
1457
|
+
input = Math.max(0, input - cacheRead);
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
const computedTotal =
|
|
1461
|
+
(Number.isFinite(input) ? input : 0) +
|
|
1462
|
+
(Number.isFinite(output) ? output : 0) +
|
|
1463
|
+
(Number.isFinite(cacheRead) ? cacheRead : 0) +
|
|
1464
|
+
(Number.isFinite(cacheWrite) ? cacheWrite : 0);
|
|
1465
|
+
const total = Number(
|
|
1466
|
+
parsed.totalTokens ?? computedTotal
|
|
1467
|
+
);
|
|
1468
|
+
const finalTotal = Number.isFinite(total)
|
|
1469
|
+
? Math.max(total, computedTotal)
|
|
1470
|
+
: computedTotal;
|
|
1471
|
+
const model =
|
|
1472
|
+
normalizeModelValue(parsed.model) ||
|
|
1473
|
+
normalizeModelValue(parsed.model_name) ||
|
|
1474
|
+
normalizeModelValue(parsed.modelName) ||
|
|
1475
|
+
normalizeModelValue(parsed.model_id) ||
|
|
1476
|
+
normalizeModelValue(parsed.modelId) ||
|
|
1477
|
+
null;
|
|
1478
|
+
const sessionId =
|
|
1479
|
+
parsed.sessionId ||
|
|
1480
|
+
parsed.session_id ||
|
|
1481
|
+
parsed.sessionID ||
|
|
1482
|
+
parsed.session ||
|
|
1483
|
+
null;
|
|
1484
|
+
const record: UsageRecord = {
|
|
833
1485
|
ts: String(parsed.ts ?? ""),
|
|
834
1486
|
type,
|
|
835
1487
|
profileKey: parsed.profileKey ? String(parsed.profileKey) : null,
|
|
836
1488
|
profileName: parsed.profileName ? String(parsed.profileName) : null,
|
|
1489
|
+
model,
|
|
1490
|
+
sessionId: sessionId ? String(sessionId) : null,
|
|
837
1491
|
inputTokens: Number.isFinite(input) ? input : 0,
|
|
838
1492
|
outputTokens: Number.isFinite(output) ? output : 0,
|
|
839
|
-
|
|
840
|
-
|
|
1493
|
+
cacheReadTokens: Number.isFinite(cacheRead) ? cacheRead : 0,
|
|
1494
|
+
cacheWriteTokens: Number.isFinite(cacheWrite) ? cacheWrite : 0,
|
|
1495
|
+
totalTokens: Number.isFinite(finalTotal) ? finalTotal : 0,
|
|
1496
|
+
};
|
|
1497
|
+
const key = buildUsageRecordKey(record);
|
|
1498
|
+
if (seen.has(key)) continue;
|
|
1499
|
+
seen.add(key);
|
|
1500
|
+
records.push(record);
|
|
841
1501
|
} catch {
|
|
842
1502
|
// ignore invalid lines
|
|
843
1503
|
}
|
|
@@ -860,7 +1520,25 @@ export function syncUsageFromSessions(
|
|
|
860
1520
|
|
|
861
1521
|
const state = readUsageState(statePath);
|
|
862
1522
|
const files = state.files || {};
|
|
863
|
-
|
|
1523
|
+
let sessions = state.sessions || {};
|
|
1524
|
+
const usageStat = readUsageFileStat(usagePath);
|
|
1525
|
+
const hasUsageData = !!usageStat && usageStat.isFile() && usageStat.size > 0;
|
|
1526
|
+
const sessionsEmpty = Object.keys(sessions).length === 0;
|
|
1527
|
+
const hasUsageMeta =
|
|
1528
|
+
Number.isFinite(state.usageMtimeMs ?? Number.NaN) &&
|
|
1529
|
+
Number.isFinite(state.usageSize ?? Number.NaN);
|
|
1530
|
+
const usageOutOfSync =
|
|
1531
|
+
hasUsageData &&
|
|
1532
|
+
(!hasUsageMeta ||
|
|
1533
|
+
state.usageMtimeMs !== usageStat.mtimeMs ||
|
|
1534
|
+
state.usageSize !== usageStat.size);
|
|
1535
|
+
if (hasUsageData && (sessionsEmpty || usageOutOfSync)) {
|
|
1536
|
+
const records = readUsageRecords(usagePath);
|
|
1537
|
+
if (records.length > 0) {
|
|
1538
|
+
sessions = buildUsageSessionsFromRecords(records);
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
state.sessions = sessions;
|
|
864
1542
|
const codexFiles = collectSessionFiles(getCodexSessionsPath(config));
|
|
865
1543
|
const claudeFiles = collectSessionFiles(getClaudeSessionsPath(config));
|
|
866
1544
|
|
|
@@ -896,29 +1574,54 @@ export function syncUsageFromSessions(
|
|
|
896
1574
|
const sessionKey =
|
|
897
1575
|
stats.sessionId ? buildSessionKey(type, stats.sessionId) : null;
|
|
898
1576
|
const sessionPrev = sessionKey ? sessions[sessionKey] : null;
|
|
899
|
-
const
|
|
900
|
-
|
|
901
|
-
|
|
1577
|
+
const resolvedModel =
|
|
1578
|
+
(sessionPrev && sessionPrev.model) ||
|
|
1579
|
+
stats.model ||
|
|
1580
|
+
(prev && prev.model) ||
|
|
1581
|
+
null;
|
|
1582
|
+
const prevInput = prev ? toUsageNumber(prev.inputTokens) : 0;
|
|
1583
|
+
const prevOutput = prev ? toUsageNumber(prev.outputTokens) : 0;
|
|
1584
|
+
const prevCacheRead = prev ? toUsageNumber(prev.cacheReadTokens) : 0;
|
|
1585
|
+
const prevCacheWrite = prev ? toUsageNumber(prev.cacheWriteTokens) : 0;
|
|
1586
|
+
const prevTotal = prev ? toUsageNumber(prev.totalTokens) : 0;
|
|
902
1587
|
const prevInputMax = sessionPrev
|
|
903
|
-
? Math.max(prevInput, sessionPrev.inputTokens)
|
|
1588
|
+
? Math.max(prevInput, toUsageNumber(sessionPrev.inputTokens))
|
|
904
1589
|
: prevInput;
|
|
905
1590
|
const prevOutputMax = sessionPrev
|
|
906
|
-
? Math.max(prevOutput, sessionPrev.outputTokens)
|
|
1591
|
+
? Math.max(prevOutput, toUsageNumber(sessionPrev.outputTokens))
|
|
907
1592
|
: prevOutput;
|
|
1593
|
+
const prevCacheReadMax = sessionPrev
|
|
1594
|
+
? Math.max(prevCacheRead, toUsageNumber(sessionPrev.cacheReadTokens))
|
|
1595
|
+
: prevCacheRead;
|
|
1596
|
+
const prevCacheWriteMax = sessionPrev
|
|
1597
|
+
? Math.max(prevCacheWrite, toUsageNumber(sessionPrev.cacheWriteTokens))
|
|
1598
|
+
: prevCacheWrite;
|
|
908
1599
|
const prevTotalMax = sessionPrev
|
|
909
|
-
? Math.max(prevTotal, sessionPrev.totalTokens)
|
|
1600
|
+
? Math.max(prevTotal, toUsageNumber(sessionPrev.totalTokens))
|
|
910
1601
|
: prevTotal;
|
|
911
1602
|
let deltaInput = stats.inputTokens - prevInputMax;
|
|
912
1603
|
let deltaOutput = stats.outputTokens - prevOutputMax;
|
|
1604
|
+
let deltaCacheRead = stats.cacheReadTokens - prevCacheReadMax;
|
|
1605
|
+
let deltaCacheWrite = stats.cacheWriteTokens - prevCacheWriteMax;
|
|
913
1606
|
let deltaTotal = stats.totalTokens - prevTotalMax;
|
|
914
|
-
if (
|
|
1607
|
+
if (
|
|
1608
|
+
deltaTotal < 0 ||
|
|
1609
|
+
deltaInput < 0 ||
|
|
1610
|
+
deltaOutput < 0 ||
|
|
1611
|
+
deltaCacheRead < 0 ||
|
|
1612
|
+
deltaCacheWrite < 0
|
|
1613
|
+
) {
|
|
915
1614
|
if (sessionPrev) {
|
|
916
1615
|
deltaInput = 0;
|
|
917
1616
|
deltaOutput = 0;
|
|
1617
|
+
deltaCacheRead = 0;
|
|
1618
|
+
deltaCacheWrite = 0;
|
|
918
1619
|
deltaTotal = 0;
|
|
919
1620
|
} else {
|
|
920
1621
|
deltaInput = stats.inputTokens;
|
|
921
1622
|
deltaOutput = stats.outputTokens;
|
|
1623
|
+
deltaCacheRead = stats.cacheReadTokens;
|
|
1624
|
+
deltaCacheWrite = stats.cacheWriteTokens;
|
|
922
1625
|
deltaTotal = stats.totalTokens;
|
|
923
1626
|
}
|
|
924
1627
|
}
|
|
@@ -928,30 +1631,58 @@ export function syncUsageFromSessions(
|
|
|
928
1631
|
type,
|
|
929
1632
|
profileKey: resolved.match.profileKey,
|
|
930
1633
|
profileName: resolved.match.profileName,
|
|
1634
|
+
model: resolvedModel,
|
|
1635
|
+
sessionId: stats.sessionId,
|
|
931
1636
|
inputTokens: deltaInput,
|
|
932
1637
|
outputTokens: deltaOutput,
|
|
1638
|
+
cacheReadTokens: deltaCacheRead,
|
|
1639
|
+
cacheWriteTokens: deltaCacheWrite,
|
|
933
1640
|
totalTokens: deltaTotal,
|
|
934
1641
|
};
|
|
935
1642
|
appendUsageRecord(usagePath, record);
|
|
936
1643
|
}
|
|
937
1644
|
if (sessionKey) {
|
|
938
1645
|
const nextInput = sessionPrev
|
|
939
|
-
? Math.max(
|
|
1646
|
+
? Math.max(
|
|
1647
|
+
toUsageNumber(sessionPrev.inputTokens),
|
|
1648
|
+
stats.inputTokens
|
|
1649
|
+
)
|
|
940
1650
|
: stats.inputTokens;
|
|
941
1651
|
const nextOutput = sessionPrev
|
|
942
|
-
? Math.max(
|
|
1652
|
+
? Math.max(
|
|
1653
|
+
toUsageNumber(sessionPrev.outputTokens),
|
|
1654
|
+
stats.outputTokens
|
|
1655
|
+
)
|
|
943
1656
|
: stats.outputTokens;
|
|
1657
|
+
const nextCacheRead = sessionPrev
|
|
1658
|
+
? Math.max(
|
|
1659
|
+
toUsageNumber(sessionPrev.cacheReadTokens),
|
|
1660
|
+
stats.cacheReadTokens
|
|
1661
|
+
)
|
|
1662
|
+
: stats.cacheReadTokens;
|
|
1663
|
+
const nextCacheWrite = sessionPrev
|
|
1664
|
+
? Math.max(
|
|
1665
|
+
toUsageNumber(sessionPrev.cacheWriteTokens),
|
|
1666
|
+
stats.cacheWriteTokens
|
|
1667
|
+
)
|
|
1668
|
+
: stats.cacheWriteTokens;
|
|
944
1669
|
const nextTotal = sessionPrev
|
|
945
|
-
? Math.max(
|
|
1670
|
+
? Math.max(
|
|
1671
|
+
toUsageNumber(sessionPrev.totalTokens),
|
|
1672
|
+
stats.totalTokens
|
|
1673
|
+
)
|
|
946
1674
|
: stats.totalTokens;
|
|
947
1675
|
sessions[sessionKey] = {
|
|
948
1676
|
type,
|
|
949
1677
|
inputTokens: nextInput,
|
|
950
1678
|
outputTokens: nextOutput,
|
|
1679
|
+
cacheReadTokens: nextCacheRead,
|
|
1680
|
+
cacheWriteTokens: nextCacheWrite,
|
|
951
1681
|
totalTokens: nextTotal,
|
|
952
1682
|
startTs: sessionPrev ? sessionPrev.startTs : stats.startTs,
|
|
953
1683
|
endTs: stats.endTs || (sessionPrev ? sessionPrev.endTs : null),
|
|
954
1684
|
cwd: stats.cwd || (sessionPrev ? sessionPrev.cwd : null),
|
|
1685
|
+
model: resolvedModel,
|
|
955
1686
|
};
|
|
956
1687
|
}
|
|
957
1688
|
files[filePath] = {
|
|
@@ -960,10 +1691,13 @@ export function syncUsageFromSessions(
|
|
|
960
1691
|
type,
|
|
961
1692
|
inputTokens: stats.inputTokens,
|
|
962
1693
|
outputTokens: stats.outputTokens,
|
|
1694
|
+
cacheReadTokens: stats.cacheReadTokens,
|
|
1695
|
+
cacheWriteTokens: stats.cacheWriteTokens,
|
|
963
1696
|
totalTokens: stats.totalTokens,
|
|
964
1697
|
startTs: stats.startTs,
|
|
965
1698
|
endTs: stats.endTs,
|
|
966
1699
|
cwd: stats.cwd,
|
|
1700
|
+
model: resolvedModel,
|
|
967
1701
|
};
|
|
968
1702
|
};
|
|
969
1703
|
|
|
@@ -972,6 +1706,7 @@ export function syncUsageFromSessions(
|
|
|
972
1706
|
|
|
973
1707
|
state.files = files;
|
|
974
1708
|
state.sessions = sessions;
|
|
1709
|
+
updateUsageStateMetadata(state, usagePath);
|
|
975
1710
|
writeUsageState(statePath, state);
|
|
976
1711
|
} finally {
|
|
977
1712
|
releaseLock(lockPath, lockFd);
|