tokentracker-cli 0.90.2 → 0.91.3

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 (117) hide show
  1. package/README.de.md +7 -5
  2. package/README.ja.md +9 -5
  3. package/README.ko.md +9 -5
  4. package/README.md +7 -5
  5. package/README.zh-CN.md +6 -4
  6. package/dashboard/dist/assets/{AchievementBadge-BJl0ibLg.js → AchievementBadge-LHOuekQ3.js} +1 -1
  7. package/dashboard/dist/assets/{AchievementsPage-CV6aNe3g.js → AchievementsPage-DNzeYWBl.js} +2 -2
  8. package/dashboard/dist/assets/{AchievementsSection-DxLHu9EV.js → AchievementsSection-Ch5dImt8.js} +1 -1
  9. package/dashboard/dist/assets/{BadgeDetailModal-ConoHhIL.js → BadgeDetailModal-CuikWkUy.js} +1 -1
  10. package/dashboard/dist/assets/{Card-C_VQxG6G.js → Card-Chwjmxjz.js} +1 -1
  11. package/dashboard/dist/assets/{ClawdAnimated-_Mfn4MnU.js → ClawdAnimated-BdB7aycS.js} +1 -1
  12. package/dashboard/dist/assets/{CommandPalette-CCRCnYY9.js → CommandPalette-BMwuSEWM.js} +1 -1
  13. package/dashboard/dist/assets/{CommunityStatsModal-DuICwpdM.js → CommunityStatsModal-eEDF-xn8.js} +1 -1
  14. package/dashboard/dist/assets/{ConfirmModal-CI27robt.js → ConfirmModal-Be7f6XdL.js} +1 -1
  15. package/dashboard/dist/assets/{Controls-Cwu0gtgj.js → Controls-CtBFPCBY.js} +1 -1
  16. package/dashboard/dist/assets/{DashboardPage-GsFvPie1.js → DashboardPage-M0p-SU-f.js} +1 -1
  17. package/dashboard/dist/assets/{DevicePage-BSK76UQE.js → DevicePage-CAfcUond.js} +1 -1
  18. package/dashboard/dist/assets/{DialogClose-BXQlPs02.js → DialogClose-kd1wIljN.js} +1 -1
  19. package/dashboard/dist/assets/DialogDescription-CjJLpfCL.js +1 -0
  20. package/dashboard/dist/assets/{DialogTitle-DyvhHV1-.js → DialogTitle-DhB-tbnZ.js} +1 -1
  21. package/dashboard/dist/assets/{FadeIn-DaP6F4f8.js → FadeIn-B8eRWBdy.js} +1 -1
  22. package/dashboard/dist/assets/{HeaderGithubStar-CKB5puCd.js → HeaderGithubStar-DmWep8uP.js} +1 -1
  23. package/dashboard/dist/assets/{HoverTooltip-BG6k61dF.js → HoverTooltip-DDyTZli0.js} +1 -1
  24. package/dashboard/dist/assets/{IpCheckPage-Oqt1J9S2.js → IpCheckPage-CN8hYm4o.js} +1 -1
  25. package/dashboard/dist/assets/{LandingPage-45qoUn79.js → LandingPage-B3cHASqK.js} +2 -2
  26. package/dashboard/dist/assets/{LeaderboardAvatar-ZgXnaI_N.js → LeaderboardAvatar-CEcLXrJL.js} +1 -1
  27. package/dashboard/dist/assets/{LeaderboardPage-C7E_LrFk.js → LeaderboardPage-H6HW9hon.js} +2 -2
  28. package/dashboard/dist/assets/{LeaderboardProfileModal-D6PUcj3b.js → LeaderboardProfileModal-B_5PyjRA.js} +4 -4
  29. package/dashboard/dist/assets/{LeaderboardProfilePage-VcC9DK_B.js → LeaderboardProfilePage-DR9KWLWv.js} +3 -3
  30. package/dashboard/dist/assets/{LimitsPage-B9K0RFQD.js → LimitsPage-Da0hRxO_.js} +1 -1
  31. package/dashboard/dist/assets/{LocalOnlyNotice-BGsfKEM9.js → LocalOnlyNotice-DuTdva_n.js} +1 -1
  32. package/dashboard/dist/assets/LoginCard-DOL3osrj.js +1 -0
  33. package/dashboard/dist/assets/{LoginModal-N3pWGciC.js → LoginModal-DBmaQL2L.js} +1 -1
  34. package/dashboard/dist/assets/{LoginPage-TGgL0aXF.js → LoginPage-Dk08VrkC.js} +1 -1
  35. package/dashboard/dist/assets/{PetPage-DPi7OT_4.js → PetPage-BbCIdpzx.js} +1 -1
  36. package/dashboard/dist/assets/{PopoverPopup-BkptZaPd.js → PopoverPopup-C1fJZ4qt.js} +1 -1
  37. package/dashboard/dist/assets/ProviderIcon-FfLD-f73.js +1 -0
  38. package/dashboard/dist/assets/{ResetPasswordPage-DQ4qdjBD.js → ResetPasswordPage-Bg7eVc6L.js} +1 -1
  39. package/dashboard/dist/assets/{SegmentedControl-CuTrxHXS.js → SegmentedControl-CFNHcUz7.js} +1 -1
  40. package/dashboard/dist/assets/{Select-_Txi2j1D.js → Select-B0Q0eDcJ.js} +1 -1
  41. package/dashboard/dist/assets/{SelectItemText-DVKhj-YN.js → SelectItemText-BVbLYxSQ.js} +1 -1
  42. package/dashboard/dist/assets/{ServiceStatusPage-ChflNSK0.js → ServiceStatusPage-DCZH2kz5.js} +1 -1
  43. package/dashboard/dist/assets/{SessionsPage-CHv6xK2J.js → SessionsPage-DO5Et7ss.js} +2 -2
  44. package/dashboard/dist/assets/{SettingsPage-DkWAYALG.js → SettingsPage-afgxGfzT.js} +2 -2
  45. package/dashboard/dist/assets/{SkillsPage-DnTSlBl-.js → SkillsPage-DDKiHCF7.js} +1 -1
  46. package/dashboard/dist/assets/{TokenGalaxy-yAMCaU-k.js → TokenGalaxy-DV1Xoe5z.js} +1 -1
  47. package/dashboard/dist/assets/{ToolbarRootContext-BeDIqB0k.js → ToolbarRootContext-DNckd9Qz.js} +1 -1
  48. package/dashboard/dist/assets/TrendMonitor-CliMJxCC.js +19 -0
  49. package/dashboard/dist/assets/{WidgetsPage-y_qKZ9Gv.js → WidgetsPage-BrwG6YMM.js} +1 -1
  50. package/dashboard/dist/assets/{WrappedPage-BGUGI6vk.js → WrappedPage-Ft9YJd0D.js} +1 -1
  51. package/dashboard/dist/assets/{__vite-browser-external-CRvi_9hb.js → __vite-browser-external-C6_BtvDo.js} +4 -3
  52. package/dashboard/dist/assets/agent-logos-yYNu1ZUy.js +1 -0
  53. package/dashboard/dist/assets/{arrow-up-right-CjEesyJI.js → arrow-up-right-B_K57Vbg.js} +1 -1
  54. package/dashboard/dist/assets/{check-KDrH1gwE.js → check-xH63o2xJ.js} +1 -1
  55. package/dashboard/dist/assets/{chevron-right-DseqFm-D.js → chevron-right-D3F2rAfr.js} +1 -1
  56. package/dashboard/dist/assets/{copy-CFJ1QKAQ.js → copy-WPfJGGI0.js} +1 -1
  57. package/dashboard/dist/assets/{download-BQUNGUdh.js → download-BgtWtEXI.js} +1 -1
  58. package/dashboard/dist/assets/{external-link-BCEOLLH2.js → external-link-DSHGoZRM.js} +1 -1
  59. package/dashboard/dist/assets/{flame-BigtoGg2.js → flame-CNGl6Qac.js} +1 -1
  60. package/dashboard/dist/assets/{format-tokens-CS1GUpAV.js → format-tokens-jVm2UX7Y.js} +1 -1
  61. package/dashboard/dist/assets/{icons-DpZ1a8Ct.js → icons-B1JZg-Id.js} +1 -1
  62. package/dashboard/dist/assets/{index-DqkwD6eO.js → index-BTZibjLi.js} +1 -1
  63. package/dashboard/dist/assets/{index-lGAAw1Rv.js → index-BjD81Qce.js} +1 -1
  64. package/dashboard/dist/assets/{index-DFU_yVc5.js → index-CUFHknAy.js} +1 -1
  65. package/dashboard/dist/assets/{info-Br_jtGtO.js → info-CLM460KM.js} +1 -1
  66. package/dashboard/dist/assets/{limits-providers-CkqO1LMQ.js → limits-providers-CFO4O1qh.js} +1 -1
  67. package/dashboard/dist/assets/{link-2-D-nAhkk-.js → link-2-BHOsUnAI.js} +1 -1
  68. package/dashboard/dist/assets/{loader-circle-CuNPuDRR.js → loader-circle-CgcIQi92.js} +1 -1
  69. package/dashboard/dist/assets/{maximize-2-dlvwitfg.js → maximize-2-DvqfuJEA.js} +1 -1
  70. package/dashboard/dist/assets/{modifiers.esm-ORha_lPI.js → modifiers.esm-CNiwIkPh.js} +1 -1
  71. package/dashboard/dist/assets/module-CV1mYFsv.js +3 -0
  72. package/dashboard/dist/assets/{monitor-smartphone-CI2qvlMB.js → monitor-smartphone-BLutpxrG.js} +1 -1
  73. package/dashboard/dist/assets/provider-display-BxIgWUEa.js +42 -0
  74. package/dashboard/dist/assets/{react-Dy9iWvqT.js → react-CM-ozVnh.js} +1 -1
  75. package/dashboard/dist/assets/{search-Do7tzc3k.js → search-4T07INKt.js} +1 -1
  76. package/dashboard/dist/assets/{skills-api-CJ-8EFac.js → skills-api-EXMD3LD1.js} +1 -1
  77. package/dashboard/dist/assets/{sortable.esm-BKQg8NHC.js → sortable.esm-jC6mPJiL.js} +1 -1
  78. package/dashboard/dist/assets/{store-SUT-n3k3.js → store-CvSEdgf2.js} +1 -1
  79. package/dashboard/dist/assets/{terminal-BX4DOxd6.js → terminal-D9fyE2pP.js} +1 -1
  80. package/dashboard/dist/assets/{trash-2-VERlG247.js → trash-2-QLUsAY3l.js} +1 -1
  81. package/dashboard/dist/assets/{use-community-stats-BX6_XUdu.js → use-community-stats-Chrt3kpU.js} +1 -1
  82. package/dashboard/dist/assets/{use-limits-display-prefs-BpC3tYRw.js → use-limits-display-prefs-BJJd750e.js} +1 -1
  83. package/dashboard/dist/assets/{use-ordered-list-CX-LREPo.js → use-ordered-list-BBJp5lFv.js} +1 -1
  84. package/dashboard/dist/assets/use-reduced-motion-BvXZCGFC.js +1 -0
  85. package/dashboard/dist/assets/{use-session-efficiency-pref-B1G8hzVM.js → use-session-efficiency-pref-BXbvx2Ht.js} +1 -1
  86. package/dashboard/dist/assets/{use-transform-DNCqLbUD.js → use-transform-BVp_MUsU.js} +1 -1
  87. package/dashboard/dist/assets/{use-usage-limits-Cvbvf30m.js → use-usage-limits-Dk3EkeIs.js} +1 -1
  88. package/dashboard/dist/assets/{useCurrency-EciQmJkm.js → useCurrency-Bi88Sd__.js} +1 -1
  89. package/dashboard/dist/assets/{useScrollLock-sLkmam8C.js → useScrollLock-if34g2H-.js} +1 -1
  90. package/dashboard/dist/assets/{useTokenFormat-nb9na-2-.js → useTokenFormat-C2cz-5qJ.js} +1 -1
  91. package/dashboard/dist/assets/{zap-BQOWCMov.js → zap-D1yHmcQR.js} +1 -1
  92. package/dashboard/dist/index.html +28 -24
  93. package/dashboard/dist/ip-check.html +1 -1
  94. package/dashboard/dist/leaderboard.html +1 -1
  95. package/dashboard/dist/llms.txt +4 -2
  96. package/dashboard/dist/share.html +1 -1
  97. package/package.json +2 -2
  98. package/src/commands/init.js +12 -1
  99. package/src/commands/status.js +59 -0
  100. package/src/commands/sync.js +220 -4
  101. package/src/lib/account-usage-dedup.js +267 -0
  102. package/src/lib/local-api.js +4 -0
  103. package/src/lib/pricing/curated-overrides.json +15 -9
  104. package/src/lib/pricing/index.js +38 -2
  105. package/src/lib/pricing/matcher.js +1 -0
  106. package/src/lib/pricing/seed-snapshot.json +1 -1
  107. package/src/lib/rollout.js +853 -14
  108. package/src/lib/source-metadata.js +1 -1
  109. package/src/lib/trae-cn-config.js +539 -0
  110. package/dashboard/dist/assets/DialogDescription-M_lg2jhD.js +0 -1
  111. package/dashboard/dist/assets/LoginCard-j5HVFRdP.js +0 -1
  112. package/dashboard/dist/assets/ProviderIcon-zaz4nwxI.js +0 -1
  113. package/dashboard/dist/assets/TrendMonitor-BthLuYwy.js +0 -19
  114. package/dashboard/dist/assets/agent-logos-DwHwn_cb.js +0 -1
  115. package/dashboard/dist/assets/module-BRJxSJmz.js +0 -3
  116. package/dashboard/dist/assets/provider-display-BNCdZ83n.js +0 -42
  117. package/dashboard/dist/assets/use-reduced-motion-DbebVWBw.js +0 -1
