@vibe-cafe/vibe-usage 0.10.9 → 0.10.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -51,7 +51,7 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
51
51
 
52
52
  | Tool | Data Location |
53
53
  |------|---------------|
54
- | Alma | Electron app-data `alma/chat_threads.db` (macOS: `~/Library/Application Support/alma/chat_threads.db`; fixture/relocation override: `VIBE_USAGE_ALMA_DB`). Reads the `usage_records` ledger plus workspace names without selecting chat bodies, message metadata, provider credentials, or full workspace paths. Cache writes are included in input usage. The ledger contains assistant responses only, so Alma emits token buckets without session timing. |
54
+ | Alma | Electron app-data `alma/chat_threads.db` (macOS: `~/Library/Application Support/alma/chat_threads.db`; fixture/relocation override: `VIBE_USAGE_ALMA_DB`). Reads the `usage_records` ledger plus workspace names without selecting chat bodies, message metadata, provider credentials, or full workspace paths. Provider-prefixed model identifiers are normalized to their final model segment. Cache writes are included in input usage. The ledger contains assistant responses only, so Alma emits token buckets without session timing. |
55
55
  | Claude Code + Claude Desktop Code/Cowork | Claude Code data in `~/.claude/projects/` (tokens + sessions) and `~/.claude/transcripts/` (sessions only), plus Claude Desktop Cowork's per-session `.claude/projects/` directories. Also scans `$CLAUDE_CONFIG_DIR` and data-bearing `~/.claude-*` profiles. All variants use the existing `claude-code` source; the parser selects the most complete copy of each session so shared/copied transcripts are not counted twice. Logs are streamed and cache creation tokens are included in input usage. |
56
56
  | Codex CLI | `$CODEX_HOME/sessions/` and `$CODEX_HOME/archived_sessions/` (default `~/.codex`), plus an optional temporary `--extra-codex-home` or manually persisted `codexExtraHome`; a versioned local index avoids re-reading unchanged rollouts and reads only safe append tails for ordinary sessions, while fork/sub-agent replay matching, duplicate suppression, and live/archive/cross-root deduplication retain their existing semantics |
57
57
  | Grok | `$GROK_HOME/sessions/<encoded-cwd>/<session-id>/` (default `~/.grok`); token usage from `updates.jsonl` `turn_completed.usage` (per-model `modelUsage`, cache reads, reasoning); project from `summary.json` cwd; honors `GROK_HOME` |
@@ -69,6 +69,7 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
69
69
  | MiMoCode | `$MIMOCODE_HOME/data/mimocode.db`, `$XDG_DATA_HOME/mimocode/mimocode.db`, or `~/.local/share/mimocode/mimocode.db` (SQLite; exact input, output, reasoning, and cache-read tokens from assistant messages; honors `MIMOCODE_DB`; cache-write tokens are included in input usage) |
70
70
  | Amp | `~/.local/share/amp/threads/`; cache-creation tokens are included in input usage |
71
71
  | Droid | `~/.factory/sessions/` |
72
+ | DeepSeek Harness | `$DSH_HOME/sessions/` (default `~/.dsh`, fixture/relocation override: `VIBE_USAGE_DSH_SESSIONS`). Reads multi-frame Zstandard `session.jsonl.zstd` logs (built-in `node:zlib` zstd on Node ≥ 22.15, `zstd` CLI fallback) and plain `session.jsonl` logs. Usage comes from `assistant/message`: cache writes join uncached input, cache reads remain separate, and reasoning is split out of inclusive output. Seed replay before the last `session/end-seed` marker is skipped so resumed/forked history is not double-counted. |
72
73
  | Hermes | `~/.hermes/state.db` + `~/.hermes/profiles/<name>/state.db` (SQLite, multi-profile) |
