tokentracker-cli 1.1.3 → 1.1.4

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 (115) hide show
  1. package/README.de.md +5 -4
  2. package/README.ja.md +5 -4
  3. package/README.ko.md +5 -4
  4. package/README.md +5 -4
  5. package/README.zh-CN.md +5 -4
  6. package/dashboard/dist/assets/{AchievementBadge-dt_UDU_h.js → AchievementBadge-BGA-HT_B.js} +1 -1
  7. package/dashboard/dist/assets/{AchievementsPage-B5DJ3S9l.js → AchievementsPage-CtRh5AjH.js} +2 -2
  8. package/dashboard/dist/assets/{AchievementsSection-CDxcyig2.js → AchievementsSection-BarRcKaN.js} +1 -1
  9. package/dashboard/dist/assets/{BadgeDetailModal-Cp98zNn2.js → BadgeDetailModal-Bg0BDAN2.js} +1 -1
  10. package/dashboard/dist/assets/{BotAnimated-CR7MiUpo.js → BotAnimated-By9J3-dn.js} +1 -1
  11. package/dashboard/dist/assets/{Card-C8IdDvlg.js → Card-CIoJYi2j.js} +1 -1
  12. package/dashboard/dist/assets/{ClawdAnimated-COeTlW8z.js → ClawdAnimated-Dkvc2xe8.js} +2 -2
  13. package/dashboard/dist/assets/{CommandPalette-Co9k2CCc.js → CommandPalette-BssDtHRl.js} +1 -1
  14. package/dashboard/dist/assets/{CommunityStatsModal-C6XQeRgw.js → CommunityStatsModal-CQrgLlDb.js} +1 -1
  15. package/dashboard/dist/assets/{ConfirmModal-Ai4182O-.js → ConfirmModal-C3hBPgYc.js} +1 -1
  16. package/dashboard/dist/assets/{Controls-BbKzN-Pe.js → Controls-BSW6a1HS.js} +1 -1
  17. package/dashboard/dist/assets/DashboardPage-Dhv7fejy.js +1 -0
  18. package/dashboard/dist/assets/{DevicePage-CZbrtJmb.js → DevicePage-D5eZn0wo.js} +1 -1
  19. package/dashboard/dist/assets/{DialogClose-DMl8tAoX.js → DialogClose-BltQpaJo.js} +1 -1
  20. package/dashboard/dist/assets/DialogDescription-Cof52Icj.js +1 -0
  21. package/dashboard/dist/assets/{DialogTitle-BJxpp2t-.js → DialogTitle-CVddcRPZ.js} +1 -1
  22. package/dashboard/dist/assets/{FadeIn-hU_MXl6R.js → FadeIn-goPv73MT.js} +1 -1
  23. package/dashboard/dist/assets/{HeaderGithubStar-CM5RSxxN.js → HeaderGithubStar-BDBrrkzt.js} +1 -1
  24. package/dashboard/dist/assets/{HoverTooltip-BZKfQyZQ.js → HoverTooltip-qyMezXOs.js} +1 -1
  25. package/dashboard/dist/assets/{IpCheckPage-BjDx-zja.js → IpCheckPage-Ces8JIPb.js} +1 -1
  26. package/dashboard/dist/assets/{LandingPage-CAt8BCDv.js → LandingPage-B2TmLGno.js} +2 -2
  27. package/dashboard/dist/assets/{LeaderboardAvatar-DiCrakjI.js → LeaderboardAvatar-CVVRoNua.js} +1 -1
  28. package/dashboard/dist/assets/{LeaderboardPage-Dj3pHv6U.js → LeaderboardPage-xtt-bOY8.js} +2 -2
  29. package/dashboard/dist/assets/{LeaderboardProfileModal-DEWbcP9p.js → LeaderboardProfileModal-BM4d_I_r.js} +3 -3
  30. package/dashboard/dist/assets/{LeaderboardProfilePage-CTbWHQkY.js → LeaderboardProfilePage-BWqhFLQX.js} +1 -1
  31. package/dashboard/dist/assets/{LimitsPage-V-98OUhF.js → LimitsPage-BZL4Pcl8.js} +1 -1
  32. package/dashboard/dist/assets/{LocalOnlyNotice-a4PqtUZY.js → LocalOnlyNotice-D4gnOEJj.js} +1 -1
  33. package/dashboard/dist/assets/{LoginCard-oD1A2Ru8.js → LoginCard-CB17FQ5w.js} +1 -1
  34. package/dashboard/dist/assets/{LoginModal-D8aSmxok.js → LoginModal-BI-O1rH4.js} +1 -1
  35. package/dashboard/dist/assets/{LoginPage-DIjhBv2X.js → LoginPage-DnGzsvJ4.js} +1 -1
  36. package/dashboard/dist/assets/{PetPage-D7601BTX.js → PetPage-NRnIKMaJ.js} +1 -1
  37. package/dashboard/dist/assets/{PopoverPopup-aPnmKopO.js → PopoverPopup-DHoK6UJZ.js} +1 -1
  38. package/dashboard/dist/assets/{ProviderIcon-rwyfcNlT.js → ProviderIcon-BTkdx9o2.js} +1 -1
  39. package/dashboard/dist/assets/{ResetPasswordPage-AF3SBgW2.js → ResetPasswordPage-C6K0w318.js} +1 -1
  40. package/dashboard/dist/assets/{SegmentedControl-fnlScNMT.js → SegmentedControl-gEoWioBE.js} +1 -1
  41. package/dashboard/dist/assets/{Select-DDjC3RjB.js → Select-CI-qtEYu.js} +1 -1
  42. package/dashboard/dist/assets/{SelectItemText-VhBWE2O8.js → SelectItemText-q-yhzzjw.js} +1 -1
  43. package/dashboard/dist/assets/{ServiceStatusPage-BL3qqEsp.js → ServiceStatusPage-C4xD1CEe.js} +1 -1
  44. package/dashboard/dist/assets/{SessionsPage-DXYiuP2x.js → SessionsPage-CS0Yun2z.js} +1 -1
  45. package/dashboard/dist/assets/{SettingsPage-DTeA_ahr.js → SettingsPage-GVY_ktz4.js} +2 -2
  46. package/dashboard/dist/assets/{SkillsPage-CtTRwVQH.js → SkillsPage-COPRL95O.js} +1 -1
  47. package/dashboard/dist/assets/{TokenGalaxy-CifK91V7.js → TokenGalaxy-Czi0Ic48.js} +1 -1
  48. package/dashboard/dist/assets/{ToolbarRootContext-ClGAIYoa.js → ToolbarRootContext-BZkTiBJB.js} +1 -1
  49. package/dashboard/dist/assets/{TrendMonitor-D1evFHRM.js → TrendMonitor-CnIsuxV-.js} +1 -1
  50. package/dashboard/dist/assets/{WidgetsPage-Cq4RYJDE.js → WidgetsPage-OdX_Fsul.js} +1 -1
  51. package/dashboard/dist/assets/{WrappedPage-CoC5RJH7.js → WrappedPage-h9N63-F8.js} +1 -1
  52. package/dashboard/dist/assets/__vite-browser-external-BFNWaGsk.js +45 -0
  53. package/dashboard/dist/assets/{agent-logos-QLdA9OeA.js → agent-logos-BDiPxrST.js} +1 -1
  54. package/dashboard/dist/assets/{arrow-up-right-BYLgB3bH.js → arrow-up-right-DtFQUeJG.js} +1 -1
  55. package/dashboard/dist/assets/{check-DfnIvqZD.js → check-C35onaNL.js} +1 -1
  56. package/dashboard/dist/assets/{composite--JllTpIX.js → composite-qxBzts0W.js} +1 -1
  57. package/dashboard/dist/assets/{copy-ClKVBIgD.js → copy-D_D71TLc.js} +1 -1
  58. package/dashboard/dist/assets/{download-Di1-UFJX.js → download-DMQI2hQe.js} +1 -1
  59. package/dashboard/dist/assets/{external-link-D7zPVFLO.js → external-link-CemM4dWf.js} +1 -1
  60. package/dashboard/dist/assets/{flame-BMJTxjVg.js → flame-CIvk0SP5.js} +1 -1
  61. package/dashboard/dist/assets/{format-tokens-DCZM5Nfn.js → format-tokens-D3JSvcFl.js} +1 -1
  62. package/dashboard/dist/assets/{icons-I615W2IU.js → icons-Ccf5W0AL.js} +1 -1
  63. package/dashboard/dist/assets/{index-BejnmdPe.js → index-Brd2evyz.js} +1 -1
  64. package/dashboard/dist/assets/{index-BX3CXVdD.js → index-C8Wri_HI.js} +1 -1
  65. package/dashboard/dist/assets/{index-CwczM3BZ.js → index-CFXmD_KX.js} +1 -1
  66. package/dashboard/dist/assets/{info-CFJPny5Q.js → info-CafrKEG8.js} +1 -1
  67. package/dashboard/dist/assets/{link-2-D1ydWh_w.js → link-2-Ds-9Nzlp.js} +1 -1
  68. package/dashboard/dist/assets/{loader-circle-Cq-J-OFg.js → loader-circle-DA4I55-V.js} +1 -1
  69. package/dashboard/dist/assets/{maximize-2-kIoSq57I.js → maximize-2-CjMPt5kU.js} +1 -1
  70. package/dashboard/dist/assets/{modifiers.esm-CLojthuF.js → modifiers.esm-DIxTS09h.js} +1 -1
  71. package/dashboard/dist/assets/module-WSx8viXK.js +3 -0
  72. package/dashboard/dist/assets/{monitor-smartphone-DX2qWHYV.js → monitor-smartphone-IZpIOxn3.js} +1 -1
  73. package/dashboard/dist/assets/{plus-B-JEpo3V.js → plus-vbLFNs70.js} +1 -1
  74. package/dashboard/dist/assets/{provider-display-DJDdrTAS.js → provider-display-BAu_UIfV.js} +2 -2
  75. package/dashboard/dist/assets/{react-Ctoe8iLl.js → react-Djb4VGKb.js} +1 -1
  76. package/dashboard/dist/assets/{search-BTSQrlW-.js → search-Ct6LY9Ke.js} +1 -1
  77. package/dashboard/dist/assets/{skills-api-DKQyM8h7.js → skills-api-BnGI2gEp.js} +1 -1
  78. package/dashboard/dist/assets/{sortable.esm-pOEL13xU.js → sortable.esm-CjCGd6g5.js} +1 -1
  79. package/dashboard/dist/assets/{store-B2RplzvW.js → store-CBxkOqRe.js} +1 -1
  80. package/dashboard/dist/assets/{terminal-BH_6_EW_.js → terminal-Dlpdp5Az.js} +1 -1
  81. package/dashboard/dist/assets/{trash-CC444Mz6.js → trash-pxUSKv66.js} +1 -1
  82. package/dashboard/dist/assets/{use-community-stats-CBXLdD_j.js → use-community-stats-tHaixRIw.js} +1 -1
  83. package/dashboard/dist/assets/{use-latest-request-guard-DMuPfZQI.js → use-latest-request-guard-Cs44WPfL.js} +1 -1
  84. package/dashboard/dist/assets/{use-limits-display-prefs-CmsGTBA4.js → use-limits-display-prefs-Dw56PXWy.js} +1 -1
  85. package/dashboard/dist/assets/{use-ordered-list-BNJNpXjB.js → use-ordered-list-DpLx8oLV.js} +1 -1
  86. package/dashboard/dist/assets/use-reduced-motion-DrlJ9X6p.js +1 -0
  87. package/dashboard/dist/assets/{use-session-efficiency-pref-g_0ISxe1.js → use-session-efficiency-pref-BN3FtBvr.js} +1 -1
  88. package/dashboard/dist/assets/{use-transform-D36BHcXk.js → use-transform-C4nTGgzb.js} +1 -1
  89. package/dashboard/dist/assets/{use-usage-limits-Dxw3vzsD.js → use-usage-limits-Dmwdc1yo.js} +1 -1
  90. package/dashboard/dist/assets/{useCurrency-V2FE4TMo.js → useCurrency-DSc3A_Ih.js} +1 -1
  91. package/dashboard/dist/assets/{useTokenFormat-CEOwvzMt.js → useTokenFormat-BHjcMgm8.js} +1 -1
  92. package/dashboard/dist/assets/{zap-BS7ZU76I.js → zap-BE8i_wu2.js} +1 -1
  93. package/dashboard/dist/brand-logos/trae.svg +1 -0
  94. package/dashboard/dist/index.html +13 -11
  95. package/dashboard/dist/ip-check.html +1 -1
  96. package/dashboard/dist/leaderboard.html +1 -1
  97. package/dashboard/dist/llms.txt +3 -2
  98. package/dashboard/dist/share.html +1 -1
  99. package/package.json +6 -4
  100. package/src/commands/init.js +9 -7
  101. package/src/commands/status.js +9 -9
  102. package/src/commands/sync.js +25 -0
  103. package/src/lib/pricing/curated-overrides.json +7 -0
  104. package/src/lib/pricing/matcher.js +5 -0
  105. package/src/lib/pricing/seed-snapshot.json +1 -1
  106. package/src/lib/rollout.js +197 -18
  107. package/src/lib/trae-db.js +127 -0
  108. package/src/lib/trae-snapshot.js +230 -0
  109. package/src/lib/trae-sqlite.js +143 -0
  110. package/src/lib/trae-usage.js +121 -0
  111. package/dashboard/dist/assets/DashboardPage-DTcVGG4e.js +0 -1
  112. package/dashboard/dist/assets/DialogDescription-haPz7CX9.js +0 -1
  113. package/dashboard/dist/assets/__vite-browser-external-BDugA6I0.js +0 -45
  114. package/dashboard/dist/assets/module-DtWfRKM4.js +0 -3
  115. package/dashboard/dist/assets/use-reduced-motion-CNpG-Yoi.js +0 -1
