@geoqiao/pi-usage 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +222 -0
  3. package/bin/pi-usage.js +69 -0
  4. package/data/models.dev-LICENSE +21 -0
  5. package/data/prices.json +2678 -0
  6. package/extensions/usage-report.js +36 -0
  7. package/package.json +51 -0
  8. package/src/analytics.js +189 -0
  9. package/src/collect.js +34 -0
  10. package/src/network.js +25 -0
  11. package/src/report.js +43 -0
  12. package/vendor/vibe-usage/NOTICE.md +58 -0
  13. package/vendor/vibe-usage/src/cindy-roots.js +85 -0
  14. package/vendor/vibe-usage/src/claude-roots.js +165 -0
  15. package/vendor/vibe-usage/src/cline-roots.js +40 -0
  16. package/vendor/vibe-usage/src/codex-roots.js +46 -0
  17. package/vendor/vibe-usage/src/craft-roots.js +15 -0
  18. package/vendor/vibe-usage/src/extra-roots.js +312 -0
  19. package/vendor/vibe-usage/src/parsers/aggregate.js +196 -0
  20. package/vendor/vibe-usage/src/parsers/alma.js +94 -0
  21. package/vendor/vibe-usage/src/parsers/amp.js +156 -0
  22. package/vendor/vibe-usage/src/parsers/antigravity-db.js +359 -0
  23. package/vendor/vibe-usage/src/parsers/antigravity.js +530 -0
  24. package/vendor/vibe-usage/src/parsers/cindy-ledger.js +157 -0
  25. package/vendor/vibe-usage/src/parsers/claude-code.js +372 -0
  26. package/vendor/vibe-usage/src/parsers/cline.js +92 -0
  27. package/vendor/vibe-usage/src/parsers/codex-cache.js +138 -0
  28. package/vendor/vibe-usage/src/parsers/codex.js +1198 -0
  29. package/vendor/vibe-usage/src/parsers/contract.js +55 -0
  30. package/vendor/vibe-usage/src/parsers/copilot-cli.js +128 -0
  31. package/vendor/vibe-usage/src/parsers/craft-agent.js +21 -0
  32. package/vendor/vibe-usage/src/parsers/cursor.js +262 -0
  33. package/vendor/vibe-usage/src/parsers/dimagent.js +127 -0
  34. package/vendor/vibe-usage/src/parsers/droid.js +113 -0
  35. package/vendor/vibe-usage/src/parsers/dsh.js +563 -0
  36. package/vendor/vibe-usage/src/parsers/fs-utils.js +36 -0
  37. package/vendor/vibe-usage/src/parsers/gemini-cli.js +190 -0
  38. package/vendor/vibe-usage/src/parsers/grok.js +395 -0
  39. package/vendor/vibe-usage/src/parsers/hermes.js +123 -0
  40. package/vendor/vibe-usage/src/parsers/index.js +61 -0
  41. package/vendor/vibe-usage/src/parsers/kimi-code.js +467 -0
  42. package/vendor/vibe-usage/src/parsers/kiro.js +788 -0
  43. package/vendor/vibe-usage/src/parsers/mcode.js +182 -0
  44. package/vendor/vibe-usage/src/parsers/mimocode.js +88 -0
  45. package/vendor/vibe-usage/src/parsers/omp.js +10 -0
  46. package/vendor/vibe-usage/src/parsers/openclaw.js +142 -0
  47. package/vendor/vibe-usage/src/parsers/opencode.js +151 -0
  48. package/vendor/vibe-usage/src/parsers/pi-coding-agent.js +27 -0
  49. package/vendor/vibe-usage/src/parsers/pi-session-jsonl.js +166 -0
  50. package/vendor/vibe-usage/src/parsers/qwen-code.js +122 -0
  51. package/vendor/vibe-usage/src/parsers/roo-code.js +123 -0
  52. package/vendor/vibe-usage/src/parsers/sqlite.js +148 -0
  53. package/vendor/vibe-usage/src/parsers/trae-cli.js +171 -0
  54. package/vendor/vibe-usage/src/parsers/workbuddy.js +322 -0
  55. package/vendor/vibe-usage/src/parsers/zcode.js +115 -0
  56. package/vendor/vibe-usage/src/pi-roots.js +125 -0
  57. package/vendor/vibe-usage/src/tools.js +422 -0
  58. package/vendor/vibe-usage/src/workbuddy-roots.js +22 -0
  59. package/vendor/vibe-usage/upstream-files.json +48 -0
  60. package/web/report.css +10 -0
  61. package/web/report.html +81 -0
  62. package/web/report.js +310 -0
