tokentracker-cli 1.4.13 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +2 -2
  2. package/dashboard/dist/assets/{AchievementBadge-6wnHO-Hp.js → AchievementBadge-Dn39pyx9.js} +1 -1
  3. package/dashboard/dist/assets/{AchievementsPage-BpHA9auG.js → AchievementsPage-9_MxdI7O.js} +2 -2
  4. package/dashboard/dist/assets/{AchievementsSection-ySXWiLC3.js → AchievementsSection-CKDQGcJh.js} +1 -1
  5. package/dashboard/dist/assets/{BadgeDetailModal-DuxcntYu.js → BadgeDetailModal-DdrXPVRL.js} +1 -1
  6. package/dashboard/dist/assets/{BotAnimated-Dg1gSql-.js → BotAnimated-DsJDdDid.js} +1 -1
  7. package/dashboard/dist/assets/{Card-73qYdlEv.js → Card-Bz-sWY1C.js} +1 -1
  8. package/dashboard/dist/assets/{CloudCheckoutPage-C7-9p9zk.js → CloudCheckoutPage-DRjZcE0W.js} +1 -1
  9. package/dashboard/dist/assets/{CloudPage-CKcDcf6O.js → CloudPage-BNDyyX9r.js} +2 -2
  10. package/dashboard/dist/assets/{CommandPalette-Cq7TSYUV.js → CommandPalette-LbE6Icc0.js} +1 -1
  11. package/dashboard/dist/assets/{CommunityStatsModal-C0gj8QaO.js → CommunityStatsModal-DuEcAOB9.js} +1 -1
  12. package/dashboard/dist/assets/{ConfirmModal-Cuf2JDA9.js → ConfirmModal-B5V7ONKv.js} +1 -1
  13. package/dashboard/dist/assets/{Controls-COaS9RLV.js → Controls-ZfN0QwwD.js} +1 -1
  14. package/dashboard/dist/assets/{DashboardPage-Beg--N9T.js → DashboardPage-DqZPFmlv.js} +1 -1
  15. package/dashboard/dist/assets/{DateRangePopover-rNSRXWkr.js → DateRangePopover-CbkBeaEI.js} +1 -1
  16. package/dashboard/dist/assets/DevicePage-DrHjmpPH.js +1 -0
  17. package/dashboard/dist/assets/{DialogClose-CEYsCJ6-.js → DialogClose-DHaAwV1u.js} +1 -1
  18. package/dashboard/dist/assets/DialogDescription-CEY6jbs9.js +1 -0
  19. package/dashboard/dist/assets/{DialogTitle-CnaGDa7P.js → DialogTitle-CBhd4oLc.js} +1 -1
  20. package/dashboard/dist/assets/{ElectricLogo-CM0HfX6y.js → ElectricLogo-CSWd3b2H.js} +1 -1
  21. package/dashboard/dist/assets/{FadeIn-CngXKUqj.js → FadeIn-D5p6nDla.js} +1 -1
  22. package/dashboard/dist/assets/{HeaderGithubStar-DQn0rAVr.js → HeaderGithubStar-BKVSzY27.js} +1 -1
  23. package/dashboard/dist/assets/{HoverTooltip-By7PDA5D.js → HoverTooltip-CvkAyCDm.js} +1 -1
  24. package/dashboard/dist/assets/{IpCheckPage-BvrRGJNy.js → IpCheckPage-BEMJGKtl.js} +1 -1
  25. package/dashboard/dist/assets/{LandingPage-C0CnNfi_.js → LandingPage-iRp33enQ.js} +2 -2
  26. package/dashboard/dist/assets/{LeaderboardPage-iUXnjGy-.js → LeaderboardPage-DVMPrN4z.js} +2 -2
  27. package/dashboard/dist/assets/{LeaderboardProBadge-CrV2bYaH.js → LeaderboardProBadge-DwnaJoje.js} +1 -1
  28. package/dashboard/dist/assets/LeaderboardProfileModal-DnzVweLM.js +73 -0
  29. package/dashboard/dist/assets/{LeaderboardProfilePage-DmiiHi5U.js → LeaderboardProfilePage-BHNuPuW7.js} +1 -1
  30. package/dashboard/dist/assets/LimitsPage-9LitacBf.js +6 -0
  31. package/dashboard/dist/assets/{LocalOnlyNotice-0pQm9-gI.js → LocalOnlyNotice-DStlbPET.js} +1 -1
  32. package/dashboard/dist/assets/{LoginCard-OaDObE8j.js → LoginCard-I47ukXU5.js} +1 -1
  33. package/dashboard/dist/assets/{LoginModal-C0dRPusa.js → LoginModal-C4iotNjY.js} +1 -1
  34. package/dashboard/dist/assets/{LoginPage-D6CoX42w.js → LoginPage-46QSEnG0.js} +1 -1
  35. package/dashboard/dist/assets/{PetPage-Ch5t4JT_.js → PetPage-SvgDUR8o.js} +2 -2
  36. package/dashboard/dist/assets/{PopoverPopup-UtHXvmNC.js → PopoverPopup-k2ejLK1I.js} +1 -1
  37. package/dashboard/dist/assets/{ProviderIcon-CRlMy2AY.js → ProviderIcon-Bx1XKzin.js} +1 -1
  38. package/dashboard/dist/assets/{ResetPasswordPage-DijAhwkr.js → ResetPasswordPage-CcKIDknp.js} +1 -1
  39. package/dashboard/dist/assets/{Select-D1FvgdDV.js → Select-D0hLQyU8.js} +1 -1
  40. package/dashboard/dist/assets/{SelectItemText-Conq3RQN.js → SelectItemText-DJGBJhDK.js} +1 -1
  41. package/dashboard/dist/assets/{SelfHostPage-CLUgPEFh.js → SelfHostPage-CeIFGRep.js} +1 -1
  42. package/dashboard/dist/assets/{ServiceStatusPage-D7KRjgOE.js → ServiceStatusPage-DMtw0YDh.js} +1 -1
  43. package/dashboard/dist/assets/{SessionsPage-CWNrbLT4.js → SessionsPage-D6h5vSZn.js} +2 -2
  44. package/dashboard/dist/assets/{SettingsPage-Bh_PuEXU.js → SettingsPage-BZgpB5SB.js} +3 -3
  45. package/dashboard/dist/assets/{SkillsPage-DxZuztPd.js → SkillsPage-CtFuZ1ob.js} +1 -1
  46. package/dashboard/dist/assets/{TokenGalaxy-BE-dqlDy.js → TokenGalaxy-DC-no047.js} +1 -1
  47. package/dashboard/dist/assets/{TooltipPopup-DQrTDsmN.js → TooltipPopup-ZM0Ie2_C.js} +1 -1
  48. package/dashboard/dist/assets/{TrendMonitor-Cal2kaMh.js → TrendMonitor-fpLaqJ-o.js} +1 -1
  49. package/dashboard/dist/assets/{WidgetsPage-Cvv2NXRK.js → WidgetsPage-DIoHY6ZJ.js} +1 -1
  50. package/dashboard/dist/assets/{WrappedPage-H99MjZkq.js → WrappedPage-D1-7o8Z1.js} +1 -1
  51. package/dashboard/dist/assets/{__vite-browser-external-I1de5Swt.js → __vite-browser-external-DZF5L1pt.js} +3 -3
  52. package/dashboard/dist/assets/{agent-logos-BUBtw9FF.js → agent-logos-YBC1iAbE.js} +1 -1
  53. package/dashboard/dist/assets/{arrow-left-C99UO2S4.js → arrow-left-BIe3z7iW.js} +1 -1
  54. package/dashboard/dist/assets/{arrow-right-BMFV-A6L.js → arrow-right-CJmRcvYZ.js} +1 -1
  55. package/dashboard/dist/assets/{arrow-up-right-CReo0rju.js → arrow-up-right-IgkBHoCl.js} +1 -1
  56. package/dashboard/dist/assets/{calendar-days-CiEobm13.js → calendar-days-DTuIv3ts.js} +1 -1
  57. package/dashboard/dist/assets/{check-BBOwGLur.js → check-DrXhhj46.js} +1 -1
  58. package/dashboard/dist/assets/{chevron-down-C08QHnpk.js → chevron-down-DHaFKHMK.js} +1 -1
  59. package/dashboard/dist/assets/{cloud-checkout-NOmtAy01.js → cloud-checkout-BffVMyzI.js} +1 -1
  60. package/dashboard/dist/assets/{composite-C5GvW04X.js → composite-M_CP_KSx.js} +1 -1
  61. package/dashboard/dist/assets/{copy-DgY9F9FR.js → copy-D5xoS-Q8.js} +1 -1
  62. package/dashboard/dist/assets/{download-B7xuZSZ2.js → download-CqomMv4-.js} +1 -1
  63. package/dashboard/dist/assets/{external-link-1nRJjSL1.js → external-link--q3pCw6b.js} +1 -1
  64. package/dashboard/dist/assets/{flame-CR1qiptA.js → flame-CUGlDV__.js} +1 -1
  65. package/dashboard/dist/assets/{format-tokens-DNLvupK2.js → format-tokens-BZ6TX9Km.js} +1 -1
  66. package/dashboard/dist/assets/{gift-DUUpsPxb.js → gift-BBgOeC20.js} +1 -1
  67. package/dashboard/dist/assets/{icons-qY8ZAPUu.js → icons-BELeB4ms.js} +1 -1
  68. package/dashboard/dist/assets/{index-Tl9T3Nm3.js → index-B1B6oAbl.js} +1 -1
  69. package/dashboard/dist/assets/{index-BvNIpRP0.js → index-CoRqgJ71.js} +1 -1
  70. package/dashboard/dist/assets/{index-CfdotR_D.js → index-CsdgnKGf.js} +1 -1
  71. package/dashboard/dist/assets/{index-7VLgsnkO.js → index-DkBvk4Qg.js} +1 -1
  72. package/dashboard/dist/assets/{info-DnT3wtaz.js → info-BG1vQZ1r.js} +1 -1
  73. package/dashboard/dist/assets/{link-2-CjxRD2lW.js → link-2-oi5TrXqi.js} +1 -1
  74. package/dashboard/dist/assets/{loader-circle-BYCsfrhr.js → loader-circle-BQQ_Xbby.js} +1 -1
  75. package/dashboard/dist/assets/{local-usage-transfer-DBfMvvqx.js → local-usage-transfer-Dwz-clJ-.js} +1 -1
  76. package/dashboard/dist/assets/{modifiers.esm-DG_gVjqU.js → modifiers.esm-C7a817l7.js} +1 -1
  77. package/dashboard/dist/assets/{monitor-smartphone-CJUH6nwc.js → monitor-smartphone-Bnbo7uRg.js} +1 -1
  78. package/dashboard/dist/assets/{play-DY4d-C2J.js → play-C_xI_JM_.js} +1 -1
  79. package/dashboard/dist/assets/{plus-Dydx6cB7.js → plus-xF6PK5As.js} +1 -1
  80. package/dashboard/dist/assets/{provider-display-DCtG1A8I.js → provider-display-DxquW3js.js} +1 -1
  81. package/dashboard/dist/assets/{react-D9IQudPE.js → react-CGK0_x3G.js} +1 -1
  82. package/dashboard/dist/assets/{safePolygon-CxfZ_vVn.js → safePolygon-CCoDMR4f.js} +1 -1
  83. package/dashboard/dist/assets/{search-DBso9ppI.js → search-EZAGpmfJ.js} +1 -1
  84. package/dashboard/dist/assets/{server-Bb_KzpvJ.js → server-2qbrx2G0.js} +1 -1
  85. package/dashboard/dist/assets/{shield-check-cnqpE0zI.js → shield-check-D8kUejpE.js} +1 -1
  86. package/dashboard/dist/assets/{skills-api-DMbIT8CJ.js → skills-api-DYPf1Vxh.js} +1 -1
  87. package/dashboard/dist/assets/{sortable.esm-2Wnda4fq.js → sortable.esm-D9gC08dQ.js} +1 -1
  88. package/dashboard/dist/assets/{store-CGDaAZE0.js → store-BWUCbK6Y.js} +1 -1
  89. package/dashboard/dist/assets/{terminal-yZ69kiqV.js → terminal-B16b7r6H.js} +1 -1
  90. package/dashboard/dist/assets/{trash-DytHiAPM.js → trash-BIEoE13C.js} +1 -1
  91. package/dashboard/dist/assets/{use-community-stats-DkR1XWs8.js → use-community-stats-DWS7YKbM.js} +1 -1
  92. package/dashboard/dist/assets/{use-latest-request-guard-D_4XuEoM.js → use-latest-request-guard-CYKu-VOB.js} +1 -1
  93. package/dashboard/dist/assets/{use-limits-display-prefs-BadNsZG8.js → use-limits-display-prefs-CEAjVxFr.js} +1 -1
  94. package/dashboard/dist/assets/{use-native-settings-BfxfnQAn.js → use-native-settings-vTg4j5Ln.js} +1 -1
  95. package/dashboard/dist/assets/{use-ordered-list-BHn-XC6u.js → use-ordered-list-CtXedlqH.js} +1 -1
  96. package/dashboard/dist/assets/use-reduced-motion-DVLsAhSA.js +1 -0
  97. package/dashboard/dist/assets/{use-transform-C0qKlh_s.js → use-transform-CdeW5xhz.js} +1 -1
  98. package/dashboard/dist/assets/{use-usage-limits-CQugaku6.js → use-usage-limits-DmDa5Py8.js} +1 -1
  99. package/dashboard/dist/assets/{useCurrency-ChAc4UHj.js → useCurrency-bqycM681.js} +1 -1
  100. package/dashboard/dist/assets/{usePositioner-CGtwrNEt.js → usePositioner-DEXS10fY.js} +1 -1
  101. package/dashboard/dist/assets/{useTokenFormat-CjvWdxnV.js → useTokenFormat-YF6hY8_m.js} +1 -1
  102. package/dashboard/dist/assets/{zap-WTlrDMoe.js → zap-Dg-ij-lq.js} +1 -1
  103. package/dashboard/dist/index.html +1 -1
  104. package/dashboard/dist/ip-check.html +1 -1
  105. package/dashboard/dist/leaderboard.html +1 -1
  106. package/dashboard/dist/share.html +1 -1
  107. package/package.json +1 -1
  108. package/src/lib/dsh-session.js +580 -0
  109. package/src/lib/local-api.js +40 -3
  110. package/src/lib/pricing/seed-snapshot.json +1 -1
  111. package/src/lib/session-analytics.js +89 -11
  112. package/dashboard/dist/assets/DevicePage-jZ9S-taT.js +0 -1
  113. package/dashboard/dist/assets/DialogDescription-BAaD46Xg.js +0 -1
  114. package/dashboard/dist/assets/LeaderboardProfileModal-iGgVOvl7.js +0 -73
  115. package/dashboard/dist/assets/LimitsPage-Bpp0LYGk.js +0 -6
  116. package/dashboard/dist/assets/use-reduced-motion-BsG6Qxa-.js +0 -1
