tokentracker-cli 0.94.0 → 0.95.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 (130) hide show
  1. package/README.de.md +9 -7
  2. package/README.ja.md +8 -6
  3. package/README.ko.md +8 -6
  4. package/README.md +23 -10
  5. package/README.zh-CN.md +8 -6
  6. package/dashboard/dist/assets/{AchievementBadge-DPqwk2zI.js → AchievementBadge-Pi3Gvbm8.js} +1 -1
  7. package/dashboard/dist/assets/{AchievementsPage-CELHoMQt.js → AchievementsPage-C4WLT8Rl.js} +2 -2
  8. package/dashboard/dist/assets/{AchievementsSection-DdAC0VCb.js → AchievementsSection-ilvR2ssr.js} +1 -1
  9. package/dashboard/dist/assets/{BadgeDetailModal-Dd4I1SpM.js → BadgeDetailModal-CjEeToxa.js} +1 -1
  10. package/dashboard/dist/assets/{BotAnimated-DL2-4mnm.js → BotAnimated-DDf9pBuK.js} +1 -1
  11. package/dashboard/dist/assets/{Card-B_MpSRse.js → Card-DNous84-.js} +1 -1
  12. package/dashboard/dist/assets/{ClawdAnimated-BLZZ4rG6.js → ClawdAnimated-BLMIRntb.js} +2 -2
  13. package/dashboard/dist/assets/{CommandPalette-B-vfdIbH.js → CommandPalette-vVekhoFO.js} +1 -1
  14. package/dashboard/dist/assets/CommunityStatsModal-C0Kp_J8B.js +1 -0
  15. package/dashboard/dist/assets/{ConfirmModal-DLnRwUXC.js → ConfirmModal-DFdpcx12.js} +1 -1
  16. package/dashboard/dist/assets/{Controls-FZ8wEN5w.js → Controls-RNhqybmE.js} +1 -1
  17. package/dashboard/dist/assets/DashboardPage-BQjQh1IQ.js +1 -0
  18. package/dashboard/dist/assets/{DevicePage-D2IzPc5o.js → DevicePage-DIhh-YAk.js} +1 -1
  19. package/dashboard/dist/assets/{DialogClose-BaqQaUUk.js → DialogClose-ZqxYVIzl.js} +1 -1
  20. package/dashboard/dist/assets/DialogDescription-DDM4aanX.js +1 -0
  21. package/dashboard/dist/assets/{DialogTitle-DrbKjtuS.js → DialogTitle-BiRcejlu.js} +1 -1
  22. package/dashboard/dist/assets/{FadeIn-GddKDE8i.js → FadeIn-BDBQpeGe.js} +1 -1
  23. package/dashboard/dist/assets/{HeaderGithubStar-DUu-Z5mE.js → HeaderGithubStar-CztIwtKT.js} +1 -1
  24. package/dashboard/dist/assets/{HoverTooltip-BmDkY35A.js → HoverTooltip-Cfso7LTC.js} +1 -1
  25. package/dashboard/dist/assets/{IpCheckPage-CGJyqWk2.js → IpCheckPage-CNE7dLs3.js} +1 -1
  26. package/dashboard/dist/assets/{LandingPage-Bd91ALUj.js → LandingPage-B2a3cweM.js} +2 -2
  27. package/dashboard/dist/assets/{LeaderboardAvatar-BGGUwXFd.js → LeaderboardAvatar-CwmZnIji.js} +1 -1
  28. package/dashboard/dist/assets/{LeaderboardPage-BMEqHwif.js → LeaderboardPage-DkTHj3fB.js} +2 -2
  29. package/dashboard/dist/assets/{LeaderboardProfileModal-BSweJqm2.js → LeaderboardProfileModal-CefTO3Zh.js} +3 -3
  30. package/dashboard/dist/assets/{LeaderboardProfilePage-CRrI3RFz.js → LeaderboardProfilePage-CnOXlQ3L.js} +1 -1
  31. package/dashboard/dist/assets/{LimitsPage-XIRDv46K.js → LimitsPage-BIZ1-6BB.js} +2 -2
  32. package/dashboard/dist/assets/{LocalOnlyNotice-CCD-lGWG.js → LocalOnlyNotice-CdHQiPhy.js} +1 -1
  33. package/dashboard/dist/assets/{LoginCard-lDuqSv4f.js → LoginCard-yXLA9geu.js} +1 -1
  34. package/dashboard/dist/assets/{LoginModal-DStt5VXN.js → LoginModal-BF2Pfkqa.js} +1 -1
  35. package/dashboard/dist/assets/{LoginPage-dmVPSL72.js → LoginPage-DuOypZap.js} +1 -1
  36. package/dashboard/dist/assets/{PetPage-ybcZRctF.js → PetPage-BH3xDVV9.js} +1 -1
  37. package/dashboard/dist/assets/{PopoverPopup-Dar_UjxF.js → PopoverPopup-CewF_cGz.js} +1 -1
  38. package/dashboard/dist/assets/{ProviderIcon-Ct9tILpz.js → ProviderIcon-Dg0K0RPn.js} +1 -1
  39. package/dashboard/dist/assets/{ResetPasswordPage-B4yLh5zx.js → ResetPasswordPage-CYwt85O3.js} +1 -1
  40. package/dashboard/dist/assets/{SegmentedControl-BOE1CEzq.js → SegmentedControl-BJkXXomQ.js} +1 -1
  41. package/dashboard/dist/assets/{Select-D-bSh7Zz.js → Select-B3_WH0Qe.js} +1 -1
  42. package/dashboard/dist/assets/{SelectItemText-DMYjXXq4.js → SelectItemText-BUby_DUu.js} +1 -1
  43. package/dashboard/dist/assets/{ServiceStatusPage-CaleS-6_.js → ServiceStatusPage-BDlVZk4i.js} +1 -1
  44. package/dashboard/dist/assets/SessionsPage-TaC9nahK.js +2 -0
  45. package/dashboard/dist/assets/{SettingsPage-iJH0Yyah.js → SettingsPage-CudcQh6m.js} +2 -2
  46. package/dashboard/dist/assets/SkillsPage-iDLQ8lpZ.js +1 -0
  47. package/dashboard/dist/assets/{TokenGalaxy-CuXKoMPg.js → TokenGalaxy-DtYzUsXU.js} +1 -1
  48. package/dashboard/dist/assets/{ToolbarRootContext-Dx5GB7F5.js → ToolbarRootContext-PC8Tg1Jz.js} +1 -1
  49. package/dashboard/dist/assets/TrendMonitor-r8n8YO_9.js +19 -0
  50. package/dashboard/dist/assets/{WidgetsPage-DUaIyGtu.js → WidgetsPage-vzfGccWb.js} +1 -1
  51. package/dashboard/dist/assets/{WrappedPage-BzqlcAUC.js → WrappedPage-DaKuIkYE.js} +1 -1
  52. package/dashboard/dist/assets/{__vite-browser-external-BHz2N7Ih.css → __vite-browser-external-CCMdjCX2.css} +1 -1
  53. package/dashboard/dist/assets/__vite-browser-external-Dnxl50k1.js +45 -0
  54. package/dashboard/dist/assets/{agent-logos-BdWWxza6.js → agent-logos-B7nzn3n2.js} +1 -1
  55. package/dashboard/dist/assets/{arrow-up-right-CANSzmm-.js → arrow-up-right-CsxyvCER.js} +1 -1
  56. package/dashboard/dist/assets/{check-BnJpzXRZ.js → check-hYcC7MdH.js} +1 -1
  57. package/dashboard/dist/assets/{composite-Ccj6KQmh.js → composite-C79LNbex.js} +1 -1
  58. package/dashboard/dist/assets/{copy-C2Yc8Evu.js → copy-Cz4qzE62.js} +1 -1
  59. package/dashboard/dist/assets/{download-B52JTk6Y.js → download-CUaSNE2E.js} +1 -1
  60. package/dashboard/dist/assets/{external-link-DayTwZx-.js → external-link-vq7kIyKK.js} +1 -1
  61. package/dashboard/dist/assets/{flame-B8GEX2yT.js → flame-CB6K4eR6.js} +1 -1
  62. package/dashboard/dist/assets/{format-tokens-mhkbohkb.js → format-tokens-KI-e_JQ-.js} +1 -1
  63. package/dashboard/dist/assets/{icons-o8bPXqRP.js → icons-3maItkrp.js} +1 -1
  64. package/dashboard/dist/assets/{index-MBVWUKpA.js → index-BRXoZ7Jj.js} +1 -1
  65. package/dashboard/dist/assets/{index-CK_CduLm.js → index-C5WrfmLB.js} +1 -1
  66. package/dashboard/dist/assets/{index-Bp0ZHDtz.js → index-CUbC50Zp.js} +1 -1
  67. package/dashboard/dist/assets/{info-D94QM_vX.js → info-r0yGihUe.js} +1 -1
  68. package/dashboard/dist/assets/{limits-providers-BLzMyCiy.js → limits-providers-BN_94oe9.js} +1 -1
  69. package/dashboard/dist/assets/{link-2-CqBmFaDI.js → link-2-J9t0VXWM.js} +1 -1
  70. package/dashboard/dist/assets/{loader-circle-SaxUpDR6.js → loader-circle-DCvOsPze.js} +1 -1
  71. package/dashboard/dist/assets/{maximize-2-BoXqARks.js → maximize-2-CZ9JhGjf.js} +1 -1
  72. package/dashboard/dist/assets/{modifiers.esm-Conh_-BD.js → modifiers.esm-DJ0_WKg0.js} +1 -1
  73. package/dashboard/dist/assets/module-xpSFyNBK.js +3 -0
  74. package/dashboard/dist/assets/{monitor-smartphone-D6qLR9qd.js → monitor-smartphone-nqbWjJx_.js} +1 -1
  75. package/dashboard/dist/assets/{plus-D51mjIc2.js → plus-Cg7wd2jK.js} +1 -1
  76. package/dashboard/dist/assets/{provider-display-DpHPjd_s.js → provider-display-B9L1nRY9.js} +2 -2
  77. package/dashboard/dist/assets/{react-D_AcI466.js → react-D2AD6Pk2.js} +1 -1
  78. package/dashboard/dist/assets/{search-CwLcNdvZ.js → search-uNiWba1h.js} +1 -1
  79. package/dashboard/dist/assets/{skills-api-Dqi7Mjgo.js → skills-api-BBYuAhiL.js} +1 -1
  80. package/dashboard/dist/assets/{sortable.esm-BPDlUxPb.js → sortable.esm-CzPxc949.js} +1 -1
  81. package/dashboard/dist/assets/{store-ikeRIRGd.js → store-D4A7EUwE.js} +1 -1
  82. package/dashboard/dist/assets/{terminal-BfeX3L5T.js → terminal-Crmm_yKF.js} +1 -1
  83. package/dashboard/dist/assets/{trash-2-5_Qlp1k7.js → trash-2-ia9VjRnU.js} +1 -1
  84. package/dashboard/dist/assets/{use-community-stats-D90Fy2_j.js → use-community-stats-KaGCAxaF.js} +1 -1
  85. package/dashboard/dist/assets/use-latest-request-guard-Dy8gSDkk.js +1 -0
  86. package/dashboard/dist/assets/{use-limits-display-prefs-D4BYFaa-.js → use-limits-display-prefs-JSTYlbTD.js} +1 -1
  87. package/dashboard/dist/assets/{use-ordered-list-DJxLNdXY.js → use-ordered-list-C4JnHdE0.js} +1 -1
  88. package/dashboard/dist/assets/use-reduced-motion-AQE0ummK.js +1 -0
  89. package/dashboard/dist/assets/{use-session-efficiency-pref-CAX_GqXc.js → use-session-efficiency-pref-CgyUz5Gc.js} +1 -1
  90. package/dashboard/dist/assets/{use-transform-CeW6Z5R0.js → use-transform-C7-jSJ9k.js} +1 -1
  91. package/dashboard/dist/assets/use-usage-limits-B3YYqn-v.js +1 -0
  92. package/dashboard/dist/assets/{useCurrency-BarGeFyK.js → useCurrency-CK_gcb3J.js} +1 -1
  93. package/dashboard/dist/assets/{useTokenFormat-DUJ9iT9H.js → useTokenFormat-C_a-UkCA.js} +1 -1
  94. package/dashboard/dist/assets/{zap--m0IWoI3.js → zap-BIJNn-Ic.js} +1 -1
  95. package/dashboard/dist/brand-logos/lmstudio.svg +12 -0
  96. package/dashboard/dist/brand-logos/unsloth.svg +1 -0
  97. package/dashboard/dist/index.html +16 -12
  98. package/dashboard/dist/ip-check.html +2 -2
  99. package/dashboard/dist/leaderboard.html +2 -2
  100. package/dashboard/dist/llms.txt +4 -2
  101. package/dashboard/dist/share.html +2 -2
  102. package/package.json +2 -2
  103. package/src/commands/device-login.js +5 -10
  104. package/src/commands/init.js +28 -1
  105. package/src/commands/serve.js +6 -0
  106. package/src/commands/status.js +24 -0
  107. package/src/commands/sync.js +114 -18
  108. package/src/lib/cloud-account.js +102 -29
  109. package/src/lib/fs.js +30 -0
  110. package/src/lib/grok-context-breakdown.js +24 -33
  111. package/src/lib/grok-usage.js +95 -0
  112. package/src/lib/local-api.js +101 -13
  113. package/src/lib/pricing/index.js +15 -0
  114. package/src/lib/pricing/matcher.js +15 -0
  115. package/src/lib/pricing/seed-snapshot.json +1 -1
  116. package/src/lib/rollout.js +1094 -72
  117. package/src/lib/session-analytics.js +100 -40
  118. package/src/lib/usage-limits.js +507 -55
  119. package/src/lib/zcode-limits.js +65 -8
  120. package/dashboard/dist/assets/CommunityStatsModal-aAico565.js +0 -1
  121. package/dashboard/dist/assets/DashboardPage-BByRBsbt.js +0 -1
  122. package/dashboard/dist/assets/DialogDescription-BUrh0Jod.js +0 -1
  123. package/dashboard/dist/assets/SessionsPage-DOaY6_E9.js +0 -2
  124. package/dashboard/dist/assets/SkillsPage-BkzJOBLk.js +0 -1
  125. package/dashboard/dist/assets/TrendMonitor-BuKmsznP.js +0 -19
  126. package/dashboard/dist/assets/__vite-browser-external-CVIGknXl.js +0 -45
  127. package/dashboard/dist/assets/chevron-right-DJm0mbG0.js +0 -1
  128. package/dashboard/dist/assets/module-D2D5r9gC.js +0 -3
  129. package/dashboard/dist/assets/use-reduced-motion-yE3T3cat.js +0 -1
  130. package/dashboard/dist/assets/use-usage-limits-5HP8k9a3.js +0 -1
