@vibe-cafe/vibe-usage 0.10.18 → 0.10.20
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 +27 -6
- package/package.json +1 -1
- package/src/daemon-service.js +347 -7
- package/src/extra-roots.js +107 -0
- package/src/index.js +70 -3
- package/src/init.js +6 -2
- package/src/parsers/antigravity-db.js +9 -8
- package/src/parsers/antigravity.js +88 -11
- package/src/parsers/codex-cache.js +1 -1
- package/src/parsers/codex.js +74 -42
- package/src/parsers/cursor.js +21 -2
- package/src/parsers/grok.js +102 -18
- package/src/parsers/index.js +2 -0
- package/src/parsers/mcode.js +182 -0
- package/src/parsers/pi-session-jsonl.js +4 -1
- package/src/pi-roots.js +33 -10
- package/src/sync.js +14 -3
- package/src/tools.js +43 -11
package/src/index.js
CHANGED
|
@@ -2,6 +2,12 @@ import { loadConfig, saveConfig, getConfigPath } from './config.js';
|
|
|
2
2
|
import { detectInstalledTools, TOOLS } from './tools.js';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
4
|
import { validateExtraCodexHome } from './codex-roots.js';
|
|
5
|
+
import {
|
|
6
|
+
EXTRA_ROOT_SOURCES,
|
|
7
|
+
extraRootList,
|
|
8
|
+
normalizeExtraRoot,
|
|
9
|
+
validateExtraRoot,
|
|
10
|
+
} from './extra-roots.js';
|
|
5
11
|
import { failure, smallHeader } from './output.js';
|
|
6
12
|
|
|
7
13
|
function printSmallHeader() {
|
|
@@ -24,10 +30,18 @@ async function showStatus() {
|
|
|
24
30
|
if (config.codexExtraHome) {
|
|
25
31
|
console.log(` Extra Codex Home: ${config.codexExtraHome}`);
|
|
26
32
|
}
|
|
33
|
+
for (const source of EXTRA_ROOT_SOURCES) {
|
|
34
|
+
for (const root of extraRootList(config.extraRoots?.[source])) {
|
|
35
|
+
console.log(` Extra ${source} Root: ${root}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
27
38
|
}
|
|
28
39
|
|
|
29
40
|
console.log('\n Detected tools:');
|
|
30
|
-
const toolOptions = {
|
|
41
|
+
const toolOptions = {
|
|
42
|
+
codexExtraHome: config?.codexExtraHome,
|
|
43
|
+
extraRoots: config?.extraRoots,
|
|
44
|
+
};
|
|
31
45
|
const detected = detectInstalledTools(toolOptions);
|
|
32
46
|
if (detected.length === 0) {
|
|
33
47
|
console.log(' (none)\n');
|
|
@@ -103,9 +117,59 @@ function handleConfig(args) {
|
|
|
103
117
|
}
|
|
104
118
|
break;
|
|
105
119
|
}
|
|
120
|
+
case 'add-root': {
|
|
121
|
+
const source = args[1];
|
|
122
|
+
const value = args[2];
|
|
123
|
+
if (!source || value === undefined) {
|
|
124
|
+
console.error('Usage: vibe-usage config add-root <codex|grok|antigravity> <path>');
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
const validation = validateExtraRoot(source, value);
|
|
128
|
+
if (!validation.ok) {
|
|
129
|
+
console.error(failure(`额外 ${source} 根目录无效(${validation.reason}): ${validation.path}`));
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
const config = loadConfig() || {};
|
|
133
|
+
if (!config.extraRoots || typeof config.extraRoots !== 'object' || Array.isArray(config.extraRoots)) {
|
|
134
|
+
config.extraRoots = {};
|
|
135
|
+
}
|
|
136
|
+
const roots = extraRootList(config.extraRoots[source]);
|
|
137
|
+
config.extraRoots[source] = [...new Set([...roots, validation.path])];
|
|
138
|
+
saveConfig(config);
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
case 'remove-root': {
|
|
142
|
+
const source = args[1];
|
|
143
|
+
const value = args[2];
|
|
144
|
+
if (!EXTRA_ROOT_SOURCES.includes(source) || value === undefined) {
|
|
145
|
+
console.error('Usage: vibe-usage config remove-root <codex|grok|antigravity> <path>');
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
const config = loadConfig() || {};
|
|
149
|
+
const path = normalizeExtraRoot(value);
|
|
150
|
+
const roots = extraRootList(config.extraRoots?.[source])
|
|
151
|
+
.filter(root => normalizeExtraRoot(root) !== path);
|
|
152
|
+
if (config.extraRoots && typeof config.extraRoots === 'object' && !Array.isArray(config.extraRoots)) {
|
|
153
|
+
if (roots.length > 0) config.extraRoots[source] = roots;
|
|
154
|
+
else delete config.extraRoots[source];
|
|
155
|
+
if (Object.keys(config.extraRoots).length === 0) delete config.extraRoots;
|
|
156
|
+
}
|
|
157
|
+
saveConfig(config);
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
case 'roots': {
|
|
161
|
+
const config = loadConfig();
|
|
162
|
+
const roots = config?.extraRoots;
|
|
163
|
+
console.log(JSON.stringify(
|
|
164
|
+
roots && typeof roots === 'object' && !Array.isArray(roots) ? roots : {},
|
|
165
|
+
null,
|
|
166
|
+
2,
|
|
167
|
+
));
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
106
170
|
default:
|
|
107
171
|
console.error(`Unknown config subcommand: ${sub || '(none)'}`);
|
|
108
|
-
console.error('Usage: vibe-usage config <get|set|show>');
|
|
172
|
+
console.error('Usage: vibe-usage config <get|set|show|add-root|remove-root|roots>');
|
|
109
173
|
process.exit(1);
|
|
110
174
|
}
|
|
111
175
|
}
|
|
@@ -219,7 +283,7 @@ export async function run(rawArgs) {
|
|
|
219
283
|
npx @vibe-cafe/vibe-usage summary Print last 7 days as markdown (cost/tokens/model/project)
|
|
220
284
|
npx @vibe-cafe/vibe-usage summary --days N Same, but over the last N days (1-90)
|
|
221
285
|
npx @vibe-cafe/vibe-usage daemon Continuous sync (every 30m, foreground)
|
|
222
|
-
npx @vibe-cafe/vibe-usage daemon install Install background service (systemd/launchd)
|
|
286
|
+
npx @vibe-cafe/vibe-usage daemon install Install background service (systemd/launchd/Task Scheduler)
|
|
223
287
|
npx @vibe-cafe/vibe-usage daemon uninstall Remove background service
|
|
224
288
|
npx @vibe-cafe/vibe-usage daemon status Show background service status
|
|
225
289
|
npx @vibe-cafe/vibe-usage daemon stop Stop background service
|
|
@@ -233,6 +297,9 @@ export async function run(rawArgs) {
|
|
|
233
297
|
npx @vibe-cafe/vibe-usage config get <key> Get a config value
|
|
234
298
|
npx @vibe-cafe/vibe-usage config set <key> <value> Set a config value
|
|
235
299
|
npx @vibe-cafe/vibe-usage config set codexExtraHome <path> Persist another Codex Home
|
|
300
|
+
npx @vibe-cafe/vibe-usage config add-root <tool> <path> Add a Codex, Grok, or Antigravity data root
|
|
301
|
+
npx @vibe-cafe/vibe-usage config remove-root <tool> <path> Remove an added data root
|
|
302
|
+
npx @vibe-cafe/vibe-usage config roots Show added data roots as JSON
|
|
236
303
|
npx @vibe-cafe/vibe-usage help Show this help
|
|
237
304
|
`);
|
|
238
305
|
break;
|
package/src/init.js
CHANGED
|
@@ -27,7 +27,7 @@ function openBrowser(url) {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
function isDaemonPlatform() {
|
|
30
|
-
return process.platform === 'linux' || process.platform === 'darwin';
|
|
30
|
+
return process.platform === 'linux' || process.platform === 'darwin' || process.platform === 'win32';
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
export async function runInit(options = {}) {
|
|
@@ -81,10 +81,14 @@ export async function runInit(options = {}) {
|
|
|
81
81
|
apiUrl,
|
|
82
82
|
hostname: host,
|
|
83
83
|
...(existing?.codexExtraHome ? { codexExtraHome: existing.codexExtraHome } : {}),
|
|
84
|
+
...(existing?.extraRoots ? { extraRoots: existing.extraRoots } : {}),
|
|
84
85
|
};
|
|
85
86
|
saveConfig(config);
|
|
86
87
|
|
|
87
|
-
const tools = detectInstalledTools({
|
|
88
|
+
const tools = detectInstalledTools({
|
|
89
|
+
codexExtraHome: config.codexExtraHome,
|
|
90
|
+
extraRoots: config.extraRoots,
|
|
91
|
+
});
|
|
88
92
|
if (tools.length > 0) {
|
|
89
93
|
console.log(success(`检测到 ${tools.length} 款工具: ${dim(tools.map(t => t.name).join(' · '))}`));
|
|
90
94
|
} else {
|
|
@@ -200,14 +200,15 @@ function queryCascadeDb(conversationsDir, cascadeId, sql) {
|
|
|
200
200
|
}
|
|
201
201
|
|
|
202
202
|
/** List cascade IDs backed by a `.db` file in a conversations directory. */
|
|
203
|
-
export function listDbCascades(conversationsDir) {
|
|
203
|
+
export function listDbCascades(conversationsDir, { strict = false } = {}) {
|
|
204
204
|
try {
|
|
205
205
|
const out = [];
|
|
206
206
|
for (const f of readdirSync(conversationsDir)) {
|
|
207
207
|
if (f.endsWith('.db') && f !== 'db.sqlite') out.push(f.slice(0, -3));
|
|
208
208
|
}
|
|
209
209
|
return out;
|
|
210
|
-
} catch {
|
|
210
|
+
} catch (err) {
|
|
211
|
+
if (strict) throw err;
|
|
211
212
|
return [];
|
|
212
213
|
}
|
|
213
214
|
}
|
|
@@ -217,12 +218,12 @@ export function listDbCascades(conversationsDir) {
|
|
|
217
218
|
* records. blob is fetched as hex text so it round-trips through both the
|
|
218
219
|
* node:sqlite and sqlite3-CLI backends uniformly.
|
|
219
220
|
*/
|
|
220
|
-
export function readDbUsageRecords(conversationsDir, cascadeId) {
|
|
221
|
+
export function readDbUsageRecords(conversationsDir, cascadeId, { strict = false } = {}) {
|
|
221
222
|
let rows;
|
|
222
223
|
try {
|
|
223
224
|
rows = queryCascadeDb(conversationsDir, cascadeId, 'SELECT idx, hex(data) AS h FROM gen_metadata ORDER BY idx');
|
|
224
225
|
} catch (err) {
|
|
225
|
-
if (isSqliteUnavailableError(err)) throw err;
|
|
226
|
+
if (isSqliteUnavailableError(err) || strict) throw err;
|
|
226
227
|
return [];
|
|
227
228
|
}
|
|
228
229
|
const records = [];
|
|
@@ -247,7 +248,7 @@ export function readDbUsageRecords(conversationsDir, cascadeId) {
|
|
|
247
248
|
* system/tool steps that parseStepMetadata skips. Used to timestamp 3.7
|
|
248
249
|
* gen_metadata rows that no longer embed chatStartMetadata.
|
|
249
250
|
*/
|
|
250
|
-
export function readDbStepTimestamps(conversationsDir, cascadeId) {
|
|
251
|
+
export function readDbStepTimestamps(conversationsDir, cascadeId, { strict = false } = {}) {
|
|
251
252
|
let rows;
|
|
252
253
|
try {
|
|
253
254
|
rows = queryCascadeDb(
|
|
@@ -256,7 +257,7 @@ export function readDbStepTimestamps(conversationsDir, cascadeId) {
|
|
|
256
257
|
'SELECT idx, hex(metadata) AS h FROM steps WHERE metadata IS NOT NULL ORDER BY idx',
|
|
257
258
|
);
|
|
258
259
|
} catch (err) {
|
|
259
|
-
if (isSqliteUnavailableError(err)) throw err;
|
|
260
|
+
if (isSqliteUnavailableError(err) || strict) throw err;
|
|
260
261
|
return new Map();
|
|
261
262
|
}
|
|
262
263
|
const byIdx = new Map();
|
|
@@ -331,7 +332,7 @@ export function parseStepMetadata(buf) {
|
|
|
331
332
|
* Read session timing events (user/assistant turns) for a cascade from the
|
|
332
333
|
* steps table, chronological by idx.
|
|
333
334
|
*/
|
|
334
|
-
export function readDbSessionEvents(conversationsDir, cascadeId) {
|
|
335
|
+
export function readDbSessionEvents(conversationsDir, cascadeId, { strict = false } = {}) {
|
|
335
336
|
let rows;
|
|
336
337
|
try {
|
|
337
338
|
rows = queryCascadeDb(
|
|
@@ -340,7 +341,7 @@ export function readDbSessionEvents(conversationsDir, cascadeId) {
|
|
|
340
341
|
'SELECT hex(metadata) AS h FROM steps WHERE metadata IS NOT NULL ORDER BY idx',
|
|
341
342
|
);
|
|
342
343
|
} catch (err) {
|
|
343
|
-
if (isSqliteUnavailableError(err)) throw err;
|
|
344
|
+
if (isSqliteUnavailableError(err) || strict) throw err;
|
|
344
345
|
return [];
|
|
345
346
|
}
|
|
346
347
|
const events = [];
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { execSync } from 'node:child_process';
|
|
2
|
-
import { readdirSync } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
2
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
3
|
+
import { delimiter, join } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
|
+
import { antigravityConversationDirs, normalizeExtraRoot } from '../extra-roots.js';
|
|
5
6
|
import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
6
7
|
import { listDbCascades, readDbUsageRecords, readDbWorkspaceUri, readDbSessionEvents, readDbStepTimestamps, resolveUsageTimestamp } from './antigravity-db.js';
|
|
7
8
|
|
|
@@ -295,10 +296,10 @@ function projectFromUri(uri) {
|
|
|
295
296
|
* List cascade IDs backed by a legacy `.pb` file (App history). `.db` cascades
|
|
296
297
|
* are handled separately via offline parsing.
|
|
297
298
|
*/
|
|
298
|
-
function listPbCascades() {
|
|
299
|
+
function listPbCascades(conversationsDir = CONVERSATIONS_DIR) {
|
|
299
300
|
try {
|
|
300
301
|
const out = [];
|
|
301
|
-
for (const f of readdirSync(
|
|
302
|
+
for (const f of readdirSync(conversationsDir)) {
|
|
302
303
|
if (f.endsWith('.pb')) out.push(f.slice(0, -3));
|
|
303
304
|
}
|
|
304
305
|
return out;
|
|
@@ -316,19 +317,88 @@ function modelFromRecord(rec) {
|
|
|
316
317
|
return 'unknown';
|
|
317
318
|
}
|
|
318
319
|
|
|
319
|
-
export async function parse() {
|
|
320
|
+
export async function parse({ extraRoots = [] } = {}) {
|
|
320
321
|
const entries = [];
|
|
321
322
|
const sessionEvents = [];
|
|
322
323
|
const seenResponseIds = new Set();
|
|
323
324
|
|
|
325
|
+
const extraDirs = [];
|
|
326
|
+
for (const root of extraRoots) {
|
|
327
|
+
const dirs = antigravityConversationDirs(root);
|
|
328
|
+
let found = false;
|
|
329
|
+
for (const dir of dirs) {
|
|
330
|
+
try {
|
|
331
|
+
readdirSync(dir);
|
|
332
|
+
extraDirs.push(dir);
|
|
333
|
+
found = true;
|
|
334
|
+
} catch (err) {
|
|
335
|
+
if (err?.code === 'ENOENT') continue;
|
|
336
|
+
return {
|
|
337
|
+
buckets: [],
|
|
338
|
+
sessions: [],
|
|
339
|
+
skipped: true,
|
|
340
|
+
warnings: [`antigravity: 额外根目录读取失败,已保留上次同步数据: ${normalizeExtraRoot(root)}`],
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (!found) {
|
|
345
|
+
return {
|
|
346
|
+
buckets: [],
|
|
347
|
+
sessions: [],
|
|
348
|
+
skipped: true,
|
|
349
|
+
warnings: [`antigravity: 额外根目录不可用,已跳过本次 Antigravity 同步: ${normalizeExtraRoot(root)}`],
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
324
354
|
// ── Path 1: offline .db parsing (App 2.0 + agy CLI, no process needed) ──
|
|
325
355
|
const dbHandled = new Set();
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
356
|
+
const fixtureDirs = process.env.VIBE_USAGE_ANTIGRAVITY_DIRS?.trim();
|
|
357
|
+
const defaultDirs = fixtureDirs
|
|
358
|
+
? fixtureDirs.split(delimiter).filter(Boolean)
|
|
359
|
+
: [CONVERSATIONS_DIR, CLI_CONVERSATIONS_DIR];
|
|
360
|
+
const strictDirs = new Set(extraDirs);
|
|
361
|
+
const conversationDirs = [...new Set([...defaultDirs, ...extraDirs])];
|
|
362
|
+
const candidates = [];
|
|
363
|
+
for (const dir of conversationDirs) {
|
|
364
|
+
const strict = strictDirs.has(dir);
|
|
365
|
+
try {
|
|
366
|
+
for (const cascadeId of listDbCascades(dir, { strict })) {
|
|
367
|
+
candidates.push({ dir, cascadeId, strict });
|
|
368
|
+
}
|
|
369
|
+
} catch {
|
|
370
|
+
return {
|
|
371
|
+
buckets: [], sessions: [], skipped: true,
|
|
372
|
+
warnings: [`antigravity: 额外根目录读取失败,已保留上次同步数据: ${dir}`],
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
const configuredCascadeIds = new Set(
|
|
377
|
+
candidates.filter(candidate => candidate.strict).map(candidate => candidate.cascadeId),
|
|
378
|
+
);
|
|
379
|
+
const selectedConfiguredCopies = new Map();
|
|
380
|
+
for (const candidate of candidates) {
|
|
381
|
+
if (!configuredCascadeIds.has(candidate.cascadeId)) continue;
|
|
382
|
+
let size = 0;
|
|
383
|
+
try {
|
|
384
|
+
size = statSync(join(candidate.dir, `${candidate.cascadeId}.db`)).size;
|
|
385
|
+
} catch {
|
|
386
|
+
// The DB may move between discovery and stat; the read below will fail
|
|
387
|
+
// open in the existing offline reader.
|
|
388
|
+
}
|
|
389
|
+
const previous = selectedConfiguredCopies.get(candidate.cascadeId);
|
|
390
|
+
if (!previous || size > previous.size) selectedConfiguredCopies.set(candidate.cascadeId, { ...candidate, size });
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
for (const { dir, cascadeId, strict } of candidates) {
|
|
394
|
+
const selected = selectedConfiguredCopies.get(cascadeId);
|
|
395
|
+
if (selected && selected.dir !== dir) continue;
|
|
396
|
+
try {
|
|
397
|
+
const options = { strict };
|
|
398
|
+
const records = readDbUsageRecords(dir, cascadeId, options);
|
|
329
399
|
const project = projectFromUri(readDbWorkspaceUri(dir, cascadeId)) || 'unknown';
|
|
330
400
|
const stepTimestampsByIdx = records.some((rec) => !rec.timestamp || isNaN(rec.timestamp.getTime()))
|
|
331
|
-
? readDbStepTimestamps(dir, cascadeId)
|
|
401
|
+
? readDbStepTimestamps(dir, cascadeId, options)
|
|
332
402
|
: new Map();
|
|
333
403
|
|
|
334
404
|
if (records.length > 0) {
|
|
@@ -355,7 +425,7 @@ export async function parse() {
|
|
|
355
425
|
}
|
|
356
426
|
|
|
357
427
|
// Session timing from steps (independent of token usage presence).
|
|
358
|
-
for (const ev of readDbSessionEvents(dir, cascadeId)) {
|
|
428
|
+
for (const ev of readDbSessionEvents(dir, cascadeId, options)) {
|
|
359
429
|
sessionEvents.push({
|
|
360
430
|
sessionId: cascadeId,
|
|
361
431
|
source: SOURCE,
|
|
@@ -364,11 +434,18 @@ export async function parse() {
|
|
|
364
434
|
role: ev.role,
|
|
365
435
|
});
|
|
366
436
|
}
|
|
437
|
+
} catch (err) {
|
|
438
|
+
if (!strict) throw err;
|
|
439
|
+
return {
|
|
440
|
+
buckets: [], sessions: [], skipped: true,
|
|
441
|
+
warnings: [`antigravity: 额外根目录读取失败,已保留上次同步数据: ${dir}`],
|
|
442
|
+
};
|
|
367
443
|
}
|
|
368
444
|
}
|
|
369
445
|
|
|
370
446
|
// ── Path 2: RPC fallback, only for legacy .pb cascades not already parsed ──
|
|
371
|
-
const
|
|
447
|
+
const pbDir = defaultDirs[0] || CONVERSATIONS_DIR;
|
|
448
|
+
const pbCascades = listPbCascades(pbDir).filter((id) => !dbHandled.has(id));
|
|
372
449
|
if (pbCascades.length > 0) {
|
|
373
450
|
const server = findLanguageServer();
|
|
374
451
|
const ports = server ? findListeningPorts(server.pid) : [];
|
|
@@ -14,7 +14,7 @@ import { join } from 'node:path';
|
|
|
14
14
|
// separate from ~/.vibe-usage/state.json, whose hashes are the authoritative
|
|
15
15
|
// record of successful uploads and must remain backward-compatible.
|
|
16
16
|
export const CODEX_CACHE_SCHEMA_VERSION = 1;
|
|
17
|
-
export const CODEX_PARSER_ALGORITHM_VERSION =
|
|
17
|
+
export const CODEX_PARSER_ALGORITHM_VERSION = 3;
|
|
18
18
|
|
|
19
19
|
function hash(value, length = 24) {
|
|
20
20
|
return createHash('sha256').update(value).digest('hex').slice(0, length);
|
package/src/parsers/codex.js
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
resolveCodexHomes,
|
|
18
18
|
validateExtraCodexHome,
|
|
19
19
|
} from '../codex-roots.js';
|
|
20
|
+
import { discoverCodexHomes } from '../extra-roots.js';
|
|
20
21
|
import {
|
|
21
22
|
codexCacheEnabled,
|
|
22
23
|
fileSignature,
|
|
@@ -26,12 +27,10 @@ import {
|
|
|
26
27
|
saveCodexFileTail,
|
|
27
28
|
} from './codex-cache.js';
|
|
28
29
|
|
|
29
|
-
const CODEX_API_BILLING_MARKER = '#billing=api';
|
|
30
|
-
const CODEX_SUBSCRIPTION_BILLING_MARKER = '#billing=subscription';
|
|
31
30
|
// Changing a model id changes its server-side bucket key. Keep pre-release
|
|
32
31
|
// history byte-for-byte stable so upgrading cannot re-upload the same tokens
|
|
33
|
-
// under decorated keys and double-count them.
|
|
34
|
-
const
|
|
32
|
+
// under tier-decorated keys and double-count them.
|
|
33
|
+
const CODEX_SERVICE_TIER_ATTRIBUTION_START_MS = Date.parse('2026-08-31T00:00:00.000Z');
|
|
35
34
|
|
|
36
35
|
function normalizeCodexServiceTier(value) {
|
|
37
36
|
if (typeof value !== 'string') return null;
|
|
@@ -41,22 +40,16 @@ function normalizeCodexServiceTier(value) {
|
|
|
41
40
|
return null;
|
|
42
41
|
}
|
|
43
42
|
|
|
44
|
-
function
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (decorated === 'unknown' || timestampMs < CODEX_BILLING_ATTRIBUTION_START_MS) {
|
|
53
|
-
return decorated;
|
|
43
|
+
function decorateCodexModel(model, serviceTier, timestampMs) {
|
|
44
|
+
const rawModel = model || 'unknown';
|
|
45
|
+
if (
|
|
46
|
+
rawModel === 'unknown'
|
|
47
|
+
|| !serviceTier
|
|
48
|
+
|| timestampMs < CODEX_SERVICE_TIER_ATTRIBUTION_START_MS
|
|
49
|
+
) {
|
|
50
|
+
return rawModel;
|
|
54
51
|
}
|
|
55
|
-
|
|
56
|
-
decorated += subscriptionBilling
|
|
57
|
-
? CODEX_SUBSCRIPTION_BILLING_MARKER
|
|
58
|
-
: CODEX_API_BILLING_MARKER;
|
|
59
|
-
return decorated;
|
|
52
|
+
return `${rawModel}-${serviceTier}`;
|
|
60
53
|
}
|
|
61
54
|
|
|
62
55
|
// Codex stores live sessions in $CODEX_HOME/sessions (default ~/.codex) and,
|
|
@@ -70,20 +63,22 @@ function decorateCodexModel(model, serviceTier, subscriptionBilling, timestampMs
|
|
|
70
63
|
* Recursively find all .jsonl files under a directory.
|
|
71
64
|
* Codex CLI stores sessions as: ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
|
|
72
65
|
*/
|
|
73
|
-
function findJsonlFiles(dir) {
|
|
66
|
+
function findJsonlFiles(dir, strict = false) {
|
|
74
67
|
const results = [];
|
|
75
68
|
if (!existsSync(dir)) return results;
|
|
76
69
|
try {
|
|
77
70
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
78
71
|
const fullPath = join(dir, entry.name);
|
|
79
72
|
if (entry.isDirectory()) {
|
|
80
|
-
for (const nested of findJsonlFiles(fullPath)) results.push(nested);
|
|
73
|
+
for (const nested of findJsonlFiles(fullPath, strict)) results.push(nested);
|
|
81
74
|
} else if (entry.name.endsWith('.jsonl')) {
|
|
82
75
|
results.push(fullPath);
|
|
83
76
|
}
|
|
84
77
|
}
|
|
85
|
-
} catch {
|
|
86
|
-
|
|
78
|
+
} catch (err) {
|
|
79
|
+
if (strict && err?.code !== 'ENOENT') throw err;
|
|
80
|
+
// Default roots are best-effort; configured roots must never look empty
|
|
81
|
+
// merely because a directory became unreadable between syncs.
|
|
87
82
|
}
|
|
88
83
|
return results;
|
|
89
84
|
}
|
|
@@ -634,7 +629,6 @@ async function parseSessionFile(filePath, snapshotSize, fm, boundary, {
|
|
|
634
629
|
|
|
635
630
|
let turnContextModel = previousTail?.turnContextModel || 'unknown';
|
|
636
631
|
let serviceTier = previousTail?.serviceTier || null;
|
|
637
|
-
let subscriptionBilling = previousTail?.subscriptionBilling || false;
|
|
638
632
|
let prevTotal = previousTail?.prevTotal || null;
|
|
639
633
|
let prevCumulativeTotal = previousTail?.prevCumulativeTotal ?? null;
|
|
640
634
|
const start = previousTail?.parsedBytes || 0;
|
|
@@ -708,10 +702,6 @@ async function parseSessionFile(filePath, snapshotSize, fm, boundary, {
|
|
|
708
702
|
const isReplayedHistory = inReplayBlock;
|
|
709
703
|
rawTokenSeen++;
|
|
710
704
|
|
|
711
|
-
if (hasSubscriptionPlan(payload.rate_limits?.plan_type)) {
|
|
712
|
-
subscriptionBilling = true;
|
|
713
|
-
}
|
|
714
|
-
|
|
715
705
|
const info = payload.info;
|
|
716
706
|
if (!info) continue;
|
|
717
707
|
|
|
@@ -761,12 +751,7 @@ async function parseSessionFile(filePath, snapshotSize, fm, boundary, {
|
|
|
761
751
|
if (!timestamp || isNaN(timestamp.getTime())) continue;
|
|
762
752
|
|
|
763
753
|
const rawModel = info.model || payload.model || turnContextModel || 'unknown';
|
|
764
|
-
const model = decorateCodexModel(
|
|
765
|
-
rawModel,
|
|
766
|
-
serviceTier,
|
|
767
|
-
subscriptionBilling,
|
|
768
|
-
timestamp.getTime()
|
|
769
|
-
);
|
|
754
|
+
const model = decorateCodexModel(rawModel, serviceTier, timestamp.getTime());
|
|
770
755
|
|
|
771
756
|
// OpenAI API: input_tokens INCLUDES cached, output_tokens INCLUDES reasoning.
|
|
772
757
|
// Normalize to Anthropic-style semantics where each field is non-overlapping.
|
|
@@ -822,7 +807,6 @@ async function parseSessionFile(filePath, snapshotSize, fm, boundary, {
|
|
|
822
807
|
firstSessionMetaSeen,
|
|
823
808
|
turnContextModel,
|
|
824
809
|
serviceTier,
|
|
825
|
-
subscriptionBilling,
|
|
826
810
|
prevTotal,
|
|
827
811
|
prevCumulativeTotal,
|
|
828
812
|
buckets,
|
|
@@ -856,7 +840,8 @@ function mergeFileResults(results) {
|
|
|
856
840
|
return { buckets: aggregateToBuckets(entries), sessions };
|
|
857
841
|
}
|
|
858
842
|
|
|
859
|
-
async function parseNativeCodex({ codexExtraHome } = {}) {
|
|
843
|
+
async function parseNativeCodex({ codexExtraHome, extraRoots = [] } = {}) {
|
|
844
|
+
let extraCodexHomePath = null;
|
|
860
845
|
if (codexExtraHome?.trim()) {
|
|
861
846
|
const validation = validateExtraCodexHome(codexExtraHome);
|
|
862
847
|
if (!validation.ok) {
|
|
@@ -867,11 +852,31 @@ async function parseNativeCodex({ codexExtraHome } = {}) {
|
|
|
867
852
|
warnings: [`codex: 额外 Codex Home 不可用,已跳过本次 Codex 同步: ${validation.path}`],
|
|
868
853
|
};
|
|
869
854
|
}
|
|
855
|
+
extraCodexHomePath = validation.path;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
const configuredHomes = [];
|
|
859
|
+
for (const root of extraRoots) {
|
|
860
|
+
const discovered = discoverCodexHomes(root);
|
|
861
|
+
if (!discovered.readable || discovered.homes.length === 0) {
|
|
862
|
+
return {
|
|
863
|
+
buckets: [],
|
|
864
|
+
sessions: [],
|
|
865
|
+
skipped: true,
|
|
866
|
+
warnings: [`codex: 额外根目录不可用,已跳过本次 Codex 同步: ${discovered.root}`],
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
configuredHomes.push(...discovered.homes);
|
|
870
870
|
}
|
|
871
871
|
|
|
872
|
-
const
|
|
872
|
+
const strictHomes = new Set(configuredHomes);
|
|
873
|
+
if (extraCodexHomePath) strictHomes.add(extraCodexHomePath);
|
|
874
|
+
const codexHomes = [...new Set([
|
|
875
|
+
...resolveCodexHomes(codexExtraHome),
|
|
876
|
+
...configuredHomes,
|
|
877
|
+
])];
|
|
873
878
|
const dirs = codexHomes.flatMap(codexHome => (
|
|
874
|
-
codexSessionDirs(codexHome).map(dir => ({ codexHome, dir }))
|
|
879
|
+
codexSessionDirs(codexHome).map(dir => ({ codexHome, dir, strict: strictHomes.has(codexHome) }))
|
|
875
880
|
));
|
|
876
881
|
if (!dirs.some(({ dir }) => existsSync(dir))) return { buckets: [], sessions: [] };
|
|
877
882
|
|
|
@@ -887,8 +892,17 @@ async function parseNativeCodex({ codexExtraHome } = {}) {
|
|
|
887
892
|
audited: 0,
|
|
888
893
|
};
|
|
889
894
|
const files = [];
|
|
890
|
-
for (const { codexHome, dir } of dirs) {
|
|
891
|
-
|
|
895
|
+
for (const { codexHome, dir, strict } of dirs) {
|
|
896
|
+
let filePaths;
|
|
897
|
+
try {
|
|
898
|
+
filePaths = findJsonlFiles(dir, strict);
|
|
899
|
+
} catch {
|
|
900
|
+
return {
|
|
901
|
+
buckets: [], sessions: [], skipped: true,
|
|
902
|
+
warnings: [`codex: 额外根目录读取失败,已保留上次同步数据: ${codexHome}`],
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
for (const filePath of filePaths) {
|
|
892
906
|
try {
|
|
893
907
|
const stat = statSync(filePath);
|
|
894
908
|
if (stat.size <= 0) continue;
|
|
@@ -899,6 +913,7 @@ async function parseNativeCodex({ codexExtraHome } = {}) {
|
|
|
899
913
|
const file = {
|
|
900
914
|
codexHome,
|
|
901
915
|
filePath,
|
|
916
|
+
strict,
|
|
902
917
|
snapshotSize: stat.size,
|
|
903
918
|
signature,
|
|
904
919
|
cache,
|
|
@@ -909,9 +924,14 @@ async function parseNativeCodex({ codexExtraHome } = {}) {
|
|
|
909
924
|
};
|
|
910
925
|
if (!cache && priorCache) file.appendTail = tailStateFor(file);
|
|
911
926
|
files.push(file);
|
|
912
|
-
} catch {
|
|
927
|
+
} catch (err) {
|
|
913
928
|
// The file may move to archived_sessions between discovery and stat.
|
|
914
|
-
|
|
929
|
+
if (strict && err?.code !== 'ENOENT') {
|
|
930
|
+
return {
|
|
931
|
+
buckets: [], sessions: [], skipped: true,
|
|
932
|
+
warnings: [`codex: 额外根目录读取失败,已保留上次同步数据: ${codexHome}`],
|
|
933
|
+
};
|
|
934
|
+
}
|
|
915
935
|
}
|
|
916
936
|
}
|
|
917
937
|
}
|
|
@@ -947,6 +967,12 @@ async function parseNativeCodex({ codexExtraHome } = {}) {
|
|
|
947
967
|
cacheStats.filesRead++;
|
|
948
968
|
updateFileCache(file, { header: file.header });
|
|
949
969
|
} catch {
|
|
970
|
+
if (file.strict) {
|
|
971
|
+
return {
|
|
972
|
+
buckets: [], sessions: [], skipped: true,
|
|
973
|
+
warnings: [`codex: 额外根目录读取失败,已保留上次同步数据: ${file.codexHome}`],
|
|
974
|
+
};
|
|
975
|
+
}
|
|
950
976
|
continue;
|
|
951
977
|
}
|
|
952
978
|
}
|
|
@@ -1000,6 +1026,12 @@ async function parseNativeCodex({ codexExtraHome } = {}) {
|
|
|
1000
1026
|
cacheStats.filesRead++;
|
|
1001
1027
|
updateFileCache(file, { index: meta });
|
|
1002
1028
|
} catch {
|
|
1029
|
+
if (file.strict) {
|
|
1030
|
+
return {
|
|
1031
|
+
buckets: [], sessions: [], skipped: true,
|
|
1032
|
+
warnings: [`codex: 额外根目录读取失败,已保留上次同步数据: ${file.codexHome}`],
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1003
1035
|
continue;
|
|
1004
1036
|
}
|
|
1005
1037
|
}
|
package/src/parsers/cursor.js
CHANGED
|
@@ -68,7 +68,19 @@ function decodeJwtSub(token) {
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
// Under full sync many parsers hammer disk concurrently; cursor.com's CSV
|
|
72
|
+
// export can still succeed but take >10s. A short timeout caused silent skips.
|
|
73
|
+
const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
|
|
74
|
+
const MAX_FETCH_TIMEOUT_MS = 2_147_483_647;
|
|
75
|
+
|
|
76
|
+
export function resolveCursorFetchTimeout(value) {
|
|
77
|
+
const timeout = Number(value);
|
|
78
|
+
return Number.isInteger(timeout) && timeout > 0 && timeout <= MAX_FETCH_TIMEOUT_MS
|
|
79
|
+
? timeout
|
|
80
|
+
: DEFAULT_FETCH_TIMEOUT_MS;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const FETCH_TIMEOUT_MS = resolveCursorFetchTimeout(process.env.VIBE_USAGE_CURSOR_FETCH_TIMEOUT_MS);
|
|
72
84
|
|
|
73
85
|
async function fetchUsageCsv(token) {
|
|
74
86
|
const url = `${(process.env.CURSOR_WEB_BASE_URL?.trim() || 'https://cursor.com').replace(/\/+$/, '')}/api/dashboard/export-usage-events-csv?strategy=tokens`;
|
|
@@ -190,7 +202,14 @@ export async function parse() {
|
|
|
190
202
|
// Auth failure → bubble up so user sees they need to re-login in Cursor.
|
|
191
203
|
// Tell sync.js this was not a successful empty snapshot so it preserves
|
|
192
204
|
// Cursor's incremental state instead of pruning it as dead history.
|
|
193
|
-
if (err && err.skip)
|
|
205
|
+
if (err && err.skip) {
|
|
206
|
+
return {
|
|
207
|
+
buckets: [],
|
|
208
|
+
sessions: [],
|
|
209
|
+
skipped: true,
|
|
210
|
+
warnings: [`cursor: ${err.message}`],
|
|
211
|
+
};
|
|
212
|
+
}
|
|
194
213
|
throw err;
|
|
195
214
|
}
|
|
196
215
|
const rows = parseCsv(csv);
|