tokentracker-cli 1.4.3 → 1.4.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 (122) hide show
  1. package/README.de.md +1 -0
  2. package/README.ja.md +1 -0
  3. package/README.ko.md +1 -0
  4. package/README.md +5 -0
  5. package/README.zh-CN.md +1 -0
  6. package/dashboard/dist/assets/{AchievementBadge-3IYidbrI.js → AchievementBadge-BmWRsZMx.js} +1 -1
  7. package/dashboard/dist/assets/{AchievementsPage-DmZ_ojyM.js → AchievementsPage-CyIP2nck.js} +2 -2
  8. package/dashboard/dist/assets/{AchievementsSection-DIYrrzhw.js → AchievementsSection-BeNyJ0LW.js} +1 -1
  9. package/dashboard/dist/assets/{BadgeDetailModal-DMMExCe4.js → BadgeDetailModal-DIFnFzkv.js} +1 -1
  10. package/dashboard/dist/assets/{BotAnimated-y6vZNRvC.js → BotAnimated-CKVeJHit.js} +1 -1
  11. package/dashboard/dist/assets/{Card-LJX3G3zo.js → Card-lQmyKbh4.js} +1 -1
  12. package/dashboard/dist/assets/{CloudCheckoutPage-BGBgSXNE.js → CloudCheckoutPage-B_8y6dyX.js} +1 -1
  13. package/dashboard/dist/assets/{CloudPage-Dij2GTXi.js → CloudPage-BOQjUGAt.js} +2 -2
  14. package/dashboard/dist/assets/{CommandPalette-BIky6Fk0.js → CommandPalette-BV9LqokI.js} +1 -1
  15. package/dashboard/dist/assets/{CommunityStatsModal-DbM99i0s.js → CommunityStatsModal-WELxKEFc.js} +1 -1
  16. package/dashboard/dist/assets/{ConfirmModal-C_NH8vgO.js → ConfirmModal-B8H5o8c_.js} +1 -1
  17. package/dashboard/dist/assets/{Controls-BEAj_kvr.js → Controls-9ynPx9rF.js} +1 -1
  18. package/dashboard/dist/assets/{DashboardPage-DO7h2dfu.js → DashboardPage-B8z-U63p.js} +1 -1
  19. package/dashboard/dist/assets/{DateRangePopover-PQXB8v0k.js → DateRangePopover-DEqWsV_8.js} +1 -1
  20. package/dashboard/dist/assets/{DevicePage-0JZ3OIWZ.js → DevicePage-CUOlUySM.js} +1 -1
  21. package/dashboard/dist/assets/{DialogClose-BpWoD5h9.js → DialogClose-Dlg52bL6.js} +1 -1
  22. package/dashboard/dist/assets/DialogDescription-vuQFBlM_.js +1 -0
  23. package/dashboard/dist/assets/{DialogTitle-DoQgdh5w.js → DialogTitle-CduwXjqH.js} +1 -1
  24. package/dashboard/dist/assets/{ElectricLogo-0O87VjKv.js → ElectricLogo-DFt7_F2f.js} +1 -1
  25. package/dashboard/dist/assets/{FadeIn-9ph1h_AG.js → FadeIn-DlyusE6n.js} +1 -1
  26. package/dashboard/dist/assets/{HeaderGithubStar-DCZ92xbH.js → HeaderGithubStar-tUtJoFHh.js} +1 -1
  27. package/dashboard/dist/assets/{HoverTooltip-CCKdmuOP.js → HoverTooltip-v-zkCqsX.js} +1 -1
  28. package/dashboard/dist/assets/{IpCheckPage-CpyhpmdS.js → IpCheckPage-C5Y1uVEr.js} +1 -1
  29. package/dashboard/dist/assets/{LandingPage-spvsPkYy.js → LandingPage-DzOJOAj7.js} +2 -2
  30. package/dashboard/dist/assets/{LeaderboardPage-2nJ08ohw.js → LeaderboardPage-BBZ_6tCw.js} +2 -2
  31. package/dashboard/dist/assets/{LeaderboardProBadge-D6FSL3Qy.js → LeaderboardProBadge-DakoXIQn.js} +1 -1
  32. package/dashboard/dist/assets/{LeaderboardProfileModal-DD1jX-rD.js → LeaderboardProfileModal-CINpES_x.js} +3 -3
  33. package/dashboard/dist/assets/{LeaderboardProfilePage-DYqRrntL.js → LeaderboardProfilePage-B6ISf-1l.js} +1 -1
  34. package/dashboard/dist/assets/{LimitsPage-DXHikunk.js → LimitsPage-UVX730CQ.js} +8 -8
  35. package/dashboard/dist/assets/{LocalOnlyNotice-BURiQAtP.js → LocalOnlyNotice-BJSsmXF1.js} +1 -1
  36. package/dashboard/dist/assets/{LoginCard-Cv2ijoJx.js → LoginCard-Dggv2aGv.js} +1 -1
  37. package/dashboard/dist/assets/{LoginModal-DyOideDA.js → LoginModal-B8qDd2kY.js} +1 -1
  38. package/dashboard/dist/assets/{LoginPage-BKNvqvYi.js → LoginPage-Dv66U8yJ.js} +1 -1
  39. package/dashboard/dist/assets/{PetPage-BBmozE0L.js → PetPage-CNwrQdKM.js} +2 -2
  40. package/dashboard/dist/assets/{PopoverPopup-DDnISLKM.js → PopoverPopup-CqmvpYOi.js} +1 -1
  41. package/dashboard/dist/assets/{ProviderIcon-Da90GDcM.js → ProviderIcon-BF2Ch7wi.js} +1 -1
  42. package/dashboard/dist/assets/{ResetPasswordPage-DsETr50j.js → ResetPasswordPage-B0R8VUU3.js} +1 -1
  43. package/dashboard/dist/assets/{Select-pZDawyxt.js → Select-idAiBnJG.js} +1 -1
  44. package/dashboard/dist/assets/{SelectItemText-D5wuuN7G.js → SelectItemText-DPmmPY_D.js} +1 -1
  45. package/dashboard/dist/assets/{SelfHostPage-sm-PARYj.js → SelfHostPage-oR9cPK02.js} +1 -1
  46. package/dashboard/dist/assets/{ServiceStatusPage-D4f8h0sL.js → ServiceStatusPage-CdV9gfGF.js} +1 -1
  47. package/dashboard/dist/assets/{SessionsPage-xVLsTN4m.js → SessionsPage-BkD34VlL.js} +1 -1
  48. package/dashboard/dist/assets/{SettingsPage-Dt9iFheY.js → SettingsPage-DsthWCyM.js} +3 -3
  49. package/dashboard/dist/assets/{SkillsPage-BdWalPpd.js → SkillsPage-CzMk9s3x.js} +1 -1
  50. package/dashboard/dist/assets/{TokenGalaxy-d2_ml0tE.js → TokenGalaxy-DQ6o8YBp.js} +1 -1
  51. package/dashboard/dist/assets/{TooltipPopup-B4masxDW.js → TooltipPopup-CMQjA-HE.js} +1 -1
  52. package/dashboard/dist/assets/{TrendMonitor-CceYpa2O.js → TrendMonitor-DrV1m8E7.js} +1 -1
  53. package/dashboard/dist/assets/{WidgetsPage-CSN0829H.js → WidgetsPage-Cc16WM0Y.js} +1 -1
  54. package/dashboard/dist/assets/{WrappedPage-gP4WfO_m.js → WrappedPage-pMwbM4R7.js} +1 -1
  55. package/dashboard/dist/assets/{__vite-browser-external-BtJVmciH.js → __vite-browser-external-DCy_YLfe.js} +3 -3
  56. package/dashboard/dist/assets/{agent-logos-wxccxfWz.js → agent-logos-Zw7LNkMO.js} +1 -1
  57. package/dashboard/dist/assets/{arrow-left-Bd5C77R2.js → arrow-left-DycYjE-k.js} +1 -1
  58. package/dashboard/dist/assets/{arrow-right-C1RqrK4A.js → arrow-right-uNxLHoJ0.js} +1 -1
  59. package/dashboard/dist/assets/{arrow-up-right-B_b2i8hn.js → arrow-up-right-CmdJ7gd7.js} +1 -1
  60. package/dashboard/dist/assets/{calendar-days-C8eYygme.js → calendar-days-CJ33_t5O.js} +1 -1
  61. package/dashboard/dist/assets/{check-BlBHefQ7.js → check-Dbubk4Il.js} +1 -1
  62. package/dashboard/dist/assets/{chevron-down-mjUspqZv.js → chevron-down-BwvBVAOs.js} +1 -1
  63. package/dashboard/dist/assets/{cloud-checkout-CEkMnWXf.js → cloud-checkout-IG-IOpeP.js} +1 -1
  64. package/dashboard/dist/assets/{composite-CzXFY9IC.js → composite-IIgJPPIw.js} +1 -1
  65. package/dashboard/dist/assets/{download-CQvg59w1.js → download-DxtH7w6a.js} +1 -1
  66. package/dashboard/dist/assets/{external-link-CDYfIZ3Z.js → external-link-evKf3mnD.js} +1 -1
  67. package/dashboard/dist/assets/{flame-C9NXQRil.js → flame-D2qOE_W4.js} +1 -1
  68. package/dashboard/dist/assets/{format-tokens-fKy_3qpW.js → format-tokens-CqDmm-sK.js} +1 -1
  69. package/dashboard/dist/assets/{gift-WmV4SBGR.js → gift-Ge11HK1x.js} +1 -1
  70. package/dashboard/dist/assets/{host-mode-C5MvZ7SV.js → host-mode-8_TNh7yE.js} +1 -1
  71. package/dashboard/dist/assets/{icons-B1EtsaZs.js → icons-OmfWvE5K.js} +1 -1
  72. package/dashboard/dist/assets/{index-9EZ3o2RV.js → index-BYuEzP9p.js} +1 -1
  73. package/dashboard/dist/assets/{index-BEecBW06.js → index-BsYtyo4U.js} +1 -1
  74. package/dashboard/dist/assets/{index-DXNwBFNi.js → index-tbe1aERA.js} +1 -1
  75. package/dashboard/dist/assets/{info-CWDhggIW.js → info-xhrso9aO.js} +1 -1
  76. package/dashboard/dist/assets/{link-2-DEWNkK7s.js → link-2-BgNpTaO4.js} +1 -1
  77. package/dashboard/dist/assets/{loader-circle-DotT5gVp.js → loader-circle-DRooZ0e5.js} +1 -1
  78. package/dashboard/dist/assets/{modifiers.esm-qZnGgJAz.js → modifiers.esm-D-QAEpOZ.js} +1 -1
  79. package/dashboard/dist/assets/{monitor-smartphone-DOP5eqBD.js → monitor-smartphone-Bwu7__2B.js} +1 -1
  80. package/dashboard/dist/assets/{play-BUsybi9m.js → play-CBPvTiWM.js} +1 -1
  81. package/dashboard/dist/assets/{plus-DAjojh_m.js → plus-B4lFIyP8.js} +1 -1
  82. package/dashboard/dist/assets/{provider-display-BCQQYPWD.js → provider-display-CQlWusXH.js} +1 -1
  83. package/dashboard/dist/assets/{react-BgpqhoCj.js → react-DDOtS4Bd.js} +1 -1
  84. package/dashboard/dist/assets/{safePolygon-C-PFpZSA.js → safePolygon-BGfBVKoy.js} +1 -1
  85. package/dashboard/dist/assets/{search-W1A-6ugo.js → search-DVyNq72J.js} +1 -1
  86. package/dashboard/dist/assets/{server-CE7pFjDr.js → server-EpUTEsOf.js} +1 -1
  87. package/dashboard/dist/assets/{shield-check-KL5xc2FR.js → shield-check-DRg8cPKV.js} +1 -1
  88. package/dashboard/dist/assets/{skills-api-DL_zLjKA.js → skills-api-y42dEF96.js} +1 -1
  89. package/dashboard/dist/assets/{sortable.esm-CehJQvFH.js → sortable.esm-D2S1gXCK.js} +1 -1
  90. package/dashboard/dist/assets/{store-BHAXkHPR.js → store-DfGPS2IR.js} +1 -1
  91. package/dashboard/dist/assets/{terminal-Br5vyD6Q.js → terminal-hXIrrL2b.js} +1 -1
  92. package/dashboard/dist/assets/{trash-CzaCJP6G.js → trash-cjaASRQl.js} +1 -1
  93. package/dashboard/dist/assets/{use-community-stats-DbJrecQw.js → use-community-stats-B-nipjWt.js} +1 -1
  94. package/dashboard/dist/assets/{use-latest-request-guard-DGhwmlX8.js → use-latest-request-guard-DIEYrSzn.js} +1 -1
  95. package/dashboard/dist/assets/{use-limits-display-prefs-T7KWAWZ6.js → use-limits-display-prefs-BdHDwBBC.js} +1 -1
  96. package/dashboard/dist/assets/{use-native-settings-MZJhS328.js → use-native-settings-7w71_h44.js} +1 -1
  97. package/dashboard/dist/assets/{use-ordered-list-BDN24weA.js → use-ordered-list-CSMpK4ar.js} +1 -1
  98. package/dashboard/dist/assets/use-reduced-motion-HUGf223R.js +1 -0
  99. package/dashboard/dist/assets/{use-session-efficiency-pref-DUu16y8Z.js → use-session-efficiency-pref-BagBVWW6.js} +1 -1
  100. package/dashboard/dist/assets/{use-transform-BczE46xm.js → use-transform-sjGGXCNn.js} +1 -1
  101. package/dashboard/dist/assets/use-usage-limits-Nml-wUB-.js +1 -0
  102. package/dashboard/dist/assets/{useCurrency-BgZmWbkx.js → useCurrency-DAKIY1nR.js} +1 -1
  103. package/dashboard/dist/assets/{usePositioner-dWENyY8z.js → usePositioner-B7vVQ7th.js} +1 -1
  104. package/dashboard/dist/assets/{useTokenFormat-D7s1-Zuk.js → useTokenFormat-gtgw8k2Z.js} +1 -1
  105. package/dashboard/dist/assets/{zap-Dr__bKlq.js → zap-DFWOiTJ1.js} +1 -1
  106. package/dashboard/dist/index.html +1 -1
  107. package/dashboard/dist/ip-check.html +1 -1
  108. package/dashboard/dist/leaderboard.html +1 -1
  109. package/dashboard/dist/share.html +1 -1
  110. package/package.json +1 -1
  111. package/src/commands/sync.js +571 -34
  112. package/src/lib/async-queue-reader.js +37 -16
  113. package/src/lib/codex-context-breakdown.js +8 -1
  114. package/src/lib/codex-rollout-parser.js +65 -5
  115. package/src/lib/grok-limits.js +56 -0
  116. package/src/lib/pricing/seed-snapshot.json +1 -1
  117. package/src/lib/rollout.js +690 -60
  118. package/src/lib/session-analytics.js +1 -1
  119. package/src/lib/usage-limits.js +33 -1
  120. package/dashboard/dist/assets/DialogDescription-ne_rYSD7.js +0 -1
  121. package/dashboard/dist/assets/use-reduced-motion-CfCY-K-a.js +0 -1
  122. package/dashboard/dist/assets/use-usage-limits-CjyDfH-M.js +0 -1
