@ipv9/tokentracker-cli 0.39.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 (192) hide show
  1. package/LICENSE +21 -0
  2. package/README.ja.md +464 -0
  3. package/README.ko.md +464 -0
  4. package/README.md +476 -0
  5. package/README.zh-CN.md +469 -0
  6. package/bin/tracker.js +52 -0
  7. package/dashboard/dist/app-icon.png +0 -0
  8. package/dashboard/dist/apple-touch-icon.png +0 -0
  9. package/dashboard/dist/assets/ActivityHeatmap-BxBGHY1z.js +42 -0
  10. package/dashboard/dist/assets/Card-GROxUc_6.js +1 -0
  11. package/dashboard/dist/assets/DashboardPage-BZ5VFoBk.js +19 -0
  12. package/dashboard/dist/assets/DevicePage-CQts7ivB.js +1 -0
  13. package/dashboard/dist/assets/DialogTitle-DqFsk26U.js +1 -0
  14. package/dashboard/dist/assets/FadeIn-HBBXW_3q.js +1 -0
  15. package/dashboard/dist/assets/HeaderGithubStar-COD2HfaF.js +1 -0
  16. package/dashboard/dist/assets/IpCheckPage-Cn0l8-BQ.js +15 -0
  17. package/dashboard/dist/assets/LandingPage-CmV1BMU4.js +4356 -0
  18. package/dashboard/dist/assets/LeaderboardAvatar-CiIpWZwb.js +1 -0
  19. package/dashboard/dist/assets/LeaderboardPage-BU0mlR3g.js +6 -0
  20. package/dashboard/dist/assets/LeaderboardProfileModal-C49_3Xm9.js +72 -0
  21. package/dashboard/dist/assets/LeaderboardProfilePage-Bh1n0BAM.js +1 -0
  22. package/dashboard/dist/assets/LimitsPage-fZAmwLWT.js +2 -0
  23. package/dashboard/dist/assets/LocalOnlyNotice-D3rQdA-P.js +1 -0
  24. package/dashboard/dist/assets/LoginPage-enO4XP-c.js +1 -0
  25. package/dashboard/dist/assets/PopoverPopup-pitJEpN1.js +1 -0
  26. package/dashboard/dist/assets/Select-tT9x4ntD.js +1 -0
  27. package/dashboard/dist/assets/SelectItemText-C7F7eIm0.js +1 -0
  28. package/dashboard/dist/assets/SettingsPage-pDasZNU2.js +1 -0
  29. package/dashboard/dist/assets/SkillsPage-DEu7FY-2.js +1 -0
  30. package/dashboard/dist/assets/WidgetsPage-DGcO7rbp.js +1 -0
  31. package/dashboard/dist/assets/WrappedPage-D_1ID213.js +1 -0
  32. package/dashboard/dist/assets/agent-logos-DXQVg1xy.js +1 -0
  33. package/dashboard/dist/assets/arrow-up-right-DNLo92Ba.js +1 -0
  34. package/dashboard/dist/assets/download-DbBBBpJF.js +1 -0
  35. package/dashboard/dist/assets/geist-mono-cyrillic-400-normal-BPBWmzPh.woff +0 -0
  36. package/dashboard/dist/assets/geist-mono-cyrillic-400-normal-Ce5q_31Z.woff2 +0 -0
  37. package/dashboard/dist/assets/geist-mono-cyrillic-500-normal-CJBLNVQT.woff2 +0 -0
  38. package/dashboard/dist/assets/geist-mono-cyrillic-500-normal-mNhfPmgl.woff +0 -0
  39. package/dashboard/dist/assets/geist-mono-cyrillic-700-normal-DH5Q319x.woff +0 -0
  40. package/dashboard/dist/assets/geist-mono-cyrillic-700-normal-VCNRadI3.woff2 +0 -0
  41. package/dashboard/dist/assets/geist-mono-cyrillic-900-normal-BKC3PZkM.woff +0 -0
  42. package/dashboard/dist/assets/geist-mono-cyrillic-900-normal-DdrQ8KBw.woff2 +0 -0
  43. package/dashboard/dist/assets/geist-mono-latin-400-normal-CoULgQGM.woff +0 -0
  44. package/dashboard/dist/assets/geist-mono-latin-400-normal-LC9RFr9I.woff2 +0 -0
  45. package/dashboard/dist/assets/geist-mono-latin-500-normal-D3o2eNa9.woff2 +0 -0
  46. package/dashboard/dist/assets/geist-mono-latin-500-normal-DOxI7kZ4.woff +0 -0
  47. package/dashboard/dist/assets/geist-mono-latin-700-normal-D6izGJRP.woff2 +0 -0
  48. package/dashboard/dist/assets/geist-mono-latin-700-normal-QGw08Lff.woff +0 -0
  49. package/dashboard/dist/assets/geist-mono-latin-900-normal-CmoKXrdK.woff +0 -0
  50. package/dashboard/dist/assets/geist-mono-latin-900-normal-Cu5MFKsu.woff2 +0 -0
  51. package/dashboard/dist/assets/geist-mono-latin-ext-400-normal-Cgks_Qgx.woff2 +0 -0
  52. package/dashboard/dist/assets/geist-mono-latin-ext-400-normal-CxNRRMGd.woff +0 -0
  53. package/dashboard/dist/assets/geist-mono-latin-ext-500-normal-CQcGuCNt.woff2 +0 -0
  54. package/dashboard/dist/assets/geist-mono-latin-ext-500-normal-diTenJ8L.woff +0 -0
  55. package/dashboard/dist/assets/geist-mono-latin-ext-700-normal-BX9f1BHp.woff +0 -0
  56. package/dashboard/dist/assets/geist-mono-latin-ext-700-normal-YOllDaLV.woff2 +0 -0
  57. package/dashboard/dist/assets/geist-mono-latin-ext-900-normal-Br6WNUGb.woff +0 -0
  58. package/dashboard/dist/assets/geist-mono-latin-ext-900-normal-CrmBTzU2.woff2 +0 -0
  59. package/dashboard/dist/assets/index-BeoRn2gJ.js +2 -0
  60. package/dashboard/dist/assets/info-Bh5qTz6k.js +1 -0
  61. package/dashboard/dist/assets/main-BIY3Nj6I.js +969 -0
  62. package/dashboard/dist/assets/main-G_F9Iu_x.css +1 -0
  63. package/dashboard/dist/assets/use-limits-display-prefs-B4iJ-bBc.js +1 -0
  64. package/dashboard/dist/assets/use-native-settings-DHjPFDv8.js +1 -0
  65. package/dashboard/dist/assets/use-usage-limits-odqe9OY7.js +1 -0
  66. package/dashboard/dist/assets/useCurrency-BsSZiZMP.js +1 -0
  67. package/dashboard/dist/assets/useOpenInteractionType-BQhgq4rv.js +12 -0
  68. package/dashboard/dist/brand-logos/antigravity.svg +1 -0
  69. package/dashboard/dist/brand-logos/claude-code.svg +1 -0
  70. package/dashboard/dist/brand-logos/codebuddy.svg +1 -0
  71. package/dashboard/dist/brand-logos/codex.svg +15 -0
  72. package/dashboard/dist/brand-logos/copilot.svg +1 -0
  73. package/dashboard/dist/brand-logos/cursor.svg +1 -0
  74. package/dashboard/dist/brand-logos/gemini.svg +1 -0
  75. package/dashboard/dist/brand-logos/grok.svg +1 -0
  76. package/dashboard/dist/brand-logos/hermes.svg +1 -0
  77. package/dashboard/dist/brand-logos/kilo.svg +4 -0
  78. package/dashboard/dist/brand-logos/kimi.svg +1 -0
  79. package/dashboard/dist/brand-logos/kiro.svg +1 -0
  80. package/dashboard/dist/brand-logos/openclaw.svg +22 -0
  81. package/dashboard/dist/brand-logos/opencode.svg +3 -0
  82. package/dashboard/dist/clawd/happy.svg +161 -0
  83. package/dashboard/dist/clawd/idle/collapse.svg +101 -0
  84. package/dashboard/dist/clawd/idle/doze.svg +72 -0
  85. package/dashboard/dist/clawd/idle/follow.svg +64 -0
  86. package/dashboard/dist/clawd/idle/living.svg +196 -0
  87. package/dashboard/dist/clawd/idle/look.svg +115 -0
  88. package/dashboard/dist/clawd/idle/yawn.svg +158 -0
  89. package/dashboard/dist/clawd/mini/alert.svg +129 -0
  90. package/dashboard/dist/clawd/mini/crabwalk.svg +76 -0
  91. package/dashboard/dist/clawd/mini/enter-sleep.svg +65 -0
  92. package/dashboard/dist/clawd/mini/enter.svg +115 -0
  93. package/dashboard/dist/clawd/mini/happy.svg +124 -0
  94. package/dashboard/dist/clawd/mini/idle-tight.svg +15 -0
  95. package/dashboard/dist/clawd/mini/idle.svg +83 -0
  96. package/dashboard/dist/clawd/mini/peek.svg +82 -0
  97. package/dashboard/dist/clawd/mini/sleep.svg +112 -0
  98. package/dashboard/dist/clawd/react/double.svg +108 -0
  99. package/dashboard/dist/clawd/react/drag.svg +102 -0
  100. package/dashboard/dist/clawd/react/left.svg +102 -0
  101. package/dashboard/dist/clawd/react/right.svg +102 -0
  102. package/dashboard/dist/clawd/sleep/collapse-sleep.svg +247 -0
  103. package/dashboard/dist/clawd/sleep/sleeping.svg +118 -0
  104. package/dashboard/dist/clawd/sleep/wake.svg +277 -0
  105. package/dashboard/dist/clawd/static-base.svg +21 -0
  106. package/dashboard/dist/clawd/status/disconnected.svg +136 -0
  107. package/dashboard/dist/clawd/status/error.svg +94 -0
  108. package/dashboard/dist/clawd/status/notification.svg +134 -0
  109. package/dashboard/dist/clawd/working/building.svg +231 -0
  110. package/dashboard/dist/clawd/working/carrying.svg +107 -0
  111. package/dashboard/dist/clawd/working/conducting.svg +118 -0
  112. package/dashboard/dist/clawd/working/confused.svg +113 -0
  113. package/dashboard/dist/clawd/working/debugger.svg +91 -0
  114. package/dashboard/dist/clawd/working/juggling.svg +82 -0
  115. package/dashboard/dist/clawd/working/overheated.svg +75 -0
  116. package/dashboard/dist/clawd/working/pushing.svg +125 -0
  117. package/dashboard/dist/clawd/working/sweeping.svg +79 -0
  118. package/dashboard/dist/clawd/working/thinking.svg +119 -0
  119. package/dashboard/dist/clawd/working/typing.svg +153 -0
  120. package/dashboard/dist/clawd/working/ultrathink.svg +166 -0
  121. package/dashboard/dist/clawd/working/wizard.svg +98 -0
  122. package/dashboard/dist/dashboard-dark.png +0 -0
  123. package/dashboard/dist/favicon-16.png +0 -0
  124. package/dashboard/dist/favicon-32.png +0 -0
  125. package/dashboard/dist/favicon.ico +0 -0
  126. package/dashboard/dist/feed.xml +34 -0
  127. package/dashboard/dist/icon-192.png +0 -0
  128. package/dashboard/dist/icon-512.png +0 -0
  129. package/dashboard/dist/index.html +355 -0
  130. package/dashboard/dist/llms.txt +53 -0
  131. package/dashboard/dist/neon_bg.png +0 -0
  132. package/dashboard/dist/robots.txt +7 -0
  133. package/dashboard/dist/share.html +140 -0
  134. package/dashboard/dist/sitemap.xml +27 -0
  135. package/dashboard/dist/widgets-overview.png +0 -0
  136. package/package.json +78 -0
  137. package/scripts/install-local-service.sh +140 -0
  138. package/scripts/uninstall-local-service.sh +21 -0
  139. package/src/cli.js +95 -0
  140. package/src/commands/device-login.js +161 -0
  141. package/src/commands/diagnostics.js +38 -0
  142. package/src/commands/doctor.js +96 -0
  143. package/src/commands/init.js +1195 -0
  144. package/src/commands/serve.js +351 -0
  145. package/src/commands/status.js +609 -0
  146. package/src/commands/sync.js +2285 -0
  147. package/src/commands/uninstall.js +189 -0
  148. package/src/commands/wrapped.js +150 -0
  149. package/src/lib/antigravity-paths.js +48 -0
  150. package/src/lib/browser-auth.js +281 -0
  151. package/src/lib/categorizer-utils.js +232 -0
  152. package/src/lib/claude-categorizer.js +1319 -0
  153. package/src/lib/claude-config.js +204 -0
  154. package/src/lib/cli-ui.js +179 -0
  155. package/src/lib/codex-config.js +350 -0
  156. package/src/lib/codex-context-breakdown.js +400 -0
  157. package/src/lib/codex-rollout-parser.js +623 -0
  158. package/src/lib/codex-token-refresh.js +112 -0
  159. package/src/lib/cursor-config.js +425 -0
  160. package/src/lib/debug-flags.js +7 -0
  161. package/src/lib/diagnostics.js +272 -0
  162. package/src/lib/doctor.js +338 -0
  163. package/src/lib/fs.js +91 -0
  164. package/src/lib/gemini-config.js +288 -0
  165. package/src/lib/grok-hook.js +351 -0
  166. package/src/lib/init-flow.js +42 -0
  167. package/src/lib/local-api.js +1861 -0
  168. package/src/lib/openclaw-hook.js +420 -0
  169. package/src/lib/openclaw-session-plugin.js +520 -0
  170. package/src/lib/opencode-config.js +99 -0
  171. package/src/lib/passive-mode.js +185 -0
  172. package/src/lib/pricing/curated-overrides.json +79 -0
  173. package/src/lib/pricing/index.js +143 -0
  174. package/src/lib/pricing/litellm-fetcher.js +172 -0
  175. package/src/lib/pricing/matcher.js +255 -0
  176. package/src/lib/pricing/seed-snapshot.json +1 -0
  177. package/src/lib/progress.js +76 -0
  178. package/src/lib/project-usage-purge.js +106 -0
  179. package/src/lib/prompt.js +24 -0
  180. package/src/lib/proxy-env.js +131 -0
  181. package/src/lib/rollout.js +8442 -0
  182. package/src/lib/runtime-config.js +122 -0
  183. package/src/lib/skill-usage.js +267 -0
  184. package/src/lib/skills-manager.js +1127 -0
  185. package/src/lib/source-metadata.js +48 -0
  186. package/src/lib/sqlite-reader.js +136 -0
  187. package/src/lib/static-server.js +59 -0
  188. package/src/lib/subscriptions.js +453 -0
  189. package/src/lib/tracker-paths.js +16 -0
  190. package/src/lib/upload-throttle.js +148 -0
  191. package/src/lib/usage-limits.js +1806 -0
  192. package/src/lib/wrapped-aggregator.js +225 -0