@@ -0,0 +1,580 @@
1
+ "use strict";
2
+
3
+ // Metadata-only DeepSeek Harness (dsh) session reader for the Sessions sidecar.
4
+ // Reuses the same on-disk log format and assistant usage contract as the hourly
5
+ // dsh reader in rollout.js (`~/.dsh/sessions/<project>/<session>/session[.vN].jsonl[.zstd]`).
6
+ // Never persists prompts, assistant bodies, tool arguments, or command output.
7
+
8
+ const fs = require("node:fs");
9
+ const path = require("node:path");
10
+ const crypto = require("node:crypto");
11
+ const {
12
+ isDshSessionLogName,
13
+ parseDshVersion,
14
+ readDshSessionText,
15
+ normalizeDshModelName,
16
+ dshUsageToTotals,
17
+ } = require("./rollout");
18
+
19
+ const TOKEN_FIELDS = [
20
+ "input_tokens",
21
+ "cached_input_tokens",
22
+ "cache_creation_input_tokens",
23
+ "output_tokens",
24
+ "reasoning_output_tokens",
25
+ "total_tokens",
26
+ ];
27
+
28
+ const EDIT_TOOLS = new Set([
29
+ "apply_patch",
30
+ "edit",
31
+ "write",
32
+ "multiedit",
33
+ "multi_edit",
34
+ "notebookedit",
35
+ "ops_edit",
36
+ "ops_replace",
37
+ "ops_write",
38
+ "replace",
39
+ "search_replace",
40
+ "str_replace",
41
+ "str_replace_editor",
42
+ "strreplace",
43
+ "write_file",
44
+ "writefile",
45
+ ]);
46
+ const AGENT_TOOLS = new Set([
47
+ "agent",
48
+ "spawn_agent",
49
+ "spawn_subagent",
50
+ "spawn_teammate",
51
+ "subagent",
52
+ "task",
53
+ ]);
54
+
55
+ // Same ceiling as the hourly dsh reader: a session log is capped when decoded,
56
+ // and chunks/tool blobs are never materialized beyond their raw line.
57
+ const DSH_ACTIVE_GAP_MS = 30 * 60 * 1000;
58
+ // Reject placeholder epoch timestamps so usage cannot land in 1970 and vanish
59
+ // from every date filter (same guard as the other passive readers).
60
+ const MIN_DSH_TIMESTAMP_MS = Date.UTC(2020, 0, 1);
61
+
62
+ function count(value) {
63
+ const number = Number(value);
64
+ return Number.isFinite(number) && number > 0 ? Math.floor(number) : 0;
65
+ }
66
+
67
+ function emptyUsage() {
68
+ const usage = {};
69
+ for (const field of TOKEN_FIELDS) usage[field] = 0;
70
+ return usage;
71
+ }
72
+
73
+ function addUsage(target, delta) {
74
+ for (const field of TOKEN_FIELDS) target[field] = count(target[field]) + count(delta[field]);
75
+ }
76
+
77
+ // Harness usage -> our column vocabulary. The hourly reader owns the mapping;
78
+ // drop its queue-only conversation counter so a delta never leaks into a row.
79
+ function dshUsageDelta(usage) {
80
+ const totals = dshUsageToTotals(usage);
81
+ if (!totals) return null;
82
+ const delta = emptyUsage();
83
+ for (const field of TOKEN_FIELDS) delta[field] = count(totals[field]);
84
+ return delta;
85
+ }
86
+
87
+ function modelName(value) {
88
+ return normalizeDshModelName(value) || null;
89
+ }
90
+
91
+ function sessionIdOf(value) {
92
+ if (typeof value !== "string") return null;
93
+ // Parent sessions persist their id as "session-<uuid>" while subagent
94
+ // headers use the bare uuid; normalize so parent/child links can pair.
95
+ const id = value.trim().replace(/^session-/, "");
96
+ return id || null;
97
+ }
98
+
99
+ function eventTimestampMs(entry) {
100
+ const time = Number(entry?.time);
101
+ return Number.isFinite(time) && time >= MIN_DSH_TIMESTAMP_MS ? time : null;
102
+ }
103
+
104
+ function directoryEntryIs(entry, fullPath, kind) {
105
+ const check = kind === "dir" ? entry.isDirectory() : entry.isFile();
106
+ if (check) return true;
107
+ if (entry.isDirectory() || entry.isFile()) return false;
108
+ try {
109
+ const stat = fs.statSync(fullPath);
110
+ return kind === "dir" ? stat.isDirectory() : stat.isFile();
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+
116
+ // The harness can leave several artifacts for one session (a plain log plus
117
+ // versioned and backup copies). Mirror the hourly reader's canonical choice:
118
+ // the actively written transcript wins, then the higher format version, then
119
+ // the zstd encoding.
120
+ function selectDshTranscript(sessionDir, artifacts) {
121
+ const candidates = artifacts.filter((name) => isDshSessionLogName(name));
122
+ if (candidates.length === 0) return null;
123
+ if (candidates.length === 1) return path.join(sessionDir, candidates[0]);
124
+ const ranked = candidates.map((name) => {
125
+ const full = path.join(sessionDir, name);
126
+ let mtimeMs = 0;
127
+ try {
128
+ const stat = fs.statSync(full);
129
+ if (stat.isFile()) mtimeMs = Number(stat.mtimeMs) || 0;
130
+ } catch { /* vanished mid-listing: lowest priority */ }
131
+ return { full, name, mtimeMs };
132
+ });
133
+ ranked.sort((left, right) =>
134
+ right.mtimeMs - left.mtimeMs ||
135
+ parseDshVersion(right.name) - parseDshVersion(left.name) ||
136
+ Number(right.name.endsWith(".zstd")) - Number(left.name.endsWith(".zstd")));
137
+ return ranked[0]?.full || null;
138
+ }
139
+
140
+ function discoverDshSessionFiles(dshHomes, incomplete = []) {
141
+ // Walk sessions/ ourselves (instead of trusting a listing helper) so a
142
+ // permission error surfaces as incomplete discovery and the caller keeps the
143
+ // previous complete snapshot rather than caching "no dsh sessions".
144
+ const files = [];
145
+ const seen = new Set();
146
+ for (const dshHome of dshHomes || []) {
147
+ if (!dshHome) continue;
148
+ const sessionsRoot = path.join(dshHome, "sessions");
149
+ let projects;
150
+ try {
151
+ projects = fs.readdirSync(sessionsRoot, { withFileTypes: true });
152
+ } catch (error) {
153
+ if (error?.code !== "ENOENT") incomplete.push({ path: sessionsRoot, error: error?.code || "unreadable" });
154
+ continue;
155
+ }
156
+ for (const project of projects) {
157
+ const projectDir = path.join(sessionsRoot, project.name);
158
+ if (!directoryEntryIs(project, projectDir, "dir")) continue;
159
+ let sessions;
160
+ try {
161
+ sessions = fs.readdirSync(projectDir, { withFileTypes: true });
162
+ } catch (error) {
163
+ if (error?.code !== "ENOENT") incomplete.push({ path: projectDir, error: error?.code || "unreadable" });
164
+ continue;
165
+ }
166
+ for (const session of sessions) {
167
+ const sessionDir = path.join(projectDir, session.name);
168
+ if (!directoryEntryIs(session, sessionDir, "dir")) continue;
169
+ let artifacts;
170
+ try {
171
+ artifacts = fs.readdirSync(sessionDir, { withFileTypes: true });
172
+ } catch (error) {
173
+ if (error?.code !== "ENOENT") incomplete.push({ path: sessionDir, error: error?.code || "unreadable" });
174
+ continue;
175
+ }
176
+ const selected = selectDshTranscript(
177
+ sessionDir,
178
+ artifacts
179
+ .filter((artifact) => directoryEntryIs(artifact, path.join(sessionDir, artifact.name), "file"))
180
+ .map((artifact) => artifact.name),
181
+ );
182
+ if (selected && !seen.has(selected)) {
183
+ seen.add(selected);
184
+ files.push(selected);
185
+ }
186
+ }
187
+ }
188
+ }
189
+ files.sort((a, b) => a.localeCompare(b));
190
+ return files;
191
+ }
192
+
193
+ function fingerprintPrompt(data) {
194
+ // In-memory only: the fingerprint never reaches the sidecar.
195
+ const content = Array.isArray(data?.content) ? data.content : [];
196
+ const text = content
197
+ .filter((block) => block && typeof block === "object" && block.type === "text")
198
+ .map((block) => (typeof block.text === "string" ? block.text : ""))
199
+ .join("\u0000");
200
+ return crypto.createHash("sha256").update(text).digest("hex");
201
+ }
202
+
203
+ function eventKey(prefix, id, filePath, seq) {
204
+ if (typeof id === "string" && id.trim()) return `${prefix}:${id.trim()}`;
205
+ // No identity to dedupe on: scope it to the transcript that produced it.
206
+ return `${prefix}:#${filePath}#${seq}`;
207
+ }
208
+
209
+ // Decoding a log costs far more than reading its metadata, and the sidecar
210
+ // invalidates its whole dsh batch whenever any session changes - so one active
211
+ // conversation would otherwise re-decode every transcript on every poll. Keep
212
+ // the parsed event metadata (never the text) keyed by file signature.
213
+ //
214
+ // The bound is the total number of cached event records, not the number of
215
+ // transcripts: a transcript's memory cost is proportional to its event count,
216
+ // and an entry cap silently stops helping once the inventory is larger than the
217
+ // cap (each pass evicts what the next pass wants). This budget holds a full
218
+ // inventory of a regular history while still capping an extreme one.
219
+ const TRANSCRIPT_CACHE_EVENT_LIMIT = 150000;
220
+ const transcriptCache = new Map();
221
+ let transcriptCacheEvents = 0;
222
+ let transcriptCacheEventLimit = TRANSCRIPT_CACHE_EVENT_LIMIT;
223
+
224
+ function transcriptSignature(filePath) {
225
+ try {
226
+ const stat = fs.statSync(filePath);
227
+ return `${stat.mtimeMs}:${stat.ctimeMs}:${stat.size}`;
228
+ } catch {
229
+ return null;
230
+ }
231
+ }
232
+
233
+ function transcriptEventCost(transcript) {
234
+ return Array.isArray(transcript?.events) ? transcript.events.length : 0;
235
+ }
236
+
237
+ function forgetTranscript(filePath) {
238
+ const cached = transcriptCache.get(filePath);
239
+ if (!cached) return;
240
+ transcriptCache.delete(filePath);
241
+ transcriptCacheEvents -= cached.events;
242
+ }
243
+
244
+ function rememberTranscript(filePath, signature, transcript) {
245
+ if (!signature) return;
246
+ forgetTranscript(filePath);
247
+ const events = transcriptEventCost(transcript);
248
+ transcriptCache.set(filePath, { signature, transcript, events });
249
+ transcriptCacheEvents += events;
250
+ // Keep at least one entry so a single oversized transcript still caches.
251
+ while (transcriptCache.size > 1 && transcriptCacheEvents > transcriptCacheEventLimit) {
252
+ forgetTranscript(transcriptCache.keys().next().value);
253
+ }
254
+ }
255
+
256
+ async function parseDshTranscript(filePath) {
257
+ const signature = transcriptSignature(filePath);
258
+ const cached = signature ? transcriptCache.get(filePath) : null;
259
+ if (cached && cached.signature === signature) {
260
+ // Refresh insertion order so the LRU keeps the sessions actually in use.
261
+ transcriptCache.delete(filePath);
262
+ transcriptCache.set(filePath, cached);
263
+ return cached.transcript;
264
+ }
265
+ forgetTranscript(filePath);
266
+ const text = await readDshSessionText(filePath);
267
+ if (text == null) {
268
+ // readDshSessionText answers null for a missing or non-file path (and for
269
+ // any read that fails). Throwing keeps that gap visible: readDshSessions
270
+ // counts it as a skipped transcript so the caller marks the dsh inventory
271
+ // incomplete instead of caching a silently short session list.
272
+ throw new Error("DeepSeek Harness session log is missing or unreadable");
273
+ }
274
+ const transcript = {
275
+ filePath,
276
+ header: null,
277
+ title: null,
278
+ fallbackModel: null,
279
+ events: [],
280
+ };
281
+ const lines = text.split("\n");
282
+ for (let index = 0; index < lines.length; index += 1) {
283
+ const line = lines[index];
284
+ if (!line) continue;
285
+ let entry;
286
+ try {
287
+ entry = JSON.parse(line);
288
+ } catch {
289
+ continue;
290
+ }
291
+ if (!entry || typeof entry !== "object") continue;
292
+ const data = entry.data && typeof entry.data === "object" ? entry.data : null;
293
+ const seq = Number.isFinite(entry.seq) ? entry.seq : index;
294
+ switch (entry.type) {
295
+ case "session": {
296
+ transcript.header = entry;
297
+ break;
298
+ }
299
+ case "session/title": {
300
+ // Written twice per session (a fallback preview, then the model's
301
+ // title); the last one is the one the harness shows.
302
+ const title = typeof data?.title === "string" ? data.title.trim() : "";
303
+ if (title) transcript.title = title;
304
+ break;
305
+ }
306
+ case "request/header": {
307
+ const model = modelName(data?.header?.config?.model);
308
+ if (model) transcript.fallbackModel = model;
309
+ break;
310
+ }
311
+ case "user/message": {
312
+ if (data?.role && data.role !== "user") break;
313
+ // The harness stores every injected context block (time, memory,
314
+ // skill catalog, plugin guidance, approval prompts) as a user-role
315
+ // message. Only an actual prompt opens a turn; counting the rest
316
+ // inflates turns and drifts edit attribution by ~4x in real logs.
317
+ const kind = typeof data?.source?.kind === "string" ? data.source.kind.trim() : "";
318
+ if (kind && kind !== "user") break;
319
+ transcript.events.push({
320
+ kind: "turn",
321
+ key: eventKey("u", data?.id, filePath, seq),
322
+ ts: eventTimestampMs(entry),
323
+ prompt: fingerprintPrompt(data),
324
+ });
325
+ break;
326
+ }
327
+ case "assistant/message": {
328
+ const message = data?.message && typeof data.message === "object" ? data.message : null;
329
+ const ts = eventTimestampMs(entry);
330
+ const model = modelName(message?.source?.model) || transcript.fallbackModel || "unknown";
331
+ const delta = dshUsageDelta(data?.usage);
332
+ if (delta) {
333
+ transcript.events.push({
334
+ kind: "usage",
335
+ key: eventKey("a", message?.id, filePath, seq),
336
+ ts,
337
+ model,
338
+ delta,
339
+ });
340
+ }
341
+ break;
342
+ }
343
+ case "tool/call": {
344
+ const name = typeof data?.name === "string" ? data.name.trim().toLowerCase() : "";
345
+ if (!name) break;
346
+ transcript.events.push({
347
+ kind: "tool",
348
+ key: eventKey("t", data?.callId, filePath, seq),
349
+ ts: eventTimestampMs(entry),
350
+ name,
351
+ });
352
+ break;
353
+ }
354
+ default:
355
+ // Streaming chunks, approvals, prompts-injection and the terminal
356
+ // seed marker carry no accounting of their own. session/end-seed in
357
+ // particular can be stamped weeks after the session ended.
358
+ break;
359
+ }
360
+ }
361
+ rememberTranscript(filePath, signature, transcript);
362
+ return transcript;
363
+ }
364
+
365
+ function transcriptStartedAt(transcript) {
366
+ const created = Number(transcript?.header?.createdAt);
367
+ if (Number.isFinite(created) && created >= MIN_DSH_TIMESTAMP_MS) return created;
368
+ return null;
369
+ }
370
+
371
+ // Harness sessions seeded from a parent log replay the parent's messages
372
+ // verbatim (same message ids, same usage). Attributes every replayed event to
373
+ // the transcript that created it — the earliest one — so a fork or a seeded
374
+ // subagent does not bill the same tokens twice.
375
+ function claimEventsByOrigin(transcripts) {
376
+ const owners = new Map();
377
+ const ordered = [...transcripts].sort((left, right) => {
378
+ const leftAt = transcriptStartedAt(left) ?? Number.MAX_SAFE_INTEGER;
379
+ const rightAt = transcriptStartedAt(right) ?? Number.MAX_SAFE_INTEGER;
380
+ return leftAt - rightAt || left.filePath.localeCompare(right.filePath);
381
+ });
382
+ for (const transcript of ordered) {
383
+ for (const event of transcript.events) {
384
+ if (owners.has(event.key)) continue;
385
+ owners.set(event.key, transcript.filePath);
386
+ }
387
+ }
388
+ return owners;
389
+ }
390
+
391
+ function buildRow(transcript, owners) {
392
+ const header = transcript.header || {};
393
+ const sessionId = sessionIdOf(header.id)
394
+ || sessionIdOf(path.basename(path.dirname(transcript.filePath)))
395
+ || null;
396
+ const parentId = sessionIdOf(header.parentSession);
397
+ // A subagent session is a thread of its parent; a seeded root session is a
398
+ // fork (the harness replays the parent log so the branch starts with its
399
+ // context). Only the first one is a parent/child link.
400
+ const originKind = typeof header.origin === "string" ? header.origin.trim().toLowerCase() : "";
401
+ const isSubagent = originKind === "subagent" || count(header.delegationDepth) > 0;
402
+ const tokens = emptyUsage();
403
+ const modelUsage = new Map();
404
+ const buckets = new Map();
405
+ const times = new Set();
406
+ const agentTypes = new Map();
407
+ const modelsForEdits = new Map();
408
+ let turns = 0;
409
+ let edits = 0;
410
+ let retries = 0;
411
+ let agentCalls = 0;
412
+ let lastPrompt = null;
413
+ let turnOpen = false;
414
+ let hadEdit = false;
415
+ let turnModel = transcript.fallbackModel || "unknown";
416
+
417
+ const closeTurn = () => {
418
+ if (hadEdit) {
419
+ edits += 1;
420
+ modelsForEdits.set(turnModel, (modelsForEdits.get(turnModel) || 0) + 1);
421
+ }
422
+ hadEdit = false;
423
+ turnOpen = false;
424
+ };
425
+
426
+ const startedAt = transcriptStartedAt(transcript);
427
+ if (startedAt != null) times.add(startedAt);
428
+
429
+ // A message id names one message; a re-emitted line in the same log is a
430
+ // replay of it, not a second charge.
431
+ const seenEvents = new Set();
432
+ for (const event of transcript.events) {
433
+ if (owners.get(event.key) !== transcript.filePath) continue;
434
+ if (seenEvents.has(event.key)) continue;
435
+ seenEvents.add(event.key);
436
+ if (event.ts != null) times.add(event.ts);
437
+ if (event.kind === "turn") {
438
+ closeTurn();
439
+ turnOpen = true;
440
+ turns += 1;
441
+ turnModel = transcript.fallbackModel || "unknown";
442
+ if (lastPrompt && lastPrompt === event.prompt) retries += 1;
443
+ lastPrompt = event.prompt;
444
+ continue;
445
+ }
446
+ if (event.kind === "usage") {
447
+ const delta = event.delta;
448
+ const model = event.model || turnModel;
449
+ const modelRow = modelUsage.get(model) || { model, ...emptyUsage(), usage_events: 0 };
450
+ addUsage(tokens, delta);
451
+ addUsage(modelRow, delta);
452
+ modelRow.usage_events += 1;
453
+ modelUsage.set(model, modelRow);
454
+ if (event.ts != null) {
455
+ const bucketTime = new Date(Math.floor(event.ts / 60000) * 60000).toISOString();
456
+ const key = `${model}\u0000${bucketTime}`;
457
+ const bucket = buckets.get(key) || { model, timestamp: bucketTime, ...emptyUsage(), usage_events: 0 };
458
+ addUsage(bucket, delta);
459
+ bucket.usage_events += 1;
460
+ buckets.set(key, bucket);
461
+ }
462
+ turnModel = model;
463
+ continue;
464
+ }
465
+ if (event.kind === "tool") {
466
+ if (turnOpen && EDIT_TOOLS.has(event.name)) hadEdit = true;
467
+ if (AGENT_TOOLS.has(event.name)) {
468
+ agentCalls += 1;
469
+ agentTypes.set(event.name, (agentTypes.get(event.name) || 0) + 1);
470
+ }
471
+ }
472
+ }
473
+ closeTurn();
474
+
475
+ const orderedTimes = [...times].sort((a, b) => a - b);
476
+ let activeMs = 0;
477
+ for (let index = 1; index < orderedTimes.length; index += 1) {
478
+ const gap = orderedTimes[index] - orderedTimes[index - 1];
479
+ if (gap > 0 && gap <= DSH_ACTIVE_GAP_MS) activeMs += gap;
480
+ }
481
+ const models = [...modelUsage.values()].map((row) => ({
482
+ ...row,
483
+ edit_turns: modelsForEdits.get(row.model) || 0,
484
+ }));
485
+ // A session that only handshook (no user turn, no usage) has nothing to say.
486
+ if (!tokens.total_tokens && !turns) return null;
487
+
488
+ return {
489
+ identity: sessionId,
490
+ files: [transcript.filePath],
491
+ session_id: sessionId,
492
+ parent_session_id: isSubagent ? parentId : null,
493
+ forked_from_id: isSubagent ? null : parentId,
494
+ thread_source: originKind || null,
495
+ title: transcript.title,
496
+ project_ref: typeof header.cwd === "string" && header.cwd.trim() ? header.cwd.trim() : null,
497
+ model: models.length === 1 ? models[0].model : "unknown",
498
+ model_usage: models,
499
+ usage_buckets: [...buckets.values()],
500
+ tokens,
501
+ turns,
502
+ edit_turns: edits,
503
+ retry_turns: retries,
504
+ subagent_calls: agentCalls,
505
+ subagent_types: Object.fromEntries([...agentTypes.entries()].sort()),
506
+ usage_events: models.reduce((sum, row) => sum + count(row.usage_events), 0),
507
+ started_at: orderedTimes.length ? new Date(orderedTimes[0]).toISOString() : null,
508
+ ended_at: orderedTimes.length ? new Date(orderedTimes.at(-1)).toISOString() : null,
509
+ active_ms: activeMs,
510
+ provenance: {
511
+ source: "local-session-log",
512
+ confidence: "observed",
513
+ retry_confidence: "inferred",
514
+ content_retained: false,
515
+ usage: "dsh-jsonl-metadata",
516
+ },
517
+ };
518
+ }
519
+
520
+ // Batch the whole inventory: seeded transcripts repeat their parent's message
521
+ // ids, so the dedupe has to see every file before it emits a row (the same
522
+ // contract as the hourly dsh reader's per-message identity).
523
+ async function readDshSessions(filePaths, { onSkip = null } = {}) {
524
+ const files = [...new Set((filePaths || []).filter(Boolean))].sort((a, b) => a.localeCompare(b));
525
+ const transcripts = [];
526
+ let skippedFiles = 0;
527
+ for (const filePath of files) {
528
+ let transcript = null;
529
+ try {
530
+ transcript = await parseDshTranscript(filePath);
531
+ } catch (error) {
532
+ // One half-written or unreadable transcript must not hide the rest of
533
+ // the inventory; count it so the caller can report the gap.
534
+ skippedFiles += 1;
535
+ if (typeof onSkip === "function") onSkip(filePath, error);
536
+ continue;
537
+ }
538
+ if (transcript) transcripts.push(transcript);
539
+ }
540
+ if (transcripts.length === 0) return withSkippedFileCount([], skippedFiles);
541
+ const owners = claimEventsByOrigin(transcripts);
542
+ const rows = [];
543
+ for (const transcript of transcripts) {
544
+ const row = buildRow(transcript, owners);
545
+ if (row) rows.push(row);
546
+ }
547
+ return withSkippedFileCount(rows, skippedFiles);
548
+ }
549
+
550
+ // Non-enumerable so the array still behaves like a plain row list for callers
551
+ // that map/filter/serialize it.
552
+ function withSkippedFileCount(rows, skippedFiles) {
553
+ Object.defineProperty(rows, "skippedFiles", { value: skippedFiles, enumerable: false });
554
+ return rows;
555
+ }
556
+
557
+ // Test hooks: the budget accounting is only observable through the module
558
+ // state, and the dsh inventory is far too large to build in a unit test.
559
+ function resetDshTranscriptCacheForTests() {
560
+ transcriptCache.clear();
561
+ transcriptCacheEvents = 0;
562
+ transcriptCacheEventLimit = TRANSCRIPT_CACHE_EVENT_LIMIT;
563
+ }
564
+
565
+ function setDshTranscriptCacheEventLimitForTests(limit) {
566
+ transcriptCacheEventLimit = Number.isFinite(limit) && limit > 0 ? limit : TRANSCRIPT_CACHE_EVENT_LIMIT;
567
+ }
568
+
569
+ function dshTranscriptCacheStatsForTests() {
570
+ return { size: transcriptCache.size, events: transcriptCacheEvents, limit: transcriptCacheEventLimit };
571
+ }
572
+
573
+ module.exports = {
574
+ discoverDshSessionFiles,
575
+ readDshSessions,
576
+ selectDshTranscript,
577
+ resetDshTranscriptCacheForTests,
578
+ setDshTranscriptCacheEventLimitForTests,
579
+ dshTranscriptCacheStatsForTests,
580
+ };
@@ -27,6 +27,10 @@ const SYNC_TIMEOUT_MS = 120_000;
27
27
  // request still falls back normally; subsequent fan-out requests reuse that
28
28
  // decision for a short cooldown and return local data immediately.
29
29
  const ACCOUNT_VIEW_FAILURE_BACKOFF_MS = 2 * 60_000;
30
+ // A caller stops waiting for the cloud after the response timeout, but the
31
+ // read keeps going in the background for this many times as long so a slow
32
+ // edge can still fill the payload cache and end the backoff early.
33
+ const ACCOUNT_VIEW_BACKGROUND_TIMEOUT_FACTOR = 5;
30
34
  const TRACKER_BIN = path.resolve(__dirname, "../../bin/tracker.js");
31
35
  const MAX_DEVICE_NAME_LENGTH = 128;
32
36
 
@@ -1495,6 +1499,7 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
1495
1499
  // otherwise a fallback reason (see above) telling the caller — and, through
1496
1500
  // the `X-TokenTracker-Account-Fallback` header, the popover — why the local
1497
1501
  // single-machine data is being served instead.
1502
+ const ACCOUNT_VIEW_STILL_PENDING = Symbol("account-view-still-pending");
1498
1503
  async function tryServeAccountView(usageSlug, url, res) {
1499
1504
  if (!getCloudSyncPref()) return ACCOUNT_FALLBACK_CLOUD_SYNC_OFF;
1500
1505
  const refreshToken = getRefreshTokenForCloud();
@@ -1514,7 +1519,15 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
1514
1519
  const envTimeout = process.env.TOKENTRACKER_HTTP_TIMEOUT_MS
1515
1520
  ? parseInt(process.env.TOKENTRACKER_HTTP_TIMEOUT_MS, 10)
1516
1521
  : 0;
1517
- const timeoutMs = envTimeout > 0 ? envTimeout : 4000;
1522
+ // `responseTimeoutMs` bounds how long THIS caller waits before it gets the
1523
+ // local fallback. The cloud read itself keeps running in the background
1524
+ // for up to ACCOUNT_VIEW_BACKGROUND_TIMEOUT_FACTOR times that: full-history
1525
+ // aggregates from far regions often take 2-6 s, and the first read after a
1526
+ // backoff window meets an edge that went cold during it. A late success
1527
+ // still fills the payload cache and clears the backoff, so the next poll is
1528
+ // served instead of timing out again.
1529
+ const responseTimeoutMs = envTimeout > 0 ? envTimeout : 4000;
1530
+ const requestTimeoutMs = responseTimeoutMs * ACCOUNT_VIEW_BACKGROUND_TIMEOUT_FACTOR;
1518
1531
  try {
1519
1532
  // An identical overlapping read shares the entire probe, including its
1520
1533
  // token mint. A healthy pending read must not look like a cloud outage
@@ -1527,7 +1540,7 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
1527
1540
  baseUrl: runtime.baseUrl || DEFAULT_BASE_URL,
1528
1541
  anonKey: runtime.anonKey,
1529
1542
  refreshToken,
1530
- timeoutMs,
1543
+ timeoutMs: requestTimeoutMs,
1531
1544
  onSessionRefreshed: (session) => {
1532
1545
  getRuntime();
1533
1546
  if (sessionAtStart !== getCloudAccountSessionGeneration()) {
@@ -1542,8 +1555,32 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
1542
1555
  if (accountViewInFlight.get(requestKey) === pending) accountViewInFlight.delete(requestKey);
1543
1556
  };
1544
1557
  pending.then(cleanup, cleanup);
1558
+ // Settle the backoff from the probe itself, so it also counts when
1559
+ // every caller has already stopped waiting.
1560
+ pending.then(
1561
+ (out) => { if (out && out.data != null) accountViewFailures.delete(failureKey); },
1562
+ (e) => { if (e?.code !== "auth_session_changed") rememberAccountViewFailure(failureKey); },
1563
+ );
1564
+ }
1565
+ let responseTimer;
1566
+ const responseDeadline = new Promise((resolve) => {
1567
+ responseTimer = setTimeout(() => resolve(ACCOUNT_VIEW_STILL_PENDING), responseTimeoutMs);
1568
+ if (typeof responseTimer.unref === "function") responseTimer.unref();
1569
+ });
1570
+ let out;
1571
+ try {
1572
+ out = await Promise.race([pending, responseDeadline]);
1573
+ } finally {
1574
+ clearTimeout(responseTimer);
1575
+ }
1576
+ if (out === ACCOUNT_VIEW_STILL_PENDING) {
1577
+ // Back the fan-out off as before; the probe clears this on success.
1578
+ rememberAccountViewFailure(failureKey);
1579
+ if (getRuntime().debug) {
1580
+ console.warn(`[LocalAPI] account view still pending after ${responseTimeoutMs}ms for ${usageSlug}; continuing in background`);
1581
+ }
1582
+ return "transient-timeout";
1545
1583
  }
1546
- const out = await pending;
1547
1584
  getRuntime();
1548
1585
  // `null` here means the slug has no cloud equivalent, or the session
1549
1586
  // vanished mid-request — a routing/session fact, not an outage.