@vibescore/tracker 0.0.6 → 0.0.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.
@@ -5,6 +5,10 @@ const readline = require('node:readline');
5
5
 
6
6
  const { ensureDir } = require('./fs');
7
7
 
8
+ const DEFAULT_SOURCE = 'codex';
9
+ const DEFAULT_MODEL = 'unknown';
10
+ const BUCKET_SEPARATOR = '|';
11
+
8
12
  async function listRolloutFiles(sessionsDir) {
9
13
  const out = [];
10
14
  const years = await safeReadDir(sessionsDir);
@@ -33,7 +37,14 @@ async function listRolloutFiles(sessionsDir) {
33
37
  return out;
34
38
  }
35
39
 
36
- async function parseRolloutIncremental({ rolloutFiles, cursors, queuePath, onProgress }) {
40
+ async function listClaudeProjectFiles(projectsDir) {
41
+ const out = [];
42
+ await walkClaudeProjects(projectsDir, out);
43
+ out.sort((a, b) => a.localeCompare(b));
44
+ return out;
45
+ }
46
+
47
+ async function parseRolloutIncremental({ rolloutFiles, cursors, queuePath, onProgress, source }) {
37
48
  await ensureDir(path.dirname(queuePath));
38
49
  let filesProcessed = 0;
39
50
  let eventsAggregated = 0;
@@ -42,13 +53,18 @@ async function parseRolloutIncremental({ rolloutFiles, cursors, queuePath, onPro
42
53
  const totalFiles = Array.isArray(rolloutFiles) ? rolloutFiles.length : 0;
43
54
  const hourlyState = normalizeHourlyState(cursors?.hourly);
44
55
  const touchedBuckets = new Set();
56
+ const defaultSource = normalizeSourceInput(source) || DEFAULT_SOURCE;
45
57
 
46
58
  if (!cursors.files || typeof cursors.files !== 'object') {
47
59
  cursors.files = {};
48
60
  }
49
61
 
50
62
  for (let idx = 0; idx < rolloutFiles.length; idx++) {
51
- const filePath = rolloutFiles[idx];
63
+ const entry = rolloutFiles[idx];
64
+ const filePath = typeof entry === 'string' ? entry : entry?.path;
65
+ if (!filePath) continue;
66
+ const fileSource =
67
+ typeof entry === 'string' ? defaultSource : normalizeSourceInput(entry?.source) || defaultSource;
52
68
  const st = await fs.stat(filePath).catch(() => null);
53
69
  if (!st || !st.isFile()) continue;
54
70
 
@@ -65,7 +81,8 @@ async function parseRolloutIncremental({ rolloutFiles, cursors, queuePath, onPro
65
81
  lastTotal,
66
82
  lastModel,
67
83
  hourlyState,
68
- touchedBuckets
84
+ touchedBuckets,
85
+ source: fileSource
69
86
  });
70
87
 
71
88
  cursors.files[key] = {
@@ -98,7 +115,81 @@ async function parseRolloutIncremental({ rolloutFiles, cursors, queuePath, onPro
98
115
  return { filesProcessed, eventsAggregated, bucketsQueued };
99
116
  }
100
117
 
101
- async function parseRolloutFile({ filePath, startOffset, lastTotal, lastModel, hourlyState, touchedBuckets }) {
118
+ async function parseClaudeIncremental({ projectFiles, cursors, queuePath, onProgress, source }) {
119
+ await ensureDir(path.dirname(queuePath));
120
+ let filesProcessed = 0;
121
+ let eventsAggregated = 0;
122
+
123
+ const cb = typeof onProgress === 'function' ? onProgress : null;
124
+ const files = Array.isArray(projectFiles) ? projectFiles : [];
125
+ const totalFiles = files.length;
126
+ const hourlyState = normalizeHourlyState(cursors?.hourly);
127
+ const touchedBuckets = new Set();
128
+ const defaultSource = normalizeSourceInput(source) || 'claude';
129
+
130
+ if (!cursors.files || typeof cursors.files !== 'object') {
131
+ cursors.files = {};
132
+ }
133
+
134
+ for (let idx = 0; idx < files.length; idx++) {
135
+ const entry = files[idx];
136
+ const filePath = typeof entry === 'string' ? entry : entry?.path;
137
+ if (!filePath) continue;
138
+ const fileSource =
139
+ typeof entry === 'string' ? defaultSource : normalizeSourceInput(entry?.source) || defaultSource;
140
+ const st = await fs.stat(filePath).catch(() => null);
141
+ if (!st || !st.isFile()) continue;
142
+
143
+ const key = filePath;
144
+ const prev = cursors.files[key] || null;
145
+ const inode = st.ino || 0;
146
+ const startOffset = prev && prev.inode === inode ? prev.offset || 0 : 0;
147
+
148
+ const result = await parseClaudeFile({
149
+ filePath,
150
+ startOffset,
151
+ hourlyState,
152
+ touchedBuckets,
153
+ source: fileSource
154
+ });
155
+
156
+ cursors.files[key] = {
157
+ inode,
158
+ offset: result.endOffset,
159
+ updatedAt: new Date().toISOString()
160
+ };
161
+
162
+ filesProcessed += 1;
163
+ eventsAggregated += result.eventsAggregated;
164
+
165
+ if (cb) {
166
+ cb({
167
+ index: idx + 1,
168
+ total: totalFiles,
169
+ filePath,
170
+ filesProcessed,
171
+ eventsAggregated,
172
+ bucketsQueued: touchedBuckets.size
173
+ });
174
+ }
175
+ }
176
+
177
+ const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets });
178
+ hourlyState.updatedAt = new Date().toISOString();
179
+ cursors.hourly = hourlyState;
180
+
181
+ return { filesProcessed, eventsAggregated, bucketsQueued };
182
+ }
183
+
184
+ async function parseRolloutFile({
185
+ filePath,
186
+ startOffset,
187
+ lastTotal,
188
+ lastModel,
189
+ hourlyState,
190
+ touchedBuckets,
191
+ source
192
+ }) {
102
193
  const st = await fs.stat(filePath);
103
194
  const endOffset = st.size;
104
195
  if (startOffset >= endOffset) {
@@ -130,13 +221,13 @@ async function parseRolloutFile({ filePath, startOffset, lastTotal, lastModel, h
130
221
  continue;
131
222
  }
132
223
 
133
- const payload = obj?.payload;
134
- if (!payload || payload.type !== 'token_count') continue;
224
+ const token = extractTokenCount(obj);
225
+ if (!token) continue;
135
226
 
136
- const info = payload.info;
227
+ const info = token.info;
137
228
  if (!info || typeof info !== 'object') continue;
138
229
 
139
- const tokenTimestamp = typeof obj.timestamp === 'string' ? obj.timestamp : null;
230
+ const tokenTimestamp = typeof token.timestamp === 'string' ? token.timestamp : null;
140
231
  if (!tokenTimestamp) continue;
141
232
 
142
233
  const lastUsage = info.last_token_usage;
@@ -152,27 +243,78 @@ async function parseRolloutFile({ filePath, startOffset, lastTotal, lastModel, h
152
243
  const bucketStart = toUtcHalfHourStart(tokenTimestamp);
153
244
  if (!bucketStart) continue;
154
245
 
155
- const bucket = getHourlyBucket(hourlyState, bucketStart);
246
+ const bucket = getHourlyBucket(hourlyState, source, model, bucketStart);
156
247
  addTotals(bucket.totals, delta);
157
- touchedBuckets.add(bucketStart);
248
+ touchedBuckets.add(bucketKey(source, model, bucketStart));
158
249
  eventsAggregated += 1;
159
250
  }
160
251
 
161
252
  return { endOffset, lastTotal: totals, lastModel: model, eventsAggregated };
162
253
  }
163
254
 
255
+ async function parseClaudeFile({ filePath, startOffset, hourlyState, touchedBuckets, source }) {
256
+ const st = await fs.stat(filePath).catch(() => null);
257
+ if (!st || !st.isFile()) return { endOffset: startOffset, eventsAggregated: 0 };
258
+
259
+ const endOffset = st.size;
260
+ if (startOffset >= endOffset) return { endOffset, eventsAggregated: 0 };
261
+
262
+ const stream = fssync.createReadStream(filePath, { encoding: 'utf8', start: startOffset });
263
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
264
+
265
+ let eventsAggregated = 0;
266
+ for await (const line of rl) {
267
+ if (!line || !line.includes('\"usage\"')) continue;
268
+ let obj;
269
+ try {
270
+ obj = JSON.parse(line);
271
+ } catch (_e) {
272
+ continue;
273
+ }
274
+
275
+ const usage = obj?.message?.usage || obj?.usage;
276
+ if (!usage || typeof usage !== 'object') continue;
277
+
278
+ const model = normalizeModelInput(obj?.message?.model || obj?.model) || DEFAULT_MODEL;
279
+ const tokenTimestamp = typeof obj?.timestamp === 'string' ? obj.timestamp : null;
280
+ if (!tokenTimestamp) continue;
281
+
282
+ const delta = normalizeClaudeUsage(usage);
283
+ if (!delta || isAllZeroUsage(delta)) continue;
284
+
285
+ const bucketStart = toUtcHalfHourStart(tokenTimestamp);
286
+ if (!bucketStart) continue;
287
+
288
+ const bucket = getHourlyBucket(hourlyState, source, model, bucketStart);
289
+ addTotals(bucket.totals, delta);
290
+ touchedBuckets.add(bucketKey(source, model, bucketStart));
291
+ eventsAggregated += 1;
292
+ }
293
+
294
+ rl.close();
295
+ stream.close?.();
296
+ return { endOffset, eventsAggregated };
297
+ }
298
+
164
299
  async function enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets }) {
165
300
  if (!touchedBuckets || touchedBuckets.size === 0) return 0;
166
301
 
167
302
  const toAppend = [];
168
303
  for (const bucketStart of touchedBuckets) {
169
- const bucket = hourlyState.buckets[bucketStart];
304
+ const parsedKey = parseBucketKey(bucketStart);
305
+ const source = parsedKey.source || DEFAULT_SOURCE;
306
+ const model = parsedKey.model || DEFAULT_MODEL;
307
+ const hourStart = parsedKey.hourStart;
308
+ const bucket =
309
+ hourlyState.buckets[bucketKey(source, model, hourStart)] || hourlyState.buckets[bucketStart];
170
310
  if (!bucket || !bucket.totals) continue;
171
311
  const key = totalsKey(bucket.totals);
172
312
  if (bucket.queuedKey === key) continue;
173
313
  toAppend.push(
174
314
  JSON.stringify({
175
- hour_start: bucketStart,
315
+ source,
316
+ model,
317
+ hour_start: hourStart,
176
318
  input_tokens: bucket.totals.input_tokens,
177
319
  cached_input_tokens: bucket.totals.cached_input_tokens,
178
320
  output_tokens: bucket.totals.output_tokens,
@@ -192,20 +334,39 @@ async function enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets })
192
334
 
193
335
  function normalizeHourlyState(raw) {
194
336
  const state = raw && typeof raw === 'object' ? raw : {};
195
- const buckets = state.buckets && typeof state.buckets === 'object' ? state.buckets : {};
337
+ const version = Number(state.version || 1);
338
+ if (!Number.isFinite(version) || version < 2) {
339
+ return {
340
+ version: 2,
341
+ buckets: {},
342
+ updatedAt: null
343
+ };
344
+ }
345
+ const rawBuckets = state.buckets && typeof state.buckets === 'object' ? state.buckets : {};
346
+ const buckets = {};
347
+ for (const [key, value] of Object.entries(rawBuckets)) {
348
+ const parsed = parseBucketKey(key);
349
+ const hourStart = parsed.hourStart;
350
+ if (!hourStart) continue;
351
+ const normalizedKey = bucketKey(parsed.source, parsed.model, hourStart);
352
+ buckets[normalizedKey] = value;
353
+ }
196
354
  return {
197
- version: 1,
355
+ version: 2,
198
356
  buckets,
199
357
  updatedAt: typeof state.updatedAt === 'string' ? state.updatedAt : null
200
358
  };
201
359
  }
202
360
 
203
- function getHourlyBucket(state, hourStart) {
361
+ function getHourlyBucket(state, source, model, hourStart) {
204
362
  const buckets = state.buckets;
205
- let bucket = buckets[hourStart];
363
+ const normalizedSource = normalizeSourceInput(source) || DEFAULT_SOURCE;
364
+ const normalizedModel = normalizeModelInput(model) || DEFAULT_MODEL;
365
+ const key = bucketKey(normalizedSource, normalizedModel, hourStart);
366
+ let bucket = buckets[key];
206
367
  if (!bucket || typeof bucket !== 'object') {
207
368
  bucket = { totals: initTotals(), queuedKey: null };
208
- buckets[hourStart] = bucket;
369
+ buckets[key] = bucket;
209
370
  return bucket;
210
371
  }
211
372
 
@@ -267,6 +428,52 @@ function toUtcHalfHourStart(ts) {
267
428
  return bucketStart.toISOString();
268
429
  }
269
430
 
431
+ function bucketKey(source, model, hourStart) {
432
+ const safeSource = normalizeSourceInput(source) || DEFAULT_SOURCE;
433
+ const safeModel = normalizeModelInput(model) || DEFAULT_MODEL;
434
+ return `${safeSource}${BUCKET_SEPARATOR}${safeModel}${BUCKET_SEPARATOR}${hourStart}`;
435
+ }
436
+
437
+ function parseBucketKey(key) {
438
+ if (typeof key !== 'string') return { source: DEFAULT_SOURCE, model: DEFAULT_MODEL, hourStart: '' };
439
+ const first = key.indexOf(BUCKET_SEPARATOR);
440
+ if (first <= 0) return { source: DEFAULT_SOURCE, model: DEFAULT_MODEL, hourStart: key };
441
+ const second = key.indexOf(BUCKET_SEPARATOR, first + 1);
442
+ if (second <= 0) {
443
+ return { source: key.slice(0, first), model: DEFAULT_MODEL, hourStart: key.slice(first + 1) };
444
+ }
445
+ return {
446
+ source: key.slice(0, first),
447
+ model: key.slice(first + 1, second),
448
+ hourStart: key.slice(second + 1)
449
+ };
450
+ }
451
+
452
+ function normalizeSourceInput(value) {
453
+ if (typeof value !== 'string') return null;
454
+ const trimmed = value.trim().toLowerCase();
455
+ return trimmed.length > 0 ? trimmed : null;
456
+ }
457
+
458
+ function normalizeModelInput(value) {
459
+ if (typeof value !== 'string') return null;
460
+ const trimmed = value.trim();
461
+ return trimmed.length > 0 ? trimmed : null;
462
+ }
463
+
464
+ function extractTokenCount(obj) {
465
+ const payload = obj?.payload;
466
+ if (!payload) return null;
467
+ if (payload.type === 'token_count') {
468
+ return { info: payload.info, timestamp: obj?.timestamp || null };
469
+ }
470
+ const msg = payload.msg;
471
+ if (msg && msg.type === 'token_count') {
472
+ return { info: msg.info, timestamp: obj?.timestamp || null };
473
+ }
474
+ return null;
475
+ }
476
+
270
477
  function pickDelta(lastUsage, totalUsage, prevTotals) {
271
478
  const hasLast = isNonEmptyObject(lastUsage);
272
479
  const hasTotal = isNonEmptyObject(totalUsage);
@@ -315,6 +522,16 @@ function normalizeUsage(u) {
315
522
  return out;
316
523
  }
317
524
 
525
+ function normalizeClaudeUsage(u) {
526
+ return {
527
+ input_tokens: toNonNegativeInt(u?.input_tokens),
528
+ cached_input_tokens: toNonNegativeInt(u?.cache_read_input_tokens),
529
+ output_tokens: toNonNegativeInt(u?.output_tokens),
530
+ reasoning_output_tokens: 0,
531
+ total_tokens: toNonNegativeInt(u?.input_tokens) + toNonNegativeInt(u?.output_tokens)
532
+ };
533
+ }
534
+
318
535
  function isNonEmptyObject(v) {
319
536
  return Boolean(v && typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length > 0);
320
537
  }
@@ -359,7 +576,21 @@ async function safeReadDir(dir) {
359
576
  }
360
577
  }
361
578
 
579
+ async function walkClaudeProjects(dir, out) {
580
+ const entries = await safeReadDir(dir);
581
+ for (const entry of entries) {
582
+ const fullPath = path.join(dir, entry.name);
583
+ if (entry.isDirectory()) {
584
+ await walkClaudeProjects(fullPath, out);
585
+ continue;
586
+ }
587
+ if (entry.isFile() && entry.name.endsWith('.jsonl')) out.push(fullPath);
588
+ }
589
+ }
590
+
362
591
  module.exports = {
363
592
  listRolloutFiles,
364
- parseRolloutIncremental
593
+ listClaudeProjectFiles,
594
+ parseRolloutIncremental,
595
+ parseClaudeIncremental
365
596
  };
@@ -5,6 +5,10 @@ const readline = require('node:readline');
5
5
  const { ensureDir, readJson, writeJson } = require('./fs');
6
6
  const { ingestHourly } = require('./vibescore-api');
7
7
 
8
+ const DEFAULT_SOURCE = 'codex';
9
+ const DEFAULT_MODEL = 'unknown';
10
+ const BUCKET_SEPARATOR = '|';
11
+
8
12
  async function drainQueueToCloud({ baseUrl, deviceToken, queuePath, queueStatePath, maxBatches, batchSize, onProgress }) {
9
13
  await ensureDir(require('node:path').dirname(queueStatePath));
10
14
 
@@ -70,7 +74,11 @@ async function readBatch(queuePath, startOffset, maxBuckets) {
70
74
  }
71
75
  const hourStart = typeof bucket?.hour_start === 'string' ? bucket.hour_start : null;
72
76
  if (!hourStart) continue;
73
- bucketMap.set(hourStart, bucket);
77
+ const source = normalizeSource(bucket?.source) || DEFAULT_SOURCE;
78
+ const model = normalizeModel(bucket?.model) || DEFAULT_MODEL;
79
+ bucket.source = source;
80
+ bucket.model = model;
81
+ bucketMap.set(bucketKey(source, model, hourStart), bucket);
74
82
  linesRead += 1;
75
83
  if (linesRead >= maxBuckets) break;
76
84
  }
@@ -89,4 +97,20 @@ async function safeFileSize(p) {
89
97
  }
90
98
  }
91
99
 
100
+ function bucketKey(source, model, hourStart) {
101
+ return `${source}${BUCKET_SEPARATOR}${model}${BUCKET_SEPARATOR}${hourStart}`;
102
+ }
103
+
104
+ function normalizeSource(value) {
105
+ if (typeof value !== 'string') return null;
106
+ const trimmed = value.trim().toLowerCase();
107
+ return trimmed.length > 0 ? trimmed : null;
108
+ }
109
+
110
+ function normalizeModel(value) {
111
+ if (typeof value !== 'string') return null;
112
+ const trimmed = value.trim();
113
+ return trimmed.length > 0 ? trimmed : null;
114
+ }
115
+
92
116
  module.exports = { drainQueueToCloud };