tokentracker-cli 1.3.8 → 1.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/README.de.md +7 -6
  2. package/README.ja.md +6 -5
  3. package/README.ko.md +6 -5
  4. package/README.md +7 -6
  5. package/README.zh-CN.md +6 -5
  6. package/dashboard/dist/assets/{AchievementBadge-BqLeP5y3.js → AchievementBadge-vU4Dwu_p.js} +1 -1
  7. package/dashboard/dist/assets/{AchievementsPage-BwVkM45R.js → AchievementsPage-C8LspyPK.js} +2 -2
  8. package/dashboard/dist/assets/{AchievementsSection-CglgOcQ1.js → AchievementsSection-CP8J07fV.js} +1 -1
  9. package/dashboard/dist/assets/{BadgeDetailModal-IurF0cmo.js → BadgeDetailModal-Ci1egHvR.js} +1 -1
  10. package/dashboard/dist/assets/{BotAnimated-C4_NxPuy.js → BotAnimated-CMXILM_h.js} +1 -1
  11. package/dashboard/dist/assets/{Card-C-UuIN42.js → Card-BHHCCnqR.js} +1 -1
  12. package/dashboard/dist/assets/{CloudCheckoutPage-CrMDquLi.js → CloudCheckoutPage-BY9Hsbix.js} +1 -1
  13. package/dashboard/dist/assets/{CloudPage-D6xTPnMs.js → CloudPage-Y6JgOXcJ.js} +2 -2
  14. package/dashboard/dist/assets/{CommandPalette-8LFLY-LQ.js → CommandPalette-BRWQFJwy.js} +1 -1
  15. package/dashboard/dist/assets/CommunityStatsModal-krT4ZZX9.js +1 -0
  16. package/dashboard/dist/assets/{ConfirmModal-St89kP59.js → ConfirmModal-ZvPOJ0he.js} +1 -1
  17. package/dashboard/dist/assets/Controls-CQlxw03i.js +1 -0
  18. package/dashboard/dist/assets/{DashboardPage-BvGqFvP7.js → DashboardPage-Dbi0d2JY.js} +1 -1
  19. package/dashboard/dist/assets/{DateRangePopover-Cbd1idiF.js → DateRangePopover-DJ8RpQfV.js} +1 -1
  20. package/dashboard/dist/assets/DevicePage-C4_cbw3M.js +1 -0
  21. package/dashboard/dist/assets/{DialogClose-Cb1-Hd8P.js → DialogClose-Dx5AqmUG.js} +1 -1
  22. package/dashboard/dist/assets/DialogDescription-LvhfT1Ox.js +1 -0
  23. package/dashboard/dist/assets/{DialogTitle-VXrHY7H0.js → DialogTitle-Cro2S8s8.js} +1 -1
  24. package/dashboard/dist/assets/{ElectricLogo-DmQqYnSD.js → ElectricLogo-gqnBCtSG.js} +1 -1
  25. package/dashboard/dist/assets/{FadeIn-BEieTXMD.js → FadeIn-B9oATcDZ.js} +1 -1
  26. package/dashboard/dist/assets/{HeaderGithubStar-BlYC_PSb.js → HeaderGithubStar-CYa94wna.js} +1 -1
  27. package/dashboard/dist/assets/{HoverTooltip-C5Pe7iBz.js → HoverTooltip-BJ4qLyuS.js} +1 -1
  28. package/dashboard/dist/assets/{IpCheckPage-Diln3EdG.js → IpCheckPage-Clpq3jdY.js} +1 -1
  29. package/dashboard/dist/assets/{LandingPage-BzGYuAO7.js → LandingPage-CuazuOAo.js} +2 -2
  30. package/dashboard/dist/assets/{LeaderboardPage-Bn-ehU5S.js → LeaderboardPage-BSF1Lbtq.js} +2 -2
  31. package/dashboard/dist/assets/{LeaderboardProBadge-SovdGwYs.js → LeaderboardProBadge-K32gY52s.js} +1 -1
  32. package/dashboard/dist/assets/{LeaderboardProfileModal-BhxmTcHh.js → LeaderboardProfileModal-D5gpn7pP.js} +3 -3
  33. package/dashboard/dist/assets/{LeaderboardProfilePage-3iAYvoTb.js → LeaderboardProfilePage-BmO9nIi-.js} +1 -1
  34. package/dashboard/dist/assets/{LimitsPage-DHxFAdqK.js → LimitsPage-D9ZJ1TeK.js} +1 -1
  35. package/dashboard/dist/assets/{LocalOnlyNotice-HlGfiiI3.js → LocalOnlyNotice-BkzysTS7.js} +1 -1
  36. package/dashboard/dist/assets/{LoginCard-CamUYmiF.js → LoginCard-Do3SNJFU.js} +1 -1
  37. package/dashboard/dist/assets/{LoginModal-CcmYUJ45.js → LoginModal-hph1i_YK.js} +1 -1
  38. package/dashboard/dist/assets/{LoginPage-DMNNmZwr.js → LoginPage-DXYAaFjJ.js} +1 -1
  39. package/dashboard/dist/assets/{PetPage-D6xFX8QA.js → PetPage-D1Pk4OKI.js} +2 -2
  40. package/dashboard/dist/assets/{PopoverPopup-CKKgItAT.js → PopoverPopup-CAIuP_a5.js} +1 -1
  41. package/dashboard/dist/assets/{ProviderIcon-B-uS3pJ3.js → ProviderIcon-R6Bsj81X.js} +1 -1
  42. package/dashboard/dist/assets/{ResetPasswordPage-DX4RJjX0.js → ResetPasswordPage-BR3GTF-3.js} +1 -1
  43. package/dashboard/dist/assets/{Select-Dcg7FYAt.js → Select-38Dh-OWv.js} +1 -1
  44. package/dashboard/dist/assets/{SelectItemText-4vuj6xMs.js → SelectItemText-90n7jKbI.js} +1 -1
  45. package/dashboard/dist/assets/{SelfHostPage-ButVBgE7.js → SelfHostPage-BKVYkHI6.js} +1 -1
  46. package/dashboard/dist/assets/{ServiceStatusPage-DJDDO6lV.js → ServiceStatusPage-PBmBn29J.js} +1 -1
  47. package/dashboard/dist/assets/{SessionsPage-CiUirFMD.js → SessionsPage-58MfDHhe.js} +3 -3
  48. package/dashboard/dist/assets/SettingsPage-BNLHbZUQ.js +5 -0
  49. package/dashboard/dist/assets/{SkillsPage-D2tfFFY8.js → SkillsPage-DMMTk4jD.js} +1 -1
  50. package/dashboard/dist/assets/{TokenGalaxy-TQ-eWK8P.js → TokenGalaxy-BdPETjru.js} +1 -1
  51. package/dashboard/dist/assets/{TooltipPopup-BhSCJqFR.js → TooltipPopup-BXl-vM4L.js} +1 -1
  52. package/dashboard/dist/assets/{TrendMonitor-CP6ZZ53c.js → TrendMonitor-wPfTlX-7.js} +1 -1
  53. package/dashboard/dist/assets/{WidgetsPage-BdeR7P8D.js → WidgetsPage-CuwRMWka.js} +1 -1
  54. package/dashboard/dist/assets/{WrappedPage-OqcqupRE.js → WrappedPage-DOpA9kkg.js} +1 -1
  55. package/dashboard/dist/assets/{__vite-browser-external-BXwSXUpN.js → __vite-browser-external-Go6WKt6Z.js} +3 -3
  56. package/dashboard/dist/assets/__vite-browser-external-kAUz3kFs.css +1 -0
  57. package/dashboard/dist/assets/{agent-logos-E6GfkcgD.js → agent-logos-D7eMvAlk.js} +1 -1
  58. package/dashboard/dist/assets/{arrow-left-D_ufthLH.js → arrow-left--eOVP_Dk.js} +1 -1
  59. package/dashboard/dist/assets/{arrow-right-CgV_OrFz.js → arrow-right-DJYhwBMz.js} +1 -1
  60. package/dashboard/dist/assets/{arrow-up-right-CzeEeXRz.js → arrow-up-right-DhDvpd-S.js} +1 -1
  61. package/dashboard/dist/assets/{calendar-days-BQdTF7ec.js → calendar-days-BuOFlNcJ.js} +1 -1
  62. package/dashboard/dist/assets/{check-C--D8Lze.js → check-UztXSVYi.js} +1 -1
  63. package/dashboard/dist/assets/{chevron-down-BzkceD4z.js → chevron-down-CbJ2QGDU.js} +1 -1
  64. package/dashboard/dist/assets/{cloud-checkout-m1Yllbij.js → cloud-checkout-CoRQjKEu.js} +1 -1
  65. package/dashboard/dist/assets/{composite-7DhFi6TA.js → composite-PlKvZfdM.js} +1 -1
  66. package/dashboard/dist/assets/{download-BSyGLtfW.js → download-McE9gFto.js} +1 -1
  67. package/dashboard/dist/assets/{external-link-KzFbueBT.js → external-link-DZWnR9gS.js} +1 -1
  68. package/dashboard/dist/assets/{flame-DlPM_oaj.js → flame-0xjueXt4.js} +1 -1
  69. package/dashboard/dist/assets/{format-tokens-BHBaWyA3.js → format-tokens-CZoG68uJ.js} +1 -1
  70. package/dashboard/dist/assets/{gift-BhWBQnun.js → gift-pKc80Nqb.js} +1 -1
  71. package/dashboard/dist/assets/{host-mode--iIPGrkN.js → host-mode-99_v1j5J.js} +1 -1
  72. package/dashboard/dist/assets/{icons-COEaSBYi.js → icons-BtfYKSGC.js} +1 -1
  73. package/dashboard/dist/assets/{index-Cf4HcmLp.js → index-Cgthm7BO.js} +1 -1
  74. package/dashboard/dist/assets/{index-Bo8CSxCq.js → index-CyZxN1O0.js} +1 -1
  75. package/dashboard/dist/assets/{index-CW6A_Ff_.js → index-DuaQmNZo.js} +1 -1
  76. package/dashboard/dist/assets/{info-D1gOdsM_.js → info-C8vkxwsO.js} +1 -1
  77. package/dashboard/dist/assets/{link-2-CXDnDOK4.js → link-2-CQzIW5b6.js} +1 -1
  78. package/dashboard/dist/assets/{loader-circle-DWeQPecG.js → loader-circle-B9GqzQyC.js} +1 -1
  79. package/dashboard/dist/assets/{modifiers.esm-BljqMT7V.js → modifiers.esm-ChkCUrZp.js} +1 -1
  80. package/dashboard/dist/assets/{monitor-smartphone-DpkdtOjk.js → monitor-smartphone-DQjBY1W3.js} +1 -1
  81. package/dashboard/dist/assets/{play-BlGNrekI.js → play-qC6xc6fe.js} +1 -1
  82. package/dashboard/dist/assets/{plus-CpYVQ_t-.js → plus-CoNHx5Og.js} +1 -1
  83. package/dashboard/dist/assets/{provider-display-D6Blvktj.js → provider-display-x2EG6zAY.js} +1 -1
  84. package/dashboard/dist/assets/{react-BMJ2wuz1.js → react-EctmAw3f.js} +1 -1
  85. package/dashboard/dist/assets/{safePolygon-BFHXICk1.js → safePolygon-DlSbLtZC.js} +1 -1
  86. package/dashboard/dist/assets/{search-0NxXkytY.js → search-CBcpAj9I.js} +1 -1
  87. package/dashboard/dist/assets/{server-GHkrUKSI.js → server-DtMUoEq0.js} +1 -1
  88. package/dashboard/dist/assets/{shield-check-C16cbx1z.js → shield-check-ByUPtUmu.js} +1 -1
  89. package/dashboard/dist/assets/{skills-api-DAm1fFB4.js → skills-api-BD9VNRej.js} +1 -1
  90. package/dashboard/dist/assets/{sortable.esm-CkOax8h0.js → sortable.esm-B6qEViGJ.js} +1 -1
  91. package/dashboard/dist/assets/{store-DR444-5P.js → store-BgW2uBRe.js} +1 -1
  92. package/dashboard/dist/assets/{terminal-BpvNaIz9.js → terminal-DWPNkr2L.js} +1 -1
  93. package/dashboard/dist/assets/{trash-DX8LwOmN.js → trash-DdbRpgD1.js} +1 -1
  94. package/dashboard/dist/assets/{use-community-stats-BFW4-DYG.js → use-community-stats-43ySv9uM.js} +1 -1
  95. package/dashboard/dist/assets/{use-latest-request-guard-Cpcl08EJ.js → use-latest-request-guard-BuezAv4k.js} +1 -1
  96. package/dashboard/dist/assets/{use-limits-display-prefs-BqixyeAa.js → use-limits-display-prefs-Yi0Hznar.js} +1 -1
  97. package/dashboard/dist/assets/{use-native-settings-BSx7lGEK.js → use-native-settings-BaSrhrM8.js} +1 -1
  98. package/dashboard/dist/assets/{use-ordered-list-UCjB0_zK.js → use-ordered-list-CBD-EIBf.js} +1 -1
  99. package/dashboard/dist/assets/use-reduced-motion-ObUuoXUE.js +1 -0
  100. package/dashboard/dist/assets/{use-session-efficiency-pref-BgOJaGEy.js → use-session-efficiency-pref-CwaFIkE5.js} +1 -1
  101. package/dashboard/dist/assets/{use-transform-Y98QifnQ.js → use-transform-Dea4uz8K.js} +1 -1
  102. package/dashboard/dist/assets/{use-usage-limits-DJGO-Kxv.js → use-usage-limits-DxkYcUIR.js} +1 -1
  103. package/dashboard/dist/assets/{useCurrency-DL458pqS.js → useCurrency-CcZktkbA.js} +1 -1
  104. package/dashboard/dist/assets/{usePositioner-DnCQwlVT.js → usePositioner-BLGIGihf.js} +1 -1
  105. package/dashboard/dist/assets/{useTokenFormat-CYLt9pgs.js → useTokenFormat--dhm3nNf.js} +1 -1
  106. package/dashboard/dist/assets/{zap-BW6E_Jv3.js → zap-CWTmP6Zw.js} +1 -1
  107. package/dashboard/dist/index.html +14 -12
  108. package/dashboard/dist/ip-check.html +2 -2
  109. package/dashboard/dist/leaderboard.html +2 -2
  110. package/dashboard/dist/llms.txt +3 -2
  111. package/dashboard/dist/share.html +2 -2
  112. package/package.json +2 -2
  113. package/src/commands/init.js +9 -0
  114. package/src/commands/status.js +11 -0
  115. package/src/commands/sync.js +197 -122
  116. package/src/lib/dumate.js +95 -0
  117. package/src/lib/pricing/seed-snapshot.json +1 -1
  118. package/src/lib/qoder-cn-cli-limits.js +117 -0
  119. package/src/lib/qoder-limits.js +24 -11
  120. package/src/lib/rollout.js +12 -9
  121. package/src/lib/session-analytics.js +210 -19
  122. package/src/lib/upload-queue.js +134 -0
  123. package/dashboard/dist/assets/CommunityStatsModal-DYTjxABN.js +0 -1
  124. package/dashboard/dist/assets/Controls-BJAwM2gB.js +0 -1
  125. package/dashboard/dist/assets/DevicePage-BcSU0xgf.js +0 -1
  126. package/dashboard/dist/assets/DialogDescription-DQvYzWMp.js +0 -1
  127. package/dashboard/dist/assets/SettingsPage-CB4CML8u.js +0 -5
  128. package/dashboard/dist/assets/__vite-browser-external-DvNSoTb8.css +0 -1
  129. package/dashboard/dist/assets/use-reduced-motion-C6zOHbfR.js +0 -1
