tokentracker-cli 0.94.1 → 0.95.0

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 (126) hide show
  1. package/README.de.md +9 -7
  2. package/README.ja.md +8 -6
  3. package/README.ko.md +8 -6
  4. package/README.md +23 -10
  5. package/README.zh-CN.md +8 -6
  6. package/dashboard/dist/assets/{AchievementBadge-DnmgEa4q.js → AchievementBadge-Pi3Gvbm8.js} +1 -1
  7. package/dashboard/dist/assets/{AchievementsPage-B7YTbAuK.js → AchievementsPage-C4WLT8Rl.js} +2 -2
  8. package/dashboard/dist/assets/{AchievementsSection-CwgT5sqH.js → AchievementsSection-ilvR2ssr.js} +1 -1
  9. package/dashboard/dist/assets/{BadgeDetailModal-Dets6jzW.js → BadgeDetailModal-CjEeToxa.js} +1 -1
  10. package/dashboard/dist/assets/{BotAnimated-5KwUvC9F.js → BotAnimated-DDf9pBuK.js} +1 -1
  11. package/dashboard/dist/assets/{Card-jf9w-4-N.js → Card-DNous84-.js} +1 -1
  12. package/dashboard/dist/assets/{ClawdAnimated-CGxDVQ0c.js → ClawdAnimated-BLMIRntb.js} +2 -2
  13. package/dashboard/dist/assets/{CommandPalette-B1bUUf32.js → CommandPalette-vVekhoFO.js} +1 -1
  14. package/dashboard/dist/assets/CommunityStatsModal-C0Kp_J8B.js +1 -0
  15. package/dashboard/dist/assets/{ConfirmModal-BR-aIRH9.js → ConfirmModal-DFdpcx12.js} +1 -1
  16. package/dashboard/dist/assets/{Controls-CbHsJAqV.js → Controls-RNhqybmE.js} +1 -1
  17. package/dashboard/dist/assets/DashboardPage-BQjQh1IQ.js +1 -0
  18. package/dashboard/dist/assets/{DevicePage-DDlicLLn.js → DevicePage-DIhh-YAk.js} +1 -1
  19. package/dashboard/dist/assets/{DialogClose-B89uLXqp.js → DialogClose-ZqxYVIzl.js} +1 -1
  20. package/dashboard/dist/assets/DialogDescription-DDM4aanX.js +1 -0
  21. package/dashboard/dist/assets/{DialogTitle-jOyycYKF.js → DialogTitle-BiRcejlu.js} +1 -1
  22. package/dashboard/dist/assets/{FadeIn-BEND42ze.js → FadeIn-BDBQpeGe.js} +1 -1
  23. package/dashboard/dist/assets/{HeaderGithubStar-Dacl1WGS.js → HeaderGithubStar-CztIwtKT.js} +1 -1
  24. package/dashboard/dist/assets/{HoverTooltip-B2Mh2LTi.js → HoverTooltip-Cfso7LTC.js} +1 -1
  25. package/dashboard/dist/assets/{IpCheckPage-CPEuRhiU.js → IpCheckPage-CNE7dLs3.js} +1 -1
  26. package/dashboard/dist/assets/{LandingPage-BYWNR_dx.js → LandingPage-B2a3cweM.js} +2 -2
  27. package/dashboard/dist/assets/{LeaderboardAvatar-B9_5LDVj.js → LeaderboardAvatar-CwmZnIji.js} +1 -1
  28. package/dashboard/dist/assets/{LeaderboardPage-_Hl1NaIa.js → LeaderboardPage-DkTHj3fB.js} +2 -2
  29. package/dashboard/dist/assets/{LeaderboardProfileModal-Cydp7CyA.js → LeaderboardProfileModal-CefTO3Zh.js} +3 -3
  30. package/dashboard/dist/assets/{LeaderboardProfilePage-Dz0ecujs.js → LeaderboardProfilePage-CnOXlQ3L.js} +1 -1
  31. package/dashboard/dist/assets/{LimitsPage-CmdT2qOZ.js → LimitsPage-BIZ1-6BB.js} +2 -2
  32. package/dashboard/dist/assets/{LocalOnlyNotice-DhuPGUkw.js → LocalOnlyNotice-CdHQiPhy.js} +1 -1
  33. package/dashboard/dist/assets/{LoginCard-DQtHaX_5.js → LoginCard-yXLA9geu.js} +1 -1
  34. package/dashboard/dist/assets/{LoginModal-DvpPRnYV.js → LoginModal-BF2Pfkqa.js} +1 -1
  35. package/dashboard/dist/assets/{LoginPage-DOjfmdwb.js → LoginPage-DuOypZap.js} +1 -1
  36. package/dashboard/dist/assets/{PetPage-Dd6U9fh2.js → PetPage-BH3xDVV9.js} +1 -1
  37. package/dashboard/dist/assets/{PopoverPopup-B1V01u10.js → PopoverPopup-CewF_cGz.js} +1 -1
  38. package/dashboard/dist/assets/{ProviderIcon-BLRe55c7.js → ProviderIcon-Dg0K0RPn.js} +1 -1
  39. package/dashboard/dist/assets/{ResetPasswordPage-BJ95vy1S.js → ResetPasswordPage-CYwt85O3.js} +1 -1
  40. package/dashboard/dist/assets/{SegmentedControl-DacS_m9M.js → SegmentedControl-BJkXXomQ.js} +1 -1
  41. package/dashboard/dist/assets/{Select-bQDO5BEA.js → Select-B3_WH0Qe.js} +1 -1
  42. package/dashboard/dist/assets/{SelectItemText-CUGrs9CY.js → SelectItemText-BUby_DUu.js} +1 -1
  43. package/dashboard/dist/assets/{ServiceStatusPage-CsERvmVC.js → ServiceStatusPage-BDlVZk4i.js} +1 -1
  44. package/dashboard/dist/assets/{SessionsPage-0VvqEfPx.js → SessionsPage-TaC9nahK.js} +1 -1
  45. package/dashboard/dist/assets/{SettingsPage-9EHh2HfD.js → SettingsPage-CudcQh6m.js} +2 -2
  46. package/dashboard/dist/assets/SkillsPage-iDLQ8lpZ.js +1 -0
  47. package/dashboard/dist/assets/{TokenGalaxy-DSKTHQSs.js → TokenGalaxy-DtYzUsXU.js} +1 -1
  48. package/dashboard/dist/assets/{ToolbarRootContext-DP4mQLbq.js → ToolbarRootContext-PC8Tg1Jz.js} +1 -1
  49. package/dashboard/dist/assets/TrendMonitor-r8n8YO_9.js +19 -0
  50. package/dashboard/dist/assets/{WidgetsPage-Cr6Wg9QX.js → WidgetsPage-vzfGccWb.js} +1 -1
  51. package/dashboard/dist/assets/{WrappedPage-DqQvRcnA.js → WrappedPage-DaKuIkYE.js} +1 -1
  52. package/dashboard/dist/assets/{__vite-browser-external-BHz2N7Ih.css → __vite-browser-external-CCMdjCX2.css} +1 -1
  53. package/dashboard/dist/assets/__vite-browser-external-Dnxl50k1.js +45 -0
  54. package/dashboard/dist/assets/{agent-logos-BjIh_mqf.js → agent-logos-B7nzn3n2.js} +1 -1
  55. package/dashboard/dist/assets/{arrow-up-right-BIhbN0NO.js → arrow-up-right-CsxyvCER.js} +1 -1
  56. package/dashboard/dist/assets/{check-U-wNzPvd.js → check-hYcC7MdH.js} +1 -1
  57. package/dashboard/dist/assets/{composite-BR91CxNl.js → composite-C79LNbex.js} +1 -1
  58. package/dashboard/dist/assets/{copy-Bw3hXBCu.js → copy-Cz4qzE62.js} +1 -1
  59. package/dashboard/dist/assets/{download-WOqcQzvK.js → download-CUaSNE2E.js} +1 -1
  60. package/dashboard/dist/assets/{external-link-BZtf6WfS.js → external-link-vq7kIyKK.js} +1 -1
  61. package/dashboard/dist/assets/{flame-B5kp2VI9.js → flame-CB6K4eR6.js} +1 -1
  62. package/dashboard/dist/assets/{format-tokens-BOCnuMuJ.js → format-tokens-KI-e_JQ-.js} +1 -1
  63. package/dashboard/dist/assets/{icons-CA3FiIa_.js → icons-3maItkrp.js} +1 -1
  64. package/dashboard/dist/assets/{index-DQaanK52.js → index-BRXoZ7Jj.js} +1 -1
  65. package/dashboard/dist/assets/{index-mEG0HZ-a.js → index-C5WrfmLB.js} +1 -1
  66. package/dashboard/dist/assets/{index-DjWC63Bs.js → index-CUbC50Zp.js} +1 -1
  67. package/dashboard/dist/assets/{info-Djer9i3-.js → info-r0yGihUe.js} +1 -1
  68. package/dashboard/dist/assets/{limits-providers-F-RnNFAb.js → limits-providers-BN_94oe9.js} +1 -1
  69. package/dashboard/dist/assets/{link-2-DV4ocD0l.js → link-2-J9t0VXWM.js} +1 -1
  70. package/dashboard/dist/assets/{loader-circle-DJK72c-o.js → loader-circle-DCvOsPze.js} +1 -1
  71. package/dashboard/dist/assets/{maximize-2-BwBA_lMm.js → maximize-2-CZ9JhGjf.js} +1 -1
  72. package/dashboard/dist/assets/{modifiers.esm-B3sl4qyZ.js → modifiers.esm-DJ0_WKg0.js} +1 -1
  73. package/dashboard/dist/assets/module-xpSFyNBK.js +3 -0
  74. package/dashboard/dist/assets/{monitor-smartphone-BUTpM4ng.js → monitor-smartphone-nqbWjJx_.js} +1 -1
  75. package/dashboard/dist/assets/{plus-BA9RP5L6.js → plus-Cg7wd2jK.js} +1 -1
  76. package/dashboard/dist/assets/{provider-display-DqsJzGSr.js → provider-display-B9L1nRY9.js} +2 -2
  77. package/dashboard/dist/assets/{react-BYw5AVSz.js → react-D2AD6Pk2.js} +1 -1
  78. package/dashboard/dist/assets/{search-BzcnFWVD.js → search-uNiWba1h.js} +1 -1
  79. package/dashboard/dist/assets/{skills-api-vAME1P7G.js → skills-api-BBYuAhiL.js} +1 -1
  80. package/dashboard/dist/assets/{sortable.esm-BpEiRqqI.js → sortable.esm-CzPxc949.js} +1 -1
  81. package/dashboard/dist/assets/{store-GeT5fhRX.js → store-D4A7EUwE.js} +1 -1
  82. package/dashboard/dist/assets/{terminal-CZCMYQ8K.js → terminal-Crmm_yKF.js} +1 -1
  83. package/dashboard/dist/assets/{trash-2-DzlWEwKO.js → trash-2-ia9VjRnU.js} +1 -1
  84. package/dashboard/dist/assets/{use-community-stats-DOvJfj-i.js → use-community-stats-KaGCAxaF.js} +1 -1
  85. package/dashboard/dist/assets/use-latest-request-guard-Dy8gSDkk.js +1 -0
  86. package/dashboard/dist/assets/{use-limits-display-prefs-BmLs4HyN.js → use-limits-display-prefs-JSTYlbTD.js} +1 -1
  87. package/dashboard/dist/assets/{use-ordered-list-BgozDSSV.js → use-ordered-list-C4JnHdE0.js} +1 -1
  88. package/dashboard/dist/assets/use-reduced-motion-AQE0ummK.js +1 -0
  89. package/dashboard/dist/assets/{use-session-efficiency-pref-HP1ckVkS.js → use-session-efficiency-pref-CgyUz5Gc.js} +1 -1
  90. package/dashboard/dist/assets/{use-transform-B4VSogOx.js → use-transform-C7-jSJ9k.js} +1 -1
  91. package/dashboard/dist/assets/use-usage-limits-B3YYqn-v.js +1 -0
  92. package/dashboard/dist/assets/{useCurrency-CSuzCBAL.js → useCurrency-CK_gcb3J.js} +1 -1
  93. package/dashboard/dist/assets/{useTokenFormat-ClnL9OFe.js → useTokenFormat-C_a-UkCA.js} +1 -1
  94. package/dashboard/dist/assets/{zap-C_T9CCiS.js → zap-BIJNn-Ic.js} +1 -1
  95. package/dashboard/dist/brand-logos/lmstudio.svg +12 -0
  96. package/dashboard/dist/brand-logos/unsloth.svg +1 -0
  97. package/dashboard/dist/index.html +16 -12
  98. package/dashboard/dist/ip-check.html +2 -2
  99. package/dashboard/dist/leaderboard.html +2 -2
  100. package/dashboard/dist/llms.txt +4 -2
  101. package/dashboard/dist/share.html +2 -2
  102. package/package.json +2 -2
  103. package/src/commands/device-login.js +5 -10
  104. package/src/commands/init.js +28 -1
  105. package/src/commands/serve.js +6 -0
  106. package/src/commands/status.js +24 -0
  107. package/src/commands/sync.js +114 -18
  108. package/src/lib/cloud-account.js +102 -29
  109. package/src/lib/fs.js +30 -0
  110. package/src/lib/local-api.js +99 -13
  111. package/src/lib/pricing/index.js +5 -0
  112. package/src/lib/pricing/matcher.js +15 -0
  113. package/src/lib/pricing/seed-snapshot.json +1 -1
  114. package/src/lib/rollout.js +1035 -28
  115. package/src/lib/usage-limits.js +507 -55
  116. package/src/lib/zcode-limits.js +65 -8
  117. package/dashboard/dist/assets/CommunityStatsModal-DRDpZycV.js +0 -1
  118. package/dashboard/dist/assets/DashboardPage-B5XMQoxy.js +0 -1
  119. package/dashboard/dist/assets/DialogDescription-DiVbwvTR.js +0 -1
  120. package/dashboard/dist/assets/SkillsPage-B1AcLX41.js +0 -1
  121. package/dashboard/dist/assets/TrendMonitor-rabtcE5w.js +0 -19
  122. package/dashboard/dist/assets/__vite-browser-external-C8KwSyTz.js +0 -45
  123. package/dashboard/dist/assets/chevron-right-Du-QVNj4.js +0 -1
  124. package/dashboard/dist/assets/module-D2D5r9gC.js +0 -3
  125. package/dashboard/dist/assets/use-reduced-motion-Bc3RFJdg.js +0 -1
  126. package/dashboard/dist/assets/use-usage-limits-DNNhD2H6.js +0 -1
