@geoqiao/pi-usage 0.1.0

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.
Files changed (62) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +222 -0
  3. package/bin/pi-usage.js +69 -0
  4. package/data/models.dev-LICENSE +21 -0
  5. package/data/prices.json +2678 -0
  6. package/extensions/usage-report.js +36 -0
  7. package/package.json +51 -0
  8. package/src/analytics.js +189 -0
  9. package/src/collect.js +34 -0
  10. package/src/network.js +25 -0
  11. package/src/report.js +43 -0
  12. package/vendor/vibe-usage/NOTICE.md +58 -0
  13. package/vendor/vibe-usage/src/cindy-roots.js +85 -0
  14. package/vendor/vibe-usage/src/claude-roots.js +165 -0
  15. package/vendor/vibe-usage/src/cline-roots.js +40 -0
  16. package/vendor/vibe-usage/src/codex-roots.js +46 -0
  17. package/vendor/vibe-usage/src/craft-roots.js +15 -0
  18. package/vendor/vibe-usage/src/extra-roots.js +312 -0
  19. package/vendor/vibe-usage/src/parsers/aggregate.js +196 -0
  20. package/vendor/vibe-usage/src/parsers/alma.js +94 -0
  21. package/vendor/vibe-usage/src/parsers/amp.js +156 -0
  22. package/vendor/vibe-usage/src/parsers/antigravity-db.js +359 -0
  23. package/vendor/vibe-usage/src/parsers/antigravity.js +530 -0
  24. package/vendor/vibe-usage/src/parsers/cindy-ledger.js +157 -0
  25. package/vendor/vibe-usage/src/parsers/claude-code.js +372 -0
  26. package/vendor/vibe-usage/src/parsers/cline.js +92 -0
  27. package/vendor/vibe-usage/src/parsers/codex-cache.js +138 -0
  28. package/vendor/vibe-usage/src/parsers/codex.js +1198 -0
  29. package/vendor/vibe-usage/src/parsers/contract.js +55 -0
  30. package/vendor/vibe-usage/src/parsers/copilot-cli.js +128 -0
  31. package/vendor/vibe-usage/src/parsers/craft-agent.js +21 -0
  32. package/vendor/vibe-usage/src/parsers/cursor.js +262 -0
  33. package/vendor/vibe-usage/src/parsers/dimagent.js +127 -0
  34. package/vendor/vibe-usage/src/parsers/droid.js +113 -0
  35. package/vendor/vibe-usage/src/parsers/dsh.js +563 -0
  36. package/vendor/vibe-usage/src/parsers/fs-utils.js +36 -0
  37. package/vendor/vibe-usage/src/parsers/gemini-cli.js +190 -0
  38. package/vendor/vibe-usage/src/parsers/grok.js +395 -0
  39. package/vendor/vibe-usage/src/parsers/hermes.js +123 -0
  40. package/vendor/vibe-usage/src/parsers/index.js +61 -0
  41. package/vendor/vibe-usage/src/parsers/kimi-code.js +467 -0
  42. package/vendor/vibe-usage/src/parsers/kiro.js +788 -0
  43. package/vendor/vibe-usage/src/parsers/mcode.js +182 -0
  44. package/vendor/vibe-usage/src/parsers/mimocode.js +88 -0
  45. package/vendor/vibe-usage/src/parsers/omp.js +10 -0
  46. package/vendor/vibe-usage/src/parsers/openclaw.js +142 -0
  47. package/vendor/vibe-usage/src/parsers/opencode.js +151 -0
  48. package/vendor/vibe-usage/src/parsers/pi-coding-agent.js +27 -0
  49. package/vendor/vibe-usage/src/parsers/pi-session-jsonl.js +166 -0
  50. package/vendor/vibe-usage/src/parsers/qwen-code.js +122 -0
  51. package/vendor/vibe-usage/src/parsers/roo-code.js +123 -0
  52. package/vendor/vibe-usage/src/parsers/sqlite.js +148 -0
  53. package/vendor/vibe-usage/src/parsers/trae-cli.js +171 -0
  54. package/vendor/vibe-usage/src/parsers/workbuddy.js +322 -0
  55. package/vendor/vibe-usage/src/parsers/zcode.js +115 -0
  56. package/vendor/vibe-usage/src/pi-roots.js +125 -0
  57. package/vendor/vibe-usage/src/tools.js +422 -0
  58. package/vendor/vibe-usage/src/workbuddy-roots.js +22 -0
  59. package/vendor/vibe-usage/upstream-files.json +48 -0
  60. package/web/report.css +10 -0
  61. package/web/report.html +81 -0
  62. package/web/report.js +310 -0
