@vibe-cafe/vibe-usage 0.9.6 → 0.9.8

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 | 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` |
66
+ | Kiro | Kiro CLI native event streams `~/.kiro/sessions/cli/*.jsonl` (estimated tokens from message text: input = prompt + tool results, output = reply + tool calls, reasoning = thinking, cacheRead = re-sent context; thinking-block signatures excluded). Falls back to `~/Library/Application Support/kiro-cli/data.sqlite3` / `~/.local/share/kiro-cli/data.sqlite3` + optional `~/.kiro_sessions/*.json` archives, then IDE `q-client.log` whole-credit deltas as `kiro-credits` (floored cumulative diff — the server stores token counts as bigint); legacy IDE `dev_data/devdata.sqlite` token telemetry is opt-in with `VIBE_USAGE_KIRO_LEGACY_TOKENS=1` |
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.9.6",
3
+ "version": "0.9.8",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -46,24 +46,43 @@ export function roundToHalfHour(date) {
46
46
  return d;
47
47
  }
48
48
 
49
+ // Server column limits (usage_buckets: model varchar(100), project varchar(200)).
50
+ // Anything longer aborts the whole INSERT chunk with 22001, so clamp here.
51
+ const MODEL_MAX_LENGTH = 100;
52
+ const PROJECT_MAX_LENGTH = 200;
53
+
54
+ // Server token columns are bigint — a single fractional/NaN value aborts the
55
+ // whole INSERT chunk with 22P02, taking every other tool's rows in the batch
56
+ // down with it.
57
+ function toTokenCount(value) {
58
+ const n = Number(value);
59
+ if (!Number.isFinite(n) || n <= 0) return 0;
60
+ return Math.round(n);
61
+ }
62
+
49
63
  export function aggregateToBuckets(entries) {
50
64
  const map = new Map();
51
65
 
52
66
  for (const e of entries) {
67
+ const model = String(e.model || 'unknown').slice(0, MODEL_MAX_LENGTH);
68
+ const project = String(e.project || 'unknown').slice(0, PROJECT_MAX_LENGTH);
53
69
  const bucketStart = roundToHalfHour(e.timestamp).toISOString();
54
- const key = `${e.source}|${e.model}|${e.project}|${bucketStart}`;
70
+ const key = `${e.source}|${model}|${project}|${e.hostname || ''}|${bucketStart}`;
55
71
 
56
72
  if (!map.has(key)) {
57
73
  map.set(key, {
58
74
  source: e.source,
59
- model: e.model,
60
- project: e.project,
75
+ model,
76
+ project,
77
+ // Cloud-sourced parsers (cursor) pre-set a fixed hostname sentinel; it
78
+ // must survive aggregation, or sync.js stamps the machine hostname and
79
+ // every machine gets its own duplicate row server-side.
80
+ ...(e.hostname ? { hostname: e.hostname } : {}),
61
81
  bucketStart,
62
82
  inputTokens: 0,
63
83
  outputTokens: 0,
64
84
  cachedInputTokens: 0,
65
85
  reasoningOutputTokens: 0,
66
- totalTokens: 0,
67
86
  });
68
87
  }
69
88
 
@@ -72,10 +91,24 @@ export function aggregateToBuckets(entries) {
72
91
  b.outputTokens += e.outputTokens || 0;
73
92
  b.cachedInputTokens += e.cachedInputTokens || 0;
74
93
  b.reasoningOutputTokens += e.reasoningOutputTokens || 0;
75
- b.totalTokens += (e.inputTokens || 0) + (e.outputTokens || 0) + (e.reasoningOutputTokens || 0);
76
94
  }
77
95
 
78
- return Array.from(map.values());
96
+ // Clamp after summation, not per entry — rounding each entry first would
97
+ // discard sub-integer values instead of letting them accumulate.
98
+ return Array.from(map.values()).map((b) => {
99
+ const inputTokens = toTokenCount(b.inputTokens);
100
+ const outputTokens = toTokenCount(b.outputTokens);
101
+ const cachedInputTokens = toTokenCount(b.cachedInputTokens);
102
+ const reasoningOutputTokens = toTokenCount(b.reasoningOutputTokens);
103
+ return {
104
+ ...b,
105
+ inputTokens,
106
+ outputTokens,
107
+ cachedInputTokens,
108
+ reasoningOutputTokens,
109
+ totalTokens: inputTokens + outputTokens + reasoningOutputTokens,
110
+ };
111
+ });
79
112
  }
80
113
 
81
114
  /**
@@ -19,6 +19,25 @@ const KIRO_USER_RELATIVE = 'User';
19
19
  const CREDIT_MODEL = 'kiro-credits';
20
20
  const ESTIMATE_MODEL = 'kiro-token-estimate';
21
21
  const CHARS_PER_TOKEN = 4;
22
+ const IMAGE_TOKENS = 1600;
23
+
24
+ // The official Kiro CLI persists each conversation as a `{version, kind, data}`
25
+ // event stream under ~/.kiro/sessions/cli/<uuid>.jsonl (companion <uuid>.json
26
+ // holds cwd + model). It carries NO token counts, so tokens are estimated from
27
+ // text length (chars/CHARS_PER_TOKEN), the same heuristic used elsewhere here.
28
+ //
29
+ // String values that are NOT linguistic tokens are excluded from the count.
30
+ // The big one is `signature`: extended-thinking blocks carry a ~500-char crypto
31
+ // signature; counting it as text inflates "output" by well over 100%.
32
+ const NON_TEXT_KEYS = new Set([
33
+ 'signature', 'redactedContent', 'toolUseId', 'modelId', 'message_id', 'format', 'id',
34
+ ]);
35
+
36
+ // System prompt + tool JSON schemas are injected on every request but never
37
+ // written to the session log, so the stream alone counts none of them. They are
38
+ // a near-constant per-call overhead. Keep the default at 0 so the parser reports
39
+ // only observed log text unless the user explicitly opts into an estimate.
40
+ const DEFAULT_KIRO_CLI_SYSTEM_OVERHEAD_TOKENS = 0;
22
41
 
23
42
  function getDefaultAppPath() {
24
43
  if (process.platform === 'darwin') {
@@ -92,6 +111,17 @@ export function getKiroSessionsDir() {
92
111
  return existsSync(def) ? def : null;
93
112
  }
94
113
 
114
+ // Native Kiro CLI session event streams: ~/.kiro/sessions/cli/*.jsonl
115
+ export function getKiroCliSessionsDir() {
116
+ const explicit = process.env.KIRO_CLI_SESSIONS_DIR?.trim();
117
+ if (explicit) {
118
+ const r = resolve(explicit);
119
+ return existsSync(r) ? r : null;
120
+ }
121
+ const def = join(homedir(), '.kiro', 'sessions', 'cli');
122
+ return existsSync(def) ? def : null;
123
+ }
124
+
95
125
  function isLockError(err) {
96
126
  return err && typeof err.message === 'string' && /database is locked/i.test(err.message);
97
127
  }
@@ -400,6 +430,172 @@ function readCliEstimateEntries() {
400
430
  return conversationsToEstimateEntries(conversations);
401
431
  }
402
432
 
433
+ // ---------------------------------------------------------------------------
434
+ // Native Kiro CLI event-stream sessions: ~/.kiro/sessions/cli/<uuid>.jsonl
435
+ // Each line is a {version, kind, data} event. Relevant kinds:
436
+ // Prompt user turn; data.content[] (text/image); data.meta.timestamp
437
+ // is the ONLY timestamp (epoch SECONDS).
438
+ // AssistantMessage data.content[] items: text (reply), thinking (reasoning +
439
+ // modelId + crypto signature), toolUse (name + input).
440
+ // ToolResults tool output fed back as context for the next turn.
441
+ // Compaction context was summarized; resets the running context size.
442
+ // ---------------------------------------------------------------------------
443
+
444
+ // Sum of string-leaf character lengths, skipping non-linguistic keys.
445
+ function textLeafChars(value) {
446
+ if (typeof value === 'string') return value.length;
447
+ if (Array.isArray(value)) {
448
+ let n = 0;
449
+ for (const v of value) n += textLeafChars(v);
450
+ return n;
451
+ }
452
+ if (value && typeof value === 'object') {
453
+ let n = 0;
454
+ for (const [k, v] of Object.entries(value)) {
455
+ if (NON_TEXT_KEYS.has(k)) continue;
456
+ n += textLeafChars(v);
457
+ }
458
+ return n;
459
+ }
460
+ return 0;
461
+ }
462
+
463
+ function estTokens(value) {
464
+ return Math.floor(textLeafChars(value) / CHARS_PER_TOKEN);
465
+ }
466
+
467
+ function getKiroCliSystemOverheadTokens() {
468
+ const raw = process.env.KIRO_CLI_SYSTEM_OVERHEAD_TOKENS;
469
+ if (raw === undefined || raw.trim() === '') return DEFAULT_KIRO_CLI_SYSTEM_OVERHEAD_TOKENS;
470
+ const n = Number(raw);
471
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
472
+ }
473
+
474
+ function loadCliSessionMeta(sessionsDir, sessionId) {
475
+ try {
476
+ const d = JSON.parse(readFileSync(join(sessionsDir, `${sessionId}.json`), 'utf-8'));
477
+ return {
478
+ cwd: (d && typeof d.cwd === 'string' && d.cwd) || 'unknown',
479
+ model: d?.session_state?.rts_model_state?.model_info?.model_id || null,
480
+ };
481
+ } catch {
482
+ return { cwd: 'unknown', model: null };
483
+ }
484
+ }
485
+
486
+ async function readCliSessionEntries(sessionsDir, fileName) {
487
+ const sessionId = fileName.replace(/\.jsonl$/, '');
488
+ const { cwd, model: metaModel } = loadCliSessionMeta(sessionsDir, sessionId);
489
+ const filePath = join(sessionsDir, fileName);
490
+
491
+ let mtime;
492
+ try { mtime = statSync(filePath).mtime; } catch { mtime = new Date(); }
493
+
494
+ const events = [];
495
+ const rl = createInterface({
496
+ input: createReadStream(filePath, { encoding: 'utf-8' }),
497
+ crlfDelay: Infinity,
498
+ });
499
+ for await (const raw of rl) {
500
+ const line = raw.trim();
501
+ if (!line) continue;
502
+ try { events.push(JSON.parse(line)); } catch { /* skip malformed line */ }
503
+ }
504
+
505
+ return cliEventsToEntries(events, { cwd, model: metaModel, fallbackTimestamp: mtime });
506
+ }
507
+
508
+ // Pure state machine over a Kiro CLI session's ordered events. Exposed for tests.
509
+ export function cliEventsToEntries(events, { cwd = 'unknown', model = null, fallbackTimestamp = null } = {}) {
510
+ const entries = [];
511
+ let curTs = null; // Date from the enclosing Prompt (epoch seconds)
512
+ let cumulative = 0; // running conversation context, re-sent every turn
513
+ let pendingInput = 0; // fresh input accumulated since the last assistant turn
514
+ let curModel = model;
515
+
516
+ for (const ev of events) {
517
+ const data = ev?.data;
518
+ if (!data || typeof data !== 'object') continue;
519
+ const content = Array.isArray(data.content) ? data.content : [];
520
+
521
+ if (ev.kind === 'Prompt') {
522
+ const ts = data.meta?.timestamp;
523
+ if (typeof ts === 'number' && ts > 0) curTs = new Date(ts * 1000);
524
+ for (const item of content) {
525
+ pendingInput += item?.kind === 'image' ? IMAGE_TOKENS : estTokens(item?.data);
526
+ }
527
+ } else if (ev.kind === 'ToolResults') {
528
+ for (const item of content) pendingInput += estTokens(item?.data);
529
+ } else if (ev.kind === 'AssistantMessage') {
530
+ let output = 0;
531
+ let reasoning = 0;
532
+ let signatureTokens = 0;
533
+ for (const item of content) {
534
+ const cd = item?.data;
535
+ if (cd && typeof cd === 'object' && typeof cd.modelId === 'string' && cd.modelId) {
536
+ curModel = cd.modelId;
537
+ }
538
+ if (item?.kind === 'thinking' && cd && typeof cd === 'object') {
539
+ reasoning += Math.floor(String(cd.text ?? '').length / CHARS_PER_TOKEN);
540
+ // Signature persists in history and is re-sent as context on every
541
+ // later turn, but it is NOT model output — keep it out of output.
542
+ signatureTokens += Math.floor(String(cd.signature ?? '').length / CHARS_PER_TOKEN);
543
+ } else {
544
+ output += estTokens(cd);
545
+ }
546
+ }
547
+
548
+ const inputTokens = pendingInput;
549
+ // Each request re-sends the whole prior conversation (cache read) plus the
550
+ // optional per-call system-prompt/tool-schema overhead.
551
+ const cachedInputTokens = cumulative + getKiroCliSystemOverheadTokens();
552
+
553
+ if (inputTokens > 0 || output > 0 || reasoning > 0) {
554
+ entries.push({
555
+ source: 'kiro',
556
+ model: curModel || ESTIMATE_MODEL,
557
+ project: cwd,
558
+ timestamp: curTs || fallbackTimestamp || new Date(),
559
+ inputTokens,
560
+ outputTokens: output,
561
+ cachedInputTokens,
562
+ reasoningOutputTokens: reasoning,
563
+ });
564
+ }
565
+
566
+ // Grow the running context: fresh input + everything generated this turn,
567
+ // including the thinking signature (it persists in history and is re-sent).
568
+ cumulative += inputTokens + output + reasoning + signatureTokens;
569
+ pendingInput = 0;
570
+ } else if (ev.kind === 'Compaction') {
571
+ cumulative = estTokens(data.summary);
572
+ pendingInput = 0;
573
+ }
574
+ }
575
+
576
+ return entries;
577
+ }
578
+
579
+ async function readCliSessionStreamEntries() {
580
+ const dir = getKiroCliSessionsDir();
581
+ if (!dir) return [];
582
+ let files;
583
+ try {
584
+ files = readdirSync(dir).filter(f => f.endsWith('.jsonl'));
585
+ } catch {
586
+ return [];
587
+ }
588
+ const entries = [];
589
+ for (const file of files) {
590
+ try {
591
+ entries.push(...await readCliSessionEntries(dir, file));
592
+ } catch {
593
+ // skip unreadable / concurrently rotated session
594
+ }
595
+ }
596
+ return entries;
597
+ }
598
+
403
599
  function parseLogTimestamp(raw) {
404
600
  const d = new Date(String(raw).replace(' ', 'T'));
405
601
  return isNaN(d.getTime()) ? null : d;
@@ -540,7 +736,12 @@ export function snapshotsToCreditEntries(snapshots) {
540
736
  continue;
541
737
  }
542
738
 
543
- const delta = Number((snapshot.currentUsage - prev.currentUsage).toFixed(4));
739
+ // The server's token columns are bigint, so fractional credit deltas
740
+ // cannot be uploaded as-is. Diff floored cumulative values instead of
741
+ // rounding each delta: the diffs telescope, so sub-integer usage is never
742
+ // lost or double-counted — it surfaces on whichever snapshot crosses the
743
+ // next whole-credit boundary.
744
+ const delta = Math.floor(snapshot.currentUsage) - Math.floor(prev.currentUsage);
544
745
  if (delta > 0) {
545
746
  entries.push({
546
747
  source: 'kiro',
@@ -574,6 +775,15 @@ function parseLegacyTokens(base) {
574
775
  }
575
776
 
576
777
  export async function parse() {
778
+ // 1. Official Kiro CLI native event streams (~/.kiro/sessions/cli/*.jsonl).
779
+ // This is where the shipping CLI actually records conversations; the
780
+ // conversations_v2 DB path below is empty on those installs.
781
+ const streamEntries = await readCliSessionStreamEntries();
782
+ if (streamEntries.length > 0) {
783
+ return { buckets: aggregateToBuckets(streamEntries), sessions: [] };
784
+ }
785
+
786
+ // 2. Kiro CLI conversations DB / archived snapshots (other CLI variants).
577
787
  try {
578
788
  const estimateEntries = readCliEstimateEntries();
579
789
  if (estimateEntries.length > 0) {