@@ -0,0 +1,2285 @@
1
+ const os = require("node:os");
2
+ const path = require("node:path");
3
+ const fs = require("node:fs/promises");
4
+ const fssync = require("node:fs");
5
+ const cp = require("node:child_process");
6
+ const readline = require("node:readline");
7
+
8
+ const { ensureDir, readJson, writeJson, openLock } = require("../lib/fs");
9
+ const {
10
+ listRolloutFiles,
11
+ listClaudeProjectFiles,
12
+ listGeminiSessionFiles,
13
+ listOpencodeMessageFiles,
14
+ readOpencodeDbMessages,
15
+ resolveKiroDbPath,
16
+ resolveKiroJsonlPath,
17
+ resolveHermesPath,
18
+ resolveCopilotOtelPaths,
19
+ parseRolloutIncremental,
20
+ parseClaudeIncremental,
21
+ parseGeminiIncremental,
22
+ parseOpencodeIncremental,
23
+ parseOpencodeDbIncremental,
24
+ parseOpenclawIncremental,
25
+ parseCursorApiIncremental,
26
+ parseKiroIncremental,
27
+ parseHermesIncremental,
28
+ parseCopilotIncremental,
29
+ resolveKimiWireFiles,
30
+ parseKimiIncremental,
31
+ resolveKimiCodeWireFiles,
32
+ parseKimiCodeIncremental,
33
+ resolveOmpSessionFiles,
34
+ parseOmpIncremental,
35
+ resolvePiSessionFiles,
36
+ parsePiIncremental,
37
+ piAgentDirCollidesWithOmp,
38
+ resolveCraftSessionFiles,
39
+ parseCraftIncremental,
40
+ resolveGrokBuildSessions,
41
+ parseGrokBuildIncremental,
42
+ listAntigravityTranscripts,
43
+ parseAntigravityIncremental,
44
+ resolveCodebuddyProjectFiles,
45
+ parseCodebuddyIncremental,
46
+ resolveKiroCliSessionFiles,
47
+ resolveKiroCliDbPath,
48
+ parseKiroCliIncremental,
49
+ resolveKilocodeTaskFiles,
50
+ parseKilocodeIncremental,
51
+ resolveRoocodeTaskFiles,
52
+ parseRoocodeIncremental,
53
+ resolveZedDbPath,
54
+ parseZedIncremental,
55
+ resolveGooseDbPath,
56
+ parseGooseIncremental,
57
+ listDroidSettingsFiles,
58
+ parseDroidIncremental,
59
+ bucketKey,
60
+ totalsKey,
61
+ claudeMessageDedupKey,
62
+ } = require("../lib/rollout");
63
+ const { computeClaudeGroundTruthBuckets } = require("../lib/claude-categorizer");
64
+ const { createProgress, renderBar, formatNumber, formatBytes } = require("../lib/progress");
65
+ const {
66
+ normalizeState: normalizeUploadState,
67
+ decideAutoUpload,
68
+ recordUploadFailure,
69
+ recordUploadSuccess,
70
+ parseRetryAfterMs,
71
+ } = require("../lib/upload-throttle");
72
+ const {
73
+ isCursorInstalled,
74
+ extractCursorSessionToken,
75
+ fetchCursorUsageCsv,
76
+ parseCursorCsv,
77
+ } = require("../lib/cursor-config");
78
+ const { purgeProjectUsage } = require("../lib/project-usage-purge");
79
+ const { resolveTrackerPaths } = require("../lib/tracker-paths");
80
+ const { resolveRuntimeConfig } = require("../lib/runtime-config");
81
+
82
+ const CURSOR_UNKNOWN_MIGRATION_KEY = "cursorUnknownPurge_2026_04";
83
+ const ROLLOUT_CUMULATIVE_DELTA_MIGRATION_KEY = "rolloutCumulativeDeltaReparse_2026_05";
84
+ const CLAUDE_MEM_OBSERVER_REINCLUDE_KEY = "claudeMemObserverReinclude_2026_05_v3";
85
+ const GROK_APPEND_ONLY_REPAIR_MIGRATION_KEY = "grokAppendOnlyRepair_2026_05_v4";
86
+ const CLAUDE_MEM_OBSERVER_PATH_SEGMENT = "--claude-mem-observer-sessions";
87
+ // v1 had a cursor-format bug (wrote plain integer instead of {inode, offset,
88
+ // updatedAt}), which made parseClaudeIncremental reread every jsonl from
89
+ // byte 0 on the next sync and double everything. v2 fixed the format.
90
+ // v3 fixes two latent issues caught by adversarial review:
91
+ // (a) v2 wrote `cursors.hourly.groupQueued[claude|<hour>]` for every
92
+ // repaired bucket. enqueueTouchedBuckets uses presence of that key
93
+ // as the legacy-group marker, so any later sync that touched a
94
+ // claude hour (even just a user-message conv-count++) would re-emit
95
+ // the entire hour as one aggregate row under model=DEFAULT_MODEL,
96
+ // causing a different inflation path. v3 leaves groupQueued alone.
97
+ // (b) v2 only repaired the main queue.jsonl. project.queue.jsonl still
98
+ // carried historical claude-mem observer rows (project_key=
99
+ // "claude-mem/observer-sessions") and the project totals on the
100
+ // Project Usage panel stayed inflated. v3 drops every claude /
101
+ // claude-mem row from project.queue.jsonl too, and resets the
102
+ // matching cursors.projectHourly + project.queue.state offset.
103
+ // v4 fixes the dedup short-circuit (issue #64): v3's ground-truth scan
104
+ // itself used `if (msgId && reqId)` to build the dedup key, which silently
105
+ // disabled dedup for any provider whose jsonl entries lack `requestId`
106
+ // (DeepSeek/Kimi/Mimo/MiniMax anthropic-compatible endpoints, plus Claude
107
+ // Code's sub-agent / thinking transport paths). The repaired ground truth
108
+ // was therefore inflated by 1.6–3.7x on those providers — v3 left it that
109
+ // way.
110
+ // v6 was bumped in 0.26.3 to re-run the repair with the zero-usage dedup fix
111
+ // applied. SHIPPING 0.26.3 caused catastrophic data loss on every upgrader
112
+ // whose ~/.claude session jsonls had been pruned by Claude Code's own
113
+ // cleanup: the repair does atomic-drop + rescan, so any hour_start no longer
114
+ // represented in the on-disk logs is silently removed from queue.jsonl. On
115
+ // the reporter's machine this wiped 2.17B claude tokens (-1.27B opus-4-7,
116
+ // -474M opus-4-6, -376M sonnet-4-5, -48M haiku, -6M sonnet-4-6). The
117
+ // upload-offset reset also propagated the damage to the cloud.
118
+ // 0.26.4 HALTS back at v4 so the buggy atomic-rewrite path stops auto-firing
119
+ // on existing installs. The dedup fixes in parseClaudeFile /
120
+ // categorizeSessionFile / computeClaudeGroundTruthBuckets are KEPT — they are
121
+ // correct in isolation, and any future repair will produce the right answer
122
+ // for whatever data is actually on disk. A targeted, log-gap-safe mimo
123
+ // migration will ship later under its own key.
124
+ const CLAUDE_GROUND_TRUTH_REPAIR_KEY = "claudeGroundTruthRepair_2026_05_v4";
125
+
126
+ async function cmdSync(argv) {
127
+ const opts = parseArgs(argv);
128
+ const home = os.homedir();
129
+ const { trackerDir } = await resolveTrackerPaths({ home });
130
+
131
+ await ensureDir(trackerDir);
132
+ if (opts.fromOpenclaw) {
133
+ await writeOpenclawSignal(trackerDir);
134
+ }
135
+
136
+ const lockPath = path.join(trackerDir, "sync.lock");
137
+ const lock = await openLock(lockPath, { quietIfLocked: opts.auto });
138
+ if (!lock) return;
139
+
140
+ let progress = null;
141
+ try {
142
+ progress = !opts.auto ? createProgress({ stream: process.stdout }) : null;
143
+ const configPath = path.join(trackerDir, "config.json");
144
+ const cursorsPath = path.join(trackerDir, "cursors.json");
145
+ const queuePath = path.join(trackerDir, "queue.jsonl");
146
+ const queueStatePath = path.join(trackerDir, "queue.state.json");
147
+ const projectQueuePath = path.join(trackerDir, "project.queue.jsonl");
148
+ const projectQueueStatePath = path.join(trackerDir, "project.queue.state.json");
149
+ const uploadThrottlePath = path.join(trackerDir, "upload.throttle.json");
150
+ const grokSignalPath = path.join(trackerDir, "grok-last-session.json");
151
+ const legacyGrokSignalPath = path.join(trackerDir, "tracker", "grok-last-session.json");
152
+
153
+ const config = await readJson(configPath);
154
+ const cursors = (await readJson(cursorsPath)) || { version: 1, files: {}, updatedAt: null };
155
+ const uploadThrottle = normalizeUploadState(await readJson(uploadThrottlePath));
156
+ let uploadThrottleState = uploadThrottle;
157
+ let grokHookSignal = null;
158
+ let grokHookSignalPath = null;
159
+ for (const candidate of [grokSignalPath, legacyGrokSignalPath]) {
160
+ const signal = await readJson(candidate);
161
+ if (signal && typeof signal === "object") {
162
+ grokHookSignal = signal;
163
+ grokHookSignalPath = candidate;
164
+ break;
165
+ }
166
+ }
167
+ let grokHookSignalConsumed = false;
168
+
169
+ const codexHome = process.env.CODEX_HOME || path.join(home, ".codex");
170
+ const codeHome = process.env.CODE_HOME || path.join(home, ".code");
171
+ const claudeProjectsDir = path.join(home, ".claude", "projects");
172
+ const geminiHome = process.env.GEMINI_HOME || path.join(home, ".gemini");
173
+ const geminiTmpDir = path.join(geminiHome, "tmp");
174
+ const xdgDataHome = process.env.XDG_DATA_HOME || path.join(home, ".local", "share");
175
+ const opencodeHome = process.env.OPENCODE_HOME || path.join(xdgDataHome, "opencode");
176
+ const opencodeStorageDir = path.join(opencodeHome, "storage");
177
+ const kiloHome = process.env.KILO_HOME || path.join(xdgDataHome, "kilo");
178
+
179
+ // OpenClaw hook integration: allow a hook to request incremental parsing for a single session jsonl.
180
+ // We still parse all regular sources so model/source attribution stays complete (e.g. Kimi sessions).
181
+ const openclawSignal = opts.fromOpenclaw
182
+ ? resolveOpenclawSignal({ home, env: process.env })
183
+ : null;
184
+
185
+ const sources = [
186
+ { source: "codex", sessionsDir: path.join(codexHome, "sessions") },
187
+ { source: "every-code", sessionsDir: path.join(codeHome, "sessions") },
188
+ ];
189
+
190
+ const rolloutFiles = [];
191
+ const seenSessions = new Set();
192
+ for (const entry of sources) {
193
+ if (seenSessions.has(entry.sessionsDir)) continue;
194
+ seenSessions.add(entry.sessionsDir);
195
+ const files = await listRolloutFiles(entry.sessionsDir);
196
+ for (const filePath of files) {
197
+ rolloutFiles.push({ path: filePath, source: entry.source });
198
+ }
199
+ }
200
+
201
+ await migrateRolloutCumulativeDeltaBuckets({ cursors, queuePath, rolloutFiles });
202
+
203
+ const openclawFiles = openclawSignal?.sessionFile
204
+ ? [{ path: openclawSignal.sessionFile, source: "openclaw" }]
205
+ : [];
206
+
207
+ if (progress?.enabled) {
208
+ progress.start(
209
+ `Parsing ${renderBar(0)} 0/${formatNumber(rolloutFiles.length)} files | buckets 0`,
210
+ );
211
+ }
212
+
213
+ const parseResult = await parseRolloutIncremental({
214
+ rolloutFiles,
215
+ cursors,
216
+ queuePath,
217
+ projectQueuePath,
218
+ onProgress: (p) => {
219
+ if (!progress?.enabled) return;
220
+ const pct = p.total > 0 ? p.index / p.total : 1;
221
+ progress.update(
222
+ `Parsing ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(p.total)} files | buckets ${formatNumber(
223
+ p.bucketsQueued,
224
+ )}`,
225
+ );
226
+ },
227
+ });
228
+
229
+ let openclawResult = { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
230
+ if (openclawFiles.length > 0) {
231
+ // Only runs when explicitly triggered by OpenClaw hooks.
232
+ openclawResult = await parseOpenclawIncremental({
233
+ sessionFiles: openclawFiles,
234
+ cursors,
235
+ queuePath,
236
+ projectQueuePath,
237
+ source: "openclaw",
238
+ });
239
+ }
240
+
241
+ const openclawFallback = await applyOpenclawTotalsFallback({
242
+ trackerDir,
243
+ signal: openclawSignal,
244
+ cursors,
245
+ queuePath,
246
+ projectQueuePath,
247
+ });
248
+ openclawResult.filesProcessed += openclawFallback.filesProcessed;
249
+ openclawResult.eventsAggregated += openclawFallback.eventsAggregated;
250
+ openclawResult.bucketsQueued += openclawFallback.bucketsQueued;
251
+
252
+ const claudeFiles = await listClaudeProjectFiles(claudeProjectsDir);
253
+ await reincludeClaudeMemObserverFiles({ cursors, claudeFiles, queuePath });
254
+ await repairClaudeQueueFromGroundTruth({
255
+ cursors,
256
+ queuePath,
257
+ queueStatePath,
258
+ projectQueuePath,
259
+ projectQueueStatePath,
260
+ });
261
+ let claudeResult = { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
262
+ if (claudeFiles.length > 0) {
263
+ if (progress?.enabled) {
264
+ progress.start(
265
+ `Parsing Claude ${renderBar(0)} 0/${formatNumber(claudeFiles.length)} files | buckets 0`,
266
+ );
267
+ }
268
+ claudeResult = await parseClaudeIncremental({
269
+ projectFiles: claudeFiles,
270
+ cursors,
271
+ queuePath,
272
+ projectQueuePath,
273
+ onProgress: (p) => {
274
+ if (!progress?.enabled) return;
275
+ const pct = p.total > 0 ? p.index / p.total : 1;
276
+ progress.update(
277
+ `Parsing Claude ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(p.total)} files | buckets ${formatNumber(
278
+ p.bucketsQueued,
279
+ )}`,
280
+ );
281
+ },
282
+ source: "claude",
283
+ });
284
+ }
285
+
286
+ const geminiFiles = await listGeminiSessionFiles(geminiTmpDir);
287
+ let geminiResult = { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
288
+ if (geminiFiles.length > 0) {
289
+ if (progress?.enabled) {
290
+ progress.start(
291
+ `Parsing Gemini ${renderBar(0)} 0/${formatNumber(geminiFiles.length)} files | buckets 0`,
292
+ );
293
+ }
294
+ geminiResult = await parseGeminiIncremental({
295
+ sessionFiles: geminiFiles,
296
+ cursors,
297
+ queuePath,
298
+ projectQueuePath,
299
+ onProgress: (p) => {
300
+ if (!progress?.enabled) return;
301
+ const pct = p.total > 0 ? p.index / p.total : 1;
302
+ progress.update(
303
+ `Parsing Gemini ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(p.total)} files | buckets ${formatNumber(
304
+ p.bucketsQueued,
305
+ )}`,
306
+ );
307
+ },
308
+ source: "gemini",
309
+ });
310
+ }
311
+
312
+ const antigravityFiles = await listAntigravityTranscripts(geminiHome);
313
+ let antigravityResult = { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
314
+ if (antigravityFiles.length > 0) {
315
+ if (progress?.enabled) {
316
+ progress.start(
317
+ `Parsing Antigravity ${renderBar(0)} 0/${formatNumber(antigravityFiles.length)} files | buckets 0`,
318
+ );
319
+ }
320
+ antigravityResult = await parseAntigravityIncremental({
321
+ sessionFiles: antigravityFiles,
322
+ cursors,
323
+ queuePath,
324
+ projectQueuePath,
325
+ onProgress: (p) => {
326
+ if (!progress?.enabled) return;
327
+ const pct = p.total > 0 ? p.index / p.total : 1;
328
+ progress.update(
329
+ `Parsing Antigravity ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(p.total)} files | buckets ${formatNumber(
330
+ p.bucketsQueued,
331
+ )}`,
332
+ );
333
+ },
334
+ source: "antigravity",
335
+ });
336
+ }
337
+
338
+ const opencodeFiles = await listOpencodeMessageFiles(opencodeStorageDir);
339
+ let opencodeResult = { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
340
+ if (opencodeFiles.length > 0) {
341
+ if (progress?.enabled) {
342
+ progress.start(
343
+ `Parsing Opencode ${renderBar(0)} 0/${formatNumber(opencodeFiles.length)} files | buckets 0`,
344
+ );
345
+ }
346
+ opencodeResult = await parseOpencodeIncremental({
347
+ messageFiles: opencodeFiles,
348
+ cursors,
349
+ queuePath,
350
+ projectQueuePath,
351
+ onProgress: (p) => {
352
+ if (!progress?.enabled) return;
353
+ const pct = p.total > 0 ? p.index / p.total : 1;
354
+ progress.update(
355
+ `Parsing Opencode ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(
356
+ p.total,
357
+ )} files | buckets ${formatNumber(p.bucketsQueued)}`,
358
+ );
359
+ },
360
+ source: "opencode",
361
+ });
362
+ }
363
+
364
+ // OpenCode v1.2+ stores messages in SQLite (opencode.db) instead of JSON files.
365
+ const opencodeDbPath = path.join(opencodeHome, "opencode.db");
366
+ let opencodeDbResult = { messagesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
367
+ const dbMessages = readOpencodeDbMessages(opencodeDbPath);
368
+ if (dbMessages.length > 0) {
369
+ if (progress?.enabled) {
370
+ progress.start(
371
+ `Parsing Opencode DB ${renderBar(0)} 0/${formatNumber(dbMessages.length)} msgs | buckets 0`,
372
+ );
373
+ }
374
+ opencodeDbResult = await parseOpencodeDbIncremental({
375
+ dbMessages,
376
+ cursors,
377
+ queuePath,
378
+ projectQueuePath,
379
+ onProgress: (p) => {
380
+ if (!progress?.enabled) return;
381
+ const pct = p.total > 0 ? p.index / p.total : 1;
382
+ progress.update(
383
+ `Parsing Opencode DB ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(
384
+ p.total,
385
+ )} msgs | buckets ${formatNumber(p.bucketsQueued)}`,
386
+ );
387
+ },
388
+ source: "opencode",
389
+ });
390
+ opencodeResult.filesProcessed += opencodeDbResult.messagesProcessed;
391
+ opencodeResult.eventsAggregated += opencodeDbResult.eventsAggregated;
392
+ opencodeResult.bucketsQueued += opencodeDbResult.bucketsQueued;
393
+ }
394
+
395
+ // ── Kilo CLI (kilo.ai @kilocode/plugin — OpenCode-fork SQLite) ──
396
+ // Uses the exact same `message` table schema as OpenCode v1.2+. We reuse
397
+ // the OpenCode DB reader/parser, just with a separate cursor namespace so
398
+ // the message indexes don't collide.
399
+ const kiloDbPath = path.join(kiloHome, "kilo.db");
400
+ let kiloResult = { messagesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
401
+ const kiloDbMessages = readOpencodeDbMessages(kiloDbPath);
402
+ if (kiloDbMessages.length > 0) {
403
+ if (progress?.enabled) {
404
+ progress.start(
405
+ `Parsing Kilo CLI ${renderBar(0)} 0/${formatNumber(kiloDbMessages.length)} msgs | buckets 0`,
406
+ );
407
+ }
408
+ kiloResult = await parseOpencodeDbIncremental({
409
+ dbMessages: kiloDbMessages,
410
+ cursors,
411
+ queuePath,
412
+ projectQueuePath,
413
+ onProgress: (p) => {
414
+ if (!progress?.enabled) return;
415
+ const pct = p.total > 0 ? p.index / p.total : 1;
416
+ progress.update(
417
+ `Parsing Kilo CLI ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(
418
+ p.total,
419
+ )} msgs | buckets ${formatNumber(p.bucketsQueued)}`,
420
+ );
421
+ },
422
+ source: "kilo-cli",
423
+ cursorKey: "kiloCli",
424
+ });
425
+ }
426
+
427
+ // ── Kilo Code VS Code extension (Cline-style ui_messages.json) ──
428
+ const kilocodeTaskFiles = resolveKilocodeTaskFiles(process.env);
429
+ let kilocodeResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
430
+ if (kilocodeTaskFiles.length > 0) {
431
+ if (progress?.enabled) {
432
+ progress.start(
433
+ `Parsing Kilo Code ${renderBar(0)} 0/${formatNumber(kilocodeTaskFiles.length)} tasks | buckets 0`,
434
+ );
435
+ }
436
+ kilocodeResult = await parseKilocodeIncremental({
437
+ taskFiles: kilocodeTaskFiles,
438
+ cursors,
439
+ queuePath,
440
+ onProgress: (p) => {
441
+ if (!progress?.enabled) return;
442
+ const pct = p.total > 0 ? p.index / p.total : 1;
443
+ progress.update(
444
+ `Parsing Kilo Code ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(
445
+ p.total,
446
+ )} tasks | buckets ${formatNumber(p.bucketsQueued)}`,
447
+ );
448
+ },
449
+ });
450
+ }
451
+
452
+ // ── Goose (Block) — SQLite sessions with cumulative tokens per session ──
453
+ const gooseDbPath = resolveGooseDbPath(process.env);
454
+ let gooseResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
455
+ if (fssync.existsSync(gooseDbPath)) {
456
+ if (progress?.enabled) {
457
+ progress.start(`Parsing Goose ${renderBar(0)} 0 sessions | buckets 0`);
458
+ }
459
+ gooseResult = await parseGooseIncremental({
460
+ dbPath: gooseDbPath,
461
+ cursors,
462
+ queuePath,
463
+ onProgress: (p) => {
464
+ if (!progress?.enabled) return;
465
+ const pct = p.total > 0 ? p.index / p.total : 1;
466
+ progress.update(
467
+ `Parsing Goose ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(
468
+ p.total,
469
+ )} sessions | buckets ${formatNumber(p.bucketsQueued)}`,
470
+ );
471
+ },
472
+ });
473
+ }
474
+
475
+ // ── Droid (Factory CLI) — passive reader for ~/.factory/sessions/*.settings.json ──
476
+ const droidSettingsFiles = listDroidSettingsFiles(process.env);
477
+ let droidResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
478
+ if (droidSettingsFiles.length > 0) {
479
+ if (progress?.enabled) {
480
+ progress.start(
481
+ `Parsing Droid ${renderBar(0)} 0/${formatNumber(droidSettingsFiles.length)} sessions | buckets 0`,
482
+ );
483
+ }
484
+ droidResult = await parseDroidIncremental({
485
+ settingsFiles: droidSettingsFiles,
486
+ cursors,
487
+ queuePath,
488
+ // Full-scan sync: drop cursor entries for any session whose
489
+ // settings.json has disappeared off disk so cursors.droid stays
490
+ // bounded by the actual on-disk session count.
491
+ prune: true,
492
+ onProgress: (p) => {
493
+ if (!progress?.enabled) return;
494
+ const pct = p.total > 0 ? p.index / p.total : 1;
495
+ progress.update(
496
+ `Parsing Droid ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(
497
+ p.total,
498
+ )} sessions | buckets ${formatNumber(p.bucketsQueued)}`,
499
+ );
500
+ },
501
+ });
502
+ }
503
+
504
+ // ── Zed Agent (hosted models only; cumulative-delta over SQLite threads) ──
505
+ const zedDbPath = resolveZedDbPath(process.env);
506
+ let zedResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
507
+ if (fssync.existsSync(zedDbPath)) {
508
+ if (progress?.enabled) {
509
+ progress.start(`Parsing Zed Agent ${renderBar(0)} 0 threads | buckets 0`);
510
+ }
511
+ zedResult = await parseZedIncremental({
512
+ dbPath: zedDbPath,
513
+ cursors,
514
+ queuePath,
515
+ onProgress: (p) => {
516
+ if (!progress?.enabled) return;
517
+ const pct = p.total > 0 ? p.index / p.total : 1;
518
+ progress.update(
519
+ `Parsing Zed Agent ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(
520
+ p.total,
521
+ )} threads | buckets ${formatNumber(p.bucketsQueued)}`,
522
+ );
523
+ },
524
+ });
525
+ }
526
+
527
+ // ── Roo Code VS Code extension (Cline-derived; rooveterinaryinc.roo-cline) ──
528
+ const roocodeTaskFiles = resolveRoocodeTaskFiles(process.env);
529
+ let roocodeResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
530
+ if (roocodeTaskFiles.length > 0) {
531
+ if (progress?.enabled) {
532
+ progress.start(
533
+ `Parsing Roo Code ${renderBar(0)} 0/${formatNumber(roocodeTaskFiles.length)} tasks | buckets 0`,
534
+ );
535
+ }
536
+ roocodeResult = await parseRoocodeIncremental({
537
+ taskFiles: roocodeTaskFiles,
538
+ cursors,
539
+ queuePath,
540
+ onProgress: (p) => {
541
+ if (!progress?.enabled) return;
542
+ const pct = p.total > 0 ? p.index / p.total : 1;
543
+ progress.update(
544
+ `Parsing Roo Code ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(
545
+ p.total,
546
+ )} tasks | buckets ${formatNumber(p.bucketsQueued)}`,
547
+ );
548
+ },
549
+ });
550
+ }
551
+
552
+ // ── Cursor (API-based) ──
553
+ // One-time migration: earlier CLI versions mis-parsed the Cursor CSV after
554
+ // Cursor inserted new "Cloud Agent ID"/"Automation ID" columns, writing
555
+ // cursor records under model="unknown". Purge those local buckets, emit
556
+ // zero retractions so the cloud upserts overwrite them to zero, and reset
557
+ // the incremental cursor so the fixed parser re-fetches all affected rows.
558
+ await migrateCursorUnknownBuckets({ cursors, queuePath });
559
+
560
+ let cursorResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
561
+ if (isCursorInstalled({ home })) {
562
+ const cursorAuth = extractCursorSessionToken({ home });
563
+ if (cursorAuth) {
564
+ try {
565
+ if (progress?.enabled) {
566
+ progress.start(`Fetching Cursor usage...`);
567
+ }
568
+ const csvText = await fetchCursorUsageCsv({ cookie: cursorAuth.cookie });
569
+ const records = parseCursorCsv(csvText);
570
+ if (records.length > 0) {
571
+ if (progress?.enabled) {
572
+ progress.start(
573
+ `Parsing Cursor ${renderBar(0)} 0/${formatNumber(records.length)} records | buckets 0`,
574
+ );
575
+ }
576
+ cursorResult = await parseCursorApiIncremental({
577
+ records,
578
+ cursors,
579
+ queuePath,
580
+ onProgress: (p) => {
581
+ if (!progress?.enabled) return;
582
+ const pct = p.total > 0 ? p.index / p.total : 1;
583
+ progress.update(
584
+ `Parsing Cursor ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(
585
+ p.total,
586
+ )} records | buckets ${formatNumber(p.bucketsQueued)}`,
587
+ );
588
+ },
589
+ source: "cursor",
590
+ });
591
+ }
592
+ } catch (err) {
593
+ if (!opts.auto) {
594
+ process.stderr.write(`Cursor sync: ${err.message}\n`);
595
+ }
596
+ }
597
+ }
598
+ }
599
+
600
+ // ── Kiro (SQLite-based, with JSONL fallback) ──
601
+ let kiroResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
602
+ const kiroDbPath = resolveKiroDbPath();
603
+ const kiroJsonlPath = resolveKiroJsonlPath();
604
+ if (fssync.existsSync(kiroDbPath) || fssync.existsSync(kiroJsonlPath)) {
605
+ if (progress?.enabled) {
606
+ progress.start(`Parsing Kiro ${renderBar(0)} | buckets 0`);
607
+ }
608
+ kiroResult = await parseKiroIncremental({
609
+ dbPath: kiroDbPath,
610
+ jsonlPath: kiroJsonlPath,
611
+ cursors,
612
+ queuePath,
613
+ onProgress: (p) => {
614
+ if (!progress?.enabled) return;
615
+ const pct = p.total > 0 ? p.index / p.total : 1;
616
+ progress.update(
617
+ `Parsing Kiro ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(p.total)} records | buckets ${formatNumber(p.bucketsQueued)}`,
618
+ );
619
+ },
620
+ });
621
+ }
622
+
623
+ // ── Hermes Agent (SQLite-based) ──
624
+ let hermesResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
625
+ const hermesPath = resolveHermesPath();
626
+ if (fssync.existsSync(hermesPath)) {
627
+ if (progress?.enabled) {
628
+ progress.start(`Parsing Hermes ${renderBar(0)} | buckets 0`);
629
+ }
630
+ hermesResult = await parseHermesIncremental({
631
+ hermesPath,
632
+ cursors,
633
+ queuePath,
634
+ onProgress: (p) => {
635
+ if (!progress?.enabled) return;
636
+ const pct = p.total > 0 ? p.index / p.total : 1;
637
+ progress.update(
638
+ `Parsing Hermes ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(p.total)} sessions | buckets ${formatNumber(p.bucketsQueued)}`,
639
+ );
640
+ },
641
+ });
642
+ }
643
+
644
+ // ── Kiro CLI (reads ~/Library/Application Support/kiro-cli/data.sqlite3
645
+ // AND live sessions under ~/.kiro/sessions/cli/{uuid}.json) ──
646
+ // Runs IN PARALLEL with the Kiro IDE branch above — NOT instead of it.
647
+ // Both emit source='kiro' so totals merge transparently; cursor state
648
+ // is isolated in cursors.kiroCli. Kiro CLI does not persist explicit
649
+ // token counts (billing is credit-based on Bedrock); we approximate at
650
+ // 4 chars/token from user prompt chars and assistant response chars.
651
+ let kiroCliResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
652
+ const kiroCliDb = resolveKiroCliDbPath(process.env);
653
+ const kiroCliSessionFiles = resolveKiroCliSessionFiles(process.env);
654
+ if (fssync.existsSync(kiroCliDb) || kiroCliSessionFiles.length > 0) {
655
+ if (progress?.enabled) {
656
+ progress.start(`Parsing Kiro CLI ${renderBar(0)} | buckets 0`);
657
+ }
658
+ try {
659
+ kiroCliResult = await parseKiroCliIncremental({
660
+ cursors,
661
+ queuePath,
662
+ env: process.env,
663
+ onProgress: (p) => {
664
+ if (!progress?.enabled) return;
665
+ const pct = p.total > 0 ? p.index / p.total : 1;
666
+ progress.update(
667
+ `Parsing Kiro CLI ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(p.total)} sessions | buckets ${formatNumber(p.bucketsQueued)}`,
668
+ );
669
+ },
670
+ });
671
+ } catch (err) {
672
+ if (!opts.auto) {
673
+ process.stderr.write(`Kiro CLI sync: ${err.message}\n`);
674
+ }
675
+ }
676
+ }
677
+
678
+ // ── Kimi (passive wire.jsonl reader) ──
679
+ let kimiResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
680
+ const kimiWireFiles = resolveKimiWireFiles(process.env);
681
+ if (kimiWireFiles.length > 0) {
682
+ if (progress?.enabled) {
683
+ progress.start(`Parsing Kimi Code ${renderBar(0)} | buckets 0`);
684
+ }
685
+ kimiResult = await parseKimiIncremental({
686
+ wireFiles: kimiWireFiles,
687
+ cursors,
688
+ queuePath,
689
+ env: process.env,
690
+ onProgress: (p) => {
691
+ if (!progress?.enabled) return;
692
+ const pct = p.total > 0 ? p.index / p.total : 1;
693
+ progress.update(
694
+ `Parsing Kimi Code ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(p.total)} files | buckets ${formatNumber(p.bucketsQueued)}`,
695
+ );
696
+ },
697
+ });
698
+ }
699
+
700
+ // ── Kimi Code official (@moonshot-ai/kimi-code, ~/.kimi-code) ──
701
+ let kimiCodeResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
702
+ const kimiCodeWireFiles = resolveKimiCodeWireFiles(process.env);
703
+ if (kimiCodeWireFiles.length > 0) {
704
+ if (progress?.enabled) {
705
+ progress.start(`Parsing Kimi Code (official) ${renderBar(0)} | buckets 0`);
706
+ }
707
+ kimiCodeResult = await parseKimiCodeIncremental({
708
+ wireFiles: kimiCodeWireFiles,
709
+ cursors,
710
+ queuePath,
711
+ env: process.env,
712
+ onProgress: (p) => {
713
+ if (!progress?.enabled) return;
714
+ const pct = p.total > 0 ? p.index / p.total : 1;
715
+ progress.update(
716
+ `Parsing Kimi Code (official) ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(p.total)} files | buckets ${formatNumber(p.bucketsQueued)}`,
717
+ );
718
+ },
719
+ });
720
+ }
721
+
722
+ // ── CodeBuddy CLI (passive ~/.codebuddy/projects/**/*.jsonl reader) ──
723
+ // Tencent's CodeBuddy CLI is a Claude Code clone; no hook system, so we
724
+ // tail the per-session JSONL conversation logs incrementally on each sync.
725
+ let codebuddyResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
726
+ const codebuddyFiles = resolveCodebuddyProjectFiles(process.env);
727
+ if (codebuddyFiles.length > 0) {
728
+ if (progress?.enabled) {
729
+ progress.start(`Parsing CodeBuddy ${renderBar(0)} | buckets 0`);
730
+ }
731
+ codebuddyResult = await parseCodebuddyIncremental({
732
+ projectFiles: codebuddyFiles,
733
+ cursors,
734
+ queuePath,
735
+ env: process.env,
736
+ onProgress: (p) => {
737
+ if (!progress?.enabled) return;
738
+ const pct = p.total > 0 ? p.index / p.total : 1;
739
+ progress.update(
740
+ `Parsing CodeBuddy ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(p.total)} files | buckets ${formatNumber(p.bucketsQueued)}`,
741
+ );
742
+ },
743
+ });
744
+ }
745
+
746
+ // ── oh-my-pi (passive ~/.omp/agent/sessions/**/*.jsonl reader) ──
747
+ let ompResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
748
+ const ompFiles = resolveOmpSessionFiles(process.env);
749
+ if (ompFiles.length > 0) {
750
+ if (progress?.enabled) {
751
+ progress.start(`Parsing oh-my-pi ${renderBar(0)} | buckets 0`);
752
+ }
753
+ ompResult = await parseOmpIncremental({
754
+ sessionFiles: ompFiles,
755
+ cursors,
756
+ queuePath,
757
+ env: process.env,
758
+ onProgress: (p) => {
759
+ if (!progress?.enabled) return;
760
+ const pct = p.total > 0 ? p.index / p.total : 1;
761
+ progress.update(
762
+ `Parsing oh-my-pi ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(p.total)} files | buckets ${formatNumber(p.bucketsQueued)}`,
763
+ );
764
+ },
765
+ });
766
+ }
767
+
768
+ // ── pi (@mariozechner/pi-coding-agent) — passive ~/.pi/agent/sessions/**/*.jsonl reader ──
769
+ // Skip pi parse if its agent dir resolves to the same path as omp's. This
770
+ // prevents double-counting when explicit overrides (TOKENTRACKER_OMP_AGENT_DIR /
771
+ // TOKENTRACKER_PI_AGENT_DIR) bypass the install-signal disambiguator.
772
+ let piResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
773
+ const piFiles = piAgentDirCollidesWithOmp(process.env)
774
+ ? []
775
+ : resolvePiSessionFiles(process.env);
776
+ if (piFiles.length > 0) {
777
+ if (progress?.enabled) {
778
+ progress.start(`Parsing pi ${renderBar(0)} | buckets 0`);
779
+ }
780
+ piResult = await parsePiIncremental({
781
+ sessionFiles: piFiles,
782
+ cursors,
783
+ queuePath,
784
+ env: process.env,
785
+ onProgress: (p) => {
786
+ if (!progress?.enabled) return;
787
+ const pct = p.total > 0 ? p.index / p.total : 1;
788
+ progress.update(
789
+ `Parsing pi ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(p.total)} files | buckets ${formatNumber(p.bucketsQueued)}`,
790
+ );
791
+ },
792
+ });
793
+ }
794
+
795
+ // ── Craft Agents (passive ~/.craft-agent + workspaces session.jsonl reader) ──
796
+ let craftResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
797
+ const craftFiles = resolveCraftSessionFiles(process.env);
798
+ if (craftFiles.length > 0) {
799
+ if (progress?.enabled) {
800
+ progress.start(`Parsing Craft ${renderBar(0)} | buckets 0`);
801
+ }
802
+ craftResult = await parseCraftIncremental({
803
+ sessionFiles: craftFiles,
804
+ cursors,
805
+ queuePath,
806
+ env: process.env,
807
+ onProgress: (p) => {
808
+ if (!progress?.enabled) return;
809
+ const pct = p.total > 0 ? p.index / p.total : 1;
810
+ progress.update(
811
+ `Parsing Craft ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(p.total)} files | buckets ${formatNumber(p.bucketsQueued)}`,
812
+ );
813
+ },
814
+ });
815
+ }
816
+
817
+ // ── Grok Build (xAI) ──
818
+ let grokResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
819
+ // Full passive scan of all Grok sessions (historical + any not covered by hook)
820
+ const grokSessions = resolveGrokBuildSessions(process.env);
821
+ const grokSessionInputs = [...grokSessions];
822
+ if (grokHookSignal && typeof grokHookSignal === "object") {
823
+ const hookSessionId =
824
+ typeof grokHookSignal.sessionId === "string" && grokHookSignal.sessionId.trim()
825
+ ? grokHookSignal.sessionId.trim()
826
+ : null;
827
+ if (hookSessionId) {
828
+ const hookContextTokens =
829
+ grokHookSignal.contextTokensUsed != null
830
+ ? grokHookSignal.contextTokensUsed
831
+ : grokHookSignal.totalTokens;
832
+ const hookTotalTokens =
833
+ grokHookSignal.totalTokens != null
834
+ ? grokHookSignal.totalTokens
835
+ : hookContextTokens;
836
+ grokSessionInputs.unshift({
837
+ sessionId: hookSessionId,
838
+ sessionDir:
839
+ typeof grokHookSignal.sessionDir === "string" ? grokHookSignal.sessionDir : undefined,
840
+ updatesPath:
841
+ typeof grokHookSignal.updatesPath === "string" ? grokHookSignal.updatesPath : undefined,
842
+ signalsPath:
843
+ typeof grokHookSignal.signalsPath === "string" ? grokHookSignal.signalsPath : undefined,
844
+ summaryPath:
845
+ typeof grokHookSignal.summaryPath === "string" ? grokHookSignal.summaryPath : undefined,
846
+ signals: {
847
+ contextTokensUsed: hookContextTokens,
848
+ totalTokens: hookTotalTokens,
849
+ totalTokensBeforeCompaction: grokHookSignal.totalTokensBeforeCompaction,
850
+ assistantMessageCount: grokHookSignal.messageCount,
851
+ primaryModelId: grokHookSignal.model,
852
+ lastActiveAt: grokHookSignal.lastActive,
853
+ },
854
+ summary: { updated_at: grokHookSignal.lastActive },
855
+ });
856
+ grokHookSignalConsumed = true;
857
+ }
858
+ }
859
+ if (grokSessionInputs.length > 0) {
860
+ if (progress?.enabled) {
861
+ progress.start(`Parsing Grok Build ${renderBar(0)} | buckets 0`);
862
+ }
863
+ const grokScanResult = await parseGrokBuildIncremental({
864
+ sessions: grokSessionInputs,
865
+ cursors,
866
+ queuePath,
867
+ env: process.env,
868
+ onProgress: (p) => {
869
+ if (!progress?.enabled) return;
870
+ const pct = p.total > 0 ? p.index / p.total : 1;
871
+ progress.update(
872
+ `Parsing Grok Build ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(p.total)} sessions | buckets ${formatNumber(p.bucketsQueued)}`,
873
+ );
874
+ },
875
+ });
876
+ grokResult = {
877
+ recordsProcessed: grokResult.recordsProcessed + grokScanResult.recordsProcessed,
878
+ eventsAggregated: grokResult.eventsAggregated + grokScanResult.eventsAggregated,
879
+ bucketsQueued: grokResult.bucketsQueued + grokScanResult.bucketsQueued,
880
+ };
881
+ }
882
+ if (opts.repairGrok) {
883
+ await repairGrokQueueFromSessionSnapshots({ cursors, queuePath, queueStatePath });
884
+ }
885
+
886
+ // ── GitHub Copilot CLI (OTEL JSONL files) ──
887
+ let copilotResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
888
+ const copilotPaths = resolveCopilotOtelPaths(process.env);
889
+ if (copilotPaths.length > 0) {
890
+ if (progress?.enabled) {
891
+ progress.start(`Parsing Copilot ${renderBar(0)} | buckets 0`);
892
+ }
893
+ copilotResult = await parseCopilotIncremental({
894
+ otelPaths: copilotPaths,
895
+ cursors,
896
+ queuePath,
897
+ env: process.env,
898
+ onProgress: (p) => {
899
+ if (!progress?.enabled) return;
900
+ const pct = p.total > 0 ? p.index / p.total : 1;
901
+ progress.update(
902
+ `Parsing Copilot ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(p.total)} files | buckets ${formatNumber(p.bucketsQueued)}`,
903
+ );
904
+ },
905
+ });
906
+ }
907
+
908
+ if (cursors?.projectHourly?.projects && projectQueuePath && projectQueueStatePath) {
909
+ for (const [projectKey, meta] of Object.entries(cursors.projectHourly.projects)) {
910
+ if (!meta || typeof meta !== "object") continue;
911
+ if (meta.status !== "blocked" || !meta.purge_pending) continue;
912
+ await purgeProjectUsage({
913
+ projectKey,
914
+ projectQueuePath,
915
+ projectQueueStatePath,
916
+ projectState: cursors.projectHourly,
917
+ });
918
+ meta.purge_pending = false;
919
+ meta.purged_at = new Date().toISOString();
920
+ }
921
+ }
922
+
923
+ cursors.updatedAt = new Date().toISOString();
924
+ await writeJson(cursorsPath, cursors);
925
+ if (grokHookSignalConsumed && grokHookSignalPath) {
926
+ await fs.unlink(grokHookSignalPath).catch(() => {});
927
+ }
928
+
929
+ progress?.stop();
930
+
931
+ const runtime = resolveRuntimeConfig({ config: config || {}, env: process.env });
932
+
933
+ let uploadResult = { inserted: 0, skipped: 0 };
934
+ let uploadAttempted = false;
935
+
936
+ if (runtime.deviceToken && runtime.baseUrl) {
937
+ uploadAttempted = true;
938
+ try {
939
+ uploadResult = await drainQueueToCloud({
940
+ baseUrl: runtime.baseUrl,
941
+ deviceToken: runtime.deviceToken,
942
+ queuePath,
943
+ queueStatePath,
944
+ maxBatches: opts.drain ? 100 : 5,
945
+ batchSize: 200,
946
+ });
947
+ // Record success so the exponential backoff step resets — otherwise
948
+ // a single past failure keeps us pessimistically throttled forever.
949
+ uploadThrottleState = recordUploadSuccess({
950
+ nowMs: Date.now(),
951
+ state: uploadThrottleState,
952
+ });
953
+ await writeJson(uploadThrottlePath, uploadThrottleState);
954
+ } catch (e) {
955
+ // Persist a backoff on 429 / 5xx so the next auto-sync waits instead
956
+ // of retrying immediately and making the rate-limit worse. The
957
+ // throttle module already parses Retry-After when we surface it on
958
+ // the error object (drainQueueToCloud stamps err.status + err.retryAfterMs).
959
+ uploadThrottleState = recordUploadFailure({
960
+ nowMs: Date.now(),
961
+ state: uploadThrottleState,
962
+ error: e,
963
+ });
964
+ await writeJson(uploadThrottlePath, uploadThrottleState);
965
+ if (!opts.auto) {
966
+ process.stderr.write(`Upload error: ${e?.message || e}\n`);
967
+ }
968
+ }
969
+ }
970
+
971
+ const afterState = (await readJson(queueStatePath)) || { offset: 0 };
972
+ const queueSize = await safeStatSize(queuePath);
973
+ const projectAfterState = (await readJson(projectQueueStatePath)) || { offset: 0 };
974
+ const projectQueueSize = await safeStatSize(projectQueuePath);
975
+ const pendingBytes =
976
+ Math.max(0, queueSize - Number(afterState.offset || 0)) +
977
+ Math.max(0, projectQueueSize - Number(projectAfterState.offset || 0));
978
+
979
+ if (pendingBytes <= 0) {
980
+ await clearAutoRetry(trackerDir);
981
+ } else if (opts.auto && uploadAttempted) {
982
+ const retryAtMs = Number(uploadThrottleState?.nextAllowedAtMs || 0);
983
+ if (retryAtMs > Date.now()) {
984
+ await scheduleAutoRetry({
985
+ trackerDir,
986
+ retryAtMs,
987
+ reason: "backlog",
988
+ pendingBytes,
989
+ source: "auto-backlog",
990
+ autoRetryNoSpawn: runtime.autoRetryNoSpawn,
991
+ });
992
+ }
993
+ }
994
+
995
+ if (!opts.auto) {
996
+ const totalParsed =
997
+ parseResult.filesProcessed +
998
+ openclawResult.filesProcessed +
999
+ claudeResult.filesProcessed +
1000
+ geminiResult.filesProcessed +
1001
+ antigravityResult.filesProcessed +
1002
+ opencodeResult.filesProcessed +
1003
+ cursorResult.recordsProcessed +
1004
+ kiroResult.recordsProcessed +
1005
+ kiroCliResult.recordsProcessed +
1006
+ hermesResult.recordsProcessed +
1007
+ kimiResult.recordsProcessed +
1008
+ kimiCodeResult.recordsProcessed +
1009
+ codebuddyResult.recordsProcessed +
1010
+ ompResult.recordsProcessed +
1011
+ piResult.recordsProcessed +
1012
+ craftResult.recordsProcessed +
1013
+ grokResult.recordsProcessed +
1014
+ copilotResult.recordsProcessed +
1015
+ kiloResult.messagesProcessed +
1016
+ kilocodeResult.recordsProcessed +
1017
+ roocodeResult.recordsProcessed +
1018
+ zedResult.recordsProcessed +
1019
+ gooseResult.recordsProcessed +
1020
+ droidResult.recordsProcessed;
1021
+ const totalBuckets =
1022
+ parseResult.bucketsQueued +
1023
+ openclawResult.bucketsQueued +
1024
+ claudeResult.bucketsQueued +
1025
+ geminiResult.bucketsQueued +
1026
+ antigravityResult.bucketsQueued +
1027
+ opencodeResult.bucketsQueued +
1028
+ cursorResult.bucketsQueued +
1029
+ kiroResult.bucketsQueued +
1030
+ kiroCliResult.bucketsQueued +
1031
+ hermesResult.bucketsQueued +
1032
+ kimiResult.bucketsQueued +
1033
+ kimiCodeResult.bucketsQueued +
1034
+ codebuddyResult.bucketsQueued +
1035
+ ompResult.bucketsQueued +
1036
+ piResult.bucketsQueued +
1037
+ craftResult.bucketsQueued +
1038
+ grokResult.bucketsQueued +
1039
+ copilotResult.bucketsQueued +
1040
+ kiloResult.bucketsQueued +
1041
+ kilocodeResult.bucketsQueued +
1042
+ roocodeResult.bucketsQueued +
1043
+ zedResult.bucketsQueued +
1044
+ gooseResult.bucketsQueued +
1045
+ droidResult.bucketsQueued;
1046
+ process.stdout.write(
1047
+ [
1048
+ "Sync finished:",
1049
+ `- Parsed files: ${totalParsed}`,
1050
+ `- New 30-min buckets queued: ${totalBuckets}`,
1051
+ runtime.deviceToken
1052
+ ? `- Uploaded: ${uploadResult.inserted} inserted, ${uploadResult.skipped} skipped`
1053
+ : "- Uploaded: skipped (no device token)",
1054
+ runtime.deviceToken && pendingBytes > 0 && !opts.drain
1055
+ ? `- Remaining: ${formatBytes(pendingBytes)} pending (run sync again, or use --drain)`
1056
+ : null,
1057
+ "",
1058
+ ]
1059
+ .filter(Boolean)
1060
+ .join("\n"),
1061
+ );
1062
+ }
1063
+ } finally {
1064
+ progress?.stop();
1065
+ await lock.release();
1066
+ await fs.unlink(lockPath).catch(() => {});
1067
+ }
1068
+ }
1069
+
1070
+ function parseArgs(argv) {
1071
+ const out = {
1072
+ auto: false,
1073
+ fromNotify: false,
1074
+ fromRetry: false,
1075
+ fromOpenclaw: false,
1076
+ drain: false,
1077
+ repairGrok: false,
1078
+ };
1079
+ for (let i = 0; i < argv.length; i++) {
1080
+ const a = argv[i];
1081
+ if (a === "--auto") out.auto = true;
1082
+ else if (a === "--from-notify") out.fromNotify = true;
1083
+ else if (a === "--from-retry") out.fromRetry = true;
1084
+ else if (a === "--from-openclaw") out.fromOpenclaw = true;
1085
+ else if (a === "--drain") out.drain = true;
1086
+ else if (a === "--repair-grok") out.repairGrok = true;
1087
+ else throw new Error(`Unknown option: ${a}`);
1088
+ }
1089
+ return out;
1090
+ }
1091
+
1092
+ module.exports = {
1093
+ cmdSync,
1094
+ migrateCursorUnknownBuckets,
1095
+ migrateRolloutCumulativeDeltaBuckets,
1096
+ reincludeClaudeMemObserverFiles,
1097
+ repairGrokQueueFromSessionSnapshots,
1098
+ CURSOR_UNKNOWN_MIGRATION_KEY,
1099
+ ROLLOUT_CUMULATIVE_DELTA_MIGRATION_KEY,
1100
+ CLAUDE_MEM_OBSERVER_REINCLUDE_KEY,
1101
+ GROK_APPEND_ONLY_REPAIR_MIGRATION_KEY,
1102
+ };
1103
+
1104
+ function normalizeString(value) {
1105
+ if (typeof value !== "string") return null;
1106
+ const trimmed = value.trim();
1107
+ return trimmed.length > 0 ? trimmed : null;
1108
+ }
1109
+
1110
+ function resolveOpenclawSignal({ home, env } = {}) {
1111
+ if (!env) return null;
1112
+
1113
+ const agentId = normalizeString(env.TOKENTRACKER_OPENCLAW_AGENT_ID);
1114
+ const sessionId = normalizeString(env.TOKENTRACKER_OPENCLAW_PREV_SESSION_ID);
1115
+ if (!agentId || !sessionId) return null;
1116
+
1117
+ const openclawHome =
1118
+ normalizeString(env.TOKENTRACKER_OPENCLAW_HOME) || path.join(home || os.homedir(), ".openclaw");
1119
+ const sessionFile = path.join(openclawHome, "agents", agentId, "sessions", `${sessionId}.jsonl`);
1120
+
1121
+ const prevTotals = {
1122
+ totalTokens: normalizeNonNegativeInt(env.TOKENTRACKER_OPENCLAW_PREV_TOTAL_TOKENS),
1123
+ inputTokens: normalizeNonNegativeInt(env.TOKENTRACKER_OPENCLAW_PREV_INPUT_TOKENS),
1124
+ outputTokens: normalizeNonNegativeInt(env.TOKENTRACKER_OPENCLAW_PREV_OUTPUT_TOKENS),
1125
+ model: normalizeString(env.TOKENTRACKER_OPENCLAW_PREV_MODEL),
1126
+ updatedAt: normalizeIsoOrEpoch(env.TOKENTRACKER_OPENCLAW_PREV_UPDATED_AT),
1127
+ };
1128
+
1129
+ return {
1130
+ agentId,
1131
+ sessionId,
1132
+ sessionKey: normalizeString(env.TOKENTRACKER_OPENCLAW_SESSION_KEY),
1133
+ openclawHome,
1134
+ sessionFile,
1135
+ prevTotals,
1136
+ };
1137
+ }
1138
+
1139
+ async function applyOpenclawTotalsFallback({
1140
+ trackerDir,
1141
+ signal,
1142
+ cursors,
1143
+ queuePath,
1144
+ projectQueuePath,
1145
+ }) {
1146
+ const totalTokens = Number(signal?.prevTotals?.totalTokens || 0);
1147
+ if (!trackerDir || !signal || totalTokens <= 0) {
1148
+ return { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
1149
+ }
1150
+
1151
+ const sessionKey = `${signal.agentId}:${signal.sessionId}`;
1152
+ const statePath = path.join(trackerDir, "openclaw.fallback.state.json");
1153
+ const fallbackFilePath = path.join(trackerDir, "openclaw.fallback.jsonl");
1154
+ const state = (await readJson(statePath)) || { version: 1, sessions: {} };
1155
+ const sessions = state.sessions && typeof state.sessions === "object" ? state.sessions : {};
1156
+ const prev =
1157
+ sessions[sessionKey] && typeof sessions[sessionKey] === "object" ? sessions[sessionKey] : null;
1158
+
1159
+ const current = {
1160
+ totalTokens: normalizeNonNegativeInt(signal?.prevTotals?.totalTokens) || 0,
1161
+ inputTokens: normalizeNonNegativeInt(signal?.prevTotals?.inputTokens) || 0,
1162
+ outputTokens: normalizeNonNegativeInt(signal?.prevTotals?.outputTokens) || 0,
1163
+ model: normalizeString(signal?.prevTotals?.model) || "unknown",
1164
+ updatedAt: normalizeIsoOrEpoch(signal?.prevTotals?.updatedAt) || new Date().toISOString(),
1165
+ seenAt: new Date().toISOString(),
1166
+ };
1167
+
1168
+ let deltaTotal = current.totalTokens;
1169
+ let deltaInput = current.inputTokens;
1170
+ let deltaOutput = current.outputTokens;
1171
+ if (prev) {
1172
+ deltaTotal = Math.max(
1173
+ 0,
1174
+ current.totalTokens - (normalizeNonNegativeInt(prev.totalTokens) || 0),
1175
+ );
1176
+ deltaInput = Math.max(
1177
+ 0,
1178
+ current.inputTokens - (normalizeNonNegativeInt(prev.inputTokens) || 0),
1179
+ );
1180
+ deltaOutput = Math.max(
1181
+ 0,
1182
+ current.outputTokens - (normalizeNonNegativeInt(prev.outputTokens) || 0),
1183
+ );
1184
+ }
1185
+
1186
+ if (deltaTotal > 0 && deltaInput + deltaOutput === 0) {
1187
+ deltaInput = deltaTotal;
1188
+ }
1189
+
1190
+ sessions[sessionKey] = current;
1191
+ state.version = 1;
1192
+ state.sessions = sessions;
1193
+
1194
+ if (deltaTotal <= 0) {
1195
+ await writeJson(statePath, state);
1196
+ return { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
1197
+ }
1198
+
1199
+ await ensureDir(path.dirname(fallbackFilePath));
1200
+ const syntheticMessage = {
1201
+ type: "message",
1202
+ timestamp: current.updatedAt,
1203
+ message: {
1204
+ role: "assistant",
1205
+ model: current.model,
1206
+ usage: {
1207
+ input: deltaInput,
1208
+ output: deltaOutput,
1209
+ cacheRead: 0,
1210
+ cacheWrite: 0,
1211
+ totalTokens: deltaTotal,
1212
+ },
1213
+ },
1214
+ };
1215
+ await fs.appendFile(fallbackFilePath, `${JSON.stringify(syntheticMessage)}\n`, "utf8");
1216
+ await writeJson(statePath, state);
1217
+
1218
+ return parseOpenclawIncremental({
1219
+ sessionFiles: [{ path: fallbackFilePath, source: "openclaw" }],
1220
+ cursors,
1221
+ queuePath,
1222
+ projectQueuePath,
1223
+ source: "openclaw",
1224
+ });
1225
+ }
1226
+
1227
+ function normalizeNonNegativeInt(value) {
1228
+ const n = Number(value);
1229
+ if (!Number.isFinite(n) || n < 0) return null;
1230
+ return Math.floor(n);
1231
+ }
1232
+
1233
+ function normalizeIsoOrEpoch(value) {
1234
+ if (typeof value === "string") {
1235
+ const trimmed = value.trim();
1236
+ if (trimmed.length > 0 && !Number.isNaN(Date.parse(trimmed))) return trimmed;
1237
+ const numeric = Number(trimmed);
1238
+ if (Number.isFinite(numeric) && numeric > 0) {
1239
+ const ms = numeric < 1e12 ? Math.floor(numeric * 1000) : Math.floor(numeric);
1240
+ const iso = new Date(ms).toISOString();
1241
+ if (!Number.isNaN(Date.parse(iso))) return iso;
1242
+ }
1243
+ }
1244
+
1245
+ const n = Number(value);
1246
+ if (!Number.isFinite(n) || n <= 0) return null;
1247
+ const ms = n < 1e12 ? Math.floor(n * 1000) : Math.floor(n);
1248
+ const dt = new Date(ms);
1249
+ if (Number.isNaN(dt.getTime())) return null;
1250
+ return dt.toISOString();
1251
+ }
1252
+
1253
+ async function safeStatSize(p) {
1254
+ try {
1255
+ const st = await fs.stat(p);
1256
+ return st && st.isFile() ? st.size : 0;
1257
+ } catch (_e) {
1258
+ return 0;
1259
+ }
1260
+ }
1261
+
1262
+ function deriveAutoSkipReason({ decision, state }) {
1263
+ if (!decision || decision.reason !== "throttled") return decision?.reason || "unknown";
1264
+ const backoffUntilMs = Number(state?.backoffUntilMs || 0);
1265
+ const nextAllowedAtMs = Number(state?.nextAllowedAtMs || 0);
1266
+ if (backoffUntilMs > 0 && backoffUntilMs >= nextAllowedAtMs) return "backoff";
1267
+ return "throttled";
1268
+ }
1269
+
1270
+ async function scheduleAutoRetry({
1271
+ trackerDir,
1272
+ retryAtMs,
1273
+ reason,
1274
+ pendingBytes,
1275
+ source,
1276
+ autoRetryNoSpawn,
1277
+ }) {
1278
+ const retryMs = coerceRetryMs(retryAtMs);
1279
+ if (!retryMs) return { scheduled: false, retryAtMs: 0 };
1280
+
1281
+ const retryPath = path.join(trackerDir, AUTO_RETRY_FILENAME);
1282
+ const nowMs = Date.now();
1283
+ const existing = await readJson(retryPath);
1284
+ const existingMs = coerceRetryMs(existing?.retryAtMs);
1285
+ if (existingMs && existingMs >= retryMs - 1000) {
1286
+ return { scheduled: false, retryAtMs: existingMs };
1287
+ }
1288
+
1289
+ const payload = {
1290
+ version: 1,
1291
+ retryAtMs: retryMs,
1292
+ retryAt: new Date(retryMs).toISOString(),
1293
+ reason: typeof reason === "string" && reason.length > 0 ? reason : "throttled",
1294
+ pendingBytes: Math.max(0, Number(pendingBytes || 0)),
1295
+ scheduledAt: new Date(nowMs).toISOString(),
1296
+ source: typeof source === "string" ? source : "auto",
1297
+ };
1298
+
1299
+ await writeJson(retryPath, payload);
1300
+
1301
+ const delayMs = Math.min(AUTO_RETRY_MAX_DELAY_MS, Math.max(0, retryMs - nowMs));
1302
+ if (delayMs <= 0) return { scheduled: false, retryAtMs: retryMs };
1303
+ if (autoRetryNoSpawn) {
1304
+ return { scheduled: false, retryAtMs: retryMs };
1305
+ }
1306
+
1307
+ spawnAutoRetryProcess({
1308
+ retryPath,
1309
+ trackerBinPath: path.join(trackerDir, "app", "bin", "tracker.js"),
1310
+ fallbackPkg: "@ipv9/tokentracker-cli",
1311
+ delayMs,
1312
+ });
1313
+ return { scheduled: true, retryAtMs: retryMs };
1314
+ }
1315
+
1316
+ async function clearAutoRetry(trackerDir) {
1317
+ const retryPath = path.join(trackerDir, AUTO_RETRY_FILENAME);
1318
+ await fs.unlink(retryPath).catch(() => {});
1319
+ }
1320
+
1321
+ function spawnAutoRetryProcess({ retryPath, trackerBinPath, fallbackPkg, delayMs }) {
1322
+ const script = buildAutoRetryScript({ retryPath, trackerBinPath, fallbackPkg, delayMs });
1323
+ try {
1324
+ const child = cp.spawn(process.execPath, ["-e", script], {
1325
+ detached: true,
1326
+ stdio: "ignore",
1327
+ env: process.env,
1328
+ });
1329
+ child.unref();
1330
+ } catch (_e) {}
1331
+ }
1332
+
1333
+ function buildAutoRetryScript({ retryPath, trackerBinPath, fallbackPkg, delayMs }) {
1334
+ return (
1335
+ `'use strict';\n` +
1336
+ `const fs = require('node:fs');\n` +
1337
+ `const cp = require('node:child_process');\n` +
1338
+ `const retryPath = ${JSON.stringify(retryPath)};\n` +
1339
+ `const trackerBinPath = ${JSON.stringify(trackerBinPath)};\n` +
1340
+ `const fallbackPkg = ${JSON.stringify(fallbackPkg)};\n` +
1341
+ `const delayMs = ${Math.max(0, Math.floor(delayMs || 0))};\n` +
1342
+ `setTimeout(() => {\n` +
1343
+ ` let retryAtMs = 0;\n` +
1344
+ ` try {\n` +
1345
+ ` const raw = fs.readFileSync(retryPath, 'utf8');\n` +
1346
+ ` retryAtMs = Number(JSON.parse(raw).retryAtMs || 0);\n` +
1347
+ ` } catch (_) {}\n` +
1348
+ ` if (!retryAtMs || Date.now() + 1000 < retryAtMs) process.exit(0);\n` +
1349
+ ` const argv = ['sync', '--auto', '--from-retry'];\n` +
1350
+ ` const cmd = fs.existsSync(trackerBinPath)\n` +
1351
+ ` ? [process.execPath, trackerBinPath, ...argv]\n` +
1352
+ ` : ['npx', '--yes', fallbackPkg, ...argv];\n` +
1353
+ ` try {\n` +
1354
+ ` const child = cp.spawn(cmd[0], cmd.slice(1), { detached: true, stdio: 'ignore', env: process.env });\n` +
1355
+ ` child.unref();\n` +
1356
+ ` } catch (_) {}\n` +
1357
+ `}, delayMs);\n`
1358
+ );
1359
+ }
1360
+
1361
+ function coerceRetryMs(v) {
1362
+ const n = Number(v);
1363
+ if (!Number.isFinite(n) || n <= 0) return 0;
1364
+ return Math.floor(n);
1365
+ }
1366
+
1367
+ async function writeOpenclawSignal(trackerDir) {
1368
+ const openclawSignalPath = path.join(trackerDir, "openclaw.signal");
1369
+ try {
1370
+ await fs.writeFile(openclawSignalPath, new Date().toISOString(), "utf8");
1371
+ } catch (_e) {
1372
+ // best-effort marker
1373
+ }
1374
+ }
1375
+
1376
+ const AUTO_RETRY_FILENAME = "auto.retry.json";
1377
+ const AUTO_RETRY_MAX_DELAY_MS = 2 * 60 * 60 * 1000;
1378
+
1379
+ const INGEST_SLUG = "tokentracker-ingest";
1380
+ const MAX_INGEST_BUCKETS = 500;
1381
+
1382
+ async function drainQueueToCloud({ baseUrl, deviceToken, queuePath, queueStatePath, maxBatches = 5, batchSize = 200 }) {
1383
+ const state = (await readJson(queueStatePath)) || { offset: 0 };
1384
+ let offset = Number(state.offset || 0);
1385
+ let inserted = 0;
1386
+ let skipped = 0;
1387
+
1388
+ const queueSize = await safeStatSize(queuePath);
1389
+ const limit = Math.min(Math.max(1, Math.floor(Number(batchSize || 200))), MAX_INGEST_BUCKETS);
1390
+
1391
+ for (let batch = 0; batch < maxBatches; batch++) {
1392
+ if (offset >= queueSize) break;
1393
+ const result = await readQueueBatch(queuePath, offset, limit);
1394
+ if (result.buckets.length === 0) break;
1395
+
1396
+ const root = baseUrl.replace(/\/$/, "");
1397
+ const anonKey = process.env.TOKENTRACKER_INSFORGE_ANON_KEY || "";
1398
+ const headers = {
1399
+ "Content-Type": "application/json",
1400
+ Accept: "application/json",
1401
+ Authorization: `Bearer ${deviceToken}`,
1402
+ };
1403
+ if (anonKey) headers.apikey = anonKey;
1404
+ const res = await fetch(`${root}/functions/${INGEST_SLUG}`, {
1405
+ method: "POST",
1406
+ headers,
1407
+ body: JSON.stringify({ hourly: result.buckets }),
1408
+ });
1409
+
1410
+ const rawText = await res.text().catch(() => "");
1411
+ let data = {};
1412
+ try { data = JSON.parse(rawText); } catch { data = {}; }
1413
+ if (!res.ok) {
1414
+ const err = new Error(`HTTP ${res.status}: ${rawText.substring(0, 500)}`);
1415
+ err.status = res.status;
1416
+ const retryAfter = res.headers?.get?.("Retry-After") ?? null;
1417
+ const retryAfterMs = parseRetryAfterMs(retryAfter);
1418
+ if (retryAfterMs !== null) err.retryAfterMs = retryAfterMs;
1419
+ throw err;
1420
+ }
1421
+
1422
+ inserted += Number(data?.inserted || 0);
1423
+ skipped += Number(data?.skipped || 0);
1424
+
1425
+ offset = result.nextOffset;
1426
+ state.offset = offset;
1427
+ state.updatedAt = new Date().toISOString();
1428
+ await writeJson(queueStatePath, state);
1429
+ }
1430
+
1431
+ return { inserted, skipped };
1432
+ }
1433
+
1434
+ async function readQueueBatch(queuePath, startOffset, maxBuckets) {
1435
+ const st = await fs.stat(queuePath).catch(() => null);
1436
+ if (!st || !st.isFile()) return { buckets: [], nextOffset: startOffset };
1437
+ if (startOffset >= st.size) return { buckets: [], nextOffset: startOffset };
1438
+
1439
+ const stream = fssync.createReadStream(queuePath, { encoding: "utf8", start: startOffset });
1440
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
1441
+
1442
+ const bucketMap = new Map();
1443
+ let offset = startOffset;
1444
+ let linesRead = 0;
1445
+ for await (const line of rl) {
1446
+ const bytes = Buffer.byteLength(line, "utf8") + 1;
1447
+ offset += bytes;
1448
+ if (!line.trim()) continue;
1449
+ let bucket;
1450
+ try {
1451
+ bucket = JSON.parse(line);
1452
+ } catch (_e) {
1453
+ continue;
1454
+ }
1455
+ const hourStart = typeof bucket?.hour_start === "string" ? bucket.hour_start : null;
1456
+ if (!hourStart) continue;
1457
+ const source = (typeof bucket?.source === "string" ? bucket.source.trim().toLowerCase() : "") || "codex";
1458
+ const model = (typeof bucket?.model === "string" ? bucket.model.trim() : "") || "unknown";
1459
+ bucket.source = source;
1460
+ bucket.model = model;
1461
+ bucketMap.set(`${source}|${model}|${hourStart}`, bucket);
1462
+ linesRead += 1;
1463
+ if (linesRead >= maxBuckets) break;
1464
+ }
1465
+
1466
+ rl.close();
1467
+ stream.close?.();
1468
+ return { buckets: Array.from(bucketMap.values()), nextOffset: offset };
1469
+ }
1470
+
1471
+ function normalizeGrokRepairSource(value) {
1472
+ return typeof value === "string" ? value.trim().toLowerCase() : "";
1473
+ }
1474
+
1475
+ function normalizeGrokRepairModel(value) {
1476
+ return typeof value === "string" && value.trim() ? value.trim() : "grok-build";
1477
+ }
1478
+
1479
+ function normalizeGrokRepairNumber(value) {
1480
+ const n = Number(value);
1481
+ return Number.isFinite(n) && n > 0 ? n : 0;
1482
+ }
1483
+
1484
+ function toGrokRepairHalfHourStart(value) {
1485
+ if (value == null) return null;
1486
+ const millis =
1487
+ typeof value === "number"
1488
+ ? value < 10_000_000_000
1489
+ ? value * 1000
1490
+ : value
1491
+ : Date.parse(String(value));
1492
+ if (!Number.isFinite(millis)) return null;
1493
+ const halfHourMs = 30 * 60 * 1000;
1494
+ return new Date(Math.floor(millis / halfHourMs) * halfHourMs).toISOString();
1495
+ }
1496
+
1497
+ function estimateGrokRepairTotals(totalTokens, conversationCount) {
1498
+ const total = Math.trunc(normalizeGrokRepairNumber(totalTokens));
1499
+ const inputTokens = Math.round(total * 0.8);
1500
+ const outputTokens = Math.max(0, total - inputTokens);
1501
+ return {
1502
+ input_tokens: inputTokens,
1503
+ cached_input_tokens: 0,
1504
+ cache_creation_input_tokens: 0,
1505
+ output_tokens: outputTokens,
1506
+ reasoning_output_tokens: 0,
1507
+ total_tokens: total,
1508
+ billable_total_tokens: total,
1509
+ conversation_count: Math.trunc(normalizeGrokRepairNumber(conversationCount)),
1510
+ };
1511
+ }
1512
+
1513
+ function addGrokRepairTotals(target, delta) {
1514
+ target.input_tokens += delta.input_tokens;
1515
+ target.cached_input_tokens += delta.cached_input_tokens;
1516
+ target.cache_creation_input_tokens += delta.cache_creation_input_tokens;
1517
+ target.output_tokens += delta.output_tokens;
1518
+ target.reasoning_output_tokens += delta.reasoning_output_tokens;
1519
+ target.total_tokens += delta.total_tokens;
1520
+ target.billable_total_tokens += delta.billable_total_tokens;
1521
+ target.conversation_count += delta.conversation_count;
1522
+ }
1523
+
1524
+ function buildGrokRepairRowsFromSnapshots(sessionSnapshots) {
1525
+ if (!sessionSnapshots || typeof sessionSnapshots !== "object") return [];
1526
+
1527
+ const buckets = new Map();
1528
+ for (const snapshot of Object.values(sessionSnapshots)) {
1529
+ if (!snapshot || typeof snapshot !== "object") continue;
1530
+ const totalTokens = Math.trunc(normalizeGrokRepairNumber(snapshot.totalTokens));
1531
+ if (totalTokens <= 0) continue;
1532
+
1533
+ const hourStart = toGrokRepairHalfHourStart(
1534
+ snapshot.lastEventTimestamp || snapshot.updatedAt,
1535
+ );
1536
+ if (!hourStart) continue;
1537
+
1538
+ const model = normalizeGrokRepairModel(snapshot.model);
1539
+ const key = bucketKey("grok", model, hourStart);
1540
+ let totals = buckets.get(key);
1541
+ if (!totals) {
1542
+ totals = {
1543
+ source: "grok",
1544
+ model,
1545
+ hour_start: hourStart,
1546
+ input_tokens: 0,
1547
+ cached_input_tokens: 0,
1548
+ cache_creation_input_tokens: 0,
1549
+ output_tokens: 0,
1550
+ reasoning_output_tokens: 0,
1551
+ total_tokens: 0,
1552
+ billable_total_tokens: 0,
1553
+ conversation_count: 0,
1554
+ };
1555
+ buckets.set(key, totals);
1556
+ }
1557
+ addGrokRepairTotals(
1558
+ totals,
1559
+ estimateGrokRepairTotals(totalTokens, snapshot.messageCount),
1560
+ );
1561
+ }
1562
+
1563
+ return Array.from(buckets.values()).sort((a, b) => {
1564
+ const timeCompare = a.hour_start.localeCompare(b.hour_start);
1565
+ return timeCompare || a.model.localeCompare(b.model);
1566
+ });
1567
+ }
1568
+
1569
+ function applyGrokRepairHourlyState(cursors, rows) {
1570
+ const hourly = cursors.hourly && typeof cursors.hourly === "object" ? cursors.hourly : {};
1571
+ const buckets = hourly.buckets && typeof hourly.buckets === "object" ? hourly.buckets : {};
1572
+ const groupQueued =
1573
+ hourly.groupQueued && typeof hourly.groupQueued === "object" ? hourly.groupQueued : {};
1574
+
1575
+ for (const key of Object.keys(buckets)) {
1576
+ if (key.startsWith("grok|")) {
1577
+ delete buckets[key];
1578
+ }
1579
+ }
1580
+ for (const key of Object.keys(groupQueued)) {
1581
+ if (key.startsWith("grok|")) {
1582
+ delete groupQueued[key];
1583
+ }
1584
+ }
1585
+
1586
+ for (const row of rows) {
1587
+ const totals = {
1588
+ input_tokens: row.input_tokens,
1589
+ cached_input_tokens: row.cached_input_tokens,
1590
+ cache_creation_input_tokens: row.cache_creation_input_tokens,
1591
+ output_tokens: row.output_tokens,
1592
+ reasoning_output_tokens: row.reasoning_output_tokens,
1593
+ total_tokens: row.total_tokens,
1594
+ billable_total_tokens: row.billable_total_tokens,
1595
+ conversation_count: row.conversation_count,
1596
+ };
1597
+ buckets[bucketKey("grok", row.model, row.hour_start)] = {
1598
+ totals,
1599
+ queuedKey: totalsKey(totals),
1600
+ source: "grok",
1601
+ hour_start: row.hour_start,
1602
+ };
1603
+ }
1604
+
1605
+ cursors.hourly = {
1606
+ ...hourly,
1607
+ version: 3,
1608
+ buckets,
1609
+ groupQueued,
1610
+ updatedAt: typeof hourly.updatedAt === "string" ? hourly.updatedAt : null,
1611
+ };
1612
+ }
1613
+
1614
+ async function resetGrokRepairUploadOffset(queueStatePath) {
1615
+ if (typeof queueStatePath !== "string" || !queueStatePath) return false;
1616
+ let state = {};
1617
+ try {
1618
+ state = JSON.parse(await fs.readFile(queueStatePath, "utf8"));
1619
+ } catch (_e) {
1620
+ state = {};
1621
+ }
1622
+ state.offset = 0;
1623
+ state.updatedAt = new Date().toISOString();
1624
+ state.note = "reset_after_grok_append_only_repair_2026_05_v4";
1625
+ await ensureDir(path.dirname(queueStatePath));
1626
+ await fs.writeFile(queueStatePath, JSON.stringify(state, null, 2) + "\n", "utf8");
1627
+ return true;
1628
+ }
1629
+
1630
+ function hasAppliedGrokRepairMigration(value) {
1631
+ if (!value) return false;
1632
+ if (value === true) return true;
1633
+ if (value && typeof value === "object") {
1634
+ if (value.status === "applied" || value.status === "noop") return true;
1635
+ if (value.status) return false;
1636
+ return value.rowsWritten != null || value.rowsRemoved != null;
1637
+ }
1638
+ return false;
1639
+ }
1640
+
1641
+ function serializeGrokRepairRow(row) {
1642
+ return JSON.stringify({
1643
+ source: "grok",
1644
+ model: normalizeGrokRepairModel(row.model),
1645
+ hour_start: row.hour_start,
1646
+ input_tokens: row.input_tokens || 0,
1647
+ cached_input_tokens: row.cached_input_tokens || 0,
1648
+ cache_creation_input_tokens: row.cache_creation_input_tokens || 0,
1649
+ output_tokens: row.output_tokens || 0,
1650
+ reasoning_output_tokens: row.reasoning_output_tokens || 0,
1651
+ total_tokens: row.total_tokens || 0,
1652
+ billable_total_tokens: row.billable_total_tokens || 0,
1653
+ conversation_count: row.conversation_count || 0,
1654
+ });
1655
+ }
1656
+
1657
+ async function backupExistingFile(filePath) {
1658
+ if (typeof filePath !== "string" || !filePath) return null;
1659
+ try {
1660
+ const stat = await fs.stat(filePath);
1661
+ if (!stat.isFile()) return null;
1662
+ } catch (e) {
1663
+ if (e?.code === "ENOENT" || e?.code === "ENOTDIR") return null;
1664
+ throw e;
1665
+ }
1666
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
1667
+ const backupPath = `${filePath}.bak.${stamp}`;
1668
+ await fs.copyFile(filePath, backupPath);
1669
+ return backupPath;
1670
+ }
1671
+
1672
+ async function repairGrokQueueFromSessionSnapshots({ cursors, queuePath, queueStatePath } = {}) {
1673
+ if (!cursors || typeof cursors !== "object") return false;
1674
+ const grokState = (cursors.grok ||= {});
1675
+ const migrations = (grokState.migrations ||= {});
1676
+ if (hasAppliedGrokRepairMigration(migrations[GROK_APPEND_ONLY_REPAIR_MIGRATION_KEY])) {
1677
+ return false;
1678
+ }
1679
+
1680
+ let raw = "";
1681
+ try {
1682
+ raw = await fs.readFile(queuePath, "utf8");
1683
+ } catch (e) {
1684
+ if (e?.code !== "ENOENT") throw e;
1685
+ }
1686
+
1687
+ const latestGrokRows = new Map();
1688
+ let existingGrokRows = 0;
1689
+ for (const line of raw.split("\n")) {
1690
+ if (!line.trim()) continue;
1691
+ let row;
1692
+ try {
1693
+ row = JSON.parse(line);
1694
+ } catch (_e) {
1695
+ continue;
1696
+ }
1697
+
1698
+ if (normalizeGrokRepairSource(row?.source) === "grok") {
1699
+ const model = normalizeGrokRepairModel(row.model);
1700
+ const hourStart = typeof row.hour_start === "string" ? row.hour_start : null;
1701
+ if (!hourStart) continue;
1702
+ existingGrokRows += 1;
1703
+ latestGrokRows.set(bucketKey("grok", model, hourStart), {
1704
+ ...row,
1705
+ source: "grok",
1706
+ model,
1707
+ hour_start: hourStart,
1708
+ });
1709
+ }
1710
+ }
1711
+
1712
+ if (existingGrokRows === 0) {
1713
+ migrations[GROK_APPEND_ONLY_REPAIR_MIGRATION_KEY] = {
1714
+ status: "noop",
1715
+ appliedAt: new Date().toISOString(),
1716
+ existingGrokRows: 0,
1717
+ rowsWritten: 0,
1718
+ snapshotsUsed: 0,
1719
+ uploadOffsetReset: false,
1720
+ };
1721
+ return false;
1722
+ }
1723
+
1724
+ const repairRows = buildGrokRepairRowsFromSnapshots(grokState.sessionSnapshots);
1725
+ if (repairRows.length === 0) {
1726
+ migrations[GROK_APPEND_ONLY_REPAIR_MIGRATION_KEY] = {
1727
+ status: "skipped",
1728
+ appliedAt: new Date().toISOString(),
1729
+ reason: "missing-session-snapshots",
1730
+ existingGrokRows,
1731
+ rowsWritten: 0,
1732
+ snapshotsUsed: 0,
1733
+ uploadOffsetReset: false,
1734
+ };
1735
+ return false;
1736
+ }
1737
+
1738
+ applyGrokRepairHourlyState(cursors, repairRows);
1739
+
1740
+ const repairLines = [];
1741
+ const repairKeys = new Set();
1742
+ for (const row of repairRows) {
1743
+ const key = bucketKey("grok", row.model, row.hour_start);
1744
+ repairKeys.add(key);
1745
+ const current = latestGrokRows.get(key);
1746
+ if (current && totalsKey(current) === totalsKey(row)) continue;
1747
+ repairLines.push(serializeGrokRepairRow(row));
1748
+ }
1749
+
1750
+ const zeroTotals = {
1751
+ input_tokens: 0,
1752
+ cached_input_tokens: 0,
1753
+ cache_creation_input_tokens: 0,
1754
+ output_tokens: 0,
1755
+ reasoning_output_tokens: 0,
1756
+ total_tokens: 0,
1757
+ billable_total_tokens: 0,
1758
+ conversation_count: 0,
1759
+ };
1760
+ let staleRowsRetracted = 0;
1761
+ for (const [key, row] of latestGrokRows.entries()) {
1762
+ if (repairKeys.has(key)) continue;
1763
+ if (totalsKey(row) === totalsKey(zeroTotals)) continue;
1764
+ staleRowsRetracted += 1;
1765
+ repairLines.push(serializeGrokRepairRow({
1766
+ ...zeroTotals,
1767
+ model: row.model,
1768
+ hour_start: row.hour_start,
1769
+ }));
1770
+ }
1771
+
1772
+ if (repairLines.length === 0) {
1773
+ migrations[GROK_APPEND_ONLY_REPAIR_MIGRATION_KEY] = {
1774
+ status: "noop",
1775
+ appliedAt: new Date().toISOString(),
1776
+ existingGrokRows,
1777
+ rowsWritten: 0,
1778
+ staleRowsRetracted,
1779
+ snapshotsUsed: repairRows.length,
1780
+ uploadOffsetReset: false,
1781
+ };
1782
+ return false;
1783
+ }
1784
+
1785
+ await ensureDir(path.dirname(queuePath));
1786
+ const queueBackupPath = await backupExistingFile(queuePath);
1787
+ const queueStateBackupPath = await backupExistingFile(queueStatePath);
1788
+ await fs.appendFile(queuePath, `${repairLines.join("\n")}\n`, "utf8");
1789
+
1790
+ const uploadOffsetReset = await resetGrokRepairUploadOffset(queueStatePath);
1791
+ migrations[GROK_APPEND_ONLY_REPAIR_MIGRATION_KEY] = {
1792
+ status: "applied",
1793
+ appliedAt: new Date().toISOString(),
1794
+ existingGrokRows,
1795
+ rowsWritten: repairLines.length,
1796
+ staleRowsRetracted,
1797
+ snapshotsUsed: Object.values(grokState.sessionSnapshots || {}).filter((snapshot) => {
1798
+ if (!snapshot || typeof snapshot !== "object") return false;
1799
+ if (Math.trunc(normalizeGrokRepairNumber(snapshot.totalTokens)) <= 0) return false;
1800
+ return Boolean(toGrokRepairHalfHourStart(snapshot.lastEventTimestamp || snapshot.updatedAt));
1801
+ }).length,
1802
+ uploadOffsetReset,
1803
+ queueBackupPath,
1804
+ queueStateBackupPath,
1805
+ };
1806
+ return true;
1807
+ }
1808
+
1809
+ async function migrateCursorUnknownBuckets({ cursors, queuePath }) {
1810
+ if (!cursors || typeof cursors !== "object") return;
1811
+ cursors.migrations = cursors.migrations || {};
1812
+ if (cursors.migrations[CURSOR_UNKNOWN_MIGRATION_KEY]) return;
1813
+
1814
+ const buckets = cursors.hourly?.buckets;
1815
+ if (!buckets || typeof buckets !== "object") {
1816
+ cursors.migrations[CURSOR_UNKNOWN_MIGRATION_KEY] = new Date().toISOString();
1817
+ return;
1818
+ }
1819
+
1820
+ const retractions = [];
1821
+ for (const key of Object.keys(buckets)) {
1822
+ if (!key.startsWith("cursor|unknown|")) continue;
1823
+ const hourStart = key.split("|").slice(2).join("|");
1824
+ retractions.push(
1825
+ JSON.stringify({
1826
+ source: "cursor",
1827
+ model: "unknown",
1828
+ hour_start: hourStart,
1829
+ input_tokens: 0,
1830
+ cached_input_tokens: 0,
1831
+ cache_creation_input_tokens: 0,
1832
+ output_tokens: 0,
1833
+ reasoning_output_tokens: 0,
1834
+ total_tokens: 0,
1835
+ conversation_count: 0,
1836
+ }),
1837
+ );
1838
+ delete buckets[key];
1839
+ }
1840
+
1841
+ if (retractions.length > 0) {
1842
+ await ensureDir(path.dirname(queuePath));
1843
+ await fs.appendFile(queuePath, retractions.join("\n") + "\n");
1844
+ if (cursors.cursorApi) {
1845
+ cursors.cursorApi.lastRecordTimestamp = null;
1846
+ }
1847
+ }
1848
+
1849
+ cursors.migrations[CURSOR_UNKNOWN_MIGRATION_KEY] = new Date().toISOString();
1850
+ }
1851
+
1852
+ async function migrateRolloutCumulativeDeltaBuckets({ cursors, queuePath, rolloutFiles }) {
1853
+ if (!cursors || typeof cursors !== "object") return;
1854
+ cursors.migrations = cursors.migrations || {};
1855
+ if (cursors.migrations[ROLLOUT_CUMULATIVE_DELTA_MIGRATION_KEY]) return;
1856
+
1857
+ const rolloutPathSources = new Map();
1858
+ for (const entry of Array.isArray(rolloutFiles) ? rolloutFiles : []) {
1859
+ const filePath = typeof entry === "string" ? entry : entry?.path;
1860
+ const source = typeof entry === "string" ? "codex" : String(entry?.source || "codex");
1861
+ if (!filePath) continue;
1862
+ if (source === "codex" || source === "every-code") {
1863
+ rolloutPathSources.set(filePath, source);
1864
+ }
1865
+ }
1866
+
1867
+ if (cursors.files && typeof cursors.files === "object") {
1868
+ for (const filePath of rolloutPathSources.keys()) {
1869
+ delete cursors.files[filePath];
1870
+ }
1871
+ }
1872
+
1873
+ const buckets = cursors.hourly?.buckets;
1874
+ const retractions = [];
1875
+ if (buckets && typeof buckets === "object") {
1876
+ for (const key of Object.keys(buckets)) {
1877
+ const [source, model, ...hourParts] = key.split("|");
1878
+ if (source !== "codex" && source !== "every-code") continue;
1879
+ const hourStart = hourParts.join("|");
1880
+ retractions.push(
1881
+ JSON.stringify({
1882
+ source,
1883
+ model: model || "unknown",
1884
+ hour_start: hourStart,
1885
+ input_tokens: 0,
1886
+ cached_input_tokens: 0,
1887
+ cache_creation_input_tokens: 0,
1888
+ output_tokens: 0,
1889
+ reasoning_output_tokens: 0,
1890
+ total_tokens: 0,
1891
+ billable_total_tokens: 0,
1892
+ conversation_count: 0,
1893
+ }),
1894
+ );
1895
+ delete buckets[key];
1896
+ }
1897
+ }
1898
+
1899
+ const groupQueued = cursors.hourly?.groupQueued;
1900
+ if (groupQueued && typeof groupQueued === "object") {
1901
+ for (const key of Object.keys(groupQueued)) {
1902
+ if (key.startsWith("codex|") || key.startsWith("every-code|")) {
1903
+ delete groupQueued[key];
1904
+ }
1905
+ }
1906
+ }
1907
+
1908
+ if (retractions.length > 0) {
1909
+ await ensureDir(path.dirname(queuePath));
1910
+ await fs.appendFile(queuePath, retractions.join("\n") + "\n");
1911
+ }
1912
+
1913
+ cursors.migrations[ROLLOUT_CUMULATIVE_DELTA_MIGRATION_KEY] = new Date().toISOString();
1914
+ }
1915
+
1916
+ // One-time repair migration: rebuild source=claude rows in queue.jsonl from
1917
+ // the actual jsonl files using ccusage's algorithm (msgId+reqId global
1918
+ // dedup). Earlier `reincludeClaudeMemObserverFiles` versions (v1/v2/v3) each
1919
+ // reset the hash set and re-read observer jsonls, which silently inflated
1920
+ // queue.jsonl's claude totals by ~40%. We do an atomic rewrite — keep all
1921
+ // non-claude rows verbatim, replace every claude/claude-mem row with the
1922
+ // ground-truth set — then reset cursors so the next incremental sync stays
1923
+ // in sync, and reset the cloud upload offset so the corrected rows actually
1924
+ // reach the cloud (the ingest endpoint upserts by (source, model,
1925
+ // hour_start), so re-uploading other sources is idempotent).
1926
+ async function repairClaudeQueueFromGroundTruth({
1927
+ cursors,
1928
+ queuePath,
1929
+ queueStatePath = null,
1930
+ projectQueuePath = null,
1931
+ projectQueueStatePath = null,
1932
+ }) {
1933
+ if (!cursors || typeof cursors !== "object") return false;
1934
+ const migrations = (cursors.migrations ||= {});
1935
+ if (migrations[CLAUDE_GROUND_TRUTH_REPAIR_KEY]) return false;
1936
+
1937
+ let result;
1938
+ try {
1939
+ result = await computeClaudeGroundTruthBuckets();
1940
+ } catch (e) {
1941
+ console.error("[sync] claude ground-truth repair: scan failed:", e?.message || e);
1942
+ return false;
1943
+ }
1944
+ const { rows, seenHashes, fileList } = result;
1945
+
1946
+ // 1. Atomic rewrite of queue.jsonl: keep non-claude rows, drop existing
1947
+ // claude/claude-mem rows, append truth rows. Atomic via tmp + rename.
1948
+ let claudeRowsRemoved = 0;
1949
+ if (typeof queuePath === "string" && queuePath) {
1950
+ let raw = "";
1951
+ try {
1952
+ raw = await fs.readFile(queuePath, "utf8");
1953
+ } catch (e) {
1954
+ if (e?.code !== "ENOENT") throw e;
1955
+ }
1956
+ const keptLines = [];
1957
+ for (const line of raw.split("\n")) {
1958
+ if (!line.trim()) continue;
1959
+ let row;
1960
+ try {
1961
+ row = JSON.parse(line);
1962
+ } catch (_e) {
1963
+ // Preserve unparseable lines verbatim — operator may want to
1964
+ // recover them later.
1965
+ keptLines.push(line);
1966
+ continue;
1967
+ }
1968
+ if (row?.source === "claude" || row?.source === "claude-mem") {
1969
+ claudeRowsRemoved += 1;
1970
+ continue;
1971
+ }
1972
+ keptLines.push(line);
1973
+ }
1974
+
1975
+ const truthLines = rows.map((r) =>
1976
+ JSON.stringify({
1977
+ source: "claude",
1978
+ model: r.model,
1979
+ hour_start: r.hour_start,
1980
+ input_tokens: r.input_tokens,
1981
+ cached_input_tokens: r.cached_input_tokens,
1982
+ cache_creation_input_tokens: r.cache_creation_input_tokens,
1983
+ output_tokens: r.output_tokens,
1984
+ reasoning_output_tokens: r.reasoning_output_tokens,
1985
+ total_tokens: r.total_tokens,
1986
+ billable_total_tokens: r.billable_total_tokens,
1987
+ conversation_count: r.conversation_count,
1988
+ }),
1989
+ );
1990
+
1991
+ await ensureDir(path.dirname(queuePath));
1992
+ const out = keptLines.concat(truthLines).join("\n") + "\n";
1993
+ const tmp = `${queuePath}.tmp.${process.pid}.${Date.now()}`;
1994
+ await fs.writeFile(tmp, out, "utf8");
1995
+ await fs.rename(tmp, queuePath);
1996
+ }
1997
+
1998
+ // 2. Reset cursors.hourly.buckets / groupQueued for source=claude (and the
1999
+ // dead source=claude-mem buckets) so incremental sync's in-memory state
2000
+ // matches the truth.
2001
+ const hourly = (cursors.hourly ||= { buckets: {}, groupQueued: {} });
2002
+ hourly.buckets ||= {};
2003
+ hourly.groupQueued ||= {};
2004
+
2005
+ let bucketsCleared = 0;
2006
+ for (const k of Object.keys(hourly.buckets)) {
2007
+ if (k.startsWith("claude|") || k.startsWith("claude-mem|")) {
2008
+ delete hourly.buckets[k];
2009
+ bucketsCleared += 1;
2010
+ }
2011
+ }
2012
+ // Clear stale claude entries from groupQueued (left over by v2 repair).
2013
+ // After v3 we never repopulate it for claude, so nothing should be added
2014
+ // back during the per-model write loop below.
2015
+ for (const k of Object.keys(hourly.groupQueued)) {
2016
+ if (k.startsWith("claude|") || k.startsWith("claude-mem|")) {
2017
+ delete hourly.groupQueued[k];
2018
+ }
2019
+ }
2020
+
2021
+ // Per-model claude buckets: set queuedKey but DO NOT touch
2022
+ // hourly.groupQueued. groupQueued is used by enqueueTouchedBuckets to
2023
+ // mark a (source, hour) as legacy-aggregate state; writing claude hours
2024
+ // there would force every later sync to re-emit the hour as a single
2025
+ // model=DEFAULT_MODEL aggregate row instead of touching only the bucket
2026
+ // that actually changed. The original v2 release did write groupQueued
2027
+ // here and was the cause of an unknown-bucket inflation regression.
2028
+ for (const r of rows) {
2029
+ const totals = {
2030
+ input_tokens: r.input_tokens,
2031
+ cached_input_tokens: r.cached_input_tokens,
2032
+ cache_creation_input_tokens: r.cache_creation_input_tokens,
2033
+ output_tokens: r.output_tokens,
2034
+ reasoning_output_tokens: r.reasoning_output_tokens,
2035
+ total_tokens: r.total_tokens,
2036
+ billable_total_tokens: r.billable_total_tokens,
2037
+ conversation_count: r.conversation_count,
2038
+ };
2039
+ const key = bucketKey("claude", r.model, r.hour_start);
2040
+ hourly.buckets[key] = {
2041
+ totals,
2042
+ queuedKey: totalsKey(totals),
2043
+ source: "claude",
2044
+ hour_start: r.hour_start,
2045
+ };
2046
+ }
2047
+
2048
+ // 3. Reset per-file cursors so future incremental sync only reads genuinely
2049
+ // new tail content. Format must match what rollout.js expects:
2050
+ // { inode, offset, updatedAt }. Setting a plain integer here breaks
2051
+ // the inode-equality check inside parseClaudeFile, which would treat
2052
+ // the file as untracked and re-read it from byte 0 — silently doubling
2053
+ // everything. (That was the actual cause of the regression after the
2054
+ // first repair attempt.)
2055
+ cursors.files ||= {};
2056
+ let filesReset = 0;
2057
+ const nowIso = new Date().toISOString();
2058
+ for (const fp of fileList) {
2059
+ let st;
2060
+ try {
2061
+ st = fssync.statSync(fp);
2062
+ } catch (_e) {
2063
+ continue;
2064
+ }
2065
+ cursors.files[fp] = {
2066
+ inode: st.ino || 0,
2067
+ offset: st.size,
2068
+ updatedAt: nowIso,
2069
+ };
2070
+ filesReset += 1;
2071
+ }
2072
+ cursors.claudeHashes = seenHashes;
2073
+
2074
+ // 4. Reset cloud-upload offset so the corrected rows are re-sent. Other
2075
+ // sources are upserted idempotently by the ingest endpoint, so this is
2076
+ // safe — just costs one extra round of bandwidth.
2077
+ if (typeof queueStatePath === "string" && queueStatePath) {
2078
+ let uploadState = {};
2079
+ try {
2080
+ uploadState = JSON.parse(await fs.readFile(queueStatePath, "utf8"));
2081
+ } catch (_e) {
2082
+ uploadState = {};
2083
+ }
2084
+ uploadState.offset = 0;
2085
+ uploadState.updatedAt = new Date().toISOString();
2086
+ uploadState.note = "reset_after_claude_repair_2026_05_v4";
2087
+ await fs.writeFile(queueStatePath, JSON.stringify(uploadState));
2088
+ }
2089
+
2090
+ // 5. Repair project queue. Historical claude rows in project.queue.jsonl
2091
+ // were uniformly mis-attributed to project_key=
2092
+ // "claude-mem/observer-sessions" (left over from the observer
2093
+ // relabel migration). We can't reconstruct the true cwd-based
2094
+ // project_key for each historical message reliably, so we drop every
2095
+ // claude/claude-mem row from project.queue.jsonl and reset the
2096
+ // matching cursors.projectHourly state. New claude usage will
2097
+ // accumulate to the correct cwd-derived project_key going forward.
2098
+ let projectRowsRemoved = 0;
2099
+ let projectBucketsCleared = 0;
2100
+ if (typeof projectQueuePath === "string" && projectQueuePath) {
2101
+ let projRaw = "";
2102
+ try {
2103
+ projRaw = await fs.readFile(projectQueuePath, "utf8");
2104
+ } catch (e) {
2105
+ if (e?.code !== "ENOENT") throw e;
2106
+ }
2107
+ if (projRaw) {
2108
+ const projKept = [];
2109
+ for (const line of projRaw.split("\n")) {
2110
+ if (!line.trim()) continue;
2111
+ let row;
2112
+ try {
2113
+ row = JSON.parse(line);
2114
+ } catch (_e) {
2115
+ projKept.push(line);
2116
+ continue;
2117
+ }
2118
+ if (row?.source === "claude" || row?.source === "claude-mem") {
2119
+ projectRowsRemoved += 1;
2120
+ continue;
2121
+ }
2122
+ projKept.push(line);
2123
+ }
2124
+ await ensureDir(path.dirname(projectQueuePath));
2125
+ const tmp = `${projectQueuePath}.tmp.${process.pid}.${Date.now()}`;
2126
+ await fs.writeFile(tmp, projKept.join("\n") + "\n", "utf8");
2127
+ await fs.rename(tmp, projectQueuePath);
2128
+ }
2129
+
2130
+ // Clear matching projectHourly state so the claude project buckets
2131
+ // start fresh.
2132
+ const projHourly = (cursors.projectHourly ||= { buckets: {} });
2133
+ projHourly.buckets ||= {};
2134
+ for (const k of Object.keys(projHourly.buckets)) {
2135
+ const v = projHourly.buckets[k];
2136
+ const src = v?.source || "";
2137
+ if (src === "claude" || src === "claude-mem") {
2138
+ delete projHourly.buckets[k];
2139
+ projectBucketsCleared += 1;
2140
+ }
2141
+ }
2142
+
2143
+ // Reset project upload offset.
2144
+ if (typeof projectQueueStatePath === "string" && projectQueueStatePath) {
2145
+ let st = {};
2146
+ try {
2147
+ st = JSON.parse(await fs.readFile(projectQueueStatePath, "utf8"));
2148
+ } catch (_e) {
2149
+ st = {};
2150
+ }
2151
+ st.offset = 0;
2152
+ st.updatedAt = new Date().toISOString();
2153
+ st.note = "reset_after_claude_repair_2026_05_v6";
2154
+ await fs.writeFile(projectQueueStatePath, JSON.stringify(st));
2155
+ }
2156
+ }
2157
+
2158
+ migrations[CLAUDE_GROUND_TRUTH_REPAIR_KEY] = {
2159
+ appliedAt: new Date().toISOString(),
2160
+ bucketsWritten: rows.length,
2161
+ bucketsCleared,
2162
+ rowsRemoved: claudeRowsRemoved,
2163
+ filesReset,
2164
+ hashesRetained: seenHashes.length,
2165
+ uploadOffsetReset: typeof queueStatePath === "string" && !!queueStatePath,
2166
+ projectRowsRemoved,
2167
+ projectBucketsCleared,
2168
+ };
2169
+ return true;
2170
+ }
2171
+
2172
+ async function reincludeClaudeMemObserverFiles({ cursors, claudeFiles, queuePath }) {
2173
+ if (!cursors || typeof cursors !== "object") return false;
2174
+ const migrations = (cursors.migrations ||= {});
2175
+ if (migrations[CLAUDE_MEM_OBSERVER_REINCLUDE_KEY]) return false;
2176
+
2177
+ const observerPaths = (Array.isArray(claudeFiles) ? claudeFiles : [])
2178
+ .map((entry) => (typeof entry === "string" ? entry : entry?.path))
2179
+ .filter((p) => typeof p === "string" && p.includes(CLAUDE_MEM_OBSERVER_PATH_SEGMENT));
2180
+
2181
+ if (!cursors.files || typeof cursors.files !== "object") {
2182
+ cursors.files = {};
2183
+ }
2184
+
2185
+ let filesReset = 0;
2186
+ for (const filePath of observerPaths) {
2187
+ if (cursors.files[filePath]) {
2188
+ delete cursors.files[filePath];
2189
+ filesReset += 1;
2190
+ }
2191
+ }
2192
+
2193
+ const hashesToRemove = observerPaths.length > 0
2194
+ ? await collectClaudeMessageHashes(observerPaths)
2195
+ : new Set();
2196
+ let hashesRemoved = 0;
2197
+ if (Array.isArray(cursors.claudeHashes) && hashesToRemove.size > 0) {
2198
+ const nextHashes = [];
2199
+ for (const hash of cursors.claudeHashes) {
2200
+ if (hashesToRemove.has(hash)) {
2201
+ hashesRemoved += 1;
2202
+ continue;
2203
+ }
2204
+ nextHashes.push(hash);
2205
+ }
2206
+ cursors.claudeHashes = nextHashes;
2207
+ }
2208
+
2209
+ const queueRowsRelabeled = typeof queuePath === "string" && queuePath
2210
+ ? await relabelClaudeMemQueueRows(queuePath)
2211
+ : 0;
2212
+
2213
+ migrations[CLAUDE_MEM_OBSERVER_REINCLUDE_KEY] = {
2214
+ appliedAt: new Date().toISOString(),
2215
+ filesReset,
2216
+ hashesRemoved,
2217
+ queueRowsRelabeled,
2218
+ };
2219
+ return filesReset > 0 || hashesRemoved > 0 || queueRowsRelabeled > 0;
2220
+ }
2221
+
2222
+ async function relabelClaudeMemQueueRows(queuePath) {
2223
+ let raw;
2224
+ try {
2225
+ raw = await fs.readFile(queuePath, "utf8");
2226
+ } catch (_e) {
2227
+ return 0;
2228
+ }
2229
+ if (!raw || !raw.includes('"claude-mem"')) return 0;
2230
+
2231
+ const lines = raw.split("\n");
2232
+ const out = [];
2233
+ let relabeled = 0;
2234
+ for (const line of lines) {
2235
+ if (!line) {
2236
+ out.push(line);
2237
+ continue;
2238
+ }
2239
+ let obj;
2240
+ try {
2241
+ obj = JSON.parse(line);
2242
+ } catch (_e) {
2243
+ out.push(line);
2244
+ continue;
2245
+ }
2246
+ if (obj && obj.source === "claude-mem") {
2247
+ obj.source = "claude";
2248
+ relabeled += 1;
2249
+ out.push(JSON.stringify(obj));
2250
+ } else {
2251
+ out.push(line);
2252
+ }
2253
+ }
2254
+ if (relabeled === 0) return 0;
2255
+
2256
+ await fs.writeFile(queuePath, out.join("\n"), "utf8");
2257
+ return relabeled;
2258
+ }
2259
+
2260
+ async function collectClaudeMessageHashes(filePaths) {
2261
+ const hashes = new Set();
2262
+ for (const filePath of filePaths) {
2263
+ let stream;
2264
+ try {
2265
+ stream = fssync.createReadStream(filePath, { encoding: "utf8" });
2266
+ } catch (_e) {
2267
+ continue;
2268
+ }
2269
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
2270
+ for await (const line of rl) {
2271
+ if (!line.includes('"usage"')) continue;
2272
+ let obj;
2273
+ try {
2274
+ obj = JSON.parse(line);
2275
+ } catch (_e) {
2276
+ continue;
2277
+ }
2278
+ const hash = claudeMessageDedupKey(obj);
2279
+ if (hash) hashes.add(hash);
2280
+ }
2281
+ rl.close();
2282
+ stream.close?.();
2283
+ }
2284
+ return hashes;
2285
+ }