tokentracker-cli 1.5.12 → 1.5.14

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 (116) hide show
  1. package/dashboard/dist/assets/{AchievementBadge-DvcPjqX7.js → AchievementBadge-DqJWve1h.js} +1 -1
  2. package/dashboard/dist/assets/{AchievementsPage-C9AkvdHe.js → AchievementsPage-CnuBayj3.js} +2 -2
  3. package/dashboard/dist/assets/{AchievementsSection-g0JGNGfT.js → AchievementsSection-WRU5TxAB.js} +1 -1
  4. package/dashboard/dist/assets/{BadgeDetailModal-BHxQ8umD.js → BadgeDetailModal-B5ikEQik.js} +1 -1
  5. package/dashboard/dist/assets/{BotAnimated-C5Byp7Ml.js → BotAnimated-CaMG6j-6.js} +1 -1
  6. package/dashboard/dist/assets/{Card-BV1iTPCd.js → Card-BL3vxENk.js} +1 -1
  7. package/dashboard/dist/assets/{CloudCheckoutPage-DcAvPHXS.js → CloudCheckoutPage-CsUb8CJ7.js} +1 -1
  8. package/dashboard/dist/assets/{CloudPage-pw82pCP6.js → CloudPage-5ZiPFzeC.js} +2 -2
  9. package/dashboard/dist/assets/{CommandPalette-B88IfMHd.js → CommandPalette-CYd9nXW-.js} +1 -1
  10. package/dashboard/dist/assets/{CommunityStatsModal-DwtEZ96Z.js → CommunityStatsModal-BItox7Sz.js} +1 -1
  11. package/dashboard/dist/assets/{ConfirmModal-Cx1-1xUk.js → ConfirmModal-BE1ABR4z.js} +1 -1
  12. package/dashboard/dist/assets/{Controls-hHP6yz1h.js → Controls-BRqIsaHT.js} +1 -1
  13. package/dashboard/dist/assets/{DashboardPage-Cz98astU.js → DashboardPage-Cm0YuBlx.js} +1 -1
  14. package/dashboard/dist/assets/{DateRangePopover-BjsUociY.js → DateRangePopover-CRwEu66e.js} +1 -1
  15. package/dashboard/dist/assets/{DevicePage-C0ZyCHAD.js → DevicePage-DFUPsGb_.js} +1 -1
  16. package/dashboard/dist/assets/{DialogClose-eeerEmfs.js → DialogClose-DALl4GmP.js} +1 -1
  17. package/dashboard/dist/assets/DialogDescription-CO0hykis.js +1 -0
  18. package/dashboard/dist/assets/{DialogTitle-Bn14t_Xd.js → DialogTitle-BSR8ozyn.js} +1 -1
  19. package/dashboard/dist/assets/{ElectricLogo-OyoU_h0I.js → ElectricLogo-BtrycBUW.js} +1 -1
  20. package/dashboard/dist/assets/{FadeIn-BbC4-H2A.js → FadeIn-CfhgvLVd.js} +1 -1
  21. package/dashboard/dist/assets/{HeaderGithubStar-B2CCV29J.js → HeaderGithubStar-N8wqAdOf.js} +1 -1
  22. package/dashboard/dist/assets/{HoverTooltip-DiD_l4GJ.js → HoverTooltip-CwdMYIzZ.js} +1 -1
  23. package/dashboard/dist/assets/{IpCheckPage-1HwHzeVT.js → IpCheckPage-DCRor9cb.js} +1 -1
  24. package/dashboard/dist/assets/{LandingPage-Cx_xIavm.js → LandingPage-s9xvTHSp.js} +2 -2
  25. package/dashboard/dist/assets/{LeaderboardPage-BsP635cI.js → LeaderboardPage-CUj3WLMo.js} +2 -2
  26. package/dashboard/dist/assets/{LeaderboardProBadge-B7uhXYjD.js → LeaderboardProBadge-N4NOKZWj.js} +1 -1
  27. package/dashboard/dist/assets/{LeaderboardProfileModal-_QOUdhsi.js → LeaderboardProfileModal-09HiUyxm.js} +3 -3
  28. package/dashboard/dist/assets/{LeaderboardProfilePage-CSpx6Rw5.js → LeaderboardProfilePage-Cw9_rodG.js} +1 -1
  29. package/dashboard/dist/assets/{LimitsPage-DOj_2vh6.js → LimitsPage-F8b6kZa0.js} +1 -1
  30. package/dashboard/dist/assets/{LocalOnlyNotice-DnkG5DmI.js → LocalOnlyNotice-D1eEzDEv.js} +1 -1
  31. package/dashboard/dist/assets/{LoginCard-Ds9kOBpV.js → LoginCard-0sIovG_J.js} +1 -1
  32. package/dashboard/dist/assets/{LoginModal-B-7fS0gK.js → LoginModal-BT3oMVak.js} +1 -1
  33. package/dashboard/dist/assets/{LoginPage-B2K2tXvd.js → LoginPage-CDsXdaE5.js} +1 -1
  34. package/dashboard/dist/assets/{PetPage-DwsDJxX_.js → PetPage-BEnC1azp.js} +2 -2
  35. package/dashboard/dist/assets/{PopoverPopup-CqUafQx4.js → PopoverPopup-Dwzp-hyf.js} +1 -1
  36. package/dashboard/dist/assets/{ProviderIcon-BcE1tBpR.js → ProviderIcon-Cu_lcuuu.js} +1 -1
  37. package/dashboard/dist/assets/{ResetPasswordPage-CP17McXH.js → ResetPasswordPage-DrpEscm1.js} +1 -1
  38. package/dashboard/dist/assets/{Select-BcFrd0ex.js → Select-wIR4qvEI.js} +1 -1
  39. package/dashboard/dist/assets/{SelectItemText-C-FBw54p.js → SelectItemText-CNuJgYps.js} +1 -1
  40. package/dashboard/dist/assets/{SelfHostPage--4KcyhvD.js → SelfHostPage-CWNLygpL.js} +1 -1
  41. package/dashboard/dist/assets/{ServiceStatusPage-C4GQ-8Tm.js → ServiceStatusPage-BId9t16Q.js} +1 -1
  42. package/dashboard/dist/assets/{SessionsPage-DMW3U0i3.js → SessionsPage-C_klx7d8.js} +1 -1
  43. package/dashboard/dist/assets/{SettingsPage-sYLabRGu.js → SettingsPage-nC73JaZ4.js} +3 -3
  44. package/dashboard/dist/assets/{SkillsPage-CkjmvAds.js → SkillsPage-B2agou8Y.js} +1 -1
  45. package/dashboard/dist/assets/{TokenGalaxy-BfJtQKzg.js → TokenGalaxy-BNXUWRwa.js} +1 -1
  46. package/dashboard/dist/assets/{TooltipPopup-BxvShS92.js → TooltipPopup--X2kExQ0.js} +1 -1
  47. package/dashboard/dist/assets/{TrendMonitor-C9cHT4G_.js → TrendMonitor-BELHsTnz.js} +1 -1
  48. package/dashboard/dist/assets/{WidgetsPage-BqlH3M7h.js → WidgetsPage-DWp0SMXA.js} +1 -1
  49. package/dashboard/dist/assets/{WrappedPage-D2A1zcGs.js → WrappedPage-DgwczKHe.js} +1 -1
  50. package/dashboard/dist/assets/{__vite-browser-external-RPZeXrAv.js → __vite-browser-external-BHphZRSC.js} +2 -2
  51. package/dashboard/dist/assets/{agent-logos-CKAwJGNX.js → agent-logos-Cfx5WzU5.js} +1 -1
  52. package/dashboard/dist/assets/{arrow-left-sB5X7dRY.js → arrow-left-D83SdANw.js} +1 -1
  53. package/dashboard/dist/assets/{arrow-right-Cvwg139b.js → arrow-right-D7ye9bmJ.js} +1 -1
  54. package/dashboard/dist/assets/{arrow-up-right-H5sP370z.js → arrow-up-right-CLmwEwwC.js} +1 -1
  55. package/dashboard/dist/assets/{calendar-days-B6p2eInB.js → calendar-days-D1qLlvh4.js} +1 -1
  56. package/dashboard/dist/assets/{check-DgFRXvUP.js → check-BHJvi4ey.js} +1 -1
  57. package/dashboard/dist/assets/{chevron-down-D-ny8IBp.js → chevron-down-6RBPKNpt.js} +1 -1
  58. package/dashboard/dist/assets/{cloud-checkout-CT_OgBOA.js → cloud-checkout-DgWcF-at.js} +1 -1
  59. package/dashboard/dist/assets/{composite-BJuoijSA.js → composite-D-0pfyMY.js} +1 -1
  60. package/dashboard/dist/assets/{copy-JdswLd9u.js → copy-B6mJSQsD.js} +1 -1
  61. package/dashboard/dist/assets/{download-BI-n3YEd.js → download-D31Nn-bI.js} +1 -1
  62. package/dashboard/dist/assets/{external-link-CH7tksO4.js → external-link-C_lK9S3p.js} +1 -1
  63. package/dashboard/dist/assets/{flame-CP7hkBXq.js → flame-1ZxhHshT.js} +1 -1
  64. package/dashboard/dist/assets/{format-tokens-Biw6wloc.js → format-tokens-DJk25SkD.js} +1 -1
  65. package/dashboard/dist/assets/{gift-Dz9MeN8q.js → gift-BUZzFh-1.js} +1 -1
  66. package/dashboard/dist/assets/{icons-DP4Ni8VA.js → icons-BVPThCa4.js} +1 -1
  67. package/dashboard/dist/assets/{index-kQETJkOr.js → index-B-mxrdIz.js} +1 -1
  68. package/dashboard/dist/assets/{index-SXavLLcl.js → index-C0zFsOWh.js} +1 -1
  69. package/dashboard/dist/assets/{index-CIfh9hNs.js → index-CjPsyTl7.js} +1 -1
  70. package/dashboard/dist/assets/{index-BkeoJvCA.js → index-CzstoU6d.js} +1 -1
  71. package/dashboard/dist/assets/{info-Bba3RZJA.js → info-DmU-vlJ6.js} +1 -1
  72. package/dashboard/dist/assets/{link-2-B2C7-GOt.js → link-2-BlBM7uw7.js} +1 -1
  73. package/dashboard/dist/assets/{loader-circle-Dld8yUT7.js → loader-circle-Dhrb_Ocy.js} +1 -1
  74. package/dashboard/dist/assets/{local-usage-transfer-CAuRHyVW.js → local-usage-transfer-BudmMtkd.js} +1 -1
  75. package/dashboard/dist/assets/{modifiers.esm-CtFVyyHC.js → modifiers.esm-kmw1VM8W.js} +1 -1
  76. package/dashboard/dist/assets/{monitor-smartphone-iFNG6h_M.js → monitor-smartphone-DtjXNC_x.js} +1 -1
  77. package/dashboard/dist/assets/{play-C7GLNAdX.js → play-9w46lbjy.js} +1 -1
  78. package/dashboard/dist/assets/{plus-CSKQToSA.js → plus-DAf_WGKT.js} +1 -1
  79. package/dashboard/dist/assets/{provider-display-BwVQXnb2.js → provider-display-BZ0XlB2q.js} +1 -1
  80. package/dashboard/dist/assets/{react-Cy_Eczdb.js → react-B01ZJuZY.js} +1 -1
  81. package/dashboard/dist/assets/{safePolygon-DiPfgohh.js → safePolygon-B6_x12Gb.js} +1 -1
  82. package/dashboard/dist/assets/{search-DKyVdlXG.js → search-D3dW9GbV.js} +1 -1
  83. package/dashboard/dist/assets/{server-DaU0lAOr.js → server-C2XZBar7.js} +1 -1
  84. package/dashboard/dist/assets/{shield-check-DWiYKLWx.js → shield-check-4FX5vbS7.js} +1 -1
  85. package/dashboard/dist/assets/{skills-api-CXotXIRt.js → skills-api-BvW_MUyg.js} +1 -1
  86. package/dashboard/dist/assets/{sortable.esm-ON-92Wzk.js → sortable.esm-CAa_BJgh.js} +1 -1
  87. package/dashboard/dist/assets/{store-C91MN0gm.js → store-2AX2MNkz.js} +1 -1
  88. package/dashboard/dist/assets/{terminal-CZUta1FC.js → terminal-CON7n_ir.js} +1 -1
  89. package/dashboard/dist/assets/{trash-dOvHm24W.js → trash-C2f88jSi.js} +1 -1
  90. package/dashboard/dist/assets/{use-community-stats-BD3O7D_G.js → use-community-stats-CgQu43xV.js} +1 -1
  91. package/dashboard/dist/assets/{use-latest-request-guard-Geg2sygc.js → use-latest-request-guard-lShM46-3.js} +1 -1
  92. package/dashboard/dist/assets/{use-limits-display-prefs-Ck2CuPqb.js → use-limits-display-prefs-8o7pbEg0.js} +1 -1
  93. package/dashboard/dist/assets/{use-native-settings-3DH5xldB.js → use-native-settings-dN8shG3M.js} +1 -1
  94. package/dashboard/dist/assets/{use-ordered-list-BonkP7AB.js → use-ordered-list-beJy2IqQ.js} +1 -1
  95. package/dashboard/dist/assets/use-reduced-motion-S0bR_G1B.js +1 -0
  96. package/dashboard/dist/assets/{use-transform-C32HObUN.js → use-transform-cJpAQr2d.js} +1 -1
  97. package/dashboard/dist/assets/{use-usage-limits-6GFRuUdV.js → use-usage-limits-CuusZA6b.js} +1 -1
  98. package/dashboard/dist/assets/{useCurrency-BsGBVGb5.js → useCurrency-Cx9UsKJK.js} +1 -1
  99. package/dashboard/dist/assets/{usePositioner-DvFrrUs0.js → usePositioner-TEClK6mF.js} +1 -1
  100. package/dashboard/dist/assets/{useTokenFormat-Dryg74BA.js → useTokenFormat-BNwTLiac.js} +1 -1
  101. package/dashboard/dist/assets/{zap-BL_hxkkj.js → zap-CFgbVEhC.js} +1 -1
  102. package/dashboard/dist/index.html +1 -1
  103. package/dashboard/dist/ip-check.html +1 -1
  104. package/dashboard/dist/leaderboard.html +1 -1
  105. package/dashboard/dist/share.html +1 -1
  106. package/package.json +1 -1
  107. package/src/commands/init.js +30 -16
  108. package/src/commands/serve.js +28 -25
  109. package/src/commands/sync.js +3 -3
  110. package/src/lib/cursor-store.js +5 -1
  111. package/src/lib/local-api.js +25 -11
  112. package/src/lib/pricing/seed-snapshot.json +1 -1
  113. package/src/lib/rollout.js +262 -59
  114. package/src/lib/runtime-copy.js +77 -0
  115. package/dashboard/dist/assets/DialogDescription-DG_4qSj0.js +0 -1
  116. package/dashboard/dist/assets/use-reduced-motion-uryC1l51.js +0 -1
