@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,55 @@
1
+ /**
2
+ * Parser result contract.
3
+ *
4
+ * Every parser exports an async parse() returning either
5
+ * { buckets: object[], sessions: object[], skipped?: boolean, warnings?: string[], indexing?: object }
6
+ * or a legacy bare buckets array.
7
+ *
8
+ * buckets entries are the aggregateToBuckets() output shape
9
+ * ({ source, model, project, hostname?, bucketStart, inputTokens, ... }).
10
+ * sessions entries are the extractSessions() output shape
11
+ * ({ source, project, sessionHash, firstMessageAt, ... }).
12
+ */
13
+
14
+ /**
15
+ * Normalize a raw parser return value into a validated shape. Every emitted
16
+ * item's source must match the registry key the parser is registered under;
17
+ * rejecting a typo keeps that parser out of state pruning and prevents a
18
+ * server-side dropped source from silently discarding its prior upload state.
19
+ *
20
+ * @param {string} source registry key
21
+ * @param {unknown} result raw return value
22
+ * @returns {{ buckets: object[], sessions: object[], skipped: boolean, warnings: string[], indexing?: object }}
23
+ */
24
+ export function normalizeParserResult(source, result) {
25
+ const buckets = Array.isArray(result) ? result : result?.buckets;
26
+ const sessions = Array.isArray(result) ? [] : (result?.sessions || []);
27
+ if (!Array.isArray(buckets) || !Array.isArray(sessions)) {
28
+ throw new TypeError('Parser returned an invalid result');
29
+ }
30
+
31
+ for (const bucket of buckets) {
32
+ if (bucket?.source !== source) {
33
+ throw new TypeError(
34
+ 'parser ' + source + ' emitted a bucket with source=' + JSON.stringify(bucket?.source),
35
+ );
36
+ }
37
+ }
38
+ for (const session of sessions) {
39
+ if (session?.source !== source) {
40
+ throw new TypeError(
41
+ 'parser ' + source + ' emitted a session with source=' + JSON.stringify(session?.source),
42
+ );
43
+ }
44
+ }
45
+
46
+ const warnings = Array.isArray(result?.warnings) ? result.warnings.slice() : [];
47
+
48
+ return {
49
+ buckets,
50
+ sessions,
51
+ skipped: result?.skipped === true,
52
+ warnings,
53
+ ...(result?.indexing ? { indexing: result.indexing } : {}),
54
+ };
55
+ }
@@ -0,0 +1,128 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
5
+
6
+ const SESSION_STATE_DIR = join(homedir(), '.copilot', 'session-state');
7
+
8
+ function findEventFiles(baseDir) {
9
+ const results = [];
10
+ if (!existsSync(baseDir)) return results;
11
+
12
+ try {
13
+ for (const entry of readdirSync(baseDir, { withFileTypes: true })) {
14
+ if (!entry.isDirectory()) continue;
15
+
16
+ const eventsFile = join(baseDir, entry.name, 'events.jsonl');
17
+ if (existsSync(eventsFile)) {
18
+ results.push({ filePath: eventsFile, sessionId: entry.name });
19
+ }
20
+ }
21
+ } catch {
22
+ return results;
23
+ }
24
+
25
+ return results;
26
+ }
27
+
28
+ function getProjectFromContext(context) {
29
+ const projectPath = context?.gitRoot || context?.cwd;
30
+ if (!projectPath) return 'unknown';
31
+
32
+ return basename(projectPath) || 'unknown';
33
+ }
34
+
35
+ /**
36
+ * Parse GitHub Copilot CLI session logs from ~/.copilot/session-state.
37
+ * Returns usage buckets from session shutdown summaries and session metadata
38
+ * from user/assistant message timings.
39
+ */
40
+ export async function parse() {
41
+ const eventFiles = findEventFiles(SESSION_STATE_DIR);
42
+ if (eventFiles.length === 0) return { buckets: [], sessions: [] };
43
+
44
+ const entries = [];
45
+ const sessionEvents = [];
46
+
47
+ for (const { filePath, sessionId } of eventFiles) {
48
+ let content;
49
+ try {
50
+ content = readFileSync(filePath, 'utf-8');
51
+ } catch {
52
+ continue;
53
+ }
54
+
55
+ let currentProject = 'unknown';
56
+
57
+ for (const line of content.split('\n')) {
58
+ if (!line.trim()) continue;
59
+
60
+ try {
61
+ const obj = JSON.parse(line);
62
+ const timestamp = obj.timestamp ? new Date(obj.timestamp) : null;
63
+ const hasTimestamp = timestamp && !isNaN(timestamp.getTime());
64
+
65
+ if (obj.type === 'session.start' || obj.type === 'session.resume') {
66
+ currentProject = getProjectFromContext(obj.data?.context);
67
+ }
68
+
69
+ if (hasTimestamp && obj.type === 'user.message') {
70
+ sessionEvents.push({
71
+ sessionId,
72
+ source: 'copilot-cli',
73
+ project: currentProject,
74
+ timestamp,
75
+ role: 'user',
76
+ });
77
+ }
78
+
79
+ if (hasTimestamp && obj.type === 'assistant.message') {
80
+ sessionEvents.push({
81
+ sessionId,
82
+ source: 'copilot-cli',
83
+ project: currentProject,
84
+ timestamp,
85
+ role: 'assistant',
86
+ });
87
+ }
88
+
89
+ if (obj.type !== 'session.shutdown' || !hasTimestamp) continue;
90
+
91
+ const modelMetrics = obj.data?.modelMetrics || {};
92
+ for (const [model, metrics] of Object.entries(modelMetrics)) {
93
+ const usage = metrics?.usage;
94
+ if (!usage) continue;
95
+
96
+ const totalInput = usage.inputTokens || 0;
97
+ const cachedRead = usage.cacheReadTokens || 0;
98
+ const cacheWrite = usage.cacheWriteTokens || 0;
99
+ const output = usage.outputTokens || 0;
100
+
101
+ if (totalInput === 0 && cachedRead === 0 && cacheWrite === 0 && output === 0) {
102
+ continue;
103
+ }
104
+
105
+ entries.push({
106
+ source: 'copilot-cli',
107
+ model,
108
+ project: currentProject,
109
+ timestamp,
110
+ // Copilot reports cache reads separately, but cache writes are part of
111
+ // regular input for this schema because buckets don't have a dedicated field.
112
+ inputTokens: Math.max(0, totalInput - cachedRead),
113
+ outputTokens: output,
114
+ cachedInputTokens: cachedRead,
115
+ reasoningOutputTokens: 0,
116
+ });
117
+ }
118
+ } catch {
119
+ continue;
120
+ }
121
+ }
122
+ }
123
+
124
+ return {
125
+ buckets: aggregateToBuckets(entries),
126
+ sessions: extractSessions(sessionEvents),
127
+ };
128
+ }
@@ -0,0 +1,21 @@
1
+ import { getCraftWorkspacesDir } from '../craft-roots.js';
2
+ import { parsePiSessionJsonl } from './pi-session-jsonl.js';
3
+
4
+ function isCraftPiSession(filePath) {
5
+ return filePath.split(/[\\/]/).includes('.pi-sessions');
6
+ }
7
+
8
+ function projectFromCraftPath(filePath) {
9
+ const parts = filePath.replace(/\\/g, '/').split('/');
10
+ const sessionsIndex = parts.lastIndexOf('sessions');
11
+ return parts[sessionsIndex + 1] || 'unknown';
12
+ }
13
+
14
+ export async function parse() {
15
+ return parsePiSessionJsonl({
16
+ source: 'craft-agent',
17
+ sessionsDirs: [getCraftWorkspacesDir()],
18
+ includeFile: isCraftPiSession,
19
+ projectFromPath: projectFromCraftPath,
20
+ });
21
+ }
@@ -0,0 +1,262 @@
1
+ import { sourceFetch } from '../../../../src/network.js';
2
+ import { existsSync } from 'node:fs';
3
+ import { join, resolve } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { aggregateToBuckets } from './aggregate.js';
6
+ import { queryDbJsonSnapshotOnLock, sqliteUnavailableError, isSqliteUnavailableError } from './sqlite.js';
7
+
8
+ const STATE_DB_RELATIVE = join('User', 'globalStorage', 'state.vscdb');
9
+ const ACCESS_TOKEN_KEY = 'cursorAuth/accessToken';
10
+ const SESSION_COOKIE = 'WorkosCursorSessionToken';
11
+
12
+ function getDefaultStateDbPath() {
13
+ if (process.platform === 'darwin') {
14
+ return join(homedir(), 'Library', 'Application Support', 'Cursor', STATE_DB_RELATIVE);
15
+ }
16
+ if (process.platform === 'win32') {
17
+ const appData = process.env.APPDATA?.trim() || join(homedir(), 'AppData', 'Roaming');
18
+ return join(appData, 'Cursor', STATE_DB_RELATIVE);
19
+ }
20
+ const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), '.config');
21
+ return join(xdgConfigHome, 'Cursor', STATE_DB_RELATIVE);
22
+ }
23
+
24
+ export function getCursorStateDbPath() {
25
+ const explicit = process.env.CURSOR_STATE_DB_PATH?.trim();
26
+ if (explicit) {
27
+ const resolved = resolve(explicit);
28
+ return existsSync(resolved) ? resolved : null;
29
+ }
30
+
31
+ const configDirs = process.env.CURSOR_CONFIG_DIR?.trim();
32
+ const candidates = configDirs
33
+ ? configDirs.split(',').map(v => v.trim()).filter(Boolean).map(v => {
34
+ const r = resolve(v);
35
+ return r.endsWith('.vscdb') ? r : join(r, STATE_DB_RELATIVE);
36
+ })
37
+ : [getDefaultStateDbPath()];
38
+
39
+ for (const c of candidates) {
40
+ if (existsSync(c)) return c;
41
+ }
42
+ return null;
43
+ }
44
+
45
+ function readAccessToken(dbPath) {
46
+ // Cursor app holds a write lock; queryDbJsonSnapshotOnLock copies the WAL set
47
+ // to a temp dir and retries on "database is locked".
48
+ const sql = `SELECT value FROM ItemTable WHERE key = '${ACCESS_TOKEN_KEY}' LIMIT 1`;
49
+ const rows = queryDbJsonSnapshotOnLock(dbPath, sql, {
50
+ tempPrefix: 'vibe-usage-cursor-',
51
+ opts: { maxBuffer: 4 * 1024 * 1024, timeout: 15000 },
52
+ });
53
+ const value = rows[0]?.value;
54
+ if (typeof value !== 'string') return null;
55
+ const t = value.trim();
56
+ return t || null;
57
+ }
58
+
59
+ function decodeJwtSub(token) {
60
+ const payload = token.split('.')[1];
61
+ if (!payload) return null;
62
+ try {
63
+ const b64 = payload.replace(/-/g, '+').replace(/_/g, '/');
64
+ const padded = b64.padEnd(Math.ceil(b64.length / 4) * 4, '=');
65
+ const json = JSON.parse(Buffer.from(padded, 'base64').toString('utf-8'));
66
+ return typeof json.sub === 'string' ? json.sub.trim() : null;
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ // Under full sync many parsers hammer disk concurrently; cursor.com's CSV
73
+ // export can still succeed but take >10s. A short timeout caused silent skips.
74
+ const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
75
+ const MAX_FETCH_TIMEOUT_MS = 2_147_483_647;
76
+
77
+ export function resolveCursorFetchTimeout(value) {
78
+ const timeout = Number(value);
79
+ return Number.isInteger(timeout) && timeout > 0 && timeout <= MAX_FETCH_TIMEOUT_MS
80
+ ? timeout
81
+ : DEFAULT_FETCH_TIMEOUT_MS;
82
+ }
83
+
84
+ const FETCH_TIMEOUT_MS = resolveCursorFetchTimeout(process.env.VIBE_USAGE_CURSOR_FETCH_TIMEOUT_MS);
85
+
86
+ async function fetchUsageCsv(token) {
87
+ const url = 'https://cursor.com/api/dashboard/export-usage-events-csv?strategy=tokens';
88
+ const sub = decodeJwtSub(token);
89
+ // The dashboard API authenticates via the WorkosCursorSessionToken cookie in
90
+ // `{sub}%3A%3A{jwt}` form (what the browser sends). Bearer and bare-token
91
+ // cookies now return 401, so they're kept only as last-resort fallbacks.
92
+ const userId = sub?.includes('|') ? sub.split('|').pop() : null;
93
+ const cookieValues = [
94
+ ...(sub ? [`${sub}%3A%3A${token}`] : []),
95
+ ...(userId ? [`${userId}%3A%3A${token}`] : []),
96
+ token,
97
+ ];
98
+
99
+ // Browser-mimicking headers, matching what the dashboard sends (and what
100
+ // cursor-stats / cursor-price-tracking send) — Node's default UA is a
101
+ // common target for intermittent WAF blocks on cursor.com.
102
+ const baseHeaders = {
103
+ Accept: 'text/csv,*/*;q=0.8',
104
+ Origin: 'https://cursor.com',
105
+ Referer: 'https://cursor.com/dashboard?tab=usage',
106
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
107
+ };
108
+ const attempts = cookieValues.map(cv => ({ Cookie: `${SESSION_COOKIE}=${cv}` }));
109
+ attempts.push({ Authorization: `Bearer ${token}` });
110
+
111
+ const failures = [];
112
+ for (const headers of attempts) {
113
+ let resp;
114
+ try {
115
+ resp = await sourceFetch(url, {
116
+ headers: { ...baseHeaders, ...headers },
117
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
118
+ });
119
+ } catch (e) {
120
+ // Hard-fail on network/timeout: stop trying further headers (won't fix
121
+ // a downed host) and signal a soft skip to the caller.
122
+ const reason = e.name === 'TimeoutError' ? 'timeout' : `network: ${e.message}`;
123
+ const err = new Error(`Cursor usage export skipped (${reason})`);
124
+ err.skip = true;
125
+ throw err;
126
+ }
127
+ if (resp.ok) return await resp.text();
128
+ failures.push(`${resp.status} ${resp.statusText}`);
129
+ // Only auth rejections are worth retrying with different credentials.
130
+ // 429/5xx are transient server-side states — soft-skip like network errors
131
+ // instead of surfacing them as auth failures every daemon cycle.
132
+ if (resp.status !== 401 && resp.status !== 403) {
133
+ const err = new Error(`Cursor usage export skipped (HTTP ${resp.status} ${resp.statusText})`);
134
+ err.skip = true;
135
+ throw err;
136
+ }
137
+ }
138
+ // Every auth combo rejected — the stored token no longer works. Surface an
139
+ // actionable message: re-signing in inside Cursor rewrites the token.
140
+ throw new Error(`Cursor session rejected (${failures.join('; ')}). Open Cursor and sign in again (Cursor Settings → Account), then re-run sync.`);
141
+ }
142
+
143
+ function parseCsv(text) {
144
+ const rows = [];
145
+ let field = '';
146
+ let row = [];
147
+ let inQuotes = false;
148
+ let i = 0;
149
+ while (i < text.length) {
150
+ const c = text[i];
151
+ if (inQuotes) {
152
+ if (c === '"') {
153
+ if (text[i + 1] === '"') { field += '"'; i += 2; continue; }
154
+ inQuotes = false; i++; continue;
155
+ }
156
+ field += c; i++; continue;
157
+ }
158
+ if (c === '"') { inQuotes = true; i++; continue; }
159
+ if (c === ',') { row.push(field); field = ''; i++; continue; }
160
+ if (c === '\r') { i++; continue; }
161
+ if (c === '\n') { row.push(field); rows.push(row); field = ''; row = []; i++; continue; }
162
+ field += c; i++;
163
+ }
164
+ if (field !== '' || row.length > 0) { row.push(field); rows.push(row); }
165
+ return rows;
166
+ }
167
+
168
+ function parseDate(value) {
169
+ if (!value) return null;
170
+ const t = String(value).trim();
171
+ if (!t) return null;
172
+ if (/^\d{4}-\d{2}-\d{2}$/.test(t)) return new Date(`${t}T00:00:00Z`);
173
+ const d = new Date(t);
174
+ return isNaN(d.getTime()) ? null : d;
175
+ }
176
+
177
+ function parseInt0(value) {
178
+ if (value == null) return 0;
179
+ const n = Number(String(value).replace(/,/g, '').trim());
180
+ return Number.isFinite(n) && n > 0 ? Math.round(n) : 0;
181
+ }
182
+
183
+ export async function parse() {
184
+ const dbPath = getCursorStateDbPath();
185
+ if (!dbPath) return { buckets: [], sessions: [] };
186
+
187
+ let token;
188
+ try {
189
+ token = readAccessToken(dbPath);
190
+ } catch (err) {
191
+ if (isSqliteUnavailableError(err)) {
192
+ throw sqliteUnavailableError('Cursor');
193
+ }
194
+ throw err;
195
+ }
196
+ if (!token) return { buckets: [], sessions: [] };
197
+
198
+ let csv;
199
+ try {
200
+ csv = await fetchUsageCsv(token);
201
+ } catch (err) {
202
+ // Network/timeout → silent skip (avoid noisy daemon logs every 5 min).
203
+ // Auth failure → bubble up so user sees they need to re-login in Cursor.
204
+ // Tell sync.js this was not a successful empty snapshot so it preserves
205
+ // Cursor's incremental state instead of pruning it as dead history.
206
+ if (err && err.skip) {
207
+ return {
208
+ buckets: [],
209
+ sessions: [],
210
+ skipped: true,
211
+ warnings: [`cursor: ${err.message}`],
212
+ };
213
+ }
214
+ throw err;
215
+ }
216
+ const rows = parseCsv(csv);
217
+ if (rows.length < 2) return { buckets: [], sessions: [] };
218
+
219
+ const header = rows[0].map(h => h.trim());
220
+ const idx = (name) => header.indexOf(name);
221
+ const dateIdx = idx('Date');
222
+ const modelIdx = idx('Model');
223
+ const inputCacheWriteIdx = idx('Input (w/ Cache Write)');
224
+ const inputNoCacheIdx = idx('Input (w/o Cache Write)');
225
+ const cacheReadIdx = idx('Cache Read');
226
+ const outputIdx = idx('Output Tokens');
227
+
228
+ if (dateIdx < 0 || modelIdx < 0) return { buckets: [], sessions: [] };
229
+
230
+ const entries = [];
231
+ for (let r = 1; r < rows.length; r++) {
232
+ const row = rows[r];
233
+ if (row.length === 1 && row[0].trim() === '') continue;
234
+ const timestamp = parseDate(row[dateIdx]);
235
+ const model = row[modelIdx]?.trim();
236
+ if (!timestamp || !model) continue;
237
+
238
+ const inputCacheWrite = inputCacheWriteIdx >= 0 ? parseInt0(row[inputCacheWriteIdx]) : 0;
239
+ const inputNoCache = inputNoCacheIdx >= 0 ? parseInt0(row[inputNoCacheIdx]) : 0;
240
+ const cacheRead = cacheReadIdx >= 0 ? parseInt0(row[cacheReadIdx]) : 0;
241
+ const output = outputIdx >= 0 ? parseInt0(row[outputIdx]) : 0;
242
+
243
+ if (inputCacheWrite + inputNoCache + cacheRead + output === 0) continue;
244
+
245
+ entries.push({
246
+ source: 'cursor',
247
+ model,
248
+ project: 'unknown',
249
+ // Cursor usage is pulled from the cloud API — it reflects the same account
250
+ // data on every machine. Use a fixed sentinel so all machines share one row
251
+ // per (model, bucket_start) rather than duplicating per hostname.
252
+ hostname: 'cursor-cloud',
253
+ timestamp,
254
+ inputTokens: inputCacheWrite + inputNoCache,
255
+ outputTokens: output,
256
+ cachedInputTokens: cacheRead,
257
+ reasoningOutputTokens: 0,
258
+ });
259
+ }
260
+
261
+ return { buckets: aggregateToBuckets(entries), sessions: [] };
262
+ }
@@ -0,0 +1,127 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
3
+ import { toCount } from './fs-utils.js';
4
+ import { queryDbJson, sqliteUnavailableError, isSqliteUnavailableError } from './sqlite.js';
5
+ import { getDimAgentDbPath } from '../tools.js';
6
+
7
+ const SOURCE = 'dimagent';
8
+ const FORKED_LEDGER_ID = /^ledger_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9
+
10
+ function projectName(cwd) {
11
+ if (!cwd) return 'unknown';
12
+ const parts = String(cwd).replace(/[/\\]+$/, '').split(/[/\\]/);
13
+ return parts.at(-1) || 'unknown';
14
+ }
15
+
16
+ function usageSignature(row) {
17
+ return [
18
+ row.runId || '',
19
+ row.providerId || '',
20
+ row.modelId || '',
21
+ row.usage || '',
22
+ row.cost ?? '',
23
+ row.createdAt || '',
24
+ ].join('\0');
25
+ }
26
+
27
+ function parseUsageRows(rows) {
28
+ const entries = [];
29
+ const originalSignatures = new Set(
30
+ rows
31
+ .filter(row => !FORKED_LEDGER_ID.test(row.ledgerId || ''))
32
+ .map(usageSignature),
33
+ );
34
+ const keptOrphanClones = new Set();
35
+
36
+ for (const row of rows) {
37
+ const signature = usageSignature(row);
38
+ if (FORKED_LEDGER_ID.test(row.ledgerId || '')) {
39
+ if (originalSignatures.has(signature) || keptOrphanClones.has(signature)) continue;
40
+ keptOrphanClones.add(signature);
41
+ }
42
+
43
+ let usage;
44
+ try {
45
+ usage = typeof row.usage === 'string' ? JSON.parse(row.usage) : row.usage;
46
+ } catch {
47
+ continue;
48
+ }
49
+ if (!usage || typeof usage !== 'object') continue;
50
+
51
+ const timestamp = new Date(row.createdAt);
52
+ if (Number.isNaN(timestamp.getTime())) continue;
53
+
54
+ const promptTokens = toCount(usage.promptTokens);
55
+ const cachedInputTokens = toCount(usage.cacheReadTokens);
56
+ const inputTokens = Math.max(0, promptTokens - cachedInputTokens);
57
+ const outputTokens = toCount(usage.completionTokens);
58
+ if (inputTokens + outputTokens + cachedInputTokens === 0) continue;
59
+
60
+ entries.push({
61
+ source: SOURCE,
62
+ model: row.modelId || 'unknown',
63
+ project: projectName(row.cwd),
64
+ timestamp,
65
+ inputTokens,
66
+ outputTokens,
67
+ cachedInputTokens,
68
+ reasoningOutputTokens: 0,
69
+ });
70
+ }
71
+
72
+ return entries;
73
+ }
74
+
75
+ function queryDb(dbPath, sql) {
76
+ try {
77
+ return queryDbJson(dbPath, sql);
78
+ } catch (err) {
79
+ if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('DimAgent');
80
+ throw err;
81
+ }
82
+ }
83
+
84
+ export async function parse() {
85
+ const dbPath = getDimAgentDbPath();
86
+ if (!existsSync(dbPath)) return { buckets: [], sessions: [] };
87
+
88
+ const usageRows = queryDb(dbPath, `SELECT
89
+ u.ledgerId,
90
+ u.runId,
91
+ u.providerId,
92
+ u.modelId,
93
+ u.usage,
94
+ u.cost,
95
+ u.createdAt,
96
+ s.cwd
97
+ FROM usage_ledger u
98
+ LEFT JOIN sessions s ON s.sessionId = u.sessionId`);
99
+
100
+ const messageRows = queryDb(dbPath, `SELECT
101
+ m.sessionId,
102
+ m.role,
103
+ m.createdAt,
104
+ s.cwd
105
+ FROM messages m
106
+ LEFT JOIN sessions s ON s.sessionId = m.sessionId
107
+ WHERE m.role IN ('user', 'assistant')
108
+ AND m.messageId NOT LIKE 'msg_fork_%'`);
109
+
110
+ const sessionEvents = [];
111
+ for (const row of messageRows) {
112
+ const timestamp = new Date(row.createdAt);
113
+ if (Number.isNaN(timestamp.getTime())) continue;
114
+ sessionEvents.push({
115
+ sessionId: row.sessionId,
116
+ source: SOURCE,
117
+ project: projectName(row.cwd),
118
+ timestamp,
119
+ role: row.role,
120
+ });
121
+ }
122
+
123
+ return {
124
+ buckets: aggregateToBuckets(parseUsageRows(usageRows)),
125
+ sessions: extractSessions(sessionEvents),
126
+ };
127
+ }