@vibescore/tracker 0.1.1 → 0.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibescore/tracker",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Codex CLI token usage tracker (macOS-first, notify-driven).",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
package/src/cli.js CHANGED
@@ -48,6 +48,7 @@ function printHelp() {
48
48
  '',
49
49
  'Notes:',
50
50
  ' - init installs a Codex notify hook and issues a device token (default: browser sign in/up).',
51
+ ' - optional: pass --link-code <code> to skip browser login when provided by Dashboard.',
51
52
  ' - when ~/.code/config.toml exists, init also installs an Every Code notify hook.',
52
53
  ' - optional: set VIBESCORE_DASHBOARD_URL (or --dashboard-url) to use a hosted landing page.',
53
54
  ' - sync parses ~/.codex/sessions/**/rollout-*.jsonl and ~/.code/sessions/**/rollout-*.jsonl (Every Code), then uploads token_count deltas.',
@@ -3,6 +3,7 @@ const path = require('node:path');
3
3
  const fs = require('node:fs/promises');
4
4
  const fssync = require('node:fs');
5
5
  const cp = require('node:child_process');
6
+ const crypto = require('node:crypto');
6
7
 
7
8
  const { ensureDir, writeFileAtomic, readJson, writeJson, chmod600IfPossible } = require('../lib/fs');
8
9
  const { prompt, promptHidden } = require('../lib/prompt');
@@ -14,7 +15,11 @@ const {
14
15
  } = require('../lib/codex-config');
15
16
  const { upsertClaudeHook, buildClaudeHookCommand } = require('../lib/claude-config');
16
17
  const { beginBrowserAuth } = require('../lib/browser-auth');
17
- const { issueDeviceTokenWithPassword, issueDeviceTokenWithAccessToken } = require('../lib/insforge');
18
+ const {
19
+ issueDeviceTokenWithPassword,
20
+ issueDeviceTokenWithAccessToken,
21
+ issueDeviceTokenWithLinkCode
22
+ } = require('../lib/insforge');
18
23
 
19
24
  async function cmdInit(argv) {
20
25
  const opts = parseArgs(argv);
@@ -29,6 +34,7 @@ async function cmdInit(argv) {
29
34
 
30
35
  const configPath = path.join(trackerDir, 'config.json');
31
36
  const notifyOriginalPath = path.join(trackerDir, 'codex_notify_original.json');
37
+ const linkCodeStatePath = path.join(trackerDir, 'link_code_state.json');
32
38
 
33
39
  const baseUrl = opts.baseUrl || process.env.VIBESCORE_INSFORGE_BASE_URL || 'https://5tmappuk.us-east.insforge.app';
34
40
  let dashboardUrl = opts.dashboardUrl || process.env.VIBESCORE_DASHBOARD_URL || null;
@@ -44,7 +50,36 @@ async function cmdInit(argv) {
44
50
 
45
51
  await installLocalTrackerApp({ appDir });
46
52
 
47
- if (!deviceToken && !opts.noAuth) {
53
+ if (!deviceToken && opts.linkCode) {
54
+ const deviceName = opts.deviceName || os.hostname();
55
+ const platform = normalizePlatform(process.platform);
56
+ const linkCode = String(opts.linkCode);
57
+ const linkCodeHash = crypto.createHash('sha256').update(linkCode).digest('hex');
58
+ const existingLinkState = await readJson(linkCodeStatePath);
59
+ let requestId =
60
+ existingLinkState?.linkCodeHash === linkCodeHash && existingLinkState?.requestId
61
+ ? existingLinkState.requestId
62
+ : null;
63
+ if (!requestId) {
64
+ requestId = crypto.randomUUID();
65
+ await writeJson(linkCodeStatePath, {
66
+ linkCodeHash,
67
+ requestId,
68
+ createdAt: new Date().toISOString()
69
+ });
70
+ await chmod600IfPossible(linkCodeStatePath);
71
+ }
72
+ const issued = await issueDeviceTokenWithLinkCode({
73
+ baseUrl,
74
+ linkCode,
75
+ requestId,
76
+ deviceName,
77
+ platform
78
+ });
79
+ deviceToken = issued.token;
80
+ deviceId = issued.deviceId;
81
+ await fs.rm(linkCodeStatePath, { force: true });
82
+ } else if (!deviceToken && !opts.noAuth) {
48
83
  const deviceName = opts.deviceName || os.hostname();
49
84
 
50
85
  if (opts.email || opts.password) {
@@ -171,6 +206,7 @@ function parseArgs(argv) {
171
206
  email: null,
172
207
  password: null,
173
208
  deviceName: null,
209
+ linkCode: null,
174
210
  noAuth: false,
175
211
  noOpen: false
176
212
  };
@@ -182,6 +218,7 @@ function parseArgs(argv) {
182
218
  else if (a === '--email') out.email = argv[++i] || null;
183
219
  else if (a === '--password') out.password = argv[++i] || null;
184
220
  else if (a === '--device-name') out.deviceName = argv[++i] || null;
221
+ else if (a === '--link-code') out.linkCode = argv[++i] || null;
185
222
  else if (a === '--no-auth') out.noAuth = true;
186
223
  else if (a === '--no-open') out.noOpen = true;
187
224
  else throw new Error(`Unknown option: ${a}`);
@@ -194,6 +231,13 @@ function maskSecret(s) {
194
231
  return `${s.slice(0, 4)}…${s.slice(-4)}`;
195
232
  }
196
233
 
234
+ function normalizePlatform(value) {
235
+ if (value === 'darwin') return 'macos';
236
+ if (value === 'win32') return 'windows';
237
+ if (value === 'linux') return 'linux';
238
+ return 'unknown';
239
+ }
240
+
197
241
  function buildNotifyHandler({ trackerDir, packageName }) {
198
242
  // Keep this file dependency-free: Node built-ins only.
199
243
  // It must never block Codex; it spawns sync in the background and exits 0.
@@ -1,4 +1,4 @@
1
- const { issueDeviceToken, signInWithPassword } = require('./vibescore-api');
1
+ const { exchangeLinkCode, issueDeviceToken, signInWithPassword } = require('./vibescore-api');
2
2
 
3
3
  async function issueDeviceTokenWithPassword({ baseUrl, email, password, deviceName }) {
4
4
  const accessToken = await signInWithPassword({ baseUrl, email, password });
@@ -11,7 +11,13 @@ async function issueDeviceTokenWithAccessToken({ baseUrl, accessToken, deviceNam
11
11
  return issued;
12
12
  }
13
13
 
14
+ async function issueDeviceTokenWithLinkCode({ baseUrl, linkCode, requestId, deviceName, platform }) {
15
+ const issued = await exchangeLinkCode({ baseUrl, linkCode, requestId, deviceName, platform });
16
+ return { token: issued.token, deviceId: issued.deviceId };
17
+ }
18
+
14
19
  module.exports = {
15
20
  issueDeviceTokenWithPassword,
16
- issueDeviceTokenWithAccessToken
21
+ issueDeviceTokenWithAccessToken,
22
+ issueDeviceTokenWithLinkCode
17
23
  };
@@ -470,44 +470,207 @@ async function enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets })
470
470
  if (touchedGroups.size === 0) return 0;
471
471
 
472
472
  const groupQueued = hourlyState.groupQueued && typeof hourlyState.groupQueued === 'object' ? hourlyState.groupQueued : {};
473
+ let codexTouched = false;
473
474
  const legacyGroups = new Set();
474
475
  for (const groupKey of touchedGroups) {
475
476
  if (Object.prototype.hasOwnProperty.call(groupQueued, groupKey)) {
476
477
  legacyGroups.add(groupKey);
477
478
  }
479
+ if (!codexTouched && groupKey.startsWith(`${DEFAULT_SOURCE}${BUCKET_SEPARATOR}`)) {
480
+ codexTouched = true;
481
+ }
478
482
  }
479
483
 
480
- const toAppend = [];
481
- for (const bucketStart of touchedBuckets) {
482
- const parsed = parseBucketKey(bucketStart);
484
+ const groupedBuckets = new Map();
485
+ for (const [key, bucket] of Object.entries(hourlyState.buckets || {})) {
486
+ if (!bucket || !bucket.totals) continue;
487
+ const parsed = parseBucketKey(key);
483
488
  const hourStart = parsed.hourStart;
484
489
  if (!hourStart) continue;
485
490
  const groupKey = groupBucketKey(parsed.source, hourStart);
486
- if (legacyGroups.has(groupKey)) continue;
491
+ if (!touchedGroups.has(groupKey) || legacyGroups.has(groupKey)) continue;
492
+
493
+ const source = normalizeSourceInput(parsed.source) || DEFAULT_SOURCE;
494
+ const model = normalizeModelInput(parsed.model) || DEFAULT_MODEL;
495
+ let group = groupedBuckets.get(groupKey);
496
+ if (!group) {
497
+ group = { source, hourStart, buckets: new Map() };
498
+ groupedBuckets.set(groupKey, group);
499
+ }
487
500
 
488
- const normalizedKey = bucketKey(parsed.source, parsed.model, hourStart);
489
- const bucket = hourlyState.buckets ? hourlyState.buckets[normalizedKey] : null;
490
- if (!bucket || !bucket.totals) continue;
491
501
  if (bucket.queuedKey != null && typeof bucket.queuedKey !== 'string') {
492
502
  bucket.queuedKey = null;
493
503
  }
494
- const key = totalsKey(bucket.totals);
495
- if (bucket.queuedKey === key) continue;
496
- const source = normalizeSourceInput(parsed.source) || DEFAULT_SOURCE;
497
- const model = normalizeModelInput(parsed.model) || DEFAULT_MODEL;
504
+ group.buckets.set(model, bucket);
505
+ }
506
+
507
+ if (codexTouched) {
508
+ const recomputeGroups = new Set();
509
+ for (const [key, bucket] of Object.entries(hourlyState.buckets || {})) {
510
+ if (!bucket || !bucket.totals) continue;
511
+ const parsed = parseBucketKey(key);
512
+ const hourStart = parsed.hourStart;
513
+ if (!hourStart) continue;
514
+ const source = normalizeSourceInput(parsed.source) || DEFAULT_SOURCE;
515
+ if (source !== 'every-code') continue;
516
+ const groupKey = groupBucketKey(source, hourStart);
517
+ if (legacyGroups.has(groupKey) || groupedBuckets.has(groupKey)) continue;
518
+ const model = normalizeModelInput(parsed.model) || DEFAULT_MODEL;
519
+ if (model !== DEFAULT_MODEL) continue;
520
+ recomputeGroups.add(groupKey);
521
+ }
522
+
523
+ if (recomputeGroups.size > 0) {
524
+ for (const [key, bucket] of Object.entries(hourlyState.buckets || {})) {
525
+ if (!bucket || !bucket.totals) continue;
526
+ const parsed = parseBucketKey(key);
527
+ const hourStart = parsed.hourStart;
528
+ if (!hourStart) continue;
529
+ const source = normalizeSourceInput(parsed.source) || DEFAULT_SOURCE;
530
+ const groupKey = groupBucketKey(source, hourStart);
531
+ if (!recomputeGroups.has(groupKey)) continue;
532
+ let group = groupedBuckets.get(groupKey);
533
+ if (!group) {
534
+ group = { source, hourStart, buckets: new Map() };
535
+ groupedBuckets.set(groupKey, group);
536
+ }
537
+ if (bucket.queuedKey != null && typeof bucket.queuedKey !== 'string') {
538
+ bucket.queuedKey = null;
539
+ }
540
+ const model = normalizeModelInput(parsed.model) || DEFAULT_MODEL;
541
+ group.buckets.set(model, bucket);
542
+ }
543
+ }
544
+ }
545
+
546
+ const codexDominants = collectCodexDominantModels(hourlyState);
547
+
548
+ const toAppend = [];
549
+ for (const group of groupedBuckets.values()) {
550
+ const unknownBucket = group.buckets.get(DEFAULT_MODEL) || null;
551
+ const dominantModel = pickDominantModel(group.buckets);
552
+ let alignedModel = null;
553
+ if (unknownBucket?.alignedModel) {
554
+ const normalized = normalizeModelInput(unknownBucket.alignedModel);
555
+ alignedModel = normalized && normalized !== DEFAULT_MODEL ? normalized : null;
556
+ }
557
+ const zeroTotals = initTotals();
558
+ const zeroKey = totalsKey(zeroTotals);
559
+
560
+ if (dominantModel) {
561
+ if (alignedModel && !group.buckets.has(alignedModel)) {
562
+ toAppend.push(
563
+ JSON.stringify({
564
+ source: group.source,
565
+ model: alignedModel,
566
+ hour_start: group.hourStart,
567
+ input_tokens: zeroTotals.input_tokens,
568
+ cached_input_tokens: zeroTotals.cached_input_tokens,
569
+ output_tokens: zeroTotals.output_tokens,
570
+ reasoning_output_tokens: zeroTotals.reasoning_output_tokens,
571
+ total_tokens: zeroTotals.total_tokens
572
+ })
573
+ );
574
+ }
575
+ if (unknownBucket && !alignedModel && unknownBucket.queuedKey && unknownBucket.queuedKey !== zeroKey) {
576
+ if (unknownBucket.retractedUnknownKey !== zeroKey) {
577
+ toAppend.push(
578
+ JSON.stringify({
579
+ source: group.source,
580
+ model: DEFAULT_MODEL,
581
+ hour_start: group.hourStart,
582
+ input_tokens: zeroTotals.input_tokens,
583
+ cached_input_tokens: zeroTotals.cached_input_tokens,
584
+ output_tokens: zeroTotals.output_tokens,
585
+ reasoning_output_tokens: zeroTotals.reasoning_output_tokens,
586
+ total_tokens: zeroTotals.total_tokens
587
+ })
588
+ );
589
+ unknownBucket.retractedUnknownKey = zeroKey;
590
+ }
591
+ }
592
+ if (unknownBucket) unknownBucket.alignedModel = null;
593
+ for (const [model, bucket] of group.buckets.entries()) {
594
+ if (model === DEFAULT_MODEL) continue;
595
+ let totals = bucket.totals;
596
+ if (model === dominantModel && unknownBucket?.totals) {
597
+ totals = cloneTotals(bucket.totals);
598
+ addTotals(totals, unknownBucket.totals);
599
+ }
600
+ const key = totalsKey(totals);
601
+ if (bucket.queuedKey === key) continue;
602
+ toAppend.push(
603
+ JSON.stringify({
604
+ source: group.source,
605
+ model,
606
+ hour_start: group.hourStart,
607
+ input_tokens: totals.input_tokens,
608
+ cached_input_tokens: totals.cached_input_tokens,
609
+ output_tokens: totals.output_tokens,
610
+ reasoning_output_tokens: totals.reasoning_output_tokens,
611
+ total_tokens: totals.total_tokens
612
+ })
613
+ );
614
+ bucket.queuedKey = key;
615
+ }
616
+ continue;
617
+ }
618
+
619
+ if (!unknownBucket?.totals) continue;
620
+ let outputModel = DEFAULT_MODEL;
621
+ if (group.source === 'every-code') {
622
+ const aligned = findNearestCodexModel(group.hourStart, codexDominants);
623
+ if (aligned) outputModel = aligned;
624
+ }
625
+ const nextAligned = outputModel !== DEFAULT_MODEL ? outputModel : null;
626
+ if (alignedModel && alignedModel !== nextAligned) {
627
+ toAppend.push(
628
+ JSON.stringify({
629
+ source: group.source,
630
+ model: alignedModel,
631
+ hour_start: group.hourStart,
632
+ input_tokens: zeroTotals.input_tokens,
633
+ cached_input_tokens: zeroTotals.cached_input_tokens,
634
+ output_tokens: zeroTotals.output_tokens,
635
+ reasoning_output_tokens: zeroTotals.reasoning_output_tokens,
636
+ total_tokens: zeroTotals.total_tokens
637
+ })
638
+ );
639
+ }
640
+ if (!alignedModel && nextAligned && unknownBucket.queuedKey && unknownBucket.queuedKey !== zeroKey) {
641
+ if (unknownBucket.retractedUnknownKey !== zeroKey) {
642
+ toAppend.push(
643
+ JSON.stringify({
644
+ source: group.source,
645
+ model: DEFAULT_MODEL,
646
+ hour_start: group.hourStart,
647
+ input_tokens: zeroTotals.input_tokens,
648
+ cached_input_tokens: zeroTotals.cached_input_tokens,
649
+ output_tokens: zeroTotals.output_tokens,
650
+ reasoning_output_tokens: zeroTotals.reasoning_output_tokens,
651
+ total_tokens: zeroTotals.total_tokens
652
+ })
653
+ );
654
+ unknownBucket.retractedUnknownKey = zeroKey;
655
+ }
656
+ }
657
+ if (unknownBucket) unknownBucket.alignedModel = nextAligned;
658
+ const key = totalsKey(unknownBucket.totals);
659
+ const outputKey = outputModel === DEFAULT_MODEL ? key : `${key}|${outputModel}`;
660
+ if (unknownBucket.queuedKey === outputKey) continue;
498
661
  toAppend.push(
499
662
  JSON.stringify({
500
- source,
501
- model,
502
- hour_start: hourStart,
503
- input_tokens: bucket.totals.input_tokens,
504
- cached_input_tokens: bucket.totals.cached_input_tokens,
505
- output_tokens: bucket.totals.output_tokens,
506
- reasoning_output_tokens: bucket.totals.reasoning_output_tokens,
507
- total_tokens: bucket.totals.total_tokens
663
+ source: group.source,
664
+ model: outputModel,
665
+ hour_start: group.hourStart,
666
+ input_tokens: unknownBucket.totals.input_tokens,
667
+ cached_input_tokens: unknownBucket.totals.cached_input_tokens,
668
+ output_tokens: unknownBucket.totals.output_tokens,
669
+ reasoning_output_tokens: unknownBucket.totals.reasoning_output_tokens,
670
+ total_tokens: unknownBucket.totals.total_tokens
508
671
  })
509
672
  );
510
- bucket.queuedKey = key;
673
+ unknownBucket.queuedKey = outputKey;
511
674
  }
512
675
 
513
676
  if (legacyGroups.size > 0) {
@@ -564,6 +727,91 @@ async function enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets })
564
727
  return toAppend.length;
565
728
  }
566
729
 
730
+ function pickDominantModel(buckets) {
731
+ let dominantModel = null;
732
+ let dominantTotal = -1;
733
+ for (const [model, bucket] of buckets.entries()) {
734
+ if (model === DEFAULT_MODEL) continue;
735
+ const total = Number(bucket?.totals?.total_tokens || 0);
736
+ if (
737
+ dominantModel == null ||
738
+ total > dominantTotal ||
739
+ (total === dominantTotal && model < dominantModel)
740
+ ) {
741
+ dominantModel = model;
742
+ dominantTotal = total;
743
+ }
744
+ }
745
+ return dominantModel;
746
+ }
747
+
748
+ function cloneTotals(totals) {
749
+ const cloned = initTotals();
750
+ addTotals(cloned, totals || {});
751
+ return cloned;
752
+ }
753
+
754
+ function collectCodexDominantModels(hourlyState) {
755
+ const grouped = new Map();
756
+ for (const [key, bucket] of Object.entries(hourlyState.buckets || {})) {
757
+ if (!bucket || !bucket.totals) continue;
758
+ const parsed = parseBucketKey(key);
759
+ const hourStart = parsed.hourStart;
760
+ if (!hourStart) continue;
761
+ const source = normalizeSourceInput(parsed.source) || DEFAULT_SOURCE;
762
+ if (source !== DEFAULT_SOURCE) continue;
763
+ const model = normalizeModelInput(parsed.model) || DEFAULT_MODEL;
764
+ if (model === DEFAULT_MODEL) continue;
765
+
766
+ let models = grouped.get(hourStart);
767
+ if (!models) {
768
+ models = new Map();
769
+ grouped.set(hourStart, models);
770
+ }
771
+ const total = Number(bucket.totals.total_tokens || 0);
772
+ models.set(model, (models.get(model) || 0) + total);
773
+ }
774
+
775
+ const dominants = [];
776
+ for (const [hourStart, models] of grouped.entries()) {
777
+ let dominantModel = null;
778
+ let dominantTotal = -1;
779
+ for (const [model, total] of models.entries()) {
780
+ if (
781
+ dominantModel == null ||
782
+ total > dominantTotal ||
783
+ (total === dominantTotal && model < dominantModel)
784
+ ) {
785
+ dominantModel = model;
786
+ dominantTotal = total;
787
+ }
788
+ }
789
+ if (dominantModel) {
790
+ dominants.push({ hourStart, model: dominantModel });
791
+ }
792
+ }
793
+
794
+ return dominants;
795
+ }
796
+
797
+ function findNearestCodexModel(hourStart, dominants) {
798
+ if (!hourStart || !dominants || dominants.length === 0) return null;
799
+ const target = Date.parse(hourStart);
800
+ if (!Number.isFinite(target)) return null;
801
+
802
+ let best = null;
803
+ for (const entry of dominants) {
804
+ const candidate = Date.parse(entry.hourStart);
805
+ if (!Number.isFinite(candidate)) continue;
806
+ const diff = Math.abs(candidate - target);
807
+ if (!best || diff < best.diff || (diff === best.diff && candidate < best.time)) {
808
+ best = { diff, time: candidate, model: entry.model };
809
+ }
810
+ }
811
+
812
+ return best ? best.model : null;
813
+ }
814
+
567
815
  function normalizeHourlyState(raw) {
568
816
  const state = raw && typeof raw === 'object' ? raw : {};
569
817
  const version = Number(state.version || 1);
@@ -35,6 +35,33 @@ async function issueDeviceToken({ baseUrl, accessToken, deviceName, platform = '
35
35
  return { token, deviceId };
36
36
  }
37
37
 
38
+ async function exchangeLinkCode({ baseUrl, linkCode, requestId, deviceName, platform = 'macos' }) {
39
+ const data = await invokeFunction({
40
+ baseUrl,
41
+ accessToken: null,
42
+ slug: 'vibescore-link-code-exchange',
43
+ method: 'POST',
44
+ body: {
45
+ link_code: linkCode,
46
+ request_id: requestId,
47
+ device_name: deviceName,
48
+ platform
49
+ },
50
+ errorPrefix: 'Link code exchange failed'
51
+ });
52
+
53
+ const token = data?.token;
54
+ const deviceId = data?.device_id;
55
+ if (typeof token !== 'string' || token.length === 0) {
56
+ throw new Error('Link code exchange failed: missing token');
57
+ }
58
+ if (typeof deviceId !== 'string' || deviceId.length === 0) {
59
+ throw new Error('Link code exchange failed: missing device_id');
60
+ }
61
+
62
+ return { token, deviceId, userId: data?.user_id || null };
63
+ }
64
+
38
65
  async function ingestHourly({ baseUrl, deviceToken, hourly }) {
39
66
  const data = await invokeFunctionWithRetry({
40
67
  baseUrl,
@@ -72,6 +99,7 @@ async function syncHeartbeat({ baseUrl, deviceToken }) {
72
99
  module.exports = {
73
100
  signInWithPassword,
74
101
  issueDeviceToken,
102
+ exchangeLinkCode,
75
103
  ingestHourly,
76
104
  syncHeartbeat
77
105
  };