@@ -15,6 +15,7 @@ const {
15
15
  createUsageDeltaState,
16
16
  snapshotUsageBaselines,
17
17
  } = require("./codex-token-usage");
18
+ const { USD_TICKS_PER_USD, normalizeGrokUsage } = require("./grok-usage");
18
19
 
19
20
  const DEFAULT_SOURCE = "codex";
20
21
  const DEFAULT_MODEL = "unknown";
@@ -2887,6 +2888,7 @@ async function enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets })
2887
2888
  reasoning_output_tokens: zeroTotals.reasoning_output_tokens,
2888
2889
  total_tokens: zeroTotals.total_tokens,
2889
2890
  billable_total_tokens: zeroTotals.billable_total_tokens,
2891
+ total_cost_usd: zeroTotals.total_cost_usd,
2890
2892
  conversation_count: zeroTotals.conversation_count,
2891
2893
  }),
2892
2894
  );
@@ -2905,10 +2907,12 @@ async function enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets })
2905
2907
  hour_start: group.hourStart,
2906
2908
  input_tokens: zeroTotals.input_tokens,
2907
2909
  cached_input_tokens: zeroTotals.cached_input_tokens,
2910
+ cache_creation_input_tokens: zeroTotals.cache_creation_input_tokens,
2908
2911
  output_tokens: zeroTotals.output_tokens,
2909
2912
  reasoning_output_tokens: zeroTotals.reasoning_output_tokens,
2910
2913
  total_tokens: zeroTotals.total_tokens,
2911
2914
  billable_total_tokens: zeroTotals.billable_total_tokens,
2915
+ total_cost_usd: zeroTotals.total_cost_usd,
2912
2916
  conversation_count: zeroTotals.conversation_count,
2913
2917
  }),
2914
2918
  );
@@ -2923,7 +2927,8 @@ async function enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets })
2923
2927
  totals = cloneTotals(bucket.totals);
2924
2928
  addTotals(totals, unknownBucket.totals);
2925
2929
  }
2926
- const key = totalsKey(totals);
2930
+ const usagePrecision = bucket.usage_precision || null;
2931
+ const key = usagePrecision ? `${totalsKey(totals)}|${usagePrecision}` : totalsKey(totals);
2927
2932
  if (bucket.queuedKey === key) continue;
2928
2933
  toAppend.push(
2929
2934
  JSON.stringify({
@@ -2937,6 +2942,8 @@ async function enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets })
2937
2942
  reasoning_output_tokens: totals.reasoning_output_tokens,
2938
2943
  total_tokens: totals.total_tokens,
2939
2944
  billable_total_tokens: totals.billable_total_tokens ?? totals.total_tokens,
2945
+ total_cost_usd: totals.total_cost_usd || 0,
2946
+ usage_precision: usagePrecision || undefined,
2940
2947
  conversation_count: totals.conversation_count,
2941
2948
  }),
2942
2949
  );
@@ -2960,10 +2967,12 @@ async function enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets })
2960
2967
  hour_start: group.hourStart,
2961
2968
  input_tokens: zeroTotals.input_tokens,
2962
2969
  cached_input_tokens: zeroTotals.cached_input_tokens,
2970
+ cache_creation_input_tokens: zeroTotals.cache_creation_input_tokens,
2963
2971
  output_tokens: zeroTotals.output_tokens,
2964
2972
  reasoning_output_tokens: zeroTotals.reasoning_output_tokens,
2965
2973
  total_tokens: zeroTotals.total_tokens,
2966
2974
  billable_total_tokens: zeroTotals.billable_total_tokens,
2975
+ total_cost_usd: zeroTotals.total_cost_usd,
2967
2976
  conversation_count: zeroTotals.conversation_count,
2968
2977
  }),
2969
2978
  );
@@ -2987,6 +2996,7 @@ async function enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets })
2987
2996
  reasoning_output_tokens: zeroTotals.reasoning_output_tokens,
2988
2997
  total_tokens: zeroTotals.total_tokens,
2989
2998
  billable_total_tokens: zeroTotals.billable_total_tokens,
2999
+ total_cost_usd: zeroTotals.total_cost_usd,
2990
3000
  conversation_count: zeroTotals.conversation_count,
2991
3001
  }),
2992
3002
  );
@@ -2994,7 +3004,10 @@ async function enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets })
2994
3004
  }
2995
3005
  }
2996
3006
  if (unknownBucket) unknownBucket.alignedModel = nextAligned;
2997
- const key = totalsKey(unknownBucket.totals);
3007
+ const usagePrecision = unknownBucket.usage_precision || null;
3008
+ const key = usagePrecision
3009
+ ? `${totalsKey(unknownBucket.totals)}|${usagePrecision}`
3010
+ : totalsKey(unknownBucket.totals);
2998
3011
  const outputKey = outputModel === DEFAULT_MODEL ? key : `${key}|${outputModel}`;
2999
3012
  if (unknownBucket.queuedKey === outputKey) continue;
3000
3013
  toAppend.push(
@@ -3009,6 +3022,8 @@ async function enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets })
3009
3022
  reasoning_output_tokens: unknownBucket.totals.reasoning_output_tokens,
3010
3023
  total_tokens: unknownBucket.totals.total_tokens,
3011
3024
  billable_total_tokens: unknownBucket.totals.billable_total_tokens ?? unknownBucket.totals.total_tokens,
3025
+ total_cost_usd: unknownBucket.totals.total_cost_usd || 0,
3026
+ usage_precision: usagePrecision || undefined,
3012
3027
  conversation_count: unknownBucket.totals.conversation_count,
3013
3028
  }),
3014
3029
  );
@@ -3056,6 +3071,7 @@ async function enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets })
3056
3071
  reasoning_output_tokens: group.totals.reasoning_output_tokens,
3057
3072
  total_tokens: group.totals.total_tokens,
3058
3073
  billable_total_tokens: group.totals.billable_total_tokens ?? group.totals.total_tokens,
3074
+ total_cost_usd: group.totals.total_cost_usd || 0,
3059
3075
  conversation_count: group.totals.conversation_count,
3060
3076
  }),
3061
3077
  );
@@ -3111,6 +3127,7 @@ async function enqueueTouchedProjectBuckets({
3111
3127
  reasoning_output_tokens: totals.reasoning_output_tokens,
3112
3128
  total_tokens: totals.total_tokens,
3113
3129
  billable_total_tokens: totals.billable_total_tokens ?? totals.total_tokens,
3130
+ total_cost_usd: totals.total_cost_usd || 0,
3114
3131
  conversation_count: totals.conversation_count,
3115
3132
  }),
3116
3133
  );
@@ -3635,6 +3652,7 @@ function initTotals() {
3635
3652
  reasoning_output_tokens: 0,
3636
3653
  total_tokens: 0,
3637
3654
  billable_total_tokens: 0,
3655
+ total_cost_usd: 0,
3638
3656
  conversation_count: 0,
3639
3657
  };
3640
3658
  }