@@ -47,6 +47,8 @@ const {
47
47
  buildDevinUsageEvents,
48
48
  } = require("./devin-usage");
49
49
  const { USD_TICKS_PER_USD, normalizeGrokUsage } = require("./grok-usage");
50
+ const { resolveTraeDbPaths, readTraeUsageRows } = require("./trae-db");
51
+ const { normalizeTraeUsage, normalizeTraeModel, traeTimestamp } = require("./trae-usage");
50
52
 
51
53
  const DEFAULT_SOURCE = "codex";
52
54
  const DEFAULT_MODEL = "unknown";
@@ -19662,12 +19664,13 @@ function mergeGrokUsagePrecision(current, next) {
19662
19664
  return "mixed";
19663
19665
  }
19664
19666
 
19665
- function clearGrokHourlyBuckets(hourlyState) {
19667
+ function clearSourceHourlyBuckets(hourlyState, source) {
19666
19668
  if (!hourlyState || typeof hourlyState !== "object") return;
19669
+ const prefix = `${source}${BUCKET_SEPARATOR}`;
19667
19670
  const buckets = hourlyState.buckets && typeof hourlyState.buckets === "object" ? hourlyState.buckets : null;
19668
19671
  if (buckets) {
19669
19672
  for (const key of Object.keys(buckets)) {
19670
- if (key.startsWith("grok|")) delete buckets[key];
19673
+ if (key.startsWith(prefix)) delete buckets[key];
19671
19674
  }
19672
19675
  }
19673
19676
  const groupQueued =
@@ -19676,12 +19679,12 @@ function clearGrokHourlyBuckets(hourlyState) {
19676
19679
  : null;
19677
19680
  if (groupQueued) {
19678
19681
  for (const key of Object.keys(groupQueued)) {
19679
- if (key.startsWith("grok|")) delete groupQueued[key];
19682
+ if (key.startsWith(prefix)) delete groupQueued[key];
19680
19683
  }
19681
19684
  }
19682
19685
  }
19683
19686
 
19684
- async function retractStaleGrokQueueRows(queuePath, keepKeys) {
19687
+ async function retractStaleSourceQueueRows(queuePath, source, keepKeys) {
19685
19688
  if (!queuePath) return 0;
19686
19689
  let raw = "";
19687
19690
  try {
@@ -19691,7 +19694,7 @@ async function retractStaleGrokQueueRows(queuePath, keepKeys) {
19691
19694
  throw error;
19692
19695
  }
19693
19696
 
19694
- const latestGrok = new Map();
19697
+ const latestRows = new Map();
19695
19698
  for (const line of raw.split("\n")) {
19696
19699
  if (!line.trim()) continue;
19697
19700
  let row;
@@ -19700,21 +19703,21 @@ async function retractStaleGrokQueueRows(queuePath, keepKeys) {
19700
19703
  } catch {
19701
19704
  continue;
19702
19705
  }
19703
- if ((row?.source || "") !== "grok") continue;
19706
+ if ((row?.source || "") !== source) continue;
19704
19707
  const model = normalizeModelInput(row.model) || DEFAULT_MODEL;
19705
19708
  const hourStart = typeof row.hour_start === "string" ? row.hour_start : null;
19706
19709
  if (!hourStart) continue;
19707
- latestGrok.set(bucketKey("grok", model, hourStart), { model, hour_start: hourStart, row });
19710
+ latestRows.set(bucketKey(source, model, hourStart), { model, hour_start: hourStart, row });
19708
19711
  }
19709
19712
 
19710
19713
  const zero = initTotals();
19711
19714
  const lines = [];
19712
- for (const [key, entry] of latestGrok.entries()) {
19715
+ for (const [key, entry] of latestRows.entries()) {
19713
19716
  if (keepKeys.has(key)) continue;
19714
19717
  if (totalsKey(entry.row) === totalsKey(zero)) continue;
19715
19718
  lines.push(
19716
19719
  JSON.stringify({
19717
- source: "grok",
19720
+ source,
19718
19721
  model: entry.model,
19719
19722
  hour_start: entry.hour_start,
19720
19723
  ...zero,
@@ -19814,7 +19817,7 @@ async function parseGrokBuildIncremental({
19814
19817
  legacySeen: true,
19815
19818
  };
19816
19819
  }
19817
- clearGrokHourlyBuckets(hourlyState);
19820
+ clearSourceHourlyBuckets(hourlyState, "grok");
19818
19821
  } else {
19819
19822
  sessionSnapshots = normalizeGrokSessionSnapshots(grokState);
19820
19823
  }
@@ -20195,7 +20198,7 @@ async function parseGrokBuildIncremental({
20195
20198
  if (!key.startsWith("grok|") || !bucket?.totals) continue;
20196
20199
  keepKeys.add(key);
20197
20200
  }
20198
- const retracted = await retractStaleGrokQueueRows(queuePath, keepKeys);
20201
+ const retracted = await retractStaleSourceQueueRows(queuePath, "grok", keepKeys);
20199
20202
  bucketsQueued += retracted;
20200
20203
  }
20201
20204
 
@@ -21687,13 +21690,188 @@ function isCjkCodePoint(code) {
21687
21690
  // ── Trae SOLO (ByteDance AI IDE) ─────────────────────────────────────────────
21688
21691
  // https://www.trae.ai
21689
21692
  //
21690
- // Trae SOLO is scoped to detection (init) + entitlement display (status):
21691
- // - init: resolveTraeStoragePath() proves an install exists.
21692
- // - status: readTraeEntitlementFromStorage() serves the plan/limits snapshot.
21693
- // Trae SOLO does NOT expose per-request token usage in a readable local format
21694
- // (session transcripts are SQLCipher-encrypted; memory summaries carry no
21695
- // token counts), so the token-count-only queue is intentionally never written
21696
- // for this provider — the cloud hourly table stays clean.
21693
+ // International TRAE usage comes from local encrypted chat_turn metadata.
21694
+ //
21695
+ // The ledger keeps one compact tuple per turn, keyed by a 128-bit digest, so a
21696
+ // correction can retract exactly what the turn contributed even after TRAE
21697
+ // deletes its history. total_tokens is the five token columns plus any input
21698
+ // without a cache split, which is counted but unpriced (normalizeTraeUsage).
21699
+ const TRAE_CURSOR_VERSION = 2;
21700
+ const HALF_HOUR_MS = 30 * 60 * 1000;
21701
+ const [T_SESSION, T_MODEL, T_BUCKET, T_MODIFIED, T_STORE, T_INPUT, T_CACHED, T_WRITTEN,
21702
+ T_OUTPUT, T_REASONING, T_CONVERSATIONS, T_ESTIMATED, T_UNPRICED] = Array.from({ length: 13 }, (_, i) => i);
21703
+
21704
+ function packTraeTurn({ session, model, bucketStart, totals, modifiedAt, store }) {
21705
+ return [session, model, Date.parse(bucketStart) / HALF_HOUR_MS, modifiedAt, store,
21706
+ totals.input_tokens, totals.cached_input_tokens, totals.cache_creation_input_tokens,
21707
+ totals.output_tokens, totals.reasoning_output_tokens, totals.conversation_count,
21708
+ totals.usage_precision === "estimated" ? 1 : 0, totals.unpriced_input_tokens || 0];
21709
+ }
21710
+
21711
+ function unpackTraeTotals(turn) {
21712
+ const totals = {
21713
+ input_tokens: turn[T_INPUT],
21714
+ cached_input_tokens: turn[T_CACHED],
21715
+ cache_creation_input_tokens: turn[T_WRITTEN],
21716
+ output_tokens: turn[T_OUTPUT],
21717
+ reasoning_output_tokens: turn[T_REASONING],
21718
+ total_tokens: turn[T_INPUT] + turn[T_CACHED] + turn[T_WRITTEN] + turn[T_OUTPUT] + turn[T_REASONING]
21719
+ + turn[T_UNPRICED],
21720
+ conversation_count: turn[T_CONVERSATIONS],
21721
+ };
21722
+ if (turn[T_ESTIMATED]) totals.usage_precision = "estimated";
21723
+ return totals;
21724
+ }
21725
+
21726
+ function traeTurnBucketStart(turn) {
21727
+ return new Date(turn[T_BUCKET] * HALF_HOUR_MS).toISOString();
21728
+ }
21729
+
21730
+ async function parseTraeIncremental({
21731
+ dbPaths,
21732
+ cursors,
21733
+ queuePath,
21734
+ env = process.env,
21735
+ onProgress,
21736
+ readUsageRows = readTraeUsageRows,
21737
+ } = {}) {
21738
+ const paths = dbPaths || resolveTraeDbPaths(env);
21739
+ const hourlyState = normalizeHourlyState(cursors.hourly);
21740
+ // Queue writes and reads may fail. Publish cursor changes only after the
21741
+ // append succeeds, including copies of the mutable bucket totals.
21742
+ hourlyState.buckets = Object.fromEntries(Object.entries(hourlyState.buckets).map(([key, bucket]) => [
21743
+ key, key.startsWith("trae|") ? { ...bucket, totals: { ...bucket.totals } } : bucket,
21744
+ ]));
21745
+ hourlyState.groupQueued = { ...hourlyState.groupQueued };
21746
+ // A ledger with another TRAE_CURSOR_VERSION cannot retract what it
21747
+ // contributed, so rebuild the TRAE buckets from the stores instead of
21748
+ // adding every turn on top of them.
21749
+ const prior = cursors.trae?.version === TRAE_CURSOR_VERSION ? cursors.trae : null;
21750
+ const rebuilding = !prior && Boolean(cursors.trae);
21751
+ if (rebuilding) clearSourceHourlyBuckets(hourlyState, "trae");
21752
+ const turns = { ...prior?.turns };
21753
+ const databases = { ...prior?.databases };
21754
+ const stores = [...(prior?.stores || [])];
21755
+ const touchedBuckets = new Set();
21756
+ const conversations = new Set(Object.values(turns)
21757
+ .filter((turn) => turn[T_CONVERSATIONS] > 0).map((turn) => turn[T_SESSION]));
21758
+ // 128-bit digests: identifiers are hashed before they are stored.
21759
+ const digest = (value) => crypto.createHash("sha256").update(value).digest().subarray(0, 16).toString("base64url");
21760
+ let recordsProcessed = 0;
21761
+ let eventsAggregated = 0;
21762
+ let recordsSkipped = 0;
21763
+ let estimatedRecords = 0;
21764
+ let unpricedRecords = 0;
21765
+ const errors = [];
21766
+ for (const dbPath of [...new Set(paths)]) {
21767
+ const databaseKey = digest(path.resolve(dbPath));
21768
+ let store = stores.indexOf(databaseKey);
21769
+ if (store < 0) store = stores.push(databaseKey) - 1;
21770
+ let rows;
21771
+ let fingerprint;
21772
+ let finalFingerprint;
21773
+ try {
21774
+ // existsSync also returns false for permission errors. Let stat/read
21775
+ // report inaccessible stores instead of treating them as absent.
21776
+ fssync.statSync(dbPath);
21777
+ fingerprint = devinSqliteFingerprint(dbPath);
21778
+ if (sameSqliteFingerprint(fingerprint, databases[databaseKey])) continue;
21779
+ rows = await readUsageRows(dbPath, { env });
21780
+ finalFingerprint = devinSqliteFingerprint(dbPath);
21781
+ } catch (err) {
21782
+ // One install can be locked, corrupt, or use a different key while
21783
+ // another remains readable. Its prior fingerprint stays retryable;
21784
+ // successful stores still publish together after the queue append.
21785
+ errors.push({ database: dbPath, message: err?.message || String(err) });
21786
+ continue;
21787
+ }
21788
+ for (const row of rows) {
21789
+ recordsProcessed += 1;
21790
+ const totals = normalizeTraeUsage(row.usage, { model: row.model });
21791
+ const timestamp = traeTimestamp(row.created_at);
21792
+ const bucketStart = timestamp && toUtcHalfHourStart(timestamp);
21793
+ const rowId = row.turn_id || row.id;
21794
+ if (!totals || !bucketStart || rowId == null || rowId === "") {
21795
+ recordsSkipped += 1;
21796
+ continue;
21797
+ }
21798
+ const session = digest(String(row.session_id || `${databaseKey}:${rowId}`));
21799
+ // Stable turn ids deduplicate copies across the two international app
21800
+ // stores. Numeric SQLite ids are only unique within their database.
21801
+ const identity = digest(JSON.stringify(row.turn_id
21802
+ ? [session, row.turn_id] : [databaseKey, rowId]));
21803
+ const previous = turns[identity];
21804
+ const modifiedAt = Date.parse(traeTimestamp(row.updated_at) || timestamp);
21805
+ // A copied turn in another install can lag behind the original. Never
21806
+ // let an unrelated rescan of that stale store retract newer usage.
21807
+ // Without a newer timestamp, conflicting copies retain their owner.
21808
+ if (previous && previous[T_STORE] !== store && modifiedAt <= previous[T_MODIFIED]) continue;
21809
+ if (!previous && totals.total_tokens === 0) continue;
21810
+ if (totals.usage_precision === "estimated") estimatedRecords += 1;
21811
+ if (totals.unpriced_input_tokens > 0) unpricedRecords += 1;
21812
+ totals.conversation_count = previous
21813
+ ? previous[T_CONVERSATIONS]
21814
+ : conversations.has(session) ? 0 : 1;
21815
+ conversations.add(session);
21816
+ const model = normalizeTraeModel(row.model);
21817
+ const next = packTraeTurn({ session, model, bucketStart, totals, modifiedAt, store });
21818
+ if (previous && next.every((value, i) => i === T_MODIFIED || i === T_STORE || value === previous[i])) {
21819
+ turns[identity] = next;
21820
+ continue;
21821
+ }
21822
+ if (previous) {
21823
+ const previousBucket = traeTurnBucketStart(previous);
21824
+ const old = getHourlyBucket(hourlyState, "trae", previous[T_MODEL], previousBucket);
21825
+ subtractTotals(old.totals, unpackTraeTotals(previous));
21826
+ touchedBuckets.add(bucketKey("trae", previous[T_MODEL], previousBucket));
21827
+ }
21828
+ const bucket = getHourlyBucket(hourlyState, "trae", model, bucketStart);
21829
+ addTotals(bucket.totals, totals);
21830
+ touchedBuckets.add(bucketKey("trae", model, bucketStart));
21831
+ turns[identity] = next;
21832
+ eventsAggregated += 1;
21833
+ }
21834
+ // A read that races with a writer must be retried on the next sync.
21835
+ databases[databaseKey] = sameSqliteFingerprint(fingerprint, finalFingerprint)
21836
+ ? fingerprint : null;
21837
+ if (typeof onProgress === "function") {
21838
+ onProgress({ recordsProcessed, eventsAggregated, bucketsQueued: touchedBuckets.size });
21839
+ }
21840
+ }
21841
+ // Rebuild precision from the retained contributions, including unchanged
21842
+ // stores. A correction can replace the last estimated turn with reported
21843
+ // usage without changing numeric totals, or move it to another bucket.
21844
+ const bucketPrecisions = new Map();
21845
+ for (const turn of Object.values(turns)) {
21846
+ const key = bucketKey("trae", turn[T_MODEL], traeTurnBucketStart(turn));
21847
+ if (!touchedBuckets.has(key) || unpackTraeTotals(turn).total_tokens === 0) continue;
21848
+ bucketPrecisions.set(key, mergeGrokUsagePrecision(
21849
+ bucketPrecisions.get(key), turn[T_ESTIMATED] ? "estimated" : "reported",
21850
+ ));
21851
+ }
21852
+ for (const key of touchedBuckets) {
21853
+ const precision = bucketPrecisions.get(key);
21854
+ hourlyState.buckets[key].usage_precision = precision === "reported" ? null : precision || null;
21855
+ }
21856
+ // A rebuild is all or nothing. Buckets rebuilt from a subset of the stores
21857
+ // would replace shared buckets with partial totals, and each retry would
21858
+ // append every row again. Keep the old hourly state and ledger instead.
21859
+ if (rebuilding && errors.length) {
21860
+ return { recordsProcessed, eventsAggregated, bucketsQueued: 0, recordsSkipped, estimatedRecords, unpricedRecords, errors };
21861
+ }
21862
+ await ensureDir(path.dirname(queuePath));
21863
+ let bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets });
21864
+ cursors.hourly = hourlyState;
21865
+ if (rebuilding) {
21866
+ // The old ledger may have filed a turn under another bucket key (model or
21867
+ // half-hour); retract those rows so the dashboard does not count it twice.
21868
+ const keepKeys = new Set(Object.keys(hourlyState.buckets).filter((key) => key.startsWith("trae|")));
21869
+ bucketsQueued += await retractStaleSourceQueueRows(queuePath, "trae", keepKeys);
21870
+ }
21871
+ cursors.trae = { version: TRAE_CURSOR_VERSION, stores, databases, turns };
21872
+ return { recordsProcessed, eventsAggregated, bucketsQueued, recordsSkipped, estimatedRecords, unpricedRecords, errors };
21873
+ }
21874
+
21697
21875
  // Ordered candidate app-dir names. The CN IDE build installs as "Trae CN" on
21698
21876
  // Windows; the international build installs as "TRAE SOLO". Both expose the same
21699
21877
  // iCubeServerData entitlement key, so either is a valid Trae IDE install.
@@ -24192,6 +24370,7 @@ async function parseCommandCodeIncremental({
24192
24370
  }
24193
24371
 
24194
24372
  module.exports = {
24373
+ parseTraeIncremental,
24195
24374
  listRolloutFiles,
24196
24375
  listRolloutFilesDeep,
24197
24376
  codexSessionIdFromPath,
@@ -0,0 +1,127 @@
1
+ const fs = require("node:fs");
2
+ const os = require("node:os");
3
+ const path = require("node:path");
4
+
5
+ const { openTraeSnapshot } = require("./trae-snapshot");
6
+ const { withTraeSqlite } = require("./trae-sqlite");
7
+
8
+ const TOKEN_FIELDS = [
9
+ "prompt_tokens", "prompt_tokens_total", "input_tokens",
10
+ "completion_tokens", "completion_tokens_total", "output_tokens",
11
+ "total_tokens", "cache_read_input_tokens", "cache_creation_input_tokens",
12
+ "reasoning_tokens", "reasoning_output_tokens",
13
+ ];
14
+
15
+ function resolveTraeDbPaths(env = process.env, { platform = process.platform, home = os.homedir() } = {}) {
16
+ const explicit = String(env.TOKENTRACKER_TRAE_DB || "").trim();
17
+ if (explicit) return [path.resolve(explicit)];
18
+ const customHome = String(env.TOKENTRACKER_TRAE_HOME || "").trim();
19
+ const parent = platform === "win32"
20
+ ? env.APPDATA || path.join(home, "AppData", "Roaming")
21
+ : platform === "darwin"
22
+ ? path.join(home, "Library", "Application Support")
23
+ : env.XDG_CONFIG_HOME || path.join(home, ".config");
24
+ const roots = customHome ? [customHome] : [path.join(parent, "Trae"), path.join(parent, "TRAE SOLO")];
25
+ const result = [];
26
+ for (const root of roots) {
27
+ // A decrypted sibling may be an old export. Only an explicit DB override
28
+ // opts into one; normal discovery always reads the current application DB.
29
+ for (const module of ["ai-agent", "ai-chat"]) {
30
+ const candidate = path.resolve(root, "ModularData", module, "database.db");
31
+ let present;
32
+ try { present = fs.statSync(candidate).isFile(); }
33
+ catch (err) {
34
+ // Permission failures must reach the reader's diagnostics, not masquerade
35
+ // as an absent installation and silently drop an entire usage history.
36
+ present = err.code !== "ENOENT" && err.code !== "ENOTDIR";
37
+ }
38
+ if (present) {
39
+ result.push(candidate);
40
+ break;
41
+ }
42
+ }
43
+ }
44
+ return [...new Set(result)];
45
+ }
46
+
47
+ function error(message) {
48
+ return new Error(`Cannot read TRAE usage: ${message}`);
49
+ }
50
+
51
+ async function projectUsage(queryRows) {
52
+ const tables = await queryRows("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'chat_turn'");
53
+ // history_v2.token_usage can be a context-size snapshot rather than billed
54
+ // usage. Do not synthesize input tokens from those scalar values.
55
+ if (!tables.length) throw error("unsupported database schema: chat_turn table is missing.");
56
+ const columns = new Set((await queryRows("PRAGMA table_info(chat_turn)")).map((row) => row.name));
57
+ if (!columns.has("context") || (!columns.has("created_at") && !columns.has("updated_at"))) {
58
+ throw error("unsupported chat_turn schema.");
59
+ }
60
+ const column = (name, fallback = "NULL") => columns.has(name) ? `"${name}"` : fallback;
61
+ const date = columns.has("created_at") && columns.has("updated_at")
62
+ ? 'COALESCE("created_at", "updated_at")' : column("created_at", column("updated_at"));
63
+ const modelPaths = [
64
+ "persist_user_message_context.model_info.display_model_name",
65
+ "persist_user_message_context.model_info.model_name",
66
+ "persist_user_message_context.model_info.config_name", "model_name", "model",
67
+ "agent_model", "selected_model.name", "selectedModel.name",
68
+ ];
69
+ const model = modelPaths.map((field) => `CASE WHEN json_type(context, '$.${field}') = 'text'
70
+ THEN NULLIF(NULLIF(TRIM(json_extract(context, '$.${field}')), ''), '-') END`);
71
+ const fields = TOKEN_FIELDS.map((field) => `CASE
72
+ WHEN json_type(context, '$.token_usage.${field}') IN ('integer', 'real')
73
+ THEN json_extract(context, '$.token_usage.${field}') END AS "${field}"`);
74
+ const invalidFields = TOKEN_FIELDS.map((field) =>
75
+ `COALESCE(json_type(context, '$.token_usage.${field}') NOT IN ('integer', 'real', 'null'), 0)`);
76
+ // Project inside SQLite: prompts, messages, session titles, fee/account data,
77
+ // and the rest of the context never cross into JS results or cursor state.
78
+ const query = `SELECT CAST(${column("id", "rowid")} AS TEXT) AS id,
79
+ ${column("session_id")} AS session_id, ${column("turn_id")} AS turn_id,
80
+ ${date} AS created_at, ${column("updated_at")} AS updated_at,
81
+ COALESCE(${model.join(", ")}, 'trae-unknown') AS model,
82
+ ${fields.join(", ")}, (${invalidFields.join(" OR ")}) AS invalid_usage
83
+ FROM chat_turn WHERE CASE WHEN json_valid(context)
84
+ THEN json_type(context, '$.token_usage') = 'object' ELSE 0 END`;
85
+ return (await queryRows(query)).map((row) => {
86
+ const usage = {};
87
+ let invalid = Boolean(row.invalid_usage);
88
+ for (const field of TOKEN_FIELDS) {
89
+ const value = row[field];
90
+ if (value == null) continue;
91
+ if (!Number.isSafeInteger(value) || value < 0) invalid = true;
92
+ else usage[field] = value;
93
+ }
94
+ return {
95
+ id: row.id, session_id: row.session_id, turn_id: row.turn_id,
96
+ created_at: row.created_at, updated_at: row.updated_at,
97
+ model: row.model, usage: invalid ? null : usage,
98
+ };
99
+ });
100
+ }
101
+
102
+ async function readTraeUsageRows(dbPath, { env = process.env } = {}) {
103
+ for (let attempt = 0; attempt < 3; attempt++) {
104
+ let snapshot;
105
+ try { snapshot = openTraeSnapshot(dbPath, env); }
106
+ catch (err) {
107
+ // A writer can also change the files while the snapshot opens. Retry
108
+ // that too, and report the specific failure once attempts run out.
109
+ if (err?.transient && attempt < 2) continue;
110
+ throw err;
111
+ }
112
+ try {
113
+ let result;
114
+ try { result = await withTraeSqlite(snapshot, projectUsage); }
115
+ catch (err) {
116
+ // A writer may have changed pages while SQLite was reading. Discard
117
+ // every projected row and retry; never publish a mixed snapshot.
118
+ if (snapshot.isStable()) throw err;
119
+ continue;
120
+ }
121
+ if (snapshot.isStable()) return result;
122
+ } finally { snapshot.close(); }
123
+ }
124
+ throw error("database changed during the read; retry after TRAE finishes its current turn.");
125
+ }
126
+
127
+ module.exports = { resolveTraeDbPaths, readTraeUsageRows };
@@ -0,0 +1,230 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const crypto = require("node:crypto");
5
+
6
+ const SQLITE_HEADER = Buffer.from("SQLite format 3\0");
7
+ const PAGE_SIZE = 4096;
8
+ const RESERVED = 80;
9
+ // TRAE's shared application key
10
+ const DEFAULT_SQLCIPHER_KEY = "3605f6691095a993f03d5009c918352ef5be31ae31e8f000212b81ff058da773";
11
+ const CACHE_PAGES = 64;
12
+ const MAX_WAL_INDEX_PAGES = 1024 * 1024;
13
+
14
+ function error(message) { return new Error(`Cannot read TRAE usage: ${message}`); }
15
+ // A writer race rather than a bad store: the caller may retry immediately.
16
+ function transientError(message) { return Object.assign(error(message), { transient: true }); }
17
+
18
+ function statOptional(file) {
19
+ try { return fs.statSync(file, { bigint: true }); }
20
+ catch (err) { if (err.code === "ENOENT" || err.code === "ENOTDIR") return null; throw err; }
21
+ }
22
+
23
+ function sameStat(a, b) {
24
+ if (!a || !b) return a === b;
25
+ return a.size === b.size && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs && a.ino === b.ino;
26
+ }
27
+
28
+ function readExact(fd, target, offset) {
29
+ let read = 0;
30
+ while (read < target.length) {
31
+ const count = fs.readSync(fd, target, read, target.length - read, offset + read);
32
+ if (!count) throw transientError("database or WAL was truncated during the read; retry when TRAE is idle.");
33
+ read += count;
34
+ }
35
+ return target;
36
+ }
37
+
38
+ function pageDecryptor(firstPage, env) {
39
+ const hex = String(env.TOKENTRACKER_TRAE_SQLCIPHER_KEY || "").trim() || DEFAULT_SQLCIPHER_KEY;
40
+ if (!/^[0-9a-f]{64}$/i.test(hex)) throw error("TOKENTRACKER_TRAE_SQLCIPHER_KEY must contain 64 hex characters.");
41
+ const key = Buffer.from(hex, "hex");
42
+ const salt = Buffer.from(firstPage.subarray(0, 16).map((byte) => byte ^ 0x3a));
43
+ // SQLCipher 4 raw-key format: AES-256-CBC, 4096-byte pages, SHA-512 HMAC.
44
+ // https://github.com/sqlcipher/sqlcipher/blob/master/src/sqlcipher.c
45
+ const hmacKey = crypto.pbkdf2Sync(key, salt, 2, 32, "sha512");
46
+ salt.fill(0);
47
+ return {
48
+ decrypt(page, number) {
49
+ const start = number === 1 ? 16 : 0;
50
+ const ivOffset = PAGE_SIZE - RESERVED;
51
+ const pageId = Buffer.alloc(4);
52
+ pageId.writeUInt32LE(number);
53
+ const expected = crypto.createHmac("sha512", hmacKey)
54
+ .update(page.subarray(start, ivOffset + 16)).update(pageId).digest();
55
+ if (!crypto.timingSafeEqual(expected, page.subarray(ivOffset + 16))) {
56
+ throw error("SQLCipher authentication failed (incorrect key, unsupported format, or damaged database); set TOKENTRACKER_TRAE_SQLCIPHER_KEY to override the shared application key.");
57
+ }
58
+ const decipher = crypto.createDecipheriv("aes-256-cbc", key, page.subarray(ivOffset, ivOffset + 16));
59
+ decipher.setAutoPadding(false);
60
+ const decoded = decipher.update(page.subarray(start, ivOffset));
61
+ decipher.final();
62
+ page.fill(0);
63
+ if (number === 1) SQLITE_HEADER.copy(page);
64
+ decoded.copy(page, start);
65
+ decoded.fill(0);
66
+ if (number === 1 && (page.readUInt16BE(16) !== PAGE_SIZE || page[20] !== RESERVED)) {
67
+ throw error("unsupported SQLCipher page format.");
68
+ }
69
+ return page;
70
+ },
71
+ dispose() { key.fill(0); hmacKey.fill(0); },
72
+ };
73
+ }
74
+
75
+ function checksum(data, bigEndian, state = [0, 0]) {
76
+ let [a, b] = state;
77
+ for (let i = 0; i < data.length; i += 8) {
78
+ const x = bigEndian ? data.readUInt32BE(i) : data.readUInt32LE(i);
79
+ const y = bigEndian ? data.readUInt32BE(i + 4) : data.readUInt32LE(i + 4);
80
+ a = (a + x + b) >>> 0;
81
+ b = (b + y + a) >>> 0;
82
+ }
83
+ return [a, b];
84
+ }
85
+
86
+ function indexWal(fd, size, pageSize) {
87
+ const pages = new Map();
88
+ if (fd == null || size === 0) return { pages, pageCount: null };
89
+ if (size < 32) throw transientError("incomplete WAL header; retry when TRAE is idle.");
90
+ const header = readExact(fd, Buffer.alloc(32), 0);
91
+ const magic = header.readUInt32BE(0);
92
+ if ((magic !== 0x377f0682 && magic !== 0x377f0683) ||
93
+ header.readUInt32BE(4) !== 3007000 || header.readUInt32BE(8) !== pageSize) {
94
+ throw error("unsupported WAL format.");
95
+ }
96
+ const bigEndian = magic === 0x377f0683;
97
+ let sum = checksum(header.subarray(0, 24), bigEndian);
98
+ if (sum[0] !== header.readUInt32BE(24) || sum[1] !== header.readUInt32BE(28)) throw error("WAL header checksum failed.");
99
+ let pageCount = null;
100
+ let pending = new Map();
101
+ const frame = Buffer.alloc(24 + pageSize);
102
+ // Index offsets instead of buffering/decrypting an entire WAL. Uncommitted
103
+ // frames never enter the visible page map. SQLite checksums cover ciphertext.
104
+ // https://www.sqlite.org/fileformat2.html#walformat
105
+ for (let offset = 32; offset + frame.length <= size; offset += frame.length) {
106
+ readExact(fd, frame, offset);
107
+ if (!frame.subarray(8, 16).equals(header.subarray(16, 24))) break;
108
+ // Like a salt mismatch, the first checksum mismatch ends the valid log (a
109
+ // torn append or crash leftover); only committed frames before it count.
110
+ sum = checksum(frame.subarray(24), bigEndian, checksum(frame.subarray(0, 8), bigEndian, sum));
111
+ if (sum[0] !== frame.readUInt32BE(16) || sum[1] !== frame.readUInt32BE(20)) break;
112
+ const number = frame.readUInt32BE(0);
113
+ const commit = frame.readUInt32BE(4);
114
+ if (!number || number === 0xffffffff || commit === 0xffffffff) throw error("invalid WAL page number or database size.");
115
+ pending.set(number, offset + 24);
116
+ if (pending.size + pages.size > MAX_WAL_INDEX_PAGES) throw error("WAL index exceeds the bounded-memory limit; let TRAE checkpoint its WAL and retry.");
117
+ if (commit) {
118
+ for (const [page, position] of pending) pages.set(page, position);
119
+ pending = new Map();
120
+ pageCount = commit;
121
+ for (const page of pages.keys()) if (page > commit) pages.delete(page);
122
+ }
123
+ }
124
+ frame.fill(0);
125
+ return { pages, pageCount };
126
+ }
127
+
128
+ // A read-only, optimistic snapshot. SQLite reads authenticated pages on demand;
129
+ // no decrypted database is ever written to disk or materialized in memory.
130
+ // The caller must discard results if isStable() fails after the SQL query.
131
+ function openTraeSnapshot(dbPath, env = process.env) {
132
+ const paths = [dbPath, `${dbPath}-wal`, `${dbPath}-journal`];
133
+ const before = paths.map(statOptional);
134
+ if (!before[0]) throw error("database no longer exists.");
135
+ if (before.some((stat) => stat && (!stat.isFile() || stat.size > BigInt(Number.MAX_SAFE_INTEGER)))) {
136
+ throw error("unsupported database file or size.");
137
+ }
138
+ const handles = [];
139
+ const cache = new Map();
140
+ let decryptor;
141
+ const close = () => {
142
+ for (const page of cache.values()) page.fill(0);
143
+ cache.clear();
144
+ if (decryptor) { decryptor.dispose(); decryptor = null; }
145
+ let failure;
146
+ while (handles.length) {
147
+ try { fs.closeSync(handles.pop()); }
148
+ catch (err) { failure ||= err; }
149
+ }
150
+ if (failure) throw failure;
151
+ };
152
+ try {
153
+ const dbFd = fs.openSync(dbPath, "r");
154
+ handles.push(dbFd);
155
+ if (!sameStat(before[0], fs.fstatSync(dbFd, { bigint: true }))) throw transientError("database changed while opening; retry when TRAE is idle.");
156
+ let walFd = null;
157
+ if (before[1]) {
158
+ walFd = fs.openSync(paths[1], "r");
159
+ handles.push(walFd);
160
+ if (!sameStat(before[1], fs.fstatSync(walFd, { bigint: true }))) throw transientError("WAL changed while opening; retry when TRAE is idle.");
161
+ }
162
+ if (before[2] && before[2].size >= 8n) {
163
+ const journalFd = fs.openSync(paths[2], "r");
164
+ handles.push(journalFd);
165
+ if (readExact(journalFd, Buffer.alloc(8), 0).some((byte) => byte !== 0)) throw error("a rollback transaction is active; retry when TRAE is idle.");
166
+ }
167
+ const size = Number(before[0].size);
168
+ if (size < 100) throw error("database is truncated.");
169
+ const header = readExact(dbFd, Buffer.alloc(100), 0);
170
+ const encrypted = !header.subarray(0, 16).equals(SQLITE_HEADER);
171
+ const encodedSize = encrypted ? PAGE_SIZE : header.readUInt16BE(16);
172
+ const pageSize = encodedSize === 1 ? 65536 : encodedSize;
173
+ if (pageSize < 512 || pageSize > 65536 || (pageSize & (pageSize - 1)) || size % pageSize) throw error("invalid database page size or truncated database.");
174
+ if (encrypted) decryptor = pageDecryptor(header, env);
175
+ const { pages, pageCount } = indexWal(walFd, Number(before[1]?.size || 0n), pageSize);
176
+ const finalPageCount = pageCount == null ? size / pageSize : pageCount;
177
+ if (!finalPageCount || finalPageCount > 0xfffffffe) throw error("invalid database size.");
178
+ const byteLength = finalPageCount * pageSize;
179
+ const getPage = (number) => {
180
+ if (cache.has(number)) {
181
+ const page = cache.get(number);
182
+ cache.delete(number);
183
+ cache.set(number, page);
184
+ return page;
185
+ }
186
+ const page = Buffer.alloc(pageSize);
187
+ try {
188
+ if (pages.has(number)) readExact(walFd, page, pages.get(number));
189
+ else readExact(dbFd, page, (number - 1) * pageSize);
190
+ if (decryptor) decryptor.decrypt(page, number);
191
+ if (number === 1) {
192
+ // Present the committed view as a standalone rollback-mode database.
193
+ page[18] = 1;
194
+ page[19] = 1;
195
+ page.writeUInt32BE(finalPageCount, 28);
196
+ page.writeUInt32BE(page.readUInt32BE(24), 92);
197
+ }
198
+ } catch (err) { page.fill(0); throw err; }
199
+ cache.set(number, page);
200
+ if (cache.size > CACHE_PAGES) {
201
+ const oldest = cache.keys().next().value;
202
+ cache.get(oldest).fill(0);
203
+ cache.delete(oldest);
204
+ }
205
+ return page;
206
+ };
207
+ return {
208
+ byteLength,
209
+ read(target, offset) {
210
+ if (!Number.isSafeInteger(offset) || offset < 0) throw error("invalid read offset.");
211
+ target.fill(0);
212
+ const count = Math.min(target.length, Math.max(0, byteLength - offset));
213
+ let copied = 0;
214
+ while (copied < count) {
215
+ const position = offset + copied;
216
+ const pageOffset = position % pageSize;
217
+ const length = Math.min(count - copied, pageSize - pageOffset);
218
+ const page = getPage(Math.floor(position / pageSize) + 1);
219
+ target.set(page.subarray(pageOffset, pageOffset + length), copied);
220
+ copied += length;
221
+ }
222
+ return count;
223
+ },
224
+ isStable() { return paths.map(statOptional).every((stat, i) => sameStat(before[i], stat)); },
225
+ close,
226
+ };
227
+ } catch (err) { close(); throw err; }
228
+ }
229
+
230
+ module.exports = { openTraeSnapshot };