tokentracker-cli 0.87.8 → 0.87.10

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 (97) hide show
  1. package/dashboard/dist/assets/{AchievementBadge-CpxX02OK.js → AchievementBadge-BG9bVXuQ.js} +1 -1
  2. package/dashboard/dist/assets/{AchievementsPage-CSms8IjI.js → AchievementsPage-DwYIajgz.js} +2 -2
  3. package/dashboard/dist/assets/{AchievementsSection-Sm43tnkh.js → AchievementsSection-BPuhWCWC.js} +1 -1
  4. package/dashboard/dist/assets/{BadgeDetailModal-cSNqMLsK.js → BadgeDetailModal-BU81AHIH.js} +1 -1
  5. package/dashboard/dist/assets/{Card-wNwNRVSb.js → Card-CxiGV6bE.js} +1 -1
  6. package/dashboard/dist/assets/{ClawdAnimated-S8sIS2Ca.js → ClawdAnimated-NHcv2_KK.js} +1 -1
  7. package/dashboard/dist/assets/{CommandPalette-DVeiwbPM.js → CommandPalette-BKmTa5b8.js} +1 -1
  8. package/dashboard/dist/assets/{CommunityStatsModal-g5P4j3um.js → CommunityStatsModal-nhHtSw7U.js} +1 -1
  9. package/dashboard/dist/assets/{ConfirmModal-2P2D5iIp.js → ConfirmModal-DCicMVqg.js} +1 -1
  10. package/dashboard/dist/assets/{Controls-CCaotoQP.js → Controls-CtjjJ8gt.js} +1 -1
  11. package/dashboard/dist/assets/{DashboardPage-DxPGTJpe.js → DashboardPage-BL-NfLIM.js} +1 -1
  12. package/dashboard/dist/assets/DevicePage-DuIiuci7.js +1 -0
  13. package/dashboard/dist/assets/{DialogClose-D0giFwO3.js → DialogClose-8GK6rJCf.js} +1 -1
  14. package/dashboard/dist/assets/DialogDescription-C9StmaUp.js +1 -0
  15. package/dashboard/dist/assets/{DialogTitle-JS_04eUC.js → DialogTitle-EBphaJIt.js} +1 -1
  16. package/dashboard/dist/assets/{FadeIn-tFzTWDkC.js → FadeIn-B8099njp.js} +1 -1
  17. package/dashboard/dist/assets/{HeaderGithubStar-B2EUJUTH.js → HeaderGithubStar-Cyxc4vKa.js} +1 -1
  18. package/dashboard/dist/assets/{HoverTooltip-BJzHksPT.js → HoverTooltip-BcXNDc20.js} +1 -1
  19. package/dashboard/dist/assets/{IpCheckPage-B8mA0mut.js → IpCheckPage-Bn5aOiJK.js} +1 -1
  20. package/dashboard/dist/assets/{LandingPage-DmJZDKr9.js → LandingPage-BMbP2M_2.js} +2 -2
  21. package/dashboard/dist/assets/{LeaderboardAvatar-CDeRQUCg.js → LeaderboardAvatar-sRNGsVJE.js} +1 -1
  22. package/dashboard/dist/assets/{LeaderboardPage-Dkgm89kY.js → LeaderboardPage-DUv9hCNQ.js} +2 -2
  23. package/dashboard/dist/assets/{LeaderboardProfileModal-B6_kuR1z.js → LeaderboardProfileModal-D66DzEVL.js} +3 -3
  24. package/dashboard/dist/assets/{LeaderboardProfilePage-BRs8sJBV.js → LeaderboardProfilePage-CWzNEHjJ.js} +1 -1
  25. package/dashboard/dist/assets/{LimitsPage-DZ1s5NWV.js → LimitsPage-B7dTsxXt.js} +1 -1
  26. package/dashboard/dist/assets/{LocalOnlyNotice-Cu_hSnux.js → LocalOnlyNotice-BCR-4Ipk.js} +1 -1
  27. package/dashboard/dist/assets/{LoginCard-yDZbGsHR.js → LoginCard-Dt7v1Pgi.js} +1 -1
  28. package/dashboard/dist/assets/{LoginModal-DEvzCUfQ.js → LoginModal-BaKS0AvW.js} +1 -1
  29. package/dashboard/dist/assets/{LoginPage-DBySF9do.js → LoginPage-J7ir_N3K.js} +1 -1
  30. package/dashboard/dist/assets/{PetPage-DnzH9t-r.js → PetPage-uAxjE9Vs.js} +1 -1
  31. package/dashboard/dist/assets/{PopoverPopup-DgFsEMax.js → PopoverPopup-WRGNwhEe.js} +1 -1
  32. package/dashboard/dist/assets/{ProviderIcon-BEixe-xP.js → ProviderIcon-BTZXNlML.js} +1 -1
  33. package/dashboard/dist/assets/{ResetPasswordPage-excKqsCL.js → ResetPasswordPage-B_TvqHvF.js} +1 -1
  34. package/dashboard/dist/assets/{SegmentedControl-DDFkH64o.js → SegmentedControl-C1NPnzuC.js} +1 -1
  35. package/dashboard/dist/assets/{Select-ChEPIuJf.js → Select-DetlANRp.js} +1 -1
  36. package/dashboard/dist/assets/{SelectItemText--b7ISnJm.js → SelectItemText-CdhCuD7u.js} +1 -1
  37. package/dashboard/dist/assets/{ServiceStatusPage-DtAY-Qnn.js → ServiceStatusPage-yMTV03js.js} +1 -1
  38. package/dashboard/dist/assets/{SessionsPage-CKFglvuj.js → SessionsPage-rJw-lY6d.js} +1 -1
  39. package/dashboard/dist/assets/{SettingsPage-s0605qWg.js → SettingsPage-BHIGCXa8.js} +1 -1
  40. package/dashboard/dist/assets/{SkillsPage-DTsbCucq.js → SkillsPage-BZTTHrKR.js} +1 -1
  41. package/dashboard/dist/assets/{TokenGalaxy-C_M7q8S4.js → TokenGalaxy-D2tFBs42.js} +1 -1
  42. package/dashboard/dist/assets/{ToolbarRootContext-CXQwpea-.js → ToolbarRootContext-CD4Dh9IV.js} +1 -1
  43. package/dashboard/dist/assets/{TrendMonitor-1Yhlplf0.js → TrendMonitor-mL7Z3Pu9.js} +1 -1
  44. package/dashboard/dist/assets/{WidgetsPage-DC6pmtld.js → WidgetsPage-Dech66hD.js} +1 -1
  45. package/dashboard/dist/assets/{WrappedPage-DixoqR13.js → WrappedPage-CYaLijy2.js} +1 -1
  46. package/dashboard/dist/assets/{__vite-browser-external-5aLUeBkM.js → __vite-browser-external-CcMKDoRA.js} +2 -2
  47. package/dashboard/dist/assets/{agent-logos-DILTbPLg.js → agent-logos-BzFhwsm5.js} +1 -1
  48. package/dashboard/dist/assets/{arrow-up-right-DgIDgjVW.js → arrow-up-right-TrcWBi5O.js} +1 -1
  49. package/dashboard/dist/assets/{check-Bz6l09VH.js → check-CKWLtHpO.js} +1 -1
  50. package/dashboard/dist/assets/{chevron-right-EvGQXbI3.js → chevron-right-BjL_B3tf.js} +1 -1
  51. package/dashboard/dist/assets/{copy-DbsB0OVy.js → copy-B5XqmEDV.js} +1 -1
  52. package/dashboard/dist/assets/{download-R8nUvXYh.js → download-BtVO_3_3.js} +1 -1
  53. package/dashboard/dist/assets/{external-link-x0oNzGeq.js → external-link-CTpQdeXU.js} +1 -1
  54. package/dashboard/dist/assets/{flame-Bo4IdG6k.js → flame-CMixyq1C.js} +1 -1
  55. package/dashboard/dist/assets/{format-tokens-BSGC1-Gz.js → format-tokens-DOgiRUv2.js} +1 -1
  56. package/dashboard/dist/assets/{icons-D0Okncmo.js → icons-DNpysD5S.js} +1 -1
  57. package/dashboard/dist/assets/{index-D6F8eNr_.js → index-B7Zuq72U.js} +1 -1
  58. package/dashboard/dist/assets/{index-Bvvmei-I.js → index-Dadn8f0V.js} +1 -1
  59. package/dashboard/dist/assets/{index-BbOkC6wf.js → index-k5haeuyB.js} +1 -1
  60. package/dashboard/dist/assets/{info-DXXKNpLG.js → info-CodBJXwL.js} +1 -1
  61. package/dashboard/dist/assets/{limits-providers-DYa4fdsE.js → limits-providers-DiSK8fij.js} +1 -1
  62. package/dashboard/dist/assets/{link-2-C9mxEnT6.js → link-2-BE84-D1K.js} +1 -1
  63. package/dashboard/dist/assets/{loader-circle-Y8V8Mq9L.js → loader-circle-62hGDJej.js} +1 -1
  64. package/dashboard/dist/assets/{maximize-2-QuP69Xcg.js → maximize-2-Dn0j7N4g.js} +1 -1
  65. package/dashboard/dist/assets/{modifiers.esm-B3SleRKs.js → modifiers.esm-D48EfMXn.js} +1 -1
  66. package/dashboard/dist/assets/{monitor-smartphone-B2LslYi7.js → monitor-smartphone-B4O-1qqp.js} +1 -1
  67. package/dashboard/dist/assets/{provider-display-DzBwrDZp.js → provider-display-BBlUUXou.js} +1 -1
  68. package/dashboard/dist/assets/{react-BooKG84W.js → react-B7hdp-KU.js} +1 -1
  69. package/dashboard/dist/assets/{search-zdYuHmL-.js → search-DYcfh3D9.js} +1 -1
  70. package/dashboard/dist/assets/{skills-api-C2IkKfwE.js → skills-api-BL0Jj4TS.js} +1 -1
  71. package/dashboard/dist/assets/{sortable.esm-BHvl5vG5.js → sortable.esm-UJyxEfLh.js} +1 -1
  72. package/dashboard/dist/assets/{store-BBP4jx0R.js → store-koEJUHLx.js} +1 -1
  73. package/dashboard/dist/assets/{terminal-C0nhFNQ0.js → terminal-CA8uz-Rp.js} +1 -1
  74. package/dashboard/dist/assets/{trash-2-DNVSqbxA.js → trash-2-Cwdcw5RS.js} +1 -1
  75. package/dashboard/dist/assets/{use-community-stats-BK070d1s.js → use-community-stats-BIBrzbia.js} +1 -1
  76. package/dashboard/dist/assets/{use-limits-display-prefs-BXOUxblO.js → use-limits-display-prefs-BCdQk1ck.js} +1 -1
  77. package/dashboard/dist/assets/{use-ordered-list-0SVHZHaW.js → use-ordered-list-Bf9Ozum9.js} +1 -1
  78. package/dashboard/dist/assets/use-reduced-motion-DGTFk58t.js +1 -0
  79. package/dashboard/dist/assets/{use-session-efficiency-pref-eaUpwuLK.js → use-session-efficiency-pref-C83fbwfz.js} +1 -1
  80. package/dashboard/dist/assets/{use-transform-Bhar2U4w.js → use-transform-DnR7Iz0p.js} +1 -1
  81. package/dashboard/dist/assets/{use-usage-limits-BGGCMVzi.js → use-usage-limits-D_MtlDKv.js} +1 -1
  82. package/dashboard/dist/assets/{useCurrency-gIJVmq3w.js → useCurrency-DVO1i6vp.js} +1 -1
  83. package/dashboard/dist/assets/{useScrollLock-BhFzeRtR.js → useScrollLock-CW2lrPuY.js} +1 -1
  84. package/dashboard/dist/assets/{useTokenFormat-BZ8WT-A6.js → useTokenFormat-BFhS3XRy.js} +1 -1
  85. package/dashboard/dist/assets/{zap-Dvc_Jt1t.js → zap-DCY5z8oI.js} +1 -1
  86. package/dashboard/dist/index.html +1 -1
  87. package/dashboard/dist/ip-check.html +1 -1
  88. package/dashboard/dist/leaderboard.html +1 -1
  89. package/dashboard/dist/share.html +1 -1
  90. package/package.json +1 -1
  91. package/src/commands/sync.js +430 -1
  92. package/src/lib/install-resolver.js +22 -6
  93. package/src/lib/pricing/seed-snapshot.json +1 -1
  94. package/src/lib/rollout.js +488 -60
  95. package/dashboard/dist/assets/DevicePage-_nLwNash.js +0 -1
  96. package/dashboard/dist/assets/DialogDescription-8veCvaUb.js +0 -1
  97. package/dashboard/dist/assets/use-reduced-motion-abZbtoVS.js +0 -1