@@ -96,10 +96,13 @@ async function listRolloutFiles(sessionsDir, options = {}) {
96
96
  : null;
97
97
  const stats = options?.stats && typeof options.stats === "object" ? options.stats : null;
98
98
  if (dayInventoryCache) {
99
- if (dayInventoryCache.version !== 1) {
99
+ // v2: isRolloutFileName also accepts rollout-*.jsonl.zst/.zstd. Old v1
100
+ // day lists omit archives that were already on disk when the directory
101
+ // metadata last matched, so bump to force a one-time rescan.
102
+ if (dayInventoryCache.version !== 2) {
100
103
  dayInventoryCache.days = {};
101
104
  }
102
- dayInventoryCache.version = 1;
105
+ dayInventoryCache.version = 2;
103
106
  if (!dayInventoryCache.days || typeof dayInventoryCache.days !== "object") {
104
107
  dayInventoryCache.days = {};
105
108
  }
@@ -180,7 +183,75 @@ function isRolloutFileName(name) {
180
183
  if (typeof name !== "string") return false;
181
184
  if (name.includes("/") || name.includes("\\")) return false;
182
185
  if (name !== path.basename(name)) return false;
183
- return name.startsWith("rollout-") && name.endsWith(".jsonl");
186
+ if (!name.startsWith("rollout-")) return false;
187
+ return name.endsWith(".jsonl") || isCompressedRolloutFileName(name);
188
+ }
189
+
190
+ // Compressed rollout archives cannot be tailed by byte offset: the cursor
191
+ // stores compressed bytes while the parser consumes decompressed text. Treat
192
+ // them as content-addressed snapshots — one parse per (inode, size) version —
193
+ // reusing the DSH session's zstd machinery (built-in zlib first,
194
+ // `@mongodb-js/zstd` fallback) including its multi-frame handling. Returns
195
+ // null for a missing/non-file path; an empty archive yields no lines (the
196
+ // same outcome as an empty uncompressed rollout).
197
+ const CODEX_ROLLOUT_COMPRESSED_PATTERN = /^rollout-.*\.jsonl\.zstd?$/;
198
+ const CODEX_ROLLOUT_TEXT_MAX_BYTES = 256 * 1024 * 1024;
199
+
200
+ function isCompressedRolloutFileName(name) {
201
+ return typeof name === "string" && CODEX_ROLLOUT_COMPRESSED_PATTERN.test(name);
202
+ }
203
+
204
+ function isCompressedRolloutPath(filePath) {
205
+ if (typeof filePath !== "string") return false;
206
+ return isCompressedRolloutFileName(path.basename(filePath));
207
+ }
208
+
209
+ async function readCodexRolloutSnapshot(filePath, { maxOutputBytes = CODEX_ROLLOUT_TEXT_MAX_BYTES } = {}) {
210
+ const handle = await fs.open(filePath, "r").catch(() => null);
211
+ if (!handle) return null;
212
+ try {
213
+ const initialStat = await handle.stat().catch(() => null);
214
+ if (!initialStat || !initialStat.isFile()) return null;
215
+ const outputLimit = Number.isSafeInteger(maxOutputBytes) && maxOutputBytes >= 0
216
+ ? maxOutputBytes
217
+ : CODEX_ROLLOUT_TEXT_MAX_BYTES;
218
+ let data;
219
+ try {
220
+ data = await handle.readFile();
221
+ } catch (_error) {
222
+ // Transient mount/IO failures (e.g. WSL ESTALE) must not abort the rest
223
+ // of sync; treat like a missing archive and keep the prior cursor.
224
+ return null;
225
+ }
226
+ const metadata = {
227
+ size: data.length,
228
+ mtimeMs: Number.isFinite(initialStat.mtimeMs) ? initialStat.mtimeMs : 0,
229
+ inode: initialStat.ino || 0,
230
+ };
231
+ if (data.length === 0) return { ...metadata, unchanged: false, lines: [] };
232
+ let decoded;
233
+ try {
234
+ const inspected = inspectDshZstdFrames(data, outputLimit);
235
+ decoded = await decodeDshZstd(data, { maxOutputBytes: outputLimit, inspected });
236
+ } catch (_error) {
237
+ // Malformed / truncated / oversized archives must not abort the rest of
238
+ // the sync: return null so the caller keeps the prior cursor and retries.
239
+ return null;
240
+ }
241
+ // Fatal UTF-8: uncompressed JSONL parsing rejects invalid bytes under
242
+ // invalidRecordPolicy=throw. toString("utf8") would silently replace them
243
+ // and claim utf8Valid, so a corrupted discriminator could drop usage
244
+ // during repairCodexRescanInflation. Fail the snapshot instead.
245
+ let text;
246
+ try {
247
+ text = new TextDecoder("utf-8", { fatal: true }).decode(decoded);
248
+ } catch (_error) {
249
+ return null;
250
+ }
251
+ return { ...metadata, unchanged: false, lines: text.length > 0 ? text.split("\n") : [] };
252
+ } finally {
253
+ await handle.close().catch(() => {});
254
+ }
184
255
  }
185
256
 
186
257
  function directoryInventoryStatKey(st) {
@@ -206,7 +277,7 @@ async function listRolloutFilesDeep(dir) {
206
277
  const p = path.join(d, e.name);
207
278
  if (e.isDirectory()) {
208
279
  await walk(p);
209
- } else if (e.isFile() && e.name.startsWith("rollout-") && e.name.endsWith(".jsonl")) {
280
+ } else if (e.isFile() && isRolloutFileName(e.name)) {
210
281
  out.push(p);
211
282
  }
212
283
  }
@@ -517,6 +588,16 @@ async function parseRolloutIncremental({
517
588
  ? prev?.codexUsageRecord || (prev?.lastTotal ? { sawTokenCount: true } : null)
518
589
  : null;
519
590
 
591
+ // Compressed archives are whole-file snapshots. If the compressed bytes
592
+ // changed under the same inode, force historical dedup; parseRolloutFile
593
+ // ignores retained baselines for the parse itself. Do NOT clear cursor
594
+ // fields here — a soft decode failure must leave the prior cursor intact.
595
+ const compressedSnapshotRescan = isCompressedRolloutPath(filePath)
596
+ && sameInode
597
+ && !truncated
598
+ && prevOffset > 0
599
+ && prevOffset !== st.size;
600
+
520
601
  const codexProjectFastPath =
521
602
  projectEnabled && (fileSource === DEFAULT_SOURCE || fileSource === "acode");
522
603
  const projectOffset = sameInode && !truncated ? Number(prev.projectOffset || 0) : 0;
@@ -575,7 +656,8 @@ async function parseRolloutIncremental({
575
656
  ? getAcodeEventDedup()
576
657
  : null;
577
658
  const codexEventTracker = rolloutEventDedup
578
- ? (needsHistoricalRolloutDedup({
659
+ ? (compressedSnapshotRescan
660
+ || needsHistoricalRolloutDedup({
579
661
  filePath,
580
662
  prev,
581
663
  sameInode,
@@ -629,6 +711,22 @@ async function parseRolloutIncremental({
629
711
  invalidRecordPolicy,
630
712
  });
631
713
 
714
+ if (result.snapshotUnavailable) {
715
+ // Leave cursors.files[key] untouched so the next readable snapshot is
716
+ // compared against the prior (inode, offset, baselines).
717
+ if (cb) {
718
+ cb({
719
+ index: idx + 1,
720
+ total: totalFiles,
721
+ filePath,
722
+ filesProcessed,
723
+ eventsAggregated,
724
+ bucketsQueued: touchedBuckets.size,
725
+ });
726
+ }
727
+ continue;
728
+ }
729
+
632
730
  const nextCursor = {
633
731
  inode,
634
732
  offset: result.endOffset,
@@ -2256,7 +2354,7 @@ function openclawUsageEventIdentity(obj, msg, usage, model) {
2256
2354
  function codexSessionIdFromPath(filePath) {
2257
2355
  if (typeof filePath !== "string") return null;
2258
2356
  const m = filePath.match(
2259
- /([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\.jsonl$/,
2357
+ /([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\.jsonl(?:\.zstd?)?$/,
2260
2358
  );
2261
2359
  return m ? m[1] : null;
2262
2360
  }
@@ -2291,6 +2389,108 @@ async function parseRolloutFile({
2291
2389
  const endOffset = st.size;
2292
2390
  const projectFileContexts = [];
2293
2391
  addProjectFileContext(projectFileContexts, projectContext);
2392
+ // Compressed archives are immutable snapshots, not append-only logs: a byte
2393
+ // range of the compressed file is not a prefix of the decompressed text, so
2394
+ // resume-in-the-middle is impossible. Parse the whole snapshot whenever its
2395
+ // (inode, size) identity is new; unchanged snapshots are skipped by the
2396
+ // caller before reaching this function.
2397
+ if (isCompressedRolloutPath(filePath)) {
2398
+ // Mirror the uncompressed exhausted-cursor guard. When a custom
2399
+ // publicRepoResolver disables the outer skip, an unchanged archive must
2400
+ // not be re-parsed with retained baselines (that inflates totals).
2401
+ if (startOffset >= endOffset) {
2402
+ return {
2403
+ endOffset,
2404
+ lastTotal,
2405
+ tokenUsageBaselines,
2406
+ lastModel,
2407
+ lastServiceTier: typeof lastServiceTier === "string" ? lastServiceTier : null,
2408
+ modelAttributionState: previousModelAttributionState,
2409
+ usageRecordState: previousUsageRecordState,
2410
+ eventsAggregated: 0,
2411
+ projectFileContexts,
2412
+ };
2413
+ }
2414
+ const snapshot = await readCodexRolloutSnapshot(filePath);
2415
+ if (!snapshot) {
2416
+ // Soft sync skips a bad archive and keeps the prior cursor so later
2417
+ // files still count. Strict rebuilds (invalidRecordPolicy=throw), used
2418
+ // by repairCodexRescanInflation, must fail closed: a silent skip here
2419
+ // would let another readable session satisfy the non-empty-bucket
2420
+ // safeguard and permanently delete this archive's prior usage.
2421
+ if (invalidRecordPolicy === "throw") {
2422
+ throw new Error(`unreadable Codex compressed archive: ${filePath}`);
2423
+ }
2424
+ return {
2425
+ endOffset: startOffset,
2426
+ lastTotal,
2427
+ tokenUsageBaselines,
2428
+ lastModel,
2429
+ lastServiceTier: typeof lastServiceTier === "string" ? lastServiceTier : null,
2430
+ modelAttributionState: previousModelAttributionState,
2431
+ usageRecordState: previousUsageRecordState,
2432
+ eventsAggregated: 0,
2433
+ projectFileContexts,
2434
+ // Tell the incremental caller not to rewrite cursors.files[key]. If the
2435
+ // archive inode changed under a transient decode miss, overwriting the
2436
+ // prior cursor would drop historical-dedup baselines and double-count.
2437
+ snapshotUnavailable: true,
2438
+ };
2439
+ }
2440
+ // Decompressed lines have no meaningful compressed-byte offsets; the
2441
+ // shared parser only needs their content plus termination flags. Byte
2442
+ // counters stay at the snapshot's compressed size so the cursor records
2443
+ // (inode, size) identity, not decompressed progress.
2444
+ const records = (async function* snapshotRecords() {
2445
+ for (let index = 0; index < snapshot.lines.length; index += 1) {
2446
+ const raw = snapshot.lines[index];
2447
+ const terminated = index + 1 < snapshot.lines.length || raw.endsWith("\n");
2448
+ yield {
2449
+ line: terminated ? raw : `${raw}`,
2450
+ physicalBytes: 0,
2451
+ terminated,
2452
+ utf8Valid: true,
2453
+ };
2454
+ }
2455
+ })();
2456
+ // Size changed under the same inode: drop retained baselines for THIS
2457
+ // parse only. Soft-fail above still returns the caller-supplied baselines
2458
+ // so the prior cursor is preserved when decode fails.
2459
+ const rescanBaselines = startOffset > 0 && startOffset < endOffset;
2460
+ const parsed = await parseRolloutRecords({
2461
+ records,
2462
+ startOffset: 0,
2463
+ endOffset: 0,
2464
+ lastTotal: rescanBaselines ? null : lastTotal,
2465
+ tokenUsageBaselines: rescanBaselines ? null : tokenUsageBaselines,
2466
+ lastModel: rescanBaselines ? null : lastModel,
2467
+ lastServiceTier: rescanBaselines ? null : lastServiceTier,
2468
+ modelAttributionState: rescanBaselines ? null : previousModelAttributionState,
2469
+ usageRecordState: rescanBaselines ? null : previousUsageRecordState,
2470
+ compactionResponseIds,
2471
+ hourlyState,
2472
+ touchedBuckets,
2473
+ source,
2474
+ projectState,
2475
+ projectTouchedBuckets,
2476
+ projectRef,
2477
+ projectKey,
2478
+ projectMetaCache,
2479
+ publicRepoCache,
2480
+ publicRepoResolver,
2481
+ projectContext,
2482
+ projectFileContexts,
2483
+ seenCodexEvents,
2484
+ sessionId,
2485
+ filePath,
2486
+ invalidRecordPolicy,
2487
+ });
2488
+ // parseRolloutRecords counts streamed bytes; snapshots have none, so pin
2489
+ // the cursor to the compressed size — the (inode, size) identity that
2490
+ // makes an unchanged archive skip on the next sync.
2491
+ parsed.endOffset = snapshot.size;
2492
+ return parsed;
2493
+ }
2294
2494
  if (startOffset >= endOffset) {
2295
2495
  return {
2296
2496
  endOffset,
@@ -2309,7 +2509,78 @@ async function parseRolloutFile({
2309
2509
  start: startOffset,
2310
2510
  end: endOffset - 1,
2311
2511
  });
2512
+ // Compressed snapshots never reach this branch (parseRolloutFile routes them
2513
+ // to parseRolloutRecords directly); the byte-counting stream below is only
2514
+ // meaningful for append-only uncompressed logs.
2515
+ const records = (async function* streamRecords() {
2516
+ const invalidUtf8 = invalidRecordPolicy === "throw" ? "throw" : "record";
2517
+ for await (const record of physicalJsonlRecords(stream, { invalidUtf8 })) {
2518
+ yield record;
2519
+ }
2520
+ })();
2521
+
2522
+ return parseRolloutRecords({
2523
+ records,
2524
+ startOffset,
2525
+ endOffset,
2526
+ lastTotal,
2527
+ tokenUsageBaselines,
2528
+ lastModel,
2529
+ lastServiceTier,
2530
+ modelAttributionState: previousModelAttributionState,
2531
+ usageRecordState: previousUsageRecordState,
2532
+ compactionResponseIds,
2533
+ hourlyState,
2534
+ touchedBuckets,
2535
+ source,
2536
+ projectState,
2537
+ projectTouchedBuckets,
2538
+ projectRef,
2539
+ projectKey,
2540
+ projectMetaCache,
2541
+ publicRepoCache,
2542
+ publicRepoResolver,
2543
+ projectContext,
2544
+ projectFileContexts,
2545
+ seenCodexEvents,
2546
+ sessionId,
2547
+ filePath,
2548
+ invalidRecordPolicy,
2549
+ });
2550
+ }
2312
2551
 
2552
+ async function parseRolloutRecords({
2553
+ records,
2554
+ startOffset,
2555
+ endOffset,
2556
+ lastTotal,
2557
+ tokenUsageBaselines,
2558
+ lastModel,
2559
+ lastServiceTier,
2560
+ modelAttributionState: previousModelAttributionState,
2561
+ usageRecordState: previousUsageRecordState = null,
2562
+ compactionResponseIds = null,
2563
+ hourlyState,
2564
+ touchedBuckets,
2565
+ source,
2566
+ projectState,
2567
+ projectTouchedBuckets,
2568
+ projectRef,
2569
+ projectKey,
2570
+ projectMetaCache,
2571
+ publicRepoCache,
2572
+ publicRepoResolver,
2573
+ projectContext,
2574
+ projectFileContexts: existingProjectFileContexts,
2575
+ seenCodexEvents,
2576
+ sessionId,
2577
+ filePath,
2578
+ invalidRecordPolicy,
2579
+ }) {
2580
+ const projectFileContexts = Array.isArray(existingProjectFileContexts)
2581
+ ? existingProjectFileContexts
2582
+ : [];
2583
+ addProjectFileContext(projectFileContexts, projectContext);
2313
2584
  let model = typeof lastModel === "string" ? lastModel : null;
2314
2585
  // Codex writes thread_settings_applied before the turn_context of the turn it
2315
2586
  // takes effect on, and token_count rows carry no turn id, so a row's tier is
@@ -2393,8 +2664,7 @@ async function parseRolloutFile({
2393
2664
  eventsAggregated += 1;
2394
2665
  }
2395
2666
 
2396
- const invalidUtf8 = invalidRecordPolicy === "throw" ? "throw" : "record";
2397
- for await (const record of physicalJsonlRecords(stream, { invalidUtf8 })) {
2667
+ for await (const record of records) {
2398
2668
  scannedEndOffset += record.physicalBytes;
2399
2669
  if (record.terminated) committedEndOffset = scannedEndOffset;
2400
2670
  if (!record.utf8Valid) {
@@ -2695,6 +2965,80 @@ async function scanRolloutProjectFileContexts({
2695
2965
  };
2696
2966
  }
2697
2967
 
2968
+ // Compressed archives cannot be scanned as byte streams: JSONL newlines in
2969
+ // the compressed payload are not record boundaries. Decode the snapshot,
2970
+ // then walk decompressed lines for cwd changes. Pin the cursor to the
2971
+ // compressed size so the next sync's unchanged check keeps working.
2972
+ if (isCompressedRolloutPath(filePath)) {
2973
+ const snapshot = await readCodexRolloutSnapshot(filePath);
2974
+ if (!snapshot) {
2975
+ if (invalidRecordPolicy === "throw") {
2976
+ throw new Error(`unreadable Codex compressed archive: ${filePath}`);
2977
+ }
2978
+ return {
2979
+ endOffset,
2980
+ lastTotal,
2981
+ tokenUsageBaselines,
2982
+ lastModel,
2983
+ lastServiceTier: lastServiceTier || null,
2984
+ modelAttributionState,
2985
+ eventsAggregated: 0,
2986
+ projectFileContexts,
2987
+ snapshotUnavailable: true,
2988
+ };
2989
+ }
2990
+ let currentCwd = null;
2991
+ for (const raw of snapshot.lines) {
2992
+ const line = typeof raw === "string" ? raw : "";
2993
+ if (
2994
+ !line ||
2995
+ !(
2996
+ line.includes('"turn_context"') ||
2997
+ line.includes('"session_meta"')
2998
+ ) ||
2999
+ !line.includes('"cwd"')
3000
+ ) {
3001
+ continue;
3002
+ }
3003
+ let obj;
3004
+ try {
3005
+ obj = JSON.parse(line);
3006
+ } catch (error) {
3007
+ if (invalidRecordPolicy === "throw") throw error;
3008
+ continue;
3009
+ }
3010
+ if (
3011
+ (obj?.type !== "turn_context" && obj?.type !== "session_meta") ||
3012
+ !obj?.payload ||
3013
+ typeof obj.payload !== "object" ||
3014
+ typeof obj.payload.cwd !== "string"
3015
+ ) {
3016
+ continue;
3017
+ }
3018
+ const nextCwd = obj.payload.cwd.trim();
3019
+ if (!nextCwd || nextCwd === currentCwd) continue;
3020
+ const context = await resolveProjectContextForPath({
3021
+ startDir: wsl.mapWslCwdToUnc(nextCwd, filePath),
3022
+ projectMetaCache,
3023
+ publicRepoCache,
3024
+ publicRepoResolver,
3025
+ projectState,
3026
+ });
3027
+ currentCwd = nextCwd;
3028
+ addProjectFileContext(projectFileContexts, context);
3029
+ }
3030
+ return {
3031
+ endOffset: snapshot.size,
3032
+ lastTotal,
3033
+ tokenUsageBaselines,
3034
+ lastModel,
3035
+ lastServiceTier: lastServiceTier || null,
3036
+ modelAttributionState,
3037
+ eventsAggregated: 0,
3038
+ projectFileContexts,
3039
+ };
3040
+ }
3041
+
2698
3042
  const stream = fssync.createReadStream(filePath, { start: 0, end: endOffset - 1 });
2699
3043
  let currentCwd = null;
2700
3044
  let scannedEndOffset = 0;
@@ -10802,14 +11146,102 @@ function workbuddySqliteUsageSnapshot(row) {
10802
11146
  };
10803
11147
  }
10804
11148
 
11149
+ // WorkBuddy writes OpenAI/DeepSeek-shaped rawUsage on every LLM round-trip.
11150
+ // The totals pass and the project pass below need the same breakdown, so the
11151
+ // arithmetic lives here once. Callers treat total_tokens === 0 as "no billable
11152
+ // usage": every component is non-negative, so that is equivalent to testing
11153
+ // each field for zero.
11154
+ function workbuddyUsageDelta(rawUsage) {
11155
+ const promptTokens = toNonNegativeInt(rawUsage.prompt_tokens);
11156
+ const completionTokens = toNonNegativeInt(rawUsage.completion_tokens);
11157
+ const promptDetails =
11158
+ rawUsage.prompt_tokens_details && typeof rawUsage.prompt_tokens_details === "object"
11159
+ ? rawUsage.prompt_tokens_details
11160
+ : {};
11161
+ const completionDetails =
11162
+ rawUsage.completion_tokens_details && typeof rawUsage.completion_tokens_details === "object"
11163
+ ? rawUsage.completion_tokens_details
11164
+ : {};
11165
+
11166
+ // Cache reads are mirrored across up to three fields depending on which
11167
+ // upstream the auto-router used; take the largest non-zero mirror.
11168
+ const cacheRead = Math.max(
11169
+ toNonNegativeInt(rawUsage.cache_read_input_tokens),
11170
+ toNonNegativeInt(promptDetails.cached_tokens),
11171
+ toNonNegativeInt(rawUsage.prompt_cache_hit_tokens),
11172
+ );
11173
+ const cacheCreation = toNonNegativeInt(rawUsage.cache_creation_input_tokens);
11174
+ // prompt_tokens is the FULL prompt: subtract BOTH reads and writes so
11175
+ // input_tokens is pure non-cached input (no double-counting cache writes).
11176
+ const inputTokens = Math.max(0, promptTokens - cacheRead - cacheCreation);
11177
+ // completion_tokens INCLUDES reasoning (verified: total == prompt+completion).
11178
+ const reasoningTokens = Math.min(
11179
+ completionTokens,
11180
+ toNonNegativeInt(completionDetails.reasoning_tokens),
11181
+ );
11182
+ const outputTokens = Math.max(0, completionTokens - reasoningTokens);
11183
+
11184
+ return {
11185
+ input_tokens: inputTokens,
11186
+ cached_input_tokens: cacheRead,
11187
+ cache_creation_input_tokens: cacheCreation,
11188
+ output_tokens: outputTokens,
11189
+ reasoning_output_tokens: reasoningTokens,
11190
+ total_tokens: inputTokens + outputTokens + cacheRead + cacheCreation + reasoningTokens,
11191
+ conversation_count: 1,
11192
+ };
11193
+ }
11194
+
11195
+ // WorkBuddy stamps `cwd` on its records rather than in a session header, and a
11196
+ // session can open with a record that lacks it while later appends omit it
11197
+ // altogether. Resolve it from a bounded prefix of the file so every record in
11198
+ // the incremental window is attributed, not just the ones that happen to carry
11199
+ // their own cwd. A file with no recoverable cwd stays outside project
11200
+ // attribution — there is nothing to attribute it to.
11201
+ async function resolveWorkbuddyFileCwd(filePath, maxScanBytes = 8 * 1024 * 1024) {
11202
+ let stream;
11203
+ try {
11204
+ const stat = fssync.statSync(filePath);
11205
+ if (stat.size <= 0) return null;
11206
+ stream = fssync.createReadStream(filePath, {
11207
+ start: 0,
11208
+ end: Math.min(stat.size, maxScanBytes) - 1,
11209
+ });
11210
+ } catch {
11211
+ return null;
11212
+ }
11213
+ let cwd = null;
11214
+ try {
11215
+ for await (const record of physicalJsonlRecords(stream, {
11216
+ invalidUtf8: "record",
11217
+ maxRecordBytes: OMP_LIKE_MAX_RECORD_BYTES,
11218
+ })) {
11219
+ if (!record.line || !record.line.trim()) continue;
11220
+ let entry;
11221
+ try { entry = JSON.parse(record.line); } catch { continue; }
11222
+ if (entry && typeof entry === "object" && typeof entry.cwd === "string" && entry.cwd) {
11223
+ cwd = entry.cwd;
11224
+ break;
11225
+ }
11226
+ }
11227
+ } catch {
11228
+ // No recoverable cwd — the file stays un-attributed.
11229
+ } finally {
11230
+ try { stream.destroy(); } catch {}
11231
+ }
11232
+ return cwd;
11233
+ }
11234
+
10805
11235
  async function parseWorkbuddyIncremental({
10806
11236
  projectFiles,
10807
11237
  cursors,
10808
11238
  queuePath,
11239
+ projectQueuePath,
10809
11240
  onProgress,
10810
11241
  env,
10811
11242
  defaultModel,
10812
11243
  source = "workbuddy",
11244
+ publicRepoResolver,
10813
11245
  } = {}) {
10814
11246
  await ensureDir(path.dirname(queuePath));
10815
11247
  // `source` doubles as the variant selector and the bucket source key, so the
@@ -10841,6 +11273,45 @@ async function parseWorkbuddyIncremental({
10841
11273
  );
10842
11274
  const detailedSessionsWithUsage = new Set();
10843
11275
 
11276
+ // The shared normalizers copy the bucket maps but alias each bucket/totals
11277
+ // object, and the enqueue helpers stamp queuedKey before appendFile runs.
11278
+ // Give the buckets this parser can mutate private copies so a failed append
11279
+ // leaves the caller's published state untouched — the same staging contract
11280
+ // the Devin reader uses. Callers retry from unchanged offsets, so a polluted
11281
+ // published state would add the same usage twice.
11282
+ const ownsWorkbuddyBucket = (key) =>
11283
+ (normalizeSourceInput(key.split(BUCKET_SEPARATOR)[0]) || DEFAULT_SOURCE) === variant;
11284
+ const ownsWorkbuddyProjectBucket = (key) => {
11285
+ // Project keys are `projectKey|source|hour`; a projectKey may itself
11286
+ // contain the separator, so read the source segment from the right.
11287
+ const last = key.lastIndexOf(BUCKET_SEPARATOR);
11288
+ const prev = last > 0 ? key.lastIndexOf(BUCKET_SEPARATOR, last - 1) : -1;
11289
+ const source = prev >= 0 ? key.slice(prev + 1, last) : "";
11290
+ return (normalizeSourceInput(source) || DEFAULT_SOURCE) === variant;
11291
+ };
11292
+
11293
+ // Project attribution runs as a second, independent pass over the same JSONL
11294
+ // files (see "Project pass" below). It keeps its own offsets and seen-set, so
11295
+ // an install that already has totals backfills project history on upgrade
11296
+ // without re-counting a single token in the totals queue.
11297
+ const projectEnabled = typeof projectQueuePath === "string" && projectQueuePath.length > 0;
11298
+ const projectState = projectEnabled ? normalizeProjectState(cursors?.projectHourly) : null;
11299
+ if (projectState) {
11300
+ projectState.buckets = stageProviderBuckets(projectState.buckets, ownsWorkbuddyProjectBucket);
11301
+ }
11302
+ const projectTouchedBuckets = projectEnabled ? new Set() : null;
11303
+ const projectMetaCache = projectEnabled ? new Map() : null;
11304
+ const publicRepoCache = projectEnabled ? new Map() : null;
11305
+ const projectSeenIds = new Set(
11306
+ Array.isArray(workbuddyState.projectSeenIds) ? workbuddyState.projectSeenIds : [],
11307
+ );
11308
+ const projectFileOffsets =
11309
+ workbuddyState.projectFileOffsets && typeof workbuddyState.projectFileOffsets === "object"
11310
+ ? { ...workbuddyState.projectFileOffsets }
11311
+ : {};
11312
+ let projectBucketsQueued = 0;
11313
+ let projectCursorUpdated = false;
11314
+
10844
11315
  const allFiles = Array.isArray(projectFiles)
10845
11316
  ? projectFiles
10846
11317
  : resolveWorkbuddyProjectFiles(env || process.env, variant);
@@ -10866,10 +11337,15 @@ async function parseWorkbuddyIncremental({
10866
11337
  detailedSessions,
10867
11338
  updatedAt: new Date().toISOString(),
10868
11339
  };
10869
- return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
11340
+ return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0, projectBucketsQueued: 0, projectCursorUpdated: false };
10870
11341
  }
10871
11342
 
10872
11343
  const hourlyState = normalizeHourlyState(cursors?.hourly);
11344
+ hourlyState.buckets = stageProviderBuckets(hourlyState.buckets, ownsWorkbuddyBucket);
11345
+ hourlyState.groupQueued =
11346
+ hourlyState.groupQueued && typeof hourlyState.groupQueued === "object"
11347
+ ? { ...hourlyState.groupQueued }
11348
+ : {};
10873
11349
  const touchedBuckets = new Set();
10874
11350
  const cb = typeof onProgress === "function" ? onProgress : null;
10875
11351
  let recordsProcessed = 0;
@@ -10941,39 +11417,8 @@ async function parseWorkbuddyIncremental({
10941
11417
 
10942
11418
  recordsProcessed++;
10943
11419
 
10944
- const promptTokens = toNonNegativeInt(rawUsage.prompt_tokens);
10945
- const completionTokens = toNonNegativeInt(rawUsage.completion_tokens);
10946
- const promptDetails =
10947
- rawUsage.prompt_tokens_details && typeof rawUsage.prompt_tokens_details === "object"
10948
- ? rawUsage.prompt_tokens_details
10949
- : {};
10950
- const completionDetails =
10951
- rawUsage.completion_tokens_details && typeof rawUsage.completion_tokens_details === "object"
10952
- ? rawUsage.completion_tokens_details
10953
- : {};
10954
-
10955
- // Cache reads are mirrored across up to three fields depending on which
10956
- // upstream the auto-router used; take the largest non-zero mirror.
10957
- const cacheRead = Math.max(
10958
- toNonNegativeInt(rawUsage.cache_read_input_tokens),
10959
- toNonNegativeInt(promptDetails.cached_tokens),
10960
- toNonNegativeInt(rawUsage.prompt_cache_hit_tokens),
10961
- );
10962
- const cacheCreation = toNonNegativeInt(rawUsage.cache_creation_input_tokens);
10963
- // prompt_tokens is the FULL prompt: subtract BOTH reads and writes so
10964
- // input_tokens is pure non-cached input (no double-counting cache writes).
10965
- const inputTokens = Math.max(0, promptTokens - cacheRead - cacheCreation);
10966
- // completion_tokens INCLUDES reasoning (verified: total == prompt+completion).
10967
- const reasoningTokens = Math.min(completionTokens, toNonNegativeInt(completionDetails.reasoning_tokens));
10968
- const outputTokens = Math.max(0, completionTokens - reasoningTokens);
10969
-
10970
- if (
10971
- inputTokens === 0 &&
10972
- outputTokens === 0 &&
10973
- cacheRead === 0 &&
10974
- cacheCreation === 0 &&
10975
- reasoningTokens === 0
10976
- ) {
11420
+ const delta = workbuddyUsageDelta(rawUsage);
11421
+ if (delta.total_tokens === 0) {
10977
11422
  seenIds.add(messageId);
10978
11423
  continue;
10979
11424
  }
@@ -10992,17 +11437,6 @@ async function parseWorkbuddyIncremental({
10992
11437
  normalizeModelInput(entry.model) ||
10993
11438
  fallbackModel;
10994
11439
 
10995
- const delta = {
10996
- input_tokens: inputTokens,
10997
- cached_input_tokens: cacheRead,
10998
- cache_creation_input_tokens: cacheCreation,
10999
- output_tokens: outputTokens,
11000
- reasoning_output_tokens: reasoningTokens,
11001
- total_tokens:
11002
- inputTokens + outputTokens + cacheRead + cacheCreation + reasoningTokens,
11003
- conversation_count: 1,
11004
- };
11005
-
11006
11440
  const bucket = getHourlyBucket(hourlyState, variant, model, bucketStart);
11007
11441
  addTotals(bucket.totals, delta);
11008
11442
  touchedBuckets.add(bucketKey(variant, model, bucketStart));
@@ -11036,6 +11470,165 @@ async function parseWorkbuddyIncremental({
11036
11470
  };
11037
11471
  }
11038
11472
 
11473
+ // ── Project pass ───────────────────────────────────────────────────────
11474
+ // WorkBuddy stamps `cwd` on its records, so unlike the trace summaries below
11475
+ // there is nothing to infer: attribute each round-trip to the git repository
11476
+ // its cwd belongs to. This is a second loop over the same files with its own
11477
+ // cursor, so an install that already has totals backfills project history on
11478
+ // upgrade without touching (or double-counting) them.
11479
+ if (projectEnabled) {
11480
+ for (let fileIdx = 0; fileIdx < jsonlFiles.length; fileIdx++) {
11481
+ const filePath = jsonlFiles[fileIdx];
11482
+ let stat;
11483
+ try { stat = fssync.statSync(filePath); } catch { continue; }
11484
+
11485
+ const prevEntry = projectFileOffsets[filePath] || {};
11486
+ const prevSize = Number(prevEntry.size) || 0;
11487
+ const prevIno = prevEntry.ino;
11488
+ const inodeChanged = typeof prevIno === "number" && prevIno !== stat.ino;
11489
+ const truncated = stat.size < prevSize;
11490
+ const startOffset = truncated || inodeChanged ? 0 : prevSize;
11491
+ if (stat.size <= startOffset) continue;
11492
+
11493
+ // cwd is fixed for a session file's lifetime. Reuse the resolved value
11494
+ // while it is the same file, otherwise read it from the file head — not
11495
+ // from the incremental window, which may hold only appends that omit it.
11496
+ // Truncation (same inode, smaller size) must drop the cached cwd: the
11497
+ // replacement transcript may belong to a different checkout.
11498
+ let cwd =
11499
+ !inodeChanged && !truncated && typeof prevEntry.cwd === "string"
11500
+ ? prevEntry.cwd
11501
+ : null;
11502
+
11503
+ // No recoverable cwd yet: do not advance past history. A later append
11504
+ // may stamp cwd, and early records must still be attributable then.
11505
+ // Check the unchanged-probe cache BEFORE the head scan so idle syncs do
11506
+ // not re-read up to 8 MiB of JSONL on every pass.
11507
+ if (
11508
+ !cwd &&
11509
+ Number(prevEntry.noCwdProbedSize) === stat.size &&
11510
+ prevEntry.ino === stat.ino &&
11511
+ Number(prevEntry.size) === startOffset
11512
+ ) {
11513
+ continue;
11514
+ }
11515
+ if (!cwd) cwd = await resolveWorkbuddyFileCwd(filePath);
11516
+ if (!cwd) {
11517
+ projectCursorUpdated = true;
11518
+ projectFileOffsets[filePath] = {
11519
+ size: startOffset,
11520
+ mtimeMs: stat.mtimeMs,
11521
+ ino: stat.ino,
11522
+ noCwdProbedSize: stat.size,
11523
+ };
11524
+ continue;
11525
+ }
11526
+
11527
+ let projectRef = null;
11528
+ let projectKey = null;
11529
+ const projectContext = await resolveProjectContextForPath({
11530
+ startDir: wsl.mapWslCwdToUnc(cwd, filePath),
11531
+ projectMetaCache,
11532
+ publicRepoCache,
11533
+ publicRepoResolver,
11534
+ projectState,
11535
+ });
11536
+ projectRef = projectContext?.projectRef || null;
11537
+ projectKey = projectContext?.projectKey || null;
11538
+
11539
+ // cwd resolved but the repo is not yet public_verified (or has no git
11540
+ // checkout): keep the offset unadvanced so a later sync can retry, but
11541
+ // do not JSON-parse the transcript on every pass — there is nothing to
11542
+ // attribute until both identifiers exist.
11543
+ if (!projectKey || !projectRef) {
11544
+ projectCursorUpdated = true;
11545
+ projectFileOffsets[filePath] = {
11546
+ size: startOffset,
11547
+ mtimeMs: stat.mtimeMs,
11548
+ ino: stat.ino,
11549
+ cwd,
11550
+ };
11551
+ continue;
11552
+ }
11553
+
11554
+ const committedEndOffset = await scanOmpLikeTranscript(
11555
+ filePath,
11556
+ startOffset,
11557
+ OMP_LIKE_MAX_RECORD_BYTES,
11558
+ (entry) => {
11559
+ // Mirror the totals pass: a JSON null / non-object line must not
11560
+ // abort the whole WorkBuddy parse (and strand both cursors).
11561
+ if (!entry || typeof entry !== "object") return;
11562
+
11563
+ const provider = entry.providerData;
11564
+ const rawUsage = provider && typeof provider === "object" ? provider.rawUsage : null;
11565
+ if (!rawUsage || typeof rawUsage !== "object") return;
11566
+
11567
+ // Mirror the totals pass's suppression rules so the project queue
11568
+ // never reports a session the totals queue deliberately skipped.
11569
+ const sessionId =
11570
+ typeof entry.sessionId === "string" && entry.sessionId
11571
+ ? entry.sessionId
11572
+ : path.basename(filePath, ".jsonl");
11573
+ const sqliteSession = sqliteSessions[sessionId];
11574
+ if (sqliteSession?.detailed || tracedSessionIds.has(sessionId)) return;
11575
+
11576
+ const tsMs =
11577
+ Number.isFinite(Number(entry.timestamp)) && Number(entry.timestamp) > 0
11578
+ ? Number(entry.timestamp)
11579
+ : null;
11580
+ if (tsMs == null) return;
11581
+
11582
+ // Same response-level id preference as the totals pass, so both
11583
+ // queues dedup on identical keys.
11584
+ const messageId =
11585
+ typeof provider?.messageId === "string" && provider.messageId
11586
+ ? provider.messageId
11587
+ : typeof entry.uuid === "string" && entry.uuid
11588
+ ? entry.uuid
11589
+ : typeof entry.id === "string" && entry.id
11590
+ ? entry.id
11591
+ : `${sessionId}:${tsMs}`;
11592
+ if (projectSeenIds.has(messageId)) return;
11593
+ projectSeenIds.add(messageId);
11594
+ // The totals pass also registers the raw entry id so an older or
11595
+ // rewritten copy of the same record cannot be counted twice; the
11596
+ // project pass has to keep the same alias.
11597
+ if (typeof entry.id === "string" && entry.id && entry.id !== messageId) {
11598
+ projectSeenIds.add(entry.id);
11599
+ }
11600
+
11601
+ const delta = workbuddyUsageDelta(rawUsage);
11602
+ if (delta.total_tokens === 0) return;
11603
+
11604
+ const bucketStart = toUtcHalfHourStart(new Date(tsMs).toISOString());
11605
+ if (!bucketStart) return;
11606
+
11607
+ const projectBucket = getProjectBucket(
11608
+ projectState,
11609
+ projectKey,
11610
+ source,
11611
+ bucketStart,
11612
+ projectRef,
11613
+ );
11614
+ addTotals(projectBucket.totals, delta);
11615
+ projectTouchedBuckets.add(projectBucketKey(projectKey, source, bucketStart));
11616
+ },
11617
+ );
11618
+
11619
+ // Commit only past records that were read in full: a record appended
11620
+ // while this pass was running, or an unfinished tail line, has to stay
11621
+ // visible to the next sync. Only advance after successful attribution.
11622
+ projectCursorUpdated = true;
11623
+ projectFileOffsets[filePath] = {
11624
+ size: committedEndOffset,
11625
+ mtimeMs: stat.mtimeMs,
11626
+ ino: stat.ino,
11627
+ cwd,
11628
+ };
11629
+ }
11630
+ }
11631
+
11039
11632
  // Trace summaries are a fallback for sessions whose JSONL has no detailed
11040
11633
  // providerData.rawUsage. A trace's modelInfo totals include the cache read
11041
11634
  // portion of input, so split input into uncached input + cached input. Do
@@ -11294,6 +11887,23 @@ async function parseWorkbuddyIncremental({
11294
11887
  ? tracedSessionArr.slice(tracedSessionArr.length - 10_000)
11295
11888
  : tracedSessionArr;
11296
11889
 
11890
+ const projectSeenArr = Array.from(projectSeenIds);
11891
+ const cappedProjectSeenIds =
11892
+ projectSeenArr.length > 10_000
11893
+ ? projectSeenArr.slice(projectSeenArr.length - 10_000)
11894
+ : projectSeenArr;
11895
+
11896
+ // Publish only after both appends succeed: a throw here leaves `cursors`
11897
+ // untouched, so a retry re-derives the same contribution from the same
11898
+ // offsets instead of adding it on top of a half-applied state.
11899
+ projectBucketsQueued = projectEnabled
11900
+ ? await enqueueTouchedProjectBuckets({
11901
+ projectQueuePath,
11902
+ projectState,
11903
+ projectTouchedBuckets,
11904
+ })
11905
+ : 0;
11906
+
11297
11907
  const bucketsQueued = await enqueueTouchedBuckets({
11298
11908
  queuePath,
11299
11909
  hourlyState,
@@ -11302,6 +11912,10 @@ async function parseWorkbuddyIncremental({
11302
11912
  const updatedAt = new Date().toISOString();
11303
11913
  hourlyState.updatedAt = updatedAt;
11304
11914
  cursors.hourly = hourlyState;
11915
+ if (projectState) {
11916
+ projectState.updatedAt = updatedAt;
11917
+ cursors.projectHourly = projectState;
11918
+ }
11305
11919
  cursors[variant] = {
11306
11920
  ...workbuddyState,
11307
11921
  seenIds: cappedSeen,
@@ -11310,10 +11924,22 @@ async function parseWorkbuddyIncremental({
11310
11924
  fileOffsets,
11311
11925
  sqliteSessions: cappedSqliteSessions,
11312
11926
  detailedSessions: cappedDetailedSessions,
11927
+ ...(projectEnabled
11928
+ ? {
11929
+ projectSeenIds: cappedProjectSeenIds,
11930
+ projectFileOffsets,
11931
+ }
11932
+ : {}),
11313
11933
  updatedAt,
11314
11934
  };
11315
11935
 
11316
- return { recordsProcessed, eventsAggregated, bucketsQueued };
11936
+ return {
11937
+ recordsProcessed,
11938
+ eventsAggregated,
11939
+ bucketsQueued,
11940
+ projectBucketsQueued,
11941
+ projectCursorUpdated: projectEnabled && projectCursorUpdated,
11942
+ };
11317
11943
  }
11318
11944
 
11319
11945
  // ─────────────────────────────────────────────────────────────────────────────
@@ -14151,14 +14777,14 @@ function devinStringMap(value) {
14151
14777
  // Stage only the bucket objects this parser can mutate. The shared
14152
14778
  // normalizers copy the bucket maps but alias each bucket/totals object, and
14153
14779
  // the enqueue helpers stamp queuedKey before appendFile runs — so a failed
14154
- // append used to leave the caller's published state polluted. Only
14155
- // devin-owned entries get private copies; every other provider's buckets
14780
+ // append used to leave the caller's published state polluted. Only entries
14781
+ // the calling parser owns get private copies; every other provider's buckets
14156
14782
  // stay shared read-only references.
14157
- function stageDevinBuckets(buckets, isDevinBucket) {
14783
+ function stageProviderBuckets(buckets, isOwnedBucket) {
14158
14784
  const staged = {};
14159
14785
  for (const [key, bucket] of Object.entries(buckets || {})) {
14160
14786
  staged[key] =
14161
- bucket && typeof bucket === "object" && isDevinBucket(key, bucket)
14787
+ bucket && typeof bucket === "object" && isOwnedBucket(key, bucket)
14162
14788
  ? {
14163
14789
  ...bucket,
14164
14790
  totals:
@@ -14247,7 +14873,7 @@ async function parseDevinIncremental({
14247
14873
  // rows recover either queue. Unrelated providers' buckets stay shared
14248
14874
  // read-only references (this parser never writes their keys).
14249
14875
  const hourlyState = normalizeHourlyState(cursors?.hourly);
14250
- hourlyState.buckets = stageDevinBuckets(
14876
+ hourlyState.buckets = stageProviderBuckets(
14251
14877
  hourlyState.buckets,
14252
14878
  (key) =>
14253
14879
  (normalizeSourceInput(parseBucketKey(key).source) || DEFAULT_SOURCE) ===
@@ -14266,7 +14892,7 @@ async function parseDevinIncremental({
14266
14892
  if (projectState) {
14267
14893
  // Project bucket keys are `projectKey|source|hourStart`; this parser only
14268
14894
  // ever looks up keys whose middle segment is the devin source.
14269
- projectState.buckets = stageDevinBuckets(projectState.buckets, (key) => {
14895
+ projectState.buckets = stageProviderBuckets(projectState.buckets, (key) => {
14270
14896
  const last = key.lastIndexOf(BUCKET_SEPARATOR);
14271
14897
  const prev = last > 0 ? key.lastIndexOf(BUCKET_SEPARATOR, last - 1) : -1;
14272
14898
  const source = prev >= 0 ? key.slice(prev + 1, last) : "";
@@ -25078,6 +25704,10 @@ module.exports = {
25078
25704
  listRolloutFiles,
25079
25705
  listRolloutFilesDeep,
25080
25706
  codexSessionIdFromPath,
25707
+ isRolloutFileName,
25708
+ isCompressedRolloutFileName,
25709
+ isCompressedRolloutPath,
25710
+ readCodexRolloutSnapshot,
25081
25711
  filterColdCodexRolloutFiles,
25082
25712
  listClaudeProjectFiles,
25083
25713
  listGeminiSessionFiles,