tokentracker-cli 0.93.6 → 0.94.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 (133) hide show
  1. package/README.de.md +1 -1
  2. package/README.ja.md +1 -1
  3. package/README.ko.md +1 -1
  4. package/README.md +1 -1
  5. package/README.zh-CN.md +1 -1
  6. package/dashboard/dist/assets/{AchievementBadge-DsAclDw6.js → AchievementBadge-DPqwk2zI.js} +1 -1
  7. package/dashboard/dist/assets/{AchievementsPage-D__EwC45.js → AchievementsPage-CELHoMQt.js} +2 -2
  8. package/dashboard/dist/assets/{AchievementsSection-BAZNM3Cn.js → AchievementsSection-DdAC0VCb.js} +1 -1
  9. package/dashboard/dist/assets/BadgeDetailModal-Dd4I1SpM.js +1 -0
  10. package/dashboard/dist/assets/{BotAnimated-CwrScgrI.js → BotAnimated-DL2-4mnm.js} +1 -1
  11. package/dashboard/dist/assets/{Card-D7GmLoqP.js → Card-B_MpSRse.js} +1 -1
  12. package/dashboard/dist/assets/{ClawdAnimated-BEXgb5J_.js → ClawdAnimated-BLZZ4rG6.js} +2 -2
  13. package/dashboard/dist/assets/{CommandPalette-B9Mn3Qt2.js → CommandPalette-B-vfdIbH.js} +1 -1
  14. package/dashboard/dist/assets/CommunityStatsModal-aAico565.js +1 -0
  15. package/dashboard/dist/assets/ConfirmModal-DLnRwUXC.js +1 -0
  16. package/dashboard/dist/assets/{Controls-DxnaHBzv.js → Controls-FZ8wEN5w.js} +1 -1
  17. package/dashboard/dist/assets/DashboardPage-BByRBsbt.js +1 -0
  18. package/dashboard/dist/assets/{DevicePage-4yTYf29M.js → DevicePage-D2IzPc5o.js} +1 -1
  19. package/dashboard/dist/assets/{DialogClose-BbderFCY.js → DialogClose-BaqQaUUk.js} +1 -1
  20. package/dashboard/dist/assets/DialogDescription-BUrh0Jod.js +1 -0
  21. package/dashboard/dist/assets/DialogTitle-DrbKjtuS.js +1 -0
  22. package/dashboard/dist/assets/{FadeIn-BFLojPGG.js → FadeIn-GddKDE8i.js} +1 -1
  23. package/dashboard/dist/assets/{HeaderGithubStar-CPBNn61n.js → HeaderGithubStar-DUu-Z5mE.js} +1 -1
  24. package/dashboard/dist/assets/{HoverTooltip-DbxnLj8e.js → HoverTooltip-BmDkY35A.js} +1 -1
  25. package/dashboard/dist/assets/{IpCheckPage-CfPtXAAF.js → IpCheckPage-CGJyqWk2.js} +1 -1
  26. package/dashboard/dist/assets/{LandingPage-CocswfFj.js → LandingPage-Bd91ALUj.js} +2 -2
  27. package/dashboard/dist/assets/{LeaderboardAvatar-iZLxxZX-.js → LeaderboardAvatar-BGGUwXFd.js} +1 -1
  28. package/dashboard/dist/assets/LeaderboardPage-BMEqHwif.js +2 -0
  29. package/dashboard/dist/assets/{LeaderboardProfileModal-BWu8jbBW.js → LeaderboardProfileModal-BSweJqm2.js} +3 -3
  30. package/dashboard/dist/assets/{LeaderboardProfilePage-B77gOTzr.js → LeaderboardProfilePage-CRrI3RFz.js} +1 -1
  31. package/dashboard/dist/assets/LimitsPage-XIRDv46K.js +5 -0
  32. package/dashboard/dist/assets/LocalOnlyNotice-CCD-lGWG.js +1 -0
  33. package/dashboard/dist/assets/{LoginCard-D592L-fh.js → LoginCard-lDuqSv4f.js} +1 -1
  34. package/dashboard/dist/assets/{LoginModal-C7Qm7CVf.js → LoginModal-DStt5VXN.js} +1 -1
  35. package/dashboard/dist/assets/{LoginPage-DkYw5ZQK.js → LoginPage-dmVPSL72.js} +1 -1
  36. package/dashboard/dist/assets/PetPage-ybcZRctF.js +1 -0
  37. package/dashboard/dist/assets/PopoverPopup-Dar_UjxF.js +1 -0
  38. package/dashboard/dist/assets/{ProviderIcon-d0_vhIkS.js → ProviderIcon-Ct9tILpz.js} +1 -1
  39. package/dashboard/dist/assets/{ResetPasswordPage-UDNlpFVu.js → ResetPasswordPage-B4yLh5zx.js} +1 -1
  40. package/dashboard/dist/assets/SegmentedControl-BOE1CEzq.js +1 -0
  41. package/dashboard/dist/assets/{Select-Z2xTQ55f.js → Select-D-bSh7Zz.js} +1 -1
  42. package/dashboard/dist/assets/SelectItemText-DMYjXXq4.js +1 -0
  43. package/dashboard/dist/assets/{ServiceStatusPage-Cfr-QDZ7.js → ServiceStatusPage-CaleS-6_.js} +1 -1
  44. package/dashboard/dist/assets/SessionsPage-DOaY6_E9.js +2 -0
  45. package/dashboard/dist/assets/SettingsPage-iJH0Yyah.js +2 -0
  46. package/dashboard/dist/assets/SkillsPage-BkzJOBLk.js +1 -0
  47. package/dashboard/dist/assets/{TokenGalaxy-CjAY6rey.js → TokenGalaxy-CuXKoMPg.js} +1 -1
  48. package/dashboard/dist/assets/ToolbarRootContext-Dx5GB7F5.js +1 -0
  49. package/dashboard/dist/assets/{TrendMonitor-DAAlILwJ.js → TrendMonitor-BuKmsznP.js} +1 -1
  50. package/dashboard/dist/assets/WidgetsPage-DUaIyGtu.js +1 -0
  51. package/dashboard/dist/assets/{WrappedPage-ByYzOeZ-.js → WrappedPage-BzqlcAUC.js} +1 -1
  52. package/dashboard/dist/assets/__vite-browser-external-BHz2N7Ih.css +1 -0
  53. package/dashboard/dist/assets/{__vite-browser-external-HdCplPKs.js → __vite-browser-external-CVIGknXl.js} +3 -3
  54. package/dashboard/dist/assets/{agent-logos-BW-ajJ7p.js → agent-logos-BdWWxza6.js} +1 -1
  55. package/dashboard/dist/assets/{arrow-up-right-CA5IJkqo.js → arrow-up-right-CANSzmm-.js} +1 -1
  56. package/dashboard/dist/assets/{check-BBjF3bSm.js → check-BnJpzXRZ.js} +1 -1
  57. package/dashboard/dist/assets/{chevron-right-DCvGp9iJ.js → chevron-right-DJm0mbG0.js} +1 -1
  58. package/dashboard/dist/assets/{useScrollLock-BUVUIPTX.js → composite-Ccj6KQmh.js} +1 -1
  59. package/dashboard/dist/assets/{copy-ahF8LGeu.js → copy-C2Yc8Evu.js} +1 -1
  60. package/dashboard/dist/assets/{download-DBm8dmvt.js → download-B52JTk6Y.js} +1 -1
  61. package/dashboard/dist/assets/{external-link-C1sJSL7J.js → external-link-DayTwZx-.js} +1 -1
  62. package/dashboard/dist/assets/{flame-B6xukEYd.js → flame-B8GEX2yT.js} +1 -1
  63. package/dashboard/dist/assets/{format-tokens-6TqXlT3q.js → format-tokens-mhkbohkb.js} +1 -1
  64. package/dashboard/dist/assets/{icons-pGe_pInY.js → icons-o8bPXqRP.js} +1 -1
  65. package/dashboard/dist/assets/{index-I9O8xChC.js → index-Bp0ZHDtz.js} +1 -1
  66. package/dashboard/dist/assets/{index-Chb8mb2X.js → index-CK_CduLm.js} +1 -1
  67. package/dashboard/dist/assets/{index-RPV0zdZ9.js → index-MBVWUKpA.js} +1 -1
  68. package/dashboard/dist/assets/{info-DBfJv0yu.js → info-D94QM_vX.js} +1 -1
  69. package/dashboard/dist/assets/{limits-providers-DPQsdVq5.js → limits-providers-BLzMyCiy.js} +1 -1
  70. package/dashboard/dist/assets/{link-2-DNEARrPY.js → link-2-CqBmFaDI.js} +1 -1
  71. package/dashboard/dist/assets/{loader-circle-BCFHUr8X.js → loader-circle-SaxUpDR6.js} +1 -1
  72. package/dashboard/dist/assets/{maximize-2-1EmlA2R-.js → maximize-2-BoXqARks.js} +1 -1
  73. package/dashboard/dist/assets/{modifiers.esm-DGXFSuEq.js → modifiers.esm-Conh_-BD.js} +1 -1
  74. package/dashboard/dist/assets/{monitor-smartphone-DsPPhvsT.js → monitor-smartphone-D6qLR9qd.js} +1 -1
  75. package/dashboard/dist/assets/plus-D51mjIc2.js +1 -0
  76. package/dashboard/dist/assets/{provider-display-C20LtBC5.js → provider-display-DpHPjd_s.js} +1 -1
  77. package/dashboard/dist/assets/{react-B4FPJgs_.js → react-D_AcI466.js} +1 -1
  78. package/dashboard/dist/assets/{search-mkIFPKzW.js → search-CwLcNdvZ.js} +1 -1
  79. package/dashboard/dist/assets/{skills-api-D24vlu2H.js → skills-api-Dqi7Mjgo.js} +1 -1
  80. package/dashboard/dist/assets/{sortable.esm-BNDcP0sW.js → sortable.esm-BPDlUxPb.js} +1 -1
  81. package/dashboard/dist/assets/store-ikeRIRGd.js +1 -0
  82. package/dashboard/dist/assets/{terminal-BxVMmvEx.js → terminal-BfeX3L5T.js} +1 -1
  83. package/dashboard/dist/assets/{trash-2-crt9lDvj.js → trash-2-5_Qlp1k7.js} +1 -1
  84. package/dashboard/dist/assets/{use-community-stats-CTB_uHWV.js → use-community-stats-D90Fy2_j.js} +1 -1
  85. package/dashboard/dist/assets/use-limits-display-prefs-D4BYFaa-.js +1 -0
  86. package/dashboard/dist/assets/{use-ordered-list-BVNb5QWU.js → use-ordered-list-DJxLNdXY.js} +1 -1
  87. package/dashboard/dist/assets/use-reduced-motion-yE3T3cat.js +1 -0
  88. package/dashboard/dist/assets/{use-session-efficiency-pref-Di8nY6Lr.js → use-session-efficiency-pref-CAX_GqXc.js} +1 -1
  89. package/dashboard/dist/assets/{use-transform-Dx9C2nBC.js → use-transform-CeW6Z5R0.js} +1 -1
  90. package/dashboard/dist/assets/use-usage-limits-5HP8k9a3.js +1 -0
  91. package/dashboard/dist/assets/{useCurrency-7dkDciam.js → useCurrency-BarGeFyK.js} +1 -1
  92. package/dashboard/dist/assets/{useTokenFormat-DFSIAsAb.js → useTokenFormat-DUJ9iT9H.js} +1 -1
  93. package/dashboard/dist/assets/{zap-Png-VJSP.js → zap--m0IWoI3.js} +1 -1
  94. package/dashboard/dist/index.html +2 -2
  95. package/dashboard/dist/ip-check.html +2 -2
  96. package/dashboard/dist/leaderboard.html +2 -2
  97. package/dashboard/dist/share.html +2 -2
  98. package/package.json +1 -1
  99. package/src/commands/status.js +3 -1
  100. package/src/commands/sync.js +8 -3
  101. package/src/lib/codex-rollout-parser.js +35 -4
  102. package/src/lib/cursor-config.js +182 -1
  103. package/src/lib/fs.js +12 -3
  104. package/src/lib/local-api.js +57 -0
  105. package/src/lib/pricing/seed-snapshot.json +1 -1
  106. package/src/lib/rollout.js +758 -195
  107. package/src/lib/session-analytics.js +205 -10
  108. package/src/lib/subscription-manager.js +293 -0
  109. package/src/lib/telemetry.js +2 -2
  110. package/src/lib/usage-limits.js +55 -14
  111. package/dashboard/dist/assets/BadgeDetailModal-Dgeer5oB.js +0 -1
  112. package/dashboard/dist/assets/CommunityStatsModal-eJ_a1FMd.js +0 -1
  113. package/dashboard/dist/assets/ConfirmModal-CumOr2J1.js +0 -1
  114. package/dashboard/dist/assets/DashboardPage-BpRRXCRr.js +0 -1
  115. package/dashboard/dist/assets/DialogDescription-C5Ujj254.js +0 -1
  116. package/dashboard/dist/assets/DialogTitle-6epeLIn0.js +0 -1
  117. package/dashboard/dist/assets/LeaderboardPage-yqauB4Q1.js +0 -2
  118. package/dashboard/dist/assets/LimitsPage-C9NeIcft.js +0 -5
  119. package/dashboard/dist/assets/LocalOnlyNotice-HPXWU8TT.js +0 -1
  120. package/dashboard/dist/assets/PetPage-BOhvAJp4.js +0 -1
  121. package/dashboard/dist/assets/PopoverPopup-BK9aqQn1.js +0 -1
  122. package/dashboard/dist/assets/SegmentedControl-DE5m1pTf.js +0 -1
  123. package/dashboard/dist/assets/SelectItemText-Daq-XRZg.js +0 -1
  124. package/dashboard/dist/assets/SessionsPage-nHPj6IAt.js +0 -2
  125. package/dashboard/dist/assets/SettingsPage-NvxUG_9m.js +0 -2
  126. package/dashboard/dist/assets/SkillsPage-zFbZa42f.js +0 -1
  127. package/dashboard/dist/assets/ToolbarRootContext-DnC8LT_8.js +0 -1
  128. package/dashboard/dist/assets/WidgetsPage-CXsfi-mq.js +0 -1
  129. package/dashboard/dist/assets/__vite-browser-external-CiR9-j4L.css +0 -1
  130. package/dashboard/dist/assets/store-B2nqaAqP.js +0 -1
  131. package/dashboard/dist/assets/use-limits-display-prefs-DXXZpjGY.js +0 -1
  132. package/dashboard/dist/assets/use-reduced-motion-aSaQtoJu.js +0 -1
  133. package/dashboard/dist/assets/use-usage-limits-D9vQFB1H.js +0 -1
