@vibe-cafe/vibe-usage 0.9.4 → 0.9.6

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
@@ -63,7 +63,7 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
63
63
  | Amp | `~/.local/share/amp/threads/` |
64
64
  | Droid | `~/.factory/sessions/` |
65
65
  | Hermes | `~/.hermes/state.db` + `~/.hermes/profiles/<name>/state.db` (SQLite, multi-profile) |
66
- | Kiro | `~/Library/Application Support/Kiro/User/globalStorage/kiro.kiroagent/dev_data/devdata.sqlite` (SQLite, JSONL fallback; model name resolved from `.chat` timeline) |
66
+ | Kiro | Kiro CLI `~/Library/Application Support/kiro-cli/data.sqlite3` / `~/.local/share/kiro-cli/data.sqlite3` plus optional `~/.kiro_sessions/*.json` archives (estimated tokens: CacheWrite = chars/4, CacheRead = accumulated context, Output = streamed chunks). If no CLI data exists, falls back to IDE `q-client.log` credit deltas as `kiro-credits`; legacy IDE `dev_data/devdata.sqlite` token telemetry is opt-in with `VIBE_USAGE_KIRO_LEGACY_TOKENS=1` |
67
67
  | Cline | `<host>/User/globalStorage/saoudrizwan.claude-dev/{state/taskHistory.json,tasks/<id>/ui_messages.json}` (walks all VSCode-fork hosts: Code, Cursor, Windsurf, VSCodium, Trae, ...) |
68
68
  | Roo Code | `<host>/User/globalStorage/rooveterinaryinc.roo-cline/{tasks/_index.json,tasks/<id>/{history_item,ui_messages}.json}` (walks all VSCode-fork hosts) |
69
69
  | Antigravity | `~/.gemini/antigravity/conversations/*.pb` to discover cascades, then reads token usage + sessions from the running language server via Connect RPC (process discovered with `ps`/`lsof` on macOS/Linux, PowerShell CIM with a `wmic` fallback on Windows) |
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.9.4",
3
+ "version": "0.9.6",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
+ "scripts": {
7
+ "test": "node --test"
8
+ },
6
9
  "bin": {
7
10
  "vibe-usage": "bin/vibe-usage.js"
8
11
  },
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  copyFileSync,
3
+ createReadStream,
3
4
  existsSync,
4
5
  mkdtempSync,
5
6
  readFileSync,
@@ -7,23 +8,32 @@ import {
7
8
  rmSync,
8
9
  statSync,
9
10
  } from 'node:fs';
10
- import { join, resolve } from 'node:path';
11
+ import { createInterface } from 'node:readline';
12
+ import { dirname, join, resolve } from 'node:path';
11
13
  import { homedir, tmpdir } from 'node:os';
12
14
  import { aggregateToBuckets } from './index.js';
13
15
  import { queryDbJson } from './sqlite.js';
14
16
 
15
- const KIROAGENT_RELATIVE = join('User', 'globalStorage', 'kiro.kiroagent');
17
+ const KIRO_AGENT_RELATIVE = join('User', 'globalStorage', 'kiro.kiroagent');
18
+ const KIRO_USER_RELATIVE = 'User';
19
+ const CREDIT_MODEL = 'kiro-credits';
20
+ const ESTIMATE_MODEL = 'kiro-token-estimate';
21
+ const CHARS_PER_TOKEN = 4;
16
22
 
17
- function getDefaultBasePath() {
23
+ function getDefaultAppPath() {
18
24
  if (process.platform === 'darwin') {
19
- return join(homedir(), 'Library', 'Application Support', 'Kiro', KIROAGENT_RELATIVE);
25
+ return join(homedir(), 'Library', 'Application Support', 'Kiro');
20
26
  }
21
27
  if (process.platform === 'win32') {
22
28
  const appData = process.env.APPDATA?.trim() || join(homedir(), 'AppData', 'Roaming');
23
- return join(appData, 'Kiro', KIROAGENT_RELATIVE);
29
+ return join(appData, 'Kiro');
24
30
  }
25
31
  const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), '.config');
26
- return join(xdgConfigHome, 'Kiro', KIROAGENT_RELATIVE);
32
+ return join(xdgConfigHome, 'Kiro');
33
+ }
34
+
35
+ function getDefaultUserPath() {
36
+ return join(getDefaultAppPath(), KIRO_USER_RELATIVE);
27
37
  }
