@vibe-cafe/vibe-usage 0.9.4 → 0.9.5

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 | `~/Library/Application Support/Kiro/logs/**/kiro.kiroAgent/q-client.log` (`GetUsageLimitsCommand` credit snapshots; emits incremental `kiro-credits`, because Kiro bills by Credits / `INVOCATIONS`, not local model tokens). Legacy `dev_data/devdata.sqlite` token telemetry is available only 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.5",
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,30 @@ 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';
16
20
 
17
- function getDefaultBasePath() {
21
+ function getDefaultAppPath() {
18
22
  if (process.platform === 'darwin') {
19
- return join(homedir(), 'Library', 'Application Support', 'Kiro', KIROAGENT_RELATIVE);
23
+ return join(homedir(), 'Library', 'Application Support', 'Kiro');
20
24
  }
21
25
  if (process.platform === 'win32') {
22
26
  const appData = process.env.APPDATA?.trim() || join(homedir(), 'AppData', 'Roaming');
23
- return join(appData, 'Kiro', KIROAGENT_RELATIVE);
27
+ return join(appData, 'Kiro');
24
28
  }
25
29
  const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), '.config');
26
- return join(xdgConfigHome, 'Kiro', KIROAGENT_RELATIVE);
30
+ return join(xdgConfigHome, 'Kiro');
31
+ }
32
+
33
+ function getDefaultUserPath() {
34
+ return join(getDefaultAppPath(), KIRO_USER_RELATIVE);
27
35
  }
28
36
 
29
37
  export function getKiroBasePath() {
@@ -32,7 +40,25 @@ export function getKiroBasePath() {
32
40
  const r = resolve(explicit);
33
41
  return existsSync(r) ? r : null;
34
42
  }
35
- const def = getDefaultBasePath();
43
+ const def = join(getDefaultAppPath(), KIRO_AGENT_RELATIVE);
44
+ return existsSync(def) ? def : null;
45
+ }
46
+
47
+ export function getKiroUserPath() {
48
+ const explicitUser = process.env.KIRO_USER_PATH?.trim();
49
+ if (explicitUser) {
50
+ const r = resolve(explicitUser);
51
+ return existsSync(r) ? r : null;
52
+ }
53
+
54
+ const explicitBase = process.env.KIRO_BASE_PATH?.trim();
55
+ if (explicitBase) {
56
+ const base = resolve(explicitBase);
57
+ const userPath = resolve(base, '..', '..');
58
+ return existsSync(userPath) ? userPath : null;
59
+ }
60
+
61
+ const def = getDefaultUserPath();
36
62
  return existsSync(def) ? def : null;
37
63
  }
38
64
 
@@ -50,12 +76,11 @@ const TOKENS_SQL =
50
76
  'WHERE tokens_prompt > 0 OR tokens_generated > 0 ' +
51
77
  'ORDER BY id ASC';
52
78
 