@@ -0,0 +1,788 @@
1
+ import {
2
+ createReadStream,
3
+ existsSync,
4
+ readFileSync,
5
+ readdirSync,
6
+ statSync,
7
+ } from 'node:fs';
8
+ import { createInterface } from 'node:readline';
9
+ import { dirname, join, resolve } from 'node:path';
10
+ import { homedir } from 'node:os';
11
+ import { aggregateToBuckets } from './aggregate.js';
12
+ import { queryDbJsonSnapshotOnLock, sqliteUnavailableError, isSqliteUnavailableError } from './sqlite.js';
13
+
14
+ const KIRO_AGENT_RELATIVE = join('User', 'globalStorage', 'kiro.kiroagent');
15
+ const KIRO_USER_RELATIVE = 'User';
16
+ const CREDIT_MODEL = 'kiro-credits';
17
+ const ESTIMATE_MODEL = 'kiro-token-estimate';
18
+ const CHARS_PER_TOKEN = 4;
19
+ const IMAGE_TOKENS = 1600;
20
+
21
+ // The official Kiro CLI persists each conversation as a `{version, kind, data}`
22
+ // event stream under ~/.kiro/sessions/cli/<uuid>.jsonl (companion <uuid>.json
23
+ // holds cwd + model). It carries NO token counts, so tokens are estimated from
24
+ // text length (chars/CHARS_PER_TOKEN), the same heuristic used elsewhere here.
25
+ //
26
+ // String values that are NOT linguistic tokens are excluded from the count.
27
+ // The big one is `signature`: extended-thinking blocks carry a ~500-char crypto
28
+ // signature; counting it as text inflates "output" by well over 100%.
29
+ const NON_TEXT_KEYS = new Set([
30
+ 'signature', 'redactedContent', 'toolUseId', 'modelId', 'message_id', 'format', 'id',
31
+ ]);
32
+
33
+ // System prompt + tool JSON schemas are injected on every request but never
34
+ // written to the session log, so the stream alone counts none of them. They are
35
+ // a near-constant per-call overhead. Keep the default at 0 so the parser reports
36
+ // only observed log text unless the user explicitly opts into an estimate.
37
+ const DEFAULT_KIRO_CLI_SYSTEM_OVERHEAD_TOKENS = 0;
38
+
39
+ function getDefaultAppPath() {
40
+ if (process.platform === 'darwin') {
41
+ return join(homedir(), 'Library', 'Application Support', 'Kiro');
42
+ }
43
+ if (process.platform === 'win32') {
44
+ const appData = process.env.APPDATA?.trim() || join(homedir(), 'AppData', 'Roaming');
45
+ return join(appData, 'Kiro');
46
+ }
47
+ const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), '.config');
48
+ return join(xdgConfigHome, 'Kiro');
49
+ }
50
+
51
+ function getDefaultUserPath() {
52
+ return join(getDefaultAppPath(), KIRO_USER_RELATIVE);
53
+ }
54
+
55
+ export function getKiroBasePath() {
56
+ const explicit = process.env.KIRO_BASE_PATH?.trim();
57
+ if (explicit) {
58
+ const r = resolve(explicit);
59
+ return existsSync(r) ? r : null;
60
+ }
61
+ const def = join(getDefaultAppPath(), KIRO_AGENT_RELATIVE);
62
+ return existsSync(def) ? def : null;
63
+ }
64
+
65
+ export function getKiroUserPath() {
66
+ const explicitUser = process.env.KIRO_USER_PATH?.trim();
67
+ if (explicitUser) {
68
+ const r = resolve(explicitUser);
69
+ return existsSync(r) ? r : null;
70
+ }
71
+
72
+ const explicitBase = process.env.KIRO_BASE_PATH?.trim();
73
+ if (explicitBase) {
74
+ const base = resolve(explicitBase);
75
+ const userPath = resolve(base, '..', '..');
76
+ return existsSync(userPath) ? userPath : null;
77
+ }
78
+
79
+ const def = getDefaultUserPath();
80
+ return existsSync(def) ? def : null;
81
+ }
82
+
83
+ export function getKiroCliDbPath() {
84
+ const explicit = process.env.KIRO_CLI_DB_PATH?.trim();
85
+ if (explicit) {
86
+ const r = resolve(explicit);
87
+ return existsSync(r) ? r : null;
88
+ }
89
+ let def;
90
+ if (process.platform === 'darwin') {
91
+ def = join(homedir(), 'Library', 'Application Support', 'kiro-cli', 'data.sqlite3');
92
+ } else if (process.platform === 'win32') {
93
+ const appData = process.env.APPDATA?.trim() || join(homedir(), 'AppData', 'Roaming');
94
+ def = join(appData, 'kiro-cli', 'data.sqlite3');
95
+ } else {
96
+ def = join(homedir(), '.local', 'share', 'kiro-cli', 'data.sqlite3');
97
+ }
98
+ return existsSync(def) ? def : null;
99
+ }
100
+
101
+ export function getKiroSessionsDir() {
102
+ const explicit = process.env.KIRO_SESSIONS_DIR?.trim();
103
+ if (explicit) {
104
+ const r = resolve(explicit);
105
+ return existsSync(r) ? r : null;
106
+ }
107
+ const def = join(homedir(), '.kiro_sessions');
108
+ return existsSync(def) ? def : null;
109
+ }
110
+
111
+ // Native Kiro CLI session event streams: ~/.kiro/sessions/cli/*.jsonl
112
+ export function getKiroCliSessionsDir() {
113
+ const explicit = process.env.KIRO_CLI_SESSIONS_DIR?.trim();
114
+ if (explicit) {
115
+ const r = resolve(explicit);
116
+ return existsSync(r) ? r : null;
117
+ }
118
+ const def = join(homedir(), '.kiro', 'sessions', 'cli');
119
+ return existsSync(def) ? def : null;
120
+ }
121
+
122
+ function queryOptionalDb(dbPath, sql) {
123
+ try {
124
+ return queryDbJsonSnapshotOnLock(dbPath, sql, { tempPrefix: 'vibe-usage-kiro-' });
125
+ } catch (err) {
126
+ const msg = err && typeof err.message === 'string' ? err.message : '';
127
+ if (/no such table|no such column/i.test(msg)) return [];
128
+ throw err;
129
+ }
130
+ }
131
+
132
+ const TOKENS_SQL =
133
+ 'SELECT id, model, tokens_prompt, tokens_generated, timestamp ' +
134
+ 'FROM tokens_generated ' +
135
+ 'WHERE tokens_prompt > 0 OR tokens_generated > 0 ' +
136
+ 'ORDER BY id ASC';
137
+
138
+ function readLegacyDb(dbPath) {
139
+ return queryDbJsonSnapshotOnLock(dbPath, TOKENS_SQL, { tempPrefix: 'vibe-usage-kiro-' });
140
+ }
141
+
142
+ // Legacy Kiro dev telemetry fallback. This is opt-in because recent Kiro builds
143
+ // bill by server-side credits, while this table is often empty, estimated, or
144
+ // populated with placeholder model names such as "agent".
145
+ function readLegacyJsonl(jsonlPath) {
146
+ let raw;
147
+ try { raw = readFileSync(jsonlPath, 'utf-8'); } catch { return []; }
148
+ const lines = raw.split('\n').map(l => l.trim()).filter(Boolean);
149
+ if (lines.length === 0) return [];
150
+ let mtime;
151
+ try { mtime = statSync(jsonlPath).mtime; } catch { mtime = new Date(); }
152
+ const ts = mtime.toISOString().replace('T', ' ').replace('Z', '').slice(0, 19);
153
+ const rows = [];
154
+ for (let i = 0; i < lines.length; i++) {
155
+ try {
156
+ const obj = JSON.parse(lines[i]);
157
+ rows.push({
158
+ id: i + 1,
159
+ model: obj.model || ESTIMATE_MODEL,
160
+ tokens_prompt: obj.promptTokens || 0,
161
+ tokens_generated: obj.generatedTokens || 0,
162
+ timestamp: ts,
163
+ });
164
+ } catch {
165
+ // skip malformed lines
166
+ }
167
+ }
168
+ return rows;
169
+ }
170
+
171
+ function parseDbTimestamp(value) {
172
+ if (!value) return null;
173
+ const s = String(value).trim();
174
+ const hasZone = /(?:Z|[+-]\d\d:?\d\d)$/.test(s);
175
+ const d = new Date(hasZone ? s.replace(' ', 'T') : `${s.replace(' ', 'T')}Z`);
176
+ return isNaN(d.getTime()) ? null : d;
177
+ }
178
+
179
+ function normalizeLegacyModel(raw) {
180
+ const model = typeof raw === 'string' ? raw.trim() : '';
181
+ if (!model || model.toLowerCase() === 'agent') return ESTIMATE_MODEL;
182
+ if (model === model.toLowerCase() && model.includes('-')) return model;
183
+ return model
184
+ .replace(/_\d{8}_V\d+_\d+$/i, '')
185
+ .replace(/_V\d+$/i, '')
186
+ .toLowerCase()
187
+ .replace(/_/g, '-') || ESTIMATE_MODEL;
188
+ }
189
+
190
+ function rowsToLegacyEntries(rows) {
191
+ const entries = [];
192
+ for (const row of rows) {
193
+ const inputTokens = Math.max(0, Number(row.tokens_prompt) || 0);
194
+ const outputTokens = Math.max(0, Number(row.tokens_generated) || 0);
195
+ if (inputTokens === 0 && outputTokens === 0) continue;
196
+ const timestamp = parseDbTimestamp(row.timestamp);
197
+ if (!timestamp) continue;
198
+ entries.push({
199
+ source: 'kiro',
200
+ model: normalizeLegacyModel(row.model),
201
+ project: 'unknown',
202
+ timestamp,
203
+ inputTokens,
204
+ outputTokens,
205
+ cachedInputTokens: 0,
206
+ reasoningOutputTokens: 0,
207
+ });
208
+ }
209
+ return entries;
210
+ }
211
+
212
+ function textLength(field) {
213
+ if (!field) return 0;
214
+ if (typeof field !== 'object' || Array.isArray(field)) return String(field).length;
215
+ let len = 0;
216
+ for (const [key, value] of Object.entries(field)) {
217
+ if (key === 'images') continue;
218
+ len += String(value ?? '').length;
219
+ }
220
+ return len;
221
+ }
222
+
223
+ function imageTokens(field) {
224
+ if (!field || typeof field !== 'object' || Array.isArray(field)) return 0;
225
+ const images = field.images;
226
+ if (!Array.isArray(images)) return 0;
227
+ let total = 0;
228
+ for (const image of images) {
229
+ const source = image && typeof image === 'object' ? image.source || {} : {};
230
+ const rawData = source.Bytes;
231
+ try {
232
+ let buf;
233
+ if (Array.isArray(rawData)) {
234
+ buf = Buffer.from(rawData);
235
+ } else if (typeof rawData === 'string') {
236
+ buf = Buffer.from(rawData, 'base64');
237
+ }
238
+ if (buf?.length >= 24 && buf.subarray(0, 4).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47]))) {
239
+ total += Math.floor((buf.readUInt32BE(16) * buf.readUInt32BE(20)) / 750);
240
+ continue;
241
+ }
242
+ } catch {
243
+ // fall back below
244
+ }
245
+ total += 1600;
246
+ }
247
+ return total;
248
+ }
249
+
250
+ function estimateTextTokens(field) {
251
+ return Math.floor(textLength(field) / CHARS_PER_TOKEN);
252
+ }
253
+
254
+ function normalizeCliModel(raw) {
255
+ const model = typeof raw === 'string' ? raw.trim() : '';
256
+ return model || ESTIMATE_MODEL;
257
+ }
258
+
259
+ function parseMsTimestamp(value) {
260
+ const n = Number(value);
261
+ if (!Number.isFinite(n) || n <= 0) return null;
262
+ const d = new Date(n);
263
+ return isNaN(d.getTime()) ? null : d;
264
+ }
265
+
266
+ function extractConversationTimes(data) {
267
+ const turns = Array.isArray(data?.history) ? data.history : [];
268
+ const first = turns[0]?.request_metadata?.request_start_timestamp_ms;
269
+ const last = turns[turns.length - 1]?.request_metadata?.request_start_timestamp_ms;
270
+ return {
271
+ createdAt: Number(first) || 0,
272
+ updatedAt: Number(last) || Number(first) || 0,
273
+ };
274
+ }
275
+
276
+ function conversationToEntries(conversation) {
277
+ const data = conversation?.value;
278
+ const turns = Array.isArray(data?.history) ? data.history : [];
279
+ const summary = data?.latest_summary || [];
280
+ const summaryTokens = summary ? Math.floor(String(summary).length / CHARS_PER_TOKEN) : 0;
281
+ let cumulative = summaryTokens;
282
+ let prevAssistantTokens = 0;
283
+ const entries = [];
284
+
285
+ for (let i = 0; i < turns.length; i++) {
286
+ const turn = turns[i] || {};
287
+ const meta = turn.request_metadata || {};
288
+ const timestamp = parseMsTimestamp(meta.request_start_timestamp_ms);
289
+ if (!timestamp) continue;
290
+
291
+ const userTokens = estimateTextTokens(turn.user) + imageTokens(turn.user);
292
+ const assistantTokens = estimateTextTokens(turn.assistant);
293
+ const outputTokens = Array.isArray(meta.time_between_chunks) ? meta.time_between_chunks.length : 0;
294
+ const cachedInputTokens = i > 0 ? cumulative : 0;
295
+ const inputTokens = userTokens + (i > 0 ? prevAssistantTokens : 0);
296
+
297
+ cumulative += userTokens + assistantTokens;
298
+ prevAssistantTokens = assistantTokens;
299
+
300
+ if (inputTokens === 0 && outputTokens === 0 && cachedInputTokens === 0) continue;
301
+ entries.push({
302
+ source: 'kiro',
303
+ model: normalizeCliModel(meta.model_id),
304
+ project: conversation.cwd || 'unknown',
305
+ timestamp,
306
+ inputTokens,
307
+ outputTokens,
308
+ cachedInputTokens,
309
+ reasoningOutputTokens: 0,
310
+ });
311
+ }
312
+
313
+ return entries;
314
+ }
315
+
316
+ export function conversationsToEstimateEntries(conversations) {
317
+ const entries = [];
318
+ const byId = new Map();
319
+ for (const conversation of conversations) {
320
+ const id = conversation?.conversation_id;
321
+ if (!id) continue;
322
+ const updatedAt = Number(conversation.updated_at) || 0;
323
+ const existing = byId.get(id);
324
+ if (!existing || updatedAt >= (Number(existing.updated_at) || 0)) {
325
+ byId.set(id, conversation);
326
+ }
327
+ }
328
+ for (const conversation of byId.values()) {
329
+ for (const entry of conversationToEntries(conversation)) entries.push(entry);
330
+ }
331
+ return entries;
332
+ }
333
+
334
+ function readArchivedConversations(sessionsDir) {
335
+ if (!sessionsDir) return [];
336
+ let files;
337
+ try {
338
+ files = readdirSync(sessionsDir).filter(f => f.endsWith('.json'));
339
+ } catch {
340
+ return [];
341
+ }
342
+ const conversations = [];
343
+ for (const file of files) {
344
+ try {
345
+ const obj = JSON.parse(readFileSync(join(sessionsDir, file), 'utf-8'));
346
+ if (obj?.conversation_id && obj?.value) conversations.push(obj);
347
+ } catch {
348
+ // skip malformed archives
349
+ }
350
+ }
351
+ return conversations;
352
+ }
353
+
354
+ function readCliDbConversations(dbPath) {
355
+ if (!dbPath) return [];
356
+ const conversations = [];
357
+ for (const row of queryOptionalDb(
358
+ dbPath,
359
+ 'SELECT conversation_id, key as cwd, created_at, updated_at, value FROM conversations_v2',
360
+ )) {
361
+ try {
362
+ conversations.push({
363
+ conversation_id: row.conversation_id,
364
+ cwd: row.cwd || 'unknown',
365
+ created_at: Number(row.created_at) || 0,
366
+ updated_at: Number(row.updated_at) || 0,
367
+ value: JSON.parse(row.value),
368
+ });
369
+ } catch {
370
+ // skip malformed conversations
371
+ }
372
+ }
373
+
374
+ for (const row of queryOptionalDb(dbPath, 'SELECT key as cwd, value FROM conversations')) {
375
+ try {
376
+ const data = JSON.parse(row.value);
377
+ const conversationId = data?.conversation_id;
378
+ if (!conversationId) continue;
379
+ const { createdAt, updatedAt } = extractConversationTimes(data);
380
+ conversations.push({
381
+ conversation_id: conversationId,
382
+ cwd: row.cwd || 'unknown',
383
+ created_at: createdAt,
384
+ updated_at: updatedAt,
385
+ value: data,
386
+ });
387
+ } catch {
388
+ // skip malformed conversations
389
+ }
390
+ }
391
+ return conversations;
392
+ }
393
+
394
+ function readCliEstimateEntries() {
395
+ const conversations = [
396
+ ...readArchivedConversations(getKiroSessionsDir()),
397
+ ...readCliDbConversations(getKiroCliDbPath()),
398
+ ];
399
+ return conversationsToEstimateEntries(conversations);
400
+ }
401
+
402
+ // ---------------------------------------------------------------------------
403
+ // Native Kiro CLI event-stream sessions: ~/.kiro/sessions/cli/<uuid>.jsonl
404
+ // Each line is a {version, kind, data} event. Relevant kinds:
405
+ // Prompt user turn; data.content[] (text/image); data.meta.timestamp
406
+ // is the ONLY timestamp (epoch SECONDS).
407
+ // AssistantMessage data.content[] items: text (reply), thinking (reasoning +
408
+ // modelId + crypto signature), toolUse (name + input).
409
+ // ToolResults tool output fed back as context for the next turn.
410
+ // Compaction context was summarized; resets the running context size.
411
+ // ---------------------------------------------------------------------------
412
+
413
+ // Sum of string-leaf character lengths, skipping non-linguistic keys.
414
+ function textLeafChars(value) {
415
+ if (typeof value === 'string') return value.length;
416
+ if (Array.isArray(value)) {
417
+ let n = 0;
418
+ for (const v of value) n += textLeafChars(v);
419
+ return n;
420
+ }
421
+ if (value && typeof value === 'object') {
422
+ let n = 0;
423
+ for (const [k, v] of Object.entries(value)) {
424
+ if (NON_TEXT_KEYS.has(k)) continue;
425
+ n += textLeafChars(v);
426
+ }
427
+ return n;
428
+ }
429
+ return 0;
430
+ }
431
+
432
+ function estTokens(value) {
433
+ return Math.floor(textLeafChars(value) / CHARS_PER_TOKEN);
434
+ }
435
+
436
+ function getKiroCliSystemOverheadTokens() {
437
+ const raw = process.env.KIRO_CLI_SYSTEM_OVERHEAD_TOKENS;
438
+ if (raw === undefined || raw.trim() === '') return DEFAULT_KIRO_CLI_SYSTEM_OVERHEAD_TOKENS;
439
+ const n = Number(raw);
440
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
441
+ }
442
+
443
+ function loadCliSessionMeta(sessionsDir, sessionId) {
444
+ try {
445
+ const d = JSON.parse(readFileSync(join(sessionsDir, `${sessionId}.json`), 'utf-8'));
446
+ return {
447
+ cwd: (d && typeof d.cwd === 'string' && d.cwd) || 'unknown',
448
+ model: d?.session_state?.rts_model_state?.model_info?.model_id || null,
449
+ };
450
+ } catch {
451
+ return { cwd: 'unknown', model: null };
452
+ }
453
+ }
454
+
455
+ async function readCliSessionEntries(sessionsDir, fileName) {
456
+ const sessionId = fileName.replace(/\.jsonl$/, '');
457
+ const { cwd, model: metaModel } = loadCliSessionMeta(sessionsDir, sessionId);
458
+ const filePath = join(sessionsDir, fileName);
459
+
460
+ let mtime;
461
+ try { mtime = statSync(filePath).mtime; } catch { mtime = new Date(); }
462
+
463
+ const events = [];
464
+ const rl = createInterface({
465
+ input: createReadStream(filePath, { encoding: 'utf-8' }),
466
+ crlfDelay: Infinity,
467
+ });
468
+ for await (const raw of rl) {
469
+ const line = raw.trim();
470
+ if (!line) continue;
471
+ try { events.push(JSON.parse(line)); } catch { /* skip malformed line */ }
472
+ }
473
+
474
+ return cliEventsToEntries(events, { cwd, model: metaModel, fallbackTimestamp: mtime });
475
+ }
476
+
477
+ // Pure state machine over a Kiro CLI session's ordered events. Exposed for tests.
478
+ export function cliEventsToEntries(events, { cwd = 'unknown', model = null, fallbackTimestamp = null } = {}) {
479
+ const entries = [];
480
+ let curTs = null; // Date from the enclosing Prompt (epoch seconds)
481
+ let cumulative = 0; // running conversation context, re-sent every turn
482
+ let pendingInput = 0; // fresh input accumulated since the last assistant turn
483
+ let curModel = model;
484
+
485
+ for (const ev of events) {
486
+ const data = ev?.data;
487
+ if (!data || typeof data !== 'object') continue;
488
+ const content = Array.isArray(data.content) ? data.content : [];
489
+
490
+ if (ev.kind === 'Prompt') {
491
+ const ts = data.meta?.timestamp;
492
+ if (typeof ts === 'number' && ts > 0) curTs = new Date(ts * 1000);
493
+ for (const item of content) {
494
+ pendingInput += item?.kind === 'image' ? IMAGE_TOKENS : estTokens(item?.data);
495
+ }
496
+ } else if (ev.kind === 'ToolResults') {
497
+ for (const item of content) pendingInput += estTokens(item?.data);
498
+ } else if (ev.kind === 'AssistantMessage') {
499
+ let output = 0;
500
+ let reasoning = 0;
501
+ let signatureTokens = 0;
502
+ for (const item of content) {
503
+ const cd = item?.data;
504
+ if (cd && typeof cd === 'object' && typeof cd.modelId === 'string' && cd.modelId) {
505
+ curModel = cd.modelId;
506
+ }
507
+ if (item?.kind === 'thinking' && cd && typeof cd === 'object') {
508
+ reasoning += Math.floor(String(cd.text ?? '').length / CHARS_PER_TOKEN);
509
+ // Signature persists in history and is re-sent as context on every
510
+ // later turn, but it is NOT model output — keep it out of output.
511
+ signatureTokens += Math.floor(String(cd.signature ?? '').length / CHARS_PER_TOKEN);
512
+ } else {
513
+ output += estTokens(cd);
514
+ }
515
+ }
516
+
517
+ const inputTokens = pendingInput;
518
+ // Each request re-sends the whole prior conversation (cache read) plus the
519
+ // optional per-call system-prompt/tool-schema overhead.
520
+ const cachedInputTokens = cumulative + getKiroCliSystemOverheadTokens();
521
+
522
+ if (inputTokens > 0 || output > 0 || reasoning > 0) {
523
+ entries.push({
524
+ source: 'kiro',
525
+ model: curModel || ESTIMATE_MODEL,
526
+ project: cwd,
527
+ timestamp: curTs || fallbackTimestamp || new Date(),
528
+ inputTokens,
529
+ outputTokens: output,
530
+ cachedInputTokens,
531
+ reasoningOutputTokens: reasoning,
532
+ });
533
+ }
534
+
535
+ // Grow the running context: fresh input + everything generated this turn,
536
+ // including the thinking signature (it persists in history and is re-sent).
537
+ cumulative += inputTokens + output + reasoning + signatureTokens;
538
+ pendingInput = 0;
539
+ } else if (ev.kind === 'Compaction') {
540
+ cumulative = estTokens(data.summary);
541
+ pendingInput = 0;
542
+ }
543
+ }
544
+
545
+ return entries;
546
+ }
547
+
548
+ async function readCliSessionStreamEntries() {
549
+ const dir = getKiroCliSessionsDir();
550
+ if (!dir) return [];
551
+ let files;
552
+ try {
553
+ files = readdirSync(dir).filter(f => f.endsWith('.jsonl'));
554
+ } catch {
555
+ return [];
556
+ }
557
+ const entries = [];
558
+ for (const file of files) {
559
+ try {
560
+ for (const entry of await readCliSessionEntries(dir, file)) entries.push(entry);
561
+ } catch {
562
+ // skip unreadable / concurrently rotated session
563
+ }
564
+ }
565
+ return entries;
566
+ }
567
+
568
+ function parseLogTimestamp(raw) {
569
+ const d = new Date(String(raw).replace(' ', 'T'));
570
+ return isNaN(d.getTime()) ? null : d;
571
+ }
572
+
573
+ function parseLogLine(line) {
574
+ const match = /^(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\.\d{3}) \[[^\]]+\] (\{.*\})$/.exec(line);
575
+ if (!match) return null;
576
+ const timestamp = parseLogTimestamp(match[1]);
577
+ if (!timestamp) return null;
578
+ try {
579
+ return { timestamp, obj: JSON.parse(match[2]) };
580
+ } catch {
581
+ return null;
582
+ }
583
+ }
584
+
585
+ function usageBreakdownsFromCommand(obj) {
586
+ if (obj?.commandName !== 'GetUsageLimitsCommand') return [];
587
+ const out = obj.output || {};
588
+ if (Array.isArray(out.usageBreakdownList)) return out.usageBreakdownList;
589
+ if (Array.isArray(out.usageBreakdowns)) return out.usageBreakdowns;
590
+ return [];
591
+ }
592
+
593
+ function numberFrom(...values) {
594
+ for (const value of values) {
595
+ const n = Number(value);
596
+ if (Number.isFinite(n)) return n;
597
+ }
598
+ return null;
599
+ }
600
+
601
+ function maxNumberFrom(...values) {
602
+ const nums = values
603
+ .map(value => Number(value))
604
+ .filter(Number.isFinite);
605
+ if (nums.length === 0) return null;
606
+ return Math.max(...nums);
607
+ }
608
+
609
+ function snapshotFromBreakdown(timestamp, breakdown) {
610
+ const type = String(breakdown.resourceType || breakdown.type || '').toUpperCase();
611
+ const unit = String(breakdown.unit || '').toUpperCase();
612
+ if (type !== 'CREDIT' || unit !== 'INVOCATIONS') return null;
613
+
614
+ const currentUsage = maxNumberFrom(
615
+ breakdown.currentUsageWithPrecision,
616
+ breakdown.currentUsage,
617
+ breakdown.freeTrialInfo?.currentUsageWithPrecision,
618
+ breakdown.freeTrialInfo?.currentUsage,
619
+ breakdown.freeTrialUsage?.currentUsage,
620
+ );
621
+ if (currentUsage === null) return null;
622
+
623
+ return {
624
+ timestamp,
625
+ currentUsage,
626
+ resetDate: String(breakdown.nextDateReset || breakdown.resetDate || ''),
627
+ usageLimit: numberFrom(
628
+ breakdown.usageLimitWithPrecision,
629
+ breakdown.usageLimit,
630
+ breakdown.freeTrialInfo?.usageLimitWithPrecision,
631
+ breakdown.freeTrialInfo?.usageLimit,
632
+ breakdown.freeTrialUsage?.usageLimit,
633
+ ),
634
+ };
635
+ }
636
+
637
+ function findQClientLogs(logsRoot) {
638
+ const files = [];
639
+ const stack = [logsRoot];
640
+ while (stack.length) {
641
+ const dir = stack.pop();
642
+ let entries;
643
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; }
644
+ for (const entry of entries) {
645
+ const p = join(dir, entry.name);
646
+ if (entry.isDirectory()) {
647
+ stack.push(p);
648
+ } else if (entry.isFile() && /^q-client\.log(?:\.\d+)?$/.test(entry.name)) {
649
+ files.push(p);
650
+ }
651
+ }
652
+ }
653
+ return files.sort();
654
+ }
655
+
656
+ async function readLogSnapshots(logPath) {
657
+ const snapshots = [];
658
+ const rl = createInterface({
659
+ input: createReadStream(logPath, { encoding: 'utf-8' }),
660
+ crlfDelay: Infinity,
661
+ });
662
+ for await (const line of rl) {
663
+ const parsed = parseLogLine(line);
664
+ if (!parsed) continue;
665
+ for (const breakdown of usageBreakdownsFromCommand(parsed.obj)) {
666
+ const snapshot = snapshotFromBreakdown(parsed.timestamp, breakdown);
667
+ if (snapshot) snapshots.push(snapshot);
668
+ }
669
+ }
670
+ return snapshots;
671
+ }
672
+
673
+ async function readUsageSnapshots(userPath) {
674
+ const appPath = dirname(userPath);
675
+ const logsRoot = join(appPath, 'logs');
676
+ const files = findQClientLogs(logsRoot);
677
+ const snapshots = [];
678
+ for (const file of files) {
679
+ try {
680
+ for (const snapshot of await readLogSnapshots(file)) snapshots.push(snapshot);
681
+ } catch {
682
+ // skip unreadable / concurrently rotated logs
683
+ }
684
+ }
685
+ return snapshots;
686
+ }
687
+
688
+ function dedupeSnapshots(snapshots) {
689
+ const map = new Map();
690
+ for (const s of snapshots) {
691
+ const key = `${s.timestamp.toISOString()}|${s.resetDate}|${s.currentUsage}`;
692
+ map.set(key, s);
693
+ }
694
+ return Array.from(map.values()).sort((a, b) => a.timestamp - b.timestamp);
695
+ }
696
+
697
+ export function snapshotsToCreditEntries(snapshots) {
698
+ const ordered = dedupeSnapshots(snapshots);
699
+ const entries = [];
700
+ let prev = null;
701
+
702
+ for (const snapshot of ordered) {
703
+ if (!prev || snapshot.resetDate !== prev.resetDate || snapshot.currentUsage < prev.currentUsage) {
704
+ prev = snapshot;
705
+ continue;
706
+ }
707
+
708
+ // The server's token columns are bigint, so fractional credit deltas
709
+ // cannot be uploaded as-is. Diff floored cumulative values instead of
710
+ // rounding each delta: the diffs telescope, so sub-integer usage is never
711
+ // lost or double-counted — it surfaces on whichever snapshot crosses the
712
+ // next whole-credit boundary.
713
+ const delta = Math.floor(snapshot.currentUsage) - Math.floor(prev.currentUsage);
714
+ if (delta > 0) {
715
+ entries.push({
716
+ source: 'kiro',
717
+ model: CREDIT_MODEL,
718
+ project: 'unknown',
719
+ timestamp: snapshot.timestamp,
720
+ inputTokens: 0,
721
+ outputTokens: delta,
722
+ cachedInputTokens: 0,
723
+ reasoningOutputTokens: 0,
724
+ });
725
+ }
726
+ prev = snapshot;
727
+ }
728
+
729
+ return entries;
730
+ }
731
+
732
+ function parseLegacyTokens(base) {
733
+ const dbPath = join(base, 'dev_data', 'devdata.sqlite');
734
+ const jsonlPath = join(base, 'dev_data', 'tokens_generated.jsonl');
735
+ let rows;
736
+ if (existsSync(dbPath)) {
737
+ rows = readLegacyDb(dbPath);
738
+ } else if (existsSync(jsonlPath)) {
739
+ rows = readLegacyJsonl(jsonlPath);
740
+ } else {
741
+ rows = [];
742
+ }
743
+ return rowsToLegacyEntries(rows);
744
+ }
745
+
746
+ export async function parse() {
747
+ // 1. Official Kiro CLI native event streams (~/.kiro/sessions/cli/*.jsonl).
748
+ // This is where the shipping CLI actually records conversations; the
749
+ // conversations_v2 DB path below is empty on those installs.
750
+ const streamEntries = await readCliSessionStreamEntries();
751
+ if (streamEntries.length > 0) {
752
+ return { buckets: aggregateToBuckets(streamEntries), sessions: [] };
753
+ }
754
+
755
+ // 2. Kiro CLI conversations DB / archived snapshots (other CLI variants).
756
+ try {
757
+ const estimateEntries = readCliEstimateEntries();
758
+ if (estimateEntries.length > 0) {
759
+ return { buckets: aggregateToBuckets(estimateEntries), sessions: [] };
760
+ }
761
+ } catch (err) {
762
+ if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('Kiro CLI');
763
+ throw err;
764
+ }
765
+
766
+ const userPath = getKiroUserPath();
767
+ if (!userPath) return { buckets: [], sessions: [] };
768
+
769
+ const snapshots = await readUsageSnapshots(userPath);
770
+ const entries = snapshotsToCreditEntries(snapshots);
771
+ if (entries.length > 0) {
772
+ return { buckets: aggregateToBuckets(entries), sessions: [] };
773
+ }
774
+
775
+ // Keep old token telemetry available for explicit debugging, but do not use
776
+ // it by default: it is not Kiro's billing source and causes false model rows.
777
+ if (process.env.VIBE_USAGE_KIRO_LEGACY_TOKENS === '1') {
778
+ const base = getKiroBasePath();
779
+ if (!base) return { buckets: [], sessions: [] };
780
+ const legacyEntries = parseLegacyTokens(base);
781
+ return { buckets: aggregateToBuckets(legacyEntries), sessions: [] };
782
+ }
783
+
784
+ // state.vscdb contains only the latest cumulative credit snapshot. The parser
785
+ // stays stateless, so a single cumulative point cannot be uploaded as a bucket
786
+ // without double-counting on later syncs.
787
+ return { buckets: [], sessions: [] };
788
+ }