@@ -3526,32 +3526,72 @@ async function parseOpencodeMessageFile({
3526
3526
  // Re-emit stored buckets once on upgrade, including idle historical groups:
3527
3527
  // their totals still retain the original attribution, so no log replay is
3528
3528
  // needed. Queue readers replace rows by (source, model, hour_start).
3529
+ //
3530
+ // Older repairs sometimes flattened per-model keys into `codex|unknown|<hour>`
3531
+ // while leaving `groupQueued` pointing at the already-correct named queue
3532
+ // rows (#1052). Re-emitting that flattened unknown invents an `unknown`
3533
+ // sibling (and can zero a stale alignedModel) beside the named rows. Skip
3534
+ // re-emit for those flattened legacy groups; clear the marker only.
3529
3535
  function preserveCodexModelBuckets(hourlyState, touchedBuckets) {
3530
3536
  const upgrading = hourlyState.modelAttributionPolicy?.codex !== "observed";
3531
- if (!upgrading && !Object.keys(hourlyState.groupQueued).some(
3537
+ const groupQueued =
3538
+ hourlyState.groupQueued && typeof hourlyState.groupQueued === "object"
3539
+ ? hourlyState.groupQueued
3540
+ : {};
3541
+ if (!upgrading && !Object.keys(groupQueued).some(
3532
3542
  key => key.startsWith(`${DEFAULT_SOURCE}${BUCKET_SEPARATOR}`),
3533
3543
  )) return;
3544
+
3545
+ const groupModels = new Map();
3546
+ for (const [key, bucket] of Object.entries(hourlyState.buckets || {})) {
3547
+ const parsed = parseBucketKey(key);
3548
+ if (parsed.source !== DEFAULT_SOURCE || !bucket?.totals) continue;
3549
+ const groupKey = groupBucketKey(parsed.source, parsed.hourStart);
3550
+ const model = normalizeModelInput(parsed.model) || DEFAULT_MODEL;
3551
+ let entry = groupModels.get(groupKey);
3552
+ if (!entry) {
3553
+ entry = { named: false };
3554
+ groupModels.set(groupKey, entry);
3555
+ }
3556
+ if (model !== DEFAULT_MODEL) entry.named = true;
3557
+ }
3558
+
3534
3559
  const legacyGroups = new Set();
3560
+ const flattenedLegacyGroups = new Set();
3561
+ for (const groupKey of Object.keys(groupQueued)) {
3562
+ if (!groupKey.startsWith(`${DEFAULT_SOURCE}${BUCKET_SEPARATOR}`)) continue;
3563
+ legacyGroups.add(groupKey);
3564
+ const entry = groupModels.get(groupKey);
3565
+ // No named model buckets left: the hour was flattened to unknown (or the
3566
+ // buckets were dropped). Named queue rows from the repair are the truth.
3567
+ if (!entry || !entry.named) flattenedLegacyGroups.add(groupKey);
3568
+ }
3569
+
3535
3570
  for (const [key, bucket] of Object.entries(hourlyState.buckets || {})) {
3536
3571
  const parsed = parseBucketKey(key);
3537
3572
  if (parsed.source !== DEFAULT_SOURCE || !bucket?.totals) continue;
3538
3573
  const groupKey = groupBucketKey(parsed.source, parsed.hourStart);
3539
- const legacy = Object.prototype.hasOwnProperty.call(hourlyState.groupQueued, groupKey);
3540
- if (legacy) legacyGroups.add(groupKey);
3574
+ const legacy = legacyGroups.has(groupKey);
3575
+ if (flattenedLegacyGroups.has(groupKey)) continue;
3541
3576
  if (!upgrading && !legacy) continue;
3542
3577
  bucket.queuedKey = null;
3543
3578
  bucket.retractedUnknownKey = null;
3544
3579
  touchedBuckets.add(key);
3545
3580
  }
3546
3581
  for (const groupKey of legacyGroups) {
3582
+ if (flattenedLegacyGroups.has(groupKey)) {
3583
+ delete groupQueued[groupKey];
3584
+ continue;
3585
+ }
3547
3586
  const hourStart = groupKey.slice(DEFAULT_SOURCE.length + BUCKET_SEPARATOR.length);
3548
3587
  // Legacy groups emitted a combined unknown row for multiple named models.
3549
3588
  // Replace it with the actual unknown total (or zero) before splitting.
3550
3589
  const unknown = getHourlyBucket(hourlyState, DEFAULT_SOURCE, DEFAULT_MODEL, hourStart);
3551
3590
  unknown.queuedKey = null;
3552
3591
  touchedBuckets.add(bucketKey(DEFAULT_SOURCE, DEFAULT_MODEL, hourStart));
3553
- delete hourlyState.groupQueued[groupKey];
3592
+ delete groupQueued[groupKey];
3554
3593
  }
3594
+ hourlyState.groupQueued = groupQueued;
3555
3595
  hourlyState.modelAttributionPolicy = { ...hourlyState.modelAttributionPolicy, codex: "observed" };
3556
3596
  }
3557
3597
 
@@ -24354,6 +24394,9 @@ async function parseTraeCnApiIncremental({
24354
24394
  const DSH_SESSION_LOG_MAX_BYTES = 64 * 1024 * 1024;
24355
24395
  const DSH_SESSION_TEXT_MAX_BYTES = 128 * 1024 * 1024;
24356
24396
  const DSH_SOURCE = "dsh";
24397
+ // v2 excludes fork seeds and replayed assistant identities. Old ledgers need
24398
+ // a verified replacement, not a reset of the provider-wide hourly totals.
24399
+ const DSH_ACCOUNTING_VERSION = 2;
24357
24400
 
24358
24401
  // Precedence mirrors the harness's own resolveDshHome: an explicit
24359
24402
  // TokenTracker override, then the harness's $DSH_HOME, then `~/.dsh`. Tests
@@ -24720,20 +24763,48 @@ function legacyDshContributionsFromSnapshot(snapshot, previousState) {
24720
24763
  previousState.lastSeq < -1 ||
24721
24764
  previousState.inode !== snapshot.inode ||
24722
24765
  !Number.isFinite(previousState.size) ||
24723
- snapshot.size < previousState.size
24766
+ snapshot.size < previousState.size ||
24767
+ (snapshot.size === previousState.size && snapshot.mtimeMs !== previousState.mtimeMs)
24724
24768
  ) {
24725
24769
  return null;
24726
24770
  }
24727
24771
  const parsed = extractDshSessionUsage(snapshot.text, -1);
24728
- if (!parsed.complete || parsed.sessionId !== previousState.sessionId) return null;
24772
+ if (!parsed.complete || parsed.sessionId !== previousState.sessionId ||
24773
+ parsed.maxSeq < previousState.lastSeq) return null;
24729
24774
  // The pre-ledger parser replayed unknown-sequence usage on every pass, so
24730
24775
  // there is no safe prefix boundary for such a record. Defer instead.
24731
- if (parsed.deltas.some((delta) => !Number.isSafeInteger(delta.seq))) return null;
24776
+ const priorDeltas = previousState.accountingVersion === DSH_ACCOUNTING_VERSION
24777
+ ? parsed.deltas : parsed.rawDeltas;
24778
+ if (priorDeltas.some((delta) => !Number.isSafeInteger(delta.seq))) return null;
24732
24779
  return dshContributionsFromDeltas(
24733
- parsed.deltas.filter((delta) => delta.seq <= previousState.lastSeq),
24780
+ priorDeltas.filter((delta) => delta.seq <= previousState.lastSeq),
24734
24781
  );
24735
24782
  }
24736
24783
 
24784
+ // When the old and new artifacts differ, aggregate equality alone does not
24785
+ // establish that the replacement contains the historical events. Match the
24786
+ // count-only metadata as a multiset, allowing seq rewrites but no lost events.
24787
+ function dshRawUsageCoversPrefix(candidate, snapshot, previousState) {
24788
+ if (!snapshot?.text || !previousState) return false;
24789
+ const prefix = extractDshSessionUsage(snapshot.text).rawDeltas
24790
+ .filter((delta) => delta.seq != null && delta.seq <= previousState.lastSeq);
24791
+ const key = (delta) => JSON.stringify([
24792
+ delta.messageId, delta.model, delta.timeMs, delta.totals,
24793
+ ]);
24794
+ const available = new Map();
24795
+ for (const delta of candidate || []) {
24796
+ const identity = key(delta);
24797
+ available.set(identity, (available.get(identity) || 0) + 1);
24798
+ }
24799
+ for (const delta of prefix) {
24800
+ const identity = key(delta);
24801
+ const count = available.get(identity) || 0;
24802
+ if (!count) return false;
24803
+ available.set(identity, count - 1);
24804
+ }
24805
+ return true;
24806
+ }
24807
+
24737
24808
  function dshContributionsCoverPrior(prior, candidate) {
24738
24809
  for (const [key, previous] of Object.entries(prior || {})) {
24739
24810
  const current = candidate?.[key];
@@ -25127,17 +25198,20 @@ function isCompleteDshJsonLine(raw) {
25127
25198
  return index === text.length;
25128
25199
  }
25129
25200
 
25130
- // Parse one session log's plaintext into usage deltas, skipping events whose
25131
- // seq is at or below the watermark. Returns the deltas (each carrying the
25132
- // sequence, model and epoch-ms timestamp), the highest complete seq seen, the
25133
- // session id, and whether every non-empty JSONL line was complete.
25134
- function extractDshSessionUsage(text, lastSeq = -1) {
25135
- const deltas = [];
25201
+ // Read routing metadata and counts only; never materialize message content.
25202
+ // rawDeltas preserves the former seq-only interpretation for migration proof.
25203
+ // deltas excludes the inherited prefix and repeated assistant message ids.
25204
+ function extractDshSessionUsage(text, lastSeq = -1, lastLine = -1) {
25205
+ const rawDeltas = [];
25136
25206
  const watermark = Number.isFinite(lastSeq) ? lastSeq : -1;
25137
25207
  let maxSeq = watermark;
25208
+ let maxLine = -1;
25138
25209
  let sessionId = null;
25139
25210
  let headerModel = null;
25140
25211
  let complete = true;
25212
+ let isSeeded = false;
25213
+ let seedLength = null;
25214
+ let inheritedThrough = -1;
25141
25215
 
25142
25216
  const lines = String(text || "").split("\n");
25143
25217
  for (let i = 0; i < lines.length; i++) {
@@ -25145,29 +25219,37 @@ function extractDshSessionUsage(text, lastSeq = -1) {
25145
25219
  if (!line || !line.trim()) continue;
25146
25220
  if (!isCompleteDshJsonLine(line)) {
25147
25221
  complete = false;
25148
- // A later event cannot make this gap safe to acknowledge. Stop at the
25149
- // complete prefix so a repaired record is retried before seq advances.
25222
+ // Do not acknowledge records after a corrupt/torn interior line.
25150
25223
  break;
25151
25224
  }
25225
+ maxLine = i;
25152
25226
  const eventType = parseDshJsonString(findDshJsonProperty(line, "type"));
25153
25227
  if (!eventType) continue;
25154
-
25228
+ const data = findDshJsonProperty(line, "data");
25155
25229
  if (eventType === "session" || eventType === "session/start") {
25156
- const id =
25157
- parseDshJsonString(findDshJsonProperty(line, "id")) ||
25158
- parseDshJsonString(findDshJsonProperty(findDshJsonProperty(line, "data"), "id")) ||
25159
- parseDshJsonString(findDshJsonProperty(findDshJsonProperty(line, "data"), "sessionId"));
25230
+ const id = parseDshJsonString(findDshJsonProperty(line, "id")) ||
25231
+ parseDshJsonString(findDshJsonProperty(data, "id")) ||
25232
+ parseDshJsonString(findDshJsonProperty(data, "sessionId"));
25160
25233
  if (id) sessionId = id;
25234
+ isSeeded = findDshJsonProperty(line, "isSeeded") === "true" ||
25235
+ findDshJsonProperty(data, "isSeeded") === "true";
25236
+ const topLength = parseDshJsonNumber(findDshJsonProperty(line, "seedLength"));
25237
+ const length = Number.isFinite(topLength) ? topLength :
25238
+ parseDshJsonNumber(findDshJsonProperty(data, "seedLength"));
25239
+ if (Number.isSafeInteger(length) && length >= 0) seedLength = length;
25161
25240
  continue;
25162
25241
  }
25163
25242
 
25164
25243
  const seq = parseDshJsonNumber(findDshJsonProperty(line, "seq"));
25165
25244
  const seqKnown = Number.isFinite(seq) && seq >= 0;
25166
25245
  if (seqKnown && seq > maxSeq) maxSeq = seq;
25167
-
25168
- const data = findDshJsonProperty(line, "data");
25169
25246
  if (!data) continue;
25170
-
25247
+ if (eventType === "session/end-seed") {
25248
+ // Nested forks contain earlier markers. The LAST explicitly inherited
25249
+ // marker is the boundary; ordinary resume markers (data:{}) are not.
25250
+ if (findDshJsonProperty(data, "inherited") === "true") inheritedThrough = i;
25251
+ continue;
25252
+ }
25171
25253
  if (eventType === "request/header") {
25172
25254
  const header = findDshJsonProperty(data, "header");
25173
25255
  const config = findDshJsonProperty(header, "config");
@@ -25176,39 +25258,46 @@ function extractDshSessionUsage(text, lastSeq = -1) {
25176
25258
  if (normalized) headerModel = normalized;
25177
25259
  continue;
25178
25260
  }
25179
-
25180
25261
  if (eventType !== "assistant/message" && eventType !== "message/assistant") continue;
25181
- if (seqKnown && seq <= watermark) continue;
25182
-
25183
25262
  const message = findDshJsonProperty(data, "message");
25184
25263
  const source = message ? findDshJsonProperty(message, "source") : null;
25185
25264
  const model = normalizeDshModelName(
25186
25265
  (source && parseDshJsonString(findDshJsonProperty(source, "model"))) ||
25187
25266
  parseDshJsonString(findDshJsonProperty(data, "model")),
25188
25267
  ) || headerModel;
25189
- const totals = dshUsageToTotals(
25190
- parseDshUsageSlice(
25191
- findDshJsonProperty(data, "usage") || findDshJsonProperty(line, "usage"),
25192
- ),
25193
- );
25268
+ const totals = dshUsageToTotals(parseDshUsageSlice(
25269
+ findDshJsonProperty(data, "usage") || findDshJsonProperty(line, "usage"),
25270
+ ));
25194
25271
  if (!model || !totals) continue;
25195
-
25196
- const timeMs =
25197
- parseDshJsonNumber(findDshJsonProperty(line, "time")) ||
25272
+ const timeMs = parseDshJsonNumber(findDshJsonProperty(line, "time")) ||
25198
25273
  parseDshJsonNumber(findDshJsonProperty(line, "timestamp")) ||
25199
25274
  parseDshJsonNumber(findDshJsonProperty(data, "time")) ||
25200
25275
  parseDshJsonNumber(findDshJsonProperty(data, "timestamp"));
25201
25276
  if (!Number.isFinite(timeMs) || timeMs <= 0) continue;
25202
-
25203
- deltas.push({ seq: seqKnown ? seq : null, model, timeMs, totals });
25277
+ const id = parseDshJsonString(findDshJsonProperty(message, "id"));
25278
+ rawDeltas.push({ seq: seqKnown ? seq : null, line: i,
25279
+ messageId: id?.trim() || null, model, timeMs, totals });
25204
25280
  }
25205
25281
 
25206
- return { deltas, maxSeq, sessionId, complete };
25282
+ const seedComplete = !isSeeded || inheritedThrough >= 0 || seedLength != null;
25283
+ const seen = new Set();
25284
+ const deltas = rawDeltas.filter((delta) => {
25285
+ if (delta.messageId) {
25286
+ if (seen.has(delta.messageId)) return false;
25287
+ // Claim before applying the watermark: a replay at a newer seq is not new.
25288
+ seen.add(delta.messageId);
25289
+ }
25290
+ if (!seedComplete || delta.line <= inheritedThrough) return false;
25291
+ // Legacy seedLength is an exclusive seq boundary, not a line count.
25292
+ if (seedLength != null && (delta.seq == null || delta.seq < seedLength)) return false;
25293
+ return delta.seq != null ? delta.seq > watermark : delta.line > lastLine;
25294
+ });
25295
+ return { deltas, rawDeltas, maxSeq, maxLine, sessionId, complete, seedComplete };
25207
25296
  }
25208
25297
 
25209
25298
  // Incremental parser entrypoint. Mirrors the other passive JSONL readers:
25210
25299
  // identity-check each file (inode/size/mtime), re-read + re-parse only when it
25211
- // changed, dedup via the per-file seq watermark, accumulate into hourly
25300
+ // changed, dedup via message ownership plus seq/line watermarks, accumulate into hourly
25212
25301
  // buckets, then flush touched buckets to the queue. Session contribution ledgers
25213
25302
  // make artifact-path migrations replace one session instead of resetting DSH.
25214
25303
  async function parseDshIncremental({ sessionFiles, cursors, queuePath, onProgress }) {
@@ -25222,6 +25311,9 @@ async function parseDshIncremental({ sessionFiles, cursors, queuePath, onProgres
25222
25311
  const storedSessionState =
25223
25312
  dshState.sessions && typeof dshState.sessions === "object" ? dshState.sessions : {};
25224
25313
  const sessionState = { ...storedSessionState };
25314
+ // Never prune ownership when an origin transcript disappears. Only opaque
25315
+ // message ids and session identities are persisted, not conversation text.
25316
+ const messageOwners = { ...(dshState.messageOwners || {}) };
25225
25317
 
25226
25318
  const hourlyState = normalizeHourlyState(cursors?.hourly);
25227
25319
  // normalizeHourlyState preserves bucket objects for the other incremental
@@ -25247,6 +25339,22 @@ async function parseDshIncremental({ sessionFiles, cursors, queuePath, onProgres
25247
25339
  const presentFiles = new Set(files);
25248
25340
  const total = files.length;
25249
25341
  const deferredFilePaths = new Set();
25342
+ const legacyAliases = new Map();
25343
+ for (const [filePath, state] of Object.entries(fileState)) {
25344
+ if (!state?.sessionId || state.accountingVersion === DSH_ACCOUNTING_VERSION) continue;
25345
+ const aliases = legacyAliases.get(state.sessionId) || [];
25346
+ aliases.push(filePath);
25347
+ legacyAliases.set(state.sessionId, aliases);
25348
+ }
25349
+ for (const aliases of legacyAliases.values()) {
25350
+ if (aliases.length < 2) continue;
25351
+ // Older cursors cannot prove whether these alias paths were counted once
25352
+ // or independently. Keep both baselines instead of guessing a subtraction.
25353
+ for (const filePath of aliases) {
25354
+ deferredFilePaths.add(filePath);
25355
+ deferredMigrationPaths.set(filePath, "ambiguous-legacy-session-aliases");
25356
+ }
25357
+ }
25250
25358
  let recordsProcessed = 0;
25251
25359
  let eventsAggregated = 0;
25252
25360
 
@@ -25304,6 +25412,9 @@ async function parseDshIncremental({ sessionFiles, cursors, queuePath, onProgres
25304
25412
  const needsLedgerBackfill = Boolean(
25305
25413
  previousSessionId && !previousSessionContributions && !previousFileContributions,
25306
25414
  );
25415
+ const needsAccountingUpgrade = Boolean(
25416
+ prev && prev.accountingVersion !== DSH_ACCOUNTING_VERSION,
25417
+ );
25307
25418
  let snapshot;
25308
25419
  let parsed;
25309
25420
  let fullParsed = null;
@@ -25312,7 +25423,7 @@ async function parseDshIncremental({ sessionFiles, cursors, queuePath, onProgres
25312
25423
 
25313
25424
  try {
25314
25425
  snapshot = await readDshSessionSnapshot(filePath, {
25315
- previous: needsLedgerBackfill ? null : prev,
25426
+ previous: needsLedgerBackfill || needsAccountingUpgrade ? null : prev,
25316
25427
  });
25317
25428
  if (!snapshot) {
25318
25429
  // A resolver-selected replacement can disappear between discovery and
@@ -25320,10 +25431,11 @@ async function parseDshIncremental({ sessionFiles, cursors, queuePath, onProgres
25320
25431
  // contribution. This is deliberately separate from a thrown read
25321
25432
  // error because a missing path returns null from the snapshot helper.
25322
25433
  deferStaleMigrationPaths(filePath, "replacement-missing");
25434
+ if (needsAccountingUpgrade) deferredMigrationPaths.set(filePath, "history-missing");
25323
25435
  reportProgress(idx, recordsProcessed, eventsAggregated, total);
25324
25436
  continue;
25325
25437
  }
25326
- if (snapshot.unchanged && !needsLedgerBackfill) {
25438
+ if (snapshot.unchanged && !needsLedgerBackfill && !needsAccountingUpgrade) {
25327
25439
  reportProgress(idx, recordsProcessed, eventsAggregated, total);
25328
25440
  continue;
25329
25441
  }
@@ -25345,18 +25457,18 @@ async function parseDshIncremental({ sessionFiles, cursors, queuePath, onProgres
25345
25457
  ),
25346
25458
  );
25347
25459
  const lastSeq = fileReset || !Number.isFinite(prev?.lastSeq) ? -1 : prev.lastSeq;
25348
- parsed = extractDshSessionUsage(snapshot.text, lastSeq);
25460
+ parsed = extractDshSessionUsage(snapshot.text, lastSeq, fileReset ? -1 : prev?.lastLine);
25349
25461
  sessionChanged = Boolean(
25350
25462
  prev &&
25351
25463
  parsed.sessionId &&
25352
25464
  parsed.sessionId !== previousSessionId,
25353
25465
  );
25354
25466
  if (sessionChanged) parsed = extractDshSessionUsage(snapshot.text, -1);
25355
- if (!prev || needsLedgerBackfill || sessionChanged || fileReset) {
25467
+ if (!prev || needsLedgerBackfill || needsAccountingUpgrade || sessionChanged || fileReset) {
25356
25468
  fullParsed = extractDshSessionUsage(snapshot.text, -1);
25357
- if (!prev || sessionChanged || fileReset) parsed = fullParsed;
25469
+ if (!prev || needsAccountingUpgrade || sessionChanged || fileReset) parsed = fullParsed;
25358
25470
  }
25359
- if (!parsed.complete) snapshot.mtimeMs = -1;
25471
+ if (!parsed.complete || !parsed.seedComplete) snapshot.mtimeMs = -1;
25360
25472
  } catch (error) {
25361
25473
  if (process.env.TOKENTRACKER_DEBUG) {
25362
25474
  process.stderr.write(`[dsh] skipped ${filePath}: ${error?.message || error}\n`);
@@ -25365,11 +25477,22 @@ async function parseDshIncremental({ sessionFiles, cursors, queuePath, onProgres
25365
25477
  // selected file list. Keep that cursor until the replacement is readable
25366
25478
  // so a failed migration cannot turn into an untracked double-count.
25367
25479
  deferStaleMigrationPaths(filePath, "replacement-read-failed");
25480
+ if (needsAccountingUpgrade) deferredMigrationPaths.set(filePath, "history-read-failed");
25368
25481
  reportProgress(idx, recordsProcessed, eventsAggregated, total);
25369
25482
  continue;
25370
25483
  }
25371
25484
 
25372
25485
  const sessionId = typeof parsed.sessionId === "string" ? parsed.sessionId : null;
25486
+ if ((legacyAliases.get(sessionId)?.length || 0) > 1) {
25487
+ reportProgress(idx, recordsProcessed, eventsAggregated, total);
25488
+ continue;
25489
+ }
25490
+ if (prev && !sessionId && (previousSessionId || needsAccountingUpgrade)) {
25491
+ deferredFilePaths.add(filePath);
25492
+ deferredMigrationPaths.set(filePath, "replacement-session-id-missing");
25493
+ reportProgress(idx, recordsProcessed, eventsAggregated, total);
25494
+ continue;
25495
+ }
25373
25496
  const stalePathWithoutId = sessionId
25374
25497
  ? null
25375
25498
  : Object.keys(fileState).find(
@@ -25387,8 +25510,15 @@ async function parseDshIncremental({ sessionFiles, cursors, queuePath, onProgres
25387
25510
  const stalePath = staleFileForSession(sessionId, filePath);
25388
25511
  const previousPath =
25389
25512
  session?.lastPath && session.lastPath !== filePath ? session.lastPath : stalePath;
25513
+ if (previousPath && previousPath !== filePath) {
25514
+ // Another artifact currently owns this session ledger. Replacing that
25515
+ // ledger needs the full candidate, even if this alias has its own cursor.
25516
+ fullParsed = extractDshSessionUsage(snapshot.text);
25517
+ parsed = fullParsed;
25518
+ }
25390
25519
  const resetCurrentFile = Boolean(fileReset && !sessionChanged);
25391
- const replacementPath = previousPath || (resetCurrentFile ? filePath : null);
25520
+ const upgradeCurrentFile = needsAccountingUpgrade && !sessionChanged;
25521
+ const replacementPath = previousPath || (resetCurrentFile || upgradeCurrentFile ? filePath : null);
25392
25522
  const sessionContributions = storedDshContributions(session);
25393
25523
  const staleContributions = storedDshContributions(stalePath && fileState[stalePath]);
25394
25524
  const fileContributions = storedDshContributions(prev);
@@ -25411,7 +25541,9 @@ async function parseDshIncremental({ sessionFiles, cursors, queuePath, onProgres
25411
25541
  // A pure rename preserves the same bytes. Reusing the parsed full
25412
25542
  // contribution is safe here because replacement below subtracts and
25413
25543
  // re-adds it; no sequence watermark is transferred.
25414
- oldContributions = dshContributionsFromDeltas(parsed.deltas);
25544
+ oldContributions = dshContributionsFromDeltas(
25545
+ previousState.accountingVersion === DSH_ACCOUNTING_VERSION ? parsed.deltas : parsed.rawDeltas,
25546
+ );
25415
25547
  }
25416
25548
 
25417
25549
  // Cursors written before the contribution ledger existed can still be
@@ -25424,10 +25556,13 @@ async function parseDshIncremental({ sessionFiles, cursors, queuePath, onProgres
25424
25556
  const oldSnapshot = await readDshSessionSnapshot(previousPath).catch(() => null);
25425
25557
  oldContributions = legacyDshContributionsFromSnapshot(oldSnapshot, oldState);
25426
25558
  }
25559
+ if (sessionId && upgradeCurrentFile && !oldContributions) {
25560
+ oldContributions = legacyDshContributionsFromSnapshot(snapshot, prev);
25561
+ }
25427
25562
  const replaceSession = Boolean(
25428
25563
  sessionId &&
25429
25564
  oldContributions &&
25430
- (!prev || resetCurrentFile || (previousPath && previousPath !== filePath)),
25565
+ (!prev || resetCurrentFile || upgradeCurrentFile || (previousPath && previousPath !== filePath)),
25431
25566
  );
25432
25567
  const oldContributionCount = Object.keys(oldContributions || {}).length;
25433
25568
  if (sessionId && replacementPath && !oldContributions) {
@@ -25439,20 +25574,25 @@ async function parseDshIncremental({ sessionFiles, cursors, queuePath, onProgres
25439
25574
  }
25440
25575
  if (
25441
25576
  replaceSession &&
25442
- (!parsed.complete || (oldContributionCount > 0 && parsed.deltas.length === 0))
25577
+ (!parsed.complete || !parsed.seedComplete || (oldContributionCount > 0 && parsed.rawDeltas.length === 0))
25443
25578
  ) {
25444
25579
  // A readable header-only or torn replacement is not authoritative. Keep
25445
25580
  // the old contribution until a complete replacement with usage arrives.
25446
25581
  deferredFilePaths.add(replacementPath);
25447
25582
  deferredMigrationPaths.set(
25448
25583
  replacementPath,
25449
- parsed.complete ? "replacement-has-no-usage" : "replacement-incomplete",
25584
+ !parsed.complete ? "replacement-incomplete" :
25585
+ !parsed.seedComplete ? "seed-boundary-unavailable" : "replacement-has-no-usage",
25450
25586
  );
25451
25587
  if (resetCurrentFile) fileState[filePath] = { ...prev, mtimeMs: -1 };
25452
25588
  reportProgress(idx, recordsProcessed, eventsAggregated, total);
25453
25589
  continue;
25454
25590
  }
25455
- const candidateContributions = dshContributionsFromDeltas(parsed.deltas);
25591
+ // A correction may legitimately be lower or zero. Verify that the FULL
25592
+ // raw log still accounts for the old ledger before subtracting anything.
25593
+ // Comparing corrected usage to the old totals would reject the very repair
25594
+ // this migration must make; accepting a cropped log would erase history.
25595
+ const candidateContributions = dshContributionsFromDeltas(parsed.rawDeltas);
25456
25596
  if (
25457
25597
  replaceSession &&
25458
25598
  oldContributionCount > 0 &&
@@ -25478,6 +25618,46 @@ async function parseDshIncremental({ sessionFiles, cursors, queuePath, onProgres
25478
25618
  continue;
25479
25619
  }
25480
25620
 
25621
+ const owner = sessionId || dshSessionDirectoryKey(filePath);
25622
+ const acceptedDeltas = parsed.deltas.filter((delta) => {
25623
+ if (!delta.messageId) return true;
25624
+ const priorOwner = messageOwners[`a:${delta.messageId}`];
25625
+ return !priorOwner || (replaceSession && priorOwner === owner);
25626
+ });
25627
+ // Known ownership is stronger evidence than bucket totals. An equal or
25628
+ // larger divergent alias can still omit a previously counted message.
25629
+ const ownedIds = new Set(parsed.deltas.map((delta) => delta.messageId).filter(Boolean));
25630
+ const dropsOwnedId = replaceSession && Object.entries(messageOwners).some(([key, value]) =>
25631
+ value === owner && !ownedIds.has(key.slice(2)),
25632
+ );
25633
+ if (dropsOwnedId) {
25634
+ deferredFilePaths.add(replacementPath);
25635
+ deferredMigrationPaths.set(replacementPath, "replacement-drops-owned-message");
25636
+ reportProgress(idx, recordsProcessed, eventsAggregated, total);
25637
+ continue;
25638
+ }
25639
+ const correctedContributions = dshContributionsFromDeltas(acceptedDeltas);
25640
+ if (replaceSession && !dshContributionsCoverPrior(oldContributions, correctedContributions)) {
25641
+ // Aggregate >= is not proof: a cropped transcript can substitute larger
25642
+ // unrelated/replayed usage for a lost event. Reconstruct EXACTLY the
25643
+ // acknowledged prefix before allowing a downward/zero correction.
25644
+ const baselineState = previousState || prev;
25645
+ let baselineSnapshot = snapshot;
25646
+ if (previousPath && !sameDshSessionMetadata(baselineState, snapshot)) {
25647
+ baselineSnapshot = await readDshSessionSnapshot(previousPath).catch(() => null);
25648
+ }
25649
+ const proven = legacyDshContributionsFromSnapshot(baselineSnapshot, baselineState);
25650
+ if (baselineState?.accountingVersion === DSH_ACCOUNTING_VERSION ||
25651
+ !proven || !dshContributionsCoverPrior(oldContributions, proven) ||
25652
+ !dshContributionsCoverPrior(proven, oldContributions) ||
25653
+ !dshRawUsageCoversPrefix(parsed.rawDeltas, baselineSnapshot, baselineState)) {
25654
+ deferredFilePaths.add(replacementPath);
25655
+ deferredMigrationPaths.set(replacementPath, "historical-prefix-unverified");
25656
+ reportProgress(idx, recordsProcessed, eventsAggregated, total);
25657
+ continue;
25658
+ }
25659
+ }
25660
+
25481
25661
  if (replaceSession) {
25482
25662
  applyDshContributions({
25483
25663
  hourlyState,
@@ -25490,18 +25670,24 @@ async function parseDshIncremental({ sessionFiles, cursors, queuePath, onProgres
25490
25670
 
25491
25671
  let nextContributions = replaceSession
25492
25672
  ? {}
25493
- : sessionContributions || fileContributions || {};
25494
- if (fullParsed) {
25495
- nextContributions = dshContributionsFromDeltas(fullParsed.deltas);
25496
- }
25497
-
25498
- for (const delta of parsed.deltas) {
25673
+ : sessionChanged ? {} : sessionContributions || fileContributions || {};
25674
+ // Backfill missing ledgers without re-adding the acknowledged prefix.
25675
+ if (fullParsed && !replaceSession && prev && !sessionChanged && !fileReset) {
25676
+ nextContributions = dshContributionsFromDeltas(fullParsed.deltas.filter((delta) =>
25677
+ (!delta.messageId || !messageOwners[`a:${delta.messageId}`] ||
25678
+ messageOwners[`a:${delta.messageId}`] === owner) &&
25679
+ (delta.seq != null ? delta.seq <= prev.lastSeq : delta.line <= prev.lastLine),
25680
+ ));
25681
+ }
25682
+
25683
+ for (const delta of acceptedDeltas) {
25684
+ if (delta.messageId) messageOwners[`a:${delta.messageId}`] = owner;
25499
25685
  const bucketStart = toUtcHalfHourStart(delta.timeMs);
25500
25686
  if (!bucketStart) continue;
25501
25687
  const bucket = getHourlyBucket(hourlyState, DSH_SOURCE, delta.model, bucketStart);
25502
25688
  addTotals(bucket.totals, delta.totals);
25503
25689
  touchedBuckets.add(bucketKey(DSH_SOURCE, delta.model, bucketStart));
25504
- if (!fullParsed) addDshContribution(nextContributions, delta.model, bucketStart, delta.totals);
25690
+ addDshContribution(nextContributions, delta.model, bucketStart, delta.totals);
25505
25691
  eventsAggregated += 1;
25506
25692
  }
25507
25693
 
@@ -25512,12 +25698,15 @@ async function parseDshIncremental({ sessionFiles, cursors, queuePath, onProgres
25512
25698
  mtimeMs: snapshot.mtimeMs,
25513
25699
  sessionId,
25514
25700
  lastSeq: parsed.maxSeq,
25701
+ lastLine: parsed.maxLine,
25702
+ accountingVersion: DSH_ACCOUNTING_VERSION,
25515
25703
  contributions: nextContributions,
25516
25704
  updatedAt,
25517
25705
  };
25518
25706
  if (sessionId) {
25519
25707
  sessionState[sessionId] = {
25520
25708
  lastPath: filePath,
25709
+ accountingVersion: DSH_ACCOUNTING_VERSION,
25521
25710
  contributions: nextContributions,
25522
25711
  updatedAt,
25523
25712
  };
@@ -25535,7 +25724,11 @@ async function parseDshIncremental({ sessionFiles, cursors, queuePath, onProgres
25535
25724
  // Retain pre-ledger identity until a replacement is verified. Its counted
25536
25725
  // usage survives file deletion, so elapsed time cannot authorize replay.
25537
25726
  // Current ledgers retain the same identity in sessionState instead.
25538
- if (legacySessionId && !storedDshContributions(state) && !sessionState[legacySessionId]) {
25727
+ if ((legacySessionId && !storedDshContributions(state) && !sessionState[legacySessionId]) ||
25728
+ (!legacySessionId && state.accountingVersion !== DSH_ACCOUNTING_VERSION)) {
25729
+ if (state.accountingVersion !== DSH_ACCOUNTING_VERSION) {
25730
+ deferredMigrationPaths.set(filePath, "history-unverified");
25731
+ }
25539
25732
  if (!Number.isFinite(Number(state?.missingSince))) {
25540
25733
  fileState[filePath] = { ...state, missingSince: nowMs };
25541
25734
  }
@@ -25544,10 +25737,20 @@ async function parseDshIncremental({ sessionFiles, cursors, queuePath, onProgres
25544
25737
  delete fileState[filePath];
25545
25738
  }
25546
25739
 
25740
+ // Missing/corrupt historical transcripts retain their ledger and are made
25741
+ // visible as deferrals, never treated as a provider-wide reset opportunity.
25742
+ for (const [id, session] of Object.entries(sessionState)) {
25743
+ if (session?.accountingVersion !== DSH_ACCOUNTING_VERSION) {
25744
+ const key = session?.lastPath || `session:${id}`;
25745
+ if (!deferredMigrationPaths.has(key)) deferredMigrationPaths.set(key, "history-unverified");
25746
+ }
25747
+ }
25547
25748
  const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets });
25548
25749
  const updatedAt = new Date().toISOString();
25549
25750
  hourlyState.updatedAt = updatedAt;
25550
25751
  cursors.hourly = hourlyState;
25752
+ dshState.accountingVersion = DSH_ACCOUNTING_VERSION;
25753
+ dshState.messageOwners = messageOwners;
25551
25754
  dshState.files = fileState;
25552
25755
  dshState.sessions = sessionState;
25553
25756
  if (deferredMigrationPaths.size > 0) {
@@ -0,0 +1,77 @@
1
+ const fs = require("node:fs/promises");
2
+ const path = require("node:path");
3
+ const { writeFileAtomic } = require("./fs");
4
+
5
+ async function statIfPresent(file) {
6
+ try {
7
+ return await fs.lstat(file);
8
+ } catch (error) {
9
+ if (error.code === "ENOENT") return null;
10
+ throw error;
11
+ }
12
+ }
13
+
14
+ async function ensureRuntimeDirectory(dir) {
15
+ const target = await statIfPresent(dir);
16
+ if (target?.isDirectory() && !target.isSymbolicLink()) return;
17
+ if (target) await fs.rm(dir, { recursive: true, force: true });
18
+ await fs.mkdir(dir, { recursive: true });
19
+ }
20
+
21
+ // Runtime refreshes run on every desktop launch. Compare bytes instead of
22
+ // deleting the installation: identical versions, interrupted copies, and local
23
+ // builds with unchanged version numbers all behave correctly. Never follow a
24
+ // destination symlink when replacing files or pruning obsolete entries.
25
+ async function copyRuntimeTree(from, to) {
26
+ const source = await fs.lstat(from);
27
+ let target = await statIfPresent(to);
28
+ if (source.isSymbolicLink()) {
29
+ const link = await fs.readlink(from);
30
+ const resolved = path.resolve(path.dirname(from), link);
31
+ if (target?.isSymbolicLink()
32
+ && path.resolve(path.dirname(to), await fs.readlink(to)) === resolved) return;
33
+ if (target) await fs.rm(to, { recursive: true, force: true });
34
+ await fs.mkdir(path.dirname(to), { recursive: true });
35
+ // Keep fs.cp's existing default link semantics for npm/pnpm installations.
36
+ await fs.cp(from, to);
37
+ return;
38
+ }
39
+
40
+ if (target && (target.isSymbolicLink() || source.isDirectory() !== target.isDirectory())) {
41
+ await fs.rm(to, { recursive: true, force: true });
42
+ target = null;
43
+ }
44
+ if (source.isDirectory()) {
45
+ if (!target) await fs.mkdir(to, { recursive: true });
46
+ const names = await fs.readdir(from);
47
+ const retained = new Set(names);
48
+ for (const name of names) {
49
+ await copyRuntimeTree(path.join(from, name), path.join(to, name));
50
+ }
51
+ for (const name of await fs.readdir(to)) {
52
+ if (!retained.has(name)) {
53
+ await fs.rm(path.join(to, name), { recursive: true, force: true });
54
+ }
55
+ }
56
+ return;
57
+ }
58
+
59
+ if (!target) {
60
+ await fs.mkdir(path.dirname(to), { recursive: true });
61
+ await fs.copyFile(from, to);
62
+ return;
63
+ }
64
+ const content = await fs.readFile(from);
65
+ if (target?.isFile() && target.size === source.size
66
+ && content.equals(await fs.readFile(to))) {
67
+ if (process.platform !== "win32" && (source.mode & 0o777) !== (target.mode & 0o777)) {
68
+ await fs.chmod(to, source.mode & 0o777);
69
+ }
70
+ return;
71
+ }
72
+ // Hooks may read the installed runtime while it is refreshed. Publish each
73
+ // changed file atomically so they cannot read a truncated module.
74
+ await writeFileAtomic(to, content, { mode: source.mode & 0o777 });
75
+ }
76
+
77
+ module.exports = { copyRuntimeTree, ensureRuntimeDirectory };
@@ -1 +0,0 @@
1
- import{r as n,J as a}from"./__vite-browser-external-RPZeXrAv.js";import{u as p}from"./DialogTitle-Bn14t_Xd.js";import{u as c}from"./composite-BJuoijSA.js";const x=n.forwardRef(function(e,r){const{render:l,className:u,style:m,id:s,...t}=e,i=p(),o=c(s);return i.useSyncedValueWithCleanup("descriptionElementId",o),a("p",e,{ref:r,props:[{id:o},t]})});export{x as D};