@@ -4900,6 +4900,42 @@ function isZcodeNativeMessage(data) {
4900
4900
  );
4901
4901
  }
4902
4902
 
4903
+ // ZCode persists inclusive parent counters in both its legacy OpenCode tables
4904
+ // and the newer model_usage table: cache read/write are already included in
4905
+ // input, and reasoning is already included in output. The shared OpenCode
4906
+ // parser expects disjoint columns, so split the subsets before it computes
4907
+ // queue totals and cost (issue #554).
4908
+ function normalizeZcodeInclusiveTokens(tokens) {
4909
+ if (!tokens || typeof tokens !== "object") return tokens;
4910
+ const rawInput = toNonNegativeInt(tokens.input);
4911
+ const rawOutput = toNonNegativeInt(tokens.output);
4912
+ const cacheRead = toNonNegativeInt(tokens.cache?.read);
4913
+ const cacheWrite = toNonNegativeInt(tokens.cache?.write);
4914
+ const reasoning = toNonNegativeInt(tokens.reasoning);
4915
+ return {
4916
+ ...tokens,
4917
+ input: Math.max(0, rawInput - cacheRead - cacheWrite),
4918
+ output: Math.max(0, rawOutput - reasoning),
4919
+ reasoning,
4920
+ cache: {
4921
+ ...(tokens.cache && typeof tokens.cache === "object" ? tokens.cache : {}),
4922
+ read: cacheRead,
4923
+ write: cacheWrite,
4924
+ },
4925
+ };
4926
+ }
4927
+
4928
+ function normalizeZcodeLegacyMessage(message) {
4929
+ if (!message?.data?.tokens) return message;
4930
+ return {
4931
+ ...message,
4932
+ data: {
4933
+ ...message.data,
4934
+ tokens: normalizeZcodeInclusiveTokens(message.data.tokens),
4935
+ },
4936
+ };
4937
+ }
4938
+
4903
4939
  const ZCODE_NATIVE_USAGE_COLUMNS = new Set([
4904
4940
  "id",
4905
4941
  "logical_request_id",
@@ -5045,18 +5081,6 @@ function readZcodeNativeUsageMessages(dbPath, sqliteOptions = {}) {
5045
5081
  const startedAt = coerceEpochMs(row?.started_at);
5046
5082
  if (!providerID || !modelID || !sessionID || !id || !startedAt) continue;
5047
5083
 
5048
- // ZCode's persisted input/output columns are inclusive parents:
5049
- // cache read/write are subsets of input, and reasoning is a subset of
5050
- // output. TokenTracker stores those five columns disjointly, so split the
5051
- // subsets here before entering the shared OpenCode parser. This preserves
5052
- // sum(input + cache read + cache write + output + reasoning) without
5053
- // billing either subset twice.
5054
- const rawInput = toNonNegativeInt(row?.input_tokens);
5055
- const rawOutput = toNonNegativeInt(row?.output_tokens);
5056
- const cacheRead = toNonNegativeInt(row?.cache_read_input_tokens);
5057
- const cacheWrite = toNonNegativeInt(row?.cache_creation_input_tokens);
5058
- const reasoning = toNonNegativeInt(row?.reasoning_tokens);
5059
-
5060
5084
  const data = {
5061
5085
  id,
5062
5086
  sessionID,
@@ -5064,15 +5088,15 @@ function readZcodeNativeUsageMessages(dbPath, sqliteOptions = {}) {
5064
5088
  providerID,
5065
5089
  modelID,
5066
5090
  time: { created: startedAt, completed: startedAt },
5067
- tokens: {
5068
- input: Math.max(0, rawInput - cacheRead - cacheWrite),
5069
- output: Math.max(0, rawOutput - reasoning),
5070
- reasoning,
5091
+ tokens: normalizeZcodeInclusiveTokens({
5092
+ input: row?.input_tokens,
5093
+ output: row?.output_tokens,
5094
+ reasoning: row?.reasoning_tokens,
5071
5095
  cache: {
5072
- read: cacheRead,
5073
- write: cacheWrite,
5096
+ read: row?.cache_read_input_tokens,
5097
+ write: row?.cache_creation_input_tokens,
5074
5098
  },
5075
- },
5099
+ }),
5076
5100
  };
5077
5101
  if (typeof row?.directory === "string" && row.directory.trim()) {
5078
5102
  data.path = { cwd: row.directory.trim() };
@@ -5085,12 +5109,31 @@ function readZcodeNativeUsageMessages(dbPath, sqliteOptions = {}) {
5085
5109
 
5086
5110
  // Read only genuine ZCode assistant messages (its own GLM models via Z.ai /
5087
5111
  // BigModel), dropping any bundled sub-agent turns. See isZcodeNativeMessage.
5112
+ //
5113
+ // ZCode started writing model_usage after many installations had already
5114
+ // accumulated months of history in the OpenCode message tables. The native
5115
+ // table is authoritative from its first completed row onward, but it is not a
5116
+ // historical backfill. Keep legacy rows before that boundary so merely adding
5117
+ // the new table cannot make older usage disappear from TokenTracker.
5088
5118
  function readZcodeDbMessages(dbPath, sqliteOptions = {}) {
5089
5119
  if (!dbPath || !fssync.existsSync(dbPath)) return [];
5090
5120
  const nativeMessages = readZcodeNativeUsageMessages(dbPath, sqliteOptions);
5091
- if (nativeMessages !== null) return nativeMessages;
5092
- const all = readOpencodeDbMessages(dbPath, sqliteOptions);
5093
- return all.filter((m) => isZcodeNativeMessage(m.data));
5121
+ const legacyMessages = readOpencodeDbMessages(dbPath, sqliteOptions)
5122
+ .filter((message) => isZcodeNativeMessage(message.data))
5123
+ .map(normalizeZcodeLegacyMessage);
5124
+ if (nativeMessages === null) return legacyMessages;
5125
+
5126
+ const nativeStartMs = nativeMessages.reduce((earliest, message) => {
5127
+ const timestampMs = coerceEpochMs(message?.timeUpdated);
5128
+ return timestampMs > 0 ? Math.min(earliest, timestampMs) : earliest;
5129
+ }, Number.POSITIVE_INFINITY);
5130
+ if (!Number.isFinite(nativeStartMs)) return legacyMessages;
5131
+
5132
+ const historicalMessages = legacyMessages.filter((message) => {
5133
+ const timestampMs = coerceEpochMs(message?.timeUpdated);
5134
+ return timestampMs > 0 && timestampMs < nativeStartMs;
5135
+ });
5136
+ return [...historicalMessages, ...nativeMessages];
5094
5137
  }
5095
5138
 
5096
5139
  async function parseOpencodeDbIncremental({
@@ -11160,6 +11203,955 @@ async function parseZedIncremental({
11160
11203
  return { recordsProcessed, eventsAggregated, bucketsQueued };
11161
11204
  }
11162
11205
 
11206
+ // ─────────────────────────────────────────────────────────────────────────────
11207
+ // LM Studio
11208
+ //
11209
+ // Data: pretty-printed OpenAI-compatible final responses beneath
11210
+ // `~/.lmstudio/server-logs/`. The reader recognizes both Chat Completions and
11211
+ // Responses API usage shapes. It scans only response identity, model, timestamp,
11212
+ // and the balanced `usage` object; prompt and response bodies are never parsed
11213
+ // or persisted.
11214
+ // ─────────────────────────────────────────────────────────────────────────────
11215
+
11216
+ const LMSTUDIO_SOURCE = "lmstudio";
11217
+ const LMSTUDIO_WINDOW_BYTES = 8 * 1024 * 1024;
11218
+ const LMSTUDIO_CHUNK_BYTES = 64 * 1024;
11219
+ const LMSTUDIO_IDENTITY_OVERLAP_BYTES = 512;
11220
+ const LMSTUDIO_MESSAGE_LIMIT = 10_000;
11221
+ const LMSTUDIO_USAGE_MARKER = Buffer.from('"usage"');
11222
+
11223
+ function resolveLmstudioHome(env = process.env) {
11224
+ const override = typeof env.TOKENTRACKER_LMSTUDIO_HOME === "string"
11225
+ ? env.TOKENTRACKER_LMSTUDIO_HOME.trim()
11226
+ : "";
11227
+ if (override) return path.resolve(override);
11228
+ const nativeHome = typeof env.LM_STUDIO_HOME === "string"
11229
+ ? env.LM_STUDIO_HOME.trim()
11230
+ : "";
11231
+ if (nativeHome) return path.resolve(nativeHome);
11232
+ return path.join(env.HOME || env.USERPROFILE || os.homedir(), ".lmstudio");
11233
+ }
11234
+
11235
+ async function resolveLmstudioLogFiles(env = process.env) {
11236
+ const root = path.join(resolveLmstudioHome(env), "server-logs");
11237
+ const files = [];
11238
+ const pending = [root];
11239
+ while (pending.length > 0) {
11240
+ const dir = pending.pop();
11241
+ let entries;
11242
+ try {
11243
+ entries = await fs.readdir(dir, { withFileTypes: true });
11244
+ } catch (_e) {
11245
+ continue;
11246
+ }
11247
+ for (const entry of entries) {
11248
+ const fullPath = path.join(dir, entry.name);
11249
+ if (entry.isDirectory()) pending.push(fullPath);
11250
+ else if (entry.isFile() && entry.name.toLowerCase().endsWith(".log")) files.push(fullPath);
11251
+ }
11252
+ }
11253
+ return files.sort((a, b) => a.localeCompare(b));
11254
+ }
11255
+
11256
+ function skipLmstudioWhitespace(buffer, index) {
11257
+ while (index < buffer.length && /\s/.test(String.fromCharCode(buffer[index]))) index += 1;
11258
+ return index;
11259
+ }
11260
+
11261
+ function scanLmstudioUsageObjectStart(buffer, from = 0) {
11262
+ let cursor = Math.max(0, from);
11263
+ while (cursor < buffer.length) {
11264
+ const marker = buffer.indexOf(LMSTUDIO_USAGE_MARKER, cursor);
11265
+ if (marker < 0) break;
11266
+ cursor = marker + LMSTUDIO_USAGE_MARKER.length;
11267
+ if (marker > 0 && buffer[marker - 1] === 0x5c) continue;
11268
+ const colon = skipLmstudioWhitespace(buffer, cursor);
11269
+ if (colon >= buffer.length) return { found: null, certainTo: marker };
11270
+ if (buffer[colon] !== 0x3a) continue;
11271
+ const brace = skipLmstudioWhitespace(buffer, colon + 1);
11272
+ if (brace >= buffer.length) return { found: null, certainTo: marker };
11273
+ if (buffer[brace] === 0x7b) return { found: { marker, objectStart: brace }, certainTo: marker };
11274
+ }
11275
+ return {
11276
+ found: null,
11277
+ certainTo: Math.max(0, buffer.length - Math.max(0, LMSTUDIO_USAGE_MARKER.length - 1)),
11278
+ };
11279
+ }
11280
+
11281
+ function lmstudioBalancedObjectEnd(buffer, start) {
11282
+ if (buffer[start] !== 0x7b) return -1;
11283
+ let depth = 0;
11284
+ let inString = false;
11285
+ let escaped = false;
11286
+ for (let index = start; index < buffer.length; index++) {
11287
+ const byte = buffer[index];
11288
+ if (inString) {
11289
+ if (escaped) escaped = false;
11290
+ else if (byte === 0x5c) escaped = true;
11291
+ else if (byte === 0x22) inString = false;
11292
+ continue;
11293
+ }
11294
+ if (byte === 0x22) inString = true;
11295
+ else if (byte === 0x7b) depth += 1;
11296
+ else if (byte === 0x7d) {
11297
+ depth -= 1;
11298
+ if (depth === 0) return index + 1;
11299
+ }
11300
+ }
11301
+ return -1;
11302
+ }
11303
+
11304
+ function lmstudioJsonStringEnd(buffer, start) {
11305
+ if (buffer[start] !== 0x22) return -1;
11306
+ let escaped = false;
11307
+ for (let index = start + 1; index < buffer.length; index++) {
11308
+ const byte = buffer[index];
11309
+ if (escaped) escaped = false;
11310
+ else if (byte === 0x5c) escaped = true;
11311
+ else if (byte === 0x22) return index + 1;
11312
+ }
11313
+ return -1;
11314
+ }
11315
+
11316
+ function lastLmstudioJsonStringField(buffer, field) {
11317
+ const markerBuffer = Buffer.from(`"${field}"`);
11318
+ let cursor = 0;
11319
+ let found = null;
11320
+ while (cursor < buffer.length) {
11321
+ const index = buffer.indexOf(markerBuffer, cursor);
11322
+ if (index < 0) break;
11323
+ cursor = index + markerBuffer.length;
11324
+ if (index > 0 && buffer[index - 1] === 0x5c) continue;
11325
+ let valueStart = skipLmstudioWhitespace(buffer, cursor);
11326
+ if (buffer[valueStart] !== 0x3a) continue;
11327
+ valueStart = skipLmstudioWhitespace(buffer, valueStart + 1);
11328
+ const valueEnd = lmstudioJsonStringEnd(buffer, valueStart);
11329
+ if (valueEnd < 0) continue;
11330
+ try {
11331
+ const value = JSON.parse(buffer.subarray(valueStart, valueEnd).toString("utf8"));
11332
+ if (typeof value === "string") found = value;
11333
+ } catch (_e) { }
11334
+ }
11335
+ return found;
11336
+ }
11337
+
11338
+ function lastLmstudioTimestamp(buffer) {
11339
+ const text = buffer.toString("utf8");
11340
+ let timestamp = null;
11341
+ for (const rawLine of text.split("\n")) {
11342
+ const line = rawLine.startsWith("\r") ? rawLine.slice(1) : rawLine;
11343
+ const match = /^\[(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})\]\[/.exec(line);
11344
+ if (!match) continue;
11345
+ const date = new Date(
11346
+ Number(match[1]),
11347
+ Number(match[2]) - 1,
11348
+ Number(match[3]),
11349
+ Number(match[4]),
11350
+ Number(match[5]),
11351
+ Number(match[6]),
11352
+ );
11353
+ if (!Number.isNaN(date.getTime())) timestamp = date.getTime();
11354
+ }
11355
+ return timestamp;
11356
+ }
11357
+
11358
+ function normalizeLocalStudioTokens(usage) {
11359
+ if (!usage || typeof usage !== "object") return null;
11360
+ const prompt = toNonNegativeInt(
11361
+ usage.prompt_tokens ?? usage.promptTokens ?? usage.input_tokens ?? usage.inputTokens,
11362
+ );
11363
+ const completion = toNonNegativeInt(
11364
+ usage.completion_tokens ?? usage.completionTokens ?? usage.output_tokens ?? usage.outputTokens,
11365
+ );
11366
+ const total = Math.max(
11367
+ toNonNegativeInt(usage.total_tokens ?? usage.totalTokens),
11368
+ prompt + completion,
11369
+ );
11370
+ if (total <= 0) return null;
11371
+ const promptDetails = usage.prompt_tokens_details
11372
+ || usage.input_tokens_details
11373
+ || usage.inputTokensDetails
11374
+ || {};
11375
+ const outputDetails = usage.completion_tokens_details
11376
+ || usage.output_tokens_details
11377
+ || usage.completionTokensDetails
11378
+ || usage.outputTokensDetails
11379
+ || {};
11380
+ const cacheRead = Math.min(
11381
+ prompt,
11382
+ Math.max(
11383
+ toNonNegativeInt(promptDetails.cached_tokens ?? promptDetails.cache_read_tokens),
11384
+ toNonNegativeInt(usage.cached_tokens),
11385
+ ),
11386
+ );
11387
+ const cacheWrite = Math.min(
11388
+ Math.max(0, prompt - cacheRead),
11389
+ Math.max(
11390
+ toNonNegativeInt(
11391
+ promptDetails.cache_creation_input_tokens ?? promptDetails.cache_write_tokens,
11392
+ ),
11393
+ toNonNegativeInt(usage.cache_creation_input_tokens),
11394
+ ),
11395
+ );
11396
+ const reasoning = Math.min(
11397
+ completion,
11398
+ Math.max(
11399
+ toNonNegativeInt(outputDetails.reasoning_tokens),
11400
+ toNonNegativeInt(usage.reasoning_tokens),
11401
+ ),
11402
+ );
11403
+ return {
11404
+ input_tokens: Math.max(0, total - completion - cacheRead - cacheWrite),
11405
+ cached_input_tokens: cacheRead,
11406
+ cache_creation_input_tokens: cacheWrite,
11407
+ output_tokens: Math.max(0, completion - reasoning),
11408
+ reasoning_output_tokens: reasoning,
11409
+ total_tokens: total,
11410
+ billable_total_tokens: total,
11411
+ total_cost_usd: 0,
11412
+ conversation_count: 1,
11413
+ };
11414
+ }
11415
+
11416
+ function lmstudioResponseId(buffer) {
11417
+ const id = lastLmstudioJsonStringField(buffer, "id");
11418
+ return typeof id === "string" && ["chatcmpl-", "cmpl-", "resp_"].some((prefix) => id.startsWith(prefix))
11419
+ ? id
11420
+ : null;
11421
+ }
11422
+
11423
+ function absorbLmstudioIdentity(identity, buffer) {
11424
+ const responseId = lmstudioResponseId(buffer);
11425
+ const model = lastLmstudioJsonStringField(buffer, "model");
11426
+ const timestamp = lastLmstudioTimestamp(buffer);
11427
+ if (responseId) identity.responseId = responseId;
11428
+ if (model && model.trim()) identity.model = model.trim();
11429
+ if (timestamp) identity.timestamp = timestamp;
11430
+ }
11431
+
11432
+ function normalizeLmstudioResumeIdentity(value) {
11433
+ const identity = {};
11434
+ const responseId = typeof value?.responseId === "string" ? value.responseId : "";
11435
+ if (["chatcmpl-", "cmpl-", "resp_"].some((prefix) => responseId.startsWith(prefix))) {
11436
+ identity.responseId = responseId;
11437
+ }
11438
+ const model = typeof value?.model === "string" ? value.model.trim() : "";
11439
+ if (model) identity.model = model;
11440
+ const timestamp = Number(value?.timestamp);
11441
+ if (Number.isFinite(timestamp) && timestamp > 0) identity.timestamp = timestamp;
11442
+ return identity;
11443
+ }
11444
+
11445
+ function lmstudioRecordFromSlices({ usageBuffer, metadataBuffer, carried, filePath, marker, fallbackTimestamp }) {
11446
+ let usage;
11447
+ try {
11448
+ usage = JSON.parse(usageBuffer.toString("utf8"));
11449
+ } catch (_e) {
11450
+ return null;
11451
+ }
11452
+ const totals = normalizeLocalStudioTokens(usage);
11453
+ if (!totals) return null;
11454
+ const responseId = lmstudioResponseId(metadataBuffer) || carried.responseId || null;
11455
+ const model = normalizeModelInput(
11456
+ lastLmstudioJsonStringField(metadataBuffer, "model") || carried.model,
11457
+ ) || DEFAULT_MODEL;
11458
+ const timestampMs = lastLmstudioTimestamp(metadataBuffer) || carried.timestamp || fallbackTimestamp;
11459
+ const bucketStart = timestampMs
11460
+ ? toUtcHalfHourStart(new Date(timestampMs).toISOString())
11461
+ : null;
11462
+ if (!bucketStart) return null;
11463
+ const fallback = crypto.createHash("sha256")
11464
+ .update(filePath)
11465
+ .update("\0")
11466
+ .update(String(marker))
11467
+ .update("\0")
11468
+ .update(model)
11469
+ .update("\0")
11470
+ .update(totalsKey(totals))
11471
+ .digest("base64url");
11472
+ return {
11473
+ key: responseId ? `lmstudio:${responseId}` : `lmstudio:${fallback}`,
11474
+ model,
11475
+ bucketStart,
11476
+ totals,
11477
+ marker,
11478
+ };
11479
+ }
11480
+
11481
+ async function readLmstudioFileRecords(
11482
+ filePath,
11483
+ {
11484
+ windowBytes = LMSTUDIO_WINDOW_BYTES,
11485
+ chunkBytes = LMSTUDIO_CHUNK_BYTES,
11486
+ startOffset = 0,
11487
+ resumeIdentity,
11488
+ } = {},
11489
+ ) {
11490
+ const handle = await fs.open(filePath, "r");
11491
+ try {
11492
+ const initialStat = await handle.stat();
11493
+ const fallbackTimestamp = initialStat.mtimeMs;
11494
+ const records = [];
11495
+ const initialOffset = Math.min(
11496
+ initialStat.size,
11497
+ Math.max(0, Number.isFinite(startOffset) ? Math.floor(startOffset) : 0),
11498
+ );
11499
+ let window = Buffer.alloc(0);
11500
+ let windowStart = initialOffset;
11501
+ let metadataStart = initialOffset;
11502
+ let scannedTo = initialOffset;
11503
+ let position = initialOffset;
11504
+ let carried = normalizeLmstudioResumeIdentity(resumeIdentity);
11505
+ const chunk = Buffer.alloc(Math.max(1, chunkBytes));
11506
+
11507
+ while (true) {
11508
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, position);
11509
+ if (bytesRead > 0) {
11510
+ window = Buffer.concat([window, chunk.subarray(0, bytesRead)]);
11511
+ position += bytesRead;
11512
+ }
11513
+ const atEof = bytesRead === 0;
11514
+
11515
+ while (true) {
11516
+ const scanFrom = Math.min(window.length, Math.max(0, scannedTo - windowStart));
11517
+ const scan = scanLmstudioUsageObjectStart(window, scanFrom);
11518
+ if (!scan.found) {
11519
+ scannedTo = windowStart + scan.certainTo;
11520
+ break;
11521
+ }
11522
+ const objectEnd = lmstudioBalancedObjectEnd(window, scan.found.objectStart);
11523
+ if (objectEnd < 0) {
11524
+ scannedTo = atEof
11525
+ ? windowStart + window.length
11526
+ : windowStart + scan.found.marker;
11527
+ break;
11528
+ }
11529
+ scannedTo = windowStart + objectEnd;
11530
+ const absoluteMarker = windowStart + scan.found.marker;
11531
+ const absoluteEnd = windowStart + objectEnd;
11532
+ const metadataFrom = Math.min(
11533
+ scan.found.marker,
11534
+ Math.max(0, metadataStart - windowStart),
11535
+ );
11536
+ const record = lmstudioRecordFromSlices({
11537
+ usageBuffer: window.subarray(scan.found.objectStart, objectEnd),
11538
+ metadataBuffer: window.subarray(metadataFrom, scan.found.marker),
11539
+ carried,
11540
+ filePath,
11541
+ marker: absoluteMarker,
11542
+ fallbackTimestamp,
11543
+ });
11544
+ if (record) records.push(record);
11545
+ metadataStart = absoluteEnd;
11546
+ carried = {};
11547
+ }
11548
+
11549
+ const keepFrom = Math.min(window.length, Math.max(0, metadataStart - windowStart));
11550
+ if (keepFrom > 0) {
11551
+ window = window.subarray(keepFrom);
11552
+ windowStart += keepFrom;
11553
+ }
11554
+
11555
+ if (window.length > windowBytes) {
11556
+ const overflow = window.length - windowBytes;
11557
+ const absorbTo = Math.min(
11558
+ window.length,
11559
+ overflow + LMSTUDIO_IDENTITY_OVERLAP_BYTES,
11560
+ );
11561
+ absorbLmstudioIdentity(carried, window.subarray(0, absorbTo));
11562
+ window = window.subarray(overflow);
11563
+ windowStart += overflow;
11564
+ metadataStart = Math.max(metadataStart, windowStart);
11565
+ scannedTo = Math.max(scannedTo, windowStart);
11566
+ }
11567
+
11568
+ if (atEof) {
11569
+ const finalStat = await handle.stat().catch(() => initialStat);
11570
+ const nextIdentity = normalizeLmstudioResumeIdentity(carried);
11571
+ return {
11572
+ records,
11573
+ stat: {
11574
+ dev: finalStat.dev,
11575
+ ino: finalStat.ino,
11576
+ size: finalStat.size,
11577
+ mtimeMs: finalStat.size === position ? finalStat.mtimeMs : -1,
11578
+ resumeOffset: Math.max(metadataStart, position - windowBytes),
11579
+ ...(Object.keys(nextIdentity).length > 0
11580
+ ? { resumeIdentity: nextIdentity }
11581
+ : {}),
11582
+ },
11583
+ };
11584
+ }
11585
+ }
11586
+ } finally {
11587
+ await handle.close().catch(() => {});
11588
+ }
11589
+ }
11590
+
11591
+ function retainNewestPassiveMessages(messages, maxEntries) {
11592
+ const limit = Number.isInteger(maxEntries) && maxEntries > 0
11593
+ ? maxEntries
11594
+ : LMSTUDIO_MESSAGE_LIMIT;
11595
+ const entries = Object.entries(messages);
11596
+ if (entries.length <= limit) return messages;
11597
+ entries.sort((left, right) => {
11598
+ const leftTime = Date.parse(left[1]?.updatedAt || left[1]?.bucketStart || "") || 0;
11599
+ const rightTime = Date.parse(right[1]?.updatedAt || right[1]?.bucketStart || "") || 0;
11600
+ if (leftTime !== rightTime) return rightTime - leftTime;
11601
+ return right[0].localeCompare(left[0]);
11602
+ });
11603
+ return Object.fromEntries(entries.slice(0, limit));
11604
+ }
11605
+
11606
+ function reconcilePassiveUsageEvent({ event, source, messages, hourlyState, touchedBuckets }) {
11607
+ const previous = messages[event.key];
11608
+ const unchanged = previous
11609
+ && previous.model === event.model
11610
+ && previous.bucketStart === event.bucketStart
11611
+ && totalsKey(previous.totals) === totalsKey(event.totals);
11612
+ if (unchanged) return false;
11613
+
11614
+ if (previous?.model && previous?.bucketStart && previous?.totals) {
11615
+ const oldBucket = getHourlyBucket(hourlyState, source, previous.model, previous.bucketStart);
11616
+ subtractTotals(oldBucket.totals, previous.totals);
11617
+ touchedBuckets.add(bucketKey(source, previous.model, previous.bucketStart));
11618
+ }
11619
+ const bucket = getHourlyBucket(hourlyState, source, event.model, event.bucketStart);
11620
+ addTotals(bucket.totals, event.totals);
11621
+ touchedBuckets.add(bucketKey(source, event.model, event.bucketStart));
11622
+ messages[event.key] = {
11623
+ model: event.model,
11624
+ bucketStart: event.bucketStart,
11625
+ totals: event.totals,
11626
+ updatedAt: new Date().toISOString(),
11627
+ };
11628
+ return true;
11629
+ }
11630
+
11631
+ async function parseLmstudioIncremental({
11632
+ logFiles,
11633
+ cursors,
11634
+ queuePath,
11635
+ onProgress,
11636
+ messageLimit = LMSTUDIO_MESSAGE_LIMIT,
11637
+ readerOptions,
11638
+ } = {}) {
11639
+ await ensureDir(path.dirname(queuePath));
11640
+ const files = Array.isArray(logFiles) ? [...logFiles].sort((a, b) => a.localeCompare(b)) : [];
11641
+ const priorState = cursors.lmstudio && typeof cursors.lmstudio === "object"
11642
+ ? cursors.lmstudio
11643
+ : {};
11644
+ let fileState = priorState.files && typeof priorState.files === "object"
11645
+ ? { ...priorState.files }
11646
+ : {};
11647
+ let messages = priorState.messages && typeof priorState.messages === "object"
11648
+ ? priorState.messages
11649
+ : {};
11650
+ const hourlyState = normalizeHourlyState(cursors?.hourly);
11651
+ const touchedBuckets = new Set();
11652
+ const presentFiles = new Set(files);
11653
+ const cb = typeof onProgress === "function" ? onProgress : null;
11654
+ const effectiveMessageLimit = Number.isInteger(messageLimit) && messageLimit > 0
11655
+ ? messageLimit
11656
+ : LMSTUDIO_MESSAGE_LIMIT;
11657
+ let recordsProcessed = 0;
11658
+ let eventsAggregated = 0;
11659
+ let stateChanged = false;
11660
+ const readOptions = readerOptions && typeof readerOptions === "object" ? readerOptions : {};
11661
+ const filePlans = [];
11662
+ let allFilesReadable = true;
11663
+
11664
+ for (const filePath of files) {
11665
+ const previousFile = fileState[filePath];
11666
+ let stat;
11667
+ try {
11668
+ stat = await fs.stat(filePath);
11669
+ } catch (_e) {
11670
+ allFilesReadable = false;
11671
+ continue;
11672
+ }
11673
+ const unchanged = Boolean(previousFile
11674
+ && previousFile.dev === stat.dev
11675
+ && previousFile.ino === stat.ino
11676
+ && previousFile.size === stat.size
11677
+ && previousFile.mtimeMs === stat.mtimeMs
11678
+ && Number.isFinite(previousFile.resumeOffset));
11679
+ const canResume = Boolean(previousFile
11680
+ && previousFile.dev === stat.dev
11681
+ && previousFile.ino === stat.ino
11682
+ && (stat.size > previousFile.size || previousFile.mtimeMs === -1)
11683
+ && Number.isFinite(previousFile.resumeOffset)
11684
+ && previousFile.resumeOffset >= 0
11685
+ && previousFile.resumeOffset <= stat.size);
11686
+ filePlans.push({ filePath, previousFile, unchanged, canResume });
11687
+ }
11688
+
11689
+ const requiresRebuild = filePlans.some(
11690
+ ({ previousFile, unchanged, canResume }) => previousFile && !unchanged && !canResume,
11691
+ );
11692
+ if (requiresRebuild) {
11693
+ // Bounded message retention cannot safely deduplicate a replay from byte
11694
+ // zero. Rebuild every current LM Studio log in isolation, then replace only
11695
+ // this source's buckets while preserving their queue fingerprints.
11696
+ if (!allFilesReadable || filePlans.length !== files.length) {
11697
+ return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
11698
+ }
11699
+ const rebuiltHourly = normalizeHourlyState(null);
11700
+ const rebuiltMessages = {};
11701
+ const rebuiltFiles = {};
11702
+ const rebuiltTouched = new Set();
11703
+ let rebuiltRecords = 0;
11704
+ let rebuiltEvents = 0;
11705
+ try {
11706
+ for (let index = 0; index < filePlans.length; index++) {
11707
+ const { filePath } = filePlans[index];
11708
+ const parsed = await readLmstudioFileRecords(filePath, {
11709
+ ...readOptions,
11710
+ startOffset: 0,
11711
+ resumeIdentity: undefined,
11712
+ });
11713
+ for (const event of parsed.records) {
11714
+ if (reconcilePassiveUsageEvent({
11715
+ event,
11716
+ source: LMSTUDIO_SOURCE,
11717
+ messages: rebuiltMessages,
11718
+ hourlyState: rebuiltHourly,
11719
+ touchedBuckets: rebuiltTouched,
11720
+ })) rebuiltEvents += 1;
11721
+ }
11722
+ rebuiltFiles[filePath] = { ...parsed.stat, updatedAt: new Date().toISOString() };
11723
+ rebuiltRecords += 1;
11724
+ if (cb) {
11725
+ cb({
11726
+ index: index + 1,
11727
+ total: files.length,
11728
+ recordsProcessed: rebuiltRecords,
11729
+ eventsAggregated: rebuiltEvents,
11730
+ bucketsQueued: rebuiltTouched.size,
11731
+ });
11732
+ }
11733
+ }
11734
+ } catch (error) {
11735
+ if (process.env.TOKENTRACKER_DEBUG) {
11736
+ process.stderr.write(`[lmstudio] rebuild deferred: ${error?.message || error}\n`);
11737
+ }
11738
+ return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
11739
+ }
11740
+
11741
+ for (const [key, bucket] of Object.entries(hourlyState.buckets || {})) {
11742
+ if (parseBucketKey(key).source !== LMSTUDIO_SOURCE || !bucket?.totals) continue;
11743
+ bucket.totals = initTotals();
11744
+ touchedBuckets.add(key);
11745
+ }
11746
+ for (const [key, bucket] of Object.entries(rebuiltHourly.buckets || {})) {
11747
+ const current = hourlyState.buckets[key];
11748
+ if (current && typeof current === "object") current.totals = cloneTotals(bucket.totals);
11749
+ else hourlyState.buckets[key] = bucket;
11750
+ touchedBuckets.add(key);
11751
+ }
11752
+ fileState = rebuiltFiles;
11753
+ messages = rebuiltMessages;
11754
+ recordsProcessed = rebuiltRecords;
11755
+ eventsAggregated = rebuiltEvents;
11756
+ stateChanged = true;
11757
+ } else {
11758
+ for (let index = 0; index < filePlans.length; index++) {
11759
+ const { filePath, previousFile, unchanged, canResume } = filePlans[index];
11760
+ if (!unchanged) {
11761
+ try {
11762
+ const parsed = await readLmstudioFileRecords(filePath, {
11763
+ ...readOptions,
11764
+ startOffset: canResume ? previousFile.resumeOffset : 0,
11765
+ resumeIdentity: canResume ? previousFile.resumeIdentity : undefined,
11766
+ });
11767
+ for (const event of parsed.records) {
11768
+ if (reconcilePassiveUsageEvent({
11769
+ event,
11770
+ source: LMSTUDIO_SOURCE,
11771
+ messages,
11772
+ hourlyState,
11773
+ touchedBuckets,
11774
+ })) eventsAggregated += 1;
11775
+ }
11776
+ fileState[filePath] = { ...parsed.stat, updatedAt: new Date().toISOString() };
11777
+ recordsProcessed += 1;
11778
+ stateChanged = true;
11779
+ } catch (error) {
11780
+ if (process.env.TOKENTRACKER_DEBUG) {
11781
+ process.stderr.write(`[lmstudio] skipped ${filePath}: ${error?.message || error}\n`);
11782
+ }
11783
+ }
11784
+ }
11785
+ if (cb) {
11786
+ cb({
11787
+ index: index + 1,
11788
+ total: files.length,
11789
+ recordsProcessed,
11790
+ eventsAggregated,
11791
+ bucketsQueued: touchedBuckets.size,
11792
+ });
11793
+ }
11794
+ }
11795
+ }
11796
+
11797
+ for (const filePath of Object.keys(fileState)) {
11798
+ if (!presentFiles.has(filePath)) {
11799
+ delete fileState[filePath];
11800
+ stateChanged = true;
11801
+ }
11802
+ }
11803
+ if (
11804
+ !stateChanged
11805
+ && touchedBuckets.size === 0
11806
+ && Object.keys(messages).length <= effectiveMessageLimit
11807
+ ) {
11808
+ return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
11809
+ }
11810
+ const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets });
11811
+ const updatedAt = new Date().toISOString();
11812
+ hourlyState.updatedAt = updatedAt;
11813
+ cursors.hourly = hourlyState;
11814
+ cursors.lmstudio = {
11815
+ files: fileState,
11816
+ messages: retainNewestPassiveMessages(messages, effectiveMessageLimit),
11817
+ updatedAt,
11818
+ };
11819
+ return { recordsProcessed, eventsAggregated, bucketsQueued };
11820
+ }
11821
+
11822
+ // ─────────────────────────────────────────────────────────────────────────────
11823
+ // Unsloth Studio
11824
+ //
11825
+ // Durable inference usage lives in `studio.db`. The SQL projections below
11826
+ // extract only stable IDs, timestamps, model identifiers, and scalar counters.
11827
+ // Message content, attachments, API subjects, credentials, and training data
11828
+ // never leave SQLite.
11829
+ // ─────────────────────────────────────────────────────────────────────────────
11830
+
11831
+ const UNSLOTH_SOURCE = "unsloth";
11832
+ const UNSLOTH_SQL_PAGE_SIZE = 500;
11833
+ const UNSLOTH_CURSOR_OVERLAP_ROWS = 256;
11834
+ const UNSLOTH_METERED_PROVIDER_TYPES = new Set([
11835
+ "anthropic",
11836
+ "deepseek",
11837
+ "gemini",
11838
+ "huggingface",
11839
+ "kimi",
11840
+ "mistral",
11841
+ "openai",
11842
+ "openrouter",
11843
+ "qwen",
11844
+ ]);
11845
+
11846
+ function resolveUnslothDbPath(env = process.env) {
11847
+ const override = typeof env.TOKENTRACKER_UNSLOTH_DB === "string"
11848
+ ? env.TOKENTRACKER_UNSLOTH_DB.trim()
11849
+ : "";
11850
+ if (override) return path.resolve(override);
11851
+ const studioHome = typeof env.UNSLOTH_STUDIO_HOME === "string"
11852
+ ? env.UNSLOTH_STUDIO_HOME.trim()
11853
+ : "";
11854
+ if (studioHome) return path.join(path.resolve(studioHome), "studio.db");
11855
+ return path.join(env.HOME || env.USERPROFILE || os.homedir(), ".unsloth", "studio", "studio.db");
11856
+ }
11857
+
11858
+ function unslothSqliteFingerprint(dbPath) {
11859
+ const fingerprint = sqliteSidecarFingerprint(dbPath);
11860
+ delete fingerprint["-shm"];
11861
+ return fingerprint;
11862
+ }
11863
+
11864
+ function unslothRowPosition(row) {
11865
+ const createdAt = row?.created_at == null ? "" : String(row.created_at);
11866
+ const id = row?.id == null ? "" : String(row.id);
11867
+ return createdAt && id ? { createdAt, id } : null;
11868
+ }
11869
+
11870
+ function unslothPositionClause(rowAlias, position, inclusive) {
11871
+ if (!position?.createdAt || !position?.id) return null;
11872
+ const createdAt = sqliteStringLiteral(position.createdAt);
11873
+ const id = sqliteStringLiteral(position.id);
11874
+ const idOperator = inclusive ? ">=" : ">";
11875
+ return `(${rowAlias}.created_at > ${createdAt} OR ` +
11876
+ `(${rowAlias}.created_at = ${createdAt} AND ${rowAlias}.id ${idOperator} ${id}))`;
11877
+ }
11878
+
11879
+ function readUnslothRowsPaged({
11880
+ dbPath,
11881
+ select,
11882
+ from,
11883
+ where,
11884
+ rowAlias,
11885
+ lowerBound,
11886
+ options,
11887
+ }) {
11888
+ const requestedPageSize = Number(options?.pageSize);
11889
+ const pageSize = Number.isInteger(requestedPageSize) && requestedPageSize > 0
11890
+ ? Math.min(requestedPageSize, UNSLOTH_SQL_PAGE_SIZE)
11891
+ : UNSLOTH_SQL_PAGE_SIZE;
11892
+ const rows = [];
11893
+ let after = null;
11894
+
11895
+ while (true) {
11896
+ const clauses = [where, `${rowAlias}.created_at IS NOT NULL`, `${rowAlias}.id IS NOT NULL`];
11897
+ const positionClause = unslothPositionClause(rowAlias, after || lowerBound, !after);
11898
+ if (positionClause) clauses.push(positionClause);
11899
+ const page = readSqliteJsonRows(dbPath, `
11900
+ SELECT
11901
+ ${select}
11902
+ FROM ${from}
11903
+ WHERE ${clauses.filter(Boolean).join(" AND ")}
11904
+ ORDER BY ${rowAlias}.created_at, ${rowAlias}.id
11905
+ LIMIT ${pageSize}
11906
+ `.trim(), options);
11907
+ rows.push(...page);
11908
+ if (page.length < pageSize) break;
11909
+
11910
+ const next = unslothRowPosition(page[page.length - 1]);
11911
+ if (!next || (
11912
+ after && next.createdAt === after.createdAt && next.id === after.id
11913
+ )) {
11914
+ throw new Error("Unsloth Studio pagination did not advance");
11915
+ }
11916
+ after = next;
11917
+ }
11918
+
11919
+ return rows;
11920
+ }
11921
+
11922
+ function readUnslothUsageRows(dbPath, sqliteOptions = {}, scanState = {}) {
11923
+ if (!dbPath || !fssync.existsSync(dbPath)) return [];
11924
+ const options = {
11925
+ label: "Unsloth Studio",
11926
+ maxBuffer: 32 * 1024 * 1024,
11927
+ timeout: 30_000,
11928
+ readOnly: true,
11929
+ throwOnReadFailure: true,
11930
+ ...sqliteOptions,
11931
+ };
11932
+ const tables = new Set(
11933
+ readSqliteJsonRows(
11934
+ dbPath,
11935
+ "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('chat_messages','chat_threads','api_usage_events')",
11936
+ options,
11937
+ ).map((row) => row?.name).filter(Boolean),
11938
+ );
11939
+ const rows = [];
11940
+
11941
+ if (tables.has("chat_messages")) {
11942
+ const joinThread = tables.has("chat_threads")
11943
+ ? "LEFT JOIN chat_threads t ON t.id = m.thread_id"
11944
+ : "";
11945
+ const threadModel = tables.has("chat_threads") ? "t.model_id" : "NULL";
11946
+ const field = (jsonPath, alias) =>
11947
+ `CASE WHEN json_valid(m.metadata_json) THEN json_extract(m.metadata_json, '${jsonPath}') ELSE NULL END AS ${alias}`;
11948
+ // Re-read a bounded tail because Studio finalizes recent rows in place and
11949
+ // does not expose an updated_at column. Older immutable history stays behind
11950
+ // the persisted timestamp/ID overlap cursor.
11951
+ const chatRows = readUnslothRowsPaged({
11952
+ dbPath,
11953
+ select: `
11954
+ 'chat' AS usage_kind,
11955
+ m.id,
11956
+ m.created_at,
11957
+ ${field("$.responseDetails.responseModelId", "response_model")},
11958
+ ${field("$.contextUsage.modelId", "requested_model")},
11959
+ ${field("$.responseDetails.providerType", "provider_type")},
11960
+ ${threadModel} AS fallback_model,
11961
+ ${field("$.contextUsage.promptTokens", "prompt_tokens")},
11962
+ ${field("$.contextUsage.completionTokens", "completion_tokens")},
11963
+ ${field("$.contextUsage.totalTokens", "total_tokens")},
11964
+ ${field("$.contextUsage.cachedTokens", "cached_tokens")},
11965
+ ${field("$.contextUsage.cacheWriteTokens", "cache_write_tokens")},
11966
+ ${field("$.contextUsage.reasoningTokens", "reasoning_tokens")}
11967
+ `.trim(),
11968
+ from: `chat_messages m ${joinThread}`,
11969
+ where: "m.role = 'assistant'",
11970
+ rowAlias: "m",
11971
+ lowerBound: scanState?.chat?.overlap,
11972
+ options,
11973
+ });
11974
+ rows.push(...chatRows);
11975
+ }
11976
+
11977
+ if (tables.has("api_usage_events")) {
11978
+ const columns = new Set(
11979
+ readSqliteJsonRows(dbPath, "PRAGMA table_info(api_usage_events)", options)
11980
+ .map((row) => row?.name)
11981
+ .filter(Boolean),
11982
+ );
11983
+ const required = ["id", "model", "prompt_tokens", "completion_tokens", "total_tokens", "created_at"];
11984
+ if (required.every((column) => columns.has(column))) {
11985
+ rows.push(...readUnslothRowsPaged({
11986
+ dbPath,
11987
+ select: `
11988
+ 'api' AS usage_kind,
11989
+ e.id,
11990
+ e.created_at,
11991
+ e.model AS response_model,
11992
+ e.model AS requested_model,
11993
+ 'local' AS provider_type,
11994
+ NULL AS fallback_model,
11995
+ e.prompt_tokens,
11996
+ e.completion_tokens,
11997
+ e.total_tokens,
11998
+ 0 AS cached_tokens,
11999
+ 0 AS cache_write_tokens,
12000
+ 0 AS reasoning_tokens
12001
+ `.trim(),
12002
+ from: "api_usage_events e",
12003
+ where: "",
12004
+ rowAlias: "e",
12005
+ lowerBound: scanState?.api?.overlap,
12006
+ options,
12007
+ }));
12008
+ }
12009
+ }
12010
+ return rows;
12011
+ }
12012
+
12013
+ function qualifyUnslothModel(row) {
12014
+ const rawModel = normalizeModelInput(row?.response_model)
12015
+ || normalizeModelInput(row?.requested_model)
12016
+ || normalizeModelInput(row?.fallback_model)
12017
+ || DEFAULT_MODEL;
12018
+ const providerType = normalizeMessageKeyPart(row?.provider_type).toLowerCase();
12019
+ if (row?.usage_kind === "api" || providerType === "local") {
12020
+ return `local/${rawModel}`;
12021
+ }
12022
+ if (!UNSLOTH_METERED_PROVIDER_TYPES.has(providerType)) {
12023
+ return `unpriced/${providerType || "unknown"}/${rawModel}`;
12024
+ }
12025
+ const lowerModel = rawModel.toLowerCase();
12026
+ return lowerModel.startsWith(`${providerType}/`)
12027
+ ? rawModel
12028
+ : `${providerType}/${rawModel}`;
12029
+ }
12030
+
12031
+ function normalizeUnslothUsageRow(row) {
12032
+ const totals = normalizeLocalStudioTokens({
12033
+ prompt_tokens: row?.prompt_tokens,
12034
+ completion_tokens: row?.completion_tokens,
12035
+ total_tokens: row?.total_tokens,
12036
+ cached_tokens: row?.cached_tokens,
12037
+ cache_creation_input_tokens: row?.cache_write_tokens,
12038
+ reasoning_tokens: row?.reasoning_tokens,
12039
+ });
12040
+ const id = normalizeMessageKeyPart(row?.id == null ? "" : String(row.id));
12041
+ const timestampMs = coerceEpochMs(row?.created_at) || parseIsoTimestampMs(row?.created_at);
12042
+ const bucketStart = timestampMs
12043
+ ? toUtcHalfHourStart(new Date(timestampMs).toISOString())
12044
+ : null;
12045
+ if (!id || !totals || !bucketStart) return null;
12046
+ const kind = row?.usage_kind === "api" ? "api" : "chat";
12047
+ return {
12048
+ key: `unsloth:${kind}:${id}`,
12049
+ model: qualifyUnslothModel(row),
12050
+ bucketStart,
12051
+ totals,
12052
+ };
12053
+ }
12054
+
12055
+ async function parseUnslothIncremental({
12056
+ dbPath,
12057
+ cursors,
12058
+ queuePath,
12059
+ onProgress,
12060
+ env,
12061
+ sqliteOptions,
12062
+ overlapRows = UNSLOTH_CURSOR_OVERLAP_ROWS,
12063
+ } = {}) {
12064
+ await ensureDir(path.dirname(queuePath));
12065
+ const resolvedDb = dbPath || resolveUnslothDbPath(env || process.env);
12066
+ const priorState = cursors.unsloth && typeof cursors.unsloth === "object"
12067
+ ? cursors.unsloth
12068
+ : {};
12069
+ const messages = priorState.messages && typeof priorState.messages === "object"
12070
+ ? priorState.messages
12071
+ : {};
12072
+ if (!resolvedDb || !fssync.existsSync(resolvedDb)) {
12073
+ cursors.unsloth = { ...priorState, messages, updatedAt: new Date().toISOString() };
12074
+ return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
12075
+ }
12076
+
12077
+ const initialFingerprint = unslothSqliteFingerprint(resolvedDb);
12078
+ const hasBoundedScan = priorState.scan
12079
+ && typeof priorState.scan === "object"
12080
+ && Object.keys(messages).length <= UNSLOTH_CURSOR_OVERLAP_ROWS * 2;
12081
+ if (hasBoundedScan && sameSqliteFingerprint(initialFingerprint, priorState.fingerprint)) {
12082
+ return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
12083
+ }
12084
+
12085
+ const rows = readUnslothUsageRows(resolvedDb, sqliteOptions, priorState.scan);
12086
+ const hourlyState = normalizeHourlyState(cursors?.hourly);
12087
+ const touchedBuckets = new Set();
12088
+ const cb = typeof onProgress === "function" ? onProgress : null;
12089
+ let recordsProcessed = 0;
12090
+ let eventsAggregated = 0;
12091
+ for (let index = 0; index < rows.length; index++) {
12092
+ const event = normalizeUnslothUsageRow(rows[index]);
12093
+ recordsProcessed += 1;
12094
+ if (event && reconcilePassiveUsageEvent({
12095
+ event,
12096
+ source: UNSLOTH_SOURCE,
12097
+ messages,
12098
+ hourlyState,
12099
+ touchedBuckets,
12100
+ })) eventsAggregated += 1;
12101
+ if (cb) {
12102
+ cb({
12103
+ index: index + 1,
12104
+ total: rows.length,
12105
+ recordsProcessed,
12106
+ eventsAggregated,
12107
+ bucketsQueued: touchedBuckets.size,
12108
+ });
12109
+ }
12110
+ }
12111
+
12112
+ const retention = Number.isInteger(overlapRows) && overlapRows > 0
12113
+ ? Math.min(overlapRows, UNSLOTH_CURSOR_OVERLAP_ROWS)
12114
+ : UNSLOTH_CURSOR_OVERLAP_ROWS;
12115
+ const nextScan = {};
12116
+ const retainedMessageKeys = new Set();
12117
+ for (const kind of ["chat", "api"]) {
12118
+ const kindRows = rows.filter((row) => (row?.usage_kind === "api" ? "api" : "chat") === kind);
12119
+ if (kindRows.length === 0) {
12120
+ if (priorState.scan?.[kind]) nextScan[kind] = priorState.scan[kind];
12121
+ for (const key of Object.keys(messages)) {
12122
+ if (key.startsWith(`unsloth:${kind}:`)) retainedMessageKeys.add(key);
12123
+ }
12124
+ continue;
12125
+ }
12126
+ const tail = kindRows.slice(-retention);
12127
+ const overlap = unslothRowPosition(tail[0]);
12128
+ const latest = unslothRowPosition(kindRows[kindRows.length - 1]);
12129
+ if (overlap && latest) nextScan[kind] = { overlap, latest };
12130
+ for (const row of tail) {
12131
+ const event = normalizeUnslothUsageRow(row);
12132
+ if (event) retainedMessageKeys.add(event.key);
12133
+ }
12134
+ }
12135
+ for (const key of Object.keys(messages)) {
12136
+ if (key.startsWith("unsloth:") && !retainedMessageKeys.has(key)) delete messages[key];
12137
+ }
12138
+
12139
+ const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets });
12140
+ const finalFingerprint = unslothSqliteFingerprint(resolvedDb);
12141
+ const updatedAt = new Date().toISOString();
12142
+ hourlyState.updatedAt = updatedAt;
12143
+ cursors.hourly = hourlyState;
12144
+ cursors.unsloth = {
12145
+ messages,
12146
+ scan: nextScan,
12147
+ fingerprint: sameSqliteFingerprint(initialFingerprint, finalFingerprint)
12148
+ ? finalFingerprint
12149
+ : initialFingerprint,
12150
+ updatedAt,
12151
+ };
12152
+ return { recordsProcessed, eventsAggregated, bucketsQueued };
12153
+ }
12154
+
11163
12155
  // ─────────────────────────────────────────────────────────────────────────────
11164
12156
  // AnythingLLM Desktop (Mintplex Labs)
11165
12157
  //
@@ -14251,6 +15243,13 @@ async function migrateCopilotChatLogRecordDedup({
14251
15243
  touchedBuckets,
14252
15244
  seenIds,
14253
15245
  } = {}) {
15246
+ // A v2 cursor can retain an offset for a rotated or deleted OTEL file that
15247
+ // is no longer returned by discovery. Its historical contribution cannot be
15248
+ // reconciled, but the stale offset must not block migration of new files.
15249
+ const availableFiles = new Set(Array.isArray(files) ? files : []);
15250
+ for (const filePath of Object.keys(fileOffsets || {})) {
15251
+ if (!availableFiles.has(filePath)) delete fileOffsets[filePath];
15252
+ }
14254
15253
  const trackedFiles = Object.keys(fileOffsets || {}).filter(
14255
15254
  (filePath) => Number(fileOffsets[filePath]?.size) > 0,
14256
15255
  );
@@ -14258,11 +15257,6 @@ async function migrateCopilotChatLogRecordDedup({
14258
15257
  return { applied: true, changed: false };
14259
15258
  }
14260
15259
 
14261
- const availableFiles = new Set(Array.isArray(files) ? files : []);
14262
- if (trackedFiles.some((filePath) => !availableFiles.has(filePath))) {
14263
- return { applied: false, reason: "a previously parsed OTEL file is unavailable" };
14264
- }
14265
-
14266
15260
  const oldSeen = new Set();
14267
15261
  const newSeen = new Set();
14268
15262
  const oldTotals = new Map();
@@ -14342,7 +15336,10 @@ async function migrateCopilotChatLogRecordDedup({
14342
15336
  }
14343
15337
 
14344
15338
  const keys = new Set([...oldTotals.keys(), ...newTotals.keys()]);
14345
- for (const key of keys) {
15339
+ // Only keys that existed in the v2 contribution need coverage validation.
15340
+ // A key that exists only in newTotals is a newly recovered Chat request; its
15341
+ // old contribution is zero, so an absent old bucket is expected.
15342
+ for (const key of oldTotals.keys()) {
14346
15343
  const [model, bucketStart] = JSON.parse(key);
14347
15344
  const oldUsage = oldTotals.get(key) || initTotals();
14348
15345
  if (!copilotTotalsCover(
@@ -17417,6 +18414,7 @@ async function parseAntigravityFile({
17417
18414
  // Match the mainstream convention (Codebuddy / Kilocode / OMP / Hermes):
17418
18415
  // total_tokens = sum of every token column. No cache columns here.
17419
18416
  delta.total_tokens = inputDelta + outputTokens + reasoningTokens;
18417
+ delta.billable_total_tokens = delta.total_tokens;
17420
18418
  delta.conversation_count = 1;
17421
18419
  billedPlanner = delta.total_tokens > 0;
17422
18420
  }
@@ -18955,6 +19953,15 @@ module.exports = {
18955
19953
  sumZedRequestUsage,
18956
19954
  readZedUsage,
18957
19955
  parseZedIncremental,
19956
+ resolveLmstudioHome,
19957
+ resolveLmstudioLogFiles,
19958
+ normalizeLocalStudioTokens,
19959
+ readLmstudioFileRecords,
19960
+ parseLmstudioIncremental,
19961
+ resolveUnslothDbPath,
19962
+ readUnslothUsageRows,
19963
+ normalizeUnslothUsageRow,
19964
+ parseUnslothIncremental,
18958
19965
  resolveAnythingllmDbPath,
18959
19966
  parseAnythingllmTimestamp,
18960
19967
  readAnythingllmUsageRows,