vibeusage 0.2.15 → 0.2.16

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.
@@ -4,6 +4,7 @@ const path = require('node:path');
4
4
  const readline = require('node:readline');
5
5
 
6
6
  const { ensureDir } = require('./fs');
7
+ const { hashRepoRoot, resolveGitHubPublicStatus } = require('./vibeusage-public-repo');
7
8
 
8
9
  const DEFAULT_SOURCE = 'codex';
9
10
  const DEFAULT_MODEL = 'unknown';
@@ -69,7 +70,15 @@ async function listOpencodeMessageFiles(storageDir) {
69
70
  return out;
70
71
  }
71
72
 
72
- async function parseRolloutIncremental({ rolloutFiles, cursors, queuePath, onProgress, source }) {
73
+ async function parseRolloutIncremental({
74
+ rolloutFiles,
75
+ cursors,
76
+ queuePath,
77
+ projectQueuePath,
78
+ onProgress,
79
+ source,
80
+ publicRepoResolver
81
+ }) {
73
82
  await ensureDir(path.dirname(queuePath));
74
83
  let filesProcessed = 0;
75
84
  let eventsAggregated = 0;
@@ -77,6 +86,11 @@ async function parseRolloutIncremental({ rolloutFiles, cursors, queuePath, onPro
77
86
  const cb = typeof onProgress === 'function' ? onProgress : null;
78
87
  const totalFiles = Array.isArray(rolloutFiles) ? rolloutFiles.length : 0;
79
88
  const hourlyState = normalizeHourlyState(cursors?.hourly);
89
+ const projectEnabled = typeof projectQueuePath === 'string' && projectQueuePath.length > 0;
90
+ const projectState = projectEnabled ? normalizeProjectState(cursors?.projectHourly) : null;
91
+ const projectTouchedBuckets = projectEnabled ? new Set() : null;
92
+ const projectMetaCache = projectEnabled ? new Map() : null;
93
+ const publicRepoCache = projectEnabled ? new Map() : null;
80
94
  const touchedBuckets = new Set();
81
95
  const defaultSource = normalizeSourceInput(source) || DEFAULT_SOURCE;
82
96
 
@@ -100,6 +114,18 @@ async function parseRolloutIncremental({ rolloutFiles, cursors, queuePath, onPro
100
114
  const lastTotal = prev && prev.inode === inode ? prev.lastTotal || null : null;
101
115
  const lastModel = prev && prev.inode === inode ? prev.lastModel || null : null;
102
116
 
117
+ const projectContext = projectEnabled
118
+ ? await resolveProjectContextForFile({
119
+ filePath,
120
+ projectMetaCache,
121
+ publicRepoCache,
122
+ publicRepoResolver,
123
+ projectState
124
+ })
125
+ : null;
126
+ const projectRef = projectContext?.projectRef || null;
127
+ const projectKey = projectContext?.projectKey || null;
128
+
103
129
  const result = await parseRolloutFile({
104
130
  filePath,
105
131
  startOffset,
@@ -107,7 +133,14 @@ async function parseRolloutIncremental({ rolloutFiles, cursors, queuePath, onPro
107
133
  lastModel,
108
134
  hourlyState,
109
135
  touchedBuckets,
110
- source: fileSource
136
+ source: fileSource,
137
+ projectState,
138
+ projectTouchedBuckets,
139
+ projectRef,
140
+ projectKey,
141
+ projectMetaCache,
142
+ publicRepoCache,
143
+ publicRepoResolver
111
144
  });
112
145
 
113
146
  cursors.files[key] = {
@@ -134,13 +167,28 @@ async function parseRolloutIncremental({ rolloutFiles, cursors, queuePath, onPro
134
167
  }
135
168
 
136
169
  const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets });
170
+ const projectBucketsQueued = projectEnabled
171
+ ? await enqueueTouchedProjectBuckets({ projectQueuePath, projectState, projectTouchedBuckets })
172
+ : 0;
137
173
  hourlyState.updatedAt = new Date().toISOString();
138
174
  cursors.hourly = hourlyState;
175
+ if (projectState) {
176
+ projectState.updatedAt = new Date().toISOString();
177
+ cursors.projectHourly = projectState;
178
+ }
139
179
 
140
- return { filesProcessed, eventsAggregated, bucketsQueued };
180
+ return { filesProcessed, eventsAggregated, bucketsQueued, projectBucketsQueued };
141
181
  }
142
182
 
143
- async function parseClaudeIncremental({ projectFiles, cursors, queuePath, onProgress, source }) {
183
+ async function parseClaudeIncremental({
184
+ projectFiles,
185
+ cursors,
186
+ queuePath,
187
+ projectQueuePath,
188
+ onProgress,
189
+ source,
190
+ publicRepoResolver
191
+ }) {
144
192
  await ensureDir(path.dirname(queuePath));
145
193
  let filesProcessed = 0;
146
194
  let eventsAggregated = 0;
@@ -149,6 +197,11 @@ async function parseClaudeIncremental({ projectFiles, cursors, queuePath, onProg
149
197
  const files = Array.isArray(projectFiles) ? projectFiles : [];
150
198
  const totalFiles = files.length;
151
199
  const hourlyState = normalizeHourlyState(cursors?.hourly);
200
+ const projectEnabled = typeof projectQueuePath === 'string' && projectQueuePath.length > 0;
201
+ const projectState = projectEnabled ? normalizeProjectState(cursors?.projectHourly) : null;
202
+ const projectTouchedBuckets = projectEnabled ? new Set() : null;
203
+ const projectMetaCache = projectEnabled ? new Map() : null;
204
+ const publicRepoCache = projectEnabled ? new Map() : null;
152
205
  const touchedBuckets = new Set();
153
206
  const defaultSource = normalizeSourceInput(source) || 'claude';
154
207
 
@@ -170,12 +223,28 @@ async function parseClaudeIncremental({ projectFiles, cursors, queuePath, onProg
170
223
  const inode = st.ino || 0;
171
224
  const startOffset = prev && prev.inode === inode ? prev.offset || 0 : 0;
172
225
 
226
+ const projectContext = projectEnabled
227
+ ? await resolveProjectContextForFile({
228
+ filePath,
229
+ projectMetaCache,
230
+ publicRepoCache,
231
+ publicRepoResolver,
232
+ projectState
233
+ })
234
+ : null;
235
+ const projectRef = projectContext?.projectRef || null;
236
+ const projectKey = projectContext?.projectKey || null;
237
+
173
238
  const result = await parseClaudeFile({
174
239
  filePath,
175
240
  startOffset,
176
241
  hourlyState,
177
242
  touchedBuckets,
178
- source: fileSource
243
+ source: fileSource,
244
+ projectState,
245
+ projectTouchedBuckets,
246
+ projectRef,
247
+ projectKey
179
248
  });
180
249
 
181
250
  cursors.files[key] = {
@@ -200,13 +269,28 @@ async function parseClaudeIncremental({ projectFiles, cursors, queuePath, onProg
200
269
  }
201
270
 
202
271
  const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets });
272
+ const projectBucketsQueued = projectEnabled
273
+ ? await enqueueTouchedProjectBuckets({ projectQueuePath, projectState, projectTouchedBuckets })
274
+ : 0;
203
275
  hourlyState.updatedAt = new Date().toISOString();
204
276
  cursors.hourly = hourlyState;
277
+ if (projectState) {
278
+ projectState.updatedAt = new Date().toISOString();
279
+ cursors.projectHourly = projectState;
280
+ }
205
281
 
206
- return { filesProcessed, eventsAggregated, bucketsQueued };
282
+ return { filesProcessed, eventsAggregated, bucketsQueued, projectBucketsQueued };
207
283
  }
208
284
 
209
- async function parseGeminiIncremental({ sessionFiles, cursors, queuePath, onProgress, source }) {
285
+ async function parseGeminiIncremental({
286
+ sessionFiles,
287
+ cursors,
288
+ queuePath,
289
+ projectQueuePath,
290
+ onProgress,
291
+ source,
292
+ publicRepoResolver
293
+ }) {
210
294
  await ensureDir(path.dirname(queuePath));
211
295
  let filesProcessed = 0;
212
296
  let eventsAggregated = 0;
@@ -215,6 +299,11 @@ async function parseGeminiIncremental({ sessionFiles, cursors, queuePath, onProg
215
299
  const files = Array.isArray(sessionFiles) ? sessionFiles : [];
216
300
  const totalFiles = files.length;
217
301
  const hourlyState = normalizeHourlyState(cursors?.hourly);
302
+ const projectEnabled = typeof projectQueuePath === 'string' && projectQueuePath.length > 0;
303
+ const projectState = projectEnabled ? normalizeProjectState(cursors?.projectHourly) : null;
304
+ const projectTouchedBuckets = projectEnabled ? new Set() : null;
305
+ const projectMetaCache = projectEnabled ? new Map() : null;
306
+ const publicRepoCache = projectEnabled ? new Map() : null;
218
307
  const touchedBuckets = new Set();
219
308
  const defaultSource = normalizeSourceInput(source) || 'gemini';
220
309
 
@@ -238,6 +327,18 @@ async function parseGeminiIncremental({ sessionFiles, cursors, queuePath, onProg
238
327
  let lastTotals = prev && prev.inode === inode ? prev.lastTotals || null : null;
239
328
  let lastModel = prev && prev.inode === inode ? prev.lastModel || null : null;
240
329
 
330
+ const projectContext = projectEnabled
331
+ ? await resolveProjectContextForFile({
332
+ filePath,
333
+ projectMetaCache,
334
+ publicRepoCache,
335
+ publicRepoResolver,
336
+ projectState
337
+ })
338
+ : null;
339
+ const projectRef = projectContext?.projectRef || null;
340
+ const projectKey = projectContext?.projectKey || null;
341
+
241
342
  const result = await parseGeminiFile({
242
343
  filePath,
243
344
  startIndex,
@@ -245,7 +346,11 @@ async function parseGeminiIncremental({ sessionFiles, cursors, queuePath, onProg
245
346
  lastModel,
246
347
  hourlyState,
247
348
  touchedBuckets,
248
- source: fileSource
349
+ source: fileSource,
350
+ projectState,
351
+ projectTouchedBuckets,
352
+ projectRef,
353
+ projectKey
249
354
  });
250
355
 
251
356
  cursors.files[key] = {
@@ -272,13 +377,28 @@ async function parseGeminiIncremental({ sessionFiles, cursors, queuePath, onProg
272
377
  }
273
378
 
274
379
  const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets });
380
+ const projectBucketsQueued = projectEnabled
381
+ ? await enqueueTouchedProjectBuckets({ projectQueuePath, projectState, projectTouchedBuckets })
382
+ : 0;
275
383
  hourlyState.updatedAt = new Date().toISOString();
276
384
  cursors.hourly = hourlyState;
385
+ if (projectState) {
386
+ projectState.updatedAt = new Date().toISOString();
387
+ cursors.projectHourly = projectState;
388
+ }
277
389
 
278
- return { filesProcessed, eventsAggregated, bucketsQueued };
390
+ return { filesProcessed, eventsAggregated, bucketsQueued, projectBucketsQueued };
279
391
  }
280
392
 
281
- async function parseOpencodeIncremental({ messageFiles, cursors, queuePath, onProgress, source }) {
393
+ async function parseOpencodeIncremental({
394
+ messageFiles,
395
+ cursors,
396
+ queuePath,
397
+ projectQueuePath,
398
+ onProgress,
399
+ source,
400
+ publicRepoResolver
401
+ }) {
282
402
  await ensureDir(path.dirname(queuePath));
283
403
  let filesProcessed = 0;
284
404
  let eventsAggregated = 0;
@@ -287,6 +407,11 @@ async function parseOpencodeIncremental({ messageFiles, cursors, queuePath, onPr
287
407
  const files = Array.isArray(messageFiles) ? messageFiles : [];
288
408
  const totalFiles = files.length;
289
409
  const hourlyState = normalizeHourlyState(cursors?.hourly);
410
+ const projectEnabled = typeof projectQueuePath === 'string' && projectQueuePath.length > 0;
411
+ const projectState = projectEnabled ? normalizeProjectState(cursors?.projectHourly) : null;
412
+ const projectTouchedBuckets = projectEnabled ? new Set() : null;
413
+ const projectMetaCache = projectEnabled ? new Map() : null;
414
+ const publicRepoCache = projectEnabled ? new Map() : null;
290
415
  const opencodeState = normalizeOpencodeState(cursors?.opencode);
291
416
  const messageIndex = opencodeState.messages;
292
417
  const touchedBuckets = new Set();
@@ -329,6 +454,18 @@ async function parseOpencodeIncremental({ messageFiles, cursors, queuePath, onPr
329
454
  const fallbackTotals = prev && typeof prev.lastTotals === 'object' ? prev.lastTotals : null;
330
455
  const fallbackMessageKey =
331
456
  prev && typeof prev.messageKey === 'string' && prev.messageKey.trim() ? prev.messageKey.trim() : null;
457
+ const projectContext = projectEnabled
458
+ ? await resolveProjectContextForFile({
459
+ filePath,
460
+ projectMetaCache,
461
+ publicRepoCache,
462
+ publicRepoResolver,
463
+ projectState
464
+ })
465
+ : null;
466
+ const projectRef = projectContext?.projectRef || null;
467
+ const projectKey = projectContext?.projectKey || null;
468
+
332
469
  const result = await parseOpencodeMessageFile({
333
470
  filePath,
334
471
  messageIndex,
@@ -336,7 +473,11 @@ async function parseOpencodeIncremental({ messageFiles, cursors, queuePath, onPr
336
473
  fallbackMessageKey,
337
474
  hourlyState,
338
475
  touchedBuckets,
339
- source: fileSource
476
+ source: fileSource,
477
+ projectState,
478
+ projectTouchedBuckets,
479
+ projectRef,
480
+ projectKey
340
481
  });
341
482
 
342
483
  cursors.files[key] = {
@@ -371,12 +512,19 @@ async function parseOpencodeIncremental({ messageFiles, cursors, queuePath, onPr
371
512
  }
372
513
 
373
514
  const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets });
515
+ const projectBucketsQueued = projectEnabled
516
+ ? await enqueueTouchedProjectBuckets({ projectQueuePath, projectState, projectTouchedBuckets })
517
+ : 0;
374
518
  hourlyState.updatedAt = new Date().toISOString();
375
519
  cursors.hourly = hourlyState;
376
520
  opencodeState.updatedAt = new Date().toISOString();
377
521
  cursors.opencode = opencodeState;
522
+ if (projectState) {
523
+ projectState.updatedAt = new Date().toISOString();
524
+ cursors.projectHourly = projectState;
525
+ }
378
526
 
379
- return { filesProcessed, eventsAggregated, bucketsQueued };
527
+ return { filesProcessed, eventsAggregated, bucketsQueued, projectBucketsQueued };
380
528
  }
381
529
 
382
530
  async function parseRolloutFile({
@@ -386,7 +534,14 @@ async function parseRolloutFile({
386
534
  lastModel,
387
535
  hourlyState,
388
536
  touchedBuckets,
389
- source
537
+ source,
538
+ projectState,
539
+ projectTouchedBuckets,
540
+ projectRef,
541
+ projectKey,
542
+ projectMetaCache,
543
+ publicRepoCache,
544
+ publicRepoResolver
390
545
  }) {
391
546
  const st = await fs.stat(filePath);
392
547
  const endOffset = st.size;
@@ -399,12 +554,18 @@ async function parseRolloutFile({
399
554
 
400
555
  let model = typeof lastModel === 'string' ? lastModel : null;
401
556
  let totals = lastTotal && typeof lastTotal === 'object' ? lastTotal : null;
557
+ let currentCwd = null;
558
+ let currentProjectRef = projectRef || null;
559
+ let currentProjectKey = projectKey || null;
402
560
  let eventsAggregated = 0;
403
561
 
404
562
  for await (const line of rl) {
405
563
  if (!line) continue;
406
564
  const maybeTokenCount = line.includes('"token_count"');
407
- const maybeTurnContext = !maybeTokenCount && line.includes('"turn_context"') && line.includes('"model"');
565
+ const maybeTurnContext =
566
+ !maybeTokenCount &&
567
+ (line.includes('"turn_context"') || line.includes('"session_meta"')) &&
568
+ (line.includes('"model"') || line.includes('"cwd"'));
408
569
  if (!maybeTokenCount && !maybeTurnContext) continue;
409
570
 
410
571
  let obj;
@@ -414,8 +575,29 @@ async function parseRolloutFile({
414
575
  continue;
415
576
  }
416
577
 
417
- if (obj?.type === 'turn_context' && obj?.payload && typeof obj.payload.model === 'string') {
418
- model = obj.payload.model;
578
+ if (
579
+ (obj?.type === 'turn_context' || obj?.type === 'session_meta') &&
580
+ obj?.payload &&
581
+ typeof obj.payload === 'object'
582
+ ) {
583
+ if (typeof obj.payload.model === 'string') {
584
+ model = obj.payload.model;
585
+ }
586
+ if (projectState && typeof obj.payload.cwd === 'string') {
587
+ const nextCwd = obj.payload.cwd.trim();
588
+ if (nextCwd && nextCwd !== currentCwd) {
589
+ const context = await resolveProjectContextForPath({
590
+ startDir: nextCwd,
591
+ projectMetaCache,
592
+ publicRepoCache,
593
+ publicRepoResolver,
594
+ projectState
595
+ });
596
+ currentCwd = nextCwd;
597
+ currentProjectRef = context?.projectRef || null;
598
+ currentProjectKey = context?.projectKey || null;
599
+ }
600
+ }
419
601
  continue;
420
602
  }
421
603
 
@@ -444,13 +626,34 @@ async function parseRolloutFile({
444
626
  const bucket = getHourlyBucket(hourlyState, source, model, bucketStart);
445
627
  addTotals(bucket.totals, delta);
446
628
  touchedBuckets.add(bucketKey(source, model, bucketStart));
629
+ if (currentProjectKey && projectState && projectTouchedBuckets) {
630
+ const projectBucket = getProjectBucket(
631
+ projectState,
632
+ currentProjectKey,
633
+ source,
634
+ bucketStart,
635
+ currentProjectRef
636
+ );
637
+ addTotals(projectBucket.totals, delta);
638
+ projectTouchedBuckets.add(projectBucketKey(currentProjectKey, source, bucketStart));
639
+ }
447
640
  eventsAggregated += 1;
448
641
  }
449
642
 
450
643
  return { endOffset, lastTotal: totals, lastModel: model, eventsAggregated };
451
644
  }
452
645
 
453
- async function parseClaudeFile({ filePath, startOffset, hourlyState, touchedBuckets, source }) {
646
+ async function parseClaudeFile({
647
+ filePath,
648
+ startOffset,
649
+ hourlyState,
650
+ touchedBuckets,
651
+ source,
652
+ projectState,
653
+ projectTouchedBuckets,
654
+ projectRef,
655
+ projectKey
656
+ }) {
454
657
  const st = await fs.stat(filePath).catch(() => null);
455
658
  if (!st || !st.isFile()) return { endOffset: startOffset, eventsAggregated: 0 };
456
659
 
@@ -486,6 +689,11 @@ async function parseClaudeFile({ filePath, startOffset, hourlyState, touchedBuck
486
689
  const bucket = getHourlyBucket(hourlyState, source, model, bucketStart);
487
690
  addTotals(bucket.totals, delta);
488
691
  touchedBuckets.add(bucketKey(source, model, bucketStart));
692
+ if (projectKey && projectState && projectTouchedBuckets) {
693
+ const projectBucket = getProjectBucket(projectState, projectKey, source, bucketStart, projectRef);
694
+ addTotals(projectBucket.totals, delta);
695
+ projectTouchedBuckets.add(projectBucketKey(projectKey, source, bucketStart));
696
+ }
489
697
  eventsAggregated += 1;
490
698
  }
491
699
 
@@ -501,7 +709,11 @@ async function parseGeminiFile({
501
709
  lastModel,
502
710
  hourlyState,
503
711
  touchedBuckets,
504
- source
712
+ source,
713
+ projectState,
714
+ projectTouchedBuckets,
715
+ projectRef,
716
+ projectKey
505
717
  }) {
506
718
  const raw = await fs.readFile(filePath, 'utf8').catch(() => '');
507
719
  if (!raw.trim()) return { lastIndex: startIndex, lastTotals, lastModel, eventsAggregated: 0 };
@@ -554,6 +766,11 @@ async function parseGeminiFile({
554
766
  const bucket = getHourlyBucket(hourlyState, source, model, bucketStart);
555
767
  addTotals(bucket.totals, delta);
556
768
  touchedBuckets.add(bucketKey(source, model, bucketStart));
769
+ if (projectKey && projectState && projectTouchedBuckets) {
770
+ const projectBucket = getProjectBucket(projectState, projectKey, source, bucketStart, projectRef);
771
+ addTotals(projectBucket.totals, delta);
772
+ projectTouchedBuckets.add(projectBucketKey(projectKey, source, bucketStart));
773
+ }
557
774
  eventsAggregated += 1;
558
775
  totals = currentTotals;
559
776
  }
@@ -573,7 +790,11 @@ async function parseOpencodeMessageFile({
573
790
  fallbackMessageKey,
574
791
  hourlyState,
575
792
  touchedBuckets,
576
- source
793
+ source,
794
+ projectState,
795
+ projectTouchedBuckets,
796
+ projectRef,
797
+ projectKey
577
798
  }) {
578
799
  const fallbackKey =
579
800
  typeof fallbackMessageKey === 'string' && fallbackMessageKey.trim() ? fallbackMessageKey.trim() : null;
@@ -645,6 +866,11 @@ async function parseOpencodeMessageFile({
645
866
  const bucket = getHourlyBucket(hourlyState, source, model, bucketStart);
646
867
  addTotals(bucket.totals, delta);
647
868
  touchedBuckets.add(bucketKey(source, model, bucketStart));
869
+ if (projectKey && projectState && projectTouchedBuckets) {
870
+ const projectBucket = getProjectBucket(projectState, projectKey, source, bucketStart, projectRef);
871
+ addTotals(projectBucket.totals, delta);
872
+ projectTouchedBuckets.add(projectBucketKey(projectKey, source, bucketStart));
873
+ }
648
874
  return { messageKey, lastTotals: currentTotals, eventsAggregated: 1, shouldUpdate: true };
649
875
  }
650
876
 
@@ -918,6 +1144,45 @@ async function enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets })
918
1144
  return toAppend.length;
919
1145
  }
920
1146
 
1147
+ async function enqueueTouchedProjectBuckets({ projectQueuePath, projectState, projectTouchedBuckets }) {
1148
+ if (!projectQueuePath || !projectState || !projectTouchedBuckets || projectTouchedBuckets.size === 0) return 0;
1149
+
1150
+ await ensureDir(path.dirname(projectQueuePath));
1151
+
1152
+ const toAppend = [];
1153
+ for (const key of projectTouchedBuckets) {
1154
+ const bucket = projectState.buckets[key];
1155
+ if (!bucket || !bucket.totals) continue;
1156
+ const totals = bucket.totals;
1157
+ const queuedKey = totalsKey(totals);
1158
+ if (bucket.queuedKey === queuedKey) continue;
1159
+ const projectRef = typeof bucket.project_ref === 'string' ? bucket.project_ref : null;
1160
+ const projectKey = typeof bucket.project_key === 'string' ? bucket.project_key : null;
1161
+ if (!projectRef || !projectKey) continue;
1162
+
1163
+ toAppend.push(
1164
+ JSON.stringify({
1165
+ project_ref: projectRef,
1166
+ project_key: projectKey,
1167
+ source: bucket.source,
1168
+ hour_start: bucket.hour_start,
1169
+ input_tokens: totals.input_tokens,
1170
+ cached_input_tokens: totals.cached_input_tokens,
1171
+ output_tokens: totals.output_tokens,
1172
+ reasoning_output_tokens: totals.reasoning_output_tokens,
1173
+ total_tokens: totals.total_tokens
1174
+ })
1175
+ );
1176
+ bucket.queuedKey = queuedKey;
1177
+ }
1178
+
1179
+ if (toAppend.length > 0) {
1180
+ await fs.appendFile(projectQueuePath, toAppend.join('\n') + '\n', 'utf8');
1181
+ }
1182
+
1183
+ return toAppend.length;
1184
+ }
1185
+
921
1186
  function pickDominantModel(buckets) {
922
1187
  let dominantModel = null;
923
1188
  let dominantTotal = -1;
@@ -1049,6 +1314,31 @@ function normalizeHourlyState(raw) {
1049
1314
  };
1050
1315
  }
1051
1316
 
1317
+ function normalizeProjectState(raw) {
1318
+ const state = raw && typeof raw === 'object' ? raw : {};
1319
+ const rawBuckets = state.buckets && typeof state.buckets === 'object' ? state.buckets : {};
1320
+ const buckets = {};
1321
+ const rawProjects = state.projects && typeof state.projects === 'object' ? state.projects : {};
1322
+ const projects = {};
1323
+
1324
+ for (const [key, value] of Object.entries(rawBuckets)) {
1325
+ if (!key) continue;
1326
+ buckets[key] = value;
1327
+ }
1328
+
1329
+ for (const [key, value] of Object.entries(rawProjects)) {
1330
+ if (!key || !value || typeof value !== 'object') continue;
1331
+ projects[key] = { ...value };
1332
+ }
1333
+
1334
+ return {
1335
+ version: 2,
1336
+ buckets,
1337
+ projects,
1338
+ updatedAt: typeof state.updatedAt === 'string' ? state.updatedAt : null
1339
+ };
1340
+ }
1341
+
1052
1342
  function normalizeOpencodeState(raw) {
1053
1343
  const state = raw && typeof raw === 'object' ? raw : {};
1054
1344
  const messages = state.messages && typeof state.messages === 'object' ? state.messages : {};
@@ -1093,6 +1383,40 @@ function getHourlyBucket(state, source, model, hourStart) {
1093
1383
  return bucket;
1094
1384
  }
1095
1385
 
1386
+ function getProjectBucket(state, projectKey, source, hourStart, projectRef) {
1387
+ const buckets = state.buckets;
1388
+ const normalizedSource = normalizeSourceInput(source) || DEFAULT_SOURCE;
1389
+ const key = projectBucketKey(projectKey, normalizedSource, hourStart);
1390
+ let bucket = buckets[key];
1391
+ if (!bucket || typeof bucket !== 'object') {
1392
+ bucket = {
1393
+ totals: initTotals(),
1394
+ queuedKey: null,
1395
+ project_key: projectKey,
1396
+ project_ref: projectRef,
1397
+ source: normalizedSource,
1398
+ hour_start: hourStart
1399
+ };
1400
+ buckets[key] = bucket;
1401
+ return bucket;
1402
+ }
1403
+
1404
+ if (!bucket.totals || typeof bucket.totals !== 'object') {
1405
+ bucket.totals = initTotals();
1406
+ }
1407
+
1408
+ if (bucket.queuedKey != null && typeof bucket.queuedKey !== 'string') {
1409
+ bucket.queuedKey = null;
1410
+ }
1411
+
1412
+ if (projectRef) bucket.project_ref = projectRef;
1413
+ if (projectKey) bucket.project_key = projectKey;
1414
+ bucket.source = normalizedSource;
1415
+ bucket.hour_start = hourStart;
1416
+
1417
+ return bucket;
1418
+ }
1419
+
1096
1420
  function initTotals() {
1097
1421
  return {
1098
1422
  input_tokens: 0,
@@ -1146,6 +1470,11 @@ function bucketKey(source, model, hourStart) {
1146
1470
  return `${safeSource}${BUCKET_SEPARATOR}${safeModel}${BUCKET_SEPARATOR}${hourStart}`;
1147
1471
  }
1148
1472
 
1473
+ function projectBucketKey(projectKey, source, hourStart) {
1474
+ const safeSource = normalizeSourceInput(source) || DEFAULT_SOURCE;
1475
+ return `${projectKey}${BUCKET_SEPARATOR}${safeSource}${BUCKET_SEPARATOR}${hourStart}`;
1476
+ }
1477
+
1149
1478
  function groupBucketKey(source, hourStart) {
1150
1479
  const safeSource = normalizeSourceInput(source) || DEFAULT_SOURCE;
1151
1480
  return `${safeSource}${BUCKET_SEPARATOR}${hourStart}`;
@@ -1178,6 +1507,246 @@ function normalizeModelInput(value) {
1178
1507
  return trimmed.length > 0 ? trimmed : null;
1179
1508
  }
1180
1509
 
1510
+ async function resolveProjectMetaForPath(startDir, cache) {
1511
+ if (!startDir || typeof startDir !== 'string') return null;
1512
+ if (cache && cache.has(startDir)) return cache.get(startDir);
1513
+
1514
+ const visited = [];
1515
+ let current = startDir;
1516
+ const root = path.parse(startDir).root;
1517
+ while (current) {
1518
+ if (cache && cache.has(current)) {
1519
+ const cached = cache.get(current);
1520
+ for (const entry of visited) cache.set(entry, cached);
1521
+ return cached;
1522
+ }
1523
+ visited.push(current);
1524
+
1525
+ const configPath = await resolveGitConfigPath(current);
1526
+ if (configPath) {
1527
+ const remoteUrl = await readGitRemoteUrl(configPath);
1528
+ const projectRef = canonicalizeProjectRef(remoteUrl);
1529
+ const meta = { projectRef: projectRef || null, repoRoot: current };
1530
+ if (cache) {
1531
+ for (const entry of visited) cache.set(entry, meta);
1532
+ }
1533
+ return meta;
1534
+ }
1535
+
1536
+ if (current === root) break;
1537
+ const parent = path.dirname(current);
1538
+ if (!parent || parent === current) break;
1539
+ current = parent;
1540
+ }
1541
+
1542
+ if (cache) {
1543
+ for (const entry of visited) cache.set(entry, null);
1544
+ }
1545
+ return null;
1546
+ }
1547
+
1548
+ async function defaultPublicRepoResolver({ projectRef, repoRoot, cache }) {
1549
+ if (!projectRef) {
1550
+ return {
1551
+ status: 'blocked',
1552
+ projectKey: null,
1553
+ projectRef: null,
1554
+ repoRootHash: repoRoot ? hashRepoRoot(repoRoot) : null,
1555
+ reason: 'missing_ref'
1556
+ };
1557
+ }
1558
+
1559
+ const cached = cache && cache.has(projectRef) ? cache.get(projectRef) : null;
1560
+ const base = cached || (await resolveGitHubPublicStatus(projectRef));
1561
+ if (cache && !cached) cache.set(projectRef, base);
1562
+ return {
1563
+ ...base,
1564
+ repoRootHash: repoRoot ? hashRepoRoot(repoRoot) : null
1565
+ };
1566
+ }
1567
+
1568
+ function recordProjectMeta(projectState, meta) {
1569
+ if (!projectState || !meta || typeof meta !== 'object') return;
1570
+ const repoRootHash = typeof meta.repoRootHash === 'string' ? meta.repoRootHash : null;
1571
+ let projectKey = typeof meta.projectKey === 'string' ? meta.projectKey : null;
1572
+ if (!projectKey && repoRootHash && projectState.projects && typeof projectState.projects === 'object') {
1573
+ for (const [key, entry] of Object.entries(projectState.projects)) {
1574
+ if (entry && entry.repo_root_hash === repoRootHash) {
1575
+ projectKey = key;
1576
+ break;
1577
+ }
1578
+ }
1579
+ }
1580
+ if (!projectKey) return;
1581
+ if (!projectState.projects || typeof projectState.projects !== 'object') {
1582
+ projectState.projects = {};
1583
+ }
1584
+ const prev = projectState.projects[projectKey] || {};
1585
+ const status = typeof meta.status === 'string' ? meta.status : null;
1586
+ const projectRef = typeof meta.projectRef === 'string' ? meta.projectRef : null;
1587
+ const next = {
1588
+ ...prev,
1589
+ project_ref: projectRef || prev.project_ref || null,
1590
+ status: status || prev.status || null,
1591
+ repo_root_hash: repoRootHash || prev.repo_root_hash || null,
1592
+ updated_at: new Date().toISOString()
1593
+ };
1594
+ if (status === 'blocked' && prev.status !== 'blocked') {
1595
+ next.purge_pending = true;
1596
+ } else if (status && status !== 'blocked') {
1597
+ next.purge_pending = false;
1598
+ }
1599
+ projectState.projects[projectKey] = next;
1600
+ }
1601
+
1602
+ async function resolveProjectContextForFile({
1603
+ filePath,
1604
+ projectMetaCache,
1605
+ publicRepoCache,
1606
+ publicRepoResolver,
1607
+ projectState
1608
+ }) {
1609
+ if (!filePath) return null;
1610
+ return resolveProjectContextForPath({
1611
+ startDir: path.dirname(filePath),
1612
+ projectMetaCache,
1613
+ publicRepoCache,
1614
+ publicRepoResolver,
1615
+ projectState
1616
+ });
1617
+ }
1618
+
1619
+ async function resolveProjectContextForPath({
1620
+ startDir,
1621
+ projectMetaCache,
1622
+ publicRepoCache,
1623
+ publicRepoResolver,
1624
+ projectState
1625
+ }) {
1626
+ if (!startDir) return null;
1627
+ const projectMeta = await resolveProjectMetaForPath(startDir, projectMetaCache);
1628
+ if (!projectMeta) return null;
1629
+ const resolver = typeof publicRepoResolver === 'function' ? publicRepoResolver : defaultPublicRepoResolver;
1630
+ const meta = await resolver({
1631
+ projectRef: projectMeta.projectRef,
1632
+ repoRoot: projectMeta.repoRoot,
1633
+ cache: publicRepoCache
1634
+ });
1635
+ const repoRootHash = projectMeta.repoRoot ? hashRepoRoot(projectMeta.repoRoot) : null;
1636
+ const normalized = {
1637
+ ...(meta || {}),
1638
+ projectRef: meta?.projectRef || projectMeta.projectRef,
1639
+ projectKey: meta?.projectKey || null,
1640
+ status: meta?.status || 'blocked',
1641
+ repoRootHash: meta?.repoRootHash || repoRootHash
1642
+ };
1643
+ recordProjectMeta(projectState, normalized);
1644
+ if (normalized.status !== 'public_verified') {
1645
+ return { projectRef: normalized.projectRef, projectKey: null, status: normalized.status };
1646
+ }
1647
+ return { projectRef: normalized.projectRef, projectKey: normalized.projectKey, status: normalized.status };
1648
+ }
1649
+
1650
+ async function resolveGitConfigPath(rootDir) {
1651
+ const gitPath = path.join(rootDir, '.git');
1652
+ const st = await fs.stat(gitPath).catch(() => null);
1653
+ if (!st) return null;
1654
+ if (st.isDirectory()) {
1655
+ const configPath = path.join(gitPath, 'config');
1656
+ const cfg = await fs.stat(configPath).catch(() => null);
1657
+ return cfg && cfg.isFile() ? configPath : null;
1658
+ }
1659
+ if (st.isFile()) {
1660
+ const content = await fs.readFile(gitPath, 'utf8').catch(() => '');
1661
+ const match = content.match(/gitdir:\s*(.+)/i);
1662
+ if (!match) return null;
1663
+ let gitDir = match[1].trim();
1664
+ if (!gitDir) return null;
1665
+ if (!path.isAbsolute(gitDir)) {
1666
+ gitDir = path.resolve(rootDir, gitDir);
1667
+ }
1668
+ const configPath = path.join(gitDir, 'config');
1669
+ const cfg = await fs.stat(configPath).catch(() => null);
1670
+ if (cfg && cfg.isFile()) return configPath;
1671
+
1672
+ const commonDirRaw = await fs.readFile(path.join(gitDir, 'commondir'), 'utf8').catch(() => '');
1673
+ const commonDirRel = commonDirRaw.trim();
1674
+ if (!commonDirRel) return null;
1675
+ let commonDir = commonDirRel;
1676
+ if (!path.isAbsolute(commonDir)) {
1677
+ commonDir = path.resolve(gitDir, commonDir);
1678
+ }
1679
+ const commonConfigPath = path.join(commonDir, 'config');
1680
+ const commonCfg = await fs.stat(commonConfigPath).catch(() => null);
1681
+ return commonCfg && commonCfg.isFile() ? commonConfigPath : null;
1682
+ }
1683
+ return null;
1684
+ }
1685
+
1686
+ async function readGitRemoteUrl(configPath) {
1687
+ const raw = await fs.readFile(configPath, 'utf8').catch(() => '');
1688
+ if (!raw.trim()) return null;
1689
+
1690
+ const remotes = new Map();
1691
+ let current = null;
1692
+ for (const line of raw.split(/\r?\n/)) {
1693
+ const sectionHeader = line.match(/^\s*\[[^\]]+\]\s*$/);
1694
+ if (sectionHeader) {
1695
+ const sectionMatch = line.match(/^\s*\[remote\s+"([^"]+)"\]\s*$/i);
1696
+ current = sectionMatch ? sectionMatch[1] : null;
1697
+ continue;
1698
+ }
1699
+ if (!current) continue;
1700
+ const urlMatch = line.match(/^\s*url\s*=\s*(.+)\s*$/i);
1701
+ if (urlMatch) {
1702
+ remotes.set(current, urlMatch[1].trim());
1703
+ }
1704
+ }
1705
+
1706
+ if (remotes.has('origin')) return remotes.get('origin');
1707
+ const first = remotes.values().next();
1708
+ return first.done ? null : first.value;
1709
+ }
1710
+
1711
+ function canonicalizeProjectRef(remoteUrl) {
1712
+ if (typeof remoteUrl !== 'string') return null;
1713
+ let ref = remoteUrl.trim();
1714
+ if (!ref) return null;
1715
+
1716
+ if (ref.startsWith('file://')) return null;
1717
+ if (path.isAbsolute(ref) || path.win32.isAbsolute(ref)) return null;
1718
+
1719
+ const gitAtMatch = ref.match(/^git@([^:]+):(.+)$/i);
1720
+ if (gitAtMatch) {
1721
+ ref = `https://${gitAtMatch[1]}/${gitAtMatch[2]}`;
1722
+ } else if (ref.startsWith('ssh://')) {
1723
+ try {
1724
+ const parsed = new URL(ref);
1725
+ ref = `https://${parsed.hostname}${parsed.pathname}`;
1726
+ } catch (_e) {
1727
+ return null;
1728
+ }
1729
+ } else if (ref.startsWith('git://')) {
1730
+ ref = `https://${ref.slice('git://'.length)}`;
1731
+ } else if (ref.startsWith('http://')) {
1732
+ ref = `https://${ref.slice('http://'.length)}`;
1733
+ } else if (!ref.startsWith('https://')) {
1734
+ return null;
1735
+ }
1736
+
1737
+ try {
1738
+ const parsed = new URL(ref);
1739
+ if (!parsed.hostname) return null;
1740
+ ref = `https://${parsed.hostname}${parsed.pathname}`;
1741
+ } catch (_e) {
1742
+ return null;
1743
+ }
1744
+
1745
+ ref = ref.replace(/\.git$/i, '');
1746
+ ref = ref.replace(/\/+$/, '');
1747
+ return ref || null;
1748
+ }
1749
+
1181
1750
  function normalizeGeminiTokens(tokens) {
1182
1751
  if (!tokens || typeof tokens !== 'object') return null;
1183
1752
  const input = toNonNegativeInt(tokens.input);