tokentracker-cli 1.5.2 → 1.5.4

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 (134) hide show
  1. package/dashboard/dist/assets/{AchievementBadge-B5kmt0VD.js → AchievementBadge-CKNb_cwg.js} +1 -1
  2. package/dashboard/dist/assets/{AchievementsPage-C0FlkOo0.js → AchievementsPage-u9P8DXJI.js} +2 -2
  3. package/dashboard/dist/assets/{AchievementsSection-CRcp80Pp.js → AchievementsSection-BSCUJ4DR.js} +1 -1
  4. package/dashboard/dist/assets/{BadgeDetailModal-DAFjGbKW.js → BadgeDetailModal-Bg7cE3Zq.js} +1 -1
  5. package/dashboard/dist/assets/{BotAnimated-CO0bbimB.js → BotAnimated-C7D2WbQA.js} +1 -1
  6. package/dashboard/dist/assets/{Card-Xhr5exz8.js → Card-B4IqAhai.js} +1 -1
  7. package/dashboard/dist/assets/{CloudCheckoutPage-DsQ7FmsI.js → CloudCheckoutPage-P75pH9Ym.js} +1 -1
  8. package/dashboard/dist/assets/{CloudPage-DkSexu5V.js → CloudPage-DDEZfmZg.js} +2 -2
  9. package/dashboard/dist/assets/{CommandPalette-Bm0huQdj.js → CommandPalette-CER_AklA.js} +1 -1
  10. package/dashboard/dist/assets/{CommunityStatsModal-BsL8GN8Q.js → CommunityStatsModal-Cbb74eib.js} +1 -1
  11. package/dashboard/dist/assets/{ConfirmModal-_WPp292g.js → ConfirmModal-Q1ZQdZHt.js} +1 -1
  12. package/dashboard/dist/assets/{Controls-ktzn68KM.js → Controls-DaGOfcoz.js} +1 -1
  13. package/dashboard/dist/assets/DashboardPage-CXI7RmxO.js +1 -0
  14. package/dashboard/dist/assets/{DateRangePopover-DWr4zIzF.js → DateRangePopover-DSS1Ira_.js} +1 -1
  15. package/dashboard/dist/assets/{DevicePage-CBa8Mk47.js → DevicePage-6zPLYydh.js} +1 -1
  16. package/dashboard/dist/assets/{DialogClose-CZ5jzgtb.js → DialogClose-DdCQ36LK.js} +1 -1
  17. package/dashboard/dist/assets/DialogDescription-DztHQvj-.js +1 -0
  18. package/dashboard/dist/assets/{DialogTitle-Dr0N6q15.js → DialogTitle-BjP217Y1.js} +1 -1
  19. package/dashboard/dist/assets/{ElectricLogo-sYqgrO6-.js → ElectricLogo-Dhq8PM4h.js} +1 -1
  20. package/dashboard/dist/assets/{FadeIn-Qq-hcrZW.js → FadeIn-CGNbED4E.js} +1 -1
  21. package/dashboard/dist/assets/{HeaderGithubStar-B77zSv7i.js → HeaderGithubStar-DwqEFzb6.js} +1 -1
  22. package/dashboard/dist/assets/{HoverTooltip-lRDaUmoW.js → HoverTooltip-CQZTgyQy.js} +1 -1
  23. package/dashboard/dist/assets/{IpCheckPage-B6AvHZjA.js → IpCheckPage-Bz09HXLn.js} +1 -1
  24. package/dashboard/dist/assets/{LandingPage-DgzOjf3x.js → LandingPage-DC-lZsYR.js} +2 -2
  25. package/dashboard/dist/assets/{LeaderboardPage-BtjjtDMC.js → LeaderboardPage-D8MpLM_t.js} +2 -2
  26. package/dashboard/dist/assets/{LeaderboardProBadge-B2wNTUTU.js → LeaderboardProBadge-pdXgokhE.js} +1 -1
  27. package/dashboard/dist/assets/{LeaderboardProfileModal-DJJckwHh.js → LeaderboardProfileModal-nHZLiE0m.js} +3 -3
  28. package/dashboard/dist/assets/{LeaderboardProfilePage-DJdxByl2.js → LeaderboardProfilePage-BKv3oIAW.js} +1 -1
  29. package/dashboard/dist/assets/LimitsPage-TmRTgSCb.js +6 -0
  30. package/dashboard/dist/assets/{LocalOnlyNotice-B2lVqf_H.js → LocalOnlyNotice-aF01udyR.js} +1 -1
  31. package/dashboard/dist/assets/{LoginCard-i0gg1yby.js → LoginCard-BaroD0Ct.js} +1 -1
  32. package/dashboard/dist/assets/{LoginModal-B-loLNZM.js → LoginModal-0jgyMGVM.js} +1 -1
  33. package/dashboard/dist/assets/{LoginPage-D_swI75j.js → LoginPage-BUx8U58i.js} +1 -1
  34. package/dashboard/dist/assets/{PetPage-DDpKvpjG.js → PetPage-CpOjtJqg.js} +2 -2
  35. package/dashboard/dist/assets/{PopoverPopup-Gja4MyEK.js → PopoverPopup-BYjfer-M.js} +1 -1
  36. package/dashboard/dist/assets/{ProviderIcon-DOPj7tAZ.js → ProviderIcon-NtYa2h0u.js} +1 -1
  37. package/dashboard/dist/assets/{ResetPasswordPage-BVADLk89.js → ResetPasswordPage-CYUg7TR1.js} +1 -1
  38. package/dashboard/dist/assets/{Select-D-38tN-E.js → Select-CYP95o5y.js} +1 -1
  39. package/dashboard/dist/assets/{SelectItemText-jN61ukxk.js → SelectItemText-BCDfusOM.js} +1 -1
  40. package/dashboard/dist/assets/{SelfHostPage-k7AkGpl1.js → SelfHostPage-BmVc2rIT.js} +1 -1
  41. package/dashboard/dist/assets/ServiceStatusPage-Dyp8NwIn.js +1 -0
  42. package/dashboard/dist/assets/SessionsPage-tGEj6Ztz.js +3 -0
  43. package/dashboard/dist/assets/{SettingsPage-DfnMCAuA.js → SettingsPage-CCMbq5wc.js} +3 -3
  44. package/dashboard/dist/assets/{SkillsPage-YyIHIaHN.js → SkillsPage-C4O4fbaQ.js} +1 -1
  45. package/dashboard/dist/assets/{TokenGalaxy-BLcvZuQ9.js → TokenGalaxy-D2d738xr.js} +1 -1
  46. package/dashboard/dist/assets/{TooltipPopup-3E8kyVzz.js → TooltipPopup-BJfFYesG.js} +1 -1
  47. package/dashboard/dist/assets/{TrendMonitor-ByKAdXSF.js → TrendMonitor-CElakFSU.js} +1 -1
  48. package/dashboard/dist/assets/{WidgetsPage-BgEVIIzL.js → WidgetsPage-Cvd1LbJm.js} +1 -1
  49. package/dashboard/dist/assets/{WrappedPage-HjbX8QaA.js → WrappedPage-wRC1tE_a.js} +1 -1
  50. package/dashboard/dist/assets/{__vite-browser-external-Br-KAXh6.css → __vite-browser-external-CMBWGkOq.css} +1 -1
  51. package/dashboard/dist/assets/__vite-browser-external-V4TPLCJG.js +45 -0
  52. package/dashboard/dist/assets/{agent-logos-BVF2siPQ.js → agent-logos-Ba57HF3w.js} +1 -1
  53. package/dashboard/dist/assets/{arrow-left-CYO24PGP.js → arrow-left-B1bKnxXL.js} +1 -1
  54. package/dashboard/dist/assets/{arrow-right-Xg9PZYhw.js → arrow-right-DrOlKzX5.js} +1 -1
  55. package/dashboard/dist/assets/{arrow-up-right-B7ah6Qv-.js → arrow-up-right-hlYpo3yw.js} +1 -1
  56. package/dashboard/dist/assets/{calendar-days-BV_iG938.js → calendar-days-CSY6SEsI.js} +1 -1
  57. package/dashboard/dist/assets/{check--EZH8RTp.js → check-xrP1gLw5.js} +1 -1
  58. package/dashboard/dist/assets/{chevron-down-BzGTlk0-.js → chevron-down-6-zT5n9d.js} +1 -1
  59. package/dashboard/dist/assets/{cloud-checkout-BgyxPHEh.js → cloud-checkout-BZymZD4K.js} +1 -1
  60. package/dashboard/dist/assets/{composite-C0KrmLEK.js → composite-C9izWafa.js} +1 -1
  61. package/dashboard/dist/assets/{copy-BtucSK2U.js → copy-Dkas5wZX.js} +1 -1
  62. package/dashboard/dist/assets/{download-CWHq03Y2.js → download-CagGRs0_.js} +1 -1
  63. package/dashboard/dist/assets/{external-link-DCTXF5VZ.js → external-link-DRl10hxd.js} +1 -1
  64. package/dashboard/dist/assets/{flame-BE5TsqVF.js → flame-CkvmsTFB.js} +1 -1
  65. package/dashboard/dist/assets/{format-tokens-ThNIMWnZ.js → format-tokens-BtwhO3mV.js} +1 -1
  66. package/dashboard/dist/assets/{gift-DgaN9s4-.js → gift-YzjXkMQx.js} +1 -1
  67. package/dashboard/dist/assets/{icons-B4r5EiDC.js → icons-CHhKYs0u.js} +1 -1
  68. package/dashboard/dist/assets/{index-DqCtgU0G.js → index-B0MQZTrM.js} +1 -1
  69. package/dashboard/dist/assets/{index-fNKH4U7j.js → index-C2DK8av-.js} +1 -1
  70. package/dashboard/dist/assets/{index-CKhcCLNC.js → index-CJknN9Jd.js} +1 -1
  71. package/dashboard/dist/assets/{index-DFF9xo6f.js → index-DojzoMqd.js} +1 -1
  72. package/dashboard/dist/assets/{info-B4YzRR4y.js → info-Torep-Ti.js} +1 -1
  73. package/dashboard/dist/assets/{link-2-DzbUbaBw.js → link-2-DE5Yu-z6.js} +1 -1
  74. package/dashboard/dist/assets/{loader-circle-B9c7D-7v.js → loader-circle-BL8LxD9n.js} +1 -1
  75. package/dashboard/dist/assets/{local-usage-transfer-btuk_b0Y.js → local-usage-transfer-8X1InWix.js} +1 -1
  76. package/dashboard/dist/assets/{modifiers.esm-BjrU-Xz7.js → modifiers.esm-D60-6UQa.js} +1 -1
  77. package/dashboard/dist/assets/{monitor-smartphone-Bwn4tb4q.js → monitor-smartphone-C-IFPjqk.js} +1 -1
  78. package/dashboard/dist/assets/{play-DIFOwGIQ.js → play-n6uWrdH9.js} +1 -1
  79. package/dashboard/dist/assets/{plus-BpBqnwVg.js → plus-B2f6TaNj.js} +1 -1
  80. package/dashboard/dist/assets/{provider-display-DSrs4u7T.js → provider-display-DWqCEcsP.js} +1 -1
  81. package/dashboard/dist/assets/{react-BgG7_MY0.js → react-B-zfi_j6.js} +1 -1
  82. package/dashboard/dist/assets/{safePolygon-BUGWIDOM.js → safePolygon-C2PsvI-v.js} +1 -1
  83. package/dashboard/dist/assets/{search-aHrmBmtZ.js → search-DZOaTLF9.js} +1 -1
  84. package/dashboard/dist/assets/{server-DOEvwlEU.js → server-DIp4Qfsv.js} +1 -1
  85. package/dashboard/dist/assets/{shield-check-BPTnLhsT.js → shield-check-Cs2M8-iT.js} +1 -1
  86. package/dashboard/dist/assets/{skills-api-C052Mwez.js → skills-api-CoYy6Tov.js} +1 -1
  87. package/dashboard/dist/assets/{sortable.esm-DaZKhjhu.js → sortable.esm-B5_hjIfs.js} +1 -1
  88. package/dashboard/dist/assets/{store-Cq30Pofw.js → store-BLXqtJzb.js} +1 -1
  89. package/dashboard/dist/assets/{terminal-CoSDSkE7.js → terminal-CEMKX3ba.js} +1 -1
  90. package/dashboard/dist/assets/{trash-BAmt-kPj.js → trash-DOWYaXjK.js} +1 -1
  91. package/dashboard/dist/assets/{use-community-stats-Bxq0kBhm.js → use-community-stats-BjRI1Bzz.js} +1 -1
  92. package/dashboard/dist/assets/{use-latest-request-guard-DmIQDlvk.js → use-latest-request-guard-T7L5qBU6.js} +1 -1
  93. package/dashboard/dist/assets/use-limits-display-prefs-vNIrCPZA.js +1 -0
  94. package/dashboard/dist/assets/{use-native-settings-s_CIC8Bb.js → use-native-settings-Cg5Zb4q_.js} +1 -1
  95. package/dashboard/dist/assets/{use-ordered-list-BHhykAbt.js → use-ordered-list-BF-wIvmG.js} +1 -1
  96. package/dashboard/dist/assets/use-reduced-motion-B9K5mIx4.js +1 -0
  97. package/dashboard/dist/assets/{use-transform-CVeMFlS6.js → use-transform-CbJ1IcNc.js} +1 -1
  98. package/dashboard/dist/assets/use-usage-limits-Dgg3jpC4.js +1 -0
  99. package/dashboard/dist/assets/{useCurrency-DilqL2Iv.js → useCurrency-3oBHWfSe.js} +1 -1
  100. package/dashboard/dist/assets/{usePositioner-BcnySw8a.js → usePositioner-D0YrIW0A.js} +1 -1
  101. package/dashboard/dist/assets/{useTokenFormat-CvKW46WP.js → useTokenFormat-DZAHWE_R.js} +1 -1
  102. package/dashboard/dist/assets/{zap-D7VRNTwk.js → zap-kLub6Jiv.js} +1 -1
  103. package/dashboard/dist/index.html +2 -2
  104. package/dashboard/dist/ip-check.html +2 -2
  105. package/dashboard/dist/leaderboard.html +2 -2
  106. package/dashboard/dist/share.html +2 -2
  107. package/package.json +1 -1
  108. package/src/commands/serve.js +25 -15
  109. package/src/lib/accept-encoding.js +59 -0
  110. package/src/lib/cursor-config.js +3 -1
  111. package/src/lib/deepseek-limits.js +280 -0
  112. package/src/lib/deepseek-status.js +157 -0
  113. package/src/lib/grok-limits.js +40 -1
  114. package/src/lib/local-api.js +248 -10
  115. package/src/lib/opencode-status.js +88 -0
  116. package/src/lib/pricing/index.js +182 -79
  117. package/src/lib/pricing/seed-snapshot.json +1 -1
  118. package/src/lib/provider-api-key-store.js +2 -0
  119. package/src/lib/provider-status.js +27 -2
  120. package/src/lib/qoder-limits.js +17 -2
  121. package/src/lib/session-analytics.js +194 -29
  122. package/src/lib/static-server.js +74 -2
  123. package/src/lib/usage-limits.js +714 -53
  124. package/src/lib/workbuddy-limits.js +291 -26
  125. package/src/lib/zcode-limits.js +143 -35
  126. package/dashboard/dist/assets/DashboardPage-CdNXi3Im.js +0 -1
  127. package/dashboard/dist/assets/DialogDescription-kCZ4gN09.js +0 -1
  128. package/dashboard/dist/assets/LimitsPage-gSBHSBi_.js +0 -6
  129. package/dashboard/dist/assets/ServiceStatusPage-D_Fa_pYz.js +0 -1
  130. package/dashboard/dist/assets/SessionsPage-BymccEEz.js +0 -3
  131. package/dashboard/dist/assets/__vite-browser-external-D_cMeuqI.js +0 -45
  132. package/dashboard/dist/assets/use-limits-display-prefs-CyAn1fme.js +0 -1
  133. package/dashboard/dist/assets/use-reduced-motion-BmFyFE3S.js +0 -1
  134. package/dashboard/dist/assets/use-usage-limits-DRZMI20u.js +0 -1