@@ -7840,9 +7840,98 @@ function resolveCodebuddyDefaultModel(env = process.env) {
7840
7840
  return fallback;
7841
7841
  }
7842
7842
 
7843
+ // Modern CodeBuddy writes token usage into providerData.rawUsage on JSONL
7844
+ // records. Older IDE installs may have no usable JSONL usage and rely on the
7845
+ // extension log fallback below. Keep the sniff bounded: it is used by the
7846
+ // guarded historical migration to decide whether a JSONL rebuild is safe.
7847
+ function codebuddyJsonlHasUsage(filePath) {
7848
+ let fd;
7849
+ try {
7850
+ fd = fssync.openSync(filePath, "r");
7851
+ const buffer = Buffer.alloc(64 * 1024);
7852
+ const bytesRead = fssync.readSync(fd, buffer, 0, buffer.length, 0);
7853
+ const lines = buffer.toString("utf8", 0, bytesRead).split("\n");
7854
+ const limit = Math.min(lines.length, 100);
7855
+ for (let i = 0; i < limit; i += 1) {
7856
+ const line = lines[i];
7857
+ if (!line || (!line.includes("rawUsage") && !line.includes("\"usage\""))) continue;
7858
+ let entry;
7859
+ try { entry = JSON.parse(line); } catch { continue; }
7860
+ if (!entry || typeof entry !== "object") continue;
7861
+ const provider = entry.providerData;
7862
+ if (provider && typeof provider === "object" && provider.rawUsage && typeof provider.rawUsage === "object") {
7863
+ return true;
7864
+ }
7865
+ if (entry.usage && typeof entry.usage === "object") return true;
7866
+ }
7867
+ } catch {
7868
+ return false;
7869
+ } finally {
7870
+ if (fd !== undefined) {
7871
+ try { fssync.closeSync(fd); } catch {}
7872
+ }
7873
+ }
7874
+ return false;
7875
+ }
7876
+
7877
+ // Legacy IDE extension logs and modern JSONL records do not share an id. Keep
7878
+ // a bounded, source-neutral fingerprint ledger so a log line can be matched to
7879
+ // the JSONL round-trip it mirrors without collapsing legitimate same-second
7880
+ // requests. The ledger is persisted in the CodeBuddy cursor because the two
7881
+ // sources can arrive on different syncs.
7882
+ function codebuddyUsageFingerprint({ model, timestampMs, inputTokens, cacheRead, cacheCreation, outputTokens, reasoningTokens }) {
7883
+ if (!Number.isFinite(Number(timestampMs)) || Number(timestampMs) <= 0) return null;
7884
+ const second = Math.floor(Number(timestampMs) / 1000);
7885
+ return [
7886
+ second,
7887
+ normalizeModelInput(model) || "unknown",
7888
+ toNonNegativeInt(inputTokens),
7889
+ toNonNegativeInt(cacheRead),
7890
+ toNonNegativeInt(cacheCreation),
7891
+ toNonNegativeInt(outputTokens),
7892
+ toNonNegativeInt(reasoningTokens),
7893
+ ].join(":");
7894
+ }
7895
+
7896
+ function restoreCodebuddyUsageFingerprints(raw) {
7897
+ const state = new Map();
7898
+ if (!raw || typeof raw !== "object") return state;
7899
+ for (const [key, value] of Object.entries(raw)) {
7900
+ const jsonl = toNonNegativeInt(value?.jsonl);
7901
+ const log = toNonNegativeInt(value?.log);
7902
+ if (jsonl > 0 || log > 0) state.set(key, { jsonl, log });
7903
+ }
7904
+ return state;
7905
+ }
7906
+
7907
+ function addCodebuddyJsonlFingerprint(state, fingerprint) {
7908
+ if (!fingerprint) return false;
7909
+ const current = state.get(fingerprint) || { jsonl: 0, log: 0 };
7910
+ const mirrored = current.log > current.jsonl;
7911
+ current.jsonl += 1;
7912
+ state.set(fingerprint, current);
7913
+ return mirrored;
7914
+ }
7915
+
7916
+ function consumeCodebuddyLogFingerprint(state, fingerprint) {
7917
+ if (!fingerprint) return false;
7918
+ const current = state.get(fingerprint) || { jsonl: 0, log: 0 };
7919
+ const mirrored = current.jsonl > current.log;
7920
+ current.log += 1;
7921
+ state.set(fingerprint, current);
7922
+ return mirrored;
7923
+ }
7924
+
7925
+ function capCodebuddyUsageFingerprints(state, maxEntries = 10_000) {
7926
+ const entries = Array.from(state.entries());
7927
+ if (entries.length <= maxEntries) return Object.fromEntries(entries);
7928
+ return Object.fromEntries(entries.slice(entries.length - maxEntries));
7929
+ }
7930
+
7843
7931
  function resolveCodebuddyProjectFiles(env = process.env) {
7844
7932
  const codebuddyHome = resolveCodebuddyHome(env);
7845
- const files = [];
7933
+ const jsonlFiles = [];
7934
+ const logFiles = [];
7846
7935
 
7847
7936
  // 1. Recursive JSONL scan in codebuddyHome/projects
7848
7937
  if (codebuddyHome) {
@@ -7863,7 +7952,7 @@ function resolveCodebuddyProjectFiles(env = process.env) {
7863
7952
  } catch { continue; }
7864
7953
  }
7865
7954
  if (isDir) walkJsonl(full);
7866
- else if (isFile && entry.name.endsWith(".jsonl")) files.push(full);
7955
+ else if (isFile && entry.name.endsWith(".jsonl")) jsonlFiles.push(full);
7867
7956
  }