@@ -3647,6 +3665,9 @@ function addTotals(target, delta) {
3647
3665
  target.reasoning_output_tokens += delta.reasoning_output_tokens || 0;
3648
3666
  target.total_tokens += delta.total_tokens || 0;
3649
3667
  target.billable_total_tokens += delta.billable_total_tokens ?? delta.total_tokens ?? 0;
3668
+ target.total_cost_usd = Math.round(
3669
+ ((target.total_cost_usd || 0) + (delta.total_cost_usd || 0)) * USD_TICKS_PER_USD,
3670
+ ) / USD_TICKS_PER_USD;
3650
3671
  target.conversation_count += delta.conversation_count || 0;
3651
3672
  }
3652
3673
 
@@ -3671,6 +3692,12 @@ function subtractTotals(target, totals) {
3671
3692
  target.billable_total_tokens -
3672
3693
  (totals.billable_total_tokens ?? totals.total_tokens ?? 0),
3673
3694
  );
3695
+ target.total_cost_usd = Math.max(
3696
+ 0,
3697
+ Math.round(
3698
+ ((target.total_cost_usd || 0) - (totals.total_cost_usd || 0)) * USD_TICKS_PER_USD,
3699
+ ) / USD_TICKS_PER_USD,
3700
+ );
3674
3701
  target.conversation_count = Math.max(
3675
3702
  0,
3676
3703
  target.conversation_count - (totals.conversation_count || 0),
@@ -3686,6 +3713,7 @@ function totalsKey(totals) {
3686
3713
  totals.reasoning_output_tokens || 0,
3687
3714
  totals.total_tokens || 0,
3688
3715
  totals.billable_total_tokens ?? totals.total_tokens ?? 0,
3716
+ totals.total_cost_usd || 0,
3689
3717
  totals.conversation_count || 0,
3690
3718
  ].join("|");
3691
3719
  }
@@ -4872,6 +4900,42 @@ function isZcodeNativeMessage(data) {
4872
4900
  );
4873
4901
  }
4874
4902
 