@@ -19,11 +19,13 @@ const { ensureDir, writeFileAtomic, chmod600IfPossible } = require("./fs");
19
19
  const PROVIDER_KEY_FILES = {
20
20
  "opencode-go": "opencode-go-api-key.json",
21
21
  "command-code": "command-code-api-key.json",
22
+ "deepseek": "deepseek-api-key.json",
22
23
  };
23
24
 
24
25
  const PROVIDER_ENV_NAMES = {
25
26
  "opencode-go": "OPENCODE_GO_API_KEY",
26
27
  "command-code": "COMMAND_CODE_API_KEY",
28
+ "deepseek": "DEEPSEEK_API_KEY",
27
29
  };
28
30
 
29
31
  const MIN_API_KEY_LENGTH = 8;
@@ -9,6 +9,9 @@
9
9
  // Statuspage.io contract (`/api/v2/status.json`):
10
10
  // { "page": { "updated_at": ISO }, "status": { "indicator": "none|minor|major|critical", "description": "..." } }
11
11
 
12
+ const { parseDeepSeekStatusPage } = require("./deepseek-status");
13
+ const { parseOpenCodeStatusPayload } = require("./opencode-status");
14
+
12
15
  const STATUS_PROBE_TIMEOUT_MS = 6_000;
13
16
  // Status pages change on incident timescales; 5 minutes keeps the limits
