@vibe-cafe/vibe-usage 0.10.10 → 0.10.12

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` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.10.10",
3
+ "version": "0.10.12",
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 || {};
@@ -207,7 +195,7 @@ async function scanProjectCandidate(candidate) {
207
195
  if (usageScore === 0) return;
208
196
 
209
197
  entries.push({
210
- uuid: typeof obj.uuid === 'string' && obj.uuid ? obj.uuid : null,
198
+ dedupeKey: usageDedupeKey(obj),
211
199
  usageScore,
212
200
  source: 'claude-code',
213
201
  model,
@@ -251,22 +239,36 @@ async function scanBestCandidate(candidates, scanner, ctx) {
251
239
  return null;
252
240
  }
253
241
 
242
+ // One API call is written as several assistant lines - one per content block -
243
+ // that share `message.id`/`requestId` and repeat the same `usage` object, so a
244
+ // per-line key counts the same call once per block. Streaming also emits an
245
+ // early partial line (lower `output_tokens`) before the final one under that
246
+ // same id. Keying on the call identity collapses both, and the existing
247
+ // highest-usageScore wins rule then keeps the final, complete payload.
248
+ // Records without either id (older logs) fall back to the line uuid.
249
+ function usageDedupeKey(obj) {
250
+ const messageId = typeof obj.message?.id === 'string' ? obj.message.id.trim() : '';
251
+ const requestId = typeof obj.requestId === 'string' ? obj.requestId.trim() : '';
252
+ if (messageId || requestId) return `call:${messageId}\u0000${requestId}`;
253
+ return typeof obj.uuid === 'string' && obj.uuid ? obj.uuid : null;
254
+ }
255
+
254
256
  function mergeUsageEntry(ctx, entry) {
255
- if (!entry.uuid) {
257
+ if (!entry.dedupeKey) {
256
258
  ctx.anonymousEntries.push(entry);
257
259
  return;
258
260
  }
259
- const current = ctx.entriesByUuid.get(entry.uuid);
260
- // Claude sometimes copies the same UUID into another session with zeroed
261
+ const current = ctx.entriesByKey.get(entry.dedupeKey);
262
+ // Claude sometimes copies the same record into another session with zeroed
261
263
  // usage. Keep the most complete payload, independent of directory order.
262
264
  if (!current || entry.usageScore > current.usageScore) {
263
- ctx.entriesByUuid.set(entry.uuid, entry);
265
+ ctx.entriesByKey.set(entry.dedupeKey, entry);
264
266
  }
265
267
  }
266
268
 
267
269
  export async function parse() {
268
270
  const ctx = {
269
- entriesByUuid: new Map(),
271
+ entriesByKey: new Map(),
270
272
  anonymousEntries: [],
271
273
  sessionEvents: [],
272
274
  warnings: [],
@@ -295,8 +297,8 @@ export async function parse() {
295
297
 
296
298
  const entries = [
297
299
  ...ctx.anonymousEntries,
298
- ...ctx.entriesByUuid.values(),
299
- ].map(({ uuid: _uuid, usageScore: _usageScore, ...entry }) => entry);
300
+ ...ctx.entriesByKey.values(),
301
+ ].map(({ dedupeKey: _dedupeKey, usageScore: _usageScore, ...entry }) => entry);
300
302
 
301
303
  return {
302
304
  buckets: aggregateToBuckets(entries),
@@ -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