4903
+ // ZCode persists inclusive parent counters in both its legacy OpenCode tables
4904
+ // and the newer model_usage table: cache read/write are already included in
4905
+ // input, and reasoning is already included in output. The shared OpenCode
4906
+ // parser expects disjoint columns, so split the subsets before it computes
4907
+ // queue totals and cost (issue #554).
4908
+ function normalizeZcodeInclusiveTokens(tokens) {
4909
+ if (!tokens || typeof tokens !== "object") return tokens;
4910
+ const rawInput = toNonNegativeInt(tokens.input);
4911
+ const rawOutput = toNonNegativeInt(tokens.output);
4912
+ const cacheRead = toNonNegativeInt(tokens.cache?.read);
4913
+ const cacheWrite = toNonNegativeInt(tokens.cache?.write);
4914
+ const reasoning = toNonNegativeInt(tokens.reasoning);
4915
+ return {
4916
+ ...tokens,
4917
+ input: Math.max(0, rawInput - cacheRead - cacheWrite),
4918
+ output: Math.max(0, rawOutput - reasoning),
4919
+ reasoning,
4920
+ cache: {
4921
+ ...(tokens.cache && typeof tokens.cache === "object" ? tokens.cache : {}),
4922
+ read: cacheRead,
4923
+ write: cacheWrite,
4924
+ },
4925
+ };
4926
+ }
4927
+
4928
+ function normalizeZcodeLegacyMessage(message) {
4929
+ if (!message?.data?.tokens) return message;
4930
+ return {
4931
+ ...message,
4932
+ data: {
4933
+ ...message.data,
4934
+ tokens: normalizeZcodeInclusiveTokens(message.data.tokens),
4935
+ },
4936
+ };
4937
+ }
4938
+
4875
4939
  const ZCODE_NATIVE_USAGE_COLUMNS = new Set([
4876
4940
  "id",
4877
4941
  "logical_request_id",
@@ -5017,18 +5081,6 @@ function readZcodeNativeUsageMessages(dbPath, sqliteOptions = {}) {
5017
5081
  const startedAt = coerceEpochMs(row?.started_at);
5018
5082
  if (!providerID || !modelID || !sessionID || !id || !startedAt) continue;
5019
5083
 
5020
- // ZCode's persisted input/output columns are inclusive parents:
5021
- // cache read/write are subsets of input, and reasoning is a subset of
5022
- // output. TokenTracker stores those five columns disjointly, so split the
5023
- // subsets here before entering the shared OpenCode parser. This preserves
5024
- // sum(input + cache read + cache write + output + reasoning) without
5025
- // billing either subset twice.
5026
- const rawInput = toNonNegativeInt(row?.input_tokens);
5027
- const rawOutput = toNonNegativeInt(row?.output_tokens);
5028
- const cacheRead = toNonNegativeInt(row?.cache_read_input_tokens);
5029
- const cacheWrite = toNonNegativeInt(row?.cache_creation_input_tokens);
5030
- const reasoning = toNonNegativeInt(row?.reasoning_tokens);
5031
-
5032
5084
  const data = {
5033
5085
  id,
5034
5086
  sessionID,
@@ -5036,15 +5088,15 @@ function readZcodeNativeUsageMessages(dbPath, sqliteOptions = {}) {
5036
5088
  providerID,
5037
5089
  modelID,
5038
5090
  time: { created: startedAt, completed: startedAt },
5039
- tokens: {
5040
- input: Math.max(0, rawInput - cacheRead - cacheWrite),
5041
- output: Math.max(0, rawOutput - reasoning),
5042
- reasoning,
5091
+ tokens: normalizeZcodeInclusiveTokens({
5092
+ input: row?.input_tokens,
5093
+ output: row?.output_tokens,
5094
+ reasoning: row?.reasoning_tokens,
5043
5095
  cache: {
5044
- read: cacheRead,
5045
- write: cacheWrite,
5096
+ read: row?.cache_read_input_tokens,
5097
+ write: row?.cache_creation_input_tokens,
5046
5098
  },
5047
- },
5099
+ }),
5048
5100
  };
5049
5101
  if (typeof row?.directory === "string" && row.directory.trim()) {
5050
5102
  data.path = { cwd: row.directory.trim() };
@@ -5057,12 +5109,31 @@ function readZcodeNativeUsageMessages(dbPath, sqliteOptions = {}) {
5057
5109
 
5058
5110
  // Read only genuine ZCode assistant messages (its own GLM models via Z.ai /
5059
5111
  // BigModel), dropping any bundled sub-agent turns. See isZcodeNativeMessage.
5112
+ //
5113
+ // ZCode started writing model_usage after many installations had already
5114
+ // accumulated months of history in the OpenCode message tables. The native
5115
+ // table is authoritative from its first completed row onward, but it is not a
5116
+ // historical backfill. Keep legacy rows before that boundary so merely adding
5117
+ // the new table cannot make older usage disappear from TokenTracker.
5060
5118
  function readZcodeDbMessages(dbPath, sqliteOptions = {}) {
5061
5119
  if (!dbPath || !fssync.existsSync(dbPath)) return [];
5062
5120
  const nativeMessages = readZcodeNativeUsageMessages(dbPath, sqliteOptions);
5063
- if (nativeMessages !== null) return nativeMessages;
5064
- const all = readOpencodeDbMessages(dbPath, sqliteOptions);
5065
- return all.filter((m) => isZcodeNativeMessage(m.data));
5121
+ const legacyMessages = readOpencodeDbMessages(dbPath, sqliteOptions)
5122
+ .filter((message) => isZcodeNativeMessage(message.data))
5123
+ .map(normalizeZcodeLegacyMessage);
5124
+ if (nativeMessages === null) return legacyMessages;
5125
+
5126
+ const nativeStartMs = nativeMessages.reduce((earliest, message) => {
5127
+ const timestampMs = coerceEpochMs(message?.timeUpdated);
5128
+ return timestampMs > 0 ? Math.min(earliest, timestampMs) : earliest;
5129
+ }, Number.POSITIVE_INFINITY);
5130
+ if (!Number.isFinite(nativeStartMs)) return legacyMessages;
5131
+
5132
+ const historicalMessages = legacyMessages.filter((message) => {
5133
+ const timestampMs = coerceEpochMs(message?.timeUpdated);
5134
+ return timestampMs > 0 && timestampMs < nativeStartMs;
5135
+ });
5136
+ return [...historicalMessages, ...nativeMessages];
5066
5137
  }
5067
5138
 
5068
5139
  async function parseOpencodeDbIncremental({
@@ -11132,6 +11203,955 @@ async function parseZedIncremental({
11132
11203
  return { recordsProcessed, eventsAggregated, bucketsQueued };
11133
11204
  }
11134
11205
 
11206
+ // ─────────────────────────────────────────────────────────────────────────────
11207
+ // LM Studio
11208
+ //
11209
+ // Data: pretty-printed OpenAI-compatible final responses beneath
11210
+ // `~/.lmstudio/server-logs/`. The reader recognizes both Chat Completions and
11211
+ // Responses API usage shapes. It scans only response identity, model, timestamp,
11212
+ // and the balanced `usage` object; prompt and response bodies are never parsed
11213
+ // or persisted.
11214
+ // ─────────────────────────────────────────────────────────────────────────────
11215
+
11216
+ const LMSTUDIO_SOURCE = "lmstudio";
11217
+ const LMSTUDIO_WINDOW_BYTES = 8 * 1024 * 1024;
11218
+ const LMSTUDIO_CHUNK_BYTES = 64 * 1024;
11219
+ const LMSTUDIO_IDENTITY_OVERLAP_BYTES = 512;
11220
+ const LMSTUDIO_MESSAGE_LIMIT = 10_000;
11221
+ const LMSTUDIO_USAGE_MARKER = Buffer.from('"usage"');
11222
+
11223
+ function resolveLmstudioHome(env = process.env) {
11224
+ const override = typeof env.TOKENTRACKER_LMSTUDIO_HOME === "string"
11225
+ ? env.TOKENTRACKER_LMSTUDIO_HOME.trim()
11226
+ : "";
11227
+ if (override) return path.resolve(override);
11228
+ const nativeHome = typeof env.LM_STUDIO_HOME === "string"
11229
+ ? env.LM_STUDIO_HOME.trim()
11230
+ : "";
11231
+ if (nativeHome) return path.resolve(nativeHome);
11232
+ return path.join(env.HOME || env.USERPROFILE || os.homedir(), ".lmstudio");
11233
+ }
11234
+
11235
+ async function resolveLmstudioLogFiles(env = process.env) {
11236
+ const root = path.join(resolveLmstudioHome(env), "server-logs");
11237
+ const files = [];
11238
+ const pending = [root];
11239
+ while (pending.length > 0) {
11240
+ const dir = pending.pop();
11241
+ let entries;
11242
+ try {
11243
+ entries = await fs.readdir(dir, { withFileTypes: true });
11244
+ } catch (_e) {
11245
+ continue;
11246
+ }
11247
+ for (const entry of entries) {
11248
+ const fullPath = path.join(dir, entry.name);
11249
+ if (entry.isDirectory()) pending.push(fullPath);
11250
+ else if (entry.isFile() && entry.name.toLowerCase().endsWith(".log")) files.push(fullPath);
11251
+ }
11252
+ }
11253
+ return files.sort((a, b) => a.localeCompare(b));
11254
+ }
11255
+
11256
+ function skipLmstudioWhitespace(buffer, index) {
11257
+ while (index < buffer.length && /\s/.test(String.fromCharCode(buffer[index]))) index += 1;
11258
+ return index;
11259
+ }
11260
+
11261
+ function scanLmstudioUsageObjectStart(buffer, from = 0) {
11262
+ let cursor = Math.max(0, from);
11263
+ while (cursor < buffer.length) {
11264
+ const marker = buffer.indexOf(LMSTUDIO_USAGE_MARKER, cursor);
11265
+ if (marker < 0) break;
11266
+ cursor = marker + LMSTUDIO_USAGE_MARKER.length;
11267
+ if (marker > 0 && buffer[marker - 1] === 0x5c) continue;
11268
+ const colon = skipLmstudioWhitespace(buffer, cursor);
11269
+ if (colon >= buffer.length) return { found: null, certainTo: marker };
11270
+ if (buffer[colon] !== 0x3a) continue;
11271
+ const brace = skipLmstudioWhitespace(buffer, colon + 1);
11272
+ if (brace >= buffer.length) return { found: null, certainTo: marker };
11273
+ if (buffer[brace] === 0x7b) return { found: { marker, objectStart: brace }, certainTo: marker };
11274
+ }
11275
+ return {
11276
+ found: null,
11277
+ certainTo: Math.max(0, buffer.length - Math.max(0, LMSTUDIO_USAGE_MARKER.length - 1)),
11278
+ };
11279
+ }
11280
+
11281
+ function lmstudioBalancedObjectEnd(buffer, start) {
11282
+ if (buffer[start] !== 0x7b) return -1;
11283
+ let depth = 0;
11284
+ let inString = false;
11285
+ let escaped = false;
11286
+ for (let index = start; index < buffer.length; index++) {
11287
+ const byte = buffer[index];
11288
+ if (inString) {
11289
+ if (escaped) escaped = false;
11290
+ else if (byte === 0x5c) escaped = true;
11291
+ else if (byte === 0x22) inString = false;
11292
+ continue;
11293
+ }
11294
+ if (byte === 0x22) inString = true;
11295
+ else if (byte === 0x7b) depth += 1;
11296
+ else if (byte === 0x7d) {
11297
+ depth -= 1;
11298
+ if (depth === 0) return index + 1;
11299
+ }
11300
+ }
11301
+ return -1;
11302
+ }
11303
+
11304
+ function lmstudioJsonStringEnd(buffer, start) {
11305
+ if (buffer[start] !== 0x22) return -1;
11306
+ let escaped = false;
11307
+ for (let index = start + 1; index < buffer.length; index++) {
11308
+ const byte = buffer[index];
11309
+ if (escaped) escaped = false;
11310
+ else if (byte === 0x5c) escaped = true;
11311
+ else if (byte === 0x22) return index + 1;
11312
+ }
11313
+ return -1;
11314
+ }
11315
+
11316
+ function lastLmstudioJsonStringField(buffer, field) {
11317
+ const markerBuffer = Buffer.from(`"${field}"`);
11318
+ let cursor = 0;
11319
+ let found = null;
11320
+ while (cursor < buffer.length) {
11321
+ const index = buffer.indexOf(markerBuffer, cursor);
11322
+ if (index < 0) break;
11323
+ cursor = index + markerBuffer.length;
11324
+ if (index > 0 && buffer[index - 1] === 0x5c) continue;
11325
+ let valueStart = skipLmstudioWhitespace(buffer, cursor);
11326
+ if (buffer[valueStart] !== 0x3a) continue;
11327
+ valueStart = skipLmstudioWhitespace(buffer, valueStart + 1);
11328
+ const valueEnd = lmstudioJsonStringEnd(buffer, valueStart);
11329
+ if (valueEnd < 0) continue;
11330
+ try {
11331
+ const value = JSON.parse(buffer.subarray(valueStart, valueEnd).toString("utf8"));
11332
+ if (typeof value === "string") found = value;
11333
+ } catch (_e) { }
11334
+ }
11335
+ return found;
11336
+ }
11337
+
11338
+ function lastLmstudioTimestamp(buffer) {
11339
+ const text = buffer.toString("utf8");
11340
+ let timestamp = null;
11341
+ for (const rawLine of text.split("\n")) {
11342
+ const line = rawLine.startsWith("\r") ? rawLine.slice(1) : rawLine;
11343
+ const match = /^\[(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})\]\[/.exec(line);
11344
+ if (!match) continue;
11345
+ const date = new Date(
11346
+ Number(match[1]),
11347
+ Number(match[2]) - 1,
11348
+ Number(match[3]),
11349
+ Number(match[4]),
11350
+ Number(match[5]),
11351
+ Number(match[6]),
11352
+ );
11353
+ if (!Number.isNaN(date.getTime())) timestamp = date.getTime();
11354
+ }
11355
+ return timestamp;
11356
+ }
11357
+
11358
+ function normalizeLocalStudioTokens(usage) {
11359
+ if (!usage || typeof usage !== "object") return null;
11360
+ const prompt = toNonNegativeInt(
11361
+ usage.prompt_tokens ?? usage.promptTokens ?? usage.input_tokens ?? usage.inputTokens,
11362
+ );
11363
+ const completion = toNonNegativeInt(
11364
+ usage.completion_tokens ?? usage.completionTokens ?? usage.output_tokens ?? usage.outputTokens,
11365
+ );
11366
+ const total = Math.max(
11367
+ toNonNegativeInt(usage.total_tokens ?? usage.totalTokens),
11368
+ prompt + completion,
11369
+ );
11370
+ if (total <= 0) return null;
11371
+ const promptDetails = usage.prompt_tokens_details
11372
+ || usage.input_tokens_details
11373
+ || usage.inputTokensDetails
11374
+ || {};
11375
+ const outputDetails = usage.completion_tokens_details
11376
+ || usage.output_tokens_details
11377
+ || usage.completionTokensDetails
11378
+ || usage.outputTokensDetails
11379
+ || {};
11380
+ const cacheRead = Math.min(
11381
+ prompt,
11382
+ Math.max(
11383
+ toNonNegativeInt(promptDetails.cached_tokens ?? promptDetails.cache_read_tokens),
11384
+ toNonNegativeInt(usage.cached_tokens),
11385
+ ),
11386
+ );
11387
+ const cacheWrite = Math.min(
11388
+ Math.max(0, prompt - cacheRead),
11389
+ Math.max(
11390
+ toNonNegativeInt(
11391
+ promptDetails.cache_creation_input_tokens ?? promptDetails.cache_write_tokens,
11392
+ ),
11393
+ toNonNegativeInt(usage.cache_creation_input_tokens),
11394
+ ),
11395
+ );
11396
+ const reasoning = Math.min(
11397
+ completion,
11398
+ Math.max(
11399
+ toNonNegativeInt(outputDetails.reasoning_tokens),
11400
+ toNonNegativeInt(usage.reasoning_tokens),
11401
+ ),
11402
+ );
11403
+ return {
11404
+ input_tokens: Math.max(0, total - completion - cacheRead - cacheWrite),
11405
+ cached_input_tokens: cacheRead,
11406
+ cache_creation_input_tokens: cacheWrite,
11407
+ output_tokens: Math.max(0, completion - reasoning),
11408
+ reasoning_output_tokens: reasoning,
11409
+ total_tokens: total,
11410
+ billable_total_tokens: total,
11411
+ total_cost_usd: 0,
11412
+ conversation_count: 1,
11413
+ };
11414
+ }
11415
+
11416
+ function lmstudioResponseId(buffer) {
11417
+ const id = lastLmstudioJsonStringField(buffer, "id");
11418
+ return typeof id === "string" && ["chatcmpl-", "cmpl-", "resp_"].some((prefix) => id.startsWith(prefix))
11419
+ ? id
11420
+ : null;
11421
+ }
11422
+
11423
+ function absorbLmstudioIdentity(identity, buffer) {
11424
+ const responseId = lmstudioResponseId(buffer);
11425
+ const model = lastLmstudioJsonStringField(buffer, "model");
11426
+ const timestamp = lastLmstudioTimestamp(buffer);
11427
+ if (responseId) identity.responseId = responseId;
11428
+ if (model && model.trim()) identity.model = model.trim();
11429
+ if (timestamp) identity.timestamp = timestamp;
11430
+ }
11431
+
11432
+ function normalizeLmstudioResumeIdentity(value) {
11433
+ const identity = {};
11434
+ const responseId = typeof value?.responseId === "string" ? value.responseId : "";
11435
+ if (["chatcmpl-", "cmpl-", "resp_"].some((prefix) => responseId.startsWith(prefix))) {
11436
+ identity.responseId = responseId;
11437
+ }
11438
+ const model = typeof value?.model === "string" ? value.model.trim() : "";
11439
+ if (model) identity.model = model;
11440
+ const timestamp = Number(value?.timestamp);
11441
+ if (Number.isFinite(timestamp) && timestamp > 0) identity.timestamp = timestamp;
11442
+ return identity;
11443
+ }
11444
+
11445
+ function lmstudioRecordFromSlices({ usageBuffer, metadataBuffer, carried, filePath, marker, fallbackTimestamp }) {
11446
+ let usage;
11447
+ try {
11448
+ usage = JSON.parse(usageBuffer.toString("utf8"));
11449
+ } catch (_e) {
11450
+ return null;
11451
+ }
11452
+ const totals = normalizeLocalStudioTokens(usage);
11453
+ if (!totals) return null;
11454
+ const responseId = lmstudioResponseId(metadataBuffer) || carried.responseId || null;
11455
+ const model = normalizeModelInput(
11456
+ lastLmstudioJsonStringField(metadataBuffer, "model") || carried.model,
11457
+ ) || DEFAULT_MODEL;
11458
+ const timestampMs = lastLmstudioTimestamp(metadataBuffer) || carried.timestamp || fallbackTimestamp;
11459
+ const bucketStart = timestampMs
11460
+ ? toUtcHalfHourStart(new Date(timestampMs).toISOString())
11461
+ : null;
11462
+ if (!bucketStart) return null;
11463
+ const fallback = crypto.createHash("sha256")
11464
+ .update(filePath)
11465
+ .update("\0")
11466
+ .update(String(marker))
11467
+ .update("\0")
11468
+ .update(model)
11469
+ .update("\0")
11470
+ .update(totalsKey(totals))
11471
+ .digest("base64url");
11472
+ return {
11473
+ key: responseId ? `lmstudio:${responseId}` : `lmstudio:${fallback}`,
11474
+ model,
11475
+ bucketStart,
11476
+ totals,
11477
+ marker,
11478
+ };
11479
+ }
11480
+
11481
+ async function readLmstudioFileRecords(
11482
+ filePath,
11483
+ {
11484
+ windowBytes = LMSTUDIO_WINDOW_BYTES,
11485
+ chunkBytes = LMSTUDIO_CHUNK_BYTES,
11486
+ startOffset = 0,
11487
+ resumeIdentity,
11488
+ } = {},
11489
+ ) {
11490
+ const handle = await fs.open(filePath, "r");
11491
+ try {
11492
+ const initialStat = await handle.stat();
11493
+ const fallbackTimestamp = initialStat.mtimeMs;
11494
+ const records = [];
11495
+ const initialOffset = Math.min(
11496
+ initialStat.size,
11497
+ Math.max(0, Number.isFinite(startOffset) ? Math.floor(startOffset) : 0),
11498
+ );
11499
+ let window = Buffer.alloc(0);
11500
+ let windowStart = initialOffset;
11501
+ let metadataStart = initialOffset;
11502
+ let scannedTo = initialOffset;
11503
+ let position = initialOffset;
11504
+ let carried = normalizeLmstudioResumeIdentity(resumeIdentity);
11505
+ const chunk = Buffer.alloc(Math.max(1, chunkBytes));
11506
+
11507
+ while (true) {
11508
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, position);
11509
+ if (bytesRead > 0) {
11510
+ window = Buffer.concat([window, chunk.subarray(0, bytesRead)]);
11511
+ position += bytesRead;
11512
+ }
11513
+ const atEof = bytesRead === 0;
11514
+
11515
+ while (true) {
11516
+ const scanFrom = Math.min(window.length, Math.max(0, scannedTo - windowStart));
11517
+ const scan = scanLmstudioUsageObjectStart(window, scanFrom);
11518
+ if (!scan.found) {
11519
+ scannedTo = windowStart + scan.certainTo;
11520
+ break;
11521
+ }
11522
+ const objectEnd = lmstudioBalancedObjectEnd(window, scan.found.objectStart);
11523
+ if (objectEnd < 0) {
11524
+ scannedTo = atEof
11525
+ ? windowStart + window.length
11526
+ : windowStart + scan.found.marker;
11527
+ break;
11528
+ }
11529
+ scannedTo = windowStart + objectEnd;
11530
+ const absoluteMarker = windowStart + scan.found.marker;
11531
+ const absoluteEnd = windowStart + objectEnd;
11532
+ const metadataFrom = Math.min(
11533
+ scan.found.marker,
11534
+ Math.max(0, metadataStart - windowStart),
11535
+ );
11536
+ const record = lmstudioRecordFromSlices({
11537
+ usageBuffer: window.subarray(scan.found.objectStart, objectEnd),
11538
+ metadataBuffer: window.subarray(metadataFrom, scan.found.marker),
11539
+ carried,
11540
+ filePath,
11541
+ marker: absoluteMarker,
11542
+ fallbackTimestamp,
11543
+ });
11544
+ if (record) records.push(record);
11545
+ metadataStart = absoluteEnd;
11546
+ carried = {};
11547
+ }
11548
+
11549
+ const keepFrom = Math.min(window.length, Math.max(0, metadataStart - windowStart));
11550
+ if (keepFrom > 0) {
11551
+ window = window.subarray(keepFrom);
11552
+ windowStart += keepFrom;
11553
+ }
11554
+
11555
+ if (window.length > windowBytes) {
11556
+ const overflow = window.length - windowBytes;
11557
+ const absorbTo = Math.min(
11558
+ window.length,
11559
+ overflow + LMSTUDIO_IDENTITY_OVERLAP_BYTES,
11560
+ );
11561
+ absorbLmstudioIdentity(carried, window.subarray(0, absorbTo));
11562
+ window = window.subarray(overflow);
11563
+ windowStart += overflow;
11564
+ metadataStart = Math.max(metadataStart, windowStart);
11565
+ scannedTo = Math.max(scannedTo, windowStart);
11566
+ }
11567
+
11568
+ if (atEof) {
11569
+ const finalStat = await handle.stat().catch(() => initialStat);
11570
+ const nextIdentity = normalizeLmstudioResumeIdentity(carried);
11571
+ return {
11572
+ records,
11573
+ stat: {
11574
+ dev: finalStat.dev,
11575
+ ino: finalStat.ino,
11576
+ size: finalStat.size,
11577
+ mtimeMs: finalStat.size === position ? finalStat.mtimeMs : -1,
11578
+ resumeOffset: Math.max(metadataStart, position - windowBytes),
11579
+ ...(Object.keys(nextIdentity).length > 0
11580
+ ? { resumeIdentity: nextIdentity }
11581
+ : {}),
11582
+ },
11583
+ };
11584
+ }
11585
+ }
11586
+ } finally {
11587
+ await handle.close().catch(() => {});
11588
+ }
11589
+ }
11590
+
11591
+ function retainNewestPassiveMessages(messages, maxEntries) {
11592
+ const limit = Number.isInteger(maxEntries) && maxEntries > 0
11593
+ ? maxEntries
11594
+ : LMSTUDIO_MESSAGE_LIMIT;
11595
+ const entries = Object.entries(messages);
11596
+ if (entries.length <= limit) return messages;
11597
+ entries.sort((left, right) => {
11598
+ const leftTime = Date.parse(left[1]?.updatedAt || left[1]?.bucketStart || "") || 0;
11599
+ const rightTime = Date.parse(right[1]?.updatedAt || right[1]?.bucketStart || "") || 0;
11600
+ if (leftTime !== rightTime) return rightTime - leftTime;
11601
+ return right[0].localeCompare(left[0]);
11602
+ });
11603
+ return Object.fromEntries(entries.slice(0, limit));
11604
+ }
11605
+
11606
+ function reconcilePassiveUsageEvent({ event, source, messages, hourlyState, touchedBuckets }) {
11607
+ const previous = messages[event.key];
11608
+ const unchanged = previous
11609
+ && previous.model === event.model
11610
+ && previous.bucketStart === event.bucketStart
11611
+ && totalsKey(previous.totals) === totalsKey(event.totals);
11612
+ if (unchanged) return false;
11613
+
11614
+ if (previous?.model && previous?.bucketStart && previous?.totals) {
11615
+ const oldBucket = getHourlyBucket(hourlyState, source, previous.model, previous.bucketStart);
11616
+ subtractTotals(oldBucket.totals, previous.totals);
11617
+ touchedBuckets.add(bucketKey(source, previous.model, previous.bucketStart));
11618
+ }
11619
+ const bucket = getHourlyBucket(hourlyState, source, event.model, event.bucketStart);
11620
+ addTotals(bucket.totals, event.totals);
11621
+ touchedBuckets.add(bucketKey(source, event.model, event.bucketStart));
11622
+ messages[event.key] = {
11623
+ model: event.model,
11624
+ bucketStart: event.bucketStart,
11625
+ totals: event.totals,
11626
+ updatedAt: new Date().toISOString(),
11627
+ };
11628
+ return true;
11629
+ }
11630
+
11631
+ async function parseLmstudioIncremental({
11632
+ logFiles,
11633
+ cursors,
11634
+ queuePath,
11635
+ onProgress,
11636
+ messageLimit = LMSTUDIO_MESSAGE_LIMIT,
11637
+ readerOptions,
11638
+ } = {}) {
11639
+ await ensureDir(path.dirname(queuePath));
11640
+ const files = Array.isArray(logFiles) ? [...logFiles].sort((a, b) => a.localeCompare(b)) : [];
11641
+ const priorState = cursors.lmstudio && typeof cursors.lmstudio === "object"
11642
+ ? cursors.lmstudio
11643
+ : {};
11644
+ let fileState = priorState.files && typeof priorState.files === "object"
11645
+ ? { ...priorState.files }
11646
+ : {};
11647
+ let messages = priorState.messages && typeof priorState.messages === "object"
11648
+ ? priorState.messages
11649
+ : {};
11650
+ const hourlyState = normalizeHourlyState(cursors?.hourly);
11651
+ const touchedBuckets = new Set();
11652
+ const presentFiles = new Set(files);
11653
+ const cb = typeof onProgress === "function" ? onProgress : null;
11654
+ const effectiveMessageLimit = Number.isInteger(messageLimit) && messageLimit > 0
11655
+ ? messageLimit
11656
+ : LMSTUDIO_MESSAGE_LIMIT;
11657
+ let recordsProcessed = 0;
11658
+ let eventsAggregated = 0;
11659
+ let stateChanged = false;
11660
+ const readOptions = readerOptions && typeof readerOptions === "object" ? readerOptions : {};
11661
+ const filePlans = [];
11662
+ let allFilesReadable = true;
11663
+
11664
+ for (const filePath of files) {
11665
+ const previousFile = fileState[filePath];
11666
+ let stat;
11667
+ try {
11668
+ stat = await fs.stat(filePath);
11669
+ } catch (_e) {
11670
+ allFilesReadable = false;
11671
+ continue;
11672
+ }
11673
+ const unchanged = Boolean(previousFile
11674
+ && previousFile.dev === stat.dev
11675
+ && previousFile.ino === stat.ino
11676
+ && previousFile.size === stat.size
11677
+ && previousFile.mtimeMs === stat.mtimeMs
11678
+ && Number.isFinite(previousFile.resumeOffset));
11679
+ const canResume = Boolean(previousFile
11680
+ && previousFile.dev === stat.dev
11681
+ && previousFile.ino === stat.ino
11682
+ && (stat.size > previousFile.size || previousFile.mtimeMs === -1)
11683
+ && Number.isFinite(previousFile.resumeOffset)
11684
+ && previousFile.resumeOffset >= 0
11685
+ && previousFile.resumeOffset <= stat.size);
11686
+ filePlans.push({ filePath, previousFile, unchanged, canResume });
11687
+ }
11688
+
11689
+ const requiresRebuild = filePlans.some(
11690
+ ({ previousFile, unchanged, canResume }) => previousFile && !unchanged && !canResume,
11691
+ );
11692
+ if (requiresRebuild) {
11693
+ // Bounded message retention cannot safely deduplicate a replay from byte
11694
+ // zero. Rebuild every current LM Studio log in isolation, then replace only
11695
+ // this source's buckets while preserving their queue fingerprints.
11696
+ if (!allFilesReadable || filePlans.length !== files.length) {
11697
+ return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
11698
+ }
11699
+ const rebuiltHourly = normalizeHourlyState(null);
11700
+ const rebuiltMessages = {};
11701
+ const rebuiltFiles = {};
11702
+ const rebuiltTouched = new Set();
11703
+ let rebuiltRecords = 0;
11704
+ let rebuiltEvents = 0;
11705
+ try {
11706
+ for (let index = 0; index < filePlans.length; index++) {
11707
+ const { filePath } = filePlans[index];
11708
+ const parsed = await readLmstudioFileRecords(filePath, {
11709
+ ...readOptions,
11710
+ startOffset: 0,
11711
+ resumeIdentity: undefined,
11712
+ });
11713
+ for (const event of parsed.records) {
11714
+ if (reconcilePassiveUsageEvent({
11715
+ event,
11716
+ source: LMSTUDIO_SOURCE,
11717
+ messages: rebuiltMessages,
11718
+ hourlyState: rebuiltHourly,
11719
+ touchedBuckets: rebuiltTouched,
11720
+ })) rebuiltEvents += 1;
11721
+ }
11722
+ rebuiltFiles[filePath] = { ...parsed.stat, updatedAt: new Date().toISOString() };
11723
+ rebuiltRecords += 1;
11724
+ if (cb) {
11725
+ cb({
11726
+ index: index + 1,
11727
+ total: files.length,
11728
+ recordsProcessed: rebuiltRecords,
11729
+ eventsAggregated: rebuiltEvents,
11730
+ bucketsQueued: rebuiltTouched.size,
11731
+ });
11732
+ }
11733
+ }
11734
+ } catch (error) {
11735
+ if (process.env.TOKENTRACKER_DEBUG) {
11736
+ process.stderr.write(`[lmstudio] rebuild deferred: ${error?.message || error}\n`);
11737
+ }
11738
+ return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
11739
+ }
11740
+
11741
+ for (const [key, bucket] of Object.entries(hourlyState.buckets || {})) {
11742
+ if (parseBucketKey(key).source !== LMSTUDIO_SOURCE || !bucket?.totals) continue;
11743
+ bucket.totals = initTotals();
11744
+ touchedBuckets.add(key);
11745
+ }
11746
+ for (const [key, bucket] of Object.entries(rebuiltHourly.buckets || {})) {
11747
+ const current = hourlyState.buckets[key];
11748
+ if (current && typeof current === "object") current.totals = cloneTotals(bucket.totals);
11749
+ else hourlyState.buckets[key] = bucket;
11750
+ touchedBuckets.add(key);
11751
+ }
11752
+ fileState = rebuiltFiles;
11753
+ messages = rebuiltMessages;
11754
+ recordsProcessed = rebuiltRecords;
11755
+ eventsAggregated = rebuiltEvents;
11756
+ stateChanged = true;
11757
+ } else {
11758
+ for (let index = 0; index < filePlans.length; index++) {
11759
+ const { filePath, previousFile, unchanged, canResume } = filePlans[index];
11760
+ if (!unchanged) {
11761
+ try {
11762
+ const parsed = await readLmstudioFileRecords(filePath, {
11763
+ ...readOptions,
11764
+ startOffset: canResume ? previousFile.resumeOffset : 0,
11765
+ resumeIdentity: canResume ? previousFile.resumeIdentity : undefined,
11766
+ });
11767
+ for (const event of parsed.records) {
11768
+ if (reconcilePassiveUsageEvent({
11769
+ event,
11770
+ source: LMSTUDIO_SOURCE,
11771
+ messages,
11772
+ hourlyState,
11773
+ touchedBuckets,
11774
+ })) eventsAggregated += 1;
11775
+ }
11776
+ fileState[filePath] = { ...parsed.stat, updatedAt: new Date().toISOString() };
11777
+ recordsProcessed += 1;
11778
+ stateChanged = true;
11779
+ } catch (error) {
11780
+ if (process.env.TOKENTRACKER_DEBUG) {
11781
+ process.stderr.write(`[lmstudio] skipped ${filePath}: ${error?.message || error}\n`);
11782
+ }
11783
+ }
11784
+ }
11785
+ if (cb) {
11786
+ cb({
11787
+ index: index + 1,
11788
+ total: files.length,
11789
+ recordsProcessed,
11790
+ eventsAggregated,
11791
+ bucketsQueued: touchedBuckets.size,
11792
+ });
11793
+ }
11794
+ }
11795
+ }
11796
+
11797
+ for (const filePath of Object.keys(fileState)) {
11798
+ if (!presentFiles.has(filePath)) {
11799
+ delete fileState[filePath];
11800
+ stateChanged = true;
11801
+ }
11802
+ }
11803
+ if (
11804
+ !stateChanged
11805
+ && touchedBuckets.size === 0
11806
+ && Object.keys(messages).length <= effectiveMessageLimit
11807
+ ) {
11808
+ return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
11809
+ }
11810
+ const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets });
11811
+ const updatedAt = new Date().toISOString();
11812
+ hourlyState.updatedAt = updatedAt;
11813
+ cursors.hourly = hourlyState;
11814
+ cursors.lmstudio = {
11815
+ files: fileState,
11816
+ messages: retainNewestPassiveMessages(messages, effectiveMessageLimit),
11817
+ updatedAt,
11818
+ };
11819
+ return { recordsProcessed, eventsAggregated, bucketsQueued };
11820
+ }
11821
+
11822
+ // ─────────────────────────────────────────────────────────────────────────────
11823
+ // Unsloth Studio
11824
+ //
11825
+ // Durable inference usage lives in `studio.db`. The SQL projections below
11826
+ // extract only stable IDs, timestamps, model identifiers, and scalar counters.
11827
+ // Message content, attachments, API subjects, credentials, and training data
11828
+ // never leave SQLite.
11829
+ // ─────────────────────────────────────────────────────────────────────────────
11830
+
11831
+ const UNSLOTH_SOURCE = "unsloth";
11832
+ const UNSLOTH_SQL_PAGE_SIZE = 500;
11833
+ const UNSLOTH_CURSOR_OVERLAP_ROWS = 256;
11834
+ const UNSLOTH_METERED_PROVIDER_TYPES = new Set([
11835
+ "anthropic",
11836
+ "deepseek",
11837
+ "gemini",
11838
+ "huggingface",
11839
+ "kimi",
11840
+ "mistral",
11841
+ "openai",
11842
+ "openrouter",
11843
+ "qwen",
11844
+ ]);
11845
+
11846
+ function resolveUnslothDbPath(env = process.env) {
11847
+ const override = typeof env.TOKENTRACKER_UNSLOTH_DB === "string"
11848
+ ? env.TOKENTRACKER_UNSLOTH_DB.trim()
11849
+ : "";
11850
+ if (override) return path.resolve(override);
11851
+ const studioHome = typeof env.UNSLOTH_STUDIO_HOME === "string"
11852
+ ? env.UNSLOTH_STUDIO_HOME.trim()
11853
+ : "";
11854
+ if (studioHome) return path.join(path.resolve(studioHome), "studio.db");
11855
+ return path.join(env.HOME || env.USERPROFILE || os.homedir(), ".unsloth", "studio", "studio.db");
11856
+ }
11857
+
11858
+ function unslothSqliteFingerprint(dbPath) {
11859
+ const fingerprint = sqliteSidecarFingerprint(dbPath);
11860
+ delete fingerprint["-shm"];
11861
+ return fingerprint;
11862
+ }
11863
+
11864
+ function unslothRowPosition(row) {
11865
+ const createdAt = row?.created_at == null ? "" : String(row.created_at);
11866
+ const id = row?.id == null ? "" : String(row.id);
11867
+ return createdAt && id ? { createdAt, id } : null;
11868
+ }
11869
+
11870
+ function unslothPositionClause(rowAlias, position, inclusive) {
11871
+ if (!position?.createdAt || !position?.id) return null;
11872
+ const createdAt = sqliteStringLiteral(position.createdAt);
11873
+ const id = sqliteStringLiteral(position.id);
11874
+ const idOperator = inclusive ? ">=" : ">";
11875
+ return `(${rowAlias}.created_at > ${createdAt} OR ` +
11876
+ `(${rowAlias}.created_at = ${createdAt} AND ${rowAlias}.id ${idOperator} ${id}))`;
11877
+ }
11878
+
11879
+ function readUnslothRowsPaged({
11880
+ dbPath,
11881
+ select,
11882
+ from,
11883
+ where,
11884
+ rowAlias,
11885
+ lowerBound,
11886
+ options,
11887
+ }) {
11888
+ const requestedPageSize = Number(options?.pageSize);
11889
+ const pageSize = Number.isInteger(requestedPageSize) && requestedPageSize > 0
11890
+ ? Math.min(requestedPageSize, UNSLOTH_SQL_PAGE_SIZE)
11891
+ : UNSLOTH_SQL_PAGE_SIZE;
11892
+ const rows = [];
11893
+ let after = null;
11894
+
11895
+ while (true) {
11896
+ const clauses = [where, `${rowAlias}.created_at IS NOT NULL`, `${rowAlias}.id IS NOT NULL`];
11897
+ const positionClause = unslothPositionClause(rowAlias, after || lowerBound, !after);
11898
+ if (positionClause) clauses.push(positionClause);
11899
+ const page = readSqliteJsonRows(dbPath, `
11900
+ SELECT
11901
+ ${select}
11902
+ FROM ${from}
11903
+ WHERE ${clauses.filter(Boolean).join(" AND ")}
11904
+ ORDER BY ${rowAlias}.created_at, ${rowAlias}.id
11905
+ LIMIT ${pageSize}
11906
+ `.trim(), options);
11907
+ rows.push(...page);
11908
+ if (page.length < pageSize) break;
11909
+
11910
+ const next = unslothRowPosition(page[page.length - 1]);
11911
+ if (!next || (
11912
+ after && next.createdAt === after.createdAt && next.id === after.id
11913
+ )) {
11914
+ throw new Error("Unsloth Studio pagination did not advance");
11915
+ }
11916
+ after = next;
11917
+ }
11918
+
11919
+ return rows;
11920
+ }
11921
+
11922
+ function readUnslothUsageRows(dbPath, sqliteOptions = {}, scanState = {}) {
11923
+ if (!dbPath || !fssync.existsSync(dbPath)) return [];
11924
+ const options = {
11925
+ label: "Unsloth Studio",
11926
+ maxBuffer: 32 * 1024 * 1024,
11927
+ timeout: 30_000,
11928
+ readOnly: true,
11929
+ throwOnReadFailure: true,
11930
+ ...sqliteOptions,
11931
+ };
11932
+ const tables = new Set(
11933
+ readSqliteJsonRows(
11934
+ dbPath,
11935
+ "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('chat_messages','chat_threads','api_usage_events')",
11936
+ options,
11937
+ ).map((row) => row?.name).filter(Boolean),
11938
+ );
11939
+ const rows = [];
11940
+
11941
+ if (tables.has("chat_messages")) {
11942
+ const joinThread = tables.has("chat_threads")
11943
+ ? "LEFT JOIN chat_threads t ON t.id = m.thread_id"
11944
+ : "";
11945
+ const threadModel = tables.has("chat_threads") ? "t.model_id" : "NULL";
11946
+ const field = (jsonPath, alias) =>
11947
+ `CASE WHEN json_valid(m.metadata_json) THEN json_extract(m.metadata_json, '${jsonPath}') ELSE NULL END AS ${alias}`;
11948
+ // Re-read a bounded tail because Studio finalizes recent rows in place and
11949
+ // does not expose an updated_at column. Older immutable history stays behind
11950
+ // the persisted timestamp/ID overlap cursor.
11951
+ const chatRows = readUnslothRowsPaged({
11952
+ dbPath,
11953
+ select: `
11954
+ 'chat' AS usage_kind,
11955
+ m.id,
11956
+ m.created_at,
11957
+ ${field("$.responseDetails.responseModelId", "response_model")},
11958
+ ${field("$.contextUsage.modelId", "requested_model")},
11959
+ ${field("$.responseDetails.providerType", "provider_type")},
11960
+ ${threadModel} AS fallback_model,
11961
+ ${field("$.contextUsage.promptTokens", "prompt_tokens")},
11962
+ ${field("$.contextUsage.completionTokens", "completion_tokens")},
11963
+ ${field("$.contextUsage.totalTokens", "total_tokens")},
11964
+ ${field("$.contextUsage.cachedTokens", "cached_tokens")},
11965
+ ${field("$.contextUsage.cacheWriteTokens", "cache_write_tokens")},
11966
+ ${field("$.contextUsage.reasoningTokens", "reasoning_tokens")}
11967
+ `.trim(),
11968
+ from: `chat_messages m ${joinThread}`,
11969
+ where: "m.role = 'assistant'",
11970
+ rowAlias: "m",
11971
+ lowerBound: scanState?.chat?.overlap,
11972
+ options,
11973
+ });
11974
+ rows.push(...chatRows);
11975
+ }
11976
+
11977
+ if (tables.has("api_usage_events")) {
11978
+ const columns = new Set(
11979
+ readSqliteJsonRows(dbPath, "PRAGMA table_info(api_usage_events)", options)
11980
+ .map((row) => row?.name)
11981
+ .filter(Boolean),
11982
+ );
11983
+ const required = ["id", "model", "prompt_tokens", "completion_tokens", "total_tokens", "created_at"];
11984
+ if (required.every((column) => columns.has(column))) {
11985
+ rows.push(...readUnslothRowsPaged({
11986
+ dbPath,
11987
+ select: `
11988
+ 'api' AS usage_kind,
11989
+ e.id,
11990
+ e.created_at,
11991
+ e.model AS response_model,
11992
+ e.model AS requested_model,
11993
+ 'local' AS provider_type,
11994
+ NULL AS fallback_model,
11995
+ e.prompt_tokens,
11996
+ e.completion_tokens,
11997
+ e.total_tokens,
11998
+ 0 AS cached_tokens,
11999
+ 0 AS cache_write_tokens,
12000
+ 0 AS reasoning_tokens
12001
+ `.trim(),
12002
+ from: "api_usage_events e",
12003
+ where: "",
12004
+ rowAlias: "e",
12005
+ lowerBound: scanState?.api?.overlap,
12006
+ options,
12007
+ }));
12008
+ }
12009
+ }
12010
+ return rows;
12011
+ }
12012
+
12013
+ function qualifyUnslothModel(row) {
12014
+ const rawModel = normalizeModelInput(row?.response_model)
12015
+ || normalizeModelInput(row?.requested_model)
12016
+ || normalizeModelInput(row?.fallback_model)
12017
+ || DEFAULT_MODEL;
12018
+ const providerType = normalizeMessageKeyPart(row?.provider_type).toLowerCase();
12019
+ if (row?.usage_kind === "api" || providerType === "local") {
12020
+ return `local/${rawModel}`;
12021
+ }
12022
+ if (!UNSLOTH_METERED_PROVIDER_TYPES.has(providerType)) {
12023
+ return `unpriced/${providerType || "unknown"}/${rawModel}`;
12024
+ }
12025
+ const lowerModel = rawModel.toLowerCase();
12026
+ return lowerModel.startsWith(`${providerType}/`)
12027
+ ? rawModel
12028
+ : `${providerType}/${rawModel}`;
12029
+ }
12030
+
12031
+ function normalizeUnslothUsageRow(row) {
12032
+ const totals = normalizeLocalStudioTokens({
12033
+ prompt_tokens: row?.prompt_tokens,
12034
+ completion_tokens: row?.completion_tokens,
12035
+ total_tokens: row?.total_tokens,
12036
+ cached_tokens: row?.cached_tokens,
12037
+ cache_creation_input_tokens: row?.cache_write_tokens,
12038
+ reasoning_tokens: row?.reasoning_tokens,
12039
+ });
12040
+ const id = normalizeMessageKeyPart(row?.id == null ? "" : String(row.id));
12041
+ const timestampMs = coerceEpochMs(row?.created_at) || parseIsoTimestampMs(row?.created_at);
12042
+ const bucketStart = timestampMs
12043
+ ? toUtcHalfHourStart(new Date(timestampMs).toISOString())
12044
+ : null;
12045
+ if (!id || !totals || !bucketStart) return null;
12046
+ const kind = row?.usage_kind === "api" ? "api" : "chat";
12047
+ return {
12048
+ key: `unsloth:${kind}:${id}`,
12049
+ model: qualifyUnslothModel(row),
12050
+ bucketStart,
12051
+ totals,
12052
+ };
12053
+ }
12054
+
12055
+ async function parseUnslothIncremental({
12056
+ dbPath,
12057
+ cursors,
12058
+ queuePath,
12059
+ onProgress,
12060
+ env,
12061
+ sqliteOptions,
12062
+ overlapRows = UNSLOTH_CURSOR_OVERLAP_ROWS,
12063
+ } = {}) {
12064
+ await ensureDir(path.dirname(queuePath));
12065
+ const resolvedDb = dbPath || resolveUnslothDbPath(env || process.env);
12066
+ const priorState = cursors.unsloth && typeof cursors.unsloth === "object"
12067
+ ? cursors.unsloth
12068
+ : {};
12069
+ const messages = priorState.messages && typeof priorState.messages === "object"
12070
+ ? priorState.messages
12071
+ : {};
12072
+ if (!resolvedDb || !fssync.existsSync(resolvedDb)) {
12073
+ cursors.unsloth = { ...priorState, messages, updatedAt: new Date().toISOString() };
12074
+ return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
12075
+ }
12076
+
12077
+ const initialFingerprint = unslothSqliteFingerprint(resolvedDb);
12078
+ const hasBoundedScan = priorState.scan
12079
+ && typeof priorState.scan === "object"
12080
+ && Object.keys(messages).length <= UNSLOTH_CURSOR_OVERLAP_ROWS * 2;
12081
+ if (hasBoundedScan && sameSqliteFingerprint(initialFingerprint, priorState.fingerprint)) {
12082
+ return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
12083
+ }
12084
+
12085
+ const rows = readUnslothUsageRows(resolvedDb, sqliteOptions, priorState.scan);
12086
+ const hourlyState = normalizeHourlyState(cursors?.hourly);
12087
+ const touchedBuckets = new Set();
12088
+ const cb = typeof onProgress === "function" ? onProgress : null;
12089
+ let recordsProcessed = 0;
12090
+ let eventsAggregated = 0;
12091
+ for (let index = 0; index < rows.length; index++) {
12092
+ const event = normalizeUnslothUsageRow(rows[index]);
12093
+ recordsProcessed += 1;
12094
+ if (event && reconcilePassiveUsageEvent({
12095
+ event,
12096
+ source: UNSLOTH_SOURCE,
12097
+ messages,
12098
+ hourlyState,
12099
+ touchedBuckets,
12100
+ })) eventsAggregated += 1;
12101
+ if (cb) {
12102
+ cb({
12103
+ index: index + 1,
12104
+ total: rows.length,
12105
+ recordsProcessed,
12106
+ eventsAggregated,
12107
+ bucketsQueued: touchedBuckets.size,
12108
+ });
12109
+ }
12110
+ }
12111
+
12112
+ const retention = Number.isInteger(overlapRows) && overlapRows > 0
12113
+ ? Math.min(overlapRows, UNSLOTH_CURSOR_OVERLAP_ROWS)
12114
+ : UNSLOTH_CURSOR_OVERLAP_ROWS;
12115
+ const nextScan = {};
12116
+ const retainedMessageKeys = new Set();
12117
+ for (const kind of ["chat", "api"]) {
12118
+ const kindRows = rows.filter((row) => (row?.usage_kind === "api" ? "api" : "chat") === kind);
12119
+ if (kindRows.length === 0) {
12120
+ if (priorState.scan?.[kind]) nextScan[kind] = priorState.scan[kind];
12121
+ for (const key of Object.keys(messages)) {
12122
+ if (key.startsWith(`unsloth:${kind}:`)) retainedMessageKeys.add(key);
12123
+ }
12124
+ continue;
12125
+ }
12126
+ const tail = kindRows.slice(-retention);
12127
+ const overlap = unslothRowPosition(tail[0]);
12128
+ const latest = unslothRowPosition(kindRows[kindRows.length - 1]);
12129
+ if (overlap && latest) nextScan[kind] = { overlap, latest };
12130
+ for (const row of tail) {
12131
+ const event = normalizeUnslothUsageRow(row);
12132
+ if (event) retainedMessageKeys.add(event.key);
12133
+ }
12134
+ }
12135
+ for (const key of Object.keys(messages)) {
12136
+ if (key.startsWith("unsloth:") && !retainedMessageKeys.has(key)) delete messages[key];
12137
+ }
12138
+
12139
+ const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets });
12140
+ const finalFingerprint = unslothSqliteFingerprint(resolvedDb);
12141
+ const updatedAt = new Date().toISOString();
12142
+ hourlyState.updatedAt = updatedAt;
12143
+ cursors.hourly = hourlyState;
12144
+ cursors.unsloth = {
12145
+ messages,
12146
+ scan: nextScan,
12147
+ fingerprint: sameSqliteFingerprint(initialFingerprint, finalFingerprint)
12148
+ ? finalFingerprint
12149
+ : initialFingerprint,
12150
+ updatedAt,
12151
+ };
12152
+ return { recordsProcessed, eventsAggregated, bucketsQueued };
12153
+ }
12154
+
11135
12155
  // ─────────────────────────────────────────────────────────────────────────────
11136
12156
  // AnythingLLM Desktop (Mintplex Labs)
11137
12157
  //
@@ -14223,6 +15243,13 @@ async function migrateCopilotChatLogRecordDedup({
14223
15243
  touchedBuckets,
14224
15244
  seenIds,
14225
15245
  } = {}) {
15246
+ // A v2 cursor can retain an offset for a rotated or deleted OTEL file that
15247
+ // is no longer returned by discovery. Its historical contribution cannot be
15248
+ // reconciled, but the stale offset must not block migration of new files.
15249
+ const availableFiles = new Set(Array.isArray(files) ? files : []);
15250
+ for (const filePath of Object.keys(fileOffsets || {})) {
15251
+ if (!availableFiles.has(filePath)) delete fileOffsets[filePath];
15252
+ }
14226
15253
  const trackedFiles = Object.keys(fileOffsets || {}).filter(
14227
15254
  (filePath) => Number(fileOffsets[filePath]?.size) > 0,
14228
15255
  );
@@ -14230,11 +15257,6 @@ async function migrateCopilotChatLogRecordDedup({
14230
15257
  return { applied: true, changed: false };
14231
15258
  }
14232
15259
 
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
15260
  const oldSeen = new Set();
14239
15261
  const newSeen = new Set();
14240
15262
  const oldTotals = new Map();
@@ -14314,7 +15336,10 @@ async function migrateCopilotChatLogRecordDedup({
14314
15336
  }
14315
15337
 
14316
15338
  const keys = new Set([...oldTotals.keys(), ...newTotals.keys()]);
14317
- for (const key of keys) {
15339
+ // Only keys that existed in the v2 contribution need coverage validation.
15340
+ // A key that exists only in newTotals is a newly recovered Chat request; its
15341
+ // old contribution is zero, so an absent old bucket is expected.
15342
+ for (const key of oldTotals.keys()) {
14318
15343
  const [model, bucketStart] = JSON.parse(key);
14319
15344
  const oldUsage = oldTotals.get(key) || initTotals();
14320
15345
  if (!copilotTotalsCover(
@@ -16096,14 +17121,14 @@ async function parseCopilotAppDbIncremental({
16096
17121
  // ─────────────────────────────────────────────────────────────────────────────
16097
17122
  // Grok Build (xAI) — passive reader for ~/.grok/sessions/**/updates.jsonl + signals.json
16098
17123
  // Triggered either by full scan in sync or by the SessionEnd hook writing a signal.
16099
- // updates.jsonl exposes cumulative totalTokens metadata. Grok still does not
16100
- // expose a stable prompt/output/cache split locally, so these rows keep the
16101
- // estimated input/output split while using better local telemetry for totals.
17124
+ // turn_completed.usage exposes the reported input/output/cache/reasoning split
17125
+ // and, on current Grok builds, exact server cost ticks. Older/partial sessions
17126
+ // without that event retain the explicitly isolated context-watermark fallback.
16102
17127
  // ─────────────────────────────────────────────────────────────────────────────
16103
17128
 
16104
17129
  const GROK_ESTIMATED_INPUT_RATIO = 0.8;
16105
- // v4: bill from turn_completed.usage (true cumulative API usage), not context-window totalTokens.
16106
- const GROK_CURSOR_VERSION = 4;
17130
+ // v5: split cache creation + reasoning correctly and retain reported cost.
17131
+ const GROK_CURSOR_VERSION = 5;
16107
17132
 
16108
17133
  function resolveGrokBuildHome(env = process.env) {
16109
17134
  if (env.TOKENTRACKER_GROK_HOME) return env.TOKENTRACKER_GROK_HOME;
@@ -16346,37 +17371,13 @@ function canonicalizeGrokUsageModel(model) {
16346
17371
  }
16347
17372
 
16348
17373
  function normalizeGrokTurnUsage(usage, model, timestamp, eventId) {
16349
- if (!usage || typeof usage !== "object") return null;
16350
- const inputRaw = normalizeNonNegativeNumber(
16351
- usage.inputTokens ?? usage.input_tokens,
16352
- );
16353
- const output = normalizeNonNegativeNumber(
16354
- usage.outputTokens ?? usage.output_tokens,
16355
- );
16356
- const cached = normalizeNonNegativeNumber(
16357
- usage.cachedReadTokens ??
16358
- usage.cache_read_input_tokens ??
16359
- usage.cached_input_tokens,
16360
- );
16361
- const reasoning = normalizeNonNegativeNumber(
16362
- usage.reasoningTokens ?? usage.reasoning_output_tokens,
16363
- );
16364
- // Grok reports inputTokens as the full prompt (including cache hits). Split
16365
- // so pricing can apply cache_read rates correctly.
16366
- const nonCachedInput = Math.max(0, inputRaw - cached);
16367
- let total = normalizeNonNegativeNumber(usage.totalTokens ?? usage.total_tokens);
16368
- if (total <= 0) {
16369
- total = inputRaw + output + reasoning;
16370
- }
16371
- if (total <= 0 && nonCachedInput <= 0 && cached <= 0 && output <= 0) return null;
17374
+ const normalized = normalizeGrokUsage(usage);
17375
+ if (!normalized) return null;
16372
17376
  return {
16373
- input_tokens: nonCachedInput,
16374
- cached_input_tokens: cached,
16375
- cache_creation_input_tokens: 0,
16376
- output_tokens: output,
16377
- reasoning_output_tokens: reasoning,
16378
- total_tokens: total > 0 ? total : nonCachedInput + cached + output + reasoning,
16379
- billable_total_tokens: total > 0 ? total : nonCachedInput + cached + output + reasoning,
17377
+ ...normalized,
17378
+ // A missing/partial cost must fall back to model pricing, not turn into a
17379
+ // falsely precise $0 row in the hourly queue.
17380
+ total_cost_usd: normalized.total_cost_usd ?? 0,
16380
17381
  conversation_count: 1,
16381
17382
  model: canonicalizeGrokUsageModel(model),
16382
17383
  timestamp,
@@ -16540,6 +17541,12 @@ function estimateGrokTokenDelta(totalTokens, conversationCount, options = {}) {
16540
17541
  };
16541
17542
  }
16542
17543
 
17544
+ function mergeGrokUsagePrecision(current, next) {
17545
+ if (!current) return next;
17546
+ if (!next || current === next) return current;
17547
+ return "mixed";
17548
+ }
17549
+
16543
17550
  function clearGrokHourlyBuckets(hourlyState) {
16544
17551
  if (!hourlyState || typeof hourlyState !== "object") return;
16545
17552
  const buckets = hourlyState.buckets && typeof hourlyState.buckets === "object" ? hourlyState.buckets : null;
@@ -16668,9 +17675,9 @@ async function parseGrokBuildIncremental({
16668
17675
  const prevVersion = Number(grokState.version) || 0;
16669
17676
  const needsTurnUsageMigration = prevVersion < GROK_CURSOR_VERSION;
16670
17677
 
16671
- // v3 and earlier treated context-window totalTokens as cumulative spend, which
16672
- // undercounts heavily (often 10-50x) and mis-splits input/output. Rebuild from
16673
- // turn_completed.usage when migrating to v4.
17678
+ // v3 and earlier treated context-window totalTokens as cumulative spend;
17679
+ // v4 still overlapped output/reasoning, discarded cache creation, and ignored
17680
+ // provider-reported cost. Rebuild from turn_completed.usage for v5.
16674
17681
  //
16675
17682
  // Drop prior watermark totals / updateOffsets so files are re-read from byte 0,
16676
17683
  // but keep legacySeen markers from seenSessions so the one-shot baseline
@@ -16794,9 +17801,10 @@ async function parseGrokBuildIncremental({
16794
17801
  reasoning_output_tokens: event.reasoning_output_tokens,
16795
17802
  total_tokens: event.total_tokens,
16796
17803
  billable_total_tokens: event.billable_total_tokens,
17804
+ total_cost_usd: event.total_cost_usd,
16797
17805
  conversation_count: event.conversation_count || 1,
16798
17806
  };
16799
- pendingBucketDeltas.push({ model: eventModel, hourStartStr, delta });
17807
+ pendingBucketDeltas.push({ model: eventModel, hourStartStr, delta, usagePrecision: "reported" });
16800
17808
  cumulativeTotal += event.total_tokens;
16801
17809
  tokenDeltaForSession += event.total_tokens;
16802
17810
  finalTouchedHourStart = hourStartStr;
@@ -16823,7 +17831,7 @@ async function parseGrokBuildIncremental({
16823
17831
  toUtcHalfHourStart(Date.now());
16824
17832
  if (!hourStartStr) continue;
16825
17833
  const delta = estimateGrokTokenDelta(deltaTokens, 0, { allowZeroConversationCount: true });
16826
- pendingBucketDeltas.push({ model, hourStartStr, delta });
17834
+ pendingBucketDeltas.push({ model, hourStartStr, delta, usagePrecision: "estimated" });
16827
17835
  tokenDeltaForSession += deltaTokens;
16828
17836
  finalTouchedHourStart = hourStartStr;
16829
17837
  source = "updates";
@@ -16836,7 +17844,7 @@ async function parseGrokBuildIncremental({
16836
17844
  const hourStartStr = toUtcHalfHourStart(lastActive) || toUtcHalfHourStart(Date.now());
16837
17845
  if (hourStartStr) {
16838
17846
  const delta = estimateGrokTokenDelta(deltaTokens, 0, { allowZeroConversationCount: true });
16839
- pendingBucketDeltas.push({ model, hourStartStr, delta });
17847
+ pendingBucketDeltas.push({ model, hourStartStr, delta, usagePrecision: "estimated" });
16840
17848
  tokenDeltaForSession += deltaTokens;
16841
17849
  finalTouchedHourStart = hourStartStr;
16842
17850
  source = "signals";
@@ -16854,6 +17862,10 @@ async function parseGrokBuildIncremental({
16854
17862
  for (const pending of pendingBucketDeltas) {
16855
17863
  const bucket = getHourlyBucket(hourlyState, "grok", pending.model, pending.hourStartStr);
16856
17864
  addTotals(bucket.totals, pending.delta);
17865
+ bucket.usage_precision = mergeGrokUsagePrecision(
17866
+ bucket.usage_precision,
17867
+ pending.usagePrecision,
17868
+ );
16857
17869
  touchedBuckets.add(bucketKey("grok", pending.model, pending.hourStartStr));
16858
17870
  eventsAggregated++;
16859
17871
  }
@@ -17092,7 +18104,7 @@ async function parseGrokBuildIncremental({
17092
18104
  ? { ...grokState.migrations }
17093
18105
  : {};
17094
18106
  if (needsTurnUsageMigration) {
17095
- migrations.turnUsageV4 = {
18107
+ migrations.turnUsageV5 = {
17096
18108
  appliedAt: new Date().toISOString(),
17097
18109
  fromVersion: prevVersion,
17098
18110
  toVersion: GROK_CURSOR_VERSION,
@@ -17402,6 +18414,7 @@ async function parseAntigravityFile({
17402
18414
  // Match the mainstream convention (Codebuddy / Kilocode / OMP / Hermes):
17403
18415
  // total_tokens = sum of every token column. No cache columns here.
17404
18416
  delta.total_tokens = inputDelta + outputTokens + reasoningTokens;
18417
+ delta.billable_total_tokens = delta.total_tokens;
17405
18418
  delta.conversation_count = 1;
17406
18419
  billedPlanner = delta.total_tokens > 0;
17407
18420
  }
@@ -18940,6 +19953,15 @@ module.exports = {
18940
19953
  sumZedRequestUsage,
18941
19954
  readZedUsage,
18942
19955
  parseZedIncremental,
19956
+ resolveLmstudioHome,
19957
+ resolveLmstudioLogFiles,
19958
+ normalizeLocalStudioTokens,
19959
+ readLmstudioFileRecords,
19960
+ parseLmstudioIncremental,
19961
+ resolveUnslothDbPath,
19962
+ readUnslothUsageRows,
19963
+ normalizeUnslothUsageRow,
19964
+ parseUnslothIncremental,
18943
19965
  resolveAnythingllmDbPath,
18944
19966
  parseAnythingllmTimestamp,
18945
19967
  readAnythingllmUsageRows,