@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.
- package/LICENSE +22 -0
- package/README.md +222 -0
- package/bin/pi-usage.js +69 -0
- package/data/models.dev-LICENSE +21 -0
- package/data/prices.json +2678 -0
- package/extensions/usage-report.js +36 -0
- package/package.json +51 -0
- package/src/analytics.js +189 -0
- package/src/collect.js +34 -0
- package/src/network.js +25 -0
- package/src/report.js +43 -0
- package/vendor/vibe-usage/NOTICE.md +58 -0
- package/vendor/vibe-usage/src/cindy-roots.js +85 -0
- package/vendor/vibe-usage/src/claude-roots.js +165 -0
- package/vendor/vibe-usage/src/cline-roots.js +40 -0
- package/vendor/vibe-usage/src/codex-roots.js +46 -0
- package/vendor/vibe-usage/src/craft-roots.js +15 -0
- package/vendor/vibe-usage/src/extra-roots.js +312 -0
- package/vendor/vibe-usage/src/parsers/aggregate.js +196 -0
- package/vendor/vibe-usage/src/parsers/alma.js +94 -0
- package/vendor/vibe-usage/src/parsers/amp.js +156 -0
- package/vendor/vibe-usage/src/parsers/antigravity-db.js +359 -0
- package/vendor/vibe-usage/src/parsers/antigravity.js +530 -0
- package/vendor/vibe-usage/src/parsers/cindy-ledger.js +157 -0
- package/vendor/vibe-usage/src/parsers/claude-code.js +372 -0
- package/vendor/vibe-usage/src/parsers/cline.js +92 -0
- package/vendor/vibe-usage/src/parsers/codex-cache.js +138 -0
- package/vendor/vibe-usage/src/parsers/codex.js +1198 -0
- package/vendor/vibe-usage/src/parsers/contract.js +55 -0
- package/vendor/vibe-usage/src/parsers/copilot-cli.js +128 -0
- package/vendor/vibe-usage/src/parsers/craft-agent.js +21 -0
- package/vendor/vibe-usage/src/parsers/cursor.js +262 -0
- package/vendor/vibe-usage/src/parsers/dimagent.js +127 -0
- package/vendor/vibe-usage/src/parsers/droid.js +113 -0
- package/vendor/vibe-usage/src/parsers/dsh.js +563 -0
- package/vendor/vibe-usage/src/parsers/fs-utils.js +36 -0
- package/vendor/vibe-usage/src/parsers/gemini-cli.js +190 -0
- package/vendor/vibe-usage/src/parsers/grok.js +395 -0
- package/vendor/vibe-usage/src/parsers/hermes.js +123 -0
- package/vendor/vibe-usage/src/parsers/index.js +61 -0
- package/vendor/vibe-usage/src/parsers/kimi-code.js +467 -0
- package/vendor/vibe-usage/src/parsers/kiro.js +788 -0
- package/vendor/vibe-usage/src/parsers/mcode.js +182 -0
- package/vendor/vibe-usage/src/parsers/mimocode.js +88 -0
- package/vendor/vibe-usage/src/parsers/omp.js +10 -0
- package/vendor/vibe-usage/src/parsers/openclaw.js +142 -0
- package/vendor/vibe-usage/src/parsers/opencode.js +151 -0
- package/vendor/vibe-usage/src/parsers/pi-coding-agent.js +27 -0
- package/vendor/vibe-usage/src/parsers/pi-session-jsonl.js +166 -0
- package/vendor/vibe-usage/src/parsers/qwen-code.js +122 -0
- package/vendor/vibe-usage/src/parsers/roo-code.js +123 -0
- package/vendor/vibe-usage/src/parsers/sqlite.js +148 -0
- package/vendor/vibe-usage/src/parsers/trae-cli.js +171 -0
- package/vendor/vibe-usage/src/parsers/workbuddy.js +322 -0
- package/vendor/vibe-usage/src/parsers/zcode.js +115 -0
- package/vendor/vibe-usage/src/pi-roots.js +125 -0
- package/vendor/vibe-usage/src/tools.js +422 -0
- package/vendor/vibe-usage/src/workbuddy-roots.js +22 -0
- package/vendor/vibe-usage/upstream-files.json +48 -0
- package/web/report.css +10 -0
- package/web/report.html +81 -0
- 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
|
+
}
|