28
38
 
29
39
  export function getKiroBasePath() {
@@ -32,7 +42,53 @@ export function getKiroBasePath() {
32
42
  const r = resolve(explicit);
33
43
  return existsSync(r) ? r : null;
34
44
  }
35
- const def = getDefaultBasePath();
45
+ const def = join(getDefaultAppPath(), KIRO_AGENT_RELATIVE);
46
+ return existsSync(def) ? def : null;
47
+ }
48
+
49
+ export function getKiroUserPath() {
50
+ const explicitUser = process.env.KIRO_USER_PATH?.trim();
51
+ if (explicitUser) {
52
+ const r = resolve(explicitUser);
53
+ return existsSync(r) ? r : null;
54
+ }
55
+
56
+ const explicitBase = process.env.KIRO_BASE_PATH?.trim();
57
+ if (explicitBase) {
58
+ const base = resolve(explicitBase);
59
+ const userPath = resolve(base, '..', '..');
60
+ return existsSync(userPath) ? userPath : null;
61
+ }
62
+
63
+ const def = getDefaultUserPath();
64
+ return existsSync(def) ? def : null;
65
+ }
66
+
67
+ export function getKiroCliDbPath() {
68
+ const explicit = process.env.KIRO_CLI_DB_PATH?.trim();
69
+ if (explicit) {
70
+ const r = resolve(explicit);
71
+ return existsSync(r) ? r : null;
72
+ }
73
+ let def;
74
+ if (process.platform === 'darwin') {
75
+ def = join(homedir(), 'Library', 'Application Support', 'kiro-cli', 'data.sqlite3');
76
+ } else if (process.platform === 'win32') {
77
+ const appData = process.env.APPDATA?.trim() || join(homedir(), 'AppData', 'Roaming');
78
+ def = join(appData, 'kiro-cli', 'data.sqlite3');
79
+ } else {
80
+ def = join(homedir(), '.local', 'share', 'kiro-cli', 'data.sqlite3');
81
+ }
82
+ return existsSync(def) ? def : null;
83
+ }
84
+
85
+ export function getKiroSessionsDir() {
86
+ const explicit = process.env.KIRO_SESSIONS_DIR?.trim();
87
+ if (explicit) {
88
+ const r = resolve(explicit);
89
+ return existsSync(r) ? r : null;
90
+ }
91
+ const def = join(homedir(), '.kiro_sessions');
36
92
  return existsSync(def) ? def : null;
37
93
  }
38
94
 
@@ -44,37 +100,50 @@ function queryDb(dbPath, sql) {
44
100
  return queryDbJson(dbPath, sql);
45
101
  }
46
102
 
