tokentracker-cli 1.1.3 → 1.1.5

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 (120) 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 +20 -4
  5. package/README.zh-CN.md +5 -4
  6. package/dashboard/dist/assets/{AchievementBadge-dt_UDU_h.js → AchievementBadge-ClUmGxBJ.js} +1 -1
  7. package/dashboard/dist/assets/{AchievementsPage-B5DJ3S9l.js → AchievementsPage-BB0DFE3m.js} +2 -2
  8. package/dashboard/dist/assets/{AchievementsSection-CDxcyig2.js → AchievementsSection-BMCSk63s.js} +1 -1
  9. package/dashboard/dist/assets/{BadgeDetailModal-Cp98zNn2.js → BadgeDetailModal-DzJtvuKO.js} +1 -1
  10. package/dashboard/dist/assets/{BotAnimated-CR7MiUpo.js → BotAnimated-BviEasW4.js} +1 -1
  11. package/dashboard/dist/assets/{Card-C8IdDvlg.js → Card-BoHr-wLc.js} +1 -1
  12. package/dashboard/dist/assets/{ClawdAnimated-COeTlW8z.js → ClawdAnimated-Bwm8BfqY.js} +2 -2
  13. package/dashboard/dist/assets/{CommandPalette-Co9k2CCc.js → CommandPalette-Rb0Gfcg9.js} +1 -1
  14. package/dashboard/dist/assets/{CommunityStatsModal-C6XQeRgw.js → CommunityStatsModal-Ck7R7Q-d.js} +1 -1
  15. package/dashboard/dist/assets/{ConfirmModal-Ai4182O-.js → ConfirmModal-52Fu9wOi.js} +1 -1
  16. package/dashboard/dist/assets/{Controls-BbKzN-Pe.js → Controls-YlGr187A.js} +1 -1
  17. package/dashboard/dist/assets/DashboardPage-BRim2Rt6.js +1 -0
  18. package/dashboard/dist/assets/{DevicePage-CZbrtJmb.js → DevicePage-3mmhzfgd.js} +1 -1
  19. package/dashboard/dist/assets/{DialogClose-DMl8tAoX.js → DialogClose-D1KfBYUS.js} +1 -1
  20. package/dashboard/dist/assets/DialogDescription-BhWwQSSg.js +1 -0
  21. package/dashboard/dist/assets/{DialogTitle-BJxpp2t-.js → DialogTitle-DKye2nJK.js} +1 -1
  22. package/dashboard/dist/assets/{FadeIn-hU_MXl6R.js → FadeIn-mJp_ZFst.js} +1 -1
  23. package/dashboard/dist/assets/{HeaderGithubStar-CM5RSxxN.js → HeaderGithubStar-B2R_aqp9.js} +1 -1
  24. package/dashboard/dist/assets/{HoverTooltip-BZKfQyZQ.js → HoverTooltip-ElY02914.js} +1 -1
  25. package/dashboard/dist/assets/{IpCheckPage-BjDx-zja.js → IpCheckPage-CNzPUe3x.js} +1 -1
  26. package/dashboard/dist/assets/{LandingPage-CAt8BCDv.js → LandingPage-L25-CxXg.js} +2 -2
  27. package/dashboard/dist/assets/{LeaderboardAvatar-DiCrakjI.js → LeaderboardAvatar-BT7x5wAP.js} +1 -1
  28. package/dashboard/dist/assets/{LeaderboardPage-Dj3pHv6U.js → LeaderboardPage-bPce9gOU.js} +2 -2
  29. package/dashboard/dist/assets/{LeaderboardProfileModal-DEWbcP9p.js → LeaderboardProfileModal-DPrFGAp6.js} +3 -3
  30. package/dashboard/dist/assets/{LeaderboardProfilePage-CTbWHQkY.js → LeaderboardProfilePage-j2D1pwYO.js} +1 -1
  31. package/dashboard/dist/assets/{LimitsPage-V-98OUhF.js → LimitsPage-CSED1kBx.js} +1 -1
  32. package/dashboard/dist/assets/{LocalOnlyNotice-a4PqtUZY.js → LocalOnlyNotice-D6OERWez.js} +1 -1
  33. package/dashboard/dist/assets/{LoginCard-oD1A2Ru8.js → LoginCard-HgmRdIn8.js} +1 -1
  34. package/dashboard/dist/assets/{LoginModal-D8aSmxok.js → LoginModal-BSlEmKOV.js} +1 -1
  35. package/dashboard/dist/assets/{LoginPage-DIjhBv2X.js → LoginPage-HQAhowvh.js} +1 -1
  36. package/dashboard/dist/assets/{PetPage-D7601BTX.js → PetPage-DnbYfu-z.js} +1 -1
  37. package/dashboard/dist/assets/{PopoverPopup-aPnmKopO.js → PopoverPopup-DnyVdfB8.js} +1 -1
  38. package/dashboard/dist/assets/{ProviderIcon-rwyfcNlT.js → ProviderIcon-D8kgkZlR.js} +1 -1
  39. package/dashboard/dist/assets/{ResetPasswordPage-AF3SBgW2.js → ResetPasswordPage-ChtXGykP.js} +1 -1
  40. package/dashboard/dist/assets/{SegmentedControl-fnlScNMT.js → SegmentedControl-dHUpXqFk.js} +1 -1
  41. package/dashboard/dist/assets/{Select-DDjC3RjB.js → Select-C0tpjNiq.js} +1 -1
  42. package/dashboard/dist/assets/{SelectItemText-VhBWE2O8.js → SelectItemText-C3G_w14k.js} +1 -1
  43. package/dashboard/dist/assets/{ServiceStatusPage-BL3qqEsp.js → ServiceStatusPage-D6x4e1r8.js} +1 -1
  44. package/dashboard/dist/assets/{SessionsPage-DXYiuP2x.js → SessionsPage-DZH1QMWd.js} +1 -1
  45. package/dashboard/dist/assets/{SettingsPage-DTeA_ahr.js → SettingsPage-DGmDb2R4.js} +2 -2
  46. package/dashboard/dist/assets/{SkillsPage-CtTRwVQH.js → SkillsPage-ChloCHPa.js} +1 -1
  47. package/dashboard/dist/assets/{TokenGalaxy-CifK91V7.js → TokenGalaxy-DOC9wlkv.js} +1 -1
  48. package/dashboard/dist/assets/{ToolbarRootContext-ClGAIYoa.js → ToolbarRootContext-BB_d-qdY.js} +1 -1
  49. package/dashboard/dist/assets/{TrendMonitor-D1evFHRM.js → TrendMonitor-DVVzYdc7.js} +1 -1
  50. package/dashboard/dist/assets/{WidgetsPage-Cq4RYJDE.js → WidgetsPage-DHVSyU0i.js} +1 -1
  51. package/dashboard/dist/assets/{WrappedPage-CoC5RJH7.js → WrappedPage-BDi28nRt.js} +1 -1
  52. package/dashboard/dist/assets/__vite-browser-external-Bnc5aBhq.js +45 -0
  53. package/dashboard/dist/assets/{agent-logos-QLdA9OeA.js → agent-logos-BiGvZiLq.js} +1 -1
  54. package/dashboard/dist/assets/{arrow-up-right-BYLgB3bH.js → arrow-up-right-SvD82rok.js} +1 -1
  55. package/dashboard/dist/assets/{check-DfnIvqZD.js → check-jL5XErEW.js} +1 -1
  56. package/dashboard/dist/assets/{composite--JllTpIX.js → composite-DM6geXPA.js} +1 -1
  57. package/dashboard/dist/assets/{copy-ClKVBIgD.js → copy-Dy_GIzj2.js} +1 -1
  58. package/dashboard/dist/assets/{download-Di1-UFJX.js → download-BW_FS7nX.js} +1 -1
  59. package/dashboard/dist/assets/{external-link-D7zPVFLO.js → external-link-2RIovvCT.js} +1 -1
  60. package/dashboard/dist/assets/{flame-BMJTxjVg.js → flame-CIRJIxxA.js} +1 -1
  61. package/dashboard/dist/assets/{format-tokens-DCZM5Nfn.js → format-tokens-CT8ahEK4.js} +1 -1
  62. package/dashboard/dist/assets/{icons-I615W2IU.js → icons-7J1WD0C-.js} +1 -1
  63. package/dashboard/dist/assets/{index-BX3CXVdD.js → index-D3iSSyYr.js} +1 -1
  64. package/dashboard/dist/assets/{index-CwczM3BZ.js → index-Ddb2A6Mx.js} +1 -1
  65. package/dashboard/dist/assets/{index-BejnmdPe.js → index-_vJl6sF7.js} +1 -1
  66. package/dashboard/dist/assets/{info-CFJPny5Q.js → info-BjVWcOOC.js} +1 -1
  67. package/dashboard/dist/assets/{link-2-D1ydWh_w.js → link-2-CyRWnHWE.js} +1 -1
  68. package/dashboard/dist/assets/{loader-circle-Cq-J-OFg.js → loader-circle-DUpG5zah.js} +1 -1
  69. package/dashboard/dist/assets/{maximize-2-kIoSq57I.js → maximize-2-BVGCRkFZ.js} +1 -1
  70. package/dashboard/dist/assets/{modifiers.esm-CLojthuF.js → modifiers.esm-CZI7rED4.js} +1 -1
  71. package/dashboard/dist/assets/module-WSx8viXK.js +3 -0
  72. package/dashboard/dist/assets/{monitor-smartphone-DX2qWHYV.js → monitor-smartphone-DfBtd01T.js} +1 -1
  73. package/dashboard/dist/assets/{plus-B-JEpo3V.js → plus-CvbLCvjV.js} +1 -1
  74. package/dashboard/dist/assets/{provider-display-DJDdrTAS.js → provider-display-DC46GLrv.js} +2 -2
  75. package/dashboard/dist/assets/{react-Ctoe8iLl.js → react-mYr3J8Vw.js} +1 -1
  76. package/dashboard/dist/assets/{search-BTSQrlW-.js → search-CFaS1rSj.js} +1 -1
  77. package/dashboard/dist/assets/{skills-api-DKQyM8h7.js → skills-api-B1N6seFa.js} +1 -1
  78. package/dashboard/dist/assets/{sortable.esm-pOEL13xU.js → sortable.esm-BE2GiQaC.js} +1 -1
  79. package/dashboard/dist/assets/{store-B2RplzvW.js → store-DwBbExah.js} +1 -1
  80. package/dashboard/dist/assets/{terminal-BH_6_EW_.js → terminal-Bl2BuO9x.js} +1 -1
  81. package/dashboard/dist/assets/{trash-CC444Mz6.js → trash-D8beb27Y.js} +1 -1
  82. package/dashboard/dist/assets/{use-community-stats-CBXLdD_j.js → use-community-stats-DJG1DsXR.js} +1 -1
  83. package/dashboard/dist/assets/{use-latest-request-guard-DMuPfZQI.js → use-latest-request-guard-DYK8iyOM.js} +1 -1
  84. package/dashboard/dist/assets/{use-limits-display-prefs-CmsGTBA4.js → use-limits-display-prefs-BGmcFVX3.js} +1 -1
  85. package/dashboard/dist/assets/{use-ordered-list-BNJNpXjB.js → use-ordered-list-Cqia_VIq.js} +1 -1
  86. package/dashboard/dist/assets/use-reduced-motion-CmCk9Sb5.js +1 -0
  87. package/dashboard/dist/assets/{use-session-efficiency-pref-g_0ISxe1.js → use-session-efficiency-pref-Mj3OeVGZ.js} +1 -1
  88. package/dashboard/dist/assets/{use-transform-D36BHcXk.js → use-transform-D4BQIBHx.js} +1 -1
  89. package/dashboard/dist/assets/{use-usage-limits-Dxw3vzsD.js → use-usage-limits-BFrO7Mtq.js} +1 -1
  90. package/dashboard/dist/assets/{useCurrency-V2FE4TMo.js → useCurrency-CYJavR2h.js} +1 -1
  91. package/dashboard/dist/assets/{useTokenFormat-CEOwvzMt.js → useTokenFormat-CKruI7SP.js} +1 -1
  92. package/dashboard/dist/assets/{zap-BS7ZU76I.js → zap-a82fG76c.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 +66 -11
  102. package/src/commands/sync.js +115 -11
  103. package/src/lib/codex-context-breakdown.js +3 -1
  104. package/src/lib/diagnostics.js +20 -1
  105. package/src/lib/doctor.js +30 -0
  106. package/src/lib/pricing/curated-overrides.json +7 -0
  107. package/src/lib/pricing/matcher.js +5 -0
  108. package/src/lib/pricing/seed-snapshot.json +1 -1
  109. package/src/lib/rollout.js +197 -18
  110. package/src/lib/scan-roots.js +277 -0
  111. package/src/lib/session-analytics.js +98 -16
  112. package/src/lib/trae-db.js +127 -0
  113. package/src/lib/trae-snapshot.js +230 -0
  114. package/src/lib/trae-sqlite.js +143 -0
  115. package/src/lib/trae-usage.js +121 -0
  116. package/dashboard/dist/assets/DashboardPage-DTcVGG4e.js +0 -1
  117. package/dashboard/dist/assets/DialogDescription-haPz7CX9.js +0 -1
  118. package/dashboard/dist/assets/__vite-browser-external-BDugA6I0.js +0 -45
  119. package/dashboard/dist/assets/module-DtWfRKM4.js +0 -3
  120. package/dashboard/dist/assets/use-reduced-motion-CNpG-Yoi.js +0 -1
@@ -33,6 +33,14 @@ const { parseCodexRolloutFile } = require("./codex-rollout-parser");
33
33
  const { computeRowCost, getModelPricing } = require("./pricing");
34
34
  const { USD_TICKS_PER_USD, normalizeGrokUsage } = require("./grok-usage");
35
35
  const wsl = require("./wsl-probe");
36
+ const {
37
+ appendUniqueDirs,
38
+ dedupeDirsByRealpath,
39
+ expandHome,
40
+ loadScanRootsConfig,
41
+ normalizeScanRootsConfig,
42
+ scanRootDirState,
43
+ } = require("./scan-roots");
36
44
 
37
45
  // Bump the sidecar when derived metrics change so cached rows are rebuilt
38
46
  // instead of leaving the dashboard on the previous (over-counted) heuristic.
@@ -1074,8 +1082,11 @@ function providerRoots(home, providerDir, env, deps = {}) {
1074
1082
  && path.resolve(home) === path.resolve(homedir())
1075
1083
  && typeof env?.CODEX_HOME === "string"
1076
1084
  && env.CODEX_HOME.trim();
1085
+ // Same normalization as sync / the cursor store (scan-roots.expandHome):
1086
+ // a relative CODEX_HOME is anchored to home, never to process.cwd(), so
1087
+ // the browser walks the exact root the cursors were keyed under.
1077
1088
  const nativeRoot = useProcessCodexHome
1078
- ? path.resolve(env.CODEX_HOME.trim())
1089
+ ? expandHome(env.CODEX_HOME, home)
1079
1090
  : path.join(home, providerDir);
1080
1091
  roots.push(nativeRoot);
1081
1092
  }
@@ -1095,7 +1106,21 @@ function providerRoots(home, providerDir, env, deps = {}) {
1095
1106
  const wslRoot = discoverWslHome(providerDir, { env });
1096
1107
  if (wslRoot) roots.push(wslRoot);
1097
1108
  }
1098
- return [...new Set(roots)];
1109
+ // Extra roots (#657), mirroring src/commands/sync.js: CLAUDE_CONFIG_DIR is
1110
+ // the spawning process's implicit Claude root (additive — ~/.claude stays
1111
+ // scanned), and config.scanRoots.<provider> arrives as deps.extraRoots from
1112
+ // discoverSessionFiles. Extras are realpath-deduped against the roots above
1113
+ // so one directory listed under two spellings is walked once.
1114
+ const extras = [];
1115
+ const useProcessClaudeConfigDir = providerDir === ".claude"
1116
+ && path.resolve(home) === path.resolve(homedir())
1117
+ && typeof env?.CLAUDE_CONFIG_DIR === "string"
1118
+ && env.CLAUDE_CONFIG_DIR.trim();
1119
+ if (useProcessClaudeConfigDir) extras.push(expandHome(env.CLAUDE_CONFIG_DIR, home));
1120
+ for (const extra of Array.isArray(deps.extraRoots) ? deps.extraRoots : []) {
1121
+ if (typeof extra === "string" && extra) extras.push(path.resolve(extra));
1122
+ }
1123
+ return appendUniqueDirs([...new Set(roots)], extras);
1099
1124
  }