@@ -8,6 +8,7 @@ const { functionUrlFor, fetchFunctionResponse } = require("../lib/function-url")
8
8
 
9
9
  const { resolveInstallPaths, resolveZcodeNativeDbPath, resolveMimoNativeDbPath, ensureFlatCursor } = require("../lib/install-resolver");
10
10
  const { multiInstallParse, mergeBothFileSources } = require("../lib/multi-install-parser");
11
+ const { resolveDumateDbPaths, parseDumateIncremental } = require("../lib/dumate");
11
12
  const wsl = require("../lib/wsl-probe");
12
13
  const {
13
14
  ensureDir,
@@ -155,12 +156,14 @@ const {
155
156
  recordUploadSuccess,
156
157
  parseRetryAfterMs,
157
158
  } = require("../lib/upload-throttle");
159
+ const { compactPendingQueue, iterateQueueLines, sessionSnapshotMs } = require("../lib/upload-queue");
158
160
  const AUTO_UPLOAD_CONFIG = {
159
161
  intervalMs: 15 * 60_000,
160
162
  batchSize: 200,
161
163
  maxBatchesSmall: 5,
162
- maxBatchesLarge: 5,
164
+ maxBatchesLarge: 100,
163
165
  };
166
+ const UPLOAD_BUDGET_MS = 90_000;
164
167
  const { maybeSendHeartbeat } = require("../lib/telemetry");
165
168
  const {
166
169
  isCursorInstalled,
@@ -327,6 +330,7 @@ const AUTO_SYNC_SOURCES = new Set([
327
330
  "cursor",
328
331
  "devin",
329
332
  "droid",
333
+ "dumate",
330
334
  "dsh",
331
335
  "every-code",
332
336
  "gemini",
@@ -1686,6 +1690,20 @@ async function cmdSync(argv, context = {}) {
1686
1690
  }
1687
1691
  }
1688
1692
 
1693
+ let dumateResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
1694
+ if (sourceAllowed("dumate")) {
1695
+ const dumateDbPaths = resolveDumateDbPaths({ home });
1696
+ if (dumateDbPaths.length > 0) {
1697
+ if (progress?.enabled) progress.start(`Parsing DuMate ${renderBar(0)} | buckets 0`);
1698
+ try {
1699
+ dumateResult = await parseDumateIncremental({
1700
+ dbPaths: dumateDbPaths, cursors, queuePath, projectQueuePath,
1701
+ onProgress: makeProviderProgress("DuMate"),
1702
+ });
1703
+ } catch (err) { warnProviderParseFailure("DuMate", err, opts); }
1704
+ }
1705
+ }
1706
+
1689
1707
  // ── Mimo (mimocode — OpenCode-fork SQLite) ──
1690
1708
  // readMimoDbMessages filters out mirrored Claude Code rows to avoid
1691
1709
  // double-counting usage already counted as source=claude.
@@ -3319,6 +3337,7 @@ async function cmdSync(argv, context = {}) {
3319
3337
  anythingllmResult.recordsProcessed +
3320
3338
  devinResult.recordsProcessed +
3321
3339
  kiloResult.recordsProcessed +
3340
+ dumateResult.recordsProcessed +
3322
3341
  mimoResult.recordsProcessed +
3323
3342
  zcodeResult.recordsProcessed +
3324
3343
  kilocodeResult.recordsProcessed +
@@ -3364,6 +3383,7 @@ async function cmdSync(argv, context = {}) {
3364
3383
  anythingllmResult.bucketsQueued +
3365
3384
  devinResult.bucketsQueued +
3366
3385
  kiloResult.bucketsQueued +
3386
+ dumateResult.bucketsQueued +
3367
3387
  mimoResult.bucketsQueued +
3368
3388
  zcodeResult.bucketsQueued +
3369
3389
  kilocodeResult.bucketsQueued +
@@ -3490,6 +3510,10 @@ async function cmdSync(argv, context = {}) {
3490
3510
  if (canUpload() && runtime.deviceToken && runtime.baseUrl &&
3491
3511
  (!isBackgroundLightweightSync || opts.publishAccount) &&
3492
3512
  (!autoUploadDecision || autoUploadDecision.allowed)) {
3513
+ // Decide the window budget from the original backlog. Compact only once
3514
+ // upload is allowed so a cooldown run cannot shrink away that budget.
3515
+ const state = (await readJson(queueStatePath)) || { offset: 0 };
3516
+ await compactPendingQueue({ queuePath, offset: Number(state.offset || 0) });
3493
3517
  uploadAttempted = true;
3494
3518
  // Mirror the machine identity into the purge-surviving seed file so a
3495
3519
  // future `uninstall --purge` + reinstall recovers the same cloud device
@@ -3511,6 +3535,7 @@ async function cmdSync(argv, context = {}) {
3511
3535
  queueStatePath,
3512
3536
  maxBatches: opts.drain ? 100 : (autoUploadDecision?.maxBatches || 5),
3513
3537
  batchSize: autoUploadDecision?.batchSize || 200,
3538
+ maxDurationMs: UPLOAD_BUDGET_MS,
3514
3539
  canUpload,
3515
3540
  });
3516
3541
  try {
@@ -3570,6 +3595,14 @@ async function cmdSync(argv, context = {}) {
3570
3595
  await writeJson(uploadThrottlePath, uploadThrottleState);
3571
3596
  }
3572
3597
  } catch (e) {
3598
+ if (e.uploadResult?.batches > 0) {
3599
+ uploadResult = e.uploadResult;
3600
+ uploadThrottleState = recordUploadSuccess({
3601
+ nowMs: Date.now(), state: uploadThrottleState, config: AUTO_UPLOAD_CONFIG,
3602
+ cloudAccess: uploadResult.cloudAccess,
3603
+ attemptId: process.env.TOKENTRACKER_LOCAL_SYNC_ATTEMPT_ID,
3604
+ });
3605
+ }
3573
3606
  // Persist a backoff on 429 / 5xx so the next auto-sync waits instead
3574
3607
  // of retrying immediately and making the rate-limit worse. The
3575
3608
  // throttle module already parses Retry-After when we surface it on
@@ -4252,7 +4285,7 @@ const AUTO_RETRY_MAX_DELAY_MS = 2 * 60 * 60 * 1000;
4252
4285
  const INGEST_SLUG = "tokentracker-ingest";
4253
4286
  const MAX_INGEST_BUCKETS = 500;
4254
4287
 
4255
- async function drainQueueToCloud({ baseUrl, anonKey, deviceToken, queuePath, queueStatePath, maxBatches = 5, batchSize = 200, canUpload = () => true }) {
4288
+ async function drainQueueToCloud({ baseUrl, anonKey, deviceToken, queuePath, queueStatePath, maxBatches = 5, batchSize = 200, maxDurationMs = UPLOAD_BUDGET_MS, canUpload = () => true }) {
4256
4289
  const state = (await readJson(queueStatePath)) || { offset: 0 };
4257
4290
  let offset = Number(state.offset || 0);
4258
4291
  let inserted = 0;
@@ -4260,84 +4293,113 @@ async function drainQueueToCloud({ baseUrl, anonKey, deviceToken, queuePath, que
4260
4293
  let batches = 0;
4261
4294
  let cloudAccess = null;
4262
4295
  const uploadId = crypto.randomUUID();
4296
+ const deadline = Date.now() + maxDurationMs;
4297
+ let batchBudget = Math.min(maxBatches, 100);
4298
+ let requestSignal = null;
4299
+ const resultSoFar = () => ({ inserted, skipped, batches, ...(cloudAccess?.next_allowed_at ? { cloudAccess } : {}) });
4263
4300
 
4264
4301
  const queueSize = await safeStatSize(queuePath);
4265
4302
  const limit = Math.min(Math.max(1, Math.floor(Number(batchSize || 200))), MAX_INGEST_BUCKETS);
4266
4303
 
4267
- for (let batch = 0; batch < maxBatches; batch++) {
4268
- if (offset >= queueSize) break;
4269
- const result = await readQueueBatch(queuePath, offset, limit);
4270
- // A states-only tail (e.g. a retry batch whose bucket rows already
4271
- // uploaded) is still a meaningful upload: it advances the canonical
4272
- // session corrections cloud-side.
4273
- if (result.buckets.length === 0 && result.sessionStates.length === 0) break;
4274
-
4275
- const root = baseUrl.replace(/\/$/, "");
4276
- const headers = {
4277
- "Content-Type": "application/json",
4278
- Accept: "application/json",
4279
- Authorization: `Bearer ${deviceToken}`,
4280
- };
4281
- if (anonKey) headers.apikey = anonKey;
4282
- // Re-read after parsing/each batch so switching off stops an active drain.
4283
- if (!canUpload()) break;
4284
- const res = await fetchFunctionResponse(functionUrlFor(root, INGEST_SLUG), {
4285
- method: "POST",
4286
- headers,
4287
- body: JSON.stringify({
4288
- upload_id: uploadId,
4289
- hourly: result.buckets,
4290
- ...(result.sessionStates.length > 0
4291
- ? { account_session_states: result.sessionStates }
4292
- : {}),
4293
- }),
4294
- });
4304
+ try {
4305
+ for (let batch = 0; batch < batchBudget; batch++) {
4306
+ if (Date.now() >= deadline) break;
4307
+ if (offset >= queueSize) break;
4308
+ const result = await readQueueBatch(queuePath, offset, limit);
4309
+ // A states-only tail (e.g. a retry batch whose bucket rows already
4310
+ // uploaded) is still a meaningful upload: it advances the canonical
4311
+ // session corrections cloud-side.
4312
+ if (result.buckets.length === 0 && result.sessionStates.length === 0) {
4313
+ // Complete ignored control/malformed rows need no cloud request, but
4314
+ // must not leave a permanent pending tail. Incomplete bytes stay put.
4315
+ if (result.nextOffset > offset) {
4316
+ offset = result.nextOffset;
4317
+ state.offset = offset;
4318
+ state.updatedAt = new Date().toISOString();
4319
+ await writeJson(queueStatePath, state);
4320
+ }
4321
+ break;
4322
+ }
4295
4323
 
4296
- const rawText = await res.text().catch(() => "");
4297
- let data = {};
4298
- try { data = JSON.parse(rawText); } catch { data = {}; }
4299
- if (!res.ok) {
4300
- const err = new Error(`HTTP ${res.status}: ${rawText.substring(0, 500)}`);
4301
- err.status = res.status;
4302
- err.code = res.status === 401 ? "CLOUD_DEVICE_TOKEN_REJECTED"
4303
- : typeof data?.code === "string" ? data.code
4304
- : res.status === 403 ? "CLOUD_UPLOAD_FORBIDDEN" : "CLOUD_UPLOAD_FAILED";
4305
- const retryAfter = res.headers?.get?.("Retry-After") ?? null;
4306
- const retryAfterMs = parseRetryAfterMs(retryAfter);
4307
- if (retryAfterMs !== null) err.retryAfterMs = retryAfterMs;
4308
- throw err;
4309
- }
4310
-
4311
- cloudAccess = data;
4312
- inserted += Number(data?.inserted || 0);
4313
- skipped += Number(data?.skipped || 0);
4314
- batches += 1;
4315
-
4316
- offset = result.nextOffset;
4317
- state.offset = offset;
4318
- state.updatedAt = new Date().toISOString();
4319
- await writeJson(queueStatePath, state);
4320
- }
4324
+ const root = baseUrl.replace(/\/$/, "");
4325
+ const headers = {
4326
+ "Content-Type": "application/json",
4327
+ Accept: "application/json",
4328
+ Authorization: `Bearer ${deviceToken}`,
4329
+ };
4330
+ if (anonKey) headers.apikey = anonKey;
4331
+ // Re-read after parsing/each batch so switching off stops an active drain.
4332
+ if (!canUpload()) break;
4333
+ const remainingMs = deadline - Date.now();
4334
+ if (remainingMs <= 0) break;
4335
+ requestSignal = AbortSignal.timeout(Math.max(1, Math.ceil(remainingMs)));
4336
+ const res = await fetchFunctionResponse(functionUrlFor(root, INGEST_SLUG), {
4337
+ method: "POST",
4338
+ headers,
4339
+ signal: requestSignal,
4340
+ body: JSON.stringify({
4341
+ upload_id: uploadId,
4342
+ hourly: result.buckets,
4343
+ ...(result.sessionStates.length > 0
4344
+ ? { account_session_states: result.sessionStates }
4345
+ : {}),
4346
+ }),
4347
+ });
4321
4348
 
4322
- return { inserted, skipped, batches, ...(cloudAccess?.next_allowed_at ? { cloudAccess } : {}) };
4349
+ const rawText = await res.text();
4350
+ let data = {};
4351
+ try { data = JSON.parse(rawText); } catch { data = {}; }
4352
+ if (!res.ok) {
4353
+ const err = new Error(`HTTP ${res.status}: ${rawText.substring(0, 500)}`);
4354
+ err.status = res.status;
4355
+ err.code = res.status === 401 ? "CLOUD_DEVICE_TOKEN_REJECTED"
4356
+ : typeof data?.code === "string" ? data.code
4357
+ : res.status === 403 ? "CLOUD_UPLOAD_FORBIDDEN" : "CLOUD_UPLOAD_FAILED";
4358
+ const retryAfter = res.headers?.get?.("Retry-After") ?? null;
4359
+ const retryAfterMs = parseRetryAfterMs(retryAfter);
4360
+ if (retryAfterMs !== null) err.retryAfterMs = retryAfterMs;
4361
+ throw err;
4362
+ }
4363
+
4364
+ cloudAccess = data;
4365
+ if (data.upload_scope === "community") batchBudget = Math.min(batchBudget, 10);
4366
+ inserted += Number(data?.inserted || 0);
4367
+ skipped += Number(data?.skipped || 0);
4368
+ batches += 1;
4369
+
4370
+ offset = result.nextOffset;
4371
+ state.offset = offset;
4372
+ state.updatedAt = new Date().toISOString();
4373
+ await writeJson(queueStatePath, state);
4374
+ }
4375
+ } catch (error) {
4376
+ // A bounded drain can finish partially without discarding successful
4377
+ // offsets or the server cooldown. Other failures retain their retry path.
4378
+ const requestTimedOut = requestSignal?.aborted && requestSignal.reason?.name === "TimeoutError" &&
4379
+ (error === requestSignal.reason || error.name === "TimeoutError" || error.name === "AbortError");
4380
+ if (!requestTimedOut || batches === 0) {
4381
+ error.uploadResult = resultSoFar();
4382
+ throw error;
4383
+ }
4384
+ }
4385
+ return resultSoFar();
4323
4386
  }
4324
4387
 
4325
4388
  async function readQueueBatch(queuePath, startOffset, maxBuckets) {
4326
4389
  const st = await fs.stat(queuePath).catch(() => null);
4327
- if (!st || !st.isFile()) return { buckets: [], nextOffset: startOffset };
4328
- if (startOffset >= st.size) return { buckets: [], nextOffset: startOffset };
4390
+ if (!st || !st.isFile()) return { buckets: [], sessionStates: [], nextOffset: startOffset };
4391
+ if (startOffset >= st.size) return { buckets: [], sessionStates: [], nextOffset: startOffset };
4329
4392
 
4330
- const stream = fssync.createReadStream(queuePath, { encoding: "utf8", start: startOffset });
4331
- const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
4393
+ const handle = await fs.open(queuePath, "r");
4332
4394
 
4333
4395
  const bucketMap = new Map();
4334
4396
  // Account session states (kind: "account_session_state") are queue
4335
4397
  // control records, not usage rows: collect them separately so they ride
4336
4398
  // the same upload (after the bucket rows in file order) without entering
4337
- // the bucket stream. Keyed by (source, session_id) LAST-WINS: the
4338
- // append-only queue writes a session's newer observation after its older
4339
- // one, and only the latest per session may reach the ingest batch (a
4340
- // duplicate would make the cloud ON CONFLICT affect one row twice).
4399
+ // the bucket stream. Keyed by (source, session_id), use the strictly newer
4400
+ // snapshot_verified_at and retain the first row on ties, matching account
4401
+ // truth even after a clock rollback. Only one observation per session may
4402
+ // reach the ingest batch (duplicates would affect one ON CONFLICT row twice).
4341
4403
  // snapshot_verified_at rides along verbatim from the ORIGINAL fetch, so a
4342
4404
  // transport retry never fakes logical freshness.
4343
4405
  //
@@ -4351,68 +4413,81 @@ async function readQueueBatch(queuePath, startOffset, maxBuckets) {
4351
4413
  // (and the MAX_INGEST_BUCKETS=500 clamp above), every request stays under
4352
4414
  // the edge cap while batching follows the existing design.
4353
4415
  const sessionStateMap = new Map();
4416
+ const sessionStateStamps = new Map();
4354
4417
  let offset = startOffset;
4355
4418
  let linesRead = 0;
4356
- for await (const line of rl) {
4357
- const bytes = Buffer.byteLength(line, "utf8") + 1;
4358
- offset += bytes;
4359
- if (!line.trim()) continue;
4360
- let bucket;
4361
- try {
4362
- bucket = JSON.parse(line);
4363
- } catch (_e) {
4364
- continue;
4365
- }
4366
- if (bucket?.kind === "account_session_state") {
4367
- if (typeof bucket.source === "string" && typeof bucket.session_id === "string" && bucket.session_id.trim()) {
4368
- const ssSource = bucket.source.trim().toLowerCase();
4369
- sessionStateMap.set(ssSource + "|" + bucket.session_id.trim(), {
4370
- source: ssSource,
4371
- session_id: bucket.session_id.trim(),
4372
- model: typeof bucket.model === "string" ? bucket.model : "",
4373
- bucket_start: typeof bucket.bucket_start === "string" ? bucket.bucket_start : "",
4374
- input_tokens: Number(bucket.input_tokens) || 0,
4375
- output_tokens: Number(bucket.output_tokens) || 0,
4376
- cached_input_tokens: Number(bucket.cached_input_tokens) || 0,
4377
- cache_creation_input_tokens: Number(bucket.cache_creation_input_tokens) || 0,
4378
- reasoning_output_tokens: Number(bucket.reasoning_output_tokens) || 0,
4379
- total_tokens: Number(bucket.total_tokens) || 0,
4380
- ...(typeof bucket.snapshot_verified_at === "string" && bucket.snapshot_verified_at
4381
- ? { snapshot_verified_at: bucket.snapshot_verified_at }
4382
- : {}),
4383
- });
4384
- // Counted like a bucket row: the record rides THIS batch and its
4385
- // bytes are covered by nextOffset, so the next batch resumes right
4386
- // after it.
4387
- linesRead += 1;
4388
- if (linesRead >= maxBuckets) break;
4419
+ try {
4420
+ for await (const record of iterateQueueLines(handle, startOffset, st.size)) {
4421
+ // A producer may still be writing the last row; retry it once complete.
4422
+ if (!record.complete) break;
4423
+ const line = record.bytes.toString("utf8");
4424
+ offset += record.bytes.length;
4425
+ if (!line.trim()) continue;
4426
+ let bucket;
4427
+ try {
4428
+ bucket = JSON.parse(line);
4429
+ } catch (_e) {
4430
+ continue;
4389
4431
  }
4390
- continue;
4432
+ if (bucket?.kind === "account_session_state") {
4433
+ if (typeof bucket.source === "string" && typeof bucket.session_id === "string" && bucket.session_id.trim()) {
4434
+ const ssSource = bucket.source.trim().toLowerCase();
4435
+ const stateKey = ssSource + "|" + bucket.session_id.trim();
4436
+ const sessionState = {
4437
+ source: ssSource,
4438
+ session_id: bucket.session_id.trim(),
4439
+ model: typeof bucket.model === "string" ? bucket.model : "",
4440
+ bucket_start: typeof bucket.bucket_start === "string" ? bucket.bucket_start : "",
4441
+ input_tokens: Number(bucket.input_tokens) || 0,
4442
+ output_tokens: Number(bucket.output_tokens) || 0,
4443
+ cached_input_tokens: Number(bucket.cached_input_tokens) || 0,
4444
+ cache_creation_input_tokens: Number(bucket.cache_creation_input_tokens) || 0,
4445
+ reasoning_output_tokens: Number(bucket.reasoning_output_tokens) || 0,
4446
+ total_tokens: Number(bucket.total_tokens) || 0,
4447
+ ...(typeof bucket.snapshot_verified_at === "string" && bucket.snapshot_verified_at
4448
+ ? { snapshot_verified_at: bucket.snapshot_verified_at }
4449
+ : {}),
4450
+ };
4451
+ const priorState = sessionStateMap.get(stateKey);
4452
+ const verifiedMs = sessionSnapshotMs(bucket);
4453
+ const priorVerifiedMs = sessionStateStamps.get(stateKey) ?? null;
4454
+ if (!priorState || (verifiedMs !== null && (priorVerifiedMs === null || verifiedMs > priorVerifiedMs))) {
4455
+ sessionStateMap.set(stateKey, sessionState);
4456
+ sessionStateStamps.set(stateKey, verifiedMs);
4457
+ }
4458
+ // Counted like a bucket row: the record rides THIS batch and its
4459
+ // bytes are covered by nextOffset, so the next batch resumes right
4460
+ // after it.
4461
+ linesRead += 1;
4462
+ if (linesRead >= maxBuckets) break;
4463
+ }
4464
+ continue;
4465
+ }
4466
+ // Pre-release watermark records from this feature branch's dev queues
4467
+ // are obsolete control records: drop them instead of letting them enter
4468
+ // the bucket stream (they carry no hour_start and would be skipped
4469
+ // downstream anyway, but be explicit).
4470
+ if (bucket?.kind === "account_sync_watermark") continue;
4471
+ const hourStart = typeof bucket?.hour_start === "string" ? bucket.hour_start : null;
4472
+ if (!hourStart) continue;
4473
+ const source = (typeof bucket?.source === "string" ? bucket.source.trim().toLowerCase() : "") || "codex";
4474
+ const model = (typeof bucket?.model === "string" ? bucket.model.trim() : "") || "unknown";
4475
+ bucket.source = source;
4476
+ bucket.model = model;
4477
+ // Apply the same legacy-row corrections every local reader applies
4478
+ // (local-api readQueueData / project queue / wrapped aggregator). Without
4479
+ // this the cloud permanently kept the RAW legacy values — e.g. old Codex
4480
+ // rows whose input_tokens still include cached tokens (6-7x inflated) —
4481
+ // while the local dashboard showed corrected numbers.
4482
+ bucket = require("../lib/local-api").normalizeQueueRow(bucket);
4483
+ bucketMap.set(`${source}|${model}|${hourStart}`, bucket);
4484
+ linesRead += 1;
4485
+ if (linesRead >= maxBuckets) break;
4391
4486
  }
4392
- // Pre-release watermark records from this feature branch's dev queues
4393
- // are obsolete control records: drop them instead of letting them enter
4394
- // the bucket stream (they carry no hour_start and would be skipped
4395
- // downstream anyway, but be explicit).
4396
- if (bucket?.kind === "account_sync_watermark") continue;
4397
- const hourStart = typeof bucket?.hour_start === "string" ? bucket.hour_start : null;
4398
- if (!hourStart) continue;
4399
- const source = (typeof bucket?.source === "string" ? bucket.source.trim().toLowerCase() : "") || "codex";
4400
- const model = (typeof bucket?.model === "string" ? bucket.model.trim() : "") || "unknown";
4401
- bucket.source = source;
4402
- bucket.model = model;
4403
- // Apply the same legacy-row corrections every local reader applies
4404
- // (local-api readQueueData / project queue / wrapped aggregator). Without
4405
- // this the cloud permanently kept the RAW legacy values — e.g. old Codex
4406
- // rows whose input_tokens still include cached tokens (6-7x inflated) —
4407
- // while the local dashboard showed corrected numbers.
4408
- bucket = require("../lib/local-api").normalizeQueueRow(bucket);
4409
- bucketMap.set(`${source}|${model}|${hourStart}`, bucket);
4410
- linesRead += 1;
4411
- if (linesRead >= maxBuckets) break;
4412
- }
4413
-
4414
- rl.close();
4415
- stream.close?.();
4487
+
4488
+ } finally {
4489
+ await handle.close();
4490
+ }
4416
4491
  return {
4417
4492
  buckets: Array.from(bucketMap.values()),
4418
4493
  sessionStates: Array.from(sessionStateMap.values()),
@@ -0,0 +1,95 @@
1
+ const fs = require("node:fs");
2
+ const os = require("node:os");
3
+ const path = require("node:path");
4
+ const { expandHome } = require("./scan-roots");
5
+ const { readOpencodeDbMessages, parseOpencodeDbIncremental, normalizeOpencodeTokens } = require("./rollout");
6
+
7
+ function resolveDumateDbPaths({ home = os.homedir(), env = process.env, platform = process.platform } = {}) {
8
+ const override = expandHome(env.TOKENTRACKER_DUMATE_HOME, home);
9
+ const appdata = typeof env.APPDATA === "string" && env.APPDATA.trim()
10
+ ? env.APPDATA.trim() : path.join(home, "AppData", "Roaming");
11
+ if (!override && platform !== "win32") return [];
12
+ const root = override || path.join(appdata, "qianfan-desktop-app", "qianfan_desk_xdg");
13
+ // The override accepts the data/opencode directory or an install's XDG root.
14
+ const candidates = [path.join(root, "opencode.db"), path.join(root, "data", "opencode", "opencode.db")];
15
+ try {
16
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
17
+ if (entry.isDirectory()) candidates.push(path.join(root, entry.name, "data", "opencode", "opencode.db"));
18
+ }
19
+ } catch (_e) { }
20
+ const seen = new Set();
21
+ return candidates.sort().filter((candidate) => {
22
+ try {
23
+ if (!fs.statSync(candidate).isFile()) return false;
24
+ const realPath = fs.realpathSync(candidate);
25
+ if (seen.has(realPath)) return false;
26
+ seen.add(realPath);
27
+ return true;
28
+ } catch (_e) { return false; }
29
+ });
30
+ }
31
+
32
+ async function parseDumateIncremental({ dbPaths, ...options }) {
33
+ // Reinstalls can copy the same sessions into a new install-id directory.
34
+ // Share a message cursor across installations and choose the newest copy
35
+ // before parsing, so stale copies cannot roll back a completed turn.
36
+ const messages = new Map();
37
+ const includeZeroMessage = (message) => {
38
+ const provider = message.data?.providerID || message.data?.model?.providerID;
39
+ return String(provider || "").toLowerCase() === "qianfanpersonalquota";
40
+ };
41
+ for (const dbPath of dbPaths) {
42
+ for (const message of readOpencodeDbMessages(dbPath, {}, { propagateReadFailure: true, includeZeroMessage })) {
43
+ // DuMate 1.0.83 bundles OpenCode's import command without an import
44
+ // marker. Its own runtime uses QianfanPersonalQuota; other providers
45
+ // can be imported history already counted under Claude or OpenCode.
46
+ const provider = message.data?.providerID || message.data?.model?.providerID;
47
+ if (String(provider || "").toLowerCase() !== "qianfanpersonalquota") continue;
48
+ const id = message.data?.id || message.id;
49
+ const session = message.data?.sessionID || message.sessionID || "";
50
+ if (!id) continue;
51
+ const key = `${session}|${id}`;
52
+ if (Number(message.timeUpdated || 0) < Number(options.cursors?.dumate?.messages?.[key]?.sourceUpdatedAt || 0)) continue;
53
+ const previous = messages.get(key);
54
+ if (!previous || Number(message.timeUpdated || 0) > Number(previous.message.timeUpdated || 0)) {
55
+ messages.set(key, { message, dbPath });
56
+ }
57
+ }
58
+ }
59
+ const byDatabase = new Map();
60
+ for (const [key, { message, dbPath }] of messages) {
61
+ // DuMate's OpenAI-compatible adapter retains reasoning inside output.
62
+ // The shared parser expects separate columns, so subtract that subset once.
63
+ const usage = normalizeOpencodeTokens(message.data?.tokens);
64
+ // Zero copies participate in latest-copy selection so a stale install
65
+ // cannot resurrect usage. Only a previously counted message needs a
66
+ // queued retraction; a new empty message contributes nothing.
67
+ const previous = options.cursors?.dumate?.messages?.[key];
68
+ if (usage?.total_tokens === 0 && (!previous?.lastTotals || previous.dedupedForkCopy === true)) continue;
69
+ if (!byDatabase.has(dbPath)) byDatabase.set(dbPath, []);
70
+ const normalized = usage ? {
71
+ ...message,
72
+ data: { ...message.data, tokens: { ...message.data.tokens,
73
+ output: Math.max(0, usage.output_tokens - usage.reasoning_output_tokens),
74
+ } },
75
+ } : message;
76
+ byDatabase.get(dbPath).push(normalized);
77
+ }
78
+ const result = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
79
+ for (const [dbPath, dbMessages] of byDatabase) {
80
+ const parsed = await parseOpencodeDbIncremental({
81
+ ...options, dbPath, dbMessages, source: "dumate", cursorKey: "dumate",
82
+ });
83
+ for (const message of dbMessages) {
84
+ const key = `${message.data?.sessionID || message.sessionID || ""}|${message.data?.id || message.id}`;
85
+ const cursor = options.cursors?.dumate?.messages?.[key];
86
+ if (cursor) cursor.sourceUpdatedAt = Number(message.timeUpdated || 0);
87
+ }
88
+ result.recordsProcessed += parsed.messagesProcessed || 0;
89
+ result.eventsAggregated += parsed.eventsAggregated || 0;
90
+ result.bucketsQueued += parsed.bucketsQueued || 0;
91
+ }
92
+ return result;
93
+ }
94
+
95
+ module.exports = { resolveDumateDbPaths, parseDumateIncremental };