14
17
  // poll (2-min TTL upstream) from hammering statuspage.io every cycle.
@@ -23,6 +26,20 @@ const STATUS_PAGE_SPECS = {
23
26
  apiUrl: "https://status.claude.com/api/v2/status.json",
24
27
  pageUrl: "https://status.claude.com",
25
28
  },
29
+ deepseek: {
30
+ // Flashcat-hosted Next.js page: no public JSON API and no CORS. Relayed
31
+ // server-side here; `format` switches the fetch to HTML + RSC parsing.
32
+ apiUrl: "https://status.deepseek.com/",
33
+ pageUrl: "https://status.deepseek.com",
34
+ format: "deepseek-rsc",
35
+ },
36
+ opencode: {
37
+ // Official status feed (custom JSON schema) served without CORS headers.
38
+ // Relayed server-side; `format` selects the payload parser below.
39
+ apiUrl: "https://status.opencode.ai/api/status",
40
+ pageUrl: "https://status.opencode.ai",
41
+ format: "opencode-json",
42
+ },
26
43
  };
27
44
 
28
45
  const VALID_INDICATORS = new Set(["none", "minor", "major", "critical"]);
@@ -70,11 +87,19 @@ async function fetchProviderServiceStatus(providerId, { fetchImpl = fetch, nowMs
70
87
  try {
71
88
  const res = await fetchImpl(spec.apiUrl, {
72
89
  method: "GET",
73
- headers: { Accept: "application/json" },
90
+ headers: { Accept: spec.format === "deepseek-rsc" ? "text/html" : "application/json" },
74
91
  signal: controller.signal,
75
92
  });
76
93
  if (res.ok) {
77
- data = normalizeStatusPayload(await res.json(), spec, nowMs);
94
+ let payload;
95
+ if (spec.format === "deepseek-rsc") {
96
+ payload = parseDeepSeekStatusPage(await res.text());
97
+ } else if (spec.format === "opencode-json") {
98
+ payload = parseOpenCodeStatusPayload(await res.json());
99
+ } else {
100
+ payload = await res.json();
101
+ }
102
+ data = normalizeStatusPayload(payload, spec, nowMs);
78
103
  }
79
104
  } finally {
80
105
  clearTimeout(timer);
@@ -40,6 +40,9 @@ const QODER_ACTIVITY_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
40
40
  const QODER_LIMITS_CACHE_FILE = "qoder-usage-limits-cache.json";
41
41
  const QODER_LIMITS_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
42
42
  const QODER_LIMITS_CACHE_UNKNOWN_RESET_TTL_MS = 12 * 60 * 60 * 1000;
43
+ // Align with usage-limits provenance aging (10 min): a cache hit fresher than
44
+ // this inherits its written source and is not flagged stale (#955 self-lock).
45
+ const QODER_LIMITS_CACHE_FRESH_TTL_MS = 10 * 60 * 1000;
43
46
  // Qoder Desktop 0.4+ writes its account usage summary to main.log, which can
44
47
  // accumulate a whole session before the quota is read. Scan a bounded window
45
48
  // backwards instead of assuming the summary is near EOF.
@@ -435,6 +438,15 @@ function readQoderLimitsCache({
435
438
  if (nowMs - cachedAtMs > QODER_LIMITS_CACHE_MAX_AGE_MS && !hasUnexpiredDatedWindow) {
436
439
  return null;
437
440
  }
441
+ // Provenance must describe the data's age, not the transport (#955): a
442
+ // successful refresh writes this cache and immediately floors later log
443
+ // scans, so the next refresh re-serves the same payload. Marking every
444
+ // hit stale/disk-cache made the Limits page permanently partial.
445
+ const ageMs = nowMs - cachedAtMs;
446
+ const fresh = ageMs <= QODER_LIMITS_CACHE_FRESH_TTL_MS;
447
+ const writtenSource = typeof raw.source === "string" && raw.source.trim()
448
+ ? raw.source.trim()
449
+ : null;
438
450
  return {
439
451
  configured: true,
440
452
  error: null,
@@ -444,9 +456,9 @@ function readQoderLimitsCache({
444
456
  secondary_window: secondaryWindow,
445
457
  ...(tertiaryWindow ? { tertiary_window: tertiaryWindow } : {}),
446
458
  cached: true,
447
- stale: true,
459
+ stale: !fresh,
448
460
  cached_at: raw.cached_at,
449
- source: "disk-cache",
461
+ source: fresh && writtenSource ? writtenSource : "disk-cache",
450
462
  };
451
463
  } catch (_error) {
452
464
  return null;
@@ -467,6 +479,9 @@ function writeQoderLimitsCache(limits, {
467
479
  primary_window: limits.primary_window || null,
468
480
  secondary_window: limits.secondary_window || null,
469
481
  ...(limits.tertiary_window ? { tertiary_window: limits.tertiary_window } : {}),
482
+ // Remember the winning source so a fresh re-serve keeps honest
483
+ // provenance instead of always claiming "disk-cache" (#955).
484
+ source: typeof limits.source === "string" ? limits.source : null,
470
485
  cached_at: new Date(nowMs).toISOString(),
471
486
  },
472
487
  };
@@ -30,7 +30,7 @@ const path = require("node:path");
30
30
  const readline = require("node:readline");
31
31
  const { listClaudeProjectFiles, listRolloutFilesDeep, claudeMessageDedupKey, normalizeClaudeUsage, readZcodeDbMessages, coerceEpochMs, resolveZcodeInstallPaths, normalizeOpencodeModelFields, normalizeOpencodeTokens, deriveOpencodeMessageFingerprint, resolveDshHomes } = require("./rollout");
32
32
  const { parseCodexRolloutFile } = require("./codex-rollout-parser");
33
- const { computeRowCost, getModelPricingInfo } = require("./pricing");
33
+ const { computeRowCost, getModelPricingInfo, getPricingRevision } = require("./pricing");
34
34
  const { normalizePerformance, mergePerformance, createClaudePerformanceCollector, createCodexPerformanceCollector, createZcodePerformanceCollector } = require("./session-performance");
35
35
  const { readSqliteJsonRows, readSqliteJsonRowsAsync } = require("./sqlite-reader");
36
36
  const { resolveZcodeNativeDbPath, resolveInstallPaths } = require("./install-resolver");
@@ -92,7 +92,9 @@ const {
92
92
  // v26 retains Codex Ultrafast (service_tier ultrafast) subset columns so
93
93
  // date-filtered session views and cached sidecars keep the 6x premium.
94
94
  // v27 adds oh-my-pi (omp) sessions from ~/.omp/agent/sessions/**/*.jsonl.
95
- const SIDECAR_VERSION = 27;
95
+ // v28 records per-minute usage buckets for Claude and Grok, so date ranges
96
+ // recompute in-range tokens/cost instead of falling back to lifetime totals.
97
+ const SIDECAR_VERSION = 28;
96
98
  // Invalidate only Codex token summaries; other sources keep their cached rows.
97
99
  const CODEX_USAGE_VERSION = 1;
98
100
  const EDIT_TOOLS = new Set([
@@ -558,6 +560,7 @@ async function scanClaudeSession(filePath) {
558
560
  const seenMessages = new Set();
559
561
  const performanceCollector = createClaudePerformanceCollector();
560
562
  const modelUsageByModel = new Map();
563
+ const usageBuckets = new Map();
561
564
  const editTurnsByModel = new Map();
562
565
  const bounds = emptyBounds();
563
566
  // The basename keeps grouping stable for files that never write a sessionId
@@ -652,6 +655,16 @@ async function scanClaudeSession(filePath) {
652
655
  addTotals(modelUsage, usage);
653
656
  modelUsage.usage_events += 1;
654
657
  modelUsageByModel.set(model, modelUsage);
658
+ const usageMs = Date.parse(obj.timestamp || obj.message.timestamp || "");
659
+ if (Number.isFinite(usageMs)) {
660
+ const timestamp = new Date(Math.floor(usageMs / 60_000) * 60_000).toISOString();
661
+ const bucketKey = `${timestamp}\u0000${model}`;
662
+ const bucket = usageBuckets.get(bucketKey) || { timestamp, model: model || "unknown", ...emptyTotals(), usage_events: 0 };
663
+ addTotals(bucket, usage);
664
+ mergeUsageMetadata(bucket, usage);
665
+ bucket.usage_events = finite(bucket.usage_events) + 1;
666
+ usageBuckets.set(bucketKey, bucket);
667
+ }
655
668
  }
656
669
  const content = Array.isArray(obj.message.content) ? obj.message.content : [];
657
670
  for (const block of content) {
@@ -696,6 +709,7 @@ async function scanClaudeSession(filePath) {
696
709
  edit_turns: finite(editTurnsByModel.get(row.model)),
697
710
  performance: performance.byModel.get(row.model) || normalizePerformance(),
698
711
  })),
712
+ usage_buckets: [...usageBuckets.values()],
699
713
  performance: performance.performance,
700
714
  ...bounds,
701
715
  turns,
@@ -1016,6 +1030,7 @@ async function scanGrokSession(filePath) {
1016
1030
  const summary = readJsonFileSync(grokSummaryPathFor(filePath)) || {};
1017
1031
  const signals = readJsonFileSync(grokSignalsPathFor(filePath)) || {};
1018
1032
  const tokens = emptyTotals();
1033
+ const usageBuckets = new Map();
1019
1034
  const bounds = emptyBounds();
1020
1035
  const dirName = path.basename(path.dirname(filePath));
1021
1036
  let observedSessionId = typeof summary?.info?.id === "string" && summary.info.id
@@ -1125,6 +1140,20 @@ async function scanGrokSession(filePath) {
1125
1140
  (providerCostUsd + normalizedUsage.total_cost_usd) * USD_TICKS_PER_USD,
1126
1141
  ) / USD_TICKS_PER_USD;
1127
1142
  }
1143
+ const usageMs = Date.parse(grokTimestampIso(obj));
1144
+ if (Number.isFinite(usageMs) && normalizedUsage.total_tokens > 0) {
1145
+ const timestamp = new Date(Math.floor(usageMs / 60_000) * 60_000).toISOString();
1146
+ const bucketModel = pickGrokModel(usage, model) || model || "unknown";
1147
+ const bucketKey = `${timestamp}\u0000${bucketModel}`;
1148
+ const bucket = usageBuckets.get(bucketKey) || { timestamp, model: bucketModel, ...emptyTotals(), usage_events: 0, provider_cost_usd: 0 };
1149
+ addTotals(bucket, normalizedUsage);
1150
+ mergeUsageMetadata(bucket, normalizedUsage);
1151
+ bucket.usage_events = finite(bucket.usage_events) + 1;
1152
+ if (normalizedUsage.total_cost_usd != null) {
1153
+ bucket.provider_cost_usd = finite(bucket.provider_cost_usd) + Number(normalizedUsage.total_cost_usd);
1154
+ }
1155
+ usageBuckets.set(bucketKey, bucket);
1156
+ }
1128
1157
  }
1129
1158
  const candidate = pickGrokModel(usage, model);
1130
1159
  if (candidate && candidate !== "unknown") model = candidate;
@@ -1172,6 +1201,7 @@ async function scanGrokSession(filePath) {
1172
1201
  subagent_calls: subagentCalls,
1173
1202
  subagent_types: Object.fromEntries([...subagentTypes.entries()].sort()),
1174
1203
  tokens,
1204
+ usage_buckets: [...usageBuckets.values()],
1175
1205
  usage_events: usageEvents,
1176
1206
  usage_precision: usageIsIncomplete ? "reported_incomplete" : (usageEvents > 0 ? "reported" : "unavailable"),
1177
1207
  usage_is_incomplete: usageIsIncomplete,
@@ -2139,7 +2169,10 @@ function filesSignature(files) {
2139
2169
  for (const filePath of files) {
2140
2170
  try {
2141
2171
  const stat = fs.statSync(filePath);
2142
- hash.update(`${filePath}\0${stat.size}\0${stat.mtimeMs}\n`);
2172
+ // Keep this in sync with sessionFileStatKey(): a size+mtime-only hash
2173
+ // misses rewrites that preserve both (backup restore, mtime-preserving
2174
+ // editors, atomic replace with identical size), while ctime changes.
2175
+ hash.update(`${filePath}\0${stat.size}\0${stat.mtimeMs}\0${stat.ctimeMs}\n`);
2143
2176
  } catch { /* vanished during discovery */ }
2144
2177
  }
2145
2178
  return hash.digest("hex");
@@ -2238,13 +2271,49 @@ function analyticsEntryStatKey(source, filePath) {
2238
2271
  return sessionStat;
2239
2272
  }
2240
2273
 
2274
+ // The sidecar is a local, append-only JSONL snapshot that can be several MB.
2275
+ // Re-reading and reparsing it on every cache-hit request showed up as ~150ms on
2276
+ // a real 7.5MB sidecar. Cache the parsed+repriced rows by file identity and
2277
+ // pricing revision; writeAtomic() replaces the file (new mtime/ctime), so a
2278
+ // sync invalidates this naturally.
2279
+ const SIDECAR_READ_CACHE_MAX = 4;
2280
+ const sidecarReadCache = new Map();
2281
+
2241
2282
  function readSidecar(sidecarPath) {
2283
+ let fd;
2242
2284
  try {
2243
- return fs.readFileSync(sidecarPath, "utf8")
2285
+ // Open once and use that descriptor for both stat and read, so a file
2286
+ // replacement between the two operations cannot mix identities.
2287
+ fd = fs.openSync(sidecarPath, "r");
2288
+ const stat = fs.fstatSync(fd);
2289
+ const cacheKey = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}:${getPricingRevision()}`;
2290
+ const cached = sidecarReadCache.get(sidecarPath);
2291
+ if (cached?.key === cacheKey) return cached.rows.map((row) => ({ ...row }));
2292
+ const rows = fs.readFileSync(fd, "utf8")
2244
2293
  .split("\n")
2245
2294
  .filter(Boolean)
2246
2295
  .map((line) => repriceSessionRecord(JSON.parse(line)));
2247
- } catch { return []; }
2296
+ sidecarReadCache.set(sidecarPath, { key: cacheKey, rows });
2297
+ if (sidecarReadCache.size > SIDECAR_READ_CACHE_MAX) {
2298
+ const oldest = sidecarReadCache.keys().next().value;
2299
+ sidecarReadCache.delete(oldest);
2300
+ }
2301
+ return rows;
2302
+ } catch {
2303
+ const rows = [];
2304
+ Object.defineProperty(rows, "sidecarReadFailed", { value: true, enumerable: false });
2305
+ return rows;
2306
+ } finally {
2307
+ if (fd !== undefined) fs.closeSync(fd);
2308
+ }
2309
+ }
2310
+
2311
+ function readSidecarWithMeta(sidecarPath, meta) {
2312
+ const rows = readSidecar(sidecarPath);
2313
+ if (Number.isFinite(meta?.skipped_files)) {
2314
+ Object.defineProperty(rows, "skippedFiles", { value: meta.skipped_files, enumerable: false });
2315
+ }
2316
+ return rows;
2248
2317
  }
2249
2318
 
2250
2319
  async function writeAtomic(filePath, content) {
@@ -2254,7 +2323,11 @@ async function writeAtomic(filePath, content) {
2254
2323
  await fsp.rename(temp, filePath);
2255
2324
  }
2256
2325
 
2257
- async function buildSessionAnalyticsInternal({ home = os.homedir(), force = false, cacheTtlMs = 5 * 60_000 } = {}) {
2326
+ // `force` rescans every session file. `recheck` (a user-triggered refresh)
2327
+ // only ignores the TTL: it still reuses every file whose stat key is unchanged
2328
+ // and rescans the rest, so Refresh costs one directory walk plus the delta
2329
+ // instead of a full re-parse of all transcripts.
2330
+ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = false, recheck = false, cacheTtlMs = 5 * 60_000 } = {}) {
2258
2331
  const sidecarPath = resolveSessionSidecarPath(home);
2259
2332
  const metaPath = `${sidecarPath}.meta.json`;
2260
2333
  // A lock/permission failure need not alter the database's stat signature.
@@ -2276,11 +2349,14 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
2276
2349
  previousMeta = JSON.parse(fs.readFileSync(metaPath, "utf8"));
2277
2350
  const checkedAt = Date.parse(previousMeta.checked_at || previousMeta.generated_at || "");
2278
2351
  if (
2352
+ // `recheck` deliberately skips the TTL so Refresh always re-walks the
2353
+ // directories; the per-file stat reuse below still avoids re-parsing.
2354
+ !recheck &&
2279
2355
  !retryZcodeScan && !retryOpencodeScan && !retryKimiScan && !retryOmpScan && !retryDshScan && previousMeta.version === SIDECAR_VERSION && previousMeta.codex_usage_version === CODEX_USAGE_VERSION &&
2280
2356
  Number.isFinite(checkedAt) &&
2281
2357
  Date.now() - checkedAt < Math.max(0, Number(cacheTtlMs) || 0)
2282
2358
  ) {
2283
- return readSidecar(sidecarPath);
2359
+ return readSidecarWithMeta(sidecarPath, previousMeta);
2284
2360
  }
2285
2361
  } catch { /* first run */ }
2286
2362
  }
@@ -2293,7 +2369,7 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
2293
2369
  if (incompleteDirs.length > 0) {
2294
2370
  warnIncompleteDiscovery(incompleteDirs);
2295
2371
  if (!force && previousMeta?.version === SIDECAR_VERSION) {
2296
- const cached = readSidecar(sidecarPath);
2372
+ const cached = readSidecarWithMeta(sidecarPath, previousMeta);
2297
2373
  Object.defineProperty(cached, "incompleteDirs", { value: incompleteDirs, enumerable: false });
2298
2374
  return cached;
2299
2375
  }
@@ -2317,9 +2393,21 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
2317
2393
  // db + its WAL sidecars: the trio is one logical file for change detection.
2318
2394
  ...[...(discovered.zcode || []), ...(discovered.opencode || [])].flatMap((dbPath) => [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]),
2319
2395
  ]);
2320
- if (!force && !retryZcodeScan && !retryOpencodeScan && !retryKimiScan && !retryOmpScan && !retryDshScan && previousMeta?.version === SIDECAR_VERSION && previousMeta.codex_usage_version === CODEX_USAGE_VERSION && previousMeta.signature === signature) {
2321
- await writeAtomic(metaPath, `${JSON.stringify({ ...previousMeta, checked_at: new Date().toISOString() })}\n`);
2322
- return readSidecar(sidecarPath);
2396
+ // The signature covers every discovered file's stat key, so an identical
2397
+ // signature proves the cached rows are current — refresh included. The one
2398
+ // exception: an explicit refresh re-parses after a previous run skipped a
2399
+ // transcript, so a transient read/parse failure can recover without waiting
2400
+ // for the file itself to change.
2401
+ const retrySkippedOnRecheck = recheck && Number(previousMeta?.skipped_files) > 0;
2402
+ if (!force && !retrySkippedOnRecheck && !retryZcodeScan && !retryOpencodeScan && !retryKimiScan && !retryOmpScan && !retryDshScan && previousMeta?.version === SIDECAR_VERSION && previousMeta.codex_usage_version === CODEX_USAGE_VERSION && previousMeta.signature === signature) {
2403
+ const cached = readSidecarWithMeta(sidecarPath, previousMeta);
2404
+ // A missing/corrupt sidecar reads as an empty list. The source signature
2405
+ // can still match, so only an explicit refresh used to notice the loss;
2406
+ // rebuild instead of returning an empty cache.
2407
+ if (!cached.sidecarReadFailed) {
2408
+ await writeAtomic(metaPath, `${JSON.stringify({ ...previousMeta, checked_at: new Date().toISOString() })}\n`);
2409
+ return cached;
2410
+ }
2323
2411
  }
2324
2412
 
2325
2413
  const previousRows = !force && previousMeta?.version === SIDECAR_VERSION
@@ -2465,6 +2553,7 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
2465
2553
  signature,
2466
2554
  generated_at: generatedAt,
2467
2555
  checked_at: generatedAt,
2556
+ skipped_files: skippedFiles,
2468
2557
  files: nextFiles,
2469
2558
  })}\n`);
2470
2559
  await fsp.rm(incompleteZcodePath, { force: true });
@@ -2490,17 +2579,22 @@ function buildSessionAnalytics(options = {}) {
2490
2579
  const normalizedOptions = options && typeof options === "object" ? options : {};
2491
2580
  const home = path.resolve(String(normalizedOptions.home || os.homedir()));
2492
2581
  const force = Boolean(normalizedOptions.force);
2582
+ const recheck = Boolean(normalizedOptions.recheck);
2493
2583
  const existing = sessionAnalyticsBuilds.get(home);
2494
2584
  // Joining an in-flight build is only correct when that build is at least as
2495
2585
  // thorough as what this caller asked for. A refresh that landed while a plain
2496
2586
  // build was running used to be handed the un-refreshed result: the spinner
2497
2587
  // stopped and nothing had actually been re-scanned. Chain instead of joining
2498
2588
  // — never run two scans at once, they race the atomic sidecar write.
2499
- if (existing && (!force || existing.force)) return existing.promise;
2589
+ if (existing && (!force && !recheck || existing.force || (recheck && existing.recheck))) return existing.promise;
2500
2590
 
2501
- const run = () => buildSessionAnalyticsInternal({ ...normalizedOptions, home, force });
2591
+ const run = () => buildSessionAnalyticsInternal({ ...normalizedOptions, home, force, recheck });
2502
2592
  const promise = existing ? existing.promise.then(run, run) : run();
2503
- const entry = { promise, force: force || Boolean(existing?.force) };
2593
+ const entry = {
2594
+ promise,
2595
+ force: force || Boolean(existing?.force),
2596
+ recheck: recheck || Boolean(existing?.recheck),
2597
+ };
2504
2598
  sessionAnalyticsBuilds.set(home, entry);
2505
2599
  const clear = () => {
2506
2600
  if (sessionAnalyticsBuilds.get(home) === entry) sessionAnalyticsBuilds.delete(home);
@@ -2531,7 +2625,9 @@ function withinDayRange(row, from, to, context) {
2531
2625
  const startDay = sessionUsageDay(row?.started_at || row?.ended_at || "", context);
2532
2626
  const endDay = sessionUsageDay(row?.ended_at || row?.started_at || "", context);
2533
2627
  if (from && (!endDay || endDay < from)) return false;
2534
- if (to && (!startDay || startDay > to)) return false;
2628
+ // A row whose only timestamp is unreadable keeps the old client's tolerant
2629
+ // behavior: it is dropped only when its known end day precedes the window.
2630
+ if (to && startDay && startDay > to) return false;
2535
2631
  return true;
2536
2632
  }
2537
2633
 
@@ -2543,16 +2639,39 @@ function scopeSessionUsage(row, from, to, context) {
2543
2639
  });
2544
2640
  const tokens = emptyTotals();
2545
2641
  const byModel = new Map();
2642
+ // Grok reports its own cost per turn; keep the in-range subset so a single
2643
+ // day of a multi-day session does not inherit the lifetime total.
2644
+ let bucketProviderCost = 0;
2645
+ let bucketProviderCostEvents = 0;
2546
2646
  for (const bucket of buckets) {
2647
+ if (Number.isFinite(Number(bucket.provider_cost_usd))) {
2648
+ bucketProviderCost += Number(bucket.provider_cost_usd);
2649
+ bucketProviderCostEvents += 1;
2650
+ }
2547
2651
  addTotals(tokens, bucket);
2548
- const model = byModel.get(bucket.model) || { model: bucket.model, ...emptyTotals(), usage_events: 0 };
2652
+ const previousModelUsage = Array.isArray(row.model_usage)
2653
+ ? row.model_usage.find((usage) => usage.model === bucket.model)
2654
+ : null;
2655
+ // A record with no per-model breakdown reports exactly one model: its own
2656
+ // label (that is what toSessionBrowserRow emits unscoped). Attribute its
2657
+ // buckets to that label so the range view and the lifetime facets agree on
2658
+ // a model name the filter can actually match.
2659
+ const modelName = previousModelUsage ? previousModelUsage.model : (row.model || bucket.model);
2660
+ const model = byModel.get(modelName) || {
2661
+ ...(previousModelUsage || {}),
2662
+ model: modelName,
2663
+ ...Object.fromEntries(MODEL_USAGE_SUM_FIELDS.filter((field) => field !== "edit_turns").map((field) => [field, 0])),
2664
+ usage_events: 0,
2665
+ };
2549
2666
  addTotals(model, bucket);
2550
2667
  mergeUsageMetadata(model, bucket);
2551
2668
  if (!bucket.usage_events) model.usage_events += 1;
2552
- byModel.set(bucket.model, model);
2669
+ byModel.set(modelName, model);
2553
2670
  }
2671
+ const providerScoped = row.cost_source === "provider_reported" && bucketProviderCostEvents > 0;
2554
2672
  return repriceSessionRecord({ ...row, tokens, total_tokens: tokens.total_tokens,
2555
2673
  model_usage: [...byModel.values()], usage_buckets: buckets, cost_usd: 0,
2674
+ ...(providerScoped ? { provider_cost_usd: bucketProviderCost } : {}),
2556
2675
  lifetime_total_tokens: row.total_tokens, lifetime_cost_usd: row.cost_usd });
2557
2676
  }
2558
2677
 
@@ -2761,7 +2880,21 @@ function resumeCommandFor(source, sessionId) {
2761
2880
  return null;
2762
2881
  }
2763
2882
 
2764
- function toSessionBrowserRow(row) {
2883
+ // The list page reads a model name plus a token/cost/performance triple per
2884
+ // model row; the other counters in the dense 44-field model_usage row are not
2885
+ // read by the dashboard at all. Keeping this projection here (rather than
2886
+ // deleting fields at the HTTP layer) guarantees the shape stays declared by
2887
+ // dashboard/src/lib/sessions-api.ts.
2888
+ const BROWSER_MODEL_USAGE_FIELDS = ["model", "total_tokens", "cost_usd", "performance", "pricing"];
2889
+
2890
+ function projectModelUsageForBrowser(rows, includeModelDetail) {
2891
+ if (includeModelDetail) return rows;
2892
+ return rows.map((row) => Object.fromEntries(
2893
+ BROWSER_MODEL_USAGE_FIELDS.filter((field) => row[field] !== undefined).map((field) => [field, row[field]]),
2894
+ ));
2895
+ }
2896
+
2897
+ function toSessionBrowserRow(row, { includeBuckets = true, includeModelDetail = true } = {}) {
2765
2898
  const tokens = row.tokens && typeof row.tokens === "object" ? row.tokens : {};
2766
2899
  return {
2767
2900
  session_hash: row.session_hash,
@@ -2787,8 +2920,8 @@ function toSessionBrowserRow(row) {
2787
2920
  // browser can show where a session ran and compose a resume command.
2788
2921
  project_ref: row.project_ref || null,
2789
2922
  model: row.model,
2790
- model_usage: modelUsageForAggregation(row),
2791
- ...(Array.isArray(row.usage_buckets) ? { usage_buckets: row.usage_buckets } : {}),
2923
+ model_usage: projectModelUsageForBrowser(modelUsageForAggregation(row), includeModelDetail),
2924
+ ...(includeBuckets && Array.isArray(row.usage_buckets) ? { usage_buckets: row.usage_buckets } : {}),
2792
2925
  performance: normalizePerformance(row.performance),
2793
2926
  started_at: row.started_at || null,
2794
2927
  ended_at: row.ended_at || null,
@@ -3048,19 +3181,49 @@ function annotateThreadUsage(rows) {
3048
3181
  return rows;
3049
3182
  }
3050
3183
 
3184
+ // Distinct projects and models for the dashboard filter menus. Compute this
3185
+ // before applying the date window so a project/model with no rows in the
3186
+ // current range remains selectable (and can therefore be cleared again).
3187
+ function buildSessionBrowserFacets(rows) {
3188
+ const projects = new Set();
3189
+ const models = new Set();
3190
+ for (const row of rows || []) {
3191
+ if (row.project_key) projects.add(row.project_key);
3192
+ // Mirror what toSessionBrowserRow() emits for an unscoped request:
3193
+ // normalizeModelUsageRows() over the stored rows, else the row's own label.
3194
+ // Bucket-only models are deliberately excluded — an unscoped row reports
3195
+ // them as its label, so offering the bucket model would let the user filter
3196
+ // out the very row that introduced the option.
3197
+ const usageRows = normalizeModelUsageRows(row.model_usage);
3198
+ if (usageRows.length === 0) {
3199
+ if (typeof row.model === "string" && row.model) models.add(row.model);
3200
+ continue;
3201
+ }
3202
+ for (const usage of usageRows) {
3203
+ if (usage && typeof usage.model === "string" && usage.model) models.add(usage.model);
3204
+ }
3205
+ }
3206
+ return {
3207
+ projects: [...projects].sort((a, b) => String(a).localeCompare(String(b))),
3208
+ models: [...models].sort((a, b) => String(a).localeCompare(String(b))),
3209
+ };
3210
+ }
3211
+
3051
3212
  // Local-only session list for the dashboard session browser. Unlike
3052
3213
  // summarizeSessions (cloud/CSV safe), this retains session_id + project_ref so
3053
3214
  // the UI can offer one-click resume. Callers must only expose it over the
3054
3215
  // local API.
3055
- function listSessionsForBrowser(sessions, { from = "", to = "", limit = 0, timeZone, offsetMinutes } = {}) {
3216
+ function listSessionsForBrowser(sessions, { from = "", to = "", limit = 0, includeBuckets = true, includeModelDetail = true, timeZone, offsetMinutes } = {}) {
3056
3217
  const context = { timeZone, offsetMinutes };
3057
- const filtered = annotateThreadUsage(mergeSessionFragments(sessions)
3058
- // Only sessions that actually spent tokens are worth listing. This drops
3059
- // two kinds of noise: non-session logs under ~/.claude/projects
3060
- // (skill-injections.jsonl, journal.jsonl, …), and sessions abandoned before
3061
- // the model replied. Both render as "unknown · 0 tokens · $0.00" rows with
3062
- // no model, no cost and nothing to analyze.
3063
- .filter((row) => finite(row.total_tokens) > 0)
3218
+ // Only sessions that actually spent tokens are worth listing. This drops
3219
+ // two kinds of noise: non-session logs under ~/.claude/projects
3220
+ // (skill-injections.jsonl, journal.jsonl, …), and sessions abandoned before
3221
+ // the model replied. Both render as "unknown · 0 tokens · $0.00" rows with
3222
+ // no model, no cost and nothing to analyze.
3223
+ const merged = mergeSessionFragments(sessions)
3224
+ .filter((row) => finite(row.total_tokens) > 0);
3225
+ const facets = buildSessionBrowserFacets(merged);
3226
+ const filtered = annotateThreadUsage(merged
3064
3227
  .filter((row) => withinDayRange(row, from, to, context))
3065
3228
  .map((row) => scopeSessionUsage(row, from, to, context))
3066
3229
  .filter((row) => !Array.isArray(row.usage_buckets) || finite(row.total_tokens) > 0));
@@ -3070,9 +3233,11 @@ function listSessionsForBrowser(sessions, { from = "", to = "", limit = 0, timeZ
3070
3233
  return {
3071
3234
  available: filtered.length > 0,
3072
3235
  session_count: filtered.length,
3236
+ lifetime_session_count: merged.length,
3073
3237
  returned_count: limited.length,
3074
3238
  skipped_files: finite(sessions?.skippedFiles),
3075
- sessions: limited.map(toSessionBrowserRow),
3239
+ facets,
3240
+ sessions: limited.map((row) => toSessionBrowserRow(row, { includeBuckets, includeModelDetail })),
3076
3241
  provenance: {
3077
3242
  source: "local-session-log",
3078
3243
  confidence: "observed",
@@ -1,5 +1,25 @@
1
1
  const fsPromises = require("node:fs/promises");
2
2
  const path = require("node:path");
3
+ const zlib = require("node:zlib");
4
+ const { promisify } = require("node:util");
5
+ const { negotiateEncoding } = require("./accept-encoding");
6
+
7
+ const gzip = promisify(zlib.gzip);
8
+ const brotliCompress = promisify(zlib.brotliCompress);
9
+ const COMPRESSIBLE_EXTENSIONS = new Set([".html", ".js", ".mjs", ".css", ".json", ".svg", ".xml", ".txt", ".webmanifest"]);
10
+ const STATIC_COMPRESSION_CACHE_MAX = 16;
11
+ const STATIC_COMPRESSION_MAX_BYTES = 8 * 1024 * 1024;
12
+ const staticCompressionCache = new Map();
13
+
14
+ function sendNotAcceptable(res) {
15
+ const message = "Not Acceptable";
16
+ res.writeHead(406, {
17
+ "Content-Type": "text/plain; charset=utf-8",
18
+ "Content-Length": Buffer.byteLength(message),
19
+ "Vary": "Accept-Encoding",
20
+ });
21
+ res.end(message);
22
+ }
3
23
 
4
24
  const MIME_TYPES = {
5
25
  ".html": "text/html; charset=utf-8",
@@ -22,11 +42,26 @@ const MIME_TYPES = {
22
42
  ".webmanifest": "application/manifest+json",
23
43
  };
24
44
 
45
+ async function compressedAsset(filePath, stat, encoding, handle) {
46
+ const key = `${filePath}\0${stat.dev}\0${stat.ino}\0${stat.size}\0${stat.mtimeMs}\0${stat.ctimeMs}\0${encoding}`;
47
+ const cached = staticCompressionCache.get(key);
48
+ if (cached) return cached;
49
+ const source = await handle.readFile();
50
+ const body = encoding === "br"
51
+ ? await brotliCompress(source, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 5 } })
52
+ : await gzip(source);
53
+ staticCompressionCache.set(key, body);
54
+ while (staticCompressionCache.size > STATIC_COMPRESSION_CACHE_MAX) {
55
+ staticCompressionCache.delete(staticCompressionCache.keys().next().value);
56
+ }
57
+ return body;
58
+ }
59
+
25
60
  /**
26
61
  * Serve a static file from baseDir. Returns true if served, false otherwise.
27
62
  * For SPA: caller should fall back to index.html when this returns false.
28
63
  */
29
- async function serveStaticFile(baseDir, pathname, res, { localRuntimeConfig = false } = {}) {
64
+ async function serveStaticFile(baseDir, pathname, res, { localRuntimeConfig = false, acceptEncoding = "" } = {}) {
30
65
  const safePath = path.normalize(pathname).replace(/^(\.\.[/\\])+/, "");
31
66
  const filePath = path.join(baseDir, safePath);
32
67
 
@@ -53,8 +88,17 @@ async function serveStaticFile(baseDir, pathname, res, { localRuntimeConfig = fa
53
88
  const ext = path.extname(filePath).toLowerCase();
54
89
  const contentType = MIME_TYPES[ext] || "application/octet-stream";
55
90
  const isHtml = ext === ".html";
91
+ const negotiation = negotiateEncoding(acceptEncoding);
92
+ if (!negotiation.encoding && !negotiation.identityAllowed) {
93
+ sendNotAcceptable(res);
94
+ return true;
95
+ }
56
96
 
57
97
  if (isHtml && localRuntimeConfig && path.basename(filePath) === "index.html") {
98
+ if (!negotiation.identityAllowed) {
99
+ sendNotAcceptable(res);
100
+ return true;
101
+ }
58
102
  const source = await handle.readFile("utf8");
59
103
  const content = source.replace(/<head(?:\s[^>]*)?>/i,
60
104
  "$&\n<script>window.__TOKENTRACKER_RUNTIME_CONFIG__={configurationError:\"runtime_config_unavailable\"};</script>\n<script src=\"/api/runtime-config.js\"></script>");
@@ -66,13 +110,41 @@ async function serveStaticFile(baseDir, pathname, res, { localRuntimeConfig = fa
66
110
 
67
111
  if (clientGone) return true;
68
112
 
113
+ const encoding = negotiation.encoding;
114
+ if (encoding && COMPRESSIBLE_EXTENSIONS.has(ext) && stat.size > 1024 && stat.size <= STATIC_COMPRESSION_MAX_BYTES) {
115
+ let compressed = null;
116
+ try {
117
+ compressed = await compressedAsset(filePath, stat, encoding, handle);
118
+ } catch (error) {
119
+ console.warn("[static-server] compression failed, serving identity:", error?.message || error);
120
+ }
121
+ if (clientGone) return true;
122
+ if (compressed) {
123
+ res.writeHead(200, {
124
+ "Content-Type": contentType,
125
+ "Content-Length": compressed.length,
126
+ "Content-Encoding": encoding,
127
+ "Vary": "Accept-Encoding",
128
+ "Cache-Control": isHtml ? "no-cache" : "public, max-age=31536000, immutable",
129
+ });
130
+ res.end(compressed);
131
+ return true;
132
+ }
133
+ }
134
+
135
+ if (!negotiation.identityAllowed) {
136
+ sendNotAcceptable(res);
137
+ return true;
138
+ }
139
+
69
140
  res.writeHead(200, {
70
141
  "Content-Type": contentType,
71
142
  "Content-Length": stat.size,
143
+ "Vary": "Accept-Encoding",
72
144
  "Cache-Control": isHtml ? "no-cache" : "public, max-age=31536000, immutable",
73
145
  });
74
146
 
75
- stream = handle.createReadStream();
147
+ stream = handle.createReadStream({ start: 0 });
76
148
  handle = null; // The stream owns and closes this descriptor.
77
149
  stream.on("error", (error) => res.destroy(error));
78
150
  stream.pipe(res);