1100
1125
 
1101
1126
  // Group one logical session discovered under more than one root. A group is
@@ -1221,10 +1246,37 @@ function groupCodexFiles(filePaths) {
1221
1246
 
1222
1247
  async function discoverSessionFiles(home, env = process.env, deps = {}) {
1223
1248
  const grokHome = resolveGrokHome(home);
1224
- const claudeRoots = providerRoots(home, ".claude", env, deps);
1225
- const codexRoots = providerRoots(home, ".codex", env, deps);
1249
+ // config.scanRoots (#657): read from the tracker config unless the caller
1250
+ // injects it (tests). A missing or malformed config yields no extras.
1251
+ const scanRootsConfig = deps.scanRootsConfig !== undefined
1252
+ ? normalizeScanRootsConfig(deps.scanRootsConfig)
1253
+ : await loadScanRootsConfig({ home });
1254
+ // expandHome resolves `~` and relative entries against `home` (never cwd);
1255
+ // only readable directories are walked, absence and stat errors alike.
1256
+ const extraRootsFor = (provider) => scanRootsConfig[provider]
1257
+ .map((root) => expandHome(root, home))
1258
+ .filter((root) => root && scanRootDirState(root).exists);
1259
+ const claudeRoots = providerRoots(home, ".claude", env, { ...deps, extraRoots: extraRootsFor("claude") });
1260
+ const codexRoots = providerRoots(home, ".codex", env, { ...deps, extraRoots: extraRootsFor("codex") });
1261
+ // Two distinct roots may share one projects/ dir through a symlink; dedupe
1262
+ // the derived projects dirs by realpath (as sync does) so the browser never
1263
+ // lists the same transcript twice under two spellings.
1264
+ const claudeProjectsDirs = dedupeDirsByRealpath(claudeRoots.map((r) => path.join(r, "projects")));
1265
+ // listClaudeProjectFiles / listRolloutFilesDeep turn a listing error into an
1266
+ // empty result, so a root whose projects/ (or sessions/) exists but cannot be
1267
+ // read would look like "no sessions" and get cached as such. Report those
1268
+ // directories as incomplete discovery instead; the caller decides not to
1269
+ // persist a partial inventory (sync defers its repair on the same state).
1270
+ const incomplete = [];
1271
+ for (const dir of [
1272
+ ...claudeProjectsDirs,
1273
+ ...codexRoots.map((r) => path.join(r, "sessions")),
1274
+ ]) {
1275
+ const state = scanRootDirState(dir);
1276
+ if (state.error !== null) incomplete.push({ path: dir, error: state.error });
1277
+ }
1226
1278
  const [claudeGroups, codexGroups, archivedGroups, grok] = await Promise.all([
1227
- Promise.all(claudeRoots.map((r) => listClaudeProjectFiles(path.join(r, "projects")))),
1279
+ Promise.all(claudeProjectsDirs.map((dir) => listClaudeProjectFiles(dir))),
1228
1280
  Promise.all(codexRoots.map((r) => listRolloutFilesDeep(path.join(r, "sessions")))),
1229
1281
  Promise.all(codexRoots.map((r) => listRolloutFilesDeep(path.join(r, "archived_sessions")))),
1230
1282
  listGrokSessionFiles(path.join(grokHome, "sessions")),
@@ -1239,7 +1291,20 @@ async function discoverSessionFiles(home, env = process.env, deps = {}) {
1239
1291
  const claude = allClaude.filter((filePaths) => !filePaths.some((filePath) => filePath
1240
1292
  .split(path.sep)
1241
1293
  .some((segment) => segment.endsWith(CLAUDE_MEM_OBSERVER_PROJECT_SUFFIX))));
1242
- return { claude, codex: groupCodexFiles([...codex, ...archived]), grok };
1294
+ return { claude, codex: groupCodexFiles([...codex, ...archived]), grok, incomplete };
1295
+ }
1296
+
1297
+ // Warn once per unreadable directory per process: the dashboard polls this
1298
+ // path, and a line per poll would flood the serve log.
1299
+ const warnedIncompleteDiscovery = new Set();
1300
+ function warnIncompleteDiscovery(incomplete) {
1301
+ for (const entry of incomplete) {
1302
+ if (warnedIncompleteDiscovery.has(entry.path)) continue;
1303
+ warnedIncompleteDiscovery.add(entry.path);
1304
+ if (!process.env.NODE_TEST_CONTEXT) {
1305
+ console.warn(`[session-analytics] session directory unreadable (${entry.error}), inventory incomplete: ${entry.path}`);
1306
+ }
1307
+ }
1243
1308
  }
1244
1309
 
1245
1310
  function filesSignature(files) {
@@ -1368,6 +1433,19 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
1368
1433
  } catch { /* first run */ }
1369
1434
  }
1370
1435
  const discovered = await discoverSessionFiles(home);
1436
+ // Incomplete discovery (an unreadable projects/ or sessions/ dir): never let
1437
+ // a partial inventory replace a complete snapshot. Serve the last complete
1438
+ // sidecar when there is one; otherwise build from what is readable but do
1439
+ // not persist it, so the next refresh tries again.
1440
+ const incompleteDirs = Array.isArray(discovered.incomplete) ? discovered.incomplete : [];
1441
+ if (incompleteDirs.length > 0) {
1442
+ warnIncompleteDiscovery(incompleteDirs);
1443
+ if (!force && previousMeta?.version === SIDECAR_VERSION) {
1444
+ const cached = readSidecar(sidecarPath);
1445
+ Object.defineProperty(cached, "incompleteDirs", { value: incompleteDirs, enumerable: false });
1446
+ return cached;
1447
+ }
1448
+ }
1371
1449
  // Codex thread titles are stored separately from rollout files. Include the
1372
1450
  // index in the overall signature so an index-only rename reaches the
1373
1451
  // per-file dependency check below on the next refresh. Grok titles/metadata
@@ -1437,19 +1515,22 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
1437
1515
  nextFiles[cacheKey] = { stat_key: statKey };
1438
1516
  }
1439
1517
  sessions.sort((a, b) => String(b.ended_at || "").localeCompare(String(a.ended_at || "")));
1440
- const content = sessions.map(serializeSessionRecord).join("\n") + (sessions.length ? "\n" : "");
1441
- await writeAtomic(sidecarPath, content);
1442
- const generatedAt = new Date().toISOString();
1443
- await writeAtomic(metaPath, `${JSON.stringify({
1444
- version: SIDECAR_VERSION,
1445
- signature,
1446
- generated_at: generatedAt,
1447
- checked_at: generatedAt,
1448
- files: nextFiles,
1449
- })}\n`);
1518
+ if (incompleteDirs.length === 0) {
1519
+ const content = sessions.map(serializeSessionRecord).join("\n") + (sessions.length ? "\n" : "");
1520
+ await writeAtomic(sidecarPath, content);
1521
+ const generatedAt = new Date().toISOString();
1522
+ await writeAtomic(metaPath, `${JSON.stringify({
1523
+ version: SIDECAR_VERSION,
1524
+ signature,
1525
+ generated_at: generatedAt,
1526
+ checked_at: generatedAt,
1527
+ files: nextFiles,
1528
+ })}\n`);
1529
+ }
1450
1530
  // Non-enumerable so the array still behaves exactly like a plain row list
1451
1531
  // for every existing caller (map/filter/JSON of the rows is unaffected).
1452
1532
  Object.defineProperty(sessions, "skippedFiles", { value: skippedFiles, enumerable: false });
1533
+ Object.defineProperty(sessions, "incompleteDirs", { value: incompleteDirs, enumerable: false });
1453
1534
  return sessions;
1454
1535
  }
1455
1536
 
@@ -2006,6 +2087,7 @@ module.exports = {
2006
2087
  resumeCommandFor,
2007
2088
  sessionsToCsv,
2008
2089
  providerRoots,
2090
+ discoverSessionFiles,
2009
2091
  dedupeClaudeFilesAcrossRoots,
2010
2092
  analyticsEntryStatKey,
2011
2093
  };
@@ -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 };
@@ -0,0 +1,143 @@
1
+ const fs = require("node:fs");
2
+
3
+ let enginePromise;
4
+ let queryQueue = Promise.resolve();
5
+
6
+ async function createEngine() {
7
+ const [{ default: initialize }, sqlite, { FacadeVFS }] = await Promise.all([
8
+ import("@journeyapps/wa-sqlite/dist/wa-sqlite.mjs"),
9
+ import("@journeyapps/wa-sqlite"),
10
+ import("@journeyapps/wa-sqlite/src/FacadeVFS.js"),
11
+ ]);
12
+ const module = await initialize({
13
+ wasmBinary: fs.readFileSync(require.resolve("@journeyapps/wa-sqlite/dist/wa-sqlite.wasm")),
14
+ });
15
+ const api = sqlite.Factory(module);
16
+ const snapshots = new Map();
17
+ const handles = new Map();
18
+
19
+ // Register one VFS for the lifetime of the engine. Each query gets a distinct
20
+ // virtual filename, without accumulating WASM callbacks or touching disk.
21
+ class TraeVFS extends FacadeVFS {
22
+ constructor() {
23
+ super("tokentracker-trae", module);
24
+ }
25
+
26
+ jOpen(name, fileId, flags, outputFlags) {
27
+ const state = snapshots.get(name);
28
+ if (!state || !(flags & sqlite.SQLITE_OPEN_MAIN_DB)) return sqlite.SQLITE_CANTOPEN;
29
+ handles.set(fileId, state);
30
+ outputFlags.setInt32(0, sqlite.SQLITE_OPEN_READONLY, true);
31
+ return sqlite.SQLITE_OK;
32
+ }
33
+
34
+ jRead(fileId, output, offset) {
35
+ const state = handles.get(fileId);
36
+ if (!state) return sqlite.SQLITE_IOERR_READ;
37
+ // FacadeVFS passes a proxy over WASM memory, not a native Uint8Array.
38
+ // Keep the temporary buffer small and erase it after copying its page.
39
+ const buffer = Buffer.alloc(output.byteLength);
40
+ try {
41
+ const count = state.snapshot.read(buffer, offset);
42
+ if (!Number.isSafeInteger(count) || count < 0 || count > buffer.length) {
43
+ throw new Error("Invalid TRAE snapshot read length.");
44
+ }
45
+ buffer.fill(0, count);
46
+ output.set(buffer);
47
+ return count === buffer.length ? sqlite.SQLITE_OK : sqlite.SQLITE_IOERR_SHORT_READ;
48
+ } catch (err) {
49
+ state.error ||= err;
50
+ output.fill(0);
51
+ return sqlite.SQLITE_IOERR_READ;
52
+ } finally {
53
+ buffer.fill(0);
54
+ }
55
+ }
56
+
57
+ jFileSize(fileId, output) {
58
+ const state = handles.get(fileId);
59
+ if (!state) return sqlite.SQLITE_IOERR_FSTAT;
60
+ output.setBigInt64(0, BigInt(state.snapshot.byteLength), true);
61
+ return sqlite.SQLITE_OK;
62
+ }
63
+
64
+ jClose(fileId) {
65
+ handles.delete(fileId);
66
+ return sqlite.SQLITE_OK;
67
+ }
68
+
69
+ jAccess(name, flags, output) {
70
+ output.setInt32(0, snapshots.has(name) ? 1 : 0, true);
71
+ return sqlite.SQLITE_OK;
72
+ }
73
+
74
+ jWrite() { return sqlite.SQLITE_READONLY; }
75
+ jTruncate() { return sqlite.SQLITE_READONLY; }
76
+ jDelete() { return sqlite.SQLITE_READONLY; }
77
+ }
78
+
79
+ const vfs = new TraeVFS();
80
+ api.vfs_register(vfs, false);
81
+ return { api, sqlite, snapshots, handles, vfs, nextId: 0 };
82
+ }
83
+
84
+ /**
85
+ * Query a read-only, random-access snapshot without materializing its database.
86
+ * The caller owns snapshot cleanup; only the projected SQL rows leave this VFS.
87
+ */
88
+ async function querySnapshot(snapshot, callback) {
89
+ if (!Number.isSafeInteger(snapshot.byteLength) || snapshot.byteLength < 0) {
90
+ throw new Error("Invalid TRAE snapshot size.");
91
+ }
92
+ enginePromise ||= createEngine().catch((err) => {
93
+ enginePromise = undefined;
94
+ throw err;
95
+ });
96
+ const engine = await enginePromise;
97
+ const { api, sqlite, snapshots, handles, vfs } = engine;
98
+ const name = `trae-${++engine.nextId}.db`;
99
+ const state = { snapshot, error: null };
100
+ snapshots.set(name, state);
101
+ let db;
102
+ let failure;
103
+ try {
104
+ db = await api.open_v2(name, sqlite.SQLITE_OPEN_READONLY, vfs.name);
105
+ const query = async (sql) => {
106
+ const rows = [];
107
+ try {
108
+ await api.exec(db, sql, (values, columns) => {
109
+ rows.push(Object.fromEntries(columns.map((column, index) => [column, values[index]])));
110
+ });
111
+ } catch (err) {
112
+ throw state.error || err;
113
+ }
114
+ return rows;
115
+ };
116
+ await query("PRAGMA query_only=ON; PRAGMA cache_size=-4096; PRAGMA temp_store=MEMORY;");
117
+ return await callback(query);
118
+ } catch (err) {
119
+ failure = state.error || err;
120
+ throw failure;
121
+ } finally {
122
+ try {
123
+ if (db) await api.close(db);
124
+ } catch (err) {
125
+ if (!failure) throw state.error || err;
126
+ } finally {
127
+ snapshots.delete(name);
128
+ for (const [fileId, opened] of handles) {
129
+ if (opened === state) handles.delete(fileId);
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ function withTraeSqlite(snapshot, callback) {
136
+ // The package's async convenience API shares temporary WASM allocations.
137
+ // Serialize access to the single engine even when callers read in parallel.
138
+ const operation = queryQueue.then(() => querySnapshot(snapshot, callback));
139
+ queryQueue = operation.catch(() => {});
140
+ return operation;
141
+ }
142
+
143
+ module.exports = { withTraeSqlite };