73
74
  | Kiro | Kiro CLI native event streams `~/.kiro/sessions/cli/*.jsonl` (estimated tokens from message text: input = prompt + tool results, output = reply + tool calls, reasoning = thinking, cacheRead = re-sent context; thinking-block signatures excluded). Falls back to `~/Library/Application Support/kiro-cli/data.sqlite3` / `~/.local/share/kiro-cli/data.sqlite3` + optional `~/.kiro_sessions/*.json` archives, then IDE `q-client.log` whole-credit deltas as `kiro-credits` (floored cumulative diff — the server stores token counts as bigint); legacy IDE `dev_data/devdata.sqlite` token telemetry is opt-in with `VIBE_USAGE_KIRO_LEGACY_TOKENS=1` |
74
75
  | Cline | Standalone `~/.cline/` plus `<host>/User/globalStorage/saoudrizwan.claude-dev/` across VSCode-fork hosts; migrated copies are deduplicated and empty leftover extension stores no longer count as installed |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.10.9",
3
+ "version": "0.10.11",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/api.js CHANGED
@@ -231,75 +231,89 @@ function _jsonRequest(apiUrl, path, method, body, timeoutMs) {
231
231
  }
232
232
 
233
233
  /**
234
- * GET user settings from the vibecafe API.
235
- * Returns null after transient failures are exhausted. A 401 remains distinct
236
- * so callers can surface invalid credentials instead of calling it an outage.
234
+ * Authenticated GET returning parsed JSON. Shared by summary and settings so
235
+ * callers don't hand-roll their own https request + error mapping.
236
+ * - 401 Error('UNAUTHORIZED') with statusCode 401
237
+ * - other non-2xx / timeout / bad JSON → Error with statusCode
237
238
  * @param {string} apiUrl
238
239
  * @param {string} apiKey
239
- * @returns {Promise<{uploadProject: boolean} | null>}
240
+ * @param {string} path
241
+ * @param {{timeoutMs?: number}} [opts]
242
+ * @returns {Promise<any>}
240
243
  */