7868
7957
  };
7869
7958
  walkJsonl(projectsDir);
@@ -7916,10 +8005,10 @@ function resolveCodebuddyProjectFiles(env = process.env) {
7916
8005
  walkLogs(full);
7917
8006
  } else if (isFile && entry.name.endsWith(".log")) {
7918
8007
  if (root.pattern === "*.log") {
7919
- files.push(full);
8008
+ logFiles.push(full);
7920
8009
  } else if (root.pattern === "codebuddy-extension-log") {
7921
8010
  if (full.toLowerCase().includes("tencent-cloud.coding-copilot")) {
7922
- files.push(full);
8011
+ logFiles.push(full);
7923
8012
  }
7924
8013
  }
7925
8014
  }
@@ -7928,6 +8017,14 @@ function resolveCodebuddyProjectFiles(env = process.env) {
7928
8017
  walkLogs(root.dir);
7929
8018
  }
7930
8019
 
8020
+ const fallbackMode = String(env.TOKENTRACKER_CODEBUDDY_LOG_FALLBACK || "").trim();
8021
+ // Auto mode keeps both sources available. The parser performs bounded,
8022
+ // count-aware cross-source fingerprint matching, so mixed histories retain
8023
+ // legacy-only log sessions while mirrored rounds are counted once. Env=0 is
8024
+ // still a supported escape hatch for users who want to suppress extension
8025
+ // logs entirely; env=1 remains an explicit force-on value.
8026
+ const includeLogs = fallbackMode !== "0";
8027
+ const files = [...jsonlFiles, ...(includeLogs ? logFiles : [])];
7931
8028
  files.sort((a, b) => a.localeCompare(b));
7932
8029
  return files;
7933
8030
  }
