tokentracker-cli 0.84.4 → 0.84.6

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 (95) hide show
  1. package/dashboard/dist/assets/{AchievementBadge-C29PwsK_.js → AchievementBadge-CKIvD3lG.js} +1 -1
  2. package/dashboard/dist/assets/{AchievementsPage-BlZODLp8.js → AchievementsPage-CZWCUiS5.js} +2 -2
  3. package/dashboard/dist/assets/{AchievementsSection-Bc6NMnIa.js → AchievementsSection-BSk_fR8P.js} +1 -1
  4. package/dashboard/dist/assets/{BadgeDetailModal-BUy4636q.js → BadgeDetailModal-CjHkcmuq.js} +1 -1
  5. package/dashboard/dist/assets/{Card-CqxuFMAN.js → Card-D6hdzwvI.js} +1 -1
  6. package/dashboard/dist/assets/{ClawdAnimated-BG3m8BgW.js → ClawdAnimated-Cl6gmF7u.js} +1 -1
  7. package/dashboard/dist/assets/{CommandPalette-Dcwtom2b.js → CommandPalette-B0rcodcF.js} +1 -1
  8. package/dashboard/dist/assets/{CommunityStatsModal-dBjHrgjE.js → CommunityStatsModal-BtlTJbf4.js} +1 -1
  9. package/dashboard/dist/assets/{ConfirmModal-DncQkptq.js → ConfirmModal-tJCWVZQH.js} +1 -1
  10. package/dashboard/dist/assets/{DashboardPage-CdF5WNHN.js → DashboardPage-Dtxq8dzI.js} +1 -1
  11. package/dashboard/dist/assets/{DevicePage-B-PO4s-r.js → DevicePage-9USuEsRu.js} +1 -1
  12. package/dashboard/dist/assets/{DialogClose-DGtj17VY.js → DialogClose-DxHdqMcl.js} +1 -1
  13. package/dashboard/dist/assets/DialogDescription-DgUSe6A1.js +1 -0
  14. package/dashboard/dist/assets/{DialogTitle-DLkuu1Iw.js → DialogTitle-C1ZeQgf2.js} +1 -1
  15. package/dashboard/dist/assets/{FadeIn-Dd5RBmXd.js → FadeIn-Cx4jLl9G.js} +1 -1
  16. package/dashboard/dist/assets/{HeaderGithubStar-BBCOpHqO.js → HeaderGithubStar-BOyuGkoA.js} +1 -1
  17. package/dashboard/dist/assets/{HoverTooltip-BbigYxdC.js → HoverTooltip-BDhqMR3L.js} +1 -1
  18. package/dashboard/dist/assets/{IpCheckPage-BBpZYjSC.js → IpCheckPage-DTvA9Wmx.js} +1 -1
  19. package/dashboard/dist/assets/{LandingPage-ttXnt_s9.js → LandingPage-k1271lWV.js} +2 -2
  20. package/dashboard/dist/assets/{LeaderboardAvatar-DOl_cOO9.js → LeaderboardAvatar-3ibNg6If.js} +1 -1
  21. package/dashboard/dist/assets/{LeaderboardPage-BYIyoLkB.js → LeaderboardPage-Byww5UpT.js} +2 -2
  22. package/dashboard/dist/assets/{LeaderboardProfileModal-BO2n4LSQ.js → LeaderboardProfileModal-DeGbJtvi.js} +3 -3
  23. package/dashboard/dist/assets/{LeaderboardProfilePage-CqwW5yMw.js → LeaderboardProfilePage-DEPzO6hW.js} +1 -1
  24. package/dashboard/dist/assets/{LimitsPage-B-w771Tl.js → LimitsPage-DPtqL0PS.js} +1 -1
  25. package/dashboard/dist/assets/{LocalOnlyNotice-Cqc_diwF.js → LocalOnlyNotice-C76LAT_3.js} +1 -1
  26. package/dashboard/dist/assets/{LoginCard-D6zc1JKY.js → LoginCard-DEyCJT67.js} +1 -1
  27. package/dashboard/dist/assets/{LoginModal-udN3TqoF.js → LoginModal-CueMQgAZ.js} +1 -1
  28. package/dashboard/dist/assets/{LoginPage-BucPr3_X.js → LoginPage-CwqBb5Fd.js} +1 -1
  29. package/dashboard/dist/assets/{PetPage-6_nmMXWp.js → PetPage--xQAjW4_.js} +1 -1
  30. package/dashboard/dist/assets/{PopoverPopup-DntczBmH.js → PopoverPopup-Dt9VY2VQ.js} +1 -1
  31. package/dashboard/dist/assets/{ProviderIcon-Z6qlwSj1.js → ProviderIcon-CHx4ohfx.js} +1 -1
  32. package/dashboard/dist/assets/{ResetPasswordPage-WqQwsQC1.js → ResetPasswordPage-DB6R7fjf.js} +1 -1
  33. package/dashboard/dist/assets/{SegmentedControl-D3NM3dOv.js → SegmentedControl-BINOpXIY.js} +1 -1
  34. package/dashboard/dist/assets/{Select-DCpwSFK7.js → Select-DcYXcsKv.js} +1 -1
  35. package/dashboard/dist/assets/{SelectItemText-DICaclqa.js → SelectItemText-BDv2Kb3x.js} +1 -1
  36. package/dashboard/dist/assets/{SessionsPage-COHoSjPP.js → SessionsPage-cL_w0ytB.js} +1 -1
  37. package/dashboard/dist/assets/{SettingsPage-nzbrBo_U.js → SettingsPage-CHGA0bQS.js} +1 -1
  38. package/dashboard/dist/assets/{SkillsPage-DE8mK1lS.js → SkillsPage-I29SqkXO.js} +1 -1
  39. package/dashboard/dist/assets/{TokenGalaxy-BSWGbByr.js → TokenGalaxy-BHq4YSv2.js} +1 -1
  40. package/dashboard/dist/assets/{ToolbarRootContext-CxxXTuTT.js → ToolbarRootContext-akXaaKb6.js} +1 -1
  41. package/dashboard/dist/assets/{TrendMonitor-DillSTMV.js → TrendMonitor-DPhFXzQc.js} +1 -1
  42. package/dashboard/dist/assets/{WidgetsPage-LqyxKYAt.js → WidgetsPage-Nf0UEz7s.js} +1 -1
  43. package/dashboard/dist/assets/{WrappedPage-DNDuRKjf.js → WrappedPage-D4zOSsqD.js} +1 -1
  44. package/dashboard/dist/assets/{__vite-browser-external-DEhUaWQY.js → __vite-browser-external-UqlSqI-1.js} +2 -2
  45. package/dashboard/dist/assets/{agent-logos-D34B25op.js → agent-logos-Bja3VCAa.js} +1 -1
  46. package/dashboard/dist/assets/{arrow-up-right-BYWbL4WV.js → arrow-up-right-CzcCNJJ2.js} +1 -1
  47. package/dashboard/dist/assets/{check-DkfaR6JX.js → check-DUHSZCYn.js} +1 -1
  48. package/dashboard/dist/assets/{chevron-right-D2cpt_J3.js → chevron-right-jLvhaNUy.js} +1 -1
  49. package/dashboard/dist/assets/{copy-Cv5Ix0Pn.js → copy-ChyH-ynx.js} +1 -1
  50. package/dashboard/dist/assets/{download-5nr9Tl80.js → download-CgwvXrUe.js} +1 -1
  51. package/dashboard/dist/assets/{flame-B6M-msYH.js → flame-ysf1F8uB.js} +1 -1
  52. package/dashboard/dist/assets/{format-tokens-DiRRwgY8.js → format-tokens-DAa2cubl.js} +1 -1
  53. package/dashboard/dist/assets/{icons-DQHoG48j.js → icons-DccoWQq9.js} +1 -1
  54. package/dashboard/dist/assets/{index-hSxFduER.js → index-CAycNPQf.js} +1 -1
  55. package/dashboard/dist/assets/{index-BoH_HaYH.js → index-CxExExXh.js} +1 -1
  56. package/dashboard/dist/assets/{index-vFee2-sp.js → index-kkQrDRat.js} +1 -1
  57. package/dashboard/dist/assets/{info-CM5MR8lk.js → info-DWQQIq9M.js} +1 -1
  58. package/dashboard/dist/assets/{limits-providers-BV4Y9nI4.js → limits-providers-B_18X9Rp.js} +1 -1
  59. package/dashboard/dist/assets/{link-2-Bd5jF09Z.js → link-2-CKeVVq4P.js} +1 -1
  60. package/dashboard/dist/assets/{loader-circle-B5oZJSYV.js → loader-circle-Dw0tu6IN.js} +1 -1
  61. package/dashboard/dist/assets/{maximize-2-CiYy9Kri.js → maximize-2-BMU0YzEF.js} +1 -1
  62. package/dashboard/dist/assets/{provider-display-lzLynfde.js → provider-display-Cf9I-VLS.js} +1 -1
  63. package/dashboard/dist/assets/{react-CllryaXi.js → react-BhbcISKy.js} +1 -1
  64. package/dashboard/dist/assets/{search-DDdsotHp.js → search-BoY8EW1z.js} +1 -1
  65. package/dashboard/dist/assets/{skills-api-487vZmwI.js → skills-api-KVH21GJw.js} +1 -1
  66. package/dashboard/dist/assets/{store-CYaTRd_k.js → store-BL7rSajG.js} +1 -1
  67. package/dashboard/dist/assets/{terminal-OyeOY0tb.js → terminal-CeMuITdm.js} +1 -1
  68. package/dashboard/dist/assets/{trash-2-COI9stXJ.js → trash-2-jVUCKqrR.js} +1 -1
  69. package/dashboard/dist/assets/{use-community-stats-DkzinngC.js → use-community-stats-uWK0fE6l.js} +1 -1
  70. package/dashboard/dist/assets/{use-limits-display-prefs-CiMETU5Y.js → use-limits-display-prefs-DWsF4ShM.js} +1 -1
  71. package/dashboard/dist/assets/{use-native-settings-qzpxm9Bh.js → use-native-settings-SEbqWDvc.js} +1 -1
  72. package/dashboard/dist/assets/{use-ordered-list-BI-Qh9xR.js → use-ordered-list-D4UhrPvw.js} +1 -1
  73. package/dashboard/dist/assets/use-reduced-motion-C3UrOh55.js +1 -0
  74. package/dashboard/dist/assets/{use-session-efficiency-pref-1WyhIjeG.js → use-session-efficiency-pref-DbysyUTJ.js} +1 -1
  75. package/dashboard/dist/assets/{use-transform-D5i_ArzG.js → use-transform-DaEssGaZ.js} +1 -1
  76. package/dashboard/dist/assets/{use-usage-limits-Drcnt7__.js → use-usage-limits-CmmQ34Ov.js} +1 -1
  77. package/dashboard/dist/assets/{useCurrency-KWJo1GIr.js → useCurrency-DvBEn4rx.js} +1 -1
  78. package/dashboard/dist/assets/{useScrollLock-K0FPky1Y.js → useScrollLock-QozsdFng.js} +1 -1
  79. package/dashboard/dist/assets/{useTokenFormat-CwG4ck8v.js → useTokenFormat-BTLrL7Mi.js} +1 -1
  80. package/dashboard/dist/assets/{zap-wZ6IQAyU.js → zap-Dp_aJ1q6.js} +1 -1
  81. package/dashboard/dist/index.html +1 -1
  82. package/dashboard/dist/ip-check.html +1 -1
  83. package/dashboard/dist/leaderboard.html +1 -1
  84. package/dashboard/dist/share.html +1 -1
  85. package/package.json +1 -1
  86. package/src/commands/status.js +17 -7
  87. package/src/commands/sync.js +333 -5
  88. package/src/lib/codex-rollout-parser.js +17 -39
  89. package/src/lib/codex-token-usage.js +195 -0
  90. package/src/lib/pricing/curated-overrides.json +536 -101
  91. package/src/lib/pricing/seed-snapshot.json +1 -1
  92. package/src/lib/rollout.js +732 -163
  93. package/src/lib/usage-limits.js +211 -41
  94. package/dashboard/dist/assets/DialogDescription-wStLk8Br.js +0 -1
  95. package/dashboard/dist/assets/use-reduced-motion-DrsPn1ux.js +0 -1