53
- function readDb(dbPath) {
79
+ function readLegacyDb(dbPath) {
54
80
  try {
55
81
  return queryDb(dbPath, TOKENS_SQL);
56
82
  } catch (err) {
57
83
  if (!isLockError(err)) throw err;
58
- // Kiro app holds a write lock; snapshot WAL set to a temp dir and retry.
59
84
  const snapshotDir = mkdtempSync(join(tmpdir(), 'vibe-usage-kiro-'));
60
85
  const queryPath = join(snapshotDir, 'devdata.sqlite');
61
86
  copyFileSync(dbPath, queryPath);
@@ -71,10 +96,10 @@ function readDb(dbPath) {
71
96
  }
72
97
  }
73
98
 
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) {
99
+ // Legacy Kiro dev telemetry fallback. This is opt-in because recent Kiro builds
100
+ // bill by server-side credits, while this table is often empty, estimated, or
101
+ // populated with placeholder model names such as "agent".
102
+ function readLegacyJsonl(jsonlPath) {
78
103
  let raw;
79
104
  try { raw = readFileSync(jsonlPath, 'utf-8'); } catch { return []; }
80
105
  const lines = raw.split('\n').map(l => l.trim()).filter(Boolean);
@@ -88,7 +113,7 @@ function readJsonl(jsonlPath) {
88
113
  const obj = JSON.parse(lines[i]);
89
114
  rows.push({
90
115
  id: i + 1,
91
- model: obj.model || 'agent',
116
+ model: obj.model || 'kiro-token-estimate',
92
117
  tokens_prompt: obj.promptTokens || 0,
93
118
  tokens_generated: obj.generatedTokens || 0,
94
119
  timestamp: ts,
@@ -100,102 +125,26 @@ function readJsonl(jsonlPath) {
100
125
  return rows;
101
126
  }
102
127
 
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;
114
- try {
115
- files = readdirSync(dirPath).filter(f => f.endsWith('.chat'));
116
- } catch {
117
- continue;
118
- }
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
- }
131
- }
132
- }
133
- timeline.sort((a, b) => a.startMs - b.startMs);
134
- return timeline;
135
- }
136
-
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; }
147
- }
148
- // 10-minute tolerance — beyond that, treat as no match.
149
- return bestDist < 10 * 60 * 1000 ? best : null;
128
+ function parseDbTimestamp(value) {
129
+ if (!value) return null;
130
+ const s = String(value).trim();
131
+ const hasZone = /(?:Z|[+-]\d\d:?\d\d)$/.test(s);
132
+ const d = new Date(hasZone ? s.replace(' ', 'T') : `${s.replace(' ', 'T')}Z`);
133
+ return isNaN(d.getTime()) ? null : d;
150
134
  }
151
135
 
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
136
+ function normalizeLegacyModel(raw) {
137
+ const model = typeof raw === 'string' ? raw.trim() : '';
138
+ if (!model || model.toLowerCase() === 'agent') return 'kiro-token-estimate';
139
+ if (model === model.toLowerCase() && model.includes('-')) return model;
140
+ return model
159
141
  .replace(/_\d{8}_V\d+_\d+$/i, '')
160
142
  .replace(/_V\d+$/i, '')
161
143
  .toLowerCase()
162
- .replace(/_/g, '-');
163
- return cleaned || null;
144
+ .replace(/_/g, '-') || 'kiro-token-estimate';
164
145
  }