@@ -7989,10 +8086,21 @@ async function parseCodebuddyIncremental({
7989
8086
  codebuddyState.logModelsByAgent && typeof codebuddyState.logModelsByAgent === "object"
7990
8087
  ? { ...codebuddyState.logModelsByAgent }
7991
8088
  : {};
8089
+ const usageFingerprints = restoreCodebuddyUsageFingerprints(codebuddyState.usageFingerprints);
7992
8090
 
7993
- const files = Array.isArray(projectFiles)
8091
+ const discoveredFiles = Array.isArray(projectFiles)
7994
8092
  ? projectFiles
7995
8093
  : resolveCodebuddyProjectFiles(env || process.env);
8094
+ // JSONL must be parsed before extension logs so the fingerprint ledger can
8095
+ // match mirrored rounds in the same sync. Keep the legacy bare-string API
8096
+ // accepted for callers and tests.
8097
+ const files = [...discoveredFiles].sort((a, b) => {
8098
+ const aPath = typeof a === "string" ? a : a?.path || "";
8099
+ const bPath = typeof b === "string" ? b : b?.path || "";
8100
+ const aLog = aPath.endsWith(".log") ? 1 : 0;
8101
+ const bLog = bPath.endsWith(".log") ? 1 : 0;
8102
+ return aLog - bLog || aPath.localeCompare(bPath);
8103
+ });
7996
8104
  const fallbackModel = defaultModel || resolveCodebuddyDefaultModel(env || process.env);
7997
8105
 
7998
8106
  if (files.length === 0) {
@@ -8000,6 +8108,7 @@ async function parseCodebuddyIncremental({
8000
8108
  ...codebuddyState,
8001
8109
  seenIds: Array.from(seenIds),
8002
8110
  fileOffsets,
8111
+ usageFingerprints: capCodebuddyUsageFingerprints(usageFingerprints),
8003
8112
  updatedAt: new Date().toISOString(),
8004
8113
  };
8005
8114
  return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
@@ -8151,6 +8260,20 @@ async function parseCodebuddyIncremental({
8151
8260
  fallbackModel;
8152
8261
  const model = normalizeModelInput(rawModel);
8153
8262
 
8263
+ const fingerprint = codebuddyUsageFingerprint({
8264
+ model,
8265
+ timestampMs: tsMs,
8266
+ inputTokens,
8267
+ cacheRead,
8268
+ cacheCreation,
8269
+ outputTokens: completionTokens,
8270
+ reasoningTokens,
8271
+ });
8272
+ if (consumeCodebuddyLogFingerprint(usageFingerprints, fingerprint)) {
8273
+ seenIds.add(messageId);
8274
+ continue;
8275
+ }
8276
+
8154
8277
  const delta = {
8155
8278
  input_tokens: inputTokens,
8156
8279
  cached_input_tokens: cacheRead,
@@ -8179,9 +8302,15 @@ async function parseCodebuddyIncremental({
8179
8302
  } else {
8180
8303
  let entry;
8181
8304
  try { entry = JSON.parse(line); } catch { continue; }
8182
-
8183
- if (!entry || entry.type !== "message" || entry.role !== "assistant") continue;
8184
-
8305
+ if (!entry || typeof entry !== "object") continue;
8306
+
8307
+ // Usage is carried on ANY record with providerData.rawUsage — assistant
8308
+ // messages AND function_call records. Each LLM round-trip (whether it
8309
+ // ends in a text reply or a tool call) carries its own usage; on real
8310
+ // installs function_call records are ~93% of round-trips and ~14x the
8311
+ // assistant-message token volume, so filtering by record type drops
8312
+ // the majority of usage. Aggregate them all; dedup per round-trip.
8313
+ // (Same convention as the WorkBuddy reader — same transcript format.)
8185
8314
  const provider = entry.providerData;
8186
8315
  const rawUsage = provider && typeof provider === "object" ? provider.rawUsage : null;
8187
8316
  if (!rawUsage || typeof rawUsage !== "object") continue;
@@ -8194,38 +8323,65 @@ async function parseCodebuddyIncremental({
8194
8323
  Number.isFinite(Number(entry.timestamp)) && Number(entry.timestamp) > 0
8195
8324
  ? Number(entry.timestamp)
8196
8325
  : null;
8326
+ // One usage record per LLM round-trip; providerData.messageId is the
8327
+ // response-level id shared by the function_call/message pair of the
8328
+ // same round-trip, so it is the most stable dedup key. entry.id is the
8329
+ // per-record append id (unique per line, NOT per round-trip) and must
8330
+ // NOT be preferred — preferring it would count a round-trip once per
8331
+ // record type instead of once total.
8197
8332
  const messageId =
8198
- typeof entry.uuid === "string" && entry.uuid
8199
- ? entry.uuid
8200
- : typeof entry.id === "string" && entry.id
8201
- ? entry.id
8202
- : tsMs != null
8203
- ? `${sessionId}:${tsMs}`
8204
- : null;
8333
+ typeof provider?.messageId === "string" && provider?.messageId
8334
+ ? provider.messageId
8335
+ : typeof entry.uuid === "string" && entry.uuid
8336
+ ? entry.uuid
8337
+ : typeof entry.id === "string" && entry.id
8338
+ ? entry.id
8339
+ : tsMs != null
8340
+ ? `${sessionId}:${tsMs}`
8341
+ : null;
8205
8342
  if (!messageId) continue;
8206
8343
  if (seenIds.has(messageId)) continue;
8207
8344
 
8208
8345
  recordsProcessed++;
8209
8346
 
8210
8347
  const promptTokens = toNonNegativeInt(rawUsage.prompt_tokens);
8211
- const completionTokens = toNonNegativeInt(rawUsage.completion_tokens);
8348
+ const completionTokensRaw = toNonNegativeInt(rawUsage.completion_tokens);
8212
8349
  const details =
8213
8350
  rawUsage.prompt_tokens_details && typeof rawUsage.prompt_tokens_details === "object"
8214
8351
  ? rawUsage.prompt_tokens_details
8215
8352
  : {};
8216
- const cachedTokens = toNonNegativeInt(details.cached_tokens);
8353
+ const completionDetails =
8354
+ rawUsage.completion_tokens_details && typeof rawUsage.completion_tokens_details === "object"
8355
+ ? rawUsage.completion_tokens_details
8356
+ : {};
8357
+ // Cache-read mirrors three ways depending on upstream: Anthropic-style
8358
+ // cache_read_input_tokens, OpenAI-style prompt_tokens_details.cached_tokens,
8359
+ // DeepSeek-style prompt_cache_hit_tokens. Take the max (they mirror the
8360
+ // same quantity; on real data exactly one is non-zero).
8361
+ const cachedTokens = Math.max(
8362
+ toNonNegativeInt(details.cached_tokens),
8363
+ toNonNegativeInt(rawUsage.prompt_cache_hit_tokens),
8364
+ );
8217
8365
  const cacheReadAlt = toNonNegativeInt(rawUsage.cache_read_input_tokens);
8218
- const cacheCreation = toNonNegativeInt(rawUsage.cache_creation_input_tokens);
8219
- const reasoningTokens = toNonNegativeInt(details.reasoning_tokens);
8366
+ const cacheCreation = Math.max(
8367
+ toNonNegativeInt(rawUsage.cache_creation_input_tokens),
8368
+ toNonNegativeInt(rawUsage.prompt_cache_write_tokens),
8369
+ );
8370
+ // reasoning_tokens lives in completion_tokens_details (verified on real
8371
+ // CodeBuddy data: prompt_tokens_details.reasoning_tokens is always 0).
8372
+ // completion_tokens INCLUDES reasoning, so subtract to avoid double-count.
8373
+ const reasoningTokens = Math.min(completionTokensRaw, toNonNegativeInt(completionDetails.reasoning_tokens));
8374
+ const completionTokens = Math.max(0, completionTokensRaw - reasoningTokens);
8220
8375
 
8221
8376
  const cacheRead = Math.max(cachedTokens, cacheReadAlt);
8222
- const inputTokens = Math.max(0, promptTokens - cacheRead);
8377
+ const inputTokens = Math.max(0, promptTokens - cacheRead - cacheCreation);
8223
8378
 
8224
8379
  if (
8225
8380
  inputTokens === 0 &&
8226
8381
  completionTokens === 0 &&
8227
8382
  cacheRead === 0 &&
8228
- cacheCreation === 0
8383
+ cacheCreation === 0 &&
8384
+ reasoningTokens === 0
8229
8385
  ) {
8230
8386
  seenIds.add(messageId);
8231
8387
  continue;
@@ -8244,6 +8400,20 @@ async function parseCodebuddyIncremental({
8244
8400
  normalizeModelInput(entry.model) ||
8245
8401
  fallbackModel;
8246
8402
 
8403
+ const fingerprint = codebuddyUsageFingerprint({
8404
+ model,
8405
+ timestampMs: tsMs,
8406
+ inputTokens,
8407
+ cacheRead,
8408
+ cacheCreation,
8409
+ outputTokens: completionTokens,
8410
+ reasoningTokens,
8411
+ });
8412
+ if (addCodebuddyJsonlFingerprint(usageFingerprints, fingerprint)) {
8413
+ seenIds.add(messageId);
8414
+ continue;
8415
+ }
8416
+
8247
8417
  const delta = {
8248
8418
  input_tokens: inputTokens,
8249
8419
  cached_input_tokens: cacheRead,
@@ -8259,6 +8429,13 @@ async function parseCodebuddyIncremental({
8259
8429
  addTotals(bucket.totals, delta);
8260
8430
  touchedBuckets.add(bucketKey("codebuddy", model, bucketStart));
8261
8431
  seenIds.add(messageId);
8432
+ // Preserve the pre-fingerprint cursor key as well. Older releases
8433
+ // keyed JSONL rows by entry.id; retaining it prevents the first
8434
+ // post-upgrade sync from replaying an already-counted row when
8435
+ // providerData.messageId is now preferred.
8436
+ if (typeof entry.id === "string" && entry.id && entry.id !== messageId) {
8437
+ seenIds.add(entry.id);
8438
+ }
8262
8439
  eventsAggregated++;
8263
8440
 
8264
8441
  if (cb) {
@@ -8306,6 +8483,7 @@ async function parseCodebuddyIncremental({
8306
8483
  seenIds: cappedSeen,
8307
8484
  fileOffsets,
8308
8485
  logModelsByAgent: cappedLogModelsByAgent,
8486
+ usageFingerprints: capCodebuddyUsageFingerprints(usageFingerprints),
8309
8487
  updatedAt,
8310
8488
  };
8311
8489
 
@@ -8356,6 +8534,14 @@ async function parseCodebuddyIncremental({
8356
8534
  //
8357
8535
  // model is the auto-router placeholder ("auto") — WorkBuddy does not expose
8358
8536
  // the underlying model in the log, so we emit it verbatim.
8537
+ //
8538
+ // 4. Completed ~/.workbuddy/traces/**/trace_*.json summaries expose aggregate
8539
+ // modelInfo input/output/cache totals. They are a fallback only when the
8540
+ // session has no detailed JSONL usage. The current SQLite session_usage
8541
+ // table exposes bounded context-window state (`used`/`size`) plus credits,
8542
+ // not billable token columns, so SQLite-only rows are ignored rather than
8543
+ // mislabelled as input usage. A future schema with explicit token columns
8544
+ // is accepted by workbuddySqliteUsageSnapshot.
8359
8545
  // ─────────────────────────────────────────────────────────────────────────────
8360
8546
 
8361
8547
  function resolveWorkbuddyHome(env = process.env) {
@@ -8390,14 +8576,16 @@ function resolveWorkbuddyDefaultModel(env = process.env) {
8390
8576
 
8391
8577
  // Recursively collect every *.jsonl under ~/.workbuddy/projects so that
8392
8578
  // per-session conversation logs AND their nested subagents/agent-*.jsonl files
8393
- // are both discovered. Non-.jsonl artefacts (tool-results/*.txt) are ignored.
8579
+ // are both discovered. WorkBuddy also writes completed trace summaries under
8580
+ // ~/.workbuddy/traces/<pid>/trace_*.json. Those summaries are useful only when
8581
+ // the detailed JSONL for a session is absent, so they are returned as typed
8582
+ // entries and parsed after JSONL (JSONL remains authoritative when both exist).
8394
8583
  function resolveWorkbuddyProjectFiles(env = process.env) {
8395
8584
  const workbuddyHome = resolveWorkbuddyHome(env);
8396
8585
  if (!workbuddyHome) return [];
8397
8586
  const projectsDir = path.join(workbuddyHome, "projects");
8398
- if (!fssync.existsSync(projectsDir)) return [];
8399
8587
  const files = [];
8400
- const walk = (dir) => {
8588
+ const walkJsonl = (dir) => {
8401
8589
  let entries;
8402
8590
  try { entries = fssync.readdirSync(dir, { withFileTypes: true }); } catch { return; }
8403
8591
  for (const entry of entries) {
@@ -8412,15 +8600,89 @@ function resolveWorkbuddyProjectFiles(env = process.env) {
8412
8600
  isFile = st.isFile();
8413
8601
  } catch { continue; }
8414
8602
  }
8415
- if (isDir) walk(full);
8603
+ if (isDir) walkJsonl(full);
8416
8604
  else if (isFile && entry.name.endsWith(".jsonl")) files.push(full);
8417
8605
  }
8418
8606
  };
8419
- walk(projectsDir);
8420
- files.sort((a, b) => a.localeCompare(b));
8607
+ if (fssync.existsSync(projectsDir)) walkJsonl(projectsDir);
8608
+
8609
+ const tracesDir = path.join(workbuddyHome, "traces");
8610
+ const walkTraces = (dir) => {
8611
+ let entries;
8612
+ try { entries = fssync.readdirSync(dir, { withFileTypes: true }); } catch { return; }
8613
+ for (const entry of entries) {
8614
+ const full = path.join(dir, entry.name);
8615
+ let isDir = entry.isDirectory();
8616
+ let isFile = entry.isFile();
8617
+ if (!isDir && !isFile) {
8618
+ try {
8619
+ const st = fssync.statSync(full);
8620
+ isDir = st.isDirectory();
8621
+ isFile = st.isFile();
8622
+ } catch { continue; }
8623
+ }
8624
+ if (isDir) walkTraces(full);
8625
+ else if (isFile && /^trace_[^/]+\.json$/i.test(entry.name)) {
8626
+ files.push({ path: full, kind: "trace" });
8627
+ }
8628
+ }
8629
+ };
8630
+ if (fssync.existsSync(tracesDir)) walkTraces(tracesDir);
8631
+ files.sort((a, b) => {
8632
+ const aPath = typeof a === "string" ? a : a?.path || "";
8633
+ const bPath = typeof b === "string" ? b : b?.path || "";
8634
+ // JSONL first is intentional: trace summaries are a lossy fallback and
8635
+ // must not win over a detailed rawUsage record on the same session.
8636
+ const aKind = typeof a === "string" ? 0 : 1;
8637
+ const bKind = typeof b === "string" ? 0 : 1;
8638
+ return aKind - bKind || aPath.localeCompare(bPath);
8639
+ });
8421
8640
  return files;
8422
8641
  }
8423
8642
 
8643
+ // WorkBuddy's current session_usage table stores context-window state in
8644
+ // `used`/`size` and model credit dollars in `credit_json`; neither is a token
8645
+ // accounting breakdown. Only consume a SQLite row when a future schema (or a
8646
+ // newer installation) exposes explicit cumulative token columns. This keeps
8647
+ // the fallback forward-compatible without treating a context snapshot as
8648
+ // billable usage.
8649
+ function workbuddySqliteUsageSnapshot(row) {
8650
+ if (!row || typeof row !== "object") return null;
8651
+ const hasAny = (keys) => keys.some((key) => Object.prototype.hasOwnProperty.call(row, key));
8652
+ const inputKeys = ["input_tokens", "inputTokens", "prompt_tokens", "promptTokens"];
8653
+ const cacheReadKeys = [
8654
+ "cached_input_tokens",
8655
+ "cachedInputTokens",
8656
+ "cache_read_input_tokens",
8657
+ "cacheReadInputTokens",
8658
+ "cache_read_tokens",
8659
+ "cacheReadTokens",
8660
+ ];
8661
+ const cacheCreationKeys = [
8662
+ "cache_creation_input_tokens",
8663
+ "cacheCreationInputTokens",
8664
+ "prompt_cache_write_tokens",
8665
+ "promptCacheWriteTokens",
8666
+ ];
8667
+ const outputKeys = ["output_tokens", "outputTokens", "completion_tokens", "completionTokens"];
8668
+ const reasoningKeys = [
8669
+ "reasoning_output_tokens",
8670
+ "reasoningOutputTokens",
8671
+ "reasoning_tokens",
8672
+ "reasoningTokens",
8673
+ ];
8674
+ if (!hasAny([...inputKeys, ...cacheReadKeys, ...cacheCreationKeys, ...outputKeys, ...reasoningKeys])) {
8675
+ return null;
8676
+ }
8677
+ return {
8678
+ input: firstPresentNonNegativeInt(inputKeys.map((key) => row[key])),
8679
+ cacheRead: firstPresentNonNegativeInt(cacheReadKeys.map((key) => row[key])),
8680
+ cacheCreation: firstPresentNonNegativeInt(cacheCreationKeys.map((key) => row[key])),
8681
+ output: firstPresentNonNegativeInt(outputKeys.map((key) => row[key])),
8682
+ reasoning: firstPresentNonNegativeInt(reasoningKeys.map((key) => row[key])),
8683
+ };
8684
+ }
8685
+
8424
8686
  async function parseWorkbuddyIncremental({
8425
8687
  projectFiles,
8426
8688
  cursors,
@@ -8447,11 +8709,22 @@ async function parseWorkbuddyIncremental({
8447
8709
  workbuddyState.detailedSessions && typeof workbuddyState.detailedSessions === "object"
8448
8710
  ? { ...workbuddyState.detailedSessions }
8449
8711
  : {};
8712
+ const seenTraceIds = new Set(
8713
+ Array.isArray(workbuddyState.seenTraceIds) ? workbuddyState.seenTraceIds : [],
8714
+ );
8715
+ const tracedSessionIds = new Set(
8716
+ Array.isArray(workbuddyState.tracedSessionIds) ? workbuddyState.tracedSessionIds : [],
8717
+ );
8450
8718
  const detailedSessionsWithUsage = new Set();
8451
8719
 
8452
- const files = Array.isArray(projectFiles)
8720
+ const allFiles = Array.isArray(projectFiles)
8453
8721
  ? projectFiles
8454
8722
  : resolveWorkbuddyProjectFiles(env || process.env);
8723
+ const jsonlFiles = allFiles.filter((entry) => typeof entry === "string");
8724
+ const traceFiles = allFiles.filter(
8725
+ (entry) => entry && typeof entry === "object" && typeof entry.path === "string" && entry.kind === "trace",
8726
+ );
8727
+ const files = [...jsonlFiles, ...traceFiles];
8455
8728
  const fallbackModel = defaultModel || resolveWorkbuddyDefaultModel(env || process.env);
8456
8729
 
8457
8730
  const workbuddyHome = resolveWorkbuddyHome(env || process.env);
@@ -8462,6 +8735,8 @@ async function parseWorkbuddyIncremental({
8462
8735
  cursors.workbuddy = {
8463
8736
  ...workbuddyState,
8464
8737
  seenIds: Array.from(seenIds),
8738
+ seenTraceIds: Array.from(seenTraceIds),
8739
+ tracedSessionIds: Array.from(tracedSessionIds),
8465
8740
  fileOffsets,
8466
8741
  sqliteSessions,
8467
8742
  detailedSessions,
@@ -8476,8 +8751,8 @@ async function parseWorkbuddyIncremental({
8476
8751
  let recordsProcessed = 0;
8477
8752
  let eventsAggregated = 0;
8478
8753
 
8479
- for (let fileIdx = 0; fileIdx < files.length; fileIdx++) {
8480
- const filePath = files[fileIdx];
8754
+ for (let fileIdx = 0; fileIdx < jsonlFiles.length; fileIdx++) {
8755
+ const filePath = jsonlFiles[fileIdx];
8481
8756
  let stat;
8482
8757
  try { stat = fssync.statSync(filePath); } catch { continue; }
8483
8758
 
@@ -8515,23 +8790,28 @@ async function parseWorkbuddyIncremental({
8515
8790
  typeof entry.sessionId === "string" && entry.sessionId
8516
8791
  ? entry.sessionId
8517
8792
  : path.basename(filePath, ".jsonl");
8518
- if (Object.prototype.hasOwnProperty.call(sqliteSessions, sessionId)) {
8793
+ const sqliteSession = sqliteSessions[sessionId];
8794
+ if (sqliteSession?.detailed || tracedSessionIds.has(sessionId)) {
8519
8795
  continue;
8520
8796
  }
8521
8797
  const tsMs =
8522
8798
  Number.isFinite(Number(entry.timestamp)) && Number(entry.timestamp) > 0
8523
8799
  ? Number(entry.timestamp)
8524
8800
  : null;
8525
- // One usage record per LLM round-trip; the response id is the most stable
8526
- // dedup key, then providerData.messageId, then session+timestamp.
8801
+ // One usage record per LLM round-trip; providerData.messageId is the
8802
+ // response-level id shared by function_call/message records and must win
8803
+ // over the per-record append id. Fall back to entry.uuid/id only when the
8804
+ // response-level id is absent.
8527
8805
  const messageId =
8528
- typeof entry.id === "string" && entry.id
8529
- ? entry.id
8530
- : typeof provider.messageId === "string" && provider.messageId
8806
+ typeof provider?.messageId === "string" && provider.messageId
8531
8807
  ? provider.messageId
8532
- : tsMs != null
8533
- ? `${sessionId}:${tsMs}`
8534
- : null;
8808
+ : typeof entry.uuid === "string" && entry.uuid
8809
+ ? entry.uuid
8810
+ : typeof entry.id === "string" && entry.id
8811
+ ? entry.id
8812
+ : tsMs != null
8813
+ ? `${sessionId}:${tsMs}`
8814
+ : null;
8535
8815
  if (!messageId) continue;
8536
8816
  if (seenIds.has(messageId)) continue;
8537
8817
 
@@ -8603,6 +8883,11 @@ async function parseWorkbuddyIncremental({
8603
8883
  addTotals(bucket.totals, delta);
8604
8884
  touchedBuckets.add(bucketKey("workbuddy", model, bucketStart));
8605
8885
  seenIds.add(messageId);
8886
+ // Keep legacy entry-id keys alongside the response id so upgrading a
8887
+ // cursor cannot replay a row that an older build already counted.
8888
+ if (typeof entry.id === "string" && entry.id && entry.id !== messageId) {
8889
+ seenIds.add(entry.id);
8890
+ }
8606
8891
  detailedSessions[sessionId] = true;
8607
8892
  detailedSessionsWithUsage.add(sessionId);
8608
8893
  eventsAggregated++;
@@ -8627,14 +8912,105 @@ async function parseWorkbuddyIncremental({
8627
8912
  };
8628
8913
  }
8629
8914
 
8915
+ // Trace summaries are a fallback for sessions whose JSONL has no detailed
8916
+ // providerData.rawUsage. A trace's modelInfo totals include the cache read
8917
+ // portion of input, so split input into uncached input + cached input. Do
8918
+ // not merge a trace with the same session's JSONL (or SQLite) data: the
8919
+ // JSONL path has per-round detail and the trace summary can be partial on
8920
+ // older WorkBuddy versions.
8921
+ for (let fileIdx = 0; fileIdx < traceFiles.length; fileIdx++) {
8922
+ const entry = traceFiles[fileIdx];
8923
+ const filePath = entry.path;
8924
+ let stat;
8925
+ try { stat = fssync.statSync(filePath); } catch { continue; }
8926
+ const prevEntry = fileOffsets[filePath] || {};
8927
+ const prevSize = Number(prevEntry.size) || 0;
8928
+ const prevIno = prevEntry.ino;
8929
+ const inodeChanged = typeof prevIno === "number" && prevIno !== stat.ino;
8930
+ const startOffset = stat.size < prevSize || inodeChanged ? 0 : prevSize;
8931
+ if (stat.size <= startOffset) continue;
8932
+
8933
+ let traceDoc;
8934
+ try {
8935
+ traceDoc = JSON.parse(fssync.readFileSync(filePath, "utf8"));
8936
+ } catch {
8937
+ continue;
8938
+ }
8939
+ const trace = traceDoc && typeof traceDoc === "object" && traceDoc.trace && typeof traceDoc.trace === "object"
8940
+ ? traceDoc.trace
8941
+ : null;
8942
+ const traceId = typeof trace?.traceId === "string" && trace.traceId
8943
+ ? trace.traceId
8944
+ : path.basename(filePath, ".json");
8945
+ const metadata = trace?.metadata && typeof trace.metadata === "object" ? trace.metadata : {};
8946
+ const modelInfo = trace?.modelInfo && typeof trace.modelInfo === "object"
8947
+ ? trace.modelInfo
8948
+ : metadata.modelInfo && typeof metadata.modelInfo === "object"
8949
+ ? metadata.modelInfo
8950
+ : {};
8951
+ const sessionId =
8952
+ (typeof trace?.sessionId === "string" && trace.sessionId) ||
8953
+ (typeof metadata.sessionId === "string" && metadata.sessionId) ||
8954
+ traceId;
8955
+ const startedAtRaw = trace?.startedAt || metadata.startedAt;
8956
+ const tsMs = typeof startedAtRaw === "number" && Number.isFinite(startedAtRaw)
8957
+ ? (startedAtRaw > 10000000000 ? startedAtRaw : startedAtRaw * 1000)
8958
+ : Date.parse(String(startedAtRaw || ""));
8959
+ const totalInput = toNonNegativeInt(modelInfo.totalInputTokens);
8960
+ const totalOutput = toNonNegativeInt(modelInfo.totalOutputTokens);
8961
+ const totalCached = Math.min(totalInput, toNonNegativeInt(modelInfo.totalCachedTokens));
8962
+ if (seenTraceIds.has(traceId) || detailedSessions[sessionId] || detailedSessionsWithUsage.has(sessionId) || tracedSessionIds.has(sessionId)) {
8963
+ seenTraceIds.add(traceId);
8964
+ fileOffsets[filePath] = { size: stat.size, mtimeMs: stat.mtimeMs, ino: stat.ino };
8965
+ continue;
8966
+ }
8967
+ if (!Number.isFinite(tsMs) || tsMs <= 0 || (totalInput <= 0 && totalOutput <= 0)) {
8968
+ // Keep the file offset but do not permanently suppress a trace that may
8969
+ // still be finalized in place by WorkBuddy.
8970
+ fileOffsets[filePath] = { size: stat.size, mtimeMs: stat.mtimeMs, ino: stat.ino };
8971
+ continue;
8972
+ }
8973
+
8974
+ const bucketStart = toUtcHalfHourStart(new Date(tsMs).toISOString());
8975
+ if (!bucketStart) continue;
8976
+ const model =
8977
+ normalizeModelInput(Array.isArray(modelInfo.models) ? modelInfo.models[0] : modelInfo.model) ||
8978
+ fallbackModel;
8979
+ const inputTokens = Math.max(0, totalInput - totalCached);
8980
+ const delta = {
8981
+ input_tokens: inputTokens,
8982
+ cached_input_tokens: totalCached,
8983
+ cache_creation_input_tokens: 0,
8984
+ output_tokens: totalOutput,
8985
+ reasoning_output_tokens: 0,
8986
+ total_tokens: inputTokens + totalCached + totalOutput,
8987
+ conversation_count: 1,
8988
+ };
8989
+ const bucket = getHourlyBucket(hourlyState, "workbuddy", model, bucketStart);
8990
+ addTotals(bucket.totals, delta);
8991
+ touchedBuckets.add(bucketKey("workbuddy", model, bucketStart));
8992
+ seenTraceIds.add(traceId);
8993
+ tracedSessionIds.add(sessionId);
8994
+ recordsProcessed++;
8995
+ eventsAggregated++;
8996
+ fileOffsets[filePath] = { size: stat.size, mtimeMs: stat.mtimeMs, ino: stat.ino };
8997
+ if (cb) {
8998
+ cb({
8999
+ index: jsonlFiles.length + fileIdx + 1,
9000
+ total: files.length,
9001
+ recordsProcessed,
9002
+ eventsAggregated,
9003
+ bucketsQueued: touchedBuckets.size,
9004
+ });
9005
+ }
9006
+ }
9007
+
8630
9008
  if (dbExists) {
8631
9009
  const query = `
8632
9010
  SELECT
8633
- su.session_id,
8634
- su.used,
8635
- su.updated_at,
8636
- s.model,
8637
- s.cwd
9011
+ su.*,
9012
+ s.model AS session_model,
9013
+ s.cwd AS session_cwd
8638
9014
  FROM session_usage su
8639
9015
  LEFT JOIN sessions s ON s.id = su.session_id
8640
9016
  WHERE su.used IS NOT NULL
@@ -8659,35 +9035,74 @@ async function parseWorkbuddyIncremental({
8659
9035
  if (!row || typeof row !== "object") continue;
8660
9036
  const sessionId = typeof row.session_id === "string" ? row.session_id.trim() : "";
8661
9037
  if (!sessionId) continue;
8662
- if (detailedSessions[sessionId] || detailedSessionsWithUsage.has(sessionId)) continue;
9038
+ if (
9039
+ detailedSessions[sessionId] ||
9040
+ detailedSessionsWithUsage.has(sessionId) ||
9041
+ tracedSessionIds.has(sessionId)
9042
+ ) continue;
8663
9043
 
8664
- const usedNow = toNonNegativeInt(row.used);
8665
9044
  const updatedAtRaw = toNonNegativeInt(row.updated_at);
8666
- const rawModel = typeof row.model === "string" ? row.model.trim() : "";
9045
+ const rawModel = typeof row.session_model === "string"
9046
+ ? row.session_model.trim()
9047
+ : typeof row.model === "string" ? row.model.trim() : "";
8667
9048
 
8668
- if (usedNow <= 0 || updatedAtRaw <= 0) continue;
9049
+ if (updatedAtRaw <= 0) continue;
8669
9050
 
8670
- const prev = sqliteSessions[sessionId] || { used: 0 };
8671
- const prevUsed = toNonNegativeInt(prev.used);
8672
- const isReset = usedNow > 0 && prevUsed > 0 && usedNow < prevUsed;
8673
- const inputDelta = isReset ? usedNow : Math.max(0, usedNow - prevUsed);
8674
- if (inputDelta === 0) {
9051
+ const snapshot = workbuddySqliteUsageSnapshot(row);
9052
+ const usedNow = toNonNegativeInt(row.used);
9053
+ const prev = sqliteSessions[sessionId] || {};
9054
+
9055
+ // Current WorkBuddy releases expose only context state (`used` versus
9056
+ // `size`) here. Do not turn that bounded snapshot into a fake usage
9057
+ // delta; detailed JSONL/trace sources remain the authoritative path.
9058
+ if (!snapshot) {
9059
+ sqliteSessions[sessionId] = {
9060
+ ...prev,
9061
+ used: usedNow,
9062
+ updatedAt: updatedAtRaw,
9063
+ model: rawModel || prev.model || fallbackModel,
9064
+ detailed: false,
9065
+ };
9066
+ continue;
9067
+ }
9068
+
9069
+ const previousTokens = prev.tokens && typeof prev.tokens === "object"
9070
+ ? prev.tokens
9071
+ : { input: 0, cacheRead: 0, cacheCreation: 0, output: 0, reasoning: 0 };
9072
+ const isReset = ["input", "cacheRead", "cacheCreation", "output", "reasoning"]
9073
+ .some((key) => snapshot[key] < toNonNegativeInt(previousTokens[key]));
9074
+ const deltaTokens = {
9075
+ input: isReset ? snapshot.input : Math.max(0, snapshot.input - toNonNegativeInt(previousTokens.input)),
9076
+ cacheRead: isReset ? snapshot.cacheRead : Math.max(0, snapshot.cacheRead - toNonNegativeInt(previousTokens.cacheRead)),
9077
+ cacheCreation: isReset ? snapshot.cacheCreation : Math.max(0, snapshot.cacheCreation - toNonNegativeInt(previousTokens.cacheCreation)),
9078
+ output: isReset ? snapshot.output : Math.max(0, snapshot.output - toNonNegativeInt(previousTokens.output)),
9079
+ reasoning: isReset ? snapshot.reasoning : Math.max(0, snapshot.reasoning - toNonNegativeInt(previousTokens.reasoning)),
9080
+ };
9081
+ if (
9082
+ deltaTokens.input === 0 &&
9083
+ deltaTokens.cacheRead === 0 &&
9084
+ deltaTokens.cacheCreation === 0 &&
9085
+ deltaTokens.output === 0 &&
9086
+ deltaTokens.reasoning === 0
9087
+ ) {
8675
9088
  sqliteSessions[sessionId] = {
8676
9089
  ...prev,
8677
9090
  used: usedNow,
8678
9091
  updatedAt: updatedAtRaw,
8679
9092
  model: rawModel || prev.model || fallbackModel,
9093
+ tokens: snapshot,
9094
+ detailed: true,
8680
9095
  };
8681
9096
  continue;
8682
9097
  }
8683
9098
 
8684
9099
  recordsProcessed++;
8685
9100
 
8686
- const inputTokens = inputDelta;
8687
- const completionTokens = 0;
8688
- const cacheRead = 0;
8689
- const cacheCreation = 0;
8690
- const reasoningTokens = 0;
9101
+ const inputTokens = deltaTokens.input;
9102
+ const completionTokens = deltaTokens.output;
9103
+ const cacheRead = deltaTokens.cacheRead;
9104
+ const cacheCreation = deltaTokens.cacheCreation;
9105
+ const reasoningTokens = deltaTokens.reasoning;
8691
9106
 
8692
9107
  const tsMs = updatedAtRaw > 10000000000 ? updatedAtRaw : updatedAtRaw * 1000;
8693
9108
  const tsIso = new Date(tsMs).toISOString();
@@ -8702,8 +9117,8 @@ async function parseWorkbuddyIncremental({
8702
9117
  cache_creation_input_tokens: cacheCreation,
8703
9118
  output_tokens: completionTokens,
8704
9119
  reasoning_output_tokens: reasoningTokens,
8705
- total_tokens: inputTokens,
8706
- conversation_count: prevUsed === 0 || isReset ? 1 : 0,
9120
+ total_tokens: inputTokens + completionTokens + cacheRead + cacheCreation + reasoningTokens,
9121
+ conversation_count: Object.keys(previousTokens).every((key) => toNonNegativeInt(previousTokens[key]) === 0) || isReset ? 1 : 0,
8707
9122
  };
8708
9123
 
8709
9124
  const bucket = getHourlyBucket(hourlyState, "workbuddy", model, bucketStart);
@@ -8713,6 +9128,8 @@ async function parseWorkbuddyIncremental({
8713
9128
  used: usedNow,
8714
9129
  updatedAt: updatedAtRaw,
8715
9130
  model,
9131
+ tokens: snapshot,
9132
+ detailed: true,
8716
9133
  };
8717
9134
  eventsAggregated++;
8718
9135
  }
@@ -8740,6 +9157,14 @@ async function parseWorkbuddyIncremental({
8740
9157
  detailedSessionEntries.length > 10_000
8741
9158
  ? Object.fromEntries(detailedSessionEntries.slice(detailedSessionEntries.length - 10_000))
8742
9159
  : detailedSessions;
9160
+ const traceArr = Array.from(seenTraceIds);
9161
+ const cappedSeenTraceIds =
9162
+ traceArr.length > 10_000 ? traceArr.slice(traceArr.length - 10_000) : traceArr;
9163
+ const tracedSessionArr = Array.from(tracedSessionIds);
9164
+ const cappedTracedSessionIds =
9165
+ tracedSessionArr.length > 10_000
9166
+ ? tracedSessionArr.slice(tracedSessionArr.length - 10_000)
9167
+ : tracedSessionArr;
8743
9168
 
8744
9169
  const bucketsQueued = await enqueueTouchedBuckets({
8745
9170
  queuePath,
@@ -8752,6 +9177,8 @@ async function parseWorkbuddyIncremental({
8752
9177
  cursors.workbuddy = {
8753
9178
  ...workbuddyState,
8754
9179
  seenIds: cappedSeen,
9180
+ seenTraceIds: cappedSeenTraceIds,
9181
+ tracedSessionIds: cappedTracedSessionIds,
8755
9182
  fileOffsets,
8756
9183
  sqliteSessions: cappedSqliteSessions,
8757
9184
  detailedSessions: cappedDetailedSessions,
@@ -15448,6 +15875,7 @@ module.exports = {
15448
15875
  resolveKimiCodeDefaultModel,
15449
15876
  parseKimiCodeIncremental,
15450
15877
  resolveCodebuddyHome,
15878
+ codebuddyJsonlHasUsage,
15451
15879
  resolveCodebuddyProjectFiles,
15452
15880
  resolveCodebuddyDefaultModel,
15453
15881
  parseCodebuddyIncremental,