@@ -0,0 +1,530 @@
1
+ import { sourceFetch } from '../../../../src/network.js';
2
+ import { execSync } from 'node:child_process';
3
+ import { readdirSync, statSync } from 'node:fs';
4
+ import { delimiter, join } from 'node:path';
5
+ import { homedir } from 'node:os';
6
+ import { antigravityConversationDirs, normalizeExtraRoot } from '../extra-roots.js';
7
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
8
+ import { listDbCascades, readDbUsageRecords, readDbWorkspaceUri, readDbSessionEvents, readDbStepTimestamps, resolveUsageTimestamp } from './antigravity-db.js';
9
+
10
+
11
+
12
+ /**
13
+ * Antigravity parser.
14
+ *
15
+ * Two conversation stores, two read paths:
16
+ * - `.db` cascades (App 2.0 + `agy` CLI): plain-protobuf SQLite, parsed offline
17
+ * from disk — no running process required (see antigravity-db.js).
18
+ * - `.pb` cascades (legacy App history): encrypted/opaque, only decodable via a
19
+ * running language server's GetCascadeTrajectory RPC (fallback below).
20
+ * A cascade backed by a `.db` never uses RPC, so the two paths never double-count.
21
+ */
22
+
23
+ const SOURCE = 'antigravity';
24
+ const CONVERSATIONS_DIR = join(homedir(), '.gemini', 'antigravity', 'conversations');
25
+ // `agy` CLI stores conversations in a separate, App-independent directory.
26
+ const CLI_CONVERSATIONS_DIR = join(homedir(), '.gemini', 'antigravity-cli', 'conversations');
27
+
28
+ // User sources → role 'user'; Model source → role 'assistant'; System sources → skip
29
+ const USER_SOURCES = new Set([
30
+ 'CORTEX_STEP_SOURCE_USER_EXPLICIT',
31
+ 'CORTEX_STEP_SOURCE_USER_IMPLICIT',
32
+ ]);
33
+ const ASSISTANT_SOURCES = new Set([
34
+ 'CORTEX_STEP_SOURCE_MODEL',
35
+ ]);
36
+
37
+ // ── Process discovery (single instance) ──────────────────────────────
38
+
39
+ const IS_WIN = process.platform === 'win32';
40
+
41
+ /**
42
+ * Find ONE running language server process with a CSRF token.
43
+ * Returns { pid, csrfToken } or null.
44
+ */
45
+ function findLanguageServer() {
46
+ try {
47
+ return IS_WIN ? findLanguageServerWin() : findLanguageServerUnix();
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ function findLanguageServerUnix() {
54
+ const out = execSync("ps aux | grep -i 'antigravity.*language_server'", { encoding: 'utf-8', timeout: 5000 });
55
+ for (const line of out.split('\n')) {
56
+ if (!line.trim()) continue;
57
+ if (line.includes('grep')) continue;
58
+ const parts = line.trim().split(/\s+/);
59
+ if (parts.length < 2) continue;
60
+ const pid = parts[1];
61
+ const csrfMatch = line.match(/--csrf_token\s+([0-9a-f-]+)/);
62
+ const csrfToken = csrfMatch ? csrfMatch[1] : '';
63
+ if (csrfToken) return { pid, csrfToken };
64
+ }
65
+ return null;
66
+ }
67
+
68
+ function findLanguageServerWin() {
69
+ // Prefer PowerShell/CIM: wmic is disabled by default on Windows 11 23H2+
70
+ // and removed entirely from 25H2 onward. Fall back to wmic for old/stripped
71
+ // environments without PowerShell. Each probe is independently time-boxed and
72
+ // failures are swallowed, so a missing/hung tool never blocks the next one or
73
+ // the parsers that run after antigravity.
74
+ const out = queryProcessesWinPowerShell() ?? queryProcessesWinWmic();
75
+ if (!out) return null;
76
+ return parseWinProcessList(out);
77
+ }
78
+
79
+ /**
80
+ * Query language_server processes via PowerShell + CIM.
81
+ * Emits "ProcessId=..." / "CommandLine=..." lines (wmic /format:list shape)
82
+ * so parseWinProcessList handles either source. Returns null on failure.
83
+ */
84
+ function queryProcessesWinPowerShell() {
85
+ // Filter is applied in PowerShell so the LIKE wildcards stay server-side.
86
+ // A "---" separator before each process's ProcessId/CommandLine lines keeps
87
+ // fields grouped even when multiple processes match.
88
+ const script =
89
+ "Get-CimInstance Win32_Process -Filter \"CommandLine LIKE '%antigravity%language_server%'\" | " +
90
+ 'ForEach-Object { "---"; "ProcessId=" + $_.ProcessId; "CommandLine=" + $_.CommandLine }';
91
+ for (const exe of ['powershell.exe', 'pwsh.exe']) {
92
+ try {
93
+ const out = execSync(
94
+ `${exe} -NoProfile -NonInteractive -Command "${script.replace(/"/g, '\\"')}"`,
95
+ { encoding: 'utf-8', timeout: 4000, windowsHide: true },
96
+ );
97
+ if (out && out.trim()) return out;
98
+ // Empty (no matching process) — no point trying another shell.
99
+ return null;
100
+ } catch {
101
+ // Try next shell (pwsh on systems without legacy powershell.exe).
102
+ }
103
+ }
104
+ return null;
105
+ }
106
+
107
+ /** Legacy fallback: wmic /format:list. Returns null on failure. */
108
+ function queryProcessesWinWmic() {
109
+ try {
110
+ return execSync(
111
+ 'wmic process where "CommandLine like \'%antigravity%language_server%\'" get ProcessId,CommandLine /format:list',
112
+ { encoding: 'utf-8', timeout: 4000, shell: 'cmd.exe' },
113
+ );
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Parse "ProcessId=..." / "CommandLine=..." records (from either PowerShell or
121
+ * wmic /format:list) and return the first language_server that carries a
122
+ * --csrf_token, or null. PowerShell emits an explicit "---" separator per
123
+ * process; wmic does not and may emit the two fields in either order, so a
124
+ * record also ends whenever a field we've already captured reappears.
125
+ */
126
+ function parseWinProcessList(out) {
127
+ let pid = '';
128
+ let cmdLine = '';
129
+ const finish = () => {
130
+ if (pid && cmdLine && !/WMIC\.exe|powershell\.exe|pwsh\.exe/i.test(cmdLine)) {
131
+ const csrfMatch = cmdLine.match(/--csrf_token\s+([0-9a-f-]+)/);
132
+ if (csrfMatch) return { pid, csrfToken: csrfMatch[1] };
133
+ }
134
+ return null;
135
+ };
136
+ const reset = () => { pid = ''; cmdLine = ''; };
137
+ for (const line of out.split('\n')) {
138
+ const trimmed = line.trim();
139
+ const isPid = trimmed.startsWith('ProcessId=');
140
+ const isCmd = trimmed.startsWith('CommandLine=');
141
+ // Record boundary: explicit "---", or a field that would overwrite one we
142
+ // already hold (next process began without a separator, e.g. wmic output).
143
+ if (trimmed === '---' || (isPid && pid) || (isCmd && cmdLine)) {
144
+ const found = finish();
145
+ if (found) return found;
146
+ reset();
147
+ }
148
+ if (isPid) pid = trimmed.slice('ProcessId='.length);
149
+ else if (isCmd) cmdLine = trimmed.slice('CommandLine='.length);
150
+ }
151
+ return finish();
152
+ }
153
+
154
+ function findListeningPorts(pid) {
155
+ try {
156
+ return IS_WIN ? findListeningPortsWin(pid) : findListeningPortsUnix(pid);
157
+ } catch {
158
+ return [];
159
+ }
160
+ }
161
+
162
+ function findListeningPortsUnix(pid) {
163
+ const out = execSync(`lsof -iTCP -sTCP:LISTEN -nP -a -p ${pid}`, {
164
+ encoding: 'utf-8',
165
+ timeout: 5000,
166
+ });
167
+ const ports = [];
168
+ for (const line of out.split('\n')) {
169
+ const match = line.match(/:(\d+)\s+\(LISTEN\)/);
170
+ if (match) ports.push(parseInt(match[1], 10));
171
+ }
172
+ return ports;
173
+ }
174
+
175
+ function findListeningPortsWin(pid) {
176
+ // netstat output: TCP 127.0.0.1:49327 0.0.0.0:0 LISTENING 12345
177
+ const out = execSync('netstat -ano', { encoding: 'utf-8', timeout: 5000 });
178
+ const ports = [];
179
+ for (const line of out.split('\n')) {
180
+ if (!line.includes('LISTENING')) continue;
181
+ const parts = line.trim().split(/\s+/);
182
+ // parts: [TCP, local_addr:port, foreign_addr, LISTENING, pid]
183
+ const linePid = parts[parts.length - 1];
184
+ if (linePid !== String(pid)) continue;
185
+ const addrMatch = parts[1]?.match(/:(\d+)$/);
186
+ if (addrMatch) ports.push(parseInt(addrMatch[1], 10));
187
+ }
188
+ return ports;
189
+ }
190
+
191
+ async function rpcPost(baseUrl, path, body, csrfToken, timeoutMs = 10000) {
192
+ const url = new URL(path, baseUrl);
193
+ const headers = {
194
+ 'Content-Type': 'application/json',
195
+ 'Connect-Protocol-Version': '1',
196
+ };
197
+ if (csrfToken) headers['X-Codeium-Csrf-Token'] = csrfToken;
198
+
199
+ const res = await sourceFetch(url, {
200
+ method: 'POST',
201
+ headers,
202
+ body: JSON.stringify(body),
203
+ signal: AbortSignal.timeout(timeoutMs),
204
+ });
205
+ if (!res.ok) throw new Error(`HTTP ${res.status} from ${path}`);
206
+ return res.json();
207
+ }
208
+
209
+ async function probeHttpPort(ports, csrfToken) {
210
+ for (const port of ports) {
211
+ const baseUrl = `http://127.0.0.1:${port}`;
212
+ try {
213
+ await rpcPost(
214
+ baseUrl,
215
+ '/exa.language_server_pb.LanguageServerService/GetWorkspaceInfos',
216
+ {},
217
+ csrfToken,
218
+ 3000,
219
+ );
220
+ return baseUrl;
221
+ } catch {
222
+ // Not the right port, try next
223
+ }
224
+ }
225
+ return null;
226
+ }
227
+
228
+ // ── Helpers ──────────────────────────────────────────────────────────
229
+
230
+ /**
231
+ * Normalize model names to canonical forms.
232
+ */
233
+ // Normalize model names to canonical forms. NOTE: only legacy .pb data (which
234
+ // exposes bare slugs via responseModel) reaches this; .db data uses the
235
+ // human-readable modelDisplayName verbatim (e.g. "Gemini 3.5 Flash (High)"),
236
+ // which is never normalized. Flash reasoning tiers (-a/-b/-c) are intentionally
237
+ // NOT merged: each tier is a distinct choice and left as-is ("as it is").
238
+ const MODEL_NORMALIZE_MAP = {
239
+ 'claude-opus-4-6-thinking': 'claude-opus-4-6',
240
+ 'claude-sonnet-4-6-thinking': 'claude-sonnet-4-6',
241
+ "gemini-3.1-pro-high": "gemini-3.1-pro",
242
+ "gemini-3.1-pro-low": "gemini-3.1-pro",
243
+ "gemini-3-pro-high": "gemini-3-pro",
244
+ "gemini-3-pro-low": "gemini-3-pro",
245
+ };
246
+
247
+ /**
248
+ * Map internal placeholder model IDs to canonical names.
249
+ * Used when responseModel is empty and only chatModel.model is available.
250
+ */
251
+ const PLACEHOLDER_MODEL_MAP = {
252
+ 'MODEL_PLACEHOLDER_M37': 'gemini-3.1-pro',
253
+ 'MODEL_PLACEHOLDER_M36': 'gemini-3.1-pro',
254
+ 'MODEL_PLACEHOLDER_M47': 'gemini-3-flash',
255
+ 'MODEL_PLACEHOLDER_M35': 'claude-sonnet-4-6',
256
+ 'MODEL_PLACEHOLDER_M26': 'claude-opus-4-6',
257
+ 'MODEL_OPENAI_GPT_OSS_120B_MEDIUM': 'gpt-oss-120b',
258
+ };
259
+
260
+ function normalizeModel(raw) {
261
+ return MODEL_NORMALIZE_MAP[raw] || raw;
262
+ }
263
+
264
+ /**
265
+ * Resolve a display model name from a chatModel-like object.
266
+ * Priority: modelDisplayName (real name, e.g. "Gemini 3.5 Flash (High)") →
267
+ * responseModel slug (normalized) → placeholder map → "unknown".
268
+ *
269
+ * modelDisplayName is present on .db data (App 2.0 + CLI) and is authoritative;
270
+ * it is used verbatim (carries the reasoning tier). Legacy .pb data has no
271
+ * display name, so it falls back to the responseModel slug.
272
+ */
273
+ function resolveModel(chatModel) {
274
+ if (chatModel.modelDisplayName) return chatModel.modelDisplayName;
275
+ if (chatModel.responseModel) return normalizeModel(chatModel.responseModel);
276
+ const placeholder = chatModel.model || '';
277
+ if (PLACEHOLDER_MODEL_MAP[placeholder]) return PLACEHOLDER_MODEL_MAP[placeholder];
278
+ return 'unknown';
279
+ }
280
+
281
+ function toSafeNumber(value) {
282
+ if (value == null) return 0;
283
+ const n = Number(value);
284
+ return Number.isFinite(n) ? n : 0;
285
+ }
286
+
287
+ /**
288
+ * Extract project name from a workspace URI (e.g. "file:///Users/x/myproject" → "myproject").
289
+ */
290
+ function projectFromUri(uri) {
291
+ if (!uri) return null;
292
+ const parts = uri.replace(/\/$/, '').split('/');
293
+ return parts[parts.length - 1] || null;
294
+ }
295
+
296
+ /**
297
+ * List cascade IDs backed by a legacy `.pb` file (App history). `.db` cascades
298
+ * are handled separately via offline parsing.
299
+ */
300
+ function listPbCascades(conversationsDir = CONVERSATIONS_DIR) {
301
+ try {
302
+ const out = [];
303
+ for (const f of readdirSync(conversationsDir)) {
304
+ if (f.endsWith('.pb')) out.push(f.slice(0, -3));
305
+ }
306
+ return out;
307
+ } catch {
308
+ return [];
309
+ }
310
+ }
311
+
312
+ // ── Main parse ───────────────────────────────────────────────────────
313
+
314
+ /** Model name for an offline .db record: real display name → slug → unknown. */
315
+ function modelFromRecord(rec) {
316
+ if (rec.displayName) return rec.displayName;
317
+ if (rec.responseModel) return normalizeModel(rec.responseModel);
318
+ return 'unknown';
319
+ }
320
+
321
+ export async function parse({ extraRoots = [] } = {}) {
322
+ const entries = [];
323
+ const sessionEvents = [];
324
+ const seenResponseIds = new Set();
325
+
326
+ const extraDirs = [];
327
+ for (const root of extraRoots) {
328
+ const dirs = antigravityConversationDirs(root);
329
+ let found = false;
330
+ for (const dir of dirs) {
331
+ try {
332
+ readdirSync(dir);
333
+ extraDirs.push(dir);
334
+ found = true;
335
+ } catch (err) {
336
+ if (err?.code === 'ENOENT') continue;
337
+ return {
338
+ buckets: [],
339
+ sessions: [],
340
+ skipped: true,
341
+ warnings: [`antigravity: 额外根目录读取失败,已保留上次同步数据: ${normalizeExtraRoot(root)}`],
342
+ };
343
+ }
344
+ }
345
+ if (!found) {
346
+ return {
347
+ buckets: [],
348
+ sessions: [],
349
+ skipped: true,
350
+ warnings: [`antigravity: 额外根目录不可用,已跳过本次 Antigravity 同步: ${normalizeExtraRoot(root)}`],
351
+ };
352
+ }
353
+ }
354
+
355
+ // ── Path 1: offline .db parsing (App 2.0 + agy CLI, no process needed) ──
356
+ const dbHandled = new Set();
357
+ const fixtureDirs = process.env.VIBE_USAGE_ANTIGRAVITY_DIRS?.trim();
358
+ const defaultDirs = fixtureDirs
359
+ ? fixtureDirs.split(delimiter).filter(Boolean)
360
+ : [CONVERSATIONS_DIR, CLI_CONVERSATIONS_DIR];
361
+ const strictDirs = new Set(extraDirs);
362
+ const conversationDirs = [...new Set([...defaultDirs, ...extraDirs])];
363
+ const candidates = [];
364
+ for (const dir of conversationDirs) {
365
+ const strict = strictDirs.has(dir);
366
+ try {
367
+ for (const cascadeId of listDbCascades(dir, { strict })) {
368
+ candidates.push({ dir, cascadeId, strict });
369
+ }
370
+ } catch {
371
+ return {
372
+ buckets: [], sessions: [], skipped: true,
373
+ warnings: [`antigravity: 额外根目录读取失败,已保留上次同步数据: ${dir}`],
374
+ };
375
+ }
376
+ }
377
+ const configuredCascadeIds = new Set(
378
+ candidates.filter(candidate => candidate.strict).map(candidate => candidate.cascadeId),
379
+ );
380
+ const selectedConfiguredCopies = new Map();
381
+ for (const candidate of candidates) {
382
+ if (!configuredCascadeIds.has(candidate.cascadeId)) continue;
383
+ let size = 0;
384
+ try {
385
+ size = statSync(join(candidate.dir, `${candidate.cascadeId}.db`)).size;
386
+ } catch {
387
+ // The DB may move between discovery and stat; the read below will fail
388
+ // open in the existing offline reader.
389
+ }
390
+ const previous = selectedConfiguredCopies.get(candidate.cascadeId);
391
+ if (!previous || size > previous.size) selectedConfiguredCopies.set(candidate.cascadeId, { ...candidate, size });
392
+ }
393
+
394
+ for (const { dir, cascadeId, strict } of candidates) {
395
+ const selected = selectedConfiguredCopies.get(cascadeId);
396
+ if (selected && selected.dir !== dir) continue;
397
+ try {
398
+ const options = { strict };
399
+ const records = readDbUsageRecords(dir, cascadeId, options);
400
+ const project = projectFromUri(readDbWorkspaceUri(dir, cascadeId)) || 'unknown';
401
+ const stepTimestampsByIdx = records.some((rec) => !rec.timestamp || isNaN(rec.timestamp.getTime()))
402
+ ? readDbStepTimestamps(dir, cascadeId, options)
403
+ : new Map();
404
+
405
+ if (records.length > 0) {
406
+ dbHandled.add(cascadeId);
407
+ for (const rec of records) {
408
+ if (rec.responseId && seenResponseIds.has(rec.responseId)) continue;
409
+ if (rec.responseId) seenResponseIds.add(rec.responseId);
410
+ // Gemini 3.7 CLI blobs dropped chatStartMetadata.createdAt (9.4.1)
411
+ // and modelDisplayName (21). Usage is still in field 4; clock is
412
+ // recovered from steps.metadata at the same idx.
413
+ const timestamp = resolveUsageTimestamp(rec, stepTimestampsByIdx);
414
+ if (!timestamp || isNaN(timestamp.getTime())) continue;
415
+ entries.push({
416
+ source: SOURCE,
417
+ model: modelFromRecord(rec),
418
+ project,
419
+ timestamp,
420
+ inputTokens: toSafeNumber(rec.inputTokens),
421
+ outputTokens: toSafeNumber(rec.outputTokens),
422
+ cachedInputTokens: toSafeNumber(rec.cacheReadTokens),
423
+ reasoningOutputTokens: toSafeNumber(rec.thinkingOutputTokens),
424
+ });
425
+ }
426
+ }
427
+
428
+ // Session timing from steps (independent of token usage presence).
429
+ for (const ev of readDbSessionEvents(dir, cascadeId, options)) {
430
+ sessionEvents.push({
431
+ sessionId: cascadeId,
432
+ source: SOURCE,
433
+ project,
434
+ timestamp: ev.timestamp,
435
+ role: ev.role,
436
+ });
437
+ }
438
+ } catch (err) {
439
+ if (!strict) throw err;
440
+ return {
441
+ buckets: [], sessions: [], skipped: true,
442
+ warnings: [`antigravity: 额外根目录读取失败,已保留上次同步数据: ${dir}`],
443
+ };
444
+ }
445
+ }
446
+
447
+ // ── Path 2: RPC fallback, only for legacy .pb cascades not already parsed ──
448
+ const pbDir = defaultDirs[0] || CONVERSATIONS_DIR;
449
+ const pbCascades = listPbCascades(pbDir).filter((id) => !dbHandled.has(id));
450
+ if (pbCascades.length > 0) {
451
+ const server = findLanguageServer();
452
+ const ports = server ? findListeningPorts(server.pid) : [];
453
+ const baseUrl = ports.length > 0 ? await probeHttpPort(ports, server.csrfToken) : null;
454
+ if (baseUrl) {
455
+ const rpc = (method, body) =>
456
+ rpcPost(
457
+ baseUrl,
458
+ `/exa.language_server_pb.LanguageServerService/${method}`,
459
+ body,
460
+ server.csrfToken,
461
+ );
462
+
463
+ for (const cascadeId of pbCascades) {
464
+ let resp;
465
+ try {
466
+ resp = await rpc('GetCascadeTrajectory', { cascadeId });
467
+ } catch {
468
+ continue;
469
+ }
470
+ const trajectory = resp?.trajectory;
471
+ if (!trajectory) continue;
472
+
473
+ const steps = trajectory.steps || [];
474
+ const metadataList = trajectory.generatorMetadata || [];
475
+
476
+ let project = 'unknown';
477
+ const workspaces = trajectory.metadata?.workspaces || [];
478
+ if (workspaces.length > 0) {
479
+ project = workspaces[0].repository?.computedName
480
+ || projectFromUri(workspaces[0].workspaceFolderAbsoluteUri)
481
+ || 'unknown';
482
+ }
483
+
484
+ for (const meta of metadataList) {
485
+ const chatModel = meta?.chatModel;
486
+ if (!chatModel) continue;
487
+ const model = resolveModel(chatModel);
488
+ const createdAt = chatModel?.chatStartMetadata?.createdAt;
489
+ const ts = createdAt ? new Date(createdAt) : null;
490
+ if (!ts || isNaN(ts.getTime())) continue;
491
+
492
+ for (const retry of (chatModel.retryInfos || [])) {
493
+ const usage = retry.usage;
494
+ if (!usage) continue;
495
+ const responseId = usage.responseId || '';
496
+ if (responseId && seenResponseIds.has(responseId)) continue;
497
+ if (responseId) seenResponseIds.add(responseId);
498
+ entries.push({
499
+ source: SOURCE,
500
+ model,
501
+ project,
502
+ timestamp: ts,
503
+ inputTokens: toSafeNumber(usage.inputTokens),
504
+ outputTokens: toSafeNumber(usage.outputTokens),
505
+ cachedInputTokens: toSafeNumber(usage.cacheReadTokens),
506
+ reasoningOutputTokens: toSafeNumber(usage.thinkingOutputTokens),
507
+ });
508
+ }
509
+ }
510
+
511
+ for (const step of steps) {
512
+ const stepSource = step?.metadata?.source || '';
513
+ let role;
514
+ if (USER_SOURCES.has(stepSource)) role = 'user';
515
+ else if (ASSISTANT_SOURCES.has(stepSource)) role = 'assistant';
516
+ else continue;
517
+ const createdAt = step?.metadata?.createdAt;
518
+ const ts = createdAt ? new Date(createdAt) : null;
519
+ if (!ts || isNaN(ts.getTime())) continue;
520
+ sessionEvents.push({ sessionId: cascadeId, source: SOURCE, project, timestamp: ts, role });
521
+ }
522
+ }
523
+ }
524
+ }
525
+
526
+ return {
527
+ buckets: aggregateToBuckets(entries),
528
+ sessions: extractSessions(sessionEvents),
529
+ };
530
+ }
@@ -0,0 +1,157 @@
1
+ import { findCindyDbPaths } from '../cindy-roots.js';
2
+ import { aggregateToBuckets } from './aggregate.js';
3
+ import { toCount } from './fs-utils.js';
4
+ import {
5
+ isSqliteUnavailableError,
6
+ queryDbJsonSnapshot,
7
+ sqliteUnavailableError,
8
+ } from './sqlite.js';
9
+
10
+ const CINDY_USAGE_SQL = `
11
+ SELECT
12
+ day,
13
+ agent_kind AS agentKind,
14
+ model,
15
+ SUM(input_tokens) AS inputTokens,
16
+ SUM(output_tokens) AS outputTokens,
17
+ SUM(cache_read_tokens) AS cacheReadTokens,
18
+ SUM(cache_create_tokens) AS cacheCreateTokens
19
+ FROM daily_model_usage
20
+ GROUP BY day, agent_kind, model
21
+ ORDER BY day, agent_kind, model
22
+ `;
23
+
24
+ /** Cindy stores its ledger day as local-time YYYY-MM-DD. */
25
+ export function dateFromCindyDay(value) {
26
+ if (typeof value !== 'string') return null;
27
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
28
+ if (!match) return null;
29
+ const year = Number(match[1]);
30
+ const month = Number(match[2]);
31
+ const day = Number(match[3]);
32
+ const date = new Date(year, month - 1, day);
33
+ if (
34
+ date.getFullYear() !== year
35
+ || date.getMonth() !== month - 1
36
+ || date.getDate() !== day
37
+ ) {
38
+ return null;
39
+ }
40
+ return date;
41
+ }
42
+
43
+ function skippedResult(error) {
44
+ const message = error?.message || String(error);
45
+ let reason = 'read failed';
46
+ if (/database is locked/i.test(message)) reason = 'database is locked';
47
+ else if (/no such column/i.test(message)) reason = 'incompatible database schema';
48
+ else if (/unable to open|SQLITE_CANTOPEN/i.test(message)) reason = 'database unavailable';
49
+ return {
50
+ buckets: [],
51
+ sessions: [],
52
+ skipped: true,
53
+ warnings: [`cindy: cannot read usage database (${reason})`],
54
+ };
55
+ }
56
+
57
+ const CINDY_HARNESS_SOURCES = {
58
+ codex: 'codex',
59
+ pi: 'pi-coding-agent',
60
+ };
61
+
62
+ /**
63
+ * Read Cindy's daily ledger for a harness that does not already expose the
64
+ * same raw logs to Vibe Usage. Cindy's Claude Code SDK writes ordinary
65
+ * ~/.claude transcripts, so Claude stays owned by the claude-code parser and
66
+ * is deliberately absent from this map.
67
+ */
68
+ export function readCindyHarnessUsage(agentKind) {
69
+ const source = CINDY_HARNESS_SOURCES[agentKind];
70
+ if (!source) throw new TypeError(`Unsupported Cindy harness: ${agentKind}`);
71
+
72
+ const dbPaths = findCindyDbPaths();
73
+ if (dbPaths.length === 0) return { buckets: [], sessions: [] };
74
+
75
+ const rows = [];
76
+ for (const dbPath of dbPaths) {
77
+ try {
78
+ const dbRows = queryDbJsonSnapshot(dbPath, CINDY_USAGE_SQL, {
79
+ tempPrefix: 'vibe-usage-cindy-',
80
+ });
81
+ for (const row of dbRows) rows.push(row);
82
+ } catch (error) {
83
+ if (isSqliteUnavailableError(error)) throw sqliteUnavailableError('Cindy');
84
+ // Cindy versions before the daily ledger was introduced have no usage
85
+ // rows to import. A second, current regional/account database may still
86
+ // be readable, so skip only this legacy database.
87
+ if (/no such table:\s*daily_model_usage/i.test(error?.message || '')) continue;
88
+ return skippedResult(error);
89
+ }
90
+ }
91
+
92
+ const entries = [];
93
+ for (const row of rows) {
94
+ if (row.agentKind !== agentKind) continue;
95
+ const timestamp = dateFromCindyDay(row.day);
96
+ if (!timestamp) continue;
97
+ const inputTokens = toCount(row.inputTokens) + toCount(row.cacheCreateTokens);
98
+ const outputTokens = toCount(row.outputTokens);
99
+ const cachedInputTokens = toCount(row.cacheReadTokens);
100
+ if (inputTokens + outputTokens + cachedInputTokens === 0) continue;
101
+
102
+ entries.push({
103
+ source,
104
+ model: typeof row.model === 'string' && row.model.trim()
105
+ ? row.model.trim()
106
+ : `${source}-unknown`,
107
+ project: 'unknown',
108
+ timestamp,
109
+ inputTokens,
110
+ outputTokens,
111
+ cachedInputTokens,
112
+ reasoningOutputTokens: 0,
113
+ });
114
+ }
115
+
116
+ return {
117
+ buckets: aggregateToBuckets(entries),
118
+ sessions: [],
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Merge the optional Cindy ledger into the native harness snapshot. A failed
124
+ * Cindy read marks the whole source skipped so its previously uploaded rows
125
+ * are not pruned from incremental state.
126
+ */
127
+ export function mergeCindyHarnessUsage(nativeResult, cindyResult) {
128
+ const warnings = [
129
+ ...(nativeResult.warnings || []),
130
+ ...(cindyResult.warnings || []),
131
+ ];
132
+ if (nativeResult.skipped || cindyResult.skipped) {
133
+ return {
134
+ ...nativeResult,
135
+ buckets: [],
136
+ sessions: [],
137
+ skipped: true,
138
+ warnings,
139
+ };
140
+ }
141
+
142
+ const entries = [];
143
+ for (const bucket of [...nativeResult.buckets, ...cindyResult.buckets]) {
144
+ const timestamp = new Date(bucket.bucketStart);
145
+ if (Number.isNaN(timestamp.getTime())) continue;
146
+ entries.push({
147
+ ...bucket,
148
+ timestamp,
149
+ });
150
+ }
151
+ return {
152
+ ...nativeResult,
153
+ buckets: aggregateToBuckets(entries),
154
+ sessions: nativeResult.sessions || [],
155
+ warnings,
156
+ };
157
+ }