241
- export async function fetchSettings(apiUrl, apiKey, retry = {}) {
242
- const wait = retry.sleep ?? sleep;
243
- const random = retry.random ?? Math.random;
244
-
245
- for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
246
- try {
247
- return await fetchSettingsOnce(apiUrl, apiKey);
248
- } catch (err) {
249
- if (err.message === 'UNAUTHORIZED') throw err;
250
- // Retrying a permanent client response cannot make it valid. 429 is the
251
- // exception: it is transient load shedding and benefits from backoff.
252
- if (err.statusCode >= 400 && err.statusCode < 500 && err.statusCode !== 429) {
253
- return null;
254
- }
255
- if (attempt < MAX_RETRIES - 1) {
256
- await wait(retryDelayMs(attempt, random));
257
- }
258
- }
259
- }
260
- return null;
261
- }
262
-
263
- function fetchSettingsOnce(apiUrl, apiKey) {
244
+ export function getJson(apiUrl, apiKey, path, { timeoutMs = 15_000 } = {}) {
264
245
  return new Promise((resolve, reject) => {
265
- const url = new URL('/api/usage/settings', apiUrl);
246
+ const url = new URL(path, apiUrl);
266
247
  const mod = url.protocol === 'https:' ? https : http;
267
248
 
268
249
  const req = mod.request(url, {
269
250
  method: 'GET',
270
- timeout: 10_000,
271
- headers: {
272
- 'Authorization': `Bearer ${apiKey}`,
273
- },
251
+ timeout: timeoutMs,
252
+ headers: { 'Authorization': `Bearer ${apiKey}` },
274
253
  }, (res) => {
275
254
  let data = '';
276
255
  res.on('data', (chunk) => { data += chunk; });
277
256
  res.on('end', () => {
278
257
  if (res.statusCode === 401) {
279
- reject(new Error('UNAUTHORIZED'));
258
+ const err = new Error('UNAUTHORIZED');
259
+ err.statusCode = 401;
260
+ reject(err);
280
261
  return;
281
262
  }
282
263
  if (res.statusCode < 200 || res.statusCode >= 300) {
283
- const err = new Error(`HTTP ${res.statusCode}: ${data}`);
264
+ const err = new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`);
284
265
  err.statusCode = res.statusCode;
285
266
  reject(err);
286
267
  return;
287
268
  }
288
269
  try {
289
- const settings = JSON.parse(data);
290
- if (typeof settings?.uploadProject !== 'boolean') {
291
- reject(new Error('Invalid settings response'));
292
- return;
293
- }
294
- resolve(settings);
270
+ resolve(JSON.parse(data));
295
271
  } catch {
296
- reject(new Error('Invalid settings response'));
272
+ reject(new Error('Invalid JSON response'));
297
273
  }
298
274
  });
299
275
  });
300
276
 
301
277
  req.on('error', reject);
302
- req.on('timeout', () => { req.destroy(new Error('Settings request timed out')); });
278
+ req.on('timeout', () => { req.destroy(); reject(new Error(`Request timed out (${timeoutMs}ms)`)); });
303
279
  req.end();
304
280
  });
305
281
  }
282
+
283
+ /**
284
+ * GET user settings from the vibecafe API.
285
+ * Returns null after transient failures are exhausted. A 401 remains distinct
286
+ * so callers can surface invalid credentials instead of calling it an outage.
287
+ * @param {string} apiUrl
288
+ * @param {string} apiKey
289
+ * @returns {Promise<{uploadProject: boolean} | null>}
290
+ */
291
+ export async function fetchSettings(apiUrl, apiKey, retry = {}) {
292
+ const wait = retry.sleep ?? sleep;
293
+ const random = retry.random ?? Math.random;
294
+
295
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
296
+ try {
297
+ return await fetchSettingsOnce(apiUrl, apiKey);
298
+ } catch (err) {
299
+ if (err.message === 'UNAUTHORIZED') throw err;
300
+ // Retrying a permanent client response cannot make it valid. 429 is the
301
+ // exception: it is transient load shedding and benefits from backoff.
302
+ if (err.statusCode >= 400 && err.statusCode < 500 && err.statusCode !== 429) {
303
+ return null;
304
+ }
305
+ if (attempt < MAX_RETRIES - 1) {
306
+ await wait(retryDelayMs(attempt, random));
307
+ }
308
+ }
309
+ }
310
+ return null;
311
+ }
312
+
313
+ async function fetchSettingsOnce(apiUrl, apiKey) {
314
+ const settings = await getJson(apiUrl, apiKey, '/api/usage/settings', { timeoutMs: 10_000 });
315
+ if (typeof settings?.uploadProject !== 'boolean') {
316
+ throw new Error('Invalid settings response');
317
+ }
318
+ return settings;
319
+ }
@@ -0,0 +1,157 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ // Shared bucket/session aggregation used by every parser. Lives in its own
4
+ // module (not the parser registry in index.js) so parsers never have to import
5
+ // the registry that imports them — that circular dependency only worked because
6
+ // these functions are hoisted declarations. Keeping them here breaks the cycle.
7
+ //
8
+ // Data model: parsers emit flat per-message "entries" (token usage) and
9
+ // per-message "events" (timing), and these two functions fold them into the
10
+ // bucket/session shapes described in AGENTS.md.
11
+
12
+ export function roundToHalfHour(date) {
13
+ const d = new Date(date);
14
+ d.setMinutes(d.getMinutes() < 30 ? 0 : 30, 0, 0);
15
+ return d;
16
+ }
17
+
18
+ // Server column limits (usage_buckets: model varchar(100), project varchar(200)).
19
+ // Anything longer aborts the whole INSERT chunk with 22001, so clamp here.
20
+ const MODEL_MAX_LENGTH = 100;
21
+ const PROJECT_MAX_LENGTH = 200;
22
+
23
+ // Server token columns are bigint — a single fractional/NaN value aborts the
24
+ // whole INSERT chunk with 22P02, taking every other tool's rows in the batch
25
+ // down with it.
26
+ function toTokenCount(value) {
27
+ const n = Number(value);
28
+ if (!Number.isFinite(n) || n <= 0) return 0;
29
+ return Math.round(n);
30
+ }
31
+
32
+ export function aggregateToBuckets(entries) {
33
+ const map = new Map();
34
+
35
+ for (const e of entries) {
36
+ const model = String(e.model || 'unknown').slice(0, MODEL_MAX_LENGTH);
37
+ const project = String(e.project || 'unknown').slice(0, PROJECT_MAX_LENGTH);
38
+ const bucketStart = roundToHalfHour(e.timestamp).toISOString();
39
+ const key = `${e.source}|${model}|${project}|${e.hostname || ''}|${bucketStart}`;
40
+
41
+ if (!map.has(key)) {
42
+ map.set(key, {
43
+ source: e.source,
44
+ model,
45
+ project,
46
+ // Cloud-sourced parsers (cursor) pre-set a fixed hostname sentinel; it
47
+ // must survive aggregation, or sync.js stamps the machine hostname and
48
+ // every machine gets its own duplicate row server-side.
49
+ ...(e.hostname ? { hostname: e.hostname } : {}),
50
+ bucketStart,
51
+ inputTokens: 0,
52
+ outputTokens: 0,
53
+ cachedInputTokens: 0,
54
+ reasoningOutputTokens: 0,
55
+ });
56
+ }
57
+
58
+ const b = map.get(key);
59
+ b.inputTokens += e.inputTokens || 0;
60
+ b.outputTokens += e.outputTokens || 0;
61
+ b.cachedInputTokens += e.cachedInputTokens || 0;
62
+ b.reasoningOutputTokens += e.reasoningOutputTokens || 0;
63
+ }
64
+
65
+ // Clamp after summation, not per entry — rounding each entry first would
66
+ // discard sub-integer values instead of letting them accumulate.
67
+ return Array.from(map.values()).map((b) => {
68
+ const inputTokens = toTokenCount(b.inputTokens);
69
+ const outputTokens = toTokenCount(b.outputTokens);
70
+ const cachedInputTokens = toTokenCount(b.cachedInputTokens);
71
+ const reasoningOutputTokens = toTokenCount(b.reasoningOutputTokens);
72
+ return {
73
+ ...b,
74
+ inputTokens,
75
+ outputTokens,
76
+ cachedInputTokens,
77
+ reasoningOutputTokens,
78
+ totalTokens: inputTokens + outputTokens + reasoningOutputTokens,
79
+ };
80
+ });
81
+ }
82
+
83
+ /**
84
+ * Extract session metadata from timing events.
85
+ * Each event: { sessionId, source, project, timestamp: Date, role: 'user'|'assistant' }
86
+ *
87
+ * Turn = first AI response → last AI response before next user prompt.
88
+ * activeSeconds = sum(generation durations), excluding queue/TTFT wait.
89
+ * durationSeconds = wall clock from first to last message.
90
+ */
91
+ export function extractSessions(events) {
92
+ const groups = new Map();
93
+ for (const e of events) {
94
+ if (!groups.has(e.sessionId)) groups.set(e.sessionId, []);
95
+ groups.get(e.sessionId).push(e);
96
+ }
97
+
98
+ const sessions = [];
99
+ for (const [sessionId, sessionEvents] of groups) {
100
+ sessionEvents.sort((a, b) => a.timestamp - b.timestamp);
101
+
102
+ const first = sessionEvents[0];
103
+ const last = sessionEvents[sessionEvents.length - 1];
104
+ const durationSeconds = Math.round((last.timestamp - first.timestamp) / 1000);
105
+
106
+ let activeSeconds = 0;
107
+ let turnStart = null;
108
+ let turnEnd = null;
109
+ let waitingForFirstResponse = false;
110
+
111
+ for (const event of sessionEvents) {
112
+ if (event.role === 'user') {
113
+ if (turnStart !== null && turnEnd !== null && turnEnd > turnStart) {
114
+ activeSeconds += Math.round((turnEnd - turnStart) / 1000);
115
+ }
116
+ turnStart = null;
117
+ turnEnd = null;
118
+ waitingForFirstResponse = true;
119
+ } else if (waitingForFirstResponse) {
120
+ turnStart = event.timestamp;
121
+ turnEnd = event.timestamp;
122
+ waitingForFirstResponse = false;
123
+ } else if (turnStart !== null) {
124
+ turnEnd = event.timestamp;
125
+ }
126
+ }
127
+ if (turnStart !== null && turnEnd !== null && turnEnd > turnStart) {
128
+ activeSeconds += Math.round((turnEnd - turnStart) / 1000);
129
+ }
130
+
131
+ const userPromptHours = new Array(24).fill(0);
132
+ let userMessageCount = 0;
133
+ for (const event of sessionEvents) {
134
+ if (event.role === 'user') {
135
+ userMessageCount++;
136
+ userPromptHours[event.timestamp.getUTCHours()]++;
137
+ }
138
+ }
139
+
140
+ const sessionHash = createHash('sha256').update(sessionId).digest('hex').slice(0, 16);
141
+
142
+ sessions.push({
143
+ source: first.source,
144
+ project: first.project || 'unknown',
145
+ sessionHash,
146
+ firstMessageAt: first.timestamp.toISOString(),
147
+ lastMessageAt: last.timestamp.toISOString(),
148
+ durationSeconds,
149
+ activeSeconds,
150
+ messageCount: sessionEvents.length,
151
+ userMessageCount,
152
+ userPromptHours,
153
+ });
154
+ }
155
+
156
+ return sessions;
157
+ }
@@ -1,11 +1,21 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { basename } from 'node:path';
3
3
  import { getAlmaDbPath } from '../tools.js';
4
- import { aggregateToBuckets } from './index.js';
5
- import { queryDbJson } from './sqlite.js';
4
+ import { aggregateToBuckets } from './aggregate.js';
5
+ import { toCount } from './fs-utils.js';
6
+ import { queryDbJson, sqliteUnavailableError, isSqliteUnavailableError } from './sqlite.js';
6
7
 
7
8
  export { getAlmaDbPath as resolveAlmaDbPath };
8
9
 
10
+ export function normalizeAlmaModel(value) {
11
+ if (typeof value !== 'string') return 'unknown';
12
+ const model = value.trim();
13
+ if (!model) return 'unknown';
14
+ const separator = model.lastIndexOf(':');
15
+ if (separator === -1) return model;
16
+ return model.slice(separator + 1).trim() || 'unknown';
17
+ }
18
+
9
19
  const ALMA_USAGE_SQL = `
10
20
  SELECT
11
21
  usage_records.model AS model,
@@ -21,11 +31,6 @@ const ALMA_USAGE_SQL = `
21
31
  LEFT JOIN workspaces ON workspaces.id = chat_threads.workspace_id
22
32
  `;
23
33
 
24
- function tokenCount(value) {
25
- const number = Number(value);
26
- return Number.isFinite(number) && number > 0 ? number : 0;
27
- }
28
-
29
34
  function safeWorkspaceName(value) {
30
35
  if (typeof value !== 'string') return 'unknown';
31
36
  const normalized = value.trim().replace(/\\/g, '/').replace(/\/+$/, '');
@@ -53,9 +58,7 @@ export async function parse() {
53
58
  try {
54
59
  rows = queryDbJson(dbPath, ALMA_USAGE_SQL);
55
60
  } catch (error) {
56
- if (error?.status === 127 || /ENOENT/i.test(error?.message)) {
57
- throw new Error('sqlite3 CLI not found. Install sqlite3 (or use Node >= 22.5) to sync Alma data.');
58
- }
61
+ if (isSqliteUnavailableError(error)) throw sqliteUnavailableError('Alma');
59
62
  return skippedResult(error);
60
63
  }
61
64
 
@@ -64,15 +67,15 @@ export async function parse() {
64
67
  const timestamp = new Date(row.timestamp);
65
68
  if (Number.isNaN(timestamp.getTime())) continue;
66
69
 
67
- const inputTokens = tokenCount(row.inputTokens) + tokenCount(row.cacheWriteInputTokens);
68
- const outputTokens = tokenCount(row.outputTokens);
69
- const cachedInputTokens = tokenCount(row.cachedInputTokens);
70
- const reasoningOutputTokens = tokenCount(row.reasoningOutputTokens);
70
+ const inputTokens = toCount(row.inputTokens) + toCount(row.cacheWriteInputTokens);
71
+ const outputTokens = toCount(row.outputTokens);
72
+ const cachedInputTokens = toCount(row.cachedInputTokens);
73
+ const reasoningOutputTokens = toCount(row.reasoningOutputTokens);
71
74
  if (inputTokens + outputTokens + cachedInputTokens + reasoningOutputTokens === 0) continue;
72
75
 
73
76
  entries.push({
74
77
  source: 'alma',
75
- model: row.model || 'unknown',
78
+ model: normalizeAlmaModel(row.model),
76
79
  project: safeWorkspaceName(row.workspaceName),
77
80
  timestamp,
78
81
  inputTokens,
@@ -1,7 +1,7 @@
1
1
  import { readdirSync, readFileSync, existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
- import { aggregateToBuckets, extractSessions } from './index.js';
4
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
5
5
 
6
6
  function resolveThreadsDir() {
7
7
  if (process.env.AMP_DATA_DIR) return process.env.AMP_DATA_DIR;
@@ -1,7 +1,6 @@
1
- import { copyFileSync, existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
2
- import { tmpdir } from 'node:os';
1
+ import { readdirSync } from 'node:fs';
3
2
  import { join } from 'node:path';
4
- import { queryDbJson } from './sqlite.js';
3
+ import { queryDbJsonSnapshotOnLock, sqliteUnavailableError, isSqliteUnavailableError } from './sqlite.js';
5
4
 
6
5
  /**
7
6
  * Offline reader for Antigravity SQLite conversation stores.
@@ -149,38 +148,16 @@ export function parseGenMetadataBlob(buf) {
149
148
 
150
149
  // ── SQLite store reading ──────────────────────────────────────────────
151
150
 
152
- function isLockError(err) {
153
- return err && typeof err.message === 'string' && /database is locked/i.test(err.message);
154
- }
155
-
156
- function isSqliteUnavailableError(err) {
157
- return err && typeof err.message === 'string' && /ENOENT|sqlite3.*not found/i.test(err.message);
158
- }
159
-
160
151
  function queryCascadeDb(conversationsDir, cascadeId, sql) {
161
152
  const dbPath = join(conversationsDir, `${cascadeId}.db`);
162
153
  try {
163
- return queryDbJson(dbPath, sql);
154
+ // The App can hold the live DB open; queryDbJsonSnapshotOnLock copies the
155
+ // WAL set to a temp dir and retries on "database is locked" so one active
156
+ // cascade does not make the whole Antigravity parser go empty.
157
+ return queryDbJsonSnapshotOnLock(dbPath, sql, { tempPrefix: 'vibe-usage-antigravity-' });
164
158
  } catch (err) {
165
- if (isSqliteUnavailableError(err)) {
166
- throw new Error('sqlite3 CLI not found. Install sqlite3 (or use Node >= 22.5) to sync Antigravity data.');
167
- }
168
- if (!isLockError(err)) throw err;
169
-
170
- // The App can hold the live DB open. Query a WAL-consistent snapshot so
171
- // one active cascade does not make the whole Antigravity parser go empty.
172
- const snapshotDir = mkdtempSync(join(tmpdir(), 'vibe-usage-antigravity-'));
173
- const snapshotPath = join(snapshotDir, `${cascadeId}.db`);
174
- try {
175
- copyFileSync(dbPath, snapshotPath);
176
- for (const suffix of ['-shm', '-wal']) {
177
- const companion = `${dbPath}${suffix}`;
178
- if (existsSync(companion)) copyFileSync(companion, `${snapshotPath}${suffix}`);
179
- }
180
- return queryDbJson(snapshotPath, sql);
181
- } finally {
182
- rmSync(snapshotDir, { recursive: true, force: true });
183
- }
159
+ if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('Antigravity');
160
+ throw err;
184
161
  }
185
162
  }
186
163
 
@@ -2,7 +2,7 @@ import { execSync } from 'node:child_process';
2
2
  import { readdirSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
- import { aggregateToBuckets, extractSessions } from './index.js';
5
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
6
6
  import { listDbCascades, readDbUsageRecords, readDbWorkspaceUri, readDbSessionEvents } from './antigravity-db.js';
7
7
 
8
8
 
@@ -1,7 +1,8 @@
1
1
  import { createReadStream, readdirSync, statSync } from 'node:fs';
2
2
  import { createInterface } from 'node:readline';
3
3
  import { join, basename, sep } from 'node:path';
4
- import { aggregateToBuckets, extractSessions } from './index.js';
4
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
5
+ import { projectFromCwd, toCount } from './fs-utils.js';
5
6
  import { getClaudeRoots } from '../claude-roots.js';
6
7
 
7
8
  const MAX_WARNINGS = 20;
@@ -49,19 +50,6 @@ function projectFromRelative(relative) {
49
50
  return parts.at(-1) || 'unknown';
50
51
  }
51
52
 
52
- /** Works for Unix and Windows cwd values regardless of the current OS. */
53
- function projectFromCwd(cwd, fallback) {
54
- if (typeof cwd !== 'string') return fallback;
55
- const trimmed = cwd.trim().replace(/[\\/]+$/, '');
56
- if (!trimmed) return fallback;
57
- return trimmed.split(/[\\/]/).filter(Boolean).at(-1) || fallback;
58
- }
59
-
60
- function toCount(value) {
61
- const n = Number(value);
62
- return Number.isFinite(n) && n > 0 ? n : 0;
63
- }
64
-
65
53
  function cacheCreationTokens(usage) {
66
54
  const direct = toCount(usage.cache_creation_input_tokens);
67
55
  const breakdown = usage.cache_creation || {};
@@ -1,19 +1,9 @@
1
- import { readFileSync, statSync } from 'node:fs';
2
- import { basename, join } from 'node:path';
3
- import { aggregateToBuckets, extractSessions } from './index.js';
1
+ import { statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
4
+ import { readJsonSafe, projectFromPath } from './fs-utils.js';
4
5
  import { findClineDataDirs } from '../cline-roots.js';
5
6
 
6
- function readJsonSafe(path) {
7
- try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return null; }
8
- }
9
-
10
- function projectFromPath(absPath) {
11
- if (!absPath || typeof absPath !== 'string') return 'unknown';
12
- const trimmed = absPath.replace(/[\\/]+$/, '');
13
- const name = basename(trimmed);
14
- return name || 'unknown';
15
- }
16
-
17
7
  export async function parse() {
18
8
  const extDirs = findClineDataDirs();
19
9
  if (extDirs.length === 0) return { buckets: [], sessions: [] };
@@ -10,7 +10,7 @@ import {
10
10
  import { join } from 'node:path';
11
11
  import { createInterface } from 'node:readline';
12
12
  import { createHash } from 'node:crypto';
13
- import { aggregateToBuckets } from './index.js';
13
+ import { aggregateToBuckets } from './aggregate.js';
14
14
  import {
15
15
  codexSessionDirs,
16
16
  resolveCodexHomes,
@@ -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
+ }
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
2
  import { basename, join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
- import { aggregateToBuckets, extractSessions } from './index.js';
4
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
5
5
 
6
6
  const SESSION_STATE_DIR = join(homedir(), '.copilot', 'session-state');
7
7
 
@@ -1,8 +1,8 @@
1
- import { copyFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
1
+ import { existsSync } from 'node:fs';
2
2
  import { join, resolve } from 'node:path';
3
- import { homedir, tmpdir } from 'node:os';
4
- import { aggregateToBuckets } from './index.js';
5
- import { queryDbJson } from './sqlite.js';
3
+ import { homedir } from 'node:os';
4
+ import { aggregateToBuckets } from './aggregate.js';
5
+ import { queryDbJsonSnapshotOnLock, sqliteUnavailableError, isSqliteUnavailableError } from './sqlite.js';
6
6
 
7
7
  const STATE_DB_RELATIVE = join('User', 'globalStorage', 'state.vscdb');
8
8
  const ACCESS_TOKEN_KEY = 'cursorAuth/accessToken';
@@ -42,41 +42,19 @@ export function getCursorStateDbPath() {
42
42
  }
43
43
 
44
44
  function readAccessToken(dbPath) {
45
- let snapshotDir = null;
46
- let queryPath = dbPath;
47
- try {
48
- return queryAccessToken(queryPath);
49
- } catch (err) {
50
- // Cursor app holds a write lock; copy WAL set to a temp dir and retry
51
- if (!isLockError(err)) throw err;
52
- snapshotDir = mkdtempSync(join(tmpdir(), 'vibe-usage-cursor-'));
53
- queryPath = join(snapshotDir, 'state.vscdb');
54
- copyFileSync(dbPath, queryPath);
55
- for (const suffix of ['-shm', '-wal']) {
56
- const companion = `${dbPath}${suffix}`;
57
- if (existsSync(companion)) copyFileSync(companion, `${queryPath}${suffix}`);
58
- }
59
- try {
60
- return queryAccessToken(queryPath);
61
- } finally {
62
- rmSync(snapshotDir, { recursive: true, force: true });
63
- }
64
- }
65
- }
66
-
67
- function queryAccessToken(dbPath) {
45
+ // Cursor app holds a write lock; queryDbJsonSnapshotOnLock copies the WAL set
46
+ // to a temp dir and retries on "database is locked".
68
47
  const sql = `SELECT value FROM ItemTable WHERE key = '${ACCESS_TOKEN_KEY}' LIMIT 1`;
69
- const rows = queryDbJson(dbPath, sql, { maxBuffer: 4 * 1024 * 1024, timeout: 15000 });
48
+ const rows = queryDbJsonSnapshotOnLock(dbPath, sql, {
49
+ tempPrefix: 'vibe-usage-cursor-',
50
+ opts: { maxBuffer: 4 * 1024 * 1024, timeout: 15000 },
51
+ });
70
52
  const value = rows[0]?.value;
71
53
  if (typeof value !== 'string') return null;
72
54
  const t = value.trim();
73
55
  return t || null;
74
56
  }
75
57
 
76
- function isLockError(err) {
77
- return err && typeof err.message === 'string' && /database is locked/i.test(err.message);
78
- }
79
-
80
58
  function decodeJwtSub(token) {
81
59
  const payload = token.split('.')[1];
82
60
  if (!payload) return null;
@@ -197,8 +175,8 @@ export async function parse() {
197
175
  try {
198
176
  token = readAccessToken(dbPath);
199
177
  } catch (err) {
200
- if (err && typeof err.message === 'string' && err.message.includes('ENOENT')) {
201
- throw new Error('sqlite3 CLI not found. Install sqlite3 (or use Node >= 22.5) to sync Cursor data.');
178
+ if (isSqliteUnavailableError(err)) {
179
+ throw sqliteUnavailableError('Cursor');
202
180
  }
203
181
  throw err;
204
182
  }