165
146
 
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');
170
- return isNaN(d.getTime()) ? null : d;
171
- }
172
-
173
- export async function parse() {
174
- const base = getKiroBasePath();
175
- if (!base) return { buckets: [], sessions: [] };
176
-
177
- const dbPath = join(base, 'dev_data', 'devdata.sqlite');
178
- const jsonlPath = join(base, 'dev_data', 'tokens_generated.jsonl');
179
-
180
- let rows;
181
- try {
182
- if (existsSync(dbPath)) {
183
- rows = readDb(dbPath);
184
- } else if (existsSync(jsonlPath)) {
185
- rows = readJsonl(jsonlPath);
186
- } else {
187
- return { buckets: [], sessions: [] };
188
- }
189
- } catch (err) {
190
- 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.');
192
- }
193
- throw err;
194
- }
195
-
196
- if (!rows.length) return { buckets: [], sessions: [] };
197
-
198
- const timeline = buildModelTimeline(base);
147
+ function rowsToLegacyEntries(rows) {
199
148
  const entries = [];
200
149
  for (const row of rows) {
201
150
  const inputTokens = Math.max(0, Number(row.tokens_prompt) || 0);
@@ -203,21 +152,9 @@ export async function parse() {
203
152
  if (inputTokens === 0 && outputTokens === 0) continue;
204
153
  const timestamp = parseDbTimestamp(row.timestamp);
205
154
  if (!timestamp) continue;
206
-
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';
217
-
218
155
  entries.push({
219
156
  source: 'kiro',
220
- model,
157
+ model: normalizeLegacyModel(row.model),
221
158
  project: 'unknown',
222
159
  timestamp,
223
160
  inputTokens,
@@ -226,6 +163,203 @@ export async function parse() {
226
163
  reasoningOutputTokens: 0,
227
164
  });
228
165
  }
166
+ return entries;
167
+ }
168
+
169
+ function parseLogTimestamp(raw) {
170
+ const d = new Date(String(raw).replace(' ', 'T'));
171
+ return isNaN(d.getTime()) ? null : d;
172
+ }
173
+
174
+ function parseLogLine(line) {
175
+ const match = /^(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\.\d{3}) \[[^\]]+\] (\{.*\})$/.exec(line);
176
+ if (!match) return null;
177
+ const timestamp = parseLogTimestamp(match[1]);
178
+ if (!timestamp) return null;
179
+ try {
180
+ return { timestamp, obj: JSON.parse(match[2]) };
181
+ } catch {
182
+ return null;
183
+ }
184
+ }
185
+
186
+ function usageBreakdownsFromCommand(obj) {
187
+ if (obj?.commandName !== 'GetUsageLimitsCommand') return [];
188
+ const out = obj.output || {};
189
+ if (Array.isArray(out.usageBreakdownList)) return out.usageBreakdownList;
190
+ if (Array.isArray(out.usageBreakdowns)) return out.usageBreakdowns;
191
+ return [];
192
+ }
193
+
194
+ function numberFrom(...values) {
195
+ for (const value of values) {
196
+ const n = Number(value);
197
+ if (Number.isFinite(n)) return n;
198
+ }
199
+ return null;
200
+ }
201
+
202
+ function maxNumberFrom(...values) {
203
+ const nums = values
204
+ .map(value => Number(value))
205
+ .filter(Number.isFinite);
206
+ if (nums.length === 0) return null;
207
+ return Math.max(...nums);
208
+ }
209
+
210
+ function snapshotFromBreakdown(timestamp, breakdown) {
211
+ const type = String(breakdown.resourceType || breakdown.type || '').toUpperCase();
212
+ const unit = String(breakdown.unit || '').toUpperCase();
213
+ if (type !== 'CREDIT' || unit !== 'INVOCATIONS') return null;
214
+
215
+ const currentUsage = maxNumberFrom(
216
+ breakdown.currentUsageWithPrecision,
217
+ breakdown.currentUsage,
218
+ breakdown.freeTrialInfo?.currentUsageWithPrecision,
219
+ breakdown.freeTrialInfo?.currentUsage,
220
+ breakdown.freeTrialUsage?.currentUsage,
221
+ );
222
+ if (currentUsage === null) return null;
223
+
224
+ return {
225
+ timestamp,
226
+ currentUsage,
227
+ resetDate: String(breakdown.nextDateReset || breakdown.resetDate || ''),
228
+ usageLimit: numberFrom(
229
+ breakdown.usageLimitWithPrecision,
230
+ breakdown.usageLimit,
231
+ breakdown.freeTrialInfo?.usageLimitWithPrecision,
232
+ breakdown.freeTrialInfo?.usageLimit,
233
+ breakdown.freeTrialUsage?.usageLimit,
234
+ ),
235
+ };
236
+ }
237
+
238
+ function findQClientLogs(logsRoot) {
239
+ const files = [];
240
+ const stack = [logsRoot];
241
+ while (stack.length) {
242
+ const dir = stack.pop();
243
+ let entries;
244
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; }
245
+ for (const entry of entries) {
246
+ const p = join(dir, entry.name);
247
+ if (entry.isDirectory()) {
248
+ stack.push(p);
249
+ } else if (entry.isFile() && /^q-client\.log(?:\.\d+)?$/.test(entry.name)) {
250
+ files.push(p);
251
+ }
252
+ }
253
+ }
254
+ return files.sort();
255
+ }
256
+
257
+ async function readLogSnapshots(logPath) {
258
+ const snapshots = [];
259
+ const rl = createInterface({
260
+ input: createReadStream(logPath, { encoding: 'utf-8' }),
261
+ crlfDelay: Infinity,
262
+ });
263
+ for await (const line of rl) {
264
+ const parsed = parseLogLine(line);
265
+ if (!parsed) continue;
266
+ for (const breakdown of usageBreakdownsFromCommand(parsed.obj)) {
267
+ const snapshot = snapshotFromBreakdown(parsed.timestamp, breakdown);
268
+ if (snapshot) snapshots.push(snapshot);
269
+ }
270
+ }
271
+ return snapshots;
272
+ }
273
+
274
+ async function readUsageSnapshots(userPath) {
275
+ const appPath = dirname(userPath);
276
+ const logsRoot = join(appPath, 'logs');
277
+ const files = findQClientLogs(logsRoot);
278
+ const snapshots = [];
279
+ for (const file of files) {
280
+ try {
281
+ snapshots.push(...await readLogSnapshots(file));
282
+ } catch {
283
+ // skip unreadable / concurrently rotated logs
284
+ }
285
+ }
286
+ return snapshots;
287
+ }
288
+
289
+ function dedupeSnapshots(snapshots) {
290
+ const map = new Map();
291
+ for (const s of snapshots) {
292
+ const key = `${s.timestamp.toISOString()}|${s.resetDate}|${s.currentUsage}`;
293
+ map.set(key, s);
294
+ }
295
+ return Array.from(map.values()).sort((a, b) => a.timestamp - b.timestamp);
296
+ }
297
+
298
+ export function snapshotsToCreditEntries(snapshots) {
299
+ const ordered = dedupeSnapshots(snapshots);
300
+ const entries = [];
301
+ let prev = null;
302
+
303
+ for (const snapshot of ordered) {
304
+ if (!prev || snapshot.resetDate !== prev.resetDate || snapshot.currentUsage < prev.currentUsage) {
305
+ prev = snapshot;
306
+ continue;
307
+ }
308
+
309
+ const delta = Number((snapshot.currentUsage - prev.currentUsage).toFixed(4));
310
+ if (delta > 0) {
311
+ entries.push({
312
+ source: 'kiro',
313
+ model: CREDIT_MODEL,
314
+ project: 'unknown',
315
+ timestamp: snapshot.timestamp,
316
+ inputTokens: 0,
317
+ outputTokens: delta,
318
+ cachedInputTokens: 0,
319
+ reasoningOutputTokens: 0,
320
+ });
321
+ }
322
+ prev = snapshot;
323
+ }
324
+
325
+ return entries;
326
+ }
327
+
328
+ function parseLegacyTokens(base) {
329
+ const dbPath = join(base, 'dev_data', 'devdata.sqlite');
330
+ const jsonlPath = join(base, 'dev_data', 'tokens_generated.jsonl');
331
+ let rows;
332
+ if (existsSync(dbPath)) {
333
+ rows = readLegacyDb(dbPath);
334
+ } else if (existsSync(jsonlPath)) {
335
+ rows = readLegacyJsonl(jsonlPath);
336
+ } else {
337
+ rows = [];
338
+ }
339
+ return rowsToLegacyEntries(rows);
340
+ }
341
+
342
+ export async function parse() {
343
+ const userPath = getKiroUserPath();
344
+ if (!userPath) return { buckets: [], sessions: [] };
345
+
346
+ const snapshots = await readUsageSnapshots(userPath);
347
+ const entries = snapshotsToCreditEntries(snapshots);
348
+ if (entries.length > 0) {
349
+ return { buckets: aggregateToBuckets(entries), sessions: [] };
350
+ }
351
+
352
+ // Keep old token telemetry available for explicit debugging, but do not use
353
+ // it by default: it is not Kiro's billing source and causes false model rows.
354
+ if (process.env.VIBE_USAGE_KIRO_LEGACY_TOKENS === '1') {
355
+ const base = getKiroBasePath();
356
+ if (!base) return { buckets: [], sessions: [] };
357
+ const legacyEntries = parseLegacyTokens(base);
358
+ return { buckets: aggregateToBuckets(legacyEntries), sessions: [] };
359
+ }
229
360
 
230
- return { buckets: aggregateToBuckets(entries), sessions: [] };
361
+ // state.vscdb contains only the latest cumulative credit snapshot. The parser
362
+ // stays stateless, so a single cumulative point cannot be uploaded as a bucket
363
+ // without double-counting on later syncs.
364
+ return { buckets: [], sessions: [] };
231
365
  }