47
- const TOKENS_SQL =
48
- 'SELECT id, model, tokens_prompt, tokens_generated, timestamp ' +
49
- 'FROM tokens_generated ' +
50
- 'WHERE tokens_prompt > 0 OR tokens_generated > 0 ' +
51
- 'ORDER BY id ASC';
52
-
53
- function readDb(dbPath) {
103
+ function queryDbSnapshotOnLock(dbPath, sql) {
54
104
  try {
55
- return queryDb(dbPath, TOKENS_SQL);
105
+ return queryDb(dbPath, sql);
56
106
  } catch (err) {
57
107
  if (!isLockError(err)) throw err;
58
- // Kiro app holds a write lock; snapshot WAL set to a temp dir and retry.
59
108
  const snapshotDir = mkdtempSync(join(tmpdir(), 'vibe-usage-kiro-'));
60
- const queryPath = join(snapshotDir, 'devdata.sqlite');
109
+ const queryPath = join(snapshotDir, 'data.sqlite3');
61
110
  copyFileSync(dbPath, queryPath);
62
111
  for (const suffix of ['-shm', '-wal']) {
63
112
  const companion = `${dbPath}${suffix}`;
64
113
  if (existsSync(companion)) copyFileSync(companion, `${queryPath}${suffix}`);
65
114
  }
66
115
  try {
67
- return queryDb(queryPath, TOKENS_SQL);
116
+ return queryDb(queryPath, sql);
68
117
  } finally {
69
118
  rmSync(snapshotDir, { recursive: true, force: true });
70
119
  }
71
120
  }
72
121
  }
73
122
 
74
- // JSONL fallback: tokens_generated.jsonl. Each line:
75
- // {"model":"agent","provider":"kiro","promptTokens":N,"generatedTokens":N}
76
- // No per-row timestamp — bucket all rows under the file mtime.
77
- function readJsonl(jsonlPath) {
123
+ function queryOptionalDb(dbPath, sql) {
124
+ try {
125
+ return queryDbSnapshotOnLock(dbPath, sql);
126
+ } catch (err) {
127
+ const msg = err && typeof err.message === 'string' ? err.message : '';
128
+ if (/no such table|no such column/i.test(msg)) return [];
129
+ throw err;
130
+ }
131
+ }
132
+
133
+ const TOKENS_SQL =
134
+ 'SELECT id, model, tokens_prompt, tokens_generated, timestamp ' +
135
+ 'FROM tokens_generated ' +
136
+ 'WHERE tokens_prompt > 0 OR tokens_generated > 0 ' +
137
+ 'ORDER BY id ASC';
138
+
139
+ function readLegacyDb(dbPath) {
140
+ return queryDbSnapshotOnLock(dbPath, TOKENS_SQL);
141
+ }
142
+
143
+ // Legacy Kiro dev telemetry fallback. This is opt-in because recent Kiro builds
144
+ // bill by server-side credits, while this table is often empty, estimated, or
145
+ // populated with placeholder model names such as "agent".
146
+ function readLegacyJsonl(jsonlPath) {
78
147
  let raw;
79
148
  try { raw = readFileSync(jsonlPath, 'utf-8'); } catch { return []; }
80
149
  const lines = raw.split('\n').map(l => l.trim()).filter(Boolean);
@@ -88,7 +157,7 @@ function readJsonl(jsonlPath) {
88
157
  const obj = JSON.parse(lines[i]);
89
158
  rows.push({
90
159
  id: i + 1,
91
- model: obj.model || 'agent',
160
+ model: obj.model || ESTIMATE_MODEL,
92
161
  tokens_prompt: obj.promptTokens || 0,
93
162
  tokens_generated: obj.generatedTokens || 0,
94
163
  timestamp: ts,
@@ -100,132 +169,443 @@ function readJsonl(jsonlPath) {
100
169
  return rows;
101
170
  }
102
171
 
103
- // Walk workspace dirs under kiro.kiroagent/ and collect every .chat file's
104
- // modelId + start/end window. Used to attribute each tokens_generated row to
105
- // a real model (the SQLite `model` column is usually the literal "agent").
106
- function buildModelTimeline(base) {
107
- const timeline = [];
108
- let entries;
109
- try { entries = readdirSync(base, { withFileTypes: true }); } catch { return timeline; }
110
- for (const entry of entries) {
111
- if (!entry.isDirectory() || entry.name === 'dev_data') continue;
112
- const dirPath = join(base, entry.name);
113
- let files;
172
+ function parseDbTimestamp(value) {
173
+ if (!value) return null;
174
+ const s = String(value).trim();
175
+ const hasZone = /(?:Z|[+-]\d\d:?\d\d)$/.test(s);
176
+ const d = new Date(hasZone ? s.replace(' ', 'T') : `${s.replace(' ', 'T')}Z`);
177
+ return isNaN(d.getTime()) ? null : d;
178
+ }
179
+
180
+ function normalizeLegacyModel(raw) {
181
+ const model = typeof raw === 'string' ? raw.trim() : '';
182
+ if (!model || model.toLowerCase() === 'agent') return ESTIMATE_MODEL;
183
+ if (model === model.toLowerCase() && model.includes('-')) return model;
184
+ return model
185
+ .replace(/_\d{8}_V\d+_\d+$/i, '')
186
+ .replace(/_V\d+$/i, '')
187
+ .toLowerCase()
188
+ .replace(/_/g, '-') || ESTIMATE_MODEL;
189
+ }
190
+
191
+ function rowsToLegacyEntries(rows) {
192
+ const entries = [];
193
+ for (const row of rows) {
194
+ const inputTokens = Math.max(0, Number(row.tokens_prompt) || 0);
195
+ const outputTokens = Math.max(0, Number(row.tokens_generated) || 0);
196
+ if (inputTokens === 0 && outputTokens === 0) continue;
197
+ const timestamp = parseDbTimestamp(row.timestamp);
198
+ if (!timestamp) continue;
199
+ entries.push({
200
+ source: 'kiro',
201
+ model: normalizeLegacyModel(row.model),
202
+ project: 'unknown',
203
+ timestamp,
204
+ inputTokens,
205
+ outputTokens,
206
+ cachedInputTokens: 0,
207
+ reasoningOutputTokens: 0,
208
+ });
209
+ }
210
+ return entries;
211
+ }
212
+
213
+ function textLength(field) {
214
+ if (!field) return 0;
215
+ if (typeof field !== 'object' || Array.isArray(field)) return String(field).length;
216
+ let len = 0;
217
+ for (const [key, value] of Object.entries(field)) {
218
+ if (key === 'images') continue;
219
+ len += String(value ?? '').length;
220
+ }
221
+ return len;
222
+ }
223
+
224
+ function imageTokens(field) {
225
+ if (!field || typeof field !== 'object' || Array.isArray(field)) return 0;
226
+ const images = field.images;
227
+ if (!Array.isArray(images)) return 0;
228
+ let total = 0;
229
+ for (const image of images) {
230
+ const source = image && typeof image === 'object' ? image.source || {} : {};
231
+ const rawData = source.Bytes;
114
232
  try {
115
- files = readdirSync(dirPath).filter(f => f.endsWith('.chat'));
233
+ let buf;
234
+ if (Array.isArray(rawData)) {
235
+ buf = Buffer.from(rawData);
236
+ } else if (typeof rawData === 'string') {
237
+ buf = Buffer.from(rawData, 'base64');
238
+ }
239
+ if (buf?.length >= 24 && buf.subarray(0, 4).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47]))) {
240
+ total += Math.floor((buf.readUInt32BE(16) * buf.readUInt32BE(20)) / 750);
241
+ continue;
242
+ }
116
243
  } catch {
117
- continue;
244
+ // fall back below
118
245
  }
119
- for (const file of files) {
120
- try {
121
- const data = JSON.parse(readFileSync(join(dirPath, file), 'utf-8'));
122
- const meta = data?.metadata;
123
- if (!meta?.modelId || !meta?.startTime) continue;
124
- const startMs = Number(meta.startTime);
125
- const endMs = Number(meta.endTime || meta.startTime);
126
- if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) continue;
127
- timeline.push({ startMs, endMs, model: String(meta.modelId) });
128
- } catch {
129
- // skip unreadable / malformed chat files
130
- }
246
+ total += 1600;
247
+ }
248
+ return total;
249
+ }
250
+
251
+ function estimateTextTokens(field) {
252
+ return Math.floor(textLength(field) / CHARS_PER_TOKEN);
253
+ }
254
+
255
+ function normalizeCliModel(raw) {
256
+ const model = typeof raw === 'string' ? raw.trim() : '';
257
+ return model || ESTIMATE_MODEL;
258
+ }
259
+
260
+ function parseMsTimestamp(value) {
261
+ const n = Number(value);
262
+ if (!Number.isFinite(n) || n <= 0) return null;
263
+ const d = new Date(n);
264
+ return isNaN(d.getTime()) ? null : d;
265
+ }
266
+
267
+ function extractConversationTimes(data) {
268
+ const turns = Array.isArray(data?.history) ? data.history : [];
269
+ const first = turns[0]?.request_metadata?.request_start_timestamp_ms;
270
+ const last = turns[turns.length - 1]?.request_metadata?.request_start_timestamp_ms;
271
+ return {
272
+ createdAt: Number(first) || 0,
273
+ updatedAt: Number(last) || Number(first) || 0,
274
+ };
275
+ }
276
+
277
+ function conversationToEntries(conversation) {
278
+ const data = conversation?.value;
279
+ const turns = Array.isArray(data?.history) ? data.history : [];
280
+ const summary = data?.latest_summary || [];
281
+ const summaryTokens = summary ? Math.floor(String(summary).length / CHARS_PER_TOKEN) : 0;
282
+ let cumulative = summaryTokens;
283
+ let prevAssistantTokens = 0;
284
+ const entries = [];
285
+
286
+ for (let i = 0; i < turns.length; i++) {
287
+ const turn = turns[i] || {};
288
+ const meta = turn.request_metadata || {};
289
+ const timestamp = parseMsTimestamp(meta.request_start_timestamp_ms);
290
+ if (!timestamp) continue;
291
+
292
+ const userTokens = estimateTextTokens(turn.user) + imageTokens(turn.user);
293
+ const assistantTokens = estimateTextTokens(turn.assistant);
294
+ const outputTokens = Array.isArray(meta.time_between_chunks) ? meta.time_between_chunks.length : 0;
295
+ const cachedInputTokens = i > 0 ? cumulative : 0;
296
+ const inputTokens = userTokens + (i > 0 ? prevAssistantTokens : 0);
297
+
298
+ cumulative += userTokens + assistantTokens;
299
+ prevAssistantTokens = assistantTokens;
300
+
301
+ if (inputTokens === 0 && outputTokens === 0 && cachedInputTokens === 0) continue;
302
+ entries.push({
303
+ source: 'kiro',
304
+ model: normalizeCliModel(meta.model_id),
305
+ project: conversation.cwd || 'unknown',
306
+ timestamp,
307
+ inputTokens,
308
+ outputTokens,
309
+ cachedInputTokens,
310
+ reasoningOutputTokens: 0,
311
+ });
312
+ }
313
+
314
+ return entries;
315
+ }
316
+
317
+ export function conversationsToEstimateEntries(conversations) {
318
+ const entries = [];
319
+ const byId = new Map();
320
+ for (const conversation of conversations) {
321
+ const id = conversation?.conversation_id;
322
+ if (!id) continue;
323
+ const updatedAt = Number(conversation.updated_at) || 0;
324
+ const existing = byId.get(id);
325
+ if (!existing || updatedAt >= (Number(existing.updated_at) || 0)) {
326
+ byId.set(id, conversation);
131
327
  }
132
328
  }
133
- timeline.sort((a, b) => a.startMs - b.startMs);
134
- return timeline;
329
+ for (const conversation of byId.values()) {
330
+ entries.push(...conversationToEntries(conversation));
331
+ }
332
+ return entries;
135
333
  }
136
334
 
137
- function resolveModel(timeline, ts) {
138
- if (!timeline.length || !ts) return null;
139
- const t = ts.getTime();
140
- if (!Number.isFinite(t)) return null;
141
- let best = null;
142
- let bestDist = Infinity;
143
- for (const e of timeline) {
144
- if (t >= e.startMs && t <= e.endMs) return e.model;
145
- const d = Math.min(Math.abs(t - e.startMs), Math.abs(t - e.endMs));
146
- if (d < bestDist) { bestDist = d; best = e.model; }
335
+ function readArchivedConversations(sessionsDir) {
336
+ if (!sessionsDir) return [];
337
+ let files;
338
+ try {
339
+ files = readdirSync(sessionsDir).filter(f => f.endsWith('.json'));
340
+ } catch {
341
+ return [];
342
+ }
343
+ const conversations = [];
344
+ for (const file of files) {
345
+ try {
346
+ const obj = JSON.parse(readFileSync(join(sessionsDir, file), 'utf-8'));
347
+ if (obj?.conversation_id && obj?.value) conversations.push(obj);
348
+ } catch {
349
+ // skip malformed archives
350
+ }
147
351
  }
148
- // 10-minute tolerance — beyond that, treat as no match.
149
- return bestDist < 10 * 60 * 1000 ? best : null;
352
+ return conversations;
150
353
  }
151
354
 
152
- // "CLAUDE_SONNET_4_20250514_V1_0" -> "claude-sonnet-4"
153
- function normalizeModelName(raw) {
154
- if (!raw || typeof raw !== 'string') return null;
155
- const trimmed = raw.trim();
156
- if (!trimmed) return null;
157
- if (trimmed === trimmed.toLowerCase() && trimmed.includes('-')) return trimmed;
158
- const cleaned = trimmed
159
- .replace(/_\d{8}_V\d+_\d+$/i, '')
160
- .replace(/_V\d+$/i, '')
161
- .toLowerCase()
162
- .replace(/_/g, '-');
163
- return cleaned || null;
355
+ function readCliDbConversations(dbPath) {
356
+ if (!dbPath) return [];
357
+ const conversations = [];
358
+ for (const row of queryOptionalDb(
359
+ dbPath,
360
+ 'SELECT conversation_id, key as cwd, created_at, updated_at, value FROM conversations_v2',
361
+ )) {
362
+ try {
363
+ conversations.push({
364
+ conversation_id: row.conversation_id,
365
+ cwd: row.cwd || 'unknown',
366
+ created_at: Number(row.created_at) || 0,
367
+ updated_at: Number(row.updated_at) || 0,
368
+ value: JSON.parse(row.value),
369
+ });
370
+ } catch {
371
+ // skip malformed conversations
372
+ }
373
+ }
374
+
375
+ for (const row of queryOptionalDb(dbPath, 'SELECT key as cwd, value FROM conversations')) {
376
+ try {
377
+ const data = JSON.parse(row.value);
378
+ const conversationId = data?.conversation_id;
379
+ if (!conversationId) continue;
380
+ const { createdAt, updatedAt } = extractConversationTimes(data);
381
+ conversations.push({
382
+ conversation_id: conversationId,
383
+ cwd: row.cwd || 'unknown',
384
+ created_at: createdAt,
385
+ updated_at: updatedAt,
386
+ value: data,
387
+ });
388
+ } catch {
389
+ // skip malformed conversations
390
+ }
391
+ }
392
+ return conversations;
164
393
  }
165
394
 
166
- function parseDbTimestamp(value) {
167
- if (!value) return null;
168
- // SQLite CURRENT_TIMESTAMP: "2026-01-09 15:25:30" (UTC, naive — append Z).
169
- const d = new Date(String(value).trim().replace(' ', 'T') + 'Z');
395
+ function readCliEstimateEntries() {
396
+ const conversations = [
397
+ ...readArchivedConversations(getKiroSessionsDir()),
398
+ ...readCliDbConversations(getKiroCliDbPath()),
399
+ ];
400
+ return conversationsToEstimateEntries(conversations);
401
+ }
402
+
403
+ function parseLogTimestamp(raw) {
404
+ const d = new Date(String(raw).replace(' ', 'T'));
170
405
  return isNaN(d.getTime()) ? null : d;
171
406
  }
172
407
 
173
- export async function parse() {
174
- const base = getKiroBasePath();
175
- if (!base) return { buckets: [], sessions: [] };
408
+ function parseLogLine(line) {
409
+ const match = /^(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\.\d{3}) \[[^\]]+\] (\{.*\})$/.exec(line);
410
+ if (!match) return null;
411
+ const timestamp = parseLogTimestamp(match[1]);
412
+ if (!timestamp) return null;
413
+ try {
414
+ return { timestamp, obj: JSON.parse(match[2]) };
415
+ } catch {
416
+ return null;
417
+ }
418
+ }
419
+
420
+ function usageBreakdownsFromCommand(obj) {
421
+ if (obj?.commandName !== 'GetUsageLimitsCommand') return [];
422
+ const out = obj.output || {};
423
+ if (Array.isArray(out.usageBreakdownList)) return out.usageBreakdownList;
424
+ if (Array.isArray(out.usageBreakdowns)) return out.usageBreakdowns;
425
+ return [];
426
+ }
427
+
428
+ function numberFrom(...values) {
429
+ for (const value of values) {
430
+ const n = Number(value);
431
+ if (Number.isFinite(n)) return n;
432
+ }
433
+ return null;
434
+ }
435
+
436
+ function maxNumberFrom(...values) {
437
+ const nums = values
438
+ .map(value => Number(value))
439
+ .filter(Number.isFinite);
440
+ if (nums.length === 0) return null;
441
+ return Math.max(...nums);
442
+ }
443
+
444
+ function snapshotFromBreakdown(timestamp, breakdown) {
445
+ const type = String(breakdown.resourceType || breakdown.type || '').toUpperCase();
446
+ const unit = String(breakdown.unit || '').toUpperCase();
447
+ if (type !== 'CREDIT' || unit !== 'INVOCATIONS') return null;
176
448
 
449
+ const currentUsage = maxNumberFrom(
450
+ breakdown.currentUsageWithPrecision,
451
+ breakdown.currentUsage,
452
+ breakdown.freeTrialInfo?.currentUsageWithPrecision,
453
+ breakdown.freeTrialInfo?.currentUsage,
454
+ breakdown.freeTrialUsage?.currentUsage,
455
+ );
456
+ if (currentUsage === null) return null;
457
+
458
+ return {
459
+ timestamp,
460
+ currentUsage,
461
+ resetDate: String(breakdown.nextDateReset || breakdown.resetDate || ''),
462
+ usageLimit: numberFrom(
463
+ breakdown.usageLimitWithPrecision,
464
+ breakdown.usageLimit,
465
+ breakdown.freeTrialInfo?.usageLimitWithPrecision,
466
+ breakdown.freeTrialInfo?.usageLimit,
467
+ breakdown.freeTrialUsage?.usageLimit,
468
+ ),
469
+ };
470
+ }
471
+
472
+ function findQClientLogs(logsRoot) {
473
+ const files = [];
474
+ const stack = [logsRoot];
475
+ while (stack.length) {
476
+ const dir = stack.pop();
477
+ let entries;
478
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; }
479
+ for (const entry of entries) {
480
+ const p = join(dir, entry.name);
481
+ if (entry.isDirectory()) {
482
+ stack.push(p);
483
+ } else if (entry.isFile() && /^q-client\.log(?:\.\d+)?$/.test(entry.name)) {
484
+ files.push(p);
485
+ }
486
+ }
487
+ }
488
+ return files.sort();
489
+ }
490
+
491
+ async function readLogSnapshots(logPath) {
492
+ const snapshots = [];
493
+ const rl = createInterface({
494
+ input: createReadStream(logPath, { encoding: 'utf-8' }),
495
+ crlfDelay: Infinity,
496
+ });
497
+ for await (const line of rl) {
498
+ const parsed = parseLogLine(line);
499
+ if (!parsed) continue;
500
+ for (const breakdown of usageBreakdownsFromCommand(parsed.obj)) {
501
+ const snapshot = snapshotFromBreakdown(parsed.timestamp, breakdown);
502
+ if (snapshot) snapshots.push(snapshot);
503
+ }
504
+ }
505
+ return snapshots;
506
+ }
507
+
508
+ async function readUsageSnapshots(userPath) {
509
+ const appPath = dirname(userPath);
510
+ const logsRoot = join(appPath, 'logs');
511
+ const files = findQClientLogs(logsRoot);
512
+ const snapshots = [];
513
+ for (const file of files) {
514
+ try {
515
+ snapshots.push(...await readLogSnapshots(file));
516
+ } catch {
517
+ // skip unreadable / concurrently rotated logs
518
+ }
519
+ }
520
+ return snapshots;
521
+ }
522
+
523
+ function dedupeSnapshots(snapshots) {
524
+ const map = new Map();
525
+ for (const s of snapshots) {
526
+ const key = `${s.timestamp.toISOString()}|${s.resetDate}|${s.currentUsage}`;
527
+ map.set(key, s);
528
+ }
529
+ return Array.from(map.values()).sort((a, b) => a.timestamp - b.timestamp);
530
+ }
531
+
532
+ export function snapshotsToCreditEntries(snapshots) {
533
+ const ordered = dedupeSnapshots(snapshots);
534
+ const entries = [];
535
+ let prev = null;
536
+
537
+ for (const snapshot of ordered) {
538
+ if (!prev || snapshot.resetDate !== prev.resetDate || snapshot.currentUsage < prev.currentUsage) {
539
+ prev = snapshot;
540
+ continue;
541
+ }
542
+
543
+ const delta = Number((snapshot.currentUsage - prev.currentUsage).toFixed(4));
544
+ if (delta > 0) {
545
+ entries.push({
546
+ source: 'kiro',
547
+ model: CREDIT_MODEL,
548
+ project: 'unknown',
549
+ timestamp: snapshot.timestamp,
550
+ inputTokens: 0,
551
+ outputTokens: delta,
552
+ cachedInputTokens: 0,
553
+ reasoningOutputTokens: 0,
554
+ });
555
+ }
556
+ prev = snapshot;
557
+ }
558
+
559
+ return entries;
560
+ }
561
+
562
+ function parseLegacyTokens(base) {
177
563
  const dbPath = join(base, 'dev_data', 'devdata.sqlite');
178
564
  const jsonlPath = join(base, 'dev_data', 'tokens_generated.jsonl');
179
-
180
565
  let rows;
566
+ if (existsSync(dbPath)) {
567
+ rows = readLegacyDb(dbPath);
568
+ } else if (existsSync(jsonlPath)) {
569
+ rows = readLegacyJsonl(jsonlPath);
570
+ } else {
571
+ rows = [];
572
+ }
573
+ return rowsToLegacyEntries(rows);
574
+ }
575
+
576
+ export async function parse() {
181
577
  try {
182
- if (existsSync(dbPath)) {
183
- rows = readDb(dbPath);
184
- } else if (existsSync(jsonlPath)) {
185
- rows = readJsonl(jsonlPath);
186
- } else {
187
- return { buckets: [], sessions: [] };
578
+ const estimateEntries = readCliEstimateEntries();
579
+ if (estimateEntries.length > 0) {
580
+ return { buckets: aggregateToBuckets(estimateEntries), sessions: [] };
188
581
  }
189
582
  } catch (err) {
190
583
  if (err && typeof err.message === 'string' && err.message.includes('ENOENT')) {
191
- throw new Error('sqlite3 CLI not found. Install sqlite3 (or use Node >= 22.5) to sync Kiro data.');
584
+ throw new Error('sqlite3 CLI not found. Install sqlite3 (or use Node >= 22.5) to sync Kiro CLI data.');
192
585
  }
193
586
  throw err;
194
587
  }
195
588
 
196
- if (!rows.length) return { buckets: [], sessions: [] };
197
-
198
- const timeline = buildModelTimeline(base);
199
- const entries = [];
200
- for (const row of rows) {
201
- const inputTokens = Math.max(0, Number(row.tokens_prompt) || 0);
202
- const outputTokens = Math.max(0, Number(row.tokens_generated) || 0);
203
- if (inputTokens === 0 && outputTokens === 0) continue;
204
- const timestamp = parseDbTimestamp(row.timestamp);
205
- if (!timestamp) continue;
589
+ const userPath = getKiroUserPath();
590
+ if (!userPath) return { buckets: [], sessions: [] };
206
591
 
207
- // Prefer the .chat timeline; fall back to the row's literal model (skip
208
- // the placeholder "agent"); then "kiro-agent".
209
- let model = normalizeModelName(resolveModel(timeline, timestamp));
210
- if (!model) {
211
- const literal = (row.model || '').trim();
212
- if (literal && literal.toLowerCase() !== 'agent') {
213
- model = normalizeModelName(literal);
214
- }
215
- }
216
- if (!model) model = 'kiro-agent';
592
+ const snapshots = await readUsageSnapshots(userPath);
593
+ const entries = snapshotsToCreditEntries(snapshots);
594
+ if (entries.length > 0) {
595
+ return { buckets: aggregateToBuckets(entries), sessions: [] };
596
+ }
217
597
 
218
- entries.push({
219
- source: 'kiro',
220
- model,
221
- project: 'unknown',
222
- timestamp,
223
- inputTokens,
224
- outputTokens,
225
- cachedInputTokens: 0,
226
- reasoningOutputTokens: 0,
227
- });
598
+ // Keep old token telemetry available for explicit debugging, but do not use
599
+ // it by default: it is not Kiro's billing source and causes false model rows.
600
+ if (process.env.VIBE_USAGE_KIRO_LEGACY_TOKENS === '1') {
601
+ const base = getKiroBasePath();
602
+ if (!base) return { buckets: [], sessions: [] };
603
+ const legacyEntries = parseLegacyTokens(base);
604
+ return { buckets: aggregateToBuckets(legacyEntries), sessions: [] };
228
605
  }
229
606
 
230
- return { buckets: aggregateToBuckets(entries), sessions: [] };
607
+ // state.vscdb contains only the latest cumulative credit snapshot. The parser
608
+ // stays stateless, so a single cumulative point cannot be uploaded as a bucket
609
+ // without double-counting on later syncs.
610
+ return { buckets: [], sessions: [] };
231
611
  }