@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.
@@ -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
- // ignore unreadable directories
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 codexHomes = resolveCodexHomes(codexExtraHome);
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
- for (const filePath of findJsonlFiles(dir)) {
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
- // Its archived copy will be picked up on the next sync.
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
  }
@@ -68,7 +68,19 @@ function decodeJwtSub(token) {
68
68
  }
69
69
  }
70
70
 
71
- const FETCH_TIMEOUT_MS = 10_000;
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) return { buckets: [], sessions: [], skipped: true };
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);
@@ -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
- // ignore
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)) return results;
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
- const sessionRoots = findGrokDataDirs();
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
- for (const { sessionId, sessionPath, projectFallback } of listSessionDirs(sessionsDir)) {
208
- const summary = readJsonSafe(join(sessionPath, 'summary.json')) || {};
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
 
@@ -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
- const reasoningOutputTokens = toCount(usage.reasoningTokens);
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);