@@ -3285,6 +3285,7 @@ function normalizeOpencodeState(raw) {
3285
3285
  const messages = state.messages && typeof state.messages === "object" ? state.messages : {};
3286
3286
  return {
3287
3287
  messages,
3288
+ dbCursor: state.dbCursor && typeof state.dbCursor === "object" ? state.dbCursor : null,
3288
3289
  updatedAt: typeof state.updatedAt === "string" ? state.updatedAt : null,
3289
3290
  };
3290
3291
  }
@@ -3362,13 +3363,20 @@ function deriveOpencodeMessageFingerprint({ msg, totals, source }) {
3362
3363
  // Rebuilt per parse run. Pre-#426 entries are fingerprinted as they are read;
3363
3364
  // the first claims ownership and later cross-session matches are retracted from
3364
3365
  // persisted buckets once. Tombstoned copies never claim ownership themselves.
3365
- function buildOpencodeFingerprintIndex(messageIndex) {
3366
+ function buildOpencodeFingerprintIndex(messageIndex, wantedFingerprints = null) {
3366
3367
  const byFingerprint = new Map();
3367
3368
  if (!messageIndex || typeof messageIndex !== "object") return byFingerprint;
3368
- for (const [key, entry] of Object.entries(messageIndex)) {
3369
+ for (const key in messageIndex) {
3370
+ const entry = messageIndex[key];
3369
3371
  if (entry?.dedupedForkCopy === true) continue;
3370
3372
  const fingerprint = entry && typeof entry.fingerprint === "string" ? entry.fingerprint : null;
3371
- if (fingerprint && !byFingerprint.has(fingerprint)) byFingerprint.set(fingerprint, key);
3373
+ if (
3374
+ fingerprint &&
3375
+ (!wantedFingerprints || wantedFingerprints.has(fingerprint)) &&
3376
+ !byFingerprint.has(fingerprint)
3377
+ ) {
3378
+ byFingerprint.set(fingerprint, key);
3379
+ }
3372
3380
  }
3373
3381
  return byFingerprint;
3374
3382
  }
@@ -3393,6 +3401,52 @@ function isOpencodeForkCopy(fingerprintIndex, fingerprint, messageKey) {
3393
3401
  return ownerSession !== session;
3394
3402
  }
3395
3403
 
3404
+ function normalizeOpencodeAttribution(raw) {
3405
+ if (typeof raw === "string") {
3406
+ const [bucketStart = "", model = "", projectKey = "", projectRef = ""] = raw.split("\t");
3407
+ if (!bucketStart || !model) return null;
3408
+ return {
3409
+ bucketStart,
3410
+ model,
3411
+ projectKey: projectKey || null,
3412
+ projectRef: projectRef || null,
3413
+ };
3414
+ }
3415
+ if (!raw || typeof raw !== "object") return null;
3416
+ const bucketStart = typeof raw.bucketStart === "string" ? raw.bucketStart : "";
3417
+ const model = typeof raw.model === "string" ? raw.model : "";
3418
+ if (!bucketStart || !model) return null;
3419
+ return {
3420
+ bucketStart,
3421
+ model,
3422
+ projectKey: typeof raw.projectKey === "string" ? raw.projectKey : null,
3423
+ projectRef: typeof raw.projectRef === "string" ? raw.projectRef : null,
3424
+ };
3425
+ }
3426
+
3427
+ function encodeOpencodeAttribution(raw) {
3428
+ const attribution = normalizeOpencodeAttribution(raw);
3429
+ if (!attribution) return null;
3430
+ return [
3431
+ attribution.bucketStart,
3432
+ attribution.model,
3433
+ attribution.projectKey || "",
3434
+ attribution.projectRef || "",
3435
+ ].join("\t");
3436
+ }
3437
+
3438
+ function sameOpencodeAttribution(a, b) {
3439
+ const left = normalizeOpencodeAttribution(a);
3440
+ const right = normalizeOpencodeAttribution(b);
3441
+ if (!left || !right) return left === right;
3442
+ return (
3443
+ left.bucketStart === right.bucketStart &&
3444
+ left.model === right.model &&
3445
+ left.projectKey === right.projectKey &&
3446
+ left.projectRef === right.projectRef
3447
+ );
3448
+ }
3449
+
3396
3450
  // Persist a message's snapshot + fingerprint and claim the fingerprint for it.
3397
3451
  // Writes only when something actually changed so a steady-state sync leaves the
3398
3452
  // cursor untouched. A falsy `fingerprint` means "unknown" and preserves whatever
@@ -3404,6 +3458,7 @@ function recordOpencodeMessage({
3404
3458
  messageKey,
3405
3459
  totals,
3406
3460
  fingerprint,
3461
+ attribution,
3407
3462
  dedupedForkCopy = false,
3408
3463
  }) {
3409
3464
  if (!messageIndex || !messageKey) return;
@@ -3411,10 +3466,13 @@ function recordOpencodeMessage({
3411
3466
  const prevTotals = prev && typeof prev.lastTotals === "object" ? prev.lastTotals : null;
3412
3467
  const prevFingerprint = prev && typeof prev.fingerprint === "string" ? prev.fingerprint : null;
3413
3468
  const nextFingerprint = fingerprint || prevFingerprint;
3469
+ const prevAttribution = normalizeOpencodeAttribution(prev?.attribution);
3470
+ const nextAttribution = normalizeOpencodeAttribution(attribution) || prevAttribution;
3414
3471
  const prevDeduped = prev?.dedupedForkCopy === true;
3415
3472
  if (
3416
3473
  sameGeminiTotals(totals, prevTotals) &&
3417
3474
  prevFingerprint === nextFingerprint &&
3475
+ sameOpencodeAttribution(prevAttribution, nextAttribution) &&
3418
3476
  prevDeduped === Boolean(dedupedForkCopy)
3419
3477
  ) return;
3420
3478
 
@@ -3425,6 +3483,7 @@ function recordOpencodeMessage({
3425
3483
  }
3426
3484
  const entry = { lastTotals: totals, updatedAt: new Date().toISOString() };
3427
3485
  if (nextFingerprint) entry.fingerprint = nextFingerprint;
3486
+ if (nextAttribution) entry.attribution = encodeOpencodeAttribution(nextAttribution);
3428
3487
  if (dedupedForkCopy) entry.dedupedForkCopy = true;
3429
3488
  messageIndex[messageKey] = entry;
3430
3489
  if (
@@ -3437,41 +3496,79 @@ function recordOpencodeMessage({
3437
3496
  }
3438
3497
  }
3439
3498
 
3440
- function repairCountedOpencodeForkCopy({
3441
- msg,
3499
+ function subtractCountedOpencodeMessage({
3500
+ attribution,
3442
3501
  totals,
3443
3502
  source,
3444
3503
  hourlyState,
3445
3504
  touchedBuckets,
3446
3505
  projectState,
3447
3506
  projectTouchedBuckets,
3448
- projectRef,
3449
- projectKey,
3450
3507
  }) {
3451
- const timestampMs = coerceEpochMs(msg?.time?.completed) || coerceEpochMs(msg?.time?.created);
3452
- if (!timestampMs) return false;
3453
- const bucketStart = toUtcHalfHourStart(new Date(timestampMs).toISOString());
3454
- if (!bucketStart) return false;
3455
- const { modelId: repairModelId } = normalizeOpencodeModelFields(msg);
3456
- const model = repairModelId || DEFAULT_MODEL;
3508
+ const countedAt = normalizeOpencodeAttribution(attribution);
3509
+ if (!countedAt) return false;
3457
3510
  const counted = { ...totals, conversation_count: 1 };
3458
- const bucket = getHourlyBucket(hourlyState, source, model, bucketStart);
3511
+ const bucket = getHourlyBucket(
3512
+ hourlyState,
3513
+ source,
3514
+ countedAt.model,
3515
+ countedAt.bucketStart,
3516
+ );
3459
3517
  subtractTotals(bucket.totals, counted);
3460
- touchedBuckets.add(bucketKey(source, model, bucketStart));
3461
- if (projectKey && projectState && projectTouchedBuckets) {
3518
+ touchedBuckets.add(bucketKey(source, countedAt.model, countedAt.bucketStart));
3519
+ if (countedAt.projectKey && projectState && projectTouchedBuckets) {
3462
3520
  const projectBucket = getProjectBucket(
3463
3521
  projectState,
3464
- projectKey,
3522
+ countedAt.projectKey,
3465
3523
  source,
3466
- bucketStart,
3467
- projectRef,
3524
+ countedAt.bucketStart,
3525
+ countedAt.projectRef,
3468
3526
  );
3469
3527
  subtractTotals(projectBucket.totals, counted);
3470
- projectTouchedBuckets.add(projectBucketKey(projectKey, source, bucketStart));
3528
+ projectTouchedBuckets.add(
3529
+ projectBucketKey(countedAt.projectKey, source, countedAt.bucketStart),
3530
+ );
3471
3531
  }
3472
3532
  return true;
3473
3533
  }
3474
3534
 
3535
+ function repairCountedOpencodeForkCopy({
3536
+ msg,
3537
+ attribution,
3538
+ totals,
3539
+ source,
3540
+ hourlyState,
3541
+ touchedBuckets,
3542
+ projectState,
3543
+ projectTouchedBuckets,
3544
+ projectRef,
3545
+ projectKey,
3546
+ }) {
3547
+ let countedAt = normalizeOpencodeAttribution(attribution);
3548
+ if (!countedAt) {
3549
+ const timestampMs = coerceEpochMs(msg?.time?.completed) || coerceEpochMs(msg?.time?.created);
3550
+ if (!timestampMs) return false;
3551
+ const bucketStart = toUtcHalfHourStart(new Date(timestampMs).toISOString());
3552
+ if (!bucketStart) return false;
3553
+ const { modelId: repairModelId } = normalizeOpencodeModelFields(msg);
3554
+ countedAt = {
3555
+ bucketStart,
3556
+ model: repairModelId || DEFAULT_MODEL,
3557
+ projectKey,
3558
+ projectRef,
3559
+ };
3560
+ }
3561
+ return subtractCountedOpencodeMessage({
3562
+ attribution: countedAt,
3563
+ totals,
3564
+ source,
3565
+ hourlyState,
3566
+ touchedBuckets,
3567
+ projectState,
3568
+ projectTouchedBuckets,
3569
+ });
3570
+ }
3571
+
3475
3572
  function getHourlyBucket(state, source, model, hourStart) {
3476
3573
  const buckets = state.buckets;
3477
3574
  const normalizedSource = normalizeSourceInput(source) || DEFAULT_SOURCE;
@@ -4185,10 +4282,6 @@ function diffGeminiTotals(current, previous) {
4185
4282
  const totalReset = (current.total_tokens || 0) < (previous.total_tokens || 0);
4186
4283
  if (totalReset) return current;
4187
4284
 
4188
- // Must include cache_creation_input_tokens in both the equality check and
4189
- // the delta — OpenCode routes through this diff and its cache.write number
4190
- // would otherwise be permanently reported as zero. Gemini itself always
4191
- // emits cache_creation=0 so the extra field is a no-op for Gemini.
4192
4285
  const delta = {
4193
4286
  input_tokens: Math.max(0, (current.input_tokens || 0) - (previous.input_tokens || 0)),
4194
4287
  cached_input_tokens: Math.max(
@@ -4210,6 +4303,29 @@ function diffGeminiTotals(current, previous) {
4210
4303
  return isAllZeroUsage(delta) ? null : delta;
4211
4304
  }
4212
4305
 
4306
+ // OpenCode rows are authoritative snapshots of one message and can be
4307
+ // corrected downward or move tokens between cache/output columns. Signed
4308
+ // deltas replace the prior contribution instead of treating a correction as a
4309
+ // fresh cumulative reset. Gemini keeps its provider-specific reset behavior.
4310
+ function diffOpencodeTotals(current, previous) {
4311
+ if (!current || typeof current !== "object") return null;
4312
+ if (!previous || typeof previous !== "object") return current;
4313
+ if (sameGeminiTotals(current, previous)) return null;
4314
+
4315
+ const delta = {};
4316
+ for (const key of [
4317
+ "input_tokens",
4318
+ "cached_input_tokens",
4319
+ "cache_creation_input_tokens",
4320
+ "output_tokens",
4321
+ "reasoning_output_tokens",
4322
+ "total_tokens",
4323
+ ]) {
4324
+ delta[key] = Number(current[key] || 0) - Number(previous[key] || 0);
4325
+ }
4326
+ return isAllZeroUsage(delta) ? null : delta;
4327
+ }
4328
+
4213
4329
  function extractTokenCount(obj) {
4214
4330
  const payload = obj?.payload;
4215
4331
  if (!payload) return null;
@@ -4391,15 +4507,73 @@ async function walkOpencodeMessages(dir, out) {
4391
4507
  // both "is there v2 data?" and "which session table exists?" in one round-trip.
4392
4508
  // ---------------------------------------------------------------------------
4393
4509
 
4394
- const OPENCODE_DB_V1_MESSAGE_SQL =
4395
- `SELECT id, session_id, time_updated, data FROM message ` +
4396
- `WHERE json_extract(data, '$.role') = 'assistant' ORDER BY time_created ASC`;
4510
+ const OPENCODE_DB_CURSOR_VERSION = 1;
4511
+
4512
+ function normalizeOpencodeDbWatermark(raw) {
4513
+ if (!raw || typeof raw !== "object") return null;
4514
+ const maxRowId = Math.max(0, Math.floor(Number(raw.maxRowId) || 0));
4515
+ const maxUpdatedAt = Math.max(0, Math.floor(Number(raw.maxUpdatedAt) || 0));
4516
+ const anchor = typeof raw.anchor === "string" && raw.anchor ? raw.anchor : null;
4517
+ return maxRowId || maxUpdatedAt ? { maxRowId, maxUpdatedAt, anchor } : null;
4518
+ }
4519
+
4520
+ function opencodeDbIdentity(dbPath) {
4521
+ try {
4522
+ const stat = fssync.statSync(dbPath);
4523
+ const resolved = path.resolve(dbPath);
4524
+ return {
4525
+ pathHash: crypto.createHash("sha256").update(resolved).digest("hex"),
4526
+ dev: String(stat.dev || 0),
4527
+ ino: String(stat.ino || 0),
4528
+ };
4529
+ } catch (_e) {
4530
+ return null;
4531
+ }
4532
+ }
4533
+
4534
+ function sameOpencodeDbIdentity(a, b) {
4535
+ return Boolean(
4536
+ a &&
4537
+ b &&
4538
+ a.pathHash === b.pathHash &&
4539
+ a.dev === b.dev &&
4540
+ a.ino === b.ino,
4541
+ );
4542
+ }
4543
+
4544
+ function opencodeDbRowAnchor(row) {
4545
+ if (!row || typeof row !== "object") return null;
4546
+ const rowId = Math.max(0, Math.floor(Number(row.row_id) || 0));
4547
+ const id = typeof row.id === "string" ? row.id : "";
4548
+ const created = Math.max(0, Math.floor(Number(row.time_created) || 0));
4549
+ if (!rowId || !id) return null;
4550
+ return crypto.createHash("sha256").update(`${rowId}\0${id}\0${created}`).digest("base64url");
4551
+ }
4552
+
4553
+ function opencodeDbIncrementalPredicate(alias, watermark) {
4554
+ const cursor = normalizeOpencodeDbWatermark(watermark);
4555
+ if (!cursor) return "";
4556
+ const clauses = [];
4557
+ if (cursor.maxRowId) clauses.push(`${alias}rowid > ${cursor.maxRowId}`);
4558
+ // Re-read the boundary timestamp so simultaneous updates cannot fall through
4559
+ // a strict greater-than watermark. The duplicate is removed by messageIndex.
4560
+ if (cursor.maxUpdatedAt) clauses.push(`${alias}time_updated >= ${cursor.maxUpdatedAt}`);
4561
+ return clauses.length > 0 ? ` AND (${clauses.join(" OR ")})` : "";
4562
+ }
4563
+
4564
+ function buildV1Sql(watermark = null) {
4565
+ return (
4566
+ `SELECT rowid AS row_id, id, session_id, time_updated, data FROM message ` +
4567
+ `WHERE json_extract(data, '$.role') = 'assistant'` +
4568
+ `${opencodeDbIncrementalPredicate("", watermark)} ORDER BY time_created ASC`
4569
+ );
4570
+ }
4397
4571
 
4398
4572
  // Build the v2 query. When a session table exists the LEFT JOIN restores the
4399
4573
  // project directory for downstream attribution; when it does not (type B
4400
4574
  // without session_v2, or an exotic fork) the join and directory column are
4401
4575
  // omitted and tokens are counted as-is.
4402
- function buildV2Sql(sessionTable) {
4576
+ function buildV2Sql(sessionTable, watermark = null) {
4403
4577
  const joinClause = sessionTable
4404
4578
  ? `LEFT JOIN ${sessionTable} s ON s.id = sm.session_id `
4405
4579
  : "";
@@ -4407,10 +4581,11 @@ function buildV2Sql(sessionTable) {
4407
4581
  ? `s.directory AS directory, `
4408
4582
  : "";
4409
4583
  return (
4410
- `SELECT sm.id AS id, sm.session_id AS session_id, sm.time_updated AS time_updated, ` +
4584
+ `SELECT sm.rowid AS row_id, sm.id AS id, sm.session_id AS session_id, sm.time_updated AS time_updated, ` +
4411
4585
  `${directorySelect}sm.data AS data ` +
4412
4586
  `FROM session_message sm ${joinClause}` +
4413
- `WHERE sm.type = 'assistant' ORDER BY sm.time_created ASC`
4587
+ `WHERE sm.type = 'assistant'` +
4588
+ `${opencodeDbIncrementalPredicate("sm.", watermark)} ORDER BY sm.time_created ASC`
4414
4589
  );
4415
4590
  }
4416
4591
 
@@ -4447,8 +4622,13 @@ function opencodeMessageProvider(data) {
4447
4622
  return data?.providerID || data?.model?.providerID || "";
4448
4623
  }
4449
4624
 
4450
- function readOpencodeDbMessages(dbPath, sqliteOptions = {}) {
4451
- if (!dbPath || !fssync.existsSync(dbPath)) return [];
4625
+ function readOpencodeDbMessagesIncremental(dbPath, previousCursor = null, sqliteOptions = {}) {
4626
+ if (!dbPath || !fssync.existsSync(dbPath)) return { messages: [], cursor: null };
4627
+
4628
+ const identity = opencodeDbIdentity(dbPath);
4629
+ const canResume =
4630
+ previousCursor?.version === OPENCODE_DB_CURSOR_VERSION &&
4631
+ sameOpencodeDbIdentity(previousCursor.identity, identity);
4452
4632
 
4453
4633
  let snapshot = null;
4454
4634
  let effectiveDbPath = dbPath;
@@ -4483,7 +4663,9 @@ function readOpencodeDbMessages(dbPath, sqliteOptions = {}) {
4483
4663
  const hasTokens =
4484
4664
  toNonNegativeInt(tokens.input) > 0 ||
4485
4665
  toNonNegativeInt(tokens.output) > 0 ||
4486
- toNonNegativeInt(tokens.reasoning) > 0;
4666
+ toNonNegativeInt(tokens.reasoning) > 0 ||
4667
+ toNonNegativeInt(tokens.cache?.read) > 0 ||
4668
+ toNonNegativeInt(tokens.cache?.write) > 0;
4487
4669
  if (!hasTokens) continue;
4488
4670
  if (isV2 && typeof row.directory === "string" && row.directory.trim()) {
4489
4671
  data.path = { ...(typeof data.path === "object" && data.path ? data.path : {}), cwd: row.directory };
@@ -4512,6 +4694,74 @@ function readOpencodeDbMessages(dbPath, sqliteOptions = {}) {
4512
4694
  }
4513
4695
  };
4514
4696
 
4697
+ const readMaxRowId = (table) => {
4698
+ const rows = readGeneration(`SELECT MAX(rowid) AS max_row_id FROM ${table}`);
4699
+ if (!rows) return null;
4700
+ return Math.max(0, Math.floor(Number(rows[0]?.max_row_id) || 0));
4701
+ };
4702
+
4703
+ const readRowAnchor = (table, rowId) => {
4704
+ if (!rowId) return null;
4705
+ const rows = readGeneration(
4706
+ `SELECT rowid AS row_id, id, time_created FROM ${table} WHERE rowid = ${rowId}`,
4707
+ );
4708
+ return rows ? opencodeDbRowAnchor(rows[0]) : null;
4709
+ };
4710
+
4711
+ const readCursorGeneration = ({ table, isV2, sql }) => {
4712
+ const previous = canResume
4713
+ ? normalizeOpencodeDbWatermark(previousCursor?.[isV2 ? "v2" : "v1"])
4714
+ : null;
4715
+ let forceReplay = false;
4716
+ let lastRows = [];
4717
+
4718
+ // Each query uses a separate SQLite reader. Verify the same immutable head
4719
+ // row before and after the data query so an in-place database replacement
4720
+ // cannot make us persist a cursor from a generation we did not read.
4721
+ for (let attempt = 0; attempt < 2; attempt += 1) {
4722
+ const currentMaxRowId = readMaxRowId(table);
4723
+ if (currentMaxRowId === null) return null;
4724
+ const currentAnchor = readRowAnchor(table, currentMaxRowId);
4725
+ const previousAnchor = previous
4726
+ ? readRowAnchor(table, previous.maxRowId)
4727
+ : null;
4728
+ const watermark =
4729
+ !forceReplay &&
4730
+ previous?.anchor &&
4731
+ currentMaxRowId >= previous.maxRowId &&
4732
+ previousAnchor === previous.anchor
4733
+ ? previous
4734
+ : null;
4735
+ const rows = readGeneration(sql(watermark));
4736
+ if (!rows) return null;
4737
+ lastRows = rows;
4738
+
4739
+ if (readRowAnchor(table, currentMaxRowId) !== currentAnchor) {
4740
+ forceReplay = true;
4741
+ continue;
4742
+ }
4743
+
4744
+ let maxUpdatedAt = watermark?.maxUpdatedAt || 0;
4745
+ for (const row of rows) {
4746
+ maxUpdatedAt = Math.max(maxUpdatedAt, Math.floor(Number(row?.time_updated) || 0));
4747
+ }
4748
+ return {
4749
+ rows,
4750
+ // Advance only to the head whose anchor bracketed this query. Rows
4751
+ // appended concurrently may be returned now and harmlessly reread on
4752
+ // the next sync; advancing to them would require an unverified anchor.
4753
+ cursor: {
4754
+ maxRowId: currentMaxRowId,
4755
+ maxUpdatedAt,
4756
+ anchor: currentAnchor,
4757
+ },
4758
+ };
4759
+ }
4760
+
4761
+ // Keep parsed usage but refuse to advance while the database is unstable.
4762
+ return { rows: lastRows, cursor: null };
4763
+ };
4764
+
4515
4765
  try {
4516
4766
  // Combined probe drives the orchestration: v1 always runs (it is the
4517
4767
  // baseline every database carries or degrades to), v2 runs only when the
@@ -4519,21 +4769,48 @@ function readOpencodeDbMessages(dbPath, sqliteOptions = {}) {
4519
4769
  // against a non-existent session table on type-A databases.
4520
4770
  const probe = detectOpencodeMessageLayout(effectiveDbPath, sqliteOptions);
4521
4771
  const out = [];
4772
+ const nextCursor = {
4773
+ version: OPENCODE_DB_CURSOR_VERSION,
4774
+ identity,
4775
+ v1: null,
4776
+ v2: null,
4777
+ };
4522
4778
 
4523
- const rows1 = readGeneration(OPENCODE_DB_V1_MESSAGE_SQL);
4524
- if (rows1) appendRows(rows1, false, out);
4779
+ const v1 = readCursorGeneration({
4780
+ table: "message",
4781
+ isV2: false,
4782
+ sql: buildV1Sql,
4783
+ });
4784
+ if (v1) {
4785
+ appendRows(v1.rows, false, out);
4786
+ nextCursor.v1 = v1.cursor;
4787
+ }
4525
4788
 
4526
4789
  if (probe?.hasRows) {
4527
- const rows2 = readGeneration(buildV2Sql(probe.sessionTable));
4528
- if (rows2) appendRows(rows2, true, out);
4790
+ const v2 = readCursorGeneration({
4791
+ table: "session_message",
4792
+ isV2: true,
4793
+ sql: (watermark) => buildV2Sql(probe.sessionTable, watermark),
4794
+ });
4795
+ if (v2) {
4796
+ appendRows(v2.rows, true, out);
4797
+ nextCursor.v2 = v2.cursor;
4798
+ }
4529
4799
  }
4530
4800
 
4531
- return out;
4801
+ return {
4802
+ messages: out,
4803
+ cursor: nextCursor.v1 || nextCursor.v2 ? nextCursor : null,
4804
+ };
4532
4805
  } finally {
4533
4806
  if (snapshot) snapshot.cleanup();
4534
4807
  }
4535
4808
  }
4536
4809
 
4810
+ function readOpencodeDbMessages(dbPath, sqliteOptions = {}) {
4811
+ return readOpencodeDbMessagesIncremental(dbPath, null, sqliteOptions).messages;
4812
+ }
4813
+
4537
4814
  // mimocode mirrors the user's Claude Code + claude-mem history into its own
4538
4815
  // `message` table — via an explicit `claude_import` AND a live observer /
4539
4816
  // session sync — so the overwhelming majority of rows are anthropic-endpoint
@@ -4791,6 +5068,7 @@ function readZcodeDbMessages(dbPath, sqliteOptions = {}) {
4791
5068
  async function parseOpencodeDbIncremental({
4792
5069
  dbMessages,
4793
5070
  dbPath,
5071
+ dbCursor,
4794
5072
  cursors,
4795
5073
  queuePath,
4796
5074
  projectQueuePath,
@@ -4815,9 +5093,20 @@ async function parseOpencodeDbIncremental({
4815
5093
  const cursorNamespace = typeof cursorKey === "string" && cursorKey.length > 0 ? cursorKey : "opencode";
4816
5094
  const opencodeState = normalizeOpencodeState(cursors?.[cursorNamespace]);
4817
5095
  const messageIndex = opencodeState.messages;
4818
- const fingerprintIndex = buildOpencodeFingerprintIndex(messageIndex);
4819
5096
  const touchedBuckets = new Set();
4820
5097
  const defaultSource = normalizeSourceInput(source) || "opencode";
5098
+ const candidateFingerprints = new Set();
5099
+ for (const entry of messages) {
5100
+ const totals = normalizeOpencodeTokens(entry?.data?.tokens);
5101
+ const fingerprint = totals
5102
+ ? deriveOpencodeMessageFingerprint({ msg: entry.data, totals, source: defaultSource })
5103
+ : null;
5104
+ if (fingerprint) candidateFingerprints.add(fingerprint);
5105
+ }
5106
+ // A hook-triggered sync normally carries one changed row. Restrict the
5107
+ // historical fingerprint map to fingerprints that can actually match this
5108
+ // batch instead of duplicating the whole long-lived message index in memory.
5109
+ const fingerprintIndex = buildOpencodeFingerprintIndex(messageIndex, candidateFingerprints);
4821
5110
 
4822
5111
  for (let idx = 0; idx < messages.length; idx++) {
4823
5112
  const entry = messages[idx];
@@ -4867,6 +5156,7 @@ async function parseOpencodeDbIncremental({
4867
5156
  if (lastTotals && prev?.dedupedForkCopy !== true) {
4868
5157
  repairCountedOpencodeForkCopy({
4869
5158
  msg,
5159
+ attribution: prev?.attribution,
4870
5160
  totals: lastTotals,
4871
5161
  source: defaultSource,
4872
5162
  hourlyState,
@@ -4890,17 +5180,58 @@ async function parseOpencodeDbIncremental({
4890
5180
  }
4891
5181
 
4892
5182
  const effectiveLastTotals = prev?.dedupedForkCopy === true ? null : lastTotals;
4893
- const delta = diffGeminiTotals(currentTotals, effectiveLastTotals);
4894
- if (!delta || isAllZeroUsage(delta)) {
5183
+ const delta = diffOpencodeTotals(currentTotals, effectiveLastTotals);
5184
+ const timestampMs = coerceEpochMs(msg?.time?.completed) || coerceEpochMs(msg?.time?.created);
5185
+ if (!timestampMs) {
5186
+ messagesProcessed += 1;
5187
+ continue;
5188
+ }
5189
+
5190
+ const tsIso = new Date(timestampMs).toISOString();
5191
+ const bucketStart = toUtcHalfHourStart(tsIso);
5192
+ if (!bucketStart) {
5193
+ messagesProcessed += 1;
5194
+ continue;
5195
+ }
5196
+
5197
+ const { modelId: dbModelId } = normalizeOpencodeModelFields(msg);
5198
+ const model = dbModelId || DEFAULT_MODEL;
5199
+ const projectContext = projectEnabled
5200
+ ? await resolveProjectContextForDb({
5201
+ msg,
5202
+ dbPath,
5203
+ projectMetaCache,
5204
+ publicRepoCache,
5205
+ publicRepoResolver,
5206
+ projectState,
5207
+ })
5208
+ : null;
5209
+ const attribution = {
5210
+ bucketStart,
5211
+ model,
5212
+ projectKey: projectContext?.projectKey || null,
5213
+ projectRef: projectContext?.projectRef || null,
5214
+ };
5215
+
5216
+ const previousAttribution = effectiveLastTotals
5217
+ ? normalizeOpencodeAttribution(prev?.attribution)
5218
+ : null;
5219
+ const moved = Boolean(
5220
+ effectiveLastTotals &&
5221
+ previousAttribution &&
5222
+ !sameOpencodeAttribution(previousAttribution, attribution),
5223
+ );
5224
+ if ((!delta || isAllZeroUsage(delta)) && !moved) {
4895
5225
  // Refresh the index even without a delta: normalization may have changed,
4896
- // and pre-#426 entries need their fingerprint backfilled so a fork taken
4897
- // from an old session is still recognised.
5226
+ // and pre-#426 entries need their fingerprint backfilled. Preserve an
5227
+ // existing contribution location without bloating every legacy cursor.
4898
5228
  recordOpencodeMessage({
4899
5229
  messageIndex,
4900
5230
  fingerprintIndex,
4901
5231
  messageKey,
4902
5232
  totals: currentTotals,
4903
5233
  fingerprint,
5234
+ attribution: prev?.attribution ? attribution : null,
4904
5235
  dedupedForkCopy: false,
4905
5236
  });
4906
5237
  messagesProcessed += 1;
@@ -4915,49 +5246,37 @@ async function parseOpencodeDbIncremental({
4915
5246
  }
4916
5247
  continue;
4917
5248
  }
4918
- delta.conversation_count = 1;
4919
-
4920
- const timestampMs = coerceEpochMs(msg?.time?.completed) || coerceEpochMs(msg?.time?.created);
4921
- if (!timestampMs) {
4922
- messagesProcessed += 1;
4923
- continue;
4924
- }
4925
5249
 
4926
- const tsIso = new Date(timestampMs).toISOString();
4927
- const bucketStart = toUtcHalfHourStart(tsIso);
4928
- if (!bucketStart) {
4929
- messagesProcessed += 1;
4930
- continue;
5250
+ if (moved) {
5251
+ subtractCountedOpencodeMessage({
5252
+ attribution: previousAttribution,
5253
+ totals: effectiveLastTotals,
5254
+ source: defaultSource,
5255
+ hourlyState,
5256
+ touchedBuckets,
5257
+ projectState,
5258
+ projectTouchedBuckets,
5259
+ });
4931
5260
  }
4932
-
4933
- const { modelId: dbModelId } = normalizeOpencodeModelFields(msg);
4934
- const model = dbModelId || DEFAULT_MODEL;
5261
+ const contribution = moved
5262
+ ? { ...currentTotals, conversation_count: 1 }
5263
+ : { ...delta, conversation_count: effectiveLastTotals ? 0 : 1 };
4935
5264
  const bucket = getHourlyBucket(hourlyState, defaultSource, model, bucketStart);
4936
- addTotals(bucket.totals, delta);
5265
+ addTotals(bucket.totals, contribution);
4937
5266
  touchedBuckets.add(bucketKey(defaultSource, model, bucketStart));
4938
5267
 
4939
- if (projectEnabled) {
4940
- const projectContext = await resolveProjectContextForDb({
4941
- msg,
4942
- dbPath,
4943
- projectMetaCache,
4944
- publicRepoCache,
4945
- publicRepoResolver,
5268
+ if (attribution.projectKey && projectState && projectTouchedBuckets) {
5269
+ const projectBucket = getProjectBucket(
4946
5270
  projectState,
4947
- });
4948
- const projectRef = projectContext?.projectRef || null;
4949
- const projectKey = projectContext?.projectKey || null;
4950
- if (projectKey && projectState && projectTouchedBuckets) {
4951
- const projectBucket = getProjectBucket(
4952
- projectState,
4953
- projectKey,
4954
- defaultSource,
4955
- bucketStart,
4956
- projectRef,
4957
- );
4958
- addTotals(projectBucket.totals, delta);
4959
- projectTouchedBuckets.add(projectBucketKey(projectKey, defaultSource, bucketStart));
4960
- }
5271
+ attribution.projectKey,
5272
+ defaultSource,
5273
+ bucketStart,
5274
+ attribution.projectRef,
5275
+ );
5276
+ addTotals(projectBucket.totals, contribution);
5277
+ projectTouchedBuckets.add(
5278
+ projectBucketKey(attribution.projectKey, defaultSource, bucketStart),
5279
+ );
4961
5280
  }
4962
5281
 
4963
5282
  recordOpencodeMessage({
@@ -4966,10 +5285,11 @@ async function parseOpencodeDbIncremental({
4966
5285
  messageKey,
4967
5286
  totals: currentTotals,
4968
5287
  fingerprint,
5288
+ attribution,
4969
5289
  dedupedForkCopy: false,
4970
5290
  });
4971
5291
  messagesProcessed += 1;
4972
- eventsAggregated += 1;
5292
+ if (delta && !isAllZeroUsage(delta)) eventsAggregated += 1;
4973
5293
 
4974
5294
  if (cb) {
4975
5295
  cb({
@@ -4988,6 +5308,7 @@ async function parseOpencodeDbIncremental({
4988
5308
  : 0;
4989
5309
  hourlyState.updatedAt = new Date().toISOString();
4990
5310
  cursors.hourly = hourlyState;
5311
+ if (dbCursor && typeof dbCursor === "object") opencodeState.dbCursor = dbCursor;
4991
5312
  opencodeState.updatedAt = new Date().toISOString();
4992
5313
  cursors[cursorNamespace] = opencodeState;
4993
5314
  if (projectState) {
@@ -13542,7 +13863,7 @@ async function parseCraftIncremental({
13542
13863
  // COPILOT_OTEL_ENABLED=true
13543
13864
  // COPILOT_OTEL_EXPORTER_TYPE=file
13544
13865
  // COPILOT_OTEL_FILE_EXPORTER_PATH=$HOME/.copilot/otel/copilot-otel-...jsonl
13545
- // We scan the default directory plus the env-overridden path.
13866
+ // We scan both known default directories plus the env-overridden path.
13546
13867
  // ─────────────────────────────────────────────────────────────────────────────
13547
13868
 
13548
13869
  function resolveCopilotOtelPaths(env = process.env) {
@@ -13557,12 +13878,19 @@ function resolveCopilotOtelPaths(env = process.env) {
13557
13878
  } catch (_e) {}
13558
13879
  };
13559
13880
  if (process.platform !== "win32" || wsl.shouldProbeNative(env)) {
13560
- scanDir(path.join(home, ".copilot", "otel"));
13881
+ for (const dir of [
13882
+ path.join(home, ".copilot", "otel"),
13883
+ path.join(home, ".copilot-otel"),
13884
+ ]) {
13885
+ scanDir(dir);
13886
+ }
13561
13887
  }
13562
13888
  if (process.platform === "win32") {
13563
13889
  if (wsl.shouldProbeWsl(env)) {
13564
- const wslDir = wsl.discoverWslHome(".copilot/otel", { env });
13565
- if (wslDir) scanDir(wslDir);
13890
+ for (const providerDir of [".copilot/otel", ".copilot-otel"]) {
13891
+ const wslDir = wsl.discoverWslHome(providerDir, { env });
13892
+ if (wslDir) scanDir(wslDir);
13893
+ }
13566
13894
  }
13567
13895
  }
13568
13896
  const explicit = env.COPILOT_OTEL_FILE_EXPORTER_PATH;
@@ -13604,7 +13932,7 @@ function pickCopilotModel(attrs) {
13604
13932
  return null;
13605
13933
  }
13606
13934
 
13607
- const COPILOT_PARSER_VERSION = 2;
13935
+ const COPILOT_PARSER_VERSION = 3;
13608
13936
  const COPILOT_USAGE_CLAIM_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
13609
13937
  const COPILOT_USAGE_CLAIM_MAX_ENTRIES = 10_000;
13610
13938
 
@@ -13661,14 +13989,168 @@ function incrementMapCount(map, key, amount = 1) {
13661
13989
  map.set(key, (map.get(key) || 0) + amount);
13662
13990
  }
13663
13991
 
13664
- function getCopilotDedupKey(record, attrs = record?.attributes || {}) {
13992
+ function getCopilotResponseId(attrs = {}) {
13993
+ const responseId = attrs["gen_ai.response.id"];
13994
+ return typeof responseId === "string" && responseId.trim() ? responseId.trim() : "";
13995
+ }
13996
+
13997
+ // v2 preferred spanContext for every OTEL envelope. Chat-extension LogRecords
13998
+ // can share that nested context across several model requests, so keep the old
13999
+ // key only for the one envelope that owns top-level traceId/spanId: CLI spans.
14000
+ function getCopilotLegacyDedupKey(record, attrs = record?.attributes || {}) {
13665
14001
  const traceId = record?.traceId || record?.spanContext?.traceId || "";
13666
14002
  const spanId = record?.spanId || record?.spanContext?.spanId || "";
13667
- const responseId =
13668
- typeof attrs["gen_ai.response.id"] === "string" ? attrs["gen_ai.response.id"] : "";
14003
+ const responseId = getCopilotResponseId(attrs);
13669
14004
  return traceId && spanId ? `${traceId}:${spanId}` : responseId ? `resp:${responseId}` : null;
13670
14005
  }
13671
14006
 
14007
+ function getCopilotDedupKey(record, attrs = record?.attributes || {}) {
14008
+ const responseId = getCopilotResponseId(attrs);
14009
+ if (!isCopilotV1ChatSpan(record)) {
14010
+ return responseId ? `resp:${responseId}` : null;
14011
+ }
14012
+
14013
+ const traceId = record?.traceId || "";
14014
+ const spanId = record?.spanId || "";
14015
+ return traceId && spanId
14016
+ ? `${traceId}:${spanId}`
14017
+ : responseId
14018
+ ? `resp:${responseId}`
14019
+ : null;
14020
+ }
14021
+
14022
+ function copilotOtelAggregateKey(model, bucketStart) {
14023
+ return JSON.stringify([model, bucketStart]);
14024
+ }
14025
+
14026
+ function addCopilotOtelAggregate(aggregates, model, bucketStart, delta) {
14027
+ const key = copilotOtelAggregateKey(model, bucketStart);
14028
+ let totals = aggregates.get(key);
14029
+ if (!totals) {
14030
+ totals = initTotals();
14031
+ aggregates.set(key, totals);
14032
+ }
14033
+ addTotals(totals, delta);
14034
+ }
14035
+
14036
+ function copilotTotalsCover(existing, required) {
14037
+ if (!existing || typeof existing !== "object") return false;
14038
+ for (const field of [
14039
+ "input_tokens",
14040
+ "cached_input_tokens",
14041
+ "cache_creation_input_tokens",
14042
+ "output_tokens",
14043
+ "reasoning_output_tokens",
14044
+ "total_tokens",
14045
+ "billable_total_tokens",
14046
+ "conversation_count",
14047
+ ]) {
14048
+ const actual = Number(existing[field] || 0);
14049
+ const needed = Number(required?.[field] || 0);
14050
+ if (!Number.isFinite(actual) || !Number.isFinite(needed) || actual < needed) {
14051
+ return false;
14052
+ }
14053
+ }
14054
+ return true;
14055
+ }
14056
+
14057
+ function extractCopilotOtelUsage(record) {
14058
+ if (!isCopilotChatSpan(record)) return null;
14059
+
14060
+ const attrs = record.attributes || {};
14061
+ const inputRaw = toNonNegativeInt(attrs["gen_ai.usage.input_tokens"]);
14062
+ const output = toNonNegativeInt(attrs["gen_ai.usage.output_tokens"]);
14063
+ const cacheRead = toNonNegativeInt(
14064
+ attrs["gen_ai.usage.cache_read.input_tokens"] ??
14065
+ attrs["gen_ai.usage.cache_read_input_tokens"] ??
14066
+ attrs["gen_ai.usage.cached_input_tokens"],
14067
+ );
14068
+ // Copilot CLI: cache_write.input_tokens; Copilot Chat extension: cache_creation.input_tokens
14069
+ const cacheWrite = toNonNegativeInt(
14070
+ attrs["gen_ai.usage.cache_write.input_tokens"] ??
14071
+ attrs["gen_ai.usage.cache_creation.input_tokens"] ??
14072
+ attrs["gen_ai.usage.cache_write_input_tokens"] ??
14073
+ attrs["gen_ai.usage.cache_creation_input_tokens"],
14074
+ );
14075
+ // Copilot CLI: reasoning.output_tokens; Copilot Chat extension: reasoning_tokens
14076
+ const reasoning = toNonNegativeInt(
14077
+ attrs["gen_ai.usage.reasoning.output_tokens"] ??
14078
+ attrs["gen_ai.usage.reasoning_tokens"] ??
14079
+ attrs["gen_ai.usage.reasoning_output_tokens"],
14080
+ );
14081
+ const reasoningClamped = Math.min(reasoning, output);
14082
+ const outputWithoutReasoning = Math.max(0, output - reasoningClamped);
14083
+ const cliSpan = isCopilotV1ChatSpan(record);
14084
+ // CLI input includes both cache reads and writes. Chat-extension LogRecords
14085
+ // expose cache creation separately, so preserve their existing input-minus-read semantics.
14086
+ const cacheReadClamped = Math.min(cacheRead, inputRaw);
14087
+ const cacheWriteClamped = Math.min(
14088
+ cacheWrite,
14089
+ Math.max(0, inputRaw - cacheReadClamped),
14090
+ );
14091
+ const cacheWriteForAccounting = cliSpan ? cacheWriteClamped : cacheWrite;
14092
+ const input = Math.max(
14093
+ 0,
14094
+ inputRaw - cacheReadClamped - (cliSpan ? cacheWriteClamped : 0),
14095
+ );
14096
+ const totalInteresting =
14097
+ input +
14098
+ outputWithoutReasoning +
14099
+ cacheReadClamped +
14100
+ cacheWriteForAccounting +
14101
+ reasoningClamped;
14102
+ if (totalInteresting === 0) return null;
14103
+
14104
+ // CLI Span uses endTime/startTime; Chat extension LogRecord uses hrTime/hrTimeObserved.
14105
+ const tsMs =
14106
+ copilotOtelTimeToMs(record.endTime) ||
14107
+ copilotOtelTimeToMs(record.startTime) ||
14108
+ copilotOtelTimeToMs(record.hrTime) ||
14109
+ copilotOtelTimeToMs(record.hrTimeObserved);
14110
+ if (!tsMs) return null;
14111
+ const bucketStart = toUtcHalfHourStart(new Date(tsMs).toISOString());
14112
+ if (!bucketStart) return null;
14113
+
14114
+ const model =
14115
+ normalizeCopilotAppModel(pickCopilotModel(attrs)) ||
14116
+ COPILOT_APP_DEFAULT_MODEL;
14117
+ const cliSessionId =
14118
+ typeof attrs["gen_ai.conversation.id"] === "string"
14119
+ ? attrs["gen_ai.conversation.id"].trim()
14120
+ : "";
14121
+ const matchBase = {
14122
+ sessionId: cliSessionId,
14123
+ model,
14124
+ output: outputWithoutReasoning,
14125
+ cacheRead: cacheReadClamped,
14126
+ cacheWrite: cacheWriteClamped,
14127
+ reasoning: reasoningClamped,
14128
+ tsMs,
14129
+ };
14130
+ return {
14131
+ bucketStart,
14132
+ cliSpan,
14133
+ cliSessionId,
14134
+ delta: {
14135
+ input_tokens: input,
14136
+ cached_input_tokens: cacheReadClamped,
14137
+ cache_creation_input_tokens: cacheWriteForAccounting,
14138
+ output_tokens: outputWithoutReasoning,
14139
+ reasoning_output_tokens: reasoningClamped,
14140
+ total_tokens:
14141
+ input +
14142
+ outputWithoutReasoning +
14143
+ cacheReadClamped +
14144
+ cacheWriteForAccounting +
14145
+ reasoningClamped,
14146
+ conversation_count: 1,
14147
+ },
14148
+ matchBase,
14149
+ model,
14150
+ tsMs,
14151
+ };
14152
+ }
14153
+
13672
14154
  function copilotUsageMatchKey({
13673
14155
  sessionId,
13674
14156
  model,
@@ -13728,6 +14210,141 @@ function createCopilotStoreUsageMatcher(events) {
13728
14210
  };
13729
14211
  }
13730
14212
 
14213
+ // v2 may already have advanced the file cursor while collapsing several Chat
14214
+ // extension LogRecords that shared one nested spanContext. Recompute only that
14215
+ // envelope's historical contribution and apply the delta to the existing
14216
+ // Copilot buckets. CLI spans are deliberately left alone: their v2 key was the
14217
+ // correct top-level traceId:spanId key, and session-store adoption can coexist
14218
+ // with the OTEL parser.
14219
+ async function migrateCopilotChatLogRecordDedup({
14220
+ files,
14221
+ fileOffsets,
14222
+ hourlyState,
14223
+ touchedBuckets,
14224
+ seenIds,
14225
+ } = {}) {
14226
+ const trackedFiles = Object.keys(fileOffsets || {}).filter(
14227
+ (filePath) => Number(fileOffsets[filePath]?.size) > 0,
14228
+ );
14229
+ if (trackedFiles.length === 0) {
14230
+ return { applied: true, changed: false };
14231
+ }
14232
+
14233
+ const availableFiles = new Set(Array.isArray(files) ? files : []);
14234
+ if (trackedFiles.some((filePath) => !availableFiles.has(filePath))) {
14235
+ return { applied: false, reason: "a previously parsed OTEL file is unavailable" };
14236
+ }
14237
+
14238
+ const oldSeen = new Set();
14239
+ const newSeen = new Set();
14240
+ const oldTotals = new Map();
14241
+ const newTotals = new Map();
14242
+
14243
+ for (const filePath of files) {
14244
+ if (!Object.prototype.hasOwnProperty.call(fileOffsets, filePath)) continue;
14245
+ const prevEntry = fileOffsets[filePath] || {};
14246
+ const prevSize = Number(prevEntry.size) || 0;
14247
+ if (prevSize <= 0) continue;
14248
+
14249
+ let stat;
14250
+ try {
14251
+ stat = fssync.statSync(filePath);
14252
+ } catch (_e) {
14253
+ return { applied: false, reason: "a previously parsed OTEL file is unreadable" };
14254
+ }
14255
+ if (
14256
+ stat.size < prevSize ||
14257
+ (typeof prevEntry.ino === "number" && stat.ino !== prevEntry.ino)
14258
+ ) {
14259
+ return { applied: false, reason: "a previously parsed OTEL file changed during migration" };
14260
+ }
14261
+
14262
+ let stream;
14263
+ try {
14264
+ stream = fssync.createReadStream(filePath, {
14265
+ encoding: "utf8",
14266
+ start: 0,
14267
+ end: prevSize - 1,
14268
+ });
14269
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
14270
+ try {
14271
+ for await (const line of rl) {
14272
+ if (!line || !line.trim()) continue;
14273
+ let record;
14274
+ try {
14275
+ record = JSON.parse(line);
14276
+ } catch (_e) {
14277
+ continue;
14278
+ }
14279
+ const usage = extractCopilotOtelUsage(record);
14280
+ if (!usage || usage.cliSpan) continue;
14281
+
14282
+ const oldKey = getCopilotLegacyDedupKey(record, record.attributes || {});
14283
+ const oldDuplicate = oldKey && oldSeen.has(oldKey);
14284
+ if (oldKey) oldSeen.add(oldKey);
14285
+ if (!oldDuplicate) {
14286
+ addCopilotOtelAggregate(
14287
+ oldTotals,
14288
+ usage.model,
14289
+ usage.bucketStart,
14290
+ usage.delta,
14291
+ );
14292
+ }
14293
+
14294
+ const newKey = getCopilotDedupKey(record, record.attributes || {});
14295
+ const newDuplicate = newKey && newSeen.has(newKey);
14296
+ if (newKey) newSeen.add(newKey);
14297
+ if (!newDuplicate) {
14298
+ addCopilotOtelAggregate(
14299
+ newTotals,
14300
+ usage.model,
14301
+ usage.bucketStart,
14302
+ usage.delta,
14303
+ );
14304
+ }
14305
+ }
14306
+ } finally {
14307
+ rl.close();
14308
+ }
14309
+ } catch (_e) {
14310
+ return { applied: false, reason: "an OTEL file could not be scanned" };
14311
+ } finally {
14312
+ stream?.destroy();
14313
+ }
14314
+ }
14315
+
14316
+ const keys = new Set([...oldTotals.keys(), ...newTotals.keys()]);
14317
+ for (const key of keys) {
14318
+ const [model, bucketStart] = JSON.parse(key);
14319
+ const oldUsage = oldTotals.get(key) || initTotals();
14320
+ if (!copilotTotalsCover(
14321
+ hourlyState.buckets[bucketKey("copilot", model, bucketStart)]?.totals,
14322
+ oldUsage,
14323
+ )) {
14324
+ return {
14325
+ applied: false,
14326
+ reason: "existing Copilot buckets do not cover the old OTEL contribution",
14327
+ };
14328
+ }
14329
+ }
14330
+
14331
+ let changed = false;
14332
+ for (const key of keys) {
14333
+ const [model, bucketStart] = JSON.parse(key);
14334
+ const oldUsage = oldTotals.get(key) || initTotals();
14335
+ const newUsage = newTotals.get(key) || initTotals();
14336
+ if (totalsKey(oldUsage) === totalsKey(newUsage)) continue;
14337
+ const bucket = getHourlyBucket(hourlyState, "copilot", model, bucketStart);
14338
+ subtractTotals(bucket.totals, oldUsage);
14339
+ addTotals(bucket.totals, newUsage);
14340
+ touchedBuckets.add(bucketKey("copilot", model, bucketStart));
14341
+ changed = true;
14342
+ }
14343
+
14344
+ for (const id of newSeen) seenIds.add(id);
14345
+ return { applied: true, changed };
14346
+ }
14347
+
13731
14348
  // Migration helper: stream the bytes v1 already saw (0 -> prevSize), classify
13732
14349
  // whether the file contains old CLI spans v1 processed, and whether it also
13733
14350
  // contains v2-only chat records v1 skipped. Mixed files must be replayed, but
@@ -13841,12 +14458,18 @@ async function parseCopilotIncremental({
13841
14458
  seenIds.size > 0 || Object.keys(fileOffsetsRaw).length > 0;
13842
14459
  let usageClaimsComplete =
13843
14460
  copilotState.usageClaimsComplete === true || !hadPriorUsageHistory;
14461
+ const files = Array.isArray(otelPaths) && otelPaths.length > 0
14462
+ ? otelPaths
14463
+ : resolveCopilotOtelPaths(env || process.env);
14464
+ const hourlyState = normalizeHourlyState(cursors?.hourly);
14465
+ const touchedBuckets = new Set();
13844
14466
  const migrationSkipLineHashes = new Map();
14467
+ let cursorVersion = COPILOT_PARSER_VERSION;
13845
14468
  // One-shot v1->v2 migration:
13846
14469
  // - pure v2-only files: clear offset and re-read all skipped Chat records
13847
14470
  // - pure v1 CLI files: preserve offset to avoid replaying history beyond seenIds
13848
14471
  // - mixed files: clear offset, but skip old v1 CLI lines by hash during replay
13849
- if (priorVersion < COPILOT_PARSER_VERSION) {
14472
+ if (priorVersion < 2) {
13850
14473
  for (const filePath of Object.keys(fileOffsets)) {
13851
14474
  const prevSize = Number(fileOffsets[filePath]?.size) || 0;
13852
14475
  const scan = await scanCopilotV1MigrationFile(filePath, prevSize);
@@ -13859,13 +14482,33 @@ async function parseCopilotIncremental({
13859
14482
  }
13860
14483
  }
13861
14484
 
13862
- const files = Array.isArray(otelPaths) && otelPaths.length > 0
13863
- ? otelPaths
13864
- : resolveCopilotOtelPaths(env || process.env);
14485
+ // v2 used spanContext as the fallback key for Chat-extension LogRecords.
14486
+ // Those records can share one context across multiple model requests, so
14487
+ // repair the already-counted file prefix before switching to response.id.
14488
+ // If the prefix cannot be verified, leave the cursor at v2 and retry on the
14489
+ // next sync rather than replaying it with a different deduplication scheme.
14490
+ if (priorVersion === 2) {
14491
+ const migration = await migrateCopilotChatLogRecordDedup({
14492
+ files,
14493
+ fileOffsets,
14494
+ hourlyState,
14495
+ touchedBuckets,
14496
+ seenIds,
14497
+ });
14498
+ if (!migration.applied) {
14499
+ return {
14500
+ recordsProcessed: 0,
14501
+ eventsAggregated: 0,
14502
+ bucketsQueued: 0,
14503
+ usageClaims: recentOtelUsageEvents,
14504
+ };
14505
+ }
14506
+ }
14507
+
13865
14508
  if (files.length === 0) {
13866
14509
  cursors.copilot = {
13867
14510
  ...copilotState,
13868
- version: COPILOT_PARSER_VERSION,
14511
+ version: cursorVersion,
13869
14512
  seenIds: Array.from(seenIds),
13870
14513
  fileOffsets,
13871
14514
  recentUsageEvents: recentOtelUsageEvents,
@@ -13879,9 +14522,6 @@ async function parseCopilotIncremental({
13879
14522
  usageClaims: recentOtelUsageEvents,
13880
14523
  };
13881
14524
  }
13882
-
13883
- const hourlyState = normalizeHourlyState(cursors?.hourly);
13884
- const touchedBuckets = new Set();
13885
14525
  const cb = typeof onProgress === "function" ? onProgress : null;
13886
14526
  let recordsProcessed = 0;
13887
14527
  let eventsAggregated = 0;
@@ -13941,117 +14581,39 @@ async function parseCopilotIncremental({
13941
14581
  // gen_ai.response.id is per-LLM-call unique.
13942
14582
  const dedupKey = getCopilotDedupKey(record, attrs);
13943
14583
  if (dedupKey && seenIds.has(dedupKey)) continue;
13944
- if (skipCliSpans && isCopilotV1ChatSpan(record)) {
14584
+ const cliSpan = isCopilotV1ChatSpan(record);
14585
+ if (skipCliSpans && cliSpan) {
13945
14586
  if (dedupKey) seenIds.add(dedupKey);
13946
14587
  continue;
13947
14588
  }
13948
14589
 
13949
- const inputRaw = toNonNegativeInt(attrs["gen_ai.usage.input_tokens"]);
13950
- const output = toNonNegativeInt(attrs["gen_ai.usage.output_tokens"]);
13951
- const cacheRead = toNonNegativeInt(
13952
- attrs["gen_ai.usage.cache_read.input_tokens"] ??
13953
- attrs["gen_ai.usage.cache_read_input_tokens"] ??
13954
- attrs["gen_ai.usage.cached_input_tokens"],
13955
- );
13956
- // Copilot CLI: cache_write.input_tokens; Copilot Chat extension: cache_creation.input_tokens
13957
- const cacheWrite = toNonNegativeInt(
13958
- attrs["gen_ai.usage.cache_write.input_tokens"] ??
13959
- attrs["gen_ai.usage.cache_creation.input_tokens"] ??
13960
- attrs["gen_ai.usage.cache_write_input_tokens"] ??
13961
- attrs["gen_ai.usage.cache_creation_input_tokens"],
13962
- );
13963
- // Copilot CLI: reasoning.output_tokens; Copilot Chat extension: reasoning_tokens
13964
- const reasoning = toNonNegativeInt(
13965
- attrs["gen_ai.usage.reasoning.output_tokens"] ??
13966
- attrs["gen_ai.usage.reasoning_tokens"] ??
13967
- attrs["gen_ai.usage.reasoning_output_tokens"],
13968
- );
13969
- const reasoningClamped = Math.min(reasoning, output);
13970
- const outputWithoutReasoning = Math.max(0, output - reasoningClamped);
13971
- const cliSpan = isCopilotV1ChatSpan(record);
13972
- // CLI input includes both cache reads and writes. Chat-extension
13973
- // LogRecords expose cache creation separately, so preserve their existing
13974
- // input-minus-read semantics.
13975
- const cacheReadClamped = Math.min(cacheRead, inputRaw);
13976
- const cacheWriteClamped = Math.min(
13977
- cacheWrite,
13978
- Math.max(0, inputRaw - cacheReadClamped),
13979
- );
13980
- const cacheWriteForAccounting = cliSpan ? cacheWriteClamped : cacheWrite;
13981
- const input = Math.max(
13982
- 0,
13983
- inputRaw - cacheReadClamped - (cliSpan ? cacheWriteClamped : 0),
13984
- );
13985
- const totalInteresting =
13986
- input +
13987
- outputWithoutReasoning +
13988
- cacheReadClamped +
13989
- cacheWriteForAccounting +
13990
- reasoningClamped;
13991
- if (totalInteresting === 0) continue;
13992
-
13993
- // CLI Span uses endTime/startTime; Chat extension LogRecord uses hrTime/hrTimeObserved.
13994
- const tsMs =
13995
- copilotOtelTimeToMs(record.endTime) ||
13996
- copilotOtelTimeToMs(record.startTime) ||
13997
- copilotOtelTimeToMs(record.hrTime) ||
13998
- copilotOtelTimeToMs(record.hrTimeObserved);
13999
- if (!tsMs) continue;
14000
- const tsIso = new Date(tsMs).toISOString();
14001
- const bucketStart = toUtcHalfHourStart(tsIso);
14002
- if (!bucketStart) continue;
14003
-
14004
- const model =
14005
- normalizeCopilotAppModel(pickCopilotModel(attrs)) ||
14006
- COPILOT_APP_DEFAULT_MODEL;
14007
- const cliSessionId =
14008
- typeof attrs["gen_ai.conversation.id"] === "string"
14009
- ? attrs["gen_ai.conversation.id"].trim()
14010
- : "";
14011
- if (cliSpan && !cliSessionId) usageClaimsComplete = false;
14012
- const matchBase = {
14013
- sessionId: cliSessionId,
14014
- model,
14015
- output: outputWithoutReasoning,
14016
- cacheRead: cacheReadClamped,
14017
- cacheWrite: cacheWriteClamped,
14018
- reasoning: reasoningClamped,
14019
- tsMs,
14020
- };
14590
+ const usage = extractCopilotOtelUsage(record);
14591
+ if (!usage) continue;
14592
+ if (usage.cliSpan && !usage.cliSessionId) usageClaimsComplete = false;
14021
14593
  const matchedStoreUsage =
14022
- isCopilotV1ChatSpan(record) &&
14594
+ usage.cliSpan &&
14023
14595
  storeUsageMatcher.consume({
14024
- ...matchBase,
14025
- input,
14596
+ ...usage.matchBase,
14597
+ input: usage.delta.input_tokens,
14026
14598
  });
14027
14599
  if (matchedStoreUsage) {
14028
14600
  if (dedupKey) seenIds.add(dedupKey);
14029
14601
  continue;
14030
14602
  }
14031
14603
 
14032
- const delta = {
14033
- input_tokens: input,
14034
- cached_input_tokens: cacheReadClamped,
14035
- cache_creation_input_tokens: cacheWriteForAccounting,
14036
- output_tokens: outputWithoutReasoning,
14037
- reasoning_output_tokens: reasoningClamped,
14038
- total_tokens:
14039
- input +
14040
- outputWithoutReasoning +
14041
- cacheReadClamped +
14042
- cacheWriteForAccounting +
14043
- reasoningClamped,
14044
- conversation_count: 1,
14045
- };
14046
-
14047
- const bucket = getHourlyBucket(hourlyState, "copilot", model, bucketStart);
14048
- addTotals(bucket.totals, delta);
14049
- touchedBuckets.add(bucketKey("copilot", model, bucketStart));
14604
+ const bucket = getHourlyBucket(
14605
+ hourlyState,
14606
+ "copilot",
14607
+ usage.model,
14608
+ usage.bucketStart,
14609
+ );
14610
+ addTotals(bucket.totals, usage.delta);
14611
+ touchedBuckets.add(bucketKey("copilot", usage.model, usage.bucketStart));
14050
14612
  eventsAggregated++;
14051
- if (cliSpan && cliSessionId) {
14613
+ if (usage.cliSpan && usage.cliSessionId) {
14052
14614
  recentOtelUsageEvents.push({
14053
- ...matchBase,
14054
- input,
14615
+ ...usage.matchBase,
14616
+ input: usage.delta.input_tokens,
14055
14617
  firstSeenAtMs: claimNowMs,
14056
14618
  });
14057
14619
  }
@@ -14093,7 +14655,7 @@ async function parseCopilotIncremental({
14093
14655
  cursors.hourly = hourlyState;
14094
14656
  cursors.copilot = {
14095
14657
  ...copilotState,
14096
- version: COPILOT_PARSER_VERSION,
14658
+ version: cursorVersion,
14097
14659
  seenIds: cappedSeen,
14098
14660
  fileOffsets,
14099
14661
  recentUsageEvents: retainedRecentOtelUsageEvents,
@@ -18281,6 +18843,7 @@ module.exports = {
18281
18843
  listGeminiSessionFiles,
18282
18844
  listOpencodeMessageFiles,
18283
18845
  readOpencodeDbMessages,
18846
+ readOpencodeDbMessagesIncremental,
18284
18847
  readMimoDbMessages,
18285
18848
  readZcodeDbMessages,
18286
18849
  hasZcodeNativeUsageSchema,