@@ -8,6 +8,11 @@ const { ensureDir } = require("./fs");
8
8
  const { readSqliteJsonRows, readSqliteJsonRowsAsync } = require("./sqlite-reader");
9
9
  const wsl = require("./wsl-probe");
10
10
  const { resolveInstallPaths } = require("./install-resolver");
11
+ const {
12
+ consumeUsageDelta,
13
+ createUsageDeltaState,
14
+ snapshotUsageBaselines,
15
+ } = require("./codex-token-usage");
11
16
 
12
17
  const DEFAULT_SOURCE = "codex";
13
18
  const DEFAULT_MODEL = "unknown";
@@ -360,6 +365,9 @@ async function parseRolloutIncremental({
360
365
  const lastTotal = sameInode && !truncated && !rebuildingCodexBaseline
361
366
  ? prev.lastTotal || null
362
367
  : null;
368
+ const tokenUsageBaselines = sameInode && !truncated && !rebuildingCodexBaseline
369
+ ? prev.tokenUsageBaselines || null
370
+ : null;
363
371
  const lastModel = sameInode && !truncated ? prev.lastModel || null : null;
364
372
 
365
373
  const codexProjectFastPath = projectEnabled && fileSource === DEFAULT_SOURCE;
@@ -430,6 +438,7 @@ async function parseRolloutIncremental({
430
438
  filePath,
431
439
  fileStat: st,
432
440
  lastTotal,
441
+ tokenUsageBaselines,
433
442
  lastModel,
434
443
  projectState,
435
444
  projectMetaCache,
@@ -442,6 +451,7 @@ async function parseRolloutIncremental({
442
451
  fileStat: st,
443
452
  startOffset,
444
453
  lastTotal,
454
+ tokenUsageBaselines,
445
455
  lastModel,
446
456
  hourlyState,
447
457
  touchedBuckets,
@@ -462,6 +472,7 @@ async function parseRolloutIncremental({
462
472
  inode,
463
473
  offset: result.endOffset,
464
474
  lastTotal: result.lastTotal,
475
+ tokenUsageBaselines: result.tokenUsageBaselines,
465
476
  lastModel: result.lastModel,
466
477
  updatedAt: new Date().toISOString(),
467
478
  };
@@ -1731,6 +1742,7 @@ async function parseRolloutFile({
1731
1742
  fileStat,
1732
1743
  startOffset,
1733
1744
  lastTotal,
1745
+ tokenUsageBaselines,
1734
1746
  lastModel,
1735
1747
  hourlyState,
1736
1748
  touchedBuckets,
@@ -1751,14 +1763,25 @@ async function parseRolloutFile({
1751
1763
  const projectFileContexts = [];
1752
1764
  addProjectFileContext(projectFileContexts, projectContext);
1753
1765
  if (startOffset >= endOffset) {
1754
- return { endOffset, lastTotal, lastModel, eventsAggregated: 0, projectFileContexts };
1766
+ return {
1767
+ endOffset,
1768
+ lastTotal,
1769
+ tokenUsageBaselines,
1770
+ lastModel,
1771
+ eventsAggregated: 0,
1772
+ projectFileContexts,
1773
+ };
1755
1774
  }
1756
1775
 
1757
1776
  const stream = fssync.createReadStream(filePath, { encoding: "utf8", start: startOffset });
1758
1777
  const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
1759
1778
 
1760
1779
  let model = typeof lastModel === "string" ? lastModel : null;
1761
- let totals = lastTotal && typeof lastTotal === "object" ? lastTotal : null;
1780
+ const usageDeltaState = createUsageDeltaState({
1781
+ lastTotal,
1782
+ baselines: tokenUsageBaselines,
1783
+ });
1784
+ let latestTotal = lastTotal && typeof lastTotal === "object" ? lastTotal : null;
1762
1785
  let currentCwd = null;
1763
1786
  let currentDate = null;
1764
1787
  let isForkedRollout = false;
@@ -1840,15 +1863,13 @@ async function parseRolloutFile({
1840
1863
 
1841
1864
  const lastUsage = info.last_token_usage;
1842
1865
  const totalUsage = info.total_token_usage;
1866
+ if (totalUsage && typeof totalUsage === "object") latestTotal = totalUsage;
1843
1867
 
1844
- const delta = pickDelta(lastUsage, totalUsage, totals);
1845
- if (!delta) continue;
1868
+ const rawDelta = consumeUsageDelta(usageDeltaState, lastUsage, totalUsage);
1869
+ const delta = rawDelta ? normalizeUsage(rawDelta) : null;
1870
+ if (!delta || isAllZeroUsage(delta)) continue;
1846
1871
  delta.conversation_count = 1;
1847
1872
 
1848
- if (totalUsage && typeof totalUsage === "object") {
1849
- totals = totalUsage;
1850
- }
1851
-
1852
1873
  // Forked Codex rollouts replay the parent session's entire token history
1853
1874
  // into the child file the moment the fork is created. The date guard below
1854
1875
  // (current_date < rolloutDate) catches cross-day forks, but same-day forks
@@ -1863,8 +1884,8 @@ async function parseRolloutFile({
1863
1884
  // flush spacing and well below genuine turn cadence (≥~4.6s observed). The
1864
1885
  // first replayed row cannot be identified without lookahead, so it is still
1865
1886
  // counted (a bounded, <1% residual over-count); dropping real usage is the
1866
- // worse failure, so we bias against it. `totals` is already advanced above,
1867
- // keeping the cumulative-delta baseline correct for the live turns we keep.
1887
+ // worse failure, so we bias against it. `usageDeltaState` is already advanced above,
1888
+ // keeping the cumulative lineage correct for the live turns we keep.
1868
1889
  // Scoped to forked codex rollouts. (issue #169 follow-up.)
1869
1890
  let forkedReplaySkip = false;
1870
1891
  if (isForkedRollout && source === DEFAULT_SOURCE && replayPrefixActive) {
@@ -1903,8 +1924,8 @@ async function parseRolloutFile({
1903
1924
  // buckets. External tools rewrite session files without changing the token
1904
1925
  // data — Codex-Manager atomically rewrites them (new inode) to patch the
1905
1926
  // provider on every account/channel switch — so without dedup each switch
1906
- // double-counts the rewritten sessions. `totals` is already advanced above,
1907
- // so skipping an already-seen event keeps the cumulative-delta chain intact
1927
+ // double-counts the rewritten sessions. `usageDeltaState` is already advanced above,
1928
+ // so skipping an already-seen event keeps the cumulative lineage intact
1908
1929
  // while preventing the re-add; genuinely new turns carry new timestamps and
1909
1930
  // are still counted. Key = sessionUUID:eventTimestamp (both stable across the
1910
1931
  // rewrite and across a sessions/ -> archived_sessions/ move).
@@ -1939,13 +1960,21 @@ async function parseRolloutFile({
1939
1960
  eventsAggregated += 1;
1940
1961
  }
1941
1962
 
1942
- return { endOffset, lastTotal: totals, lastModel: model, eventsAggregated, projectFileContexts };
1963
+ return {
1964
+ endOffset,
1965
+ lastTotal: latestTotal,
1966
+ tokenUsageBaselines: snapshotUsageBaselines(usageDeltaState),
1967
+ lastModel: model,
1968
+ eventsAggregated,
1969
+ projectFileContexts,
1970
+ };
1943
1971
  }
1944
1972
 
1945
1973
  async function scanRolloutProjectFileContexts({
1946
1974
  filePath,
1947
1975
  fileStat,
1948
1976
  lastTotal,
1977
+ tokenUsageBaselines,
1949
1978
  lastModel,
1950
1979
  projectState,
1951
1980
  projectMetaCache,
@@ -1958,7 +1987,14 @@ async function scanRolloutProjectFileContexts({
1958
1987
  const projectFileContexts = [];
1959
1988
  addProjectFileContext(projectFileContexts, projectContext);
1960
1989
  if (!projectState || endOffset <= 0) {
1961
- return { endOffset, lastTotal, lastModel, eventsAggregated: 0, projectFileContexts };
1990
+ return {
1991
+ endOffset,
1992
+ lastTotal,
1993
+ tokenUsageBaselines,
1994
+ lastModel,
1995
+ eventsAggregated: 0,
1996
+ projectFileContexts,
1997
+ };
1962
1998
  }
1963
1999
 
1964
2000
  const stream = fssync.createReadStream(filePath, { encoding: "utf8", start: 0 });
@@ -2005,7 +2041,14 @@ async function scanRolloutProjectFileContexts({
2005
2041
  addProjectFileContext(projectFileContexts, context);
2006
2042
  }
2007
2043
 
2008
- return { endOffset, lastTotal, lastModel, eventsAggregated: 0, projectFileContexts };
2044
+ return {
2045
+ endOffset,
2046
+ lastTotal,
2047
+ tokenUsageBaselines,
2048
+ lastModel,
2049
+ eventsAggregated: 0,
2050
+ projectFileContexts,
2051
+ };
2009
2052
  }
2010
2053
 
2011
2054
  async function parseClaudeFile({
@@ -3570,48 +3613,6 @@ function extractTokenCount(obj) {
3570
3613
  return null;
3571
3614
  }
3572
3615
 
3573
- function pickDelta(lastUsage, totalUsage, prevTotals) {
3574
- const hasLast = isNonEmptyObject(lastUsage);
3575
- const hasTotal = isNonEmptyObject(totalUsage);
3576
- const hasPrevTotals = isNonEmptyObject(prevTotals);
3577
-
3578
- if (hasTotal && hasPrevTotals) {
3579
- if (totalsReset(totalUsage, prevTotals)) {
3580
- const resetUsage = hasLast ? lastUsage : totalUsage;
3581
- const normalized = normalizeUsage(resetUsage);
3582
- return isAllZeroUsage(normalized) ? null : normalized;
3583
- }
3584
-
3585
- const delta = {};
3586
- for (const k of [
3587
- "input_tokens",
3588
- "cached_input_tokens",
3589
- "cache_creation_input_tokens",
3590
- "output_tokens",
3591
- "reasoning_output_tokens",
3592
- "total_tokens",
3593
- ]) {
3594
- const a = Number(totalUsage[k]);
3595
- const b = Number(prevTotals[k]);
3596
- if (Number.isFinite(a) && Number.isFinite(b)) delta[k] = Math.max(0, a - b);
3597
- }
3598
- const normalized = normalizeUsage(delta);
3599
- return isAllZeroUsage(normalized) ? null : normalized;
3600
- }
3601
-
3602
- if (hasLast) {
3603
- const normalized = normalizeUsage(lastUsage);
3604
- return isAllZeroUsage(normalized) ? null : normalized;
3605
- }
3606
-
3607
- if (hasTotal) {
3608
- const normalized = normalizeUsage(totalUsage);
3609
- return isAllZeroUsage(normalized) ? null : normalized;
3610
- }
3611
-
3612
- return null;
3613
- }
3614
-
3615
3616
  function normalizeUsage(u) {
3616
3617
  const out = {};
3617
3618
  for (const k of [
@@ -3694,13 +3695,6 @@ function isAllZeroUsage(u) {
3694
3695
  return true;
3695
3696
  }
3696
3697
 
3697
- function totalsReset(curr, prev) {
3698
- const currTotal = curr?.total_tokens;
3699
- const prevTotal = prev?.total_tokens;
3700
- if (!isFiniteNumber(currTotal) || !isFiniteNumber(prevTotal)) return false;
3701
- return currTotal < prevTotal;
3702
- }
3703
-
3704
3698
  function isFiniteNumber(v) {
3705
3699
  return typeof v === "number" && Number.isFinite(v);
3706
3700
  }
@@ -4940,32 +4934,77 @@ function resolveKiroCliDbPath(env = process.env) {
4940
4934
  // no hyphens. kiro-cli writes proper UUIDs; lock to the canonical shape.
4941
4935
  const KIRO_CLI_SESSION_FILE_RE =
4942
4936
  /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.json$/i;
4943
-
4944
- // Lists ~/.kiro/sessions/cli/{uuid}.json files. Includes files whose sibling
4945
- // .lock is present we read those as tail-only snapshots so a running
4946
- // session's completed turns still land in the queue on the next sync. The
4947
- // .json files are rewritten atomically by kiro-cli on each turn flush, so
4948
- // a stale read just means we'll pick up the rest next time.
4937
+ // The directory prefix and direct messages.jsonl child are the stable
4938
+ // contract. Do not assume the suffix will always remain a UUID: Kiro has
4939
+ // already changed this layout once, and an opaque non-empty session id is
4940
+ // sufficient to keep discovery both bounded and forward-compatible.
4941
+ const KIRO_CLI_V2_SESSION_DIR_RE = /^sess_.+$/;
4942
+
4943
+ // Lists both Kiro CLI session layouts:
4944
+ // legacy: ~/.kiro/sessions/cli/{uuid}.json
4945
+ // 2.13+: ~/.kiro/sessions/{workspaceHash}/sess_{uuid}/messages.jsonl
4946
+ //
4947
+ // Legacy .json files are rewritten atomically per turn. The 2.13+ JSONL is
4948
+ // append-only and may be read while a session is active. Only the direct
4949
+ // messages.jsonl child is accepted; sub-executions and arbitrary nested JSONL
4950
+ // files are intentionally excluded.
4949
4951
  //
4950
4952
  // TASK-014: env.HOME is honored (symmetric with resolveKiroCliDbPath) so
4951
4953
  // callers can redirect to a tmp home for hermetic tests/CI.
4952
4954
  function resolveKiroCliSessionFiles(env = process.env) {
4953
4955
  const home = env.HOME || require("node:os").homedir();
4954
4956
  const kiroHome = env.KIRO_HOME || path.join(home, ".kiro");
4955
- const sessionsDir = path.join(kiroHome, "sessions", "cli");
4956
- if (!fssync.existsSync(sessionsDir)) return [];
4957
+ const sessionsRoot = path.join(kiroHome, "sessions");
4958
+ if (!fssync.existsSync(sessionsRoot)) return [];
4957
4959
  const files = [];
4960
+
4961
+ const legacyDir = path.join(sessionsRoot, "cli");
4958
4962
  try {
4959
- for (const entry of fssync.readdirSync(sessionsDir)) {
4963
+ for (const entry of fssync.readdirSync(legacyDir)) {
4960
4964
  // TASK-003: only canonical {uuid}.json files; backups, scratch,
4961
4965
  // typos are skipped so they don't feed JSON.parse garbage.
4962
4966
  if (!KIRO_CLI_SESSION_FILE_RE.test(entry)) continue;
4963
- files.push(path.join(sessionsDir, entry));
4967
+ files.push(path.join(legacyDir, entry));
4964
4968
  }
4965
4969
  } catch {
4966
4970
  // ignore read errors
4967
4971
  }
4968
- return files;
4972
+
4973
+ try {
4974
+ const workspaceDirs = fssync.readdirSync(sessionsRoot, {
4975
+ withFileTypes: true,
4976
+ });
4977
+ for (const workspace of workspaceDirs) {
4978
+ if (!workspace.isDirectory() || workspace.name === "cli") continue;
4979
+ const workspacePath = path.join(sessionsRoot, workspace.name);
4980
+ let sessionDirs;
4981
+ try {
4982
+ sessionDirs = fssync.readdirSync(workspacePath, {
4983
+ withFileTypes: true,
4984
+ });
4985
+ } catch {
4986
+ continue;
4987
+ }
4988
+ for (const session of sessionDirs) {
4989
+ if (
4990
+ !session.isDirectory() ||
4991
+ !KIRO_CLI_V2_SESSION_DIR_RE.test(session.name)
4992
+ ) {
4993
+ continue;
4994
+ }
4995
+ const messagesPath = path.join(
4996
+ workspacePath,
4997
+ session.name,
4998
+ "messages.jsonl",
4999
+ );
5000
+ if (fssync.existsSync(messagesPath)) files.push(messagesPath);
5001
+ }
5002
+ }
5003
+ } catch {
5004
+ // ignore read errors
5005
+ }
5006
+
5007
+ return files.sort();
4969
5008
  }
4970
5009
 
4971
5010
  // Build char-count maps from a .jsonl sibling file. Lets us approximate
@@ -5183,6 +5222,199 @@ async function readKiroCliSessionTurns(jsonPath) {
5183
5222
  return flat;
5184
5223
  }
5185
5224
 
5225
+ function kiroCliV2ContentChars(value) {
5226
+ if (typeof value === "string") return value.length;
5227
+ if (Array.isArray(value)) {
5228
+ return value.reduce(
5229
+ (sum, item) => sum + kiroCliV2ContentChars(item),
5230
+ 0,
5231
+ );
5232
+ }
5233
+ if (!value || typeof value !== "object") return 0;
5234
+ for (const key of ["content", "text", "value", "parts", "entries"]) {
5235
+ if (value[key] !== undefined) {
5236
+ return kiroCliV2ContentChars(value[key]);
5237
+ }
5238
+ }
5239
+ return 0;
5240
+ }
5241
+
5242
+ // Kiro CLI 2.13+ writes event-sourced sessions under
5243
+ // ~/.kiro/sessions/<workspaceHash>/sess_<uuid>/messages.jsonl. The records do
5244
+ // not carry token counts, but user/assistant text, turn boundaries, timestamps,
5245
+ // and assistant reasoningModelId are sufficient for the same 4 chars/token
5246
+ // approximation used by the legacy CLI reader.
5247
+ async function readKiroCliV2SessionTurns(messagesPath) {
5248
+ if (
5249
+ !messagesPath ||
5250
+ path.basename(messagesPath) !== "messages.jsonl" ||
5251
+ !fssync.existsSync(messagesPath)
5252
+ ) {
5253
+ return [];
5254
+ }
5255
+
5256
+ const sessionDir = path.dirname(messagesPath);
5257
+ let sessionMeta = {};
5258
+ try {
5259
+ sessionMeta = JSON.parse(
5260
+ fssync.readFileSync(path.join(sessionDir, "session.json"), "utf8"),
5261
+ );
5262
+ } catch {
5263
+ // messages.jsonl is self-contained enough to parse without session.json.
5264
+ }
5265
+
5266
+ const sessionId =
5267
+ typeof sessionMeta?.id === "string" && sessionMeta.id
5268
+ ? sessionMeta.id
5269
+ : path.basename(sessionDir);
5270
+ const fallbackModel =
5271
+ typeof sessionMeta?.modelId === "string" ? sessionMeta.modelId : null;
5272
+ const fallbackTimestamp = Date.parse(
5273
+ sessionMeta?.createdAt || sessionMeta?.lastModifiedAt || "",
5274
+ );
5275
+
5276
+ const flat = [];
5277
+ let stream;
5278
+ try {
5279
+ stream = fssync.createReadStream(messagesPath, { encoding: "utf8" });
5280
+ } catch {
5281
+ return flat;
5282
+ }
5283
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
5284
+
5285
+ let pendingUserChars = 0;
5286
+ let pendingUserTimestampMs = NaN;
5287
+ let turn = null;
5288
+ let fallbackTurnIndex = 0;
5289
+
5290
+ const flushTurn = () => {
5291
+ if (!turn) return;
5292
+ const hasUsage =
5293
+ turn.outputChars > 0 ||
5294
+ turn.reasoningChars > 0;
5295
+ const tsMs = Number.isFinite(turn.timestampMs)
5296
+ ? turn.timestampMs
5297
+ : Number.isFinite(pendingUserTimestampMs)
5298
+ ? pendingUserTimestampMs
5299
+ : fallbackTimestamp;
5300
+ if (hasUsage && Number.isFinite(tsMs) && tsMs > 0) {
5301
+ const requestId =
5302
+ turn.executionId ||
5303
+ `${sessionId}:v2:${fallbackTurnIndex}`;
5304
+ const messageIds = Array.from(turn.messageIds);
5305
+ flat.push({
5306
+ request_id: requestId,
5307
+ message_id: messageIds[0] || turn.executionId || null,
5308
+ all_message_ids: messageIds,
5309
+ session_id: sessionId,
5310
+ session_model_id: fallbackModel,
5311
+ model_id: turn.modelId || fallbackModel,
5312
+ request_start_timestamp_ms: tsMs,
5313
+ user_prompt_length: turn.inputChars,
5314
+ response_size: turn.outputChars,
5315
+ reasoning_size: turn.reasoningChars,
5316
+ });
5317
+ }
5318
+ fallbackTurnIndex++;
5319
+ turn = null;
5320
+ };
5321
+
5322
+ const startTurn = (payload, timestampMs) => {
5323
+ flushTurn();
5324
+ turn = {
5325
+ executionId:
5326
+ typeof payload?.executionId === "string" ? payload.executionId : null,
5327
+ timestampMs:
5328
+ Number.isFinite(timestampMs)
5329
+ ? timestampMs
5330
+ : pendingUserTimestampMs,
5331
+ inputChars: pendingUserChars,
5332
+ outputChars: 0,
5333
+ reasoningChars: 0,
5334
+ modelId: null,
5335
+ messageIds: new Set(),
5336
+ };
5337
+ pendingUserChars = 0;
5338
+ pendingUserTimestampMs = NaN;
5339
+ };
5340
+
5341
+ try {
5342
+ for await (const line of rl) {
5343
+ if (!line || !line.trim()) continue;
5344
+ let event;
5345
+ try {
5346
+ event = JSON.parse(line);
5347
+ } catch {
5348
+ continue;
5349
+ }
5350
+ const payload = event?.payload;
5351
+ if (!payload || typeof payload !== "object") continue;
5352
+ const type =
5353
+ typeof payload.type === "string"
5354
+ ? payload.type.toLowerCase()
5355
+ : "";
5356
+ const timestampMs = Date.parse(event.timestamp || "");
5357
+
5358
+ if (type === "user") {
5359
+ if (turn) flushTurn();
5360
+ pendingUserChars = kiroCliV2ContentChars(payload.content);
5361
+ pendingUserTimestampMs = timestampMs;
5362
+ continue;
5363
+ }
5364
+
5365
+ if (type === "turn_start") {
5366
+ startTurn(payload, timestampMs);
5367
+ continue;
5368
+ }
5369
+
5370
+ if (
5371
+ ["assistant", "tool_call", "tool_result", "usage_summary"].includes(
5372
+ type,
5373
+ ) &&
5374
+ !turn
5375
+ ) {
5376
+ startTurn(payload, timestampMs);
5377
+ }
5378
+ if (!turn) continue;
5379
+
5380
+ if (
5381
+ typeof payload.executionId === "string" &&
5382
+ payload.executionId &&
5383
+ !turn.executionId
5384
+ ) {
5385
+ turn.executionId = payload.executionId;
5386
+ }
5387
+ if (typeof event.id === "string" && event.id) {
5388
+ turn.messageIds.add(event.id);
5389
+ }
5390
+
5391
+ if (type === "assistant") {
5392
+ const chars = kiroCliV2ContentChars(payload.content);
5393
+ if (
5394
+ typeof payload.operationType === "string" &&
5395
+ payload.operationType.toLowerCase() === "reasoning"
5396
+ ) {
5397
+ turn.reasoningChars += chars;
5398
+ } else {
5399
+ turn.outputChars += chars;
5400
+ }
5401
+ if (
5402
+ typeof payload.reasoningModelId === "string" &&
5403
+ payload.reasoningModelId
5404
+ ) {
5405
+ turn.modelId = payload.reasoningModelId;
5406
+ }
5407
+ } else if (type === "turn_end") {
5408
+ flushTurn();
5409
+ }
5410
+ }
5411
+ } catch {
5412
+ // Return complete turns parsed before a concurrent truncate/delete.
5413
+ }
5414
+ flushTurn();
5415
+ return flat;
5416
+ }
5417
+
5186
5418
  // Canonicalize a Kiro-CLI-emitted model id so IDE and CLI rows collapse when
5187
5419
  // they refer to the same underlying Bedrock model. Examples:
5188
5420
  // anthropic.claude-sonnet-4-20250514-v1:0 -> claude-sonnet-4
@@ -5202,6 +5434,7 @@ function canonicalizeKiroCliModelId(raw) {
5202
5434
  if (!name) return null;
5203
5435
  name = name.toLowerCase();
5204
5436
  if (name === "auto") return null;
5437
+ name = name.replace(/^(?:qdev|kiro)::/, "");
5205
5438
  // Strip provider prefix (anthropic., aws., openai., or a full Bedrock ARN).
5206
5439
  name = name.replace(
5207
5440
  /^(?:arn:aws:bedrock:[^:]*:[^:]*:(?:foundation-model\/)?|anthropic\.|openai\.|aws\.)/,
@@ -5294,10 +5527,11 @@ async function parseKiroCliIncremental({ sessionFiles, cursors, queuePath, onPro
5294
5527
  const resolvedEnv = env || process.env;
5295
5528
  const dbPath = resolveKiroCliDbPath(resolvedEnv);
5296
5529
 
5297
- // Combine two sources under the same (source='kiro', cursors.kiroCli)
5298
- // namespace: historical rows from the SQLite DB plus live session state
5299
- // from ~/.kiro/sessions/cli/{uuid}.json (covers turns from a running
5300
- // session that hasn't flushed to SQLite yet). Request ID shapes differ:
5530
+ // Combine three sources under the same (source='kiro', cursors.kiroCli)
5531
+ // namespace: historical rows from the SQLite DB, legacy live session state
5532
+ // from ~/.kiro/sessions/cli/{uuid}.json, and Kiro CLI 2.13+ event logs at
5533
+ // ~/.kiro/sessions/<workspaceHash>/sess_<uuid>/messages.jsonl. Request ID
5534
+ // shapes differ:
5301
5535
  // SQLite carries a persisted request_id UUID; session files synthesize
5302
5536
  // `${sessionId}:${loop_id.rand}`. When kiro-cli migrates a live session
5303
5537
  // into SQLite the same turn lands under a new request_id — the cross-
@@ -5309,8 +5543,11 @@ async function parseKiroCliIncremental({ sessionFiles, cursors, queuePath, onPro
5309
5543
  : [];
5310
5544
  const sessionFilesList = resolveKiroCliSessionFiles(resolvedEnv);
5311
5545
  let flatSessions = [];
5312
- for (const jsonPath of sessionFilesList) {
5313
- const turns = await readKiroCliSessionTurns(jsonPath);
5546
+ for (const sessionPath of sessionFilesList) {
5547
+ const turns =
5548
+ path.basename(sessionPath) === "messages.jsonl"
5549
+ ? await readKiroCliV2SessionTurns(sessionPath)
5550
+ : await readKiroCliSessionTurns(sessionPath);
5314
5551
  for (const turn of turns) flatSessions.push(turn);
5315
5552
  }
5316
5553
  // Per-request state replaces the old seenIds set. Each entry captures
@@ -5382,7 +5619,14 @@ async function parseKiroCliIncremental({ sessionFiles, cursors, queuePath, onPro
5382
5619
  toRetract.push([reqId, prev, sid]);
5383
5620
  }
5384
5621
  for (const [reqId, prev, sid] of toRetract) {
5385
- if (prev.input_tokens || prev.output_tokens) {
5622
+ if (
5623
+ prev.input_tokens ||
5624
+ prev.output_tokens ||
5625
+ prev.reasoning_output_tokens
5626
+ ) {
5627
+ const prevReasoning = toNonNegativeInt(
5628
+ prev.reasoning_output_tokens,
5629
+ );
5386
5630
  const prevBucket = getHourlyBucket(
5387
5631
  hourlyState,
5388
5632
  "kiro",
@@ -5394,8 +5638,12 @@ async function parseKiroCliIncremental({ sessionFiles, cursors, queuePath, onPro
5394
5638
  cached_input_tokens: 0,
5395
5639
  cache_creation_input_tokens: 0,
5396
5640
  output_tokens: -prev.output_tokens,
5397
- reasoning_output_tokens: 0,
5398
- total_tokens: -(prev.input_tokens + prev.output_tokens),
5641
+ reasoning_output_tokens: -prevReasoning,
5642
+ total_tokens: -(
5643
+ prev.input_tokens +
5644
+ prev.output_tokens +
5645
+ prevReasoning
5646
+ ),
5399
5647
  conversation_count: -1,
5400
5648
  });
5401
5649
  touchedBuckets.add(bucketKey("kiro", prev.model, prev.bucketStart));
@@ -5488,8 +5736,12 @@ async function parseKiroCliIncremental({ sessionFiles, cursors, queuePath, onPro
5488
5736
 
5489
5737
  const promptChars = toNonNegativeInt(r.user_prompt_length);
5490
5738
  const responseChars = toNonNegativeInt(r.response_size);
5739
+ const reasoningChars = toNonNegativeInt(r.reasoning_size);
5491
5740
  const approxInput = Math.floor(promptChars / KIRO_CLI_CHARS_PER_TOKEN);
5492
5741
  const approxOutput = Math.floor(responseChars / KIRO_CLI_CHARS_PER_TOKEN);
5742
+ const approxReasoning = Math.floor(
5743
+ reasoningChars / KIRO_CLI_CHARS_PER_TOKEN,
5744
+ );
5493
5745
 
5494
5746
  const tsMs = Number(r.request_start_timestamp_ms);
5495
5747
  if (!Number.isFinite(tsMs) || tsMs <= 0) continue;
@@ -5502,37 +5754,52 @@ async function parseKiroCliIncremental({ sessionFiles, cursors, queuePath, onPro
5502
5754
  const model = canonical || "kiro-cli-agent";
5503
5755
 
5504
5756
  // Fingerprint captures every field whose change should cause a re-bucket.
5505
- const fingerprint = `${promptChars}:${responseChars}:${model}:${tsMs}`;
5757
+ const fingerprint =
5758
+ `${promptChars}:${responseChars}:${reasoningChars}:${model}:${tsMs}`;
5506
5759
  const prev = requestState[requestId];
5507
5760
  if (prev && prev.fingerprint === fingerprint) continue; // unchanged
5508
5761
 
5509
5762
  // Subtract the prior contribution (if any) from its prior bucket so the
5510
5763
  // bucket's absolute totals reflect the CURRENT truth, not the historical
5511
5764
  // truth. enqueueTouchedBuckets will emit the net delta at flush time.
5512
- if (prev && (prev.input_tokens || prev.output_tokens)) {
5765
+ if (
5766
+ prev &&
5767
+ (
5768
+ prev.input_tokens ||
5769
+ prev.output_tokens ||
5770
+ prev.reasoning_output_tokens
5771
+ )
5772
+ ) {
5773
+ const prevReasoning = toNonNegativeInt(
5774
+ prev.reasoning_output_tokens,
5775
+ );
5513
5776
  const prevBucket = getHourlyBucket(hourlyState, "kiro", prev.model, prev.bucketStart);
5514
5777
  addTotals(prevBucket.totals, {
5515
5778
  input_tokens: -prev.input_tokens,
5516
5779
  cached_input_tokens: 0,
5517
5780
  cache_creation_input_tokens: 0,
5518
5781
  output_tokens: -prev.output_tokens,
5519
- reasoning_output_tokens: 0,
5520
- total_tokens: -(prev.input_tokens + prev.output_tokens),
5782
+ reasoning_output_tokens: -prevReasoning,
5783
+ total_tokens: -(
5784
+ prev.input_tokens +
5785
+ prev.output_tokens +
5786
+ prevReasoning
5787
+ ),
5521
5788
  conversation_count: -1,
5522
5789
  });
5523
5790
  touchedBuckets.add(bucketKey("kiro", prev.model, prev.bucketStart));
5524
5791
  }
5525
5792
 
5526
5793
  // Add the new contribution.
5527
- if (approxInput > 0 || approxOutput > 0) {
5794
+ if (approxInput > 0 || approxOutput > 0 || approxReasoning > 0) {
5528
5795
  const bucket = getHourlyBucket(hourlyState, "kiro", model, bucketStart);
5529
5796
  addTotals(bucket.totals, {
5530
5797
  input_tokens: approxInput,
5531
5798
  cached_input_tokens: 0,
5532
5799
  cache_creation_input_tokens: 0,
5533
5800
  output_tokens: approxOutput,
5534
- reasoning_output_tokens: 0,
5535
- total_tokens: approxInput + approxOutput,
5801
+ reasoning_output_tokens: approxReasoning,
5802
+ total_tokens: approxInput + approxOutput + approxReasoning,
5536
5803
  conversation_count: 1,
5537
5804
  });
5538
5805
  touchedBuckets.add(bucketKey("kiro", model, bucketStart));
@@ -5551,6 +5818,7 @@ async function parseKiroCliIncremental({ sessionFiles, cursors, queuePath, onPro
5551
5818
  model,
5552
5819
  input_tokens: approxInput,
5553
5820
  output_tokens: approxOutput,
5821
+ reasoning_output_tokens: approxReasoning,
5554
5822
  ...(r.session_id ? { session_id: r.session_id } : {}),
5555
5823
  };
5556
5824
 
@@ -6204,7 +6472,7 @@ async function parseKimiCodeIncremental({ wireFiles, cursors, queuePath, onProgr
6204
6472
  // each sync (passive scan only, same shape as Kimi's wire.jsonl reader).
6205
6473
  //
6206
6474
  // Per-line record types: message, reasoning, topic, file-history-snapshot.
6207
- // Only `type=="message" && role=="assistant"` carry token usage. The shape:
6475
+ // Only `type=="message" and role=="assistant"` carry token usage. The shape:
6208
6476
  //
6209
6477
  // providerData.rawUsage = {
6210
6478
  // prompt_tokens: 22223, // OpenAI-style — INCLUDES cached
@@ -6734,7 +7002,7 @@ async function parseCodebuddyIncremental({
6734
7002
  // (each verified against real ~/.workbuddy logs, NOT assumed from CodeBuddy):
6735
7003
  //
6736
7004
  // 1. Usage lives on `function_call` records too — not only on
6737
- // `type=="message" && role=="assistant"`. Each LLM round-trip (whether it
7005
+ // `type=="message" and role=="assistant"`. Each LLM round-trip (whether it
6738
7006
  // ends in a tool call or a text reply) carries its own providerData.rawUsage.
6739
7007
  // We therefore aggregate EVERY record that has providerData.rawUsage and
6740
7008
  // dedup per response id, instead of filtering by record type.
@@ -12387,7 +12655,8 @@ async function parseCopilotAppDbIncremental({
12387
12655
  // ─────────────────────────────────────────────────────────────────────────────
12388
12656
 
12389
12657
  const GROK_ESTIMATED_INPUT_RATIO = 0.8;
12390
- const GROK_CURSOR_VERSION = 3;
12658
+ // v4: bill from turn_completed.usage (true cumulative API usage), not context-window totalTokens.
12659
+ const GROK_CURSOR_VERSION = 4;
12391
12660
 
12392
12661
  function resolveGrokBuildHome(env = process.env) {
12393
12662
  if (env.TOKENTRACKER_GROK_HOME) return env.TOKENTRACKER_GROK_HOME;
@@ -12540,6 +12809,9 @@ function grokMessageCountFromSignals(signals) {
12540
12809
  );
12541
12810
  }
12542
12811
 
12812
+ // Context-window telemetry only. Prefer turn_completed.usage when available —
12813
+ // signals.totalTokens / contextTokensUsed track the live window, not billable
12814
+ // cumulative spend across a session (especially after compaction).
12543
12815
  function grokEffectiveTotalFromSignals(signals) {
12544
12816
  if (!signals || typeof signals !== "object") return 0;
12545
12817
  const beforeCompaction = normalizeNonNegativeNumber(signals.totalTokensBeforeCompaction);
@@ -12612,28 +12884,135 @@ function grokFileEndsWithNewline(filePath, size) {
12612
12884
  }
12613
12885
  }
12614
12886
 
12615
- async function readGrokUpdateTokenEvents(updatesPath, fallbackTimestamp, prevOffsetEntry) {
12616
- if (!updatesPath) return { events: [], offsetEntry: null };
12887
+
12888
+ function canonicalizeGrokUsageModel(model) {
12889
+ const raw = normalizeModelInput(model) || "grok-build";
12890
+ const lower = raw.toLowerCase();
12891
+ // Free Build SKU must not fuzzy-match paid grok-4.5 rates in native clients.
12892
+ if (lower.includes("build-free") || lower.endsWith("-free") || lower.includes("free-tier")) {
12893
+ return "grok-build-free";
12894
+ }
12895
+ if (lower === "grok-4.5-build" || lower === "grok-4-5-build") {
12896
+ return "grok-4.5-build";
12897
+ }
12898
+ return raw;
12899
+ }
12900
+
12901
+ function normalizeGrokTurnUsage(usage, model, timestamp, eventId) {
12902
+ if (!usage || typeof usage !== "object") return null;
12903
+ const inputRaw = normalizeNonNegativeNumber(
12904
+ usage.inputTokens ?? usage.input_tokens,
12905
+ );
12906
+ const output = normalizeNonNegativeNumber(
12907
+ usage.outputTokens ?? usage.output_tokens,
12908
+ );
12909
+ const cached = normalizeNonNegativeNumber(
12910
+ usage.cachedReadTokens ??
12911
+ usage.cache_read_input_tokens ??
12912
+ usage.cached_input_tokens,
12913
+ );
12914
+ const reasoning = normalizeNonNegativeNumber(
12915
+ usage.reasoningTokens ?? usage.reasoning_output_tokens,
12916
+ );
12917
+ // Grok reports inputTokens as the full prompt (including cache hits). Split
12918
+ // so pricing can apply cache_read rates correctly.
12919
+ const nonCachedInput = Math.max(0, inputRaw - cached);
12920
+ let total = normalizeNonNegativeNumber(usage.totalTokens ?? usage.total_tokens);
12921
+ if (total <= 0) {
12922
+ total = inputRaw + output + reasoning;
12923
+ }
12924
+ if (total <= 0 && nonCachedInput <= 0 && cached <= 0 && output <= 0) return null;
12925
+ return {
12926
+ input_tokens: nonCachedInput,
12927
+ cached_input_tokens: cached,
12928
+ cache_creation_input_tokens: 0,
12929
+ output_tokens: output,
12930
+ reasoning_output_tokens: reasoning,
12931
+ total_tokens: total > 0 ? total : nonCachedInput + cached + output + reasoning,
12932
+ billable_total_tokens: total > 0 ? total : nonCachedInput + cached + output + reasoning,
12933
+ conversation_count: 1,
12934
+ model: canonicalizeGrokUsageModel(model),
12935
+ timestamp,
12936
+ eventId,
12937
+ };
12938
+ }
12939
+
12940
+ function extractGrokTurnUsageEvents(record, fallbackTimestamp, fallbackModel, lineIndex) {
12941
+ const update = record?.params?.update;
12942
+ if (!update || typeof update !== "object") return [];
12943
+ if (update.sessionUpdate !== "turn_completed") return [];
12944
+ const usage = update.usage;
12945
+ if (!usage || typeof usage !== "object") return [];
12946
+
12947
+ const meta = record?.params?._meta || record?._meta || {};
12948
+ const timestamp = grokTimestampFromUpdate(meta, record, fallbackTimestamp);
12949
+ const baseEventId = grokEventId(
12950
+ meta.eventId ?? record?.eventId ?? record?.id ?? update.prompt_id,
12951
+ String(lineIndex),
12952
+ );
12953
+
12954
+ const modelUsage =
12955
+ usage.modelUsage && typeof usage.modelUsage === "object" ? usage.modelUsage : null;
12956
+ const events = [];
12957
+ if (modelUsage && Object.keys(modelUsage).length > 0) {
12958
+ for (const [modelName, modelUsageEntry] of Object.entries(modelUsage)) {
12959
+ if (!modelUsageEntry || typeof modelUsageEntry !== "object") continue;
12960
+ const event = normalizeGrokTurnUsage(
12961
+ modelUsageEntry,
12962
+ modelName,
12963
+ timestamp,
12964
+ `${baseEventId}|${modelName}`,
12965
+ );
12966
+ if (event) events.push(event);
12967
+ }
12968
+ }
12969
+ if (events.length === 0) {
12970
+ const event = normalizeGrokTurnUsage(usage, fallbackModel, timestamp, baseEventId);
12971
+ if (event) events.push(event);
12972
+ }
12973
+ return events;
12974
+ }
12975
+
12976
+ // Context-window watermark events (legacy / fallback only).
12977
+ function extractGrokContextTokenEvent(record, fallbackTimestamp, lineIndex) {
12978
+ const meta = record?.params?._meta || record?._meta;
12979
+ if (!meta || typeof meta !== "object") return null;
12980
+ const totalTokens = normalizeNonNegativeNumber(meta.totalTokens);
12981
+ if (totalTokens <= 0) return null;
12982
+ return {
12983
+ totalTokens,
12984
+ timestamp: grokTimestampFromUpdate(meta, record, fallbackTimestamp),
12985
+ eventId: grokEventId(meta.eventId ?? record?.eventId ?? record?.id, String(lineIndex)),
12986
+ };
12987
+ }
12988
+
12989
+ async function readGrokUpdateTokenEvents(updatesPath, fallbackTimestamp, prevOffsetEntry, options = {}) {
12990
+ const fallbackModel = options.fallbackModel || "grok-build";
12991
+ if (!updatesPath) {
12992
+ return { turnEvents: [], contextEvents: [], offsetEntry: null };
12993
+ }
12617
12994
  let stat;
12618
12995
  try {
12619
12996
  stat = fssync.statSync(updatesPath);
12620
- if (!stat.isFile()) return { events: [], offsetEntry: null };
12997
+ if (!stat.isFile()) return { turnEvents: [], contextEvents: [], offsetEntry: null };
12621
12998
  } catch {
12622
- return { events: [], offsetEntry: null };
12999
+ return { turnEvents: [], contextEvents: [], offsetEntry: null };
12623
13000
  }
12624
13001
 
12625
- // updates.jsonl is append-only and carries cumulative totalTokens, deduped
12626
- // by the session high-watermark, so resuming from the last consumed byte is
12627
- // safe: re-read events stay below the watermark (no double count), and any
12628
- // bytes a write race leaves unparsed are covered by the next event's
12629
- // cumulative total. Re-read from 0 on truncation or inode change.
13002
+ // updates.jsonl is append-only. Turn usage is additive per turn_completed, so
13003
+ // resuming from the last consumed byte is safe. Re-read from 0 on truncation
13004
+ // or inode change.
12630
13005
  const prevSize = Number(prevOffsetEntry?.size) || 0;
12631
13006
  const prevIno = prevOffsetEntry?.ino;
12632
13007
  const inodeChanged = typeof prevIno === "number" && prevIno !== stat.ino;
12633
13008
  const startOffset = stat.size < prevSize || inodeChanged ? 0 : prevSize;
12634
13009
  const baseOffset = { mtimeMs: stat.mtimeMs, ino: stat.ino };
12635
13010
  if (stat.size <= startOffset) {
12636
- return { events: [], offsetEntry: { size: startOffset, ...baseOffset } };
13011
+ return {
13012
+ turnEvents: [],
13013
+ contextEvents: [],
13014
+ offsetEntry: { size: startOffset, ...baseOffset },
13015
+ };
12637
13016
  }
12638
13017
 
12639
13018
  // Only advance the stored offset to the end of the last newline-terminated
@@ -12642,7 +13021,8 @@ async function readGrokUpdateTokenEvents(updatesPath, fallbackTimestamp, prevOff
12642
13021
  // complete instead of skipping it forever (which would undercount tokens).
12643
13022
  const endsWithNewline = grokFileEndsWithNewline(updatesPath, stat.size);
12644
13023
 
12645
- const events = [];
13024
+ const turnEvents = [];
13025
+ const contextEvents = [];
12646
13026
  let lineIndex = 0;
12647
13027
  let lastLine = "";
12648
13028
  const input = fssync.createReadStream(updatesPath, {
@@ -12662,21 +13042,24 @@ async function readGrokUpdateTokenEvents(updatesPath, fallbackTimestamp, prevOff
12662
13042
  } catch {
12663
13043
  continue;
12664
13044
  }
12665
- const meta = record?.params?._meta || record?._meta;
12666
- if (!meta || typeof meta !== "object") continue;
12667
- const totalTokens = normalizeNonNegativeNumber(meta.totalTokens);
12668
- if (totalTokens <= 0) continue;
12669
- const timestamp = grokTimestampFromUpdate(meta, record, fallbackTimestamp);
12670
- events.push({
12671
- totalTokens,
12672
- timestamp,
12673
- eventId: grokEventId(meta.eventId ?? record?.eventId ?? record?.id, String(lineIndex)),
12674
- });
13045
+ const turns = extractGrokTurnUsageEvents(
13046
+ record,
13047
+ fallbackTimestamp,
13048
+ fallbackModel,
13049
+ lineIndex,
13050
+ );
13051
+ if (turns.length > 0) {
13052
+ turnEvents.push(...turns);
13053
+ continue;
13054
+ }
13055
+ const contextEvent = extractGrokContextTokenEvent(record, fallbackTimestamp, lineIndex);
13056
+ if (contextEvent) contextEvents.push(contextEvent);
12675
13057
  }
12676
13058
  } catch {
12677
- // Stream error mid-read: keep the events we got, but do not advance the
12678
- // offset so the next sync retries the same range (watermark-safe).
12679
- return { events, offsetEntry: prevOffsetEntry || null };
13059
+ // Stream error mid-read: discard partial events and do not advance the
13060
+ // offset, so the next sync re-extracts from the same range exactly once
13061
+ // instead of double-counting already-parsed turn events.
13062
+ return { turnEvents: [], contextEvents: [], offsetEntry: prevOffsetEntry || null };
12680
13063
  }
12681
13064
 
12682
13065
  // When the file does not end on a newline, the final emitted line is a
@@ -12684,7 +13067,11 @@ async function readGrokUpdateTokenEvents(updatesPath, fallbackTimestamp, prevOff
12684
13067
  // stays on a complete-line boundary and the line is re-read once finished.
12685
13068
  const trailingPartialBytes = endsWithNewline ? 0 : Buffer.byteLength(lastLine, "utf8");
12686
13069
  const committedSize = Math.max(startOffset, stat.size - trailingPartialBytes);
12687
- return { events, offsetEntry: { size: committedSize, ...baseOffset } };
13070
+ return {
13071
+ turnEvents,
13072
+ contextEvents,
13073
+ offsetEntry: { size: committedSize, ...baseOffset },
13074
+ };
12688
13075
  }
12689
13076
 
12690
13077
  function estimateGrokTokenDelta(totalTokens, conversationCount, options = {}) {
@@ -12706,6 +13093,70 @@ function estimateGrokTokenDelta(totalTokens, conversationCount, options = {}) {
12706
13093
  };
12707
13094
  }
12708
13095
 
13096
+ function clearGrokHourlyBuckets(hourlyState) {
13097
+ if (!hourlyState || typeof hourlyState !== "object") return;
13098
+ const buckets = hourlyState.buckets && typeof hourlyState.buckets === "object" ? hourlyState.buckets : null;
13099
+ if (buckets) {
13100
+ for (const key of Object.keys(buckets)) {
13101
+ if (key.startsWith("grok|")) delete buckets[key];
13102
+ }
13103
+ }
13104
+ const groupQueued =
13105
+ hourlyState.groupQueued && typeof hourlyState.groupQueued === "object"
13106
+ ? hourlyState.groupQueued
13107
+ : null;
13108
+ if (groupQueued) {
13109
+ for (const key of Object.keys(groupQueued)) {
13110
+ if (key.startsWith("grok|")) delete groupQueued[key];
13111
+ }
13112
+ }
13113
+ }
13114
+
13115
+ async function retractStaleGrokQueueRows(queuePath, keepKeys) {
13116
+ if (!queuePath) return 0;
13117
+ let raw = "";
13118
+ try {
13119
+ raw = fssync.readFileSync(queuePath, "utf8");
13120
+ } catch (error) {
13121
+ if (error?.code === "ENOENT") return 0;
13122
+ throw error;
13123
+ }
13124
+
13125
+ const latestGrok = new Map();
13126
+ for (const line of raw.split("\n")) {
13127
+ if (!line.trim()) continue;
13128
+ let row;
13129
+ try {
13130
+ row = JSON.parse(line);
13131
+ } catch {
13132
+ continue;
13133
+ }
13134
+ if ((row?.source || "") !== "grok") continue;
13135
+ const model = normalizeModelInput(row.model) || DEFAULT_MODEL;
13136
+ const hourStart = typeof row.hour_start === "string" ? row.hour_start : null;
13137
+ if (!hourStart) continue;
13138
+ latestGrok.set(bucketKey("grok", model, hourStart), { model, hour_start: hourStart, row });
13139
+ }
13140
+
13141
+ const zero = initTotals();
13142
+ const lines = [];
13143
+ for (const [key, entry] of latestGrok.entries()) {
13144
+ if (keepKeys.has(key)) continue;
13145
+ if (totalsKey(entry.row) === totalsKey(zero)) continue;
13146
+ lines.push(
13147
+ JSON.stringify({
13148
+ source: "grok",
13149
+ model: entry.model,
13150
+ hour_start: entry.hour_start,
13151
+ ...zero,
13152
+ }),
13153
+ );
13154
+ }
13155
+ if (lines.length === 0) return 0;
13156
+ await fs.appendFile(queuePath, `${lines.join("\n")}\n`, "utf8");
13157
+ return lines.length;
13158
+ }
13159
+
12709
13160
  async function parseGrokBuildIncremental({
12710
13161
  sessions,
12711
13162
  cursors = {},
@@ -12716,11 +13167,44 @@ async function parseGrokBuildIncremental({
12716
13167
  if (queuePath) await ensureDir(path.dirname(queuePath));
12717
13168
  const hourlyState = normalizeHourlyState(cursors?.hourly);
12718
13169
  const grokState = cursors.grok && typeof cursors.grok === "object" ? { ...cursors.grok } : {};
12719
- let sessionSnapshots = normalizeGrokSessionSnapshots(grokState);
13170
+ const prevVersion = Number(grokState.version) || 0;
13171
+ const needsTurnUsageMigration = prevVersion < GROK_CURSOR_VERSION;
13172
+
13173
+ // v3 and earlier treated context-window totalTokens as cumulative spend, which
13174
+ // undercounts heavily (often 10-50x) and mis-splits input/output. Rebuild from
13175
+ // turn_completed.usage when migrating to v4.
13176
+ //
13177
+ // Drop prior watermark totals / updateOffsets so files are re-read from byte 0,
13178
+ // but keep legacySeen markers from seenSessions so the one-shot baseline
13179
+ // (sessions already counted under the old scanner) still applies.
13180
+ let sessionSnapshots;
13181
+ if (needsTurnUsageMigration) {
13182
+ const normalized = normalizeGrokSessionSnapshots(grokState);
13183
+ sessionSnapshots = {};
13184
+ for (const [sessionId, snapshot] of Object.entries(normalized)) {
13185
+ if (!snapshot?.legacySeen) continue;
13186
+ sessionSnapshots[sessionId] = {
13187
+ totalTokens: 0,
13188
+ messageCount: 0,
13189
+ model: null,
13190
+ source: null,
13191
+ lastEventId: null,
13192
+ lastEventTimestamp: null,
13193
+ updatedAt: snapshot.updatedAt || null,
13194
+ legacySeen: true,
13195
+ };
13196
+ }
13197
+ clearGrokHourlyBuckets(hourlyState);
13198
+ } else {
13199
+ sessionSnapshots = normalizeGrokSessionSnapshots(grokState);
13200
+ }
12720
13201
  const prevUpdateOffsets =
12721
- grokState.updateOffsets && typeof grokState.updateOffsets === "object"
13202
+ !needsTurnUsageMigration &&
13203
+ grokState.updateOffsets &&
13204
+ typeof grokState.updateOffsets === "object"
12722
13205
  ? grokState.updateOffsets
12723
13206
  : {};
13207
+
12724
13208
  // Rebuilt from the sessions seen this scan, so entries for deleted session
12725
13209
  // dirs are pruned and the cursor stays bounded by the on-disk session count.
12726
13210
  const updateOffsets = {};
@@ -12755,83 +13239,142 @@ async function parseGrokBuildIncremental({
12755
13239
  const model = grokModelFromSignals(safeSignals);
12756
13240
  const lastActive = grokLastActiveFromSignals(safeSignals, summary);
12757
13241
 
12758
- let highWatermark = previousTotal;
12759
- let observedTotal = previousTotal;
13242
+ let cumulativeTotal = previousTotal;
12760
13243
  let tokenDeltaForSession = 0;
12761
13244
  let finalTouchedHourStart = null;
12762
13245
  let source = previous.source || null;
12763
13246
  let lastEventId = previous.lastEventId || null;
12764
13247
  let lastEventTimestamp = previous.lastEventTimestamp || null;
12765
- const pendingTokenDeltas = [];
12766
-
12767
- const recordTokenDelta = (deltaTokens, timestamp, deltaSource) => {
12768
- const hourStartStr = toUtcHalfHourStart(timestamp) || toUtcHalfHourStart(Date.now());
12769
- if (!hourStartStr) return false;
12770
- pendingTokenDeltas.push({ deltaTokens, hourStartStr });
12771
- tokenDeltaForSession += deltaTokens;
12772
- finalTouchedHourStart = hourStartStr;
12773
- source = deltaSource;
12774
- lastEventTimestamp = timestamp || lastEventTimestamp;
12775
- return true;
12776
- };
13248
+ let lastModel = previous.model || model;
13249
+ let sawTurnUsage = source === "turn_usage" || previous.source === "turn_usage";
13250
+ // Defer bucket writes until after we know whether this is a legacy baseline
13251
+ // pass (first sighting of a session already counted under an older scanner).
13252
+ const pendingBucketDeltas = [];
12777
13253
 
12778
13254
  const updatesPath = grokUpdatesPathForSession(sess);
12779
13255
  const updates = await readGrokUpdateTokenEvents(
12780
13256
  updatesPath,
12781
13257
  lastActive,
12782
13258
  updatesPath ? prevUpdateOffsets[updatesPath] : null,
13259
+ { fallbackModel: model },
12783
13260
  );
12784
13261
  if (updatesPath && updates.offsetEntry) {
12785
13262
  updateOffsets[updatesPath] = updates.offsetEntry;
12786
13263
  }
12787
- for (const event of updates.events) {
12788
- observedTotal = Math.max(observedTotal, event.totalTokens);
13264
+
13265
+ // Preferred path: each turn_completed carries true cumulative API usage for
13266
+ // that turn (input/output/cache/reasoning). Sum them.
13267
+ for (const event of updates.turnEvents) {
13268
+ sawTurnUsage = true;
13269
+ const hourStartStr = toUtcHalfHourStart(event.timestamp) || toUtcHalfHourStart(lastActive) || toUtcHalfHourStart(Date.now());
13270
+ if (!hourStartStr) continue;
13271
+ const eventModel = event.model || model;
13272
+ const delta = {
13273
+ input_tokens: event.input_tokens,
13274
+ cached_input_tokens: event.cached_input_tokens,
13275
+ cache_creation_input_tokens: event.cache_creation_input_tokens,
13276
+ output_tokens: event.output_tokens,
13277
+ reasoning_output_tokens: event.reasoning_output_tokens,
13278
+ total_tokens: event.total_tokens,
13279
+ billable_total_tokens: event.billable_total_tokens,
13280
+ conversation_count: event.conversation_count || 1,
13281
+ };
13282
+ pendingBucketDeltas.push({ model: eventModel, hourStartStr, delta });
13283
+ cumulativeTotal += event.total_tokens;
13284
+ tokenDeltaForSession += event.total_tokens;
13285
+ finalTouchedHourStart = hourStartStr;
13286
+ source = "turn_usage";
12789
13287
  lastEventId = event.eventId || lastEventId;
12790
13288
  lastEventTimestamp = event.timestamp || lastEventTimestamp;
12791
- if (event.totalTokens <= highWatermark) continue;
12792
- const deltaTokens = event.totalTokens - highWatermark;
12793
- highWatermark = event.totalTokens;
12794
- recordTokenDelta(deltaTokens, event.timestamp || lastActive, "updates");
12795
- }
13289
+ lastModel = eventModel;
13290
+ }
13291
+
13292
+ // Fallback only when this session never emitted turn_completed usage
13293
+ // (older logs / partial sessions). Context watermark is a lower-bound
13294
+ // estimate and must not run on top of turn usage.
13295
+ if (!sawTurnUsage) {
13296
+ let highWatermark = previousTotal;
13297
+ for (const event of updates.contextEvents) {
13298
+ lastEventId = event.eventId || lastEventId;
13299
+ lastEventTimestamp = event.timestamp || lastEventTimestamp;
13300
+ if (event.totalTokens <= highWatermark) continue;
13301
+ const deltaTokens = event.totalTokens - highWatermark;
13302
+ highWatermark = event.totalTokens;
13303
+ const hourStartStr =
13304
+ toUtcHalfHourStart(event.timestamp) ||
13305
+ toUtcHalfHourStart(lastActive) ||
13306
+ toUtcHalfHourStart(Date.now());
13307
+ if (!hourStartStr) continue;
13308
+ const delta = estimateGrokTokenDelta(deltaTokens, 0, { allowZeroConversationCount: true });
13309
+ pendingBucketDeltas.push({ model, hourStartStr, delta });
13310
+ tokenDeltaForSession += deltaTokens;
13311
+ finalTouchedHourStart = hourStartStr;
13312
+ source = "updates";
13313
+ }
12796
13314
 
12797
- const effectiveSignalTotal = grokEffectiveTotalFromSignals(safeSignals);
12798
- observedTotal = Math.max(observedTotal, effectiveSignalTotal);
12799
- if (effectiveSignalTotal > highWatermark) {
12800
- const deltaTokens = effectiveSignalTotal - highWatermark;
12801
- highWatermark = effectiveSignalTotal;
12802
- recordTokenDelta(deltaTokens, lastActive, "signals");
13315
+ const effectiveSignalTotal = grokEffectiveTotalFromSignals(safeSignals);
13316
+ if (effectiveSignalTotal > highWatermark) {
13317
+ const deltaTokens = effectiveSignalTotal - highWatermark;
13318
+ highWatermark = effectiveSignalTotal;
13319
+ const hourStartStr = toUtcHalfHourStart(lastActive) || toUtcHalfHourStart(Date.now());
13320
+ if (hourStartStr) {
13321
+ const delta = estimateGrokTokenDelta(deltaTokens, 0, { allowZeroConversationCount: true });
13322
+ pendingBucketDeltas.push({ model, hourStartStr, delta });
13323
+ tokenDeltaForSession += deltaTokens;
13324
+ finalTouchedHourStart = hourStartStr;
13325
+ source = "signals";
13326
+ }
13327
+ }
13328
+ cumulativeTotal = Math.max(previousTotal, highWatermark);
12803
13329
  }
12804
13330
 
12805
- const finalTotal = Math.max(previousTotal, highWatermark, observedTotal);
13331
+ const finalTotal = Math.max(previousTotal, cumulativeTotal);
13332
+ // Sessions already observed under an older scanner must establish a watermark
13333
+ // without backfilling historical tokens as brand-new usage.
12806
13334
  const legacyBaselineOnly = previous.legacySeen && previousTotal === 0 && finalTotal > 0;
13335
+
12807
13336
  if (!legacyBaselineOnly) {
12808
- for (const pending of pendingTokenDeltas) {
12809
- const delta = estimateGrokTokenDelta(pending.deltaTokens, 0, { allowZeroConversationCount: true });
12810
- const bucket = getHourlyBucket(hourlyState, "grok", model, pending.hourStartStr);
12811
- addTotals(bucket.totals, delta);
12812
- touchedBuckets.add(bucketKey("grok", model, pending.hourStartStr));
13337
+ for (const pending of pendingBucketDeltas) {
13338
+ const bucket = getHourlyBucket(hourlyState, "grok", pending.model, pending.hourStartStr);
13339
+ addTotals(bucket.totals, pending.delta);
13340
+ touchedBuckets.add(bucketKey("grok", pending.model, pending.hourStartStr));
12813
13341
  eventsAggregated++;
12814
13342
  }
12815
- }
12816
13343
 
12817
- if (!legacyBaselineOnly && tokenDeltaForSession > 0 && finalTouchedHourStart) {
12818
- const deltaMessageCount =
12819
- messageCount > previousMessageCount ? messageCount - previousMessageCount : 1;
12820
- const bucket = getHourlyBucket(hourlyState, "grok", model, finalTouchedHourStart);
12821
- addTotals(bucket.totals, { conversation_count: deltaMessageCount });
12822
- touchedBuckets.add(bucketKey("grok", model, finalTouchedHourStart));
13344
+ // Message/conversation count for fallback-only sessions (turn path already
13345
+ // counts each turn_completed as one conversation).
13346
+ if (!sawTurnUsage && tokenDeltaForSession > 0 && finalTouchedHourStart) {
13347
+ const deltaMessageCount =
13348
+ messageCount > previousMessageCount ? messageCount - previousMessageCount : 1;
13349
+ const bucket = getHourlyBucket(hourlyState, "grok", lastModel || model, finalTouchedHourStart);
13350
+ addTotals(bucket.totals, { conversation_count: deltaMessageCount });
13351
+ touchedBuckets.add(bucketKey("grok", lastModel || model, finalTouchedHourStart));
13352
+ }
12823
13353
  }
12824
13354
 
12825
13355
  if (finalTotal > 0 && (tokenDeltaForSession > 0 || previousTotal > 0 || legacyBaselineOnly)) {
12826
13356
  sessionSnapshots[sessionId] = {
12827
13357
  totalTokens: finalTotal,
12828
13358
  messageCount: Math.max(previousMessageCount, messageCount),
12829
- model,
13359
+ model: lastModel || model,
12830
13360
  source: source || previous.source || null,
12831
13361
  lastEventId,
12832
13362
  lastEventTimestamp,
12833
13363
  updatedAt: new Date().toISOString(),
12834
13364
  };
13365
+ } else if (previous.legacySeen && finalTotal === 0) {
13366
+ // Keep the baseline marker across empty syncs so later growth is still
13367
+ // baselined once instead of backfilled as brand-new usage.
13368
+ sessionSnapshots[sessionId] = {
13369
+ totalTokens: 0,
13370
+ messageCount: Math.max(previousMessageCount, messageCount),
13371
+ model: lastModel || model,
13372
+ source: previous.source || null,
13373
+ lastEventId: lastEventId || previous.lastEventId || null,
13374
+ lastEventTimestamp: lastEventTimestamp || previous.lastEventTimestamp || null,
13375
+ updatedAt: new Date().toISOString(),
13376
+ legacySeen: true,
13377
+ };
12835
13378
  }
12836
13379
 
12837
13380
  if (onProgress) {
@@ -12839,19 +13382,45 @@ async function parseGrokBuildIncremental({
12839
13382
  }
12840
13383
  }
12841
13384
 
12842
- const bucketsQueued = queuePath
13385
+ let bucketsQueued = queuePath
12843
13386
  ? await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets })
12844
13387
  : 0;
13388
+
13389
+ // After a semantics migration, retract stale grok queue keys that the full
13390
+ // rescan no longer produces so dashboard "latest per key" no longer keeps
13391
+ // the old undercounted rows.
13392
+ if (needsTurnUsageMigration && queuePath) {
13393
+ const keepKeys = new Set();
13394
+ for (const [key, bucket] of Object.entries(hourlyState.buckets || {})) {
13395
+ if (!key.startsWith("grok|") || !bucket?.totals) continue;
13396
+ keepKeys.add(key);
13397
+ }
13398
+ const retracted = await retractStaleGrokQueueRows(queuePath, keepKeys);
13399
+ bucketsQueued += retracted;
13400
+ }
13401
+
12845
13402
  hourlyState.updatedAt = new Date().toISOString();
12846
13403
  cursors.hourly = hourlyState;
12847
13404
  sessionSnapshots = capGrokSessionSnapshots(sessionSnapshots);
12848
13405
 
13406
+ const migrations = grokState.migrations && typeof grokState.migrations === "object"
13407
+ ? { ...grokState.migrations }
13408
+ : {};
13409
+ if (needsTurnUsageMigration) {
13410
+ migrations.turnUsageV4 = {
13411
+ appliedAt: new Date().toISOString(),
13412
+ fromVersion: prevVersion,
13413
+ toVersion: GROK_CURSOR_VERSION,
13414
+ };
13415
+ }
13416
+
12849
13417
  cursors.grok = {
12850
13418
  ...grokState,
12851
13419
  version: GROK_CURSOR_VERSION,
12852
13420
  sessionSnapshots,
12853
13421
  seenSessions: Object.keys(sessionSnapshots),
12854
13422
  updateOffsets,
13423
+ migrations,
12855
13424
  updatedAt: new Date().toISOString()
12856
13425
  };
12857
13426