@@ -12397,6 +12397,57 @@ function resolvePiDefaultModel() {
12397
12397
  return "pi-unknown";
12398
12398
  }
12399
12399
 
12400
+ // Prime Agent (PrimeIntellect-ai/prime-agent) persists the same metadata-only
12401
+ // assistant usage envelope as pi, but uses a flat sessions directory:
12402
+ // ~/.prime/agent/sessions/<session-id>.jsonl
12403
+ // Keep its path and cursor namespace independent from pi so installations of
12404
+ // both tools can never suppress or double-count each other.
12405
+ function resolvePrimeAgentHome(env = process.env) {
12406
+ if (env.TOKENTRACKER_PRIME_AGENT_HOME) {
12407
+ return expandHomePath(env.TOKENTRACKER_PRIME_AGENT_HOME, env);
12408
+ }
12409
+ const home = env.HOME || require("node:os").homedir();
12410
+ if (process.platform === "win32") {
12411
+ return pickWin32ProviderPath({
12412
+ env,
12413
+ nativeValue: path.join(home, ".prime"),
12414
+ wslProviderDir: ".prime",
12415
+ });
12416
+ }
12417
+ return path.join(home, ".prime");
12418
+ }
12419
+
12420
+ function resolvePrimeAgentDir(env = process.env) {
12421
+ if (env.TOKENTRACKER_PRIME_AGENT_DIR) {
12422
+ return expandHomePath(env.TOKENTRACKER_PRIME_AGENT_DIR, env);
12423
+ }
12424
+ const primeHome = resolvePrimeAgentHome(env);
12425
+ return primeHome ? path.join(primeHome, "agent") : null;
12426
+ }
12427
+
12428
+ function resolvePrimeAgentSessionFiles(env = process.env) {
12429
+ const agentDir = resolvePrimeAgentDir(env);
12430
+ if (!agentDir) return [];
12431
+ const sessionsDir = path.join(agentDir, "sessions");
12432
+ if (!fssync.existsSync(sessionsDir)) return [];
12433
+ const files = [];
12434
+ const walk = (dir) => {
12435
+ let entries;
12436
+ try { entries = fssync.readdirSync(dir, { withFileTypes: true }); } catch { return; }
12437
+ for (const entry of entries) {
12438
+ const full = path.join(dir, entry.name);
12439
+ if (entry.isDirectory()) walk(full);
12440
+ else if (entry.isFile() && entry.name.endsWith(".jsonl")) files.push(full);
12441
+ }
12442
+ };
12443
+ walk(sessionsDir);
12444
+ return files.sort((a, b) => a.localeCompare(b));
12445
+ }
12446
+
12447
+ function resolvePrimeAgentDefaultModel() {
12448
+ return "prime-agent-unknown";
12449
+ }
12450
+
12400
12451
  // Pi is a router: the same session can send turns to Anthropic, GitHub
12401
12452
  // Copilot, or another backend. Keep provider names in the queue source so
12402
12453
  // those turns cannot collapse into one bucket (or inherit the wrong pricing).
@@ -12413,30 +12464,47 @@ function piSourceForProvider(provider) {
12413
12464
  return slug ? `pi-${slug}` : "pi";
12414
12465
  }
12415
12466
 
12416
- async function parsePiIncremental({
12467
+ function primeAgentSourceForProvider(provider) {
12468
+ if (typeof provider !== "string" || !provider.trim()) return "prime-agent";
12469
+ const slug = provider
12470
+ .trim()
12471
+ .toLowerCase()
12472
+ .replace(/[^a-z0-9]+/g, "-")
12473
+ .replace(/^-+|-+$/g, "")
12474
+ .slice(0, 64);
12475
+ return slug ? `prime-agent-${slug}` : "prime-agent";
12476
+ }
12477
+
12478
+ async function parsePiLikeIncremental({
12417
12479
  sessionFiles,
12418
12480
  cursors,
12419
12481
  queuePath,
12420
12482
  onProgress,
12421
12483
  env,
12422
12484
  defaultModel,
12485
+ stateKey,
12486
+ resolveSessionFiles,
12487
+ resolveDefaultModel,
12488
+ sourceForProvider,
12423
12489
  } = {}) {
12424
12490
  await ensureDir(path.dirname(queuePath));
12425
- const piState = cursors.pi && typeof cursors.pi === "object" ? cursors.pi : {};
12426
- const seenIds = new Set(Array.isArray(piState.seenIds) ? piState.seenIds : []);
12491
+ const providerState = cursors[stateKey] && typeof cursors[stateKey] === "object"
12492
+ ? cursors[stateKey]
12493
+ : {};
12494
+ const seenIds = new Set(Array.isArray(providerState.seenIds) ? providerState.seenIds : []);
12427
12495
  const fileOffsets =
12428
- piState.fileOffsets && typeof piState.fileOffsets === "object"
12429
- ? { ...piState.fileOffsets }
12496
+ providerState.fileOffsets && typeof providerState.fileOffsets === "object"
12497
+ ? { ...providerState.fileOffsets }
12430
12498
  : {};
12431
12499
 
12432
12500
  const files = Array.isArray(sessionFiles)
12433
12501
  ? sessionFiles
12434
- : resolvePiSessionFiles(env || process.env);
12435
- const fallbackModel = defaultModel || resolvePiDefaultModel();
12502
+ : resolveSessionFiles(env || process.env);
12503
+ const fallbackModel = defaultModel || resolveDefaultModel();
12436
12504
 
12437
12505
  if (files.length === 0) {
12438
- cursors.pi = {
12439
- ...piState,
12506
+ cursors[stateKey] = {
12507
+ ...providerState,
12440
12508
  seenIds: Array.from(seenIds),
12441
12509
  fileOffsets,
12442
12510
  updatedAt: new Date().toISOString(),
@@ -12463,12 +12531,29 @@ async function parsePiIncremental({
12463
12531
  if (stat.size <= startOffset) continue;
12464
12532
 
12465
12533
  let stream;
12534
+ let streamedBytes = 0;
12535
+ let lastCompleteOffset = startOffset;
12466
12536
  try {
12467
12537
  stream = fssync.createReadStream(filePath, {
12468
12538
  encoding: "utf8",
12469
12539
  start: startOffset,
12470
12540
  });
12471
12541
  } catch { continue; }
12542
+ // readline emits an unterminated final fragment as if it were a line. If
12543
+ // the producer is still writing that JSON object, parsing fails; advancing
12544
+ // to stat.size here would then lose the completed record forever. Track the
12545
+ // last physical newline and commit only through that byte boundary.
12546
+ stream.on("data", (chunk) => {
12547
+ let searchFrom = 0;
12548
+ let newlineIndex;
12549
+ while ((newlineIndex = chunk.indexOf("\n", searchFrom)) !== -1) {
12550
+ lastCompleteOffset = startOffset
12551
+ + streamedBytes
12552
+ + Buffer.byteLength(chunk.slice(0, newlineIndex + 1), "utf8");
12553
+ searchFrom = newlineIndex + 1;
12554
+ }
12555
+ streamedBytes += Buffer.byteLength(chunk, "utf8");
12556
+ });
12472
12557
  const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
12473
12558
 
12474
12559
  for await (const line of rl) {
@@ -12529,7 +12614,7 @@ async function parsePiIncremental({
12529
12614
  : input + output + cacheRead + cacheWrite + reasoningTokens;
12530
12615
 
12531
12616
  const model = normalizeModelInput(msg.model) || fallbackModel;
12532
- const source = piSourceForProvider(msg.provider);
12617
+ const source = sourceForProvider(msg.provider);
12533
12618
 
12534
12619
  const delta = {
12535
12620
  input_tokens: input,
@@ -12561,7 +12646,7 @@ async function parsePiIncremental({
12561
12646
  let postStat = stat;
12562
12647
  try { postStat = fssync.statSync(filePath); } catch {}
12563
12648
  fileOffsets[filePath] = {
12564
- size: postStat.size,
12649
+ size: Math.min(lastCompleteOffset, postStat.size),
12565
12650
  mtimeMs: postStat.mtimeMs,
12566
12651
  ino: postStat.ino,
12567
12652
  };
@@ -12579,8 +12664,8 @@ async function parsePiIncremental({
12579
12664
  const updatedAt = new Date().toISOString();
12580
12665
  hourlyState.updatedAt = updatedAt;
12581
12666
  cursors.hourly = hourlyState;
12582
- cursors.pi = {
12583
- ...piState,
12667
+ cursors[stateKey] = {
12668
+ ...providerState,
12584
12669
  seenIds: cappedSeen,
12585
12670
  fileOffsets,
12586
12671
  updatedAt,
@@ -12589,6 +12674,26 @@ async function parsePiIncremental({
12589
12674
  return { recordsProcessed, eventsAggregated, bucketsQueued };
12590
12675
  }
12591
12676
 
12677
+ async function parsePiIncremental(options = {}) {
12678
+ return parsePiLikeIncremental({
12679
+ ...options,
12680
+ stateKey: "pi",
12681
+ resolveSessionFiles: resolvePiSessionFiles,
12682
+ resolveDefaultModel: resolvePiDefaultModel,
12683
+ sourceForProvider: piSourceForProvider,
12684
+ });
12685
+ }
12686
+
12687
+ async function parsePrimeAgentIncremental(options = {}) {
12688
+ return parsePiLikeIncremental({
12689
+ ...options,
12690
+ stateKey: "primeAgent",
12691
+ resolveSessionFiles: resolvePrimeAgentSessionFiles,
12692
+ resolveDefaultModel: resolvePrimeAgentDefaultModel,
12693
+ sourceForProvider: primeAgentSourceForProvider,
12694
+ });
12695
+ }
12696
+
12592
12697
  // ─────────────────────────────────────────────────────────────────────────────
12593
12698
  // Craft Agents (lukilabs/craft-agents-oss) — passive JSONL reader
12594
12699
  //
@@ -15568,10 +15673,61 @@ async function retractStaleGrokQueueRows(queuePath, keepKeys) {
15568
15673
  return lines.length;
15569
15674
  }
15570
15675
 
15676
+ function pruneMissingGrokProjectUpdateOffsets(projectUpdateOffsets) {
15677
+ if (!projectUpdateOffsets || typeof projectUpdateOffsets !== "object") return;
15678
+ for (const updatesPath of Object.keys(projectUpdateOffsets)) {
15679
+ if (typeof updatesPath !== "string" || !updatesPath) {
15680
+ delete projectUpdateOffsets[updatesPath];
15681
+ continue;
15682
+ }
15683
+ try {
15684
+ if (!fssync.existsSync(updatesPath)) delete projectUpdateOffsets[updatesPath];
15685
+ } catch {
15686
+ delete projectUpdateOffsets[updatesPath];
15687
+ }
15688
+ }
15689
+ }
15690
+
15691
+ function grokTryDecodeUriComponent(value) {
15692
+ if (typeof value !== "string") return null;
15693
+ const trimmed = value.trim();
15694
+ if (!trimmed) return null;
15695
+ try {
15696
+ const decoded = decodeURIComponent(trimmed).trim();
15697
+ return decoded || null;
15698
+ } catch {
15699
+ return null;
15700
+ }
15701
+ }
15702
+
15703
+ function grokEncodedCwdLooksEncoded(value) {
15704
+ return typeof value === "string" && /%[0-9A-Fa-f]{2}/.test(value);
15705
+ }
15706
+
15707
+ // First non-empty wins: summary.info.cwd, hook sess.cwd, decode(encodedCwd),
15708
+ // then decode(basename(dirname(sessionDir))). Do not guess from updates.jsonl.
15709
+ function resolveGrokSessionCwd(sess, summary) {
15710
+ const summaryCwd = summary?.info?.cwd;
15711
+ if (typeof summaryCwd === "string" && summaryCwd.trim()) return summaryCwd.trim();
15712
+ if (typeof sess?.cwd === "string" && sess.cwd.trim()) return sess.cwd.trim();
15713
+ if (grokEncodedCwdLooksEncoded(sess?.encodedCwd)) {
15714
+ const decoded = grokTryDecodeUriComponent(sess.encodedCwd);
15715
+ if (decoded) return decoded;
15716
+ }
15717
+ const sessionDir = typeof sess?.sessionDir === "string" ? sess.sessionDir.trim() : "";
15718
+ if (sessionDir) {
15719
+ const decoded = grokTryDecodeUriComponent(path.basename(path.dirname(sessionDir)));
15720
+ if (decoded) return decoded;
15721
+ }
15722
+ return null;
15723
+ }
15724
+
15571
15725
  async function parseGrokBuildIncremental({
15572
15726
  sessions,
15573
15727
  cursors = {},
15574
15728
  queuePath,
15729
+ projectQueuePath,
15730
+ publicRepoResolver,
15575
15731
  onProgress,
15576
15732
  env = process.env
15577
15733
  } = {}) {
@@ -15616,6 +15772,25 @@ async function parseGrokBuildIncremental({
15616
15772
  ? grokState.updateOffsets
15617
15773
  : {};
15618
15774
 
15775
+ const projectEnabled = typeof projectQueuePath === "string" && projectQueuePath.length > 0;
15776
+ const projectState = projectEnabled ? normalizeProjectState(cursors?.projectHourly) : null;
15777
+ const projectTouchedBuckets = projectEnabled ? new Set() : null;
15778
+ const projectMetaCache = projectEnabled ? new Map() : null;
15779
+ const publicRepoCache = projectEnabled ? new Map() : null;
15780
+ // Independent of global updateOffsets so an upgrade can backfill historical
15781
+ // turn_completed events into project.queue.jsonl without re-appending totals.
15782
+ const projectUpdateOffsets =
15783
+ projectEnabled &&
15784
+ grokState.projectUpdateOffsets &&
15785
+ typeof grokState.projectUpdateOffsets === "object"
15786
+ ? { ...grokState.projectUpdateOffsets }
15787
+ : {};
15788
+ const projectSessionSnapshots = projectEnabled
15789
+ ? normalizeGrokSessionSnapshots({
15790
+ sessionSnapshots: grokState.projectSessionSnapshots,
15791
+ })
15792
+ : {};
15793
+
15619
15794
  // Rebuilt from the sessions seen this scan, so entries for deleted session
15620
15795
  // dirs are pruned and the cursor stays bounded by the on-disk session count.
15621
15796
  const updateOffsets = {};
@@ -15788,11 +15963,167 @@ async function parseGrokBuildIncremental({
15788
15963
  };
15789
15964
  }
15790
15965
 
15966
+ if (projectEnabled) {
15967
+ const cwd = resolveGrokSessionCwd(sess, summary);
15968
+ if (cwd) {
15969
+ const projectContext = await resolveProjectContextForPath({
15970
+ startDir: wsl.mapWslCwdToUnc(cwd, updatesPath || sess?.sessionDir || cwd),
15971
+ projectMetaCache,
15972
+ publicRepoCache,
15973
+ publicRepoResolver,
15974
+ projectState,
15975
+ });
15976
+ const projectRef = projectContext?.projectRef || null;
15977
+ const projectKey = projectContext?.projectKey || null;
15978
+ // Only advance the project cursor on successful public_verified attribution.
15979
+ // Blocked / missing git / unverified stay unadvanced so a later sync can retry.
15980
+ if (projectKey && projectRef) {
15981
+ const projectPrevious = projectSessionSnapshots[sessionId] || {};
15982
+ const projectPreviousTotal = normalizeNonNegativeNumber(projectPrevious.totalTokens);
15983
+ const projectPreviousMessageCount = normalizeNonNegativeNumber(
15984
+ projectPrevious.messageCount,
15985
+ );
15986
+ let projectSawTurnUsage = projectPrevious.source === "turn_usage";
15987
+ let projectCumulativeTotal = projectPreviousTotal;
15988
+ let projectTokenDelta = 0;
15989
+ let projectFinalTouchedHourStart = null;
15990
+ let projectSource = projectPrevious.source || null;
15991
+ let projectLastModel = projectPrevious.model || model;
15992
+ const projectPendingDeltas = [];
15993
+
15994
+ const projectUpdates = await readGrokUpdateTokenEvents(
15995
+ updatesPath,
15996
+ lastActive,
15997
+ updatesPath ? projectUpdateOffsets[updatesPath] : null,
15998
+ { fallbackModel: model },
15999
+ );
16000
+
16001
+ for (const event of projectUpdates.turnEvents) {
16002
+ projectSawTurnUsage = true;
16003
+ const hourStartStr =
16004
+ toUtcHalfHourStart(event.timestamp) ||
16005
+ toUtcHalfHourStart(lastActive) ||
16006
+ toUtcHalfHourStart(Date.now());
16007
+ if (!hourStartStr) continue;
16008
+ const eventModel = event.model || model;
16009
+ const delta = {
16010
+ input_tokens: event.input_tokens,
16011
+ cached_input_tokens: event.cached_input_tokens,
16012
+ cache_creation_input_tokens: event.cache_creation_input_tokens,
16013
+ output_tokens: event.output_tokens,
16014
+ reasoning_output_tokens: event.reasoning_output_tokens,
16015
+ total_tokens: event.total_tokens,
16016
+ billable_total_tokens: event.billable_total_tokens,
16017
+ conversation_count: event.conversation_count || 1,
16018
+ };
16019
+ projectPendingDeltas.push({ model: eventModel, hourStartStr, delta });
16020
+ projectCumulativeTotal += event.total_tokens;
16021
+ projectTokenDelta += event.total_tokens;
16022
+ projectFinalTouchedHourStart = hourStartStr;
16023
+ projectSource = "turn_usage";
16024
+ projectLastModel = eventModel;
16025
+ }
16026
+
16027
+ // Watermark fallback uses the independent project snapshot, never
16028
+ // global sessionSnapshots.totalTokens, and never stacks on turn usage.
16029
+ if (!projectSawTurnUsage) {
16030
+ let highWatermark = projectPreviousTotal;
16031
+ for (const event of projectUpdates.contextEvents) {
16032
+ if (event.totalTokens <= highWatermark) continue;
16033
+ const deltaTokens = event.totalTokens - highWatermark;
16034
+ highWatermark = event.totalTokens;
16035
+ const hourStartStr =
16036
+ toUtcHalfHourStart(event.timestamp) ||
16037
+ toUtcHalfHourStart(lastActive) ||
16038
+ toUtcHalfHourStart(Date.now());
16039
+ if (!hourStartStr) continue;
16040
+ const delta = estimateGrokTokenDelta(deltaTokens, 0, {
16041
+ allowZeroConversationCount: true,
16042
+ });
16043
+ projectPendingDeltas.push({ model, hourStartStr, delta });
16044
+ projectTokenDelta += deltaTokens;
16045
+ projectFinalTouchedHourStart = hourStartStr;
16046
+ projectSource = "updates";
16047
+ }
16048
+
16049
+ const effectiveSignalTotal = grokEffectiveTotalFromSignals(safeSignals);
16050
+ if (effectiveSignalTotal > highWatermark) {
16051
+ const deltaTokens = effectiveSignalTotal - highWatermark;
16052
+ highWatermark = effectiveSignalTotal;
16053
+ const hourStartStr =
16054
+ toUtcHalfHourStart(lastActive) || toUtcHalfHourStart(Date.now());
16055
+ if (hourStartStr) {
16056
+ const delta = estimateGrokTokenDelta(deltaTokens, 0, {
16057
+ allowZeroConversationCount: true,
16058
+ });
16059
+ projectPendingDeltas.push({ model, hourStartStr, delta });
16060
+ projectTokenDelta += deltaTokens;
16061
+ projectFinalTouchedHourStart = hourStartStr;
16062
+ projectSource = "signals";
16063
+ }
16064
+ }
16065
+ projectCumulativeTotal = Math.max(projectPreviousTotal, highWatermark);
16066
+ }
16067
+
16068
+ const projectFinalTotal = Math.max(projectPreviousTotal, projectCumulativeTotal);
16069
+
16070
+ // legacyBaselineOnly applies only to global buckets.
16071
+ for (const pending of projectPendingDeltas) {
16072
+ const projectBucket = getProjectBucket(
16073
+ projectState,
16074
+ projectKey,
16075
+ "grok",
16076
+ pending.hourStartStr,
16077
+ projectRef,
16078
+ );
16079
+ addTotals(projectBucket.totals, pending.delta);
16080
+ projectTouchedBuckets.add(
16081
+ projectBucketKey(projectKey, "grok", pending.hourStartStr),
16082
+ );
16083
+ }
16084
+
16085
+ if (!projectSawTurnUsage && projectTokenDelta > 0 && projectFinalTouchedHourStart) {
16086
+ const deltaMessageCount =
16087
+ messageCount > projectPreviousMessageCount
16088
+ ? messageCount - projectPreviousMessageCount
16089
+ : 1;
16090
+ const projectBucket = getProjectBucket(
16091
+ projectState,
16092
+ projectKey,
16093
+ "grok",
16094
+ projectFinalTouchedHourStart,
16095
+ projectRef,
16096
+ );
16097
+ addTotals(projectBucket.totals, { conversation_count: deltaMessageCount });
16098
+ projectTouchedBuckets.add(
16099
+ projectBucketKey(projectKey, "grok", projectFinalTouchedHourStart),
16100
+ );
16101
+ }
16102
+
16103
+ if (updatesPath && projectUpdates.offsetEntry) {
16104
+ projectUpdateOffsets[updatesPath] = projectUpdates.offsetEntry;
16105
+ }
16106
+
16107
+ if (projectFinalTotal > 0 && (projectTokenDelta > 0 || projectPreviousTotal > 0)) {
16108
+ projectSessionSnapshots[sessionId] = {
16109
+ totalTokens: projectFinalTotal,
16110
+ messageCount: Math.max(projectPreviousMessageCount, messageCount),
16111
+ model: projectLastModel || model,
16112
+ source: projectSource || projectPrevious.source || null,
16113
+ updatedAt: new Date().toISOString(),
16114
+ };
16115
+ }
16116
+ }
16117
+ }
16118
+ }
16119
+
15791
16120
  if (onProgress) {
15792
16121
  onProgress({ index: index + 1, total: sessionList.length, bucketsQueued: touchedBuckets.size });
15793
16122
  }
15794
16123
  }
15795
16124
 
16125
+ if (projectEnabled) pruneMissingGrokProjectUpdateOffsets(projectUpdateOffsets);
16126
+
15796
16127
  let bucketsQueued = queuePath
15797
16128
  ? await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets })
15798
16129
  : 0;
@@ -15810,8 +16141,20 @@ async function parseGrokBuildIncremental({
15810
16141
  bucketsQueued += retracted;
15811
16142
  }
15812
16143
 
16144
+ const projectBucketsQueued = projectEnabled
16145
+ ? await enqueueTouchedProjectBuckets({
16146
+ projectQueuePath,
16147
+ projectState,
16148
+ projectTouchedBuckets,
16149
+ })
16150
+ : 0;
16151
+
15813
16152
  hourlyState.updatedAt = new Date().toISOString();
15814
16153
  cursors.hourly = hourlyState;
16154
+ if (projectState) {
16155
+ projectState.updatedAt = hourlyState.updatedAt;
16156
+ cursors.projectHourly = projectState;
16157
+ }
15815
16158
  sessionSnapshots = capGrokSessionSnapshots(sessionSnapshots);
15816
16159
 
15817
16160
  const migrations = grokState.migrations && typeof grokState.migrations === "object"
@@ -15831,6 +16174,12 @@ async function parseGrokBuildIncremental({
15831
16174
  sessionSnapshots,
15832
16175
  seenSessions: Object.keys(sessionSnapshots),
15833
16176
  updateOffsets,
16177
+ ...(projectEnabled
16178
+ ? {
16179
+ projectUpdateOffsets,
16180
+ projectSessionSnapshots: capGrokSessionSnapshots(projectSessionSnapshots),
16181
+ }
16182
+ : {}),
15834
16183
  migrations,
15835
16184
  updatedAt: new Date().toISOString()
15836
16185
  };
@@ -15838,7 +16187,8 @@ async function parseGrokBuildIncremental({
15838
16187
  return {
15839
16188
  recordsProcessed: eventsAggregated,
15840
16189
  eventsAggregated,
15841
- bucketsQueued
16190
+ bucketsQueued,
16191
+ projectBucketsQueued,
15842
16192
  };
15843
16193
  }
15844
16194
 
@@ -16378,6 +16728,489 @@ function readTraeEntitlementFromStorage(storagePath) {
16378
16728
  };
16379
16729
  }
16380
16730
 
16731
+ // ─────────────────────────────────────────────────────────────────────────────
16732
+ // Trae Work CN (国内版) — usage API incremental parser.
16733
+ //
16734
+ // TRAE Work CN's usage API reports per-session rows that are NOT append-only:
16735
+ // a session can be re-reported with corrected token totals, a different model,
16736
+ // or a shifted time bucket. This parser keeps the last normalized contribution
16737
+ // per session_id under `cursors.traeCn` and reconciles by subtracting the
16738
+ // previous contribution from its old bucket before adding the new one, so
16739
+ // corrections retract the stale tuple instead of stacking. Only the normalized
16740
+ // contribution is persisted — no raw API rows, cost/credits, auth, refresh
16741
+ // tokens, or prompt previews. Source is always `trae-cn`.
16742
+ const TRAE_CN_SOURCE = "trae-cn";
16743
+ const TRAE_CN_STATE_VERSION = 1;
16744
+ const TRAE_CN_UNKNOWN_MODEL = "trae-cn-unknown";
16745
+
16746
+ function normalizeTraeCnState(raw) {
16747
+ if (raw === undefined || raw === null) {
16748
+ return {
16749
+ version: TRAE_CN_STATE_VERSION,
16750
+ sessions: {},
16751
+ prunedBeforeMs: 0,
16752
+ updatedAt: null,
16753
+ };
16754
+ }
16755
+ if (typeof raw !== "object" || Array.isArray(raw)) {
16756
+ throw new Error("Trae CN cursor state is malformed.");
16757
+ }
16758
+ if (raw.version !== TRAE_CN_STATE_VERSION) {
16759
+ throw new Error(`Trae CN cursor state version ${raw.version} is not supported.`);
16760
+ }
16761
+ if (!raw.sessions || typeof raw.sessions !== "object" || Array.isArray(raw.sessions)) {
16762
+ throw new Error("Trae CN cursor sessions are malformed.");
16763
+ }
16764
+ return {
16765
+ version: TRAE_CN_STATE_VERSION,
16766
+ sessions: { ...raw.sessions },
16767
+ prunedBeforeMs:
16768
+ Number.isFinite(raw.prunedBeforeMs) && raw.prunedBeforeMs > 0 ? raw.prunedBeforeMs : 0,
16769
+ updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : null,
16770
+ };
16771
+ }
16772
+
16773
+ function isValidTraeCnTotals(totals) {
16774
+ if (!totals || typeof totals !== "object") return false;
16775
+ for (const key of [
16776
+ "input_tokens",
16777
+ "cached_input_tokens",
16778
+ "cache_creation_input_tokens",
16779
+ "output_tokens",
16780
+ "reasoning_output_tokens",
16781
+ "total_tokens",
16782
+ "billable_total_tokens",
16783
+ "conversation_count",
16784
+ ]) {
16785
+ if (!Number.isSafeInteger(totals[key]) || totals[key] < 0) return false;
16786
+ }
16787
+ return true;
16788
+ }
16789
+
16790
+ // Canonical UTC half-hour bucketStart produced by toUtcHalfHourStart:
16791
+ // "YYYY-MM-DDTHH:00:00.000Z" or "YYYY-MM-DDTHH:30:00.000Z".
16792
+ function isValidTraeCnBucketStart(value) {
16793
+ if (typeof value !== "string") return false;
16794
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:(00|30):00\.000Z$/.test(value)) return false;
16795
+ const dt = new Date(value);
16796
+ return Number.isFinite(dt.getTime()) && dt.toISOString() === value;
16797
+ }
16798
+
16799
+ // Validate a stored prior contribution before it is ever subtracted, so a
16800
+ // tampered or corrupt cursor fails closed instead of silently rewinding buckets.
16801
+ // Enforces this provider's fixed canonical invariant (all totals safe
16802
+ // nonnegative integers, reasoning=0, conversation_count=1,
16803
+ // total=input+cached+cacheCreation+output, billable=total) plus a canonical
16804
+ // half-hour UTC bucketStart.
16805
+ function validateTraeCnStoredContribution(entry) {
16806
+ if (!entry || typeof entry !== "object") {
16807
+ throw new Error("Trae CN stored session contribution is malformed.");
16808
+ }
16809
+ if (
16810
+ typeof entry.model !== "string" ||
16811
+ !entry.model.trim() ||
16812
+ entry.model.includes("|")
16813
+ ) {
16814
+ throw new Error("Trae CN stored session model is malformed.");
16815
+ }
16816
+ if (!isValidTraeCnBucketStart(entry.bucketStart)) {
16817
+ throw new Error("Trae CN stored session bucket is malformed.");
16818
+ }
16819
+ if (!isValidTraeCnTotals(entry.totals)) {
16820
+ throw new Error("Trae CN stored session totals are malformed.");
16821
+ }
16822
+ const { totals } = entry;
16823
+ if (totals.reasoning_output_tokens !== 0 || totals.conversation_count !== 1) {
16824
+ throw new Error("Trae CN stored session totals are malformed.");
16825
+ }
16826
+ const sum =
16827
+ totals.input_tokens +
16828
+ totals.cached_input_tokens +
16829
+ totals.cache_creation_input_tokens +
16830
+ totals.output_tokens;
16831
+ if (totals.total_tokens !== sum || totals.billable_total_tokens !== totals.total_tokens) {
16832
+ throw new Error("Trae CN stored session totals are malformed.");
16833
+ }
16834
+ }
16835
+
16836
+ // Token fields may live in `row.extra_info` (object or JSON string) or at the
16837
+ // top level of the row. Missing values are NOT coerced to zero.
16838
+ function traeCnExtraInfo(row) {
16839
+ const extra = row?.extra_info;
16840
+ if (extra && typeof extra === "object" && !Array.isArray(extra)) return extra;
16841
+ if (typeof extra === "string") {
16842
+ try {
16843
+ const parsed = JSON.parse(extra);
16844
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
16845
+ } catch (_e) {}
16846
+ }
16847
+ return null;
16848
+ }
16849
+
16850
+ function traeCnTokenField(row, extra, key) {
16851
+ if (extra && extra[key] !== undefined && extra[key] !== null) return extra[key];
16852
+ if (row && row[key] !== undefined && row[key] !== null) return row[key];
16853
+ return undefined;
16854
+ }
16855
+
16856
+ // Normalize one raw session row into the canonical contribution, or throw
16857
+ // before any cursor/bucket mutation happens. Returns
16858
+ // { sessionId, model, bucketStart, totals }.
16859
+ function normalizeTraeCnSession(row) {
16860
+ const sessionId = typeof row?.session_id === "string" ? row.session_id.trim() : "";
16861
+ if (!sessionId) {
16862
+ throw new Error("Trae CN session row is missing session_id.");
16863
+ }
16864
+ let model;
16865
+ if (row?.model_name === undefined || row?.model_name === null) {
16866
+ model = TRAE_CN_UNKNOWN_MODEL;
16867
+ } else if (typeof row.model_name === "string") {
16868
+ model = row.model_name.trim() || TRAE_CN_UNKNOWN_MODEL;
16869
+ } else {
16870
+ throw new Error("Trae CN session row has an invalid model_name.");
16871
+ }
16872
+ if (model.includes("|")) {
16873
+ throw new Error("Trae CN session row has an unsupported model name.");
16874
+ }
16875
+ if (!Number.isSafeInteger(row?.usage_time) || row.usage_time <= 0) {
16876
+ throw new Error("Trae CN session row has an invalid usage_time.");
16877
+ }
16878
+ const bucketStart = toUtcHalfHourStart(row.usage_time * 1000);
16879
+ if (!bucketStart) {
16880
+ throw new Error("Trae CN session row has an invalid usage_time.");
16881
+ }
16882
+ const extra = traeCnExtraInfo(row);
16883
+ // Cache fields may be absent for models without a prompt-cache concept
16884
+ // (Doubao / DeepSeek on TRAE CN) — an absent cache field means "no cache
16885
+ // activity", i.e. 0, NOT a malformed row. input/output stay mandatory.
16886
+ //
16887
+ // `input_token` is cache-INCLUSIVE: verifying real rows against TRAE CN's
16888
+ // own credit billing, credits = fresh_input*p + cache_read*(p/4) + output*q
16889
+ // fits every row with zero residual only under this reading (same
16890
+ // convention as Codex / Qoder). Peel the cache subsets off into their own
16891
+ // columns and report only the remainder as ordinary input, otherwise cached
16892
+ // context is double-counted in dashboards and cost calculations. cache_write
16893
+ // was 0 across all observed rows; it is treated as a further subset of the
16894
+ // cache-inclusive input (clamped so the buckets always sum back to it).
16895
+ const cacheRead = traeCnTokenField(row, extra, "cache_read_token");
16896
+ const cacheWrite = traeCnTokenField(row, extra, "cache_write_token");
16897
+ const fields = [
16898
+ ["input_token", traeCnTokenField(row, extra, "input_token")],
16899
+ ["output_token", traeCnTokenField(row, extra, "output_token")],
16900
+ ["cache_read_token", cacheRead === undefined || cacheRead === null ? 0 : cacheRead],
16901
+ ["cache_write_token", cacheWrite === undefined || cacheWrite === null ? 0 : cacheWrite],
16902
+ ];
16903
+ for (const [label, value] of fields) {
16904
+ if (!Number.isSafeInteger(value) || value < 0) {
16905
+ throw new Error(`Trae CN session row has an invalid ${label}.`);
16906
+ }
16907
+ }
16908
+ const rawInput = fields[0][1];
16909
+ const outputTokens = fields[1][1];
16910
+ const cachedInput = Math.min(rawInput, fields[2][1]);
16911
+ const cacheCreation = Math.min(rawInput - cachedInput, fields[3][1]);
16912
+ const total = rawInput + outputTokens;
16913
+ if (!Number.isSafeInteger(total)) {
16914
+ throw new Error("Trae CN session row token totals overflow.");
16915
+ }
16916
+ return {
16917
+ sessionId,
16918
+ model,
16919
+ bucketStart,
16920
+ totals: {
16921
+ input_tokens: rawInput - cachedInput - cacheCreation,
16922
+ output_tokens: outputTokens,
16923
+ cached_input_tokens: cachedInput,
16924
+ cache_creation_input_tokens: cacheCreation,
16925
+ reasoning_output_tokens: 0,
16926
+ total_tokens: total,
16927
+ billable_total_tokens: total,
16928
+ conversation_count: 1,
16929
+ },
16930
+ };
16931
+ }
16932
+
16933
+ // Fail closed instead of letting subtractTotals clamp a stored prior
16934
+ // contribution that exceeds its source bucket (a corruption signal).
16935
+ function assertTraeCnBucketCovers(bucketTotals, previousTotals) {
16936
+ for (const key of [
16937
+ "input_tokens",
16938
+ "cached_input_tokens",
16939
+ "cache_creation_input_tokens",
16940
+ "output_tokens",
16941
+ "reasoning_output_tokens",
16942
+ "total_tokens",
16943
+ "billable_total_tokens",
16944
+ "conversation_count",
16945
+ ]) {
16946
+ if ((bucketTotals[key] || 0) < (previousTotals[key] || 0)) {
16947
+ throw new Error("Trae CN state corruption: stored contribution exceeds its bucket totals.");
16948
+ }
16949
+ }
16950
+ }
16951
+
16952
+ // Account session state: a queue record (kind: "account_session_state")
16953
+ // carrying the CANONICAL observation of ONE provider-side TRAE CN session.
16954
+ // Cloud truth for trae-cn lives at the session level
16955
+ // (tokentracker_account_session_states; ingest edge upserts via
16956
+ // tokentracker_upsert_account_session_states): identity is
16957
+ // (user_id, source, session_id) - device_id is NOT identity (the usage API
16958
+ // request carries no device discriminator). Evidence split (2026-08-17, one
16959
+ // account, three real fetches 137 -> 141 -> 164): repeated-fetch id
16960
+ // stability VERIFIED (137/137 persisted, corrections KEPT ids), cross-window
16961
+ // stability VERIFIED (exact subsets), no duplicate ids OBSERVED. Cross-device
16962
+ // same-account id stability is NOT DIRECTLY VERIFIED - no device
16963
+ // discriminator in the request body is necessary but not sufficient (a
16964
+ // device/login context could ride inside the JWT / server auth context), and
16965
+ // no second independent device/auth experiment was run.
16966
+ //
16967
+ // The three correction classes collapse into ONE whole-row replace:
16968
+ // downward S tokens 100 -> 60
16969
+ // model S model A -> B
16970
+ // bucket S bucket 10:00 -> 10:30
16971
+ // A fresh device with no cursor history uploads every session it observes;
16972
+ // the cloud LWW guard reconciles versions. ABSENCE is NOT PROVEN to mean
16973
+ // deletion, so nothing is ever emitted for sessions missing from a
16974
+ // non-empty snapshot, and an empty payload emits nothing at all.
16975
+ //
16976
+ // snapshot_verified_at is the CLIENT logical fetch stamp (the API exposes no
16977
+ // provider-side ordering signal - headers carry only CDN trace ids, rows
16978
+ // carry no revision). It is stamped once per real fetch and replayed
16979
+ // verbatim by this append-only queue; the cloud upsert applies strictly
16980
+ // newer (>) stamps only, so replays are idempotent and a transport retry of
16981
+ // an older observation cannot displace a newer one. Cross-device ordering
16982
+ // under clock skew is a documented residual risk, NOT strict correctness.
16983
+ //
16984
+ // Appended AFTER the bucket rows of the same sync (queue order guarantees a
16985
+ // device's rows land before the states describing them). Bucket-row readers
16986
+ // skip this record via its kind field (it carries no hour_start and is not
16987
+ // a usage row).
16988
+ async function appendTraeCnSessionStates({ queuePath, observations, verifiedAtMs }) {
16989
+ if (!Array.isArray(observations) || observations.length === 0) return 0;
16990
+ if (!Number.isFinite(verifiedAtMs)) {
16991
+ throw new Error("Trae CN session states require a finite verification stamp.");
16992
+ }
16993
+ const verifiedAt = new Date(verifiedAtMs).toISOString();
16994
+ const lines = [];
16995
+ for (const obs of observations) {
16996
+ const t = obs.totals;
16997
+ lines.push(
16998
+ JSON.stringify({
16999
+ kind: "account_session_state",
17000
+ source: TRAE_CN_SOURCE,
17001
+ session_id: obs.sessionId,
17002
+ model: obs.model,
17003
+ bucket_start: obs.bucketStart,
17004
+ input_tokens: t.input_tokens,
17005
+ output_tokens: t.output_tokens,
17006
+ cached_input_tokens: t.cached_input_tokens,
17007
+ cache_creation_input_tokens: t.cache_creation_input_tokens,
17008
+ reasoning_output_tokens: t.reasoning_output_tokens,
17009
+ total_tokens: t.total_tokens,
17010
+ snapshot_verified_at: verifiedAt,
17011
+ }),
17012
+ );
17013
+ }
17014
+ await fs.appendFile(queuePath, lines.join("\n") + "\n", "utf8");
17015
+ return lines.length;
17016
+ }
17017
+
17018
+ async function parseTraeCnApiIncremental({
17019
+ sessions,
17020
+ cursors,
17021
+ queuePath,
17022
+ onProgress,
17023
+ windowStartMs,
17024
+ windowEndMs,
17025
+ snapshotVerifiedAtMs,
17026
+ } = {}) {
17027
+ if (!Array.isArray(sessions)) {
17028
+ throw new Error("Trae CN sessions must be an array.");
17029
+ }
17030
+ if (!cursors || typeof cursors !== "object" || Array.isArray(cursors)) {
17031
+ throw new Error("Trae CN cursors must be a writable object.");
17032
+ }
17033
+ await ensureDir(path.dirname(queuePath));
17034
+
17035
+ // Validate persisted state up front (fresh absent state initializes version
17036
+ // 1; malformed state / unexpected version fails closed instead of resetting).
17037
+ // Deep-clone the normalized working states so reconciliation and enqueue
17038
+ // mutations never touch `cursors` before the final assignment succeeds.
17039
+ const hourlyState = structuredClone(normalizeHourlyState(cursors?.hourly));
17040
+ const traeCnState = structuredClone(normalizeTraeCnState(cursors?.traeCn));
17041
+
17042
+ if (sessions.length === 0) {
17043
+ // Empty payload is a successful no-op: no usage mutation, no session
17044
+ // state records.
17045
+ //
17046
+ // Evidence check (2026-08-16, two real fetches 17min apart over the same
17047
+ // account: 137 -> 141 sessions, 0 disappeared, 0 rows changed) shows the
17048
+ // session set is stable, but NOTHING proves 'absent from the response'
17049
+ // means 'authoritatively deleted / zero' — so the absence contract is
17050
+ // NOT PROVEN and must stay symmetric everywhere: a session missing from
17051
+ // a non-empty snapshot never emits a retraction, and an empty response
17052
+ // asserts nothing at all. Canonical corrections ride on explicit
17053
+ // observations only.
17054
+ return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
17055
+ }
17056
+
17057
+ // Full prevalidation + in-payload dedupe BEFORE any cursor/bucket mutation.
17058
+ //
17059
+ // ANY malformed row fails the whole snapshot closed. Canonical session
17060
+ // states assert this device actually understood the snapshot, so a
17061
+ // partially understood snapshot (99 valid + 1 uninterpretable row) must
17062
+ // never be enqueued: it would become the canonical cloud state. The
17063
+ // provider-level try/catch in cmdSync keeps the failure
17064
+ // isolated (other providers sync on) and the error message carries only
17065
+ // the count + reason - no session ids, tokens, or credentials. Confirmed
17066
+ // LEGAL variations must not land here: absent cache fields are already
17067
+ // coerced to 0 inside normalizeTraeCnSession (models without a prompt
17068
+ // cache concept); only truly uninterpretable rows (bad session_id /
17069
+ // usage_time / token numbers / model, impossible schema) throw.
17070
+ const bySession = new Map();
17071
+ let skippedRows = 0;
17072
+ let firstSkipReason = "";
17073
+ for (const row of sessions) {
17074
+ let normalized;
17075
+ try {
17076
+ normalized = normalizeTraeCnSession(row);
17077
+ } catch (error) {
17078
+ skippedRows += 1;
17079
+ if (!firstSkipReason) firstSkipReason = error?.message || "unknown";
17080
+ continue;
17081
+ }
17082
+ const existing = bySession.get(normalized.sessionId);
17083
+ if (existing) {
17084
+ const identical =
17085
+ existing.model === normalized.model &&
17086
+ existing.bucketStart === normalized.bucketStart &&
17087
+ totalsKey(existing.totals) === totalsKey(normalized.totals);
17088
+ if (!identical) {
17089
+ throw new Error("Trae CN session rows contain conflicting contributions.");
17090
+ }
17091
+ continue; // exact duplicate within one payload is accepted once
17092
+ }
17093
+ bySession.set(normalized.sessionId, normalized);
17094
+ }
17095
+ if (skippedRows > 0) {
17096
+ // Partial snapshots are not authoritative: one uninterpretable row means
17097
+ // the window was NOT fully verified, so fail closed - no bucket rows, no
17098
+ // session states, no cursor commit. The next sync replays from the same
17099
+ // cursor state (idempotent).
17100
+ throw new Error(
17101
+ `Trae CN snapshot is not authoritative: ${skippedRows} malformed row${skippedRows !== 1 ? "s" : ""} (first: ${firstSkipReason}).`,
17102
+ );
17103
+ }
17104
+ const touchedBuckets = new Set();
17105
+ const cb = typeof onProgress === "function" ? onProgress : null;
17106
+ const total = sessions.length;
17107
+ let index = 0;
17108
+ let eventsAggregated = 0;
17109
+
17110
+ // Validate EVERY existing stored contribution up front — not just sessions
17111
+ // present in the incoming payload — so an unrelated malformed entry fails
17112
+ // closed before any reconciliation mutation.
17113
+ for (const sessionId of Object.keys(traeCnState.sessions)) {
17114
+ validateTraeCnStoredContribution(traeCnState.sessions[sessionId]);
17115
+ }
17116
+
17117
+ // Monotonic prune (same watermark pattern as the kiroCli cap above): the
17118
+ // sync caller fetches a fixed trailing window, so a session whose entire
17119
+ // half-hour bucket sits before windowStartMs can never reappear in a later
17120
+ // payload — its stored contribution is dead reconciliation weight. Prune
17121
+ // only when the window start moved FORWARD past the persisted watermark:
17122
+ // an entry evicted under window W1 and re-fetched under an earlier window
17123
+ // would find previousEntry undefined and count twice. The +30min margin
17124
+ // matches bucketStart's half-hour floor, which can trail the row's
17125
+ // usage_time by up to 30 minutes.
17126
+ if (Number.isFinite(windowStartMs) && windowStartMs > traeCnState.prunedBeforeMs) {
17127
+ const pruneCutoffMs = windowStartMs - 30 * 60 * 1000;
17128
+ for (const sessionId of Object.keys(traeCnState.sessions)) {
17129
+ const bucketMs = Date.parse(traeCnState.sessions[sessionId].bucketStart);
17130
+ if (Number.isFinite(bucketMs) && bucketMs <= pruneCutoffMs) {
17131
+ delete traeCnState.sessions[sessionId];
17132
+ }
17133
+ }
17134
+ traeCnState.prunedBeforeMs = windowStartMs;
17135
+ }
17136
+
17137
+ // Deterministic: process unique session ids in sorted order. Sessions
17138
+ // whose canonical observation changed (or that were never seen) are also
17139
+ // collected for the account_session_state queue records.
17140
+ const sessionIds = [...bySession.keys()].sort();
17141
+ const changedObservations = [];
17142
+ for (const sessionId of sessionIds) {
17143
+ const current = bySession.get(sessionId);
17144
+ const previousEntry = traeCnState.sessions[sessionId];
17145
+
17146
+ const unchanged =
17147
+ previousEntry &&
17148
+ totalsKey(previousEntry.totals) === totalsKey(current.totals) &&
17149
+ previousEntry.bucketStart === current.bucketStart &&
17150
+ previousEntry.model === current.model;
17151
+ if (unchanged) {
17152
+ index += 1;
17153
+ if (cb) cb({ index, total, eventsAggregated, bucketsQueued: touchedBuckets.size });
17154
+ continue;
17155
+ }
17156
+
17157
+ if (previousEntry) {
17158
+ const oldBucket = getHourlyBucket(
17159
+ hourlyState,
17160
+ TRAE_CN_SOURCE,
17161
+ previousEntry.model,
17162
+ previousEntry.bucketStart,
17163
+ );
17164
+ assertTraeCnBucketCovers(oldBucket.totals, previousEntry.totals);
17165
+ subtractTotals(oldBucket.totals, previousEntry.totals);
17166
+ touchedBuckets.add(bucketKey(TRAE_CN_SOURCE, previousEntry.model, previousEntry.bucketStart));
17167
+ }
17168
+
17169
+ const bucket = getHourlyBucket(hourlyState, TRAE_CN_SOURCE, current.model, current.bucketStart);
17170
+ addTotals(bucket.totals, current.totals);
17171
+ touchedBuckets.add(bucketKey(TRAE_CN_SOURCE, current.model, current.bucketStart));
17172
+
17173
+ traeCnState.sessions[sessionId] = {
17174
+ model: current.model,
17175
+ bucketStart: current.bucketStart,
17176
+ totals: { ...current.totals },
17177
+ updatedAt: new Date().toISOString(),
17178
+ };
17179
+ changedObservations.push({
17180
+ sessionId,
17181
+ model: current.model,
17182
+ bucketStart: current.bucketStart,
17183
+ totals: current.totals,
17184
+ });
17185
+ eventsAggregated += 1;
17186
+ index += 1;
17187
+ if (cb) cb({ index, total, eventsAggregated, bucketsQueued: touchedBuckets.size });
17188
+ }
17189
+
17190
+ const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets });
17191
+ // Emit canonical session observations only after the reconciled bucket
17192
+ // rows are durably queued; a failure here aborts before cursor commit, so
17193
+ // the next sync replays the (idempotent) reconciliation and re-appends
17194
+ // both. snapshot_verified_at is stamped once per real fetch; the
17195
+ // append-only queue replays it verbatim, so transport retries never fake
17196
+ // freshness. Unchanged sessions emit nothing - a fixed-now no-change sync
17197
+ // stays byte-identical.
17198
+ const verifiedAtMs = Number.isFinite(snapshotVerifiedAtMs) ? snapshotVerifiedAtMs : Date.now();
17199
+ await appendTraeCnSessionStates({
17200
+ queuePath,
17201
+ observations: changedObservations,
17202
+ verifiedAtMs,
17203
+ });
17204
+ const updatedAt = new Date().toISOString();
17205
+ hourlyState.updatedAt = updatedAt;
17206
+ traeCnState.updatedAt = updatedAt;
17207
+ // Assign cursor state only after enqueue succeeds.
17208
+ cursors.hourly = hourlyState;
17209
+ cursors.traeCn = traeCnState;
17210
+
17211
+ return { recordsProcessed: total, eventsAggregated, bucketsQueued, skippedRows };
17212
+ }
17213
+
16381
17214
  // ─────────────────────────────────────────────────────────────────────────────
16382
17215
  // DeepSeek Harness (dsh) — passive reader of the harness's session logs.
16383
17216
  //
@@ -17182,6 +18015,11 @@ module.exports = {
17182
18015
  resolvePiDefaultModel,
17183
18016
  parsePiIncremental,
17184
18017
  piAgentDirCollidesWithOmp,
18018
+ resolvePrimeAgentHome,
18019
+ resolvePrimeAgentDir,
18020
+ resolvePrimeAgentSessionFiles,
18021
+ resolvePrimeAgentDefaultModel,
18022
+ parsePrimeAgentIncremental,
17185
18023
  resolveCraftConfigDir,
17186
18024
  resolveCraftWorkspaceRoots,
17187
18025
  resolveCraftSessionFiles,
@@ -17226,6 +18064,7 @@ module.exports = {
17226
18064
  resolveTraePath,
17227
18065
  resolveTraeStoragePath,
17228
18066
  readTraeEntitlementFromStorage,
18067
+ parseTraeCnApiIncremental,
17229
18068
  // DeepSeek Harness (dsh) — passive session-log reader
17230
18069
  resolveDshHome,
17231
18070
  resolveDshHomes,