@vibe-cafe/vibe-usage 0.10.19 → 0.10.21
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 +33 -6
- package/package.json +1 -1
- package/src/daemon-service.js +347 -7
- package/src/extra-roots.js +312 -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.js +62 -11
- 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-coding-agent.js +17 -2
- package/src/parsers/pi-session-jsonl.js +23 -2
- package/src/pi-roots.js +43 -11
- package/src/sync.js +14 -3
- package/src/tools.js +46 -12
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,
|
|
@@ -62,20 +63,22 @@ function decorateCodexModel(model, serviceTier, timestampMs) {
|
|
|
62
63
|
* Recursively find all .jsonl files under a directory.
|
|
63
64
|
* Codex CLI stores sessions as: ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
|
|
64
65
|
*/
|
|
65
|
-
function findJsonlFiles(dir) {
|
|
66
|
+
function findJsonlFiles(dir, strict = false) {
|
|
66
67
|
const results = [];
|
|
67
68
|
if (!existsSync(dir)) return results;
|
|
68
69
|
try {
|
|
69
70
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
70
71
|
const fullPath = join(dir, entry.name);
|
|
71
72
|
if (entry.isDirectory()) {
|
|
72
|
-
for (const nested of findJsonlFiles(fullPath)) results.push(nested);
|
|
73
|
+
for (const nested of findJsonlFiles(fullPath, strict)) results.push(nested);
|
|
73
74
|
} else if (entry.name.endsWith('.jsonl')) {
|
|
74
75
|
results.push(fullPath);
|
|
75
76
|
}
|
|
76
77
|
}
|
|
77
|
-
} catch {
|
|
78
|
-
|
|
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.
|
|
79
82
|
}
|
|
80
83
|
return results;
|
|
81
84
|
}
|
|
@@ -837,7 +840,8 @@ function mergeFileResults(results) {
|
|
|
837
840
|
return { buckets: aggregateToBuckets(entries), sessions };
|
|
838
841
|
}
|
|
839
842
|
|
|
840
|
-
async function parseNativeCodex({ codexExtraHome } = {}) {
|
|
843
|
+
async function parseNativeCodex({ codexExtraHome, extraRoots = [] } = {}) {
|
|
844
|
+
let extraCodexHomePath = null;
|
|
841
845
|
if (codexExtraHome?.trim()) {
|
|
842
846
|
const validation = validateExtraCodexHome(codexExtraHome);
|
|
843
847
|
if (!validation.ok) {
|
|
@@ -848,11 +852,31 @@ async function parseNativeCodex({ codexExtraHome } = {}) {
|
|
|
848
852
|
warnings: [`codex: 额外 Codex Home 不可用,已跳过本次 Codex 同步: ${validation.path}`],
|
|
849
853
|
};
|
|
850
854
|
}
|
|
855
|
+
extraCodexHomePath = validation.path;
|
|
851
856
|
}
|
|
852
857
|
|
|
853
|
-
const
|
|
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
|
+
}
|
|
871
|
+
|
|
872
|
+
const strictHomes = new Set(configuredHomes);
|
|
873
|
+
if (extraCodexHomePath) strictHomes.add(extraCodexHomePath);
|
|
874
|
+
const codexHomes = [...new Set([
|
|
875
|
+
...resolveCodexHomes(codexExtraHome),
|
|
876
|
+
...configuredHomes,
|
|
877
|
+
])];
|
|
854
878
|
const dirs = codexHomes.flatMap(codexHome => (
|
|
855
|
-
codexSessionDirs(codexHome).map(dir => ({ codexHome, dir }))
|
|
879
|
+
codexSessionDirs(codexHome).map(dir => ({ codexHome, dir, strict: strictHomes.has(codexHome) }))
|
|
856
880
|
));
|
|
857
881
|
if (!dirs.some(({ dir }) => existsSync(dir))) return { buckets: [], sessions: [] };
|
|
858
882
|
|
|
@@ -868,8 +892,17 @@ async function parseNativeCodex({ codexExtraHome } = {}) {
|
|
|
868
892
|
audited: 0,
|
|
869
893
|
};
|
|
870
894
|
const files = [];
|
|
871
|
-
for (const { codexHome, dir } of dirs) {
|
|
872
|
-
|
|
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) {
|
|
873
906
|
try {
|
|
874
907
|
const stat = statSync(filePath);
|
|
875
908
|
if (stat.size <= 0) continue;
|
|
@@ -880,6 +913,7 @@ async function parseNativeCodex({ codexExtraHome } = {}) {
|
|
|
880
913
|
const file = {
|
|
881
914
|
codexHome,
|
|
882
915
|
filePath,
|
|
916
|
+
strict,
|
|
883
917
|
snapshotSize: stat.size,
|
|
884
918
|
signature,
|
|
885
919
|
cache,
|
|
@@ -890,9 +924,14 @@ async function parseNativeCodex({ codexExtraHome } = {}) {
|
|
|
890
924
|
};
|
|
891
925
|
if (!cache && priorCache) file.appendTail = tailStateFor(file);
|
|
892
926
|
files.push(file);
|
|
893
|
-
} catch {
|
|
927
|
+
} catch (err) {
|
|
894
928
|
// The file may move to archived_sessions between discovery and stat.
|
|
895
|
-
|
|
929
|
+
if (strict && err?.code !== 'ENOENT') {
|
|
930
|
+
return {
|
|
931
|
+
buckets: [], sessions: [], skipped: true,
|
|
932
|
+
warnings: [`codex: 额外根目录读取失败,已保留上次同步数据: ${codexHome}`],
|
|
933
|
+
};
|
|
934
|
+
}
|
|
896
935
|
}
|
|
897
936
|
}
|
|
898
937
|
}
|
|
@@ -928,6 +967,12 @@ async function parseNativeCodex({ codexExtraHome } = {}) {
|
|
|
928
967
|
cacheStats.filesRead++;
|
|
929
968
|
updateFileCache(file, { header: file.header });
|
|
930
969
|
} catch {
|
|
970
|
+
if (file.strict) {
|
|
971
|
+
return {
|
|
972
|
+
buckets: [], sessions: [], skipped: true,
|
|
973
|
+
warnings: [`codex: 额外根目录读取失败,已保留上次同步数据: ${file.codexHome}`],
|
|
974
|
+
};
|
|
975
|
+
}
|
|
931
976
|
continue;
|
|
932
977
|
}
|
|
933
978
|
}
|
|
@@ -981,6 +1026,12 @@ async function parseNativeCodex({ codexExtraHome } = {}) {
|
|
|
981
1026
|
cacheStats.filesRead++;
|
|
982
1027
|
updateFileCache(file, { index: meta });
|
|
983
1028
|
} catch {
|
|
1029
|
+
if (file.strict) {
|
|
1030
|
+
return {
|
|
1031
|
+
buckets: [], sessions: [], skipped: true,
|
|
1032
|
+
warnings: [`codex: 额外根目录读取失败,已保留上次同步数据: ${file.codexHome}`],
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
984
1035
|
continue;
|
|
985
1036
|
}
|
|
986
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);
|
package/src/parsers/grok.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { createReadStream, existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
1
|
+
import { createReadStream, existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
2
|
import { createInterface } from 'node:readline';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { findGrokDataDirs, getGrokSessionsDir } from '../tools.js';
|
|
5
|
+
import { grokSessionsDir, normalizeExtraRoot } from '../extra-roots.js';
|
|
5
6
|
import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
6
7
|
import { readJsonSafe, projectFromPath } from './fs-utils.js';
|
|
7
8
|
|
|
@@ -25,14 +26,14 @@ const SOURCE = 'grok';
|
|
|
25
26
|
*/
|
|
26
27
|
|
|
27
28
|
/** Decode a sessions group dirname; fall back to basename after decode. */
|
|
28
|
-
function projectFromGroupDir(groupName, groupPath) {
|
|
29
|
+
function projectFromGroupDir(groupName, groupPath, strict = false) {
|
|
29
30
|
const cwdFile = join(groupPath, '.cwd');
|
|
30
31
|
if (existsSync(cwdFile)) {
|
|
31
32
|
try {
|
|
32
33
|
const raw = readFileSync(cwdFile, 'utf-8').trim();
|
|
33
34
|
if (raw) return projectFromPath(raw);
|
|
34
|
-
} catch {
|
|
35
|
-
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if (strict) throw err;
|
|
36
37
|
}
|
|
37
38
|
}
|
|
38
39
|
try {
|
|
@@ -115,12 +116,13 @@ function emitTurnUsage(entries, { usage, project, timestamp, fallbackModel }) {
|
|
|
115
116
|
});
|
|
116
117
|
}
|
|
117
118
|
|
|
118
|
-
async function forEachJsonlLine(filePath, onLine) {
|
|
119
|
+
async function forEachJsonlLine(filePath, onLine, strict = false) {
|
|
119
120
|
if (!existsSync(filePath)) return;
|
|
120
121
|
let stream;
|
|
121
122
|
try {
|
|
122
123
|
stream = createReadStream(filePath, { encoding: 'utf-8' });
|
|
123
|
-
} catch {
|
|
124
|
+
} catch (err) {
|
|
125
|
+
if (strict) throw err;
|
|
124
126
|
return;
|
|
125
127
|
}
|
|
126
128
|
|
|
@@ -137,7 +139,8 @@ async function forEachJsonlLine(filePath, onLine) {
|
|
|
137
139
|
}
|
|
138
140
|
onLine(obj);
|
|
139
141
|
}
|
|
140
|
-
} catch {
|
|
142
|
+
} catch (err) {
|
|
143
|
+
if (strict) throw err;
|
|
141
144
|
// unreadable / truncated mid-write — keep what we have
|
|
142
145
|
} finally {
|
|
143
146
|
rl.close();
|
|
@@ -145,14 +148,18 @@ async function forEachJsonlLine(filePath, onLine) {
|
|
|
145
148
|
}
|
|
146
149
|
}
|
|
147
150
|
|
|
148
|
-
function listSessionDirs(sessionsDir) {
|
|
151
|
+
function listSessionDirs(sessionsDir, strict = false) {
|
|
149
152
|
const results = [];
|
|
150
|
-
if (!existsSync(sessionsDir))
|
|
153
|
+
if (!existsSync(sessionsDir)) {
|
|
154
|
+
if (strict) throw new Error(`missing sessions directory: ${sessionsDir}`);
|
|
155
|
+
return results;
|
|
156
|
+
}
|
|
151
157
|
|
|
152
158
|
let groups;
|
|
153
159
|
try {
|
|
154
160
|
groups = readdirSync(sessionsDir, { withFileTypes: true });
|
|
155
|
-
} catch {
|
|
161
|
+
} catch (err) {
|
|
162
|
+
if (strict) throw err;
|
|
156
163
|
return results;
|
|
157
164
|
}
|
|
158
165
|
|
|
@@ -163,11 +170,12 @@ function listSessionDirs(sessionsDir) {
|
|
|
163
170
|
let children;
|
|
164
171
|
try {
|
|
165
172
|
children = readdirSync(groupPath, { withFileTypes: true });
|
|
166
|
-
} catch {
|
|
173
|
+
} catch (err) {
|
|
174
|
+
if (strict) throw err;
|
|
167
175
|
continue;
|
|
168
176
|
}
|
|
169
177
|
|
|
170
|
-
const projectFallback = projectFromGroupDir(group.name, groupPath);
|
|
178
|
+
const projectFallback = projectFromGroupDir(group.name, groupPath, strict);
|
|
171
179
|
|
|
172
180
|
for (const child of children) {
|
|
173
181
|
if (!child.isDirectory()) continue;
|
|
@@ -194,8 +202,29 @@ function listSessionDirs(sessionsDir) {
|
|
|
194
202
|
* Parse all Grok sessions under the configured sessions root(s).
|
|
195
203
|
* @returns {Promise<{ buckets: object[], sessions: object[] }>}
|
|
196
204
|
*/
|
|
197
|
-
export async function parse() {
|
|
198
|
-
|
|
205
|
+
export async function parse({ extraRoots = [] } = {}) {
|
|
206
|
+
if (!process.env.VIBE_USAGE_GROK_SESSIONS?.trim()) {
|
|
207
|
+
for (const root of extraRoots) {
|
|
208
|
+
const sessionsDir = grokSessionsDir(root);
|
|
209
|
+
try {
|
|
210
|
+
if (!statSync(sessionsDir).isDirectory()) throw new Error('not a directory');
|
|
211
|
+
} catch {
|
|
212
|
+
return {
|
|
213
|
+
buckets: [],
|
|
214
|
+
sessions: [],
|
|
215
|
+
skipped: true,
|
|
216
|
+
warnings: [`grok: 额外根目录不可用,已跳过本次 Grok 同步: ${normalizeExtraRoot(root)}`],
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const strictRoots = process.env.VIBE_USAGE_GROK_SESSIONS?.trim()
|
|
222
|
+
? new Set()
|
|
223
|
+
: new Set(extraRoots.map(grokSessionsDir));
|
|
224
|
+
const sessionRoots = findGrokDataDirs(extraRoots);
|
|
225
|
+
for (const configuredRoot of strictRoots) {
|
|
226
|
+
if (!sessionRoots.includes(configuredRoot)) sessionRoots.push(configuredRoot);
|
|
227
|
+
}
|
|
199
228
|
// findGrokDataDirs returns sessions dirs; also allow empty → try default once
|
|
200
229
|
const roots = sessionRoots.length > 0 ? sessionRoots : [getGrokSessionsDir()].filter(existsSync);
|
|
201
230
|
if (roots.length === 0) return { buckets: [], sessions: [] };
|
|
@@ -203,9 +232,58 @@ export async function parse() {
|
|
|
203
232
|
const entries = [];
|
|
204
233
|
const sessionEvents = [];
|
|
205
234
|
|
|
235
|
+
const candidates = [];
|
|
206
236
|
for (const sessionsDir of roots) {
|
|
207
|
-
|
|
208
|
-
|
|
237
|
+
const strict = strictRoots.has(sessionsDir);
|
|
238
|
+
try {
|
|
239
|
+
for (const session of listSessionDirs(sessionsDir, strict)) {
|
|
240
|
+
candidates.push({ ...session, strict, configuredRoot: sessionsDir });
|
|
241
|
+
}
|
|
242
|
+
} catch {
|
|
243
|
+
return {
|
|
244
|
+
buckets: [], sessions: [], skipped: true,
|
|
245
|
+
warnings: [`grok: 额外根目录读取失败,已保留上次同步数据: ${sessionsDir}`],
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let sessionsToParse = candidates;
|
|
251
|
+
if (roots.length > 1) {
|
|
252
|
+
const selectedSessions = new Map();
|
|
253
|
+
for (const session of candidates) {
|
|
254
|
+
const fileSize = (name) => {
|
|
255
|
+
try {
|
|
256
|
+
return statSync(join(session.sessionPath, name)).size;
|
|
257
|
+
} catch {
|
|
258
|
+
return 0;
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
const score = [fileSize('updates.jsonl'), fileSize('events.jsonl'), fileSize('summary.json')];
|
|
262
|
+
const previous = selectedSessions.get(session.sessionId);
|
|
263
|
+
const moreComplete = !previous || score.some((value, index) => (
|
|
264
|
+
value !== previous.score[index] && value > previous.score[index]
|
|
265
|
+
&& score.slice(0, index).every((prior, priorIndex) => prior === previous.score[priorIndex])
|
|
266
|
+
));
|
|
267
|
+
if (moreComplete) selectedSessions.set(session.sessionId, { ...session, score });
|
|
268
|
+
}
|
|
269
|
+
sessionsToParse = [...selectedSessions.values()];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
for (const {
|
|
273
|
+
sessionId,
|
|
274
|
+
sessionPath,
|
|
275
|
+
projectFallback,
|
|
276
|
+
strict,
|
|
277
|
+
configuredRoot,
|
|
278
|
+
} of sessionsToParse) {
|
|
279
|
+
try {
|
|
280
|
+
const summaryPath = join(sessionPath, 'summary.json');
|
|
281
|
+
let summary;
|
|
282
|
+
if (strict && existsSync(summaryPath)) {
|
|
283
|
+
summary = JSON.parse(readFileSync(summaryPath, 'utf-8'));
|
|
284
|
+
} else {
|
|
285
|
+
summary = readJsonSafe(summaryPath) || {};
|
|
286
|
+
}
|
|
209
287
|
const cwd = summary.info?.cwd || summary.git_root_dir || null;
|
|
210
288
|
const project = cwd ? projectFromPath(cwd) : projectFallback;
|
|
211
289
|
const fallbackModel = summary.current_model_id || 'unknown';
|
|
@@ -249,7 +327,7 @@ export async function parse() {
|
|
|
249
327
|
role: 'assistant',
|
|
250
328
|
});
|
|
251
329
|
}
|
|
252
|
-
});
|
|
330
|
+
}, strict);
|
|
253
331
|
|
|
254
332
|
// Fallback timing from events.jsonl when updates lack message chunks
|
|
255
333
|
// (short/aborted sessions, older builds).
|
|
@@ -274,7 +352,7 @@ export async function parse() {
|
|
|
274
352
|
role: 'assistant',
|
|
275
353
|
});
|
|
276
354
|
}
|
|
277
|
-
});
|
|
355
|
+
}, strict);
|
|
278
356
|
}
|
|
279
357
|
|
|
280
358
|
// Last-resort session envelope from summary timestamps so a session with
|
|
@@ -301,6 +379,12 @@ export async function parse() {
|
|
|
301
379
|
});
|
|
302
380
|
}
|
|
303
381
|
}
|
|
382
|
+
} catch (err) {
|
|
383
|
+
if (!strict) throw err;
|
|
384
|
+
return {
|
|
385
|
+
buckets: [], sessions: [], skipped: true,
|
|
386
|
+
warnings: [`grok: 额外根目录读取失败,已保留上次同步数据: ${configuredRoot}`],
|
|
387
|
+
};
|
|
304
388
|
}
|
|
305
389
|
}
|
|
306
390
|
|
package/src/parsers/index.js
CHANGED
|
@@ -20,6 +20,7 @@ import { parse as parseDsh } from './dsh.js';
|
|
|
20
20
|
import { parse as parseAntigravity } from './antigravity.js';
|
|
21
21
|
import { parse as parseHermes } from './hermes.js';
|
|
22
22
|
import { parse as parseKiro } from './kiro.js';
|
|
23
|
+
import { parse as parseMcode } from './mcode.js';
|
|
23
24
|
import { parse as parseMimocode } from './mimocode.js';
|
|
24
25
|
import { parse as parsePiCodingAgent } from './pi-coding-agent.js';
|
|
25
26
|
import { parse as parseZcode } from './zcode.js';
|
|
@@ -49,6 +50,7 @@ export const parsers = {
|
|
|
49
50
|
'trae-cli': parseTraeCli,
|
|
50
51
|
'hermes': parseHermes,
|
|
51
52
|
'kiro': parseKiro,
|
|
53
|
+
'mcode': parseMcode,
|
|
52
54
|
'mimocode': parseMimocode,
|
|
53
55
|
'cline': parseCline,
|
|
54
56
|
'roo-code': parseRooCode,
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { projectFromPath } from './fs-utils.js';
|
|
3
|
+
import { aggregateToBuckets } from './aggregate.js';
|
|
4
|
+
import {
|
|
5
|
+
queryDbJsonSnapshotOnLock,
|
|
6
|
+
isSqliteUnavailableError,
|
|
7
|
+
sqliteUnavailableError,
|
|
8
|
+
} from './sqlite.js';
|
|
9
|
+
import { getMcodeDbPath } from '../tools.js';
|
|
10
|
+
|
|
11
|
+
const SOURCE = 'mcode';
|
|
12
|
+
|
|
13
|
+
// Strict column allow-list. The mcode token table also stores a `raw` JSON
|
|
14
|
+
// payload (and the sessions table stores `record_json` / `extra_data_json`)
|
|
15
|
+
// that contains message bodies — we never select those, neither in this
|
|
16
|
+
// parser nor in any test fixture.
|
|
17
|
+
const TOKEN_COLUMNS = [
|
|
18
|
+
'session_id',
|
|
19
|
+
'model',
|
|
20
|
+
'ts',
|
|
21
|
+
'input_tokens',
|
|
22
|
+
'output_tokens',
|
|
23
|
+
'reasoning_tokens',
|
|
24
|
+
'cache_read_tokens',
|
|
25
|
+
'cache_write_tokens',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
// `local_runtime_sessions` carries both `workspace_dir` (per-session scratch
|
|
29
|
+
// dir, always present) and `project_workspace_dir` (the project root when one
|
|
30
|
+
// is known). We pick the project column first, then the workspace, then
|
|
31
|
+
// fall back to "unknown". Never read `record_json` / `extra_data_json`.
|
|
32
|
+
const SESSION_COLUMNS = ['session_id', 'workspace_dir', 'project_workspace_dir'];
|
|
33
|
+
|
|
34
|
+
// Keep token rows and their project metadata in one SQLite statement. Separate
|
|
35
|
+
// reads can observe different WAL snapshots while mcode is writing, causing a
|
|
36
|
+
// token row to be uploaded once as "unknown" and again under its real project.
|
|
37
|
+
const USAGE_SQL = `
|
|
38
|
+
SELECT
|
|
39
|
+
${TOKEN_COLUMNS.map(column => `token.${column}`).join(', ')},
|
|
40
|
+
session.workspace_dir,
|
|
41
|
+
session.project_workspace_dir
|
|
42
|
+
FROM local_runtime_token_usage AS token
|
|
43
|
+
LEFT JOIN local_runtime_sessions AS session
|
|
44
|
+
ON session.session_id = token.session_id
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolve the mcode runtime-state SQLite database. Mirrors the precedence used
|
|
49
|
+
* by sibling tools (MiMoCode, DimAgent): explicit env var wins, then a
|
|
50
|
+
* tool-specific HOME, then the default layout.
|
|
51
|
+
*
|
|
52
|
+
* Defaults to `<homedir()>/.minimax/v2/sqlite/runtime-state.sqlite`, which is
|
|
53
|
+
* where the mcode CLI keeps its WAL database on macOS / Linux.
|
|
54
|
+
*/
|
|
55
|
+
export function resolveMcodeDbPath(env = process.env) {
|
|
56
|
+
return getMcodeDbPath(env);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Token count (ms vs seconds). The mcode runtime writes ts as integer
|
|
61
|
+
* milliseconds — confirmed against the live schema (`typeof(ts)=integer`,
|
|
62
|
+
* values ≈ 1.787e12 for 2026-08-29). Stay defensive: anything < 1e12 is
|
|
63
|
+
* treated as seconds and scaled up.
|
|
64
|
+
*/
|
|
65
|
+
function tsToDate(value) {
|
|
66
|
+
const n = Number(value);
|
|
67
|
+
if (!Number.isFinite(n)) return null;
|
|
68
|
+
const ms = n < 1e12 ? n * 1000 : n;
|
|
69
|
+
const d = new Date(ms);
|
|
70
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
function toNonNegative(value) {
|
|
76
|
+
const n = Number(value);
|
|
77
|
+
if (!Number.isFinite(n) || n < 0) return 0;
|
|
78
|
+
return n;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function dbHasColumns(dbPath, table, columns) {
|
|
82
|
+
const info = queryDbJsonSnapshotOnLock(
|
|
83
|
+
dbPath,
|
|
84
|
+
`PRAGMA table_info(${table})`,
|
|
85
|
+
{ tempPrefix: 'vibe-usage-mcode' },
|
|
86
|
+
);
|
|
87
|
+
const present = new Set(info.map(row => String(row.name)));
|
|
88
|
+
return columns.every(col => present.has(col));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function parse() {
|
|
92
|
+
const dbPath = resolveMcodeDbPath();
|
|
93
|
+
if (!existsSync(dbPath)) return { buckets: [], sessions: [] };
|
|
94
|
+
|
|
95
|
+
// Schema guard: every allow-listed column must exist. If the mcode
|
|
96
|
+
// runtime ever renames / drops a column, fail soft (skipped) so the
|
|
97
|
+
// incremental sync keeps the last good upload state for this source.
|
|
98
|
+
let schemaOk;
|
|
99
|
+
try {
|
|
100
|
+
schemaOk =
|
|
101
|
+
dbHasColumns(dbPath, 'local_runtime_token_usage', TOKEN_COLUMNS) &&
|
|
102
|
+
dbHasColumns(dbPath, 'local_runtime_sessions', SESSION_COLUMNS);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('mcode');
|
|
105
|
+
return { buckets: [], sessions: [], skipped: true };
|
|
106
|
+
}
|
|
107
|
+
if (!schemaOk) {
|
|
108
|
+
return { buckets: [], sessions: [], skipped: true };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Read tokens and session project metadata from one statement/snapshot.
|
|
112
|
+
let usageRows;
|
|
113
|
+
try {
|
|
114
|
+
usageRows = queryDbJsonSnapshotOnLock(dbPath, USAGE_SQL, {
|
|
115
|
+
tempPrefix: 'vibe-usage-mcode',
|
|
116
|
+
});
|
|
117
|
+
} catch (err) {
|
|
118
|
+
if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('mcode');
|
|
119
|
+
return { buckets: [], sessions: [], skipped: true };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const entries = [];
|
|
123
|
+
|
|
124
|
+
for (const row of usageRows) {
|
|
125
|
+
const sessionId = row.session_id != null ? String(row.session_id) : '';
|
|
126
|
+
if (!sessionId) continue;
|
|
127
|
+
const ts = tsToDate(row.ts);
|
|
128
|
+
if (!ts) continue;
|
|
129
|
+
|
|
130
|
+
// MCode stores output and reasoning as separate counters. Its own
|
|
131
|
+
// summary code computes total = input + output + reasoning, so do not
|
|
132
|
+
// subtract reasoning from output here.
|
|
133
|
+
const inputRaw = toNonNegative(row.input_tokens);
|
|
134
|
+
const cacheWrite = toNonNegative(row.cache_write_tokens);
|
|
135
|
+
const outputRaw = toNonNegative(row.output_tokens);
|
|
136
|
+
const reasoningRaw = toNonNegative(row.reasoning_tokens);
|
|
137
|
+
const cacheRead = toNonNegative(row.cache_read_tokens);
|
|
138
|
+
|
|
139
|
+
const inputTokens = inputRaw + cacheWrite;
|
|
140
|
+
const reasoningOutputTokens = reasoningRaw;
|
|
141
|
+
const outputTokens = outputRaw;
|
|
142
|
+
const cachedInputTokens = cacheRead;
|
|
143
|
+
|
|
144
|
+
if (
|
|
145
|
+
inputTokens +
|
|
146
|
+
outputTokens +
|
|
147
|
+
cachedInputTokens +
|
|
148
|
+
reasoningOutputTokens ===
|
|
149
|
+
0
|
|
150
|
+
) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const projectPath = row.project_workspace_dir || row.workspace_dir;
|
|
155
|
+
const project = projectPath ? projectFromPath(String(projectPath)) : 'unknown';
|
|
156
|
+
const model = row.model != null && String(row.model).trim()
|
|
157
|
+
? String(row.model).trim()
|
|
158
|
+
: 'unknown';
|
|
159
|
+
|
|
160
|
+
entries.push({
|
|
161
|
+
source: SOURCE,
|
|
162
|
+
model,
|
|
163
|
+
project,
|
|
164
|
+
timestamp: ts,
|
|
165
|
+
inputTokens,
|
|
166
|
+
outputTokens,
|
|
167
|
+
cachedInputTokens,
|
|
168
|
+
reasoningOutputTokens,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
buckets: aggregateToBuckets(entries),
|
|
174
|
+
// The token ledger contains assistant usage rows only. Reconstructing
|
|
175
|
+
// user prompts would require reading message payloads, which this parser
|
|
176
|
+
// deliberately never selects, so mcode emits buckets only like Alma.
|
|
177
|
+
sessions: [],
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Re-export for tests / external consumers.
|
|
182
|
+
export { SOURCE as MCODE_SOURCE };
|
|
@@ -1,12 +1,27 @@
|
|
|
1
|
+
import { normalizeExtraRoot, piSessionsDir } from '../extra-roots.js';
|
|
1
2
|
import { getPiSessionDirs } from '../pi-roots.js';
|
|
2
3
|
import { mergeCindyHarnessUsage, readCindyHarnessUsage } from './cindy-ledger.js';
|
|
3
4
|
import { parsePiSessionJsonl } from './pi-session-jsonl.js';
|
|
4
5
|
|
|
5
6
|
/** Parse the official Pi agent's Pi-compatible JSONL sessions. */
|
|
6
|
-
export async function parse() {
|
|
7
|
+
export async function parse({ extraRoots = [] } = {}) {
|
|
8
|
+
for (const root of extraRoots) {
|
|
9
|
+
// piSessionsDir re-resolves the root's shape, so an agent home that lost
|
|
10
|
+
// its `sessions/` child is caught here too, not just a root that vanished.
|
|
11
|
+
if (piSessionsDir(root) !== null) continue;
|
|
12
|
+
// An explicitly configured root that is momentarily unreadable is not
|
|
13
|
+
// proof that its usage disappeared, so skip instead of reporting empty.
|
|
14
|
+
return {
|
|
15
|
+
buckets: [],
|
|
16
|
+
sessions: [],
|
|
17
|
+
skipped: true,
|
|
18
|
+
warnings: [`pi-coding-agent: 额外根目录不可用,已跳过本次 Pi 同步: ${normalizeExtraRoot(root)}`],
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
7
22
|
const nativeResult = await parsePiSessionJsonl({
|
|
8
23
|
source: 'pi-coding-agent',
|
|
9
|
-
sessionsDirs: getPiSessionDirs(),
|
|
24
|
+
sessionsDirs: getPiSessionDirs(extraRoots),
|
|
10
25
|
});
|
|
11
26
|
return mergeCindyHarnessUsage(nativeResult, readCindyHarnessUsage('pi'));
|
|
12
27
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
2
2
|
import { basename, join, relative } from 'node:path';
|
|
3
3
|
import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
4
4
|
import { projectFromCwd, toCount } from './fs-utils.js';
|
|
@@ -38,6 +38,19 @@ export function projectFromFirstDir(filePath, sessionsDir) {
|
|
|
38
38
|
return first.split('-').filter(Boolean).at(-1) || 'unknown';
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// Configured stores can overlap: an ancestor and its descendant, or two paths
|
|
42
|
+
// that resolve to the same place through a symlink. Record-level dedup only
|
|
43
|
+
// covers entries carrying an `id`, so the same anonymous record would be
|
|
44
|
+
// counted once per path that reaches it. Collapse on the canonical file path
|
|
45
|
+
// instead, which also folds symlinked duplicates of a single file.
|
|
46
|
+
function canonicalFilePath(filePath) {
|
|
47
|
+
try {
|
|
48
|
+
return realpathSync.native(filePath);
|
|
49
|
+
} catch {
|
|
50
|
+
return filePath;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
41
54
|
export async function parsePiSessionJsonl({
|
|
42
55
|
source,
|
|
43
56
|
sessionsDirs,
|
|
@@ -49,9 +62,14 @@ export async function parsePiSessionJsonl({
|
|
|
49
62
|
const anonymousEntries = [];
|
|
50
63
|
const eventsById = new Map();
|
|
51
64
|
const anonymousEvents = [];
|
|
65
|
+
const seenFiles = new Set();
|
|
52
66
|
|
|
53
67
|
for (const sessionsDir of sessionsDirs) {
|
|
54
68
|
for (const filePath of findJsonlFiles(sessionsDir, includeFile, ctx)) {
|
|
69
|
+
const canonical = canonicalFilePath(filePath);
|
|
70
|
+
if (seenFiles.has(canonical)) continue;
|
|
71
|
+
seenFiles.add(canonical);
|
|
72
|
+
|
|
55
73
|
let content;
|
|
56
74
|
try {
|
|
57
75
|
content = readFileSync(filePath, 'utf8');
|
|
@@ -99,7 +117,10 @@ export async function parsePiSessionJsonl({
|
|
|
99
117
|
if (message.role !== 'assistant' || !message.usage) continue;
|
|
100
118
|
const usage = message.usage;
|
|
101
119
|
const inputTokens = toCount(usage.input) + toCount(usage.cacheWrite);
|
|
102
|
-
|
|
120
|
+
// Pi's Usage type names this field `reasoning` (a documented subset of
|
|
121
|
+
// `output`); older/adjacent stores wrote `reasoningTokens`. Reading only
|
|
122
|
+
// the latter left every Pi reasoning token inside outputTokens.
|
|
123
|
+
const reasoningOutputTokens = toCount(usage.reasoning ?? usage.reasoningTokens);
|
|
103
124
|
// OMP/Pi usage.output includes reasoning; the shared bucket contract
|
|
104
125
|
// stores non-reasoning output and reasoning separately.
|
|
105
126
|
const outputTokens = Math.max(0, toCount(usage.output) - reasoningOutputTokens);
|