tokentracker-cli 0.21.3 → 0.22.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 (48) hide show
  1. package/README.ja.md +457 -0
  2. package/README.ko.md +457 -0
  3. package/README.md +45 -6
  4. package/README.zh-CN.md +45 -6
  5. package/dashboard/dist/assets/{Card-Cv4wn6W8.js → Card-CD18G4Ge.js} +1 -1
  6. package/dashboard/dist/assets/DashboardPage-DKY_Mi9v.js +64 -0
  7. package/dashboard/dist/assets/DevicePage-BkavlAal.js +1 -0
  8. package/dashboard/dist/assets/{FadeIn-DjQyRfLZ.js → FadeIn-CVNJ4aZy.js} +1 -1
  9. package/dashboard/dist/assets/{HeaderGithubStar-D2BjLT1b.js → HeaderGithubStar-COu1Xy3I.js} +1 -1
  10. package/dashboard/dist/assets/{IpCheckPage-D0uvbHPe.js → IpCheckPage-B2HjZ3vY.js} +1 -1
  11. package/dashboard/dist/assets/{LandingPage-DGJcVAg7.js → LandingPage-9PSLFnys.js} +1 -1
  12. package/dashboard/dist/assets/{LeaderboardPage-Dnt_YLsP.js → LeaderboardPage-CQT5dBHU.js} +1 -1
  13. package/dashboard/dist/assets/{LeaderboardProfilePage-DM7S9_kG.js → LeaderboardProfilePage-aPP-Raey.js} +1 -1
  14. package/dashboard/dist/assets/{LimitsPage-COomwRa6.js → LimitsPage-eFrAHmoA.js} +2 -2
  15. package/dashboard/dist/assets/{LoginPage-k0k50kws.js → LoginPage-CczTNZ_P.js} +1 -1
  16. package/dashboard/dist/assets/{PopoverPopup-DctOj5-q.js → PopoverPopup-CEvWSWgZ.js} +2 -2
  17. package/dashboard/dist/assets/{ProviderIcon-DGlYzr9I.js → ProviderIcon-ewev19y3.js} +1 -1
  18. package/dashboard/dist/assets/SettingsPage-taBxq6ux.js +1 -0
  19. package/dashboard/dist/assets/SkillsPage-cqHO3rMB.js +1 -0
  20. package/dashboard/dist/assets/{WidgetsPage-DsMj8Qcz.js → WidgetsPage-B53b1hwG.js} +1 -1
  21. package/dashboard/dist/assets/WrappedPage-DwAhprTa.js +1 -0
  22. package/dashboard/dist/assets/check-JnFJsHgI.js +1 -0
  23. package/dashboard/dist/assets/{chevron-down-kcaroSaH.js → chevron-down-zOKEzHdv.js} +1 -1
  24. package/dashboard/dist/assets/{download-DKMK6oF8.js → download-BZZ4vKc1.js} +1 -1
  25. package/dashboard/dist/assets/{leaderboard-columns-BZ06dD2h.js → leaderboard-columns-BNGlMUsD.js} +1 -1
  26. package/dashboard/dist/assets/main-A_x5MMU-.css +1 -0
  27. package/dashboard/dist/assets/{main-DKVBnAOd.js → main-D0Irg9xR.js} +62 -17
  28. package/dashboard/dist/assets/{use-limits-display-prefs-Bx-K-27B.js → use-limits-display-prefs-BTuSZo27.js} +1 -1
  29. package/dashboard/dist/assets/{use-native-settings-DtuifRKC.js → use-native-settings-BPXVZrWe.js} +1 -1
  30. package/dashboard/dist/assets/{use-reduced-motion-Cen-UCKO.js → use-reduced-motion-HUOV_JD1.js} +1 -1
  31. package/dashboard/dist/assets/{use-usage-limits-CAWz6ijv.js → use-usage-limits-G6-vCBcN.js} +1 -1
  32. package/dashboard/dist/index.html +2 -2
  33. package/dashboard/dist/share.html +2 -2
  34. package/package.json +3 -2
  35. package/src/cli.js +11 -0
  36. package/src/commands/device-login.js +161 -0
  37. package/src/commands/status.js +199 -1
  38. package/src/commands/sync.js +85 -2
  39. package/src/commands/wrapped.js +150 -0
  40. package/src/lib/local-api.js +37 -2
  41. package/src/lib/passive-mode.js +185 -0
  42. package/src/lib/pricing/seed-snapshot.json +1 -1
  43. package/src/lib/rollout.js +913 -0
  44. package/src/lib/wrapped-aggregator.js +225 -0
  45. package/dashboard/dist/assets/DashboardPage-DsfcNgai.js +0 -1
  46. package/dashboard/dist/assets/SettingsPage-D2sqM9g_.js +0 -1
  47. package/dashboard/dist/assets/SkillsPage-B8K--edc.js +0 -1
  48. package/dashboard/dist/assets/main-DX38hz5f.css +0 -1
@@ -4840,6 +4840,905 @@ function normalizeKilocodeProviderToModel(providerName) {
4840
4840
  return `provider:${slug}`;
4841
4841
  }
4842
4842
 
4843
+ // ─────────────────────────────────────────────────────────────────────────────
4844
+ // Roo Code (rooveterinaryinc.roo-cline)
4845
+ //
4846
+ // Same Cline-derived ui_messages.json format as Kilo Code, but two real
4847
+ // differences worth noting:
4848
+ //
4849
+ // 1. The model name is NOT in the per-turn payload (Roo Code only writes
4850
+ // provider via `apiProtocol`). It lives in a sibling
4851
+ // `api_conversation_history.json` inside `<environment_details>` blocks:
4852
+ //
4853
+ // <environment_details>
4854
+ // <model>claude-3-7-sonnet-20250219</model>
4855
+ // </environment_details>
4856
+ //
4857
+ // We read the most recent occurrence — Roo can switch models mid-task,
4858
+ // so the last-seen value is the most accurate attribution; if the file
4859
+ // or tag is missing we fall back to `protocol:<apiProtocol>` (e.g.
4860
+ // `protocol:anthropic`) and finally to "unknown".
4861
+ //
4862
+ // 2. Same multi-IDE root scan as Kilo Code (Cursor, Code, CodeBuddy, …) —
4863
+ // we reuse resolveKilocodeRoots so both parsers stay in sync when a new
4864
+ // VS Code fork ships.
4865
+ // ─────────────────────────────────────────────────────────────────────────────
4866
+
4867
+ function resolveRoocodeTaskFiles(env = process.env) {
4868
+ const roots = resolveKilocodeRoots(env);
4869
+ const out = [];
4870
+ for (const root of roots) {
4871
+ const tasksDir = path.join(root, "User", "globalStorage", "rooveterinaryinc.roo-cline", "tasks");
4872
+ if (!fssync.existsSync(tasksDir)) continue;
4873
+ let entries;
4874
+ try { entries = fssync.readdirSync(tasksDir); } catch { continue; }
4875
+ for (const taskUuid of entries) {
4876
+ const filePath = path.join(tasksDir, taskUuid, "ui_messages.json");
4877
+ if (!fssync.existsSync(filePath)) continue;
4878
+ out.push({ filePath, taskUuid, ide: path.basename(root) });
4879
+ }
4880
+ }
4881
+ out.sort((a, b) => a.filePath.localeCompare(b.filePath));
4882
+ return out;
4883
+ }
4884
+
4885
+ // Pull the most recent <model>…</model> from a Roo Code task's
4886
+ // api_conversation_history.json (each Cline turn appends a fresh
4887
+ // <environment_details> block). Returns null when the sibling file is
4888
+ // missing, unreadable, or contains no tag. Bounded to first 1MB to avoid
4889
+ // pathological history files starving sync.
4890
+ function readRoocodeTaskModel(uiMessagesPath) {
4891
+ const historyPath = path.join(path.dirname(uiMessagesPath), "api_conversation_history.json");
4892
+ let raw;
4893
+ try { raw = fssync.readFileSync(historyPath, "utf8"); } catch { return null; }
4894
+ if (raw.length > 1_048_576) {
4895
+ // Naive `slice(raw.length - 1MB)` can split a `<environment_details>`
4896
+ // block mid-tag — e.g. the keep window starts at "...<mod" so the
4897
+ // regex finds nothing and we fall back to "unknown". Align the cut
4898
+ // to the first `<environment_details>` start in the keep window so
4899
+ // every retained tag is intact.
4900
+ const naive = raw.slice(raw.length - 1_048_576);
4901
+ const blockStart = naive.indexOf("<environment_details>");
4902
+ raw = blockStart >= 0 ? naive.slice(blockStart) : naive;
4903
+ }
4904
+ let lastModel = null;
4905
+ const re = /<model>\s*([^<\s][^<]*?)\s*<\/model>/g;
4906
+ let m;
4907
+ while ((m = re.exec(raw)) !== null) {
4908
+ const value = m[1].trim();
4909
+ if (value) lastModel = value;
4910
+ }
4911
+ return lastModel;
4912
+ }
4913
+
4914
+ function normalizeRoocodeModel({ explicitModel, apiProtocol }) {
4915
+ const trimmed = typeof explicitModel === "string" ? explicitModel.trim() : "";
4916
+ if (trimmed) return trimmed;
4917
+ if (typeof apiProtocol === "string" && apiProtocol.trim()) {
4918
+ const slug = apiProtocol.trim().toLowerCase().replace(/[^a-z0-9._-]/g, "");
4919
+ if (slug) return `protocol:${slug}`;
4920
+ }
4921
+ return "unknown";
4922
+ }
4923
+
4924
+ async function parseRoocodeIncremental({
4925
+ taskFiles,
4926
+ cursors,
4927
+ queuePath,
4928
+ onProgress,
4929
+ env,
4930
+ } = {}) {
4931
+ await ensureDir(path.dirname(queuePath));
4932
+ const roocodeState =
4933
+ cursors.roocode && typeof cursors.roocode === "object" ? cursors.roocode : {};
4934
+ const seenIds = new Set(
4935
+ Array.isArray(roocodeState.seenIds) ? roocodeState.seenIds : [],
4936
+ );
4937
+ const fileOffsets =
4938
+ roocodeState.fileOffsets && typeof roocodeState.fileOffsets === "object"
4939
+ ? { ...roocodeState.fileOffsets }
4940
+ : {};
4941
+
4942
+ const files = Array.isArray(taskFiles)
4943
+ ? taskFiles
4944
+ : resolveRoocodeTaskFiles(env || process.env);
4945
+
4946
+ if (files.length === 0) {
4947
+ cursors.roocode = {
4948
+ ...roocodeState,
4949
+ seenIds: Array.from(seenIds),
4950
+ fileOffsets,
4951
+ updatedAt: new Date().toISOString(),
4952
+ };
4953
+ return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
4954
+ }
4955
+
4956
+ const hourlyState = normalizeHourlyState(cursors?.hourly);
4957
+ const touchedBuckets = new Set();
4958
+ const cb = typeof onProgress === "function" ? onProgress : null;
4959
+ let recordsProcessed = 0;
4960
+ let eventsAggregated = 0;
4961
+
4962
+ for (let fileIdx = 0; fileIdx < files.length; fileIdx++) {
4963
+ const entry = files[fileIdx];
4964
+ const { filePath, taskUuid } = entry;
4965
+ let stat;
4966
+ try { stat = fssync.statSync(filePath); } catch { continue; }
4967
+
4968
+ const prevEntry = fileOffsets[filePath];
4969
+ if (
4970
+ prevEntry &&
4971
+ Number(prevEntry.size) === stat.size &&
4972
+ Number(prevEntry.mtimeMs) === stat.mtimeMs
4973
+ ) {
4974
+ continue;
4975
+ }
4976
+
4977
+ let raw;
4978
+ try { raw = fssync.readFileSync(filePath, "utf8"); } catch { continue; }
4979
+ let data;
4980
+ try { data = JSON.parse(raw); } catch { continue; }
4981
+ if (!Array.isArray(data)) continue;
4982
+
4983
+ // Read sibling history once per task — model can change mid-task but is
4984
+ // stable enough at this granularity that re-reading on every entry would
4985
+ // just burn IO. Task attribution at the bucket layer is hourly anyway.
4986
+ const taskModel = readRoocodeTaskModel(filePath);
4987
+
4988
+ for (const msg of data) {
4989
+ if (!msg || typeof msg !== "object") continue;
4990
+ // Like Kilo Code, accept both api_req_started (live) and api_req_deleted
4991
+ // (user-removed turn whose tokens were already consumed).
4992
+ if (msg.say !== "api_req_started" && msg.say !== "api_req_deleted") continue;
4993
+ if (typeof msg.text !== "string" || !msg.text.startsWith("{")) continue;
4994
+
4995
+ let payload;
4996
+ try { payload = JSON.parse(msg.text); } catch { continue; }
4997
+ if (!payload || typeof payload !== "object") continue;
4998
+
4999
+ const ts = Number(msg.ts);
5000
+ if (!Number.isFinite(ts) || ts <= 0) continue;
5001
+
5002
+ const dedupKey = `${taskUuid}:${ts}`;
5003
+ recordsProcessed++;
5004
+ if (seenIds.has(dedupKey)) continue;
5005
+
5006
+ const tokensIn = toNonNegativeInt(payload.tokensIn);
5007
+ const tokensOut = toNonNegativeInt(payload.tokensOut);
5008
+ const cacheReads = toNonNegativeInt(payload.cacheReads);
5009
+ const cacheWrites = toNonNegativeInt(payload.cacheWrites);
5010
+ if (tokensIn === 0 && tokensOut === 0 && cacheReads === 0 && cacheWrites === 0) {
5011
+ seenIds.add(dedupKey);
5012
+ continue;
5013
+ }
5014
+
5015
+ const tsIso = new Date(ts).toISOString();
5016
+ const bucketStart = toUtcHalfHourStart(tsIso);
5017
+ if (!bucketStart) continue;
5018
+
5019
+ const delta = {
5020
+ input_tokens: tokensIn,
5021
+ cached_input_tokens: cacheReads,
5022
+ cache_creation_input_tokens: cacheWrites,
5023
+ output_tokens: tokensOut,
5024
+ reasoning_output_tokens: 0,
5025
+ total_tokens: tokensIn + tokensOut + cacheReads + cacheWrites,
5026
+ conversation_count: 1,
5027
+ };
5028
+
5029
+ const model = normalizeRoocodeModel({
5030
+ explicitModel: taskModel,
5031
+ apiProtocol: payload.apiProtocol,
5032
+ });
5033
+ const bucket = getHourlyBucket(hourlyState, "roocode", model, bucketStart);
5034
+ addTotals(bucket.totals, delta);
5035
+ touchedBuckets.add(bucketKey("roocode", model, bucketStart));
5036
+ seenIds.add(dedupKey);
5037
+ eventsAggregated++;
5038
+ }
5039
+
5040
+ fileOffsets[filePath] = { size: stat.size, mtimeMs: stat.mtimeMs, ino: stat.ino };
5041
+
5042
+ if (cb) {
5043
+ cb({
5044
+ index: fileIdx + 1,
5045
+ total: files.length,
5046
+ recordsProcessed,
5047
+ eventsAggregated,
5048
+ bucketsQueued: touchedBuckets.size,
5049
+ });
5050
+ }
5051
+ }
5052
+
5053
+ const seenArr = Array.from(seenIds);
5054
+ const cappedSeen = seenArr.length > 50_000 ? seenArr.slice(seenArr.length - 50_000) : seenArr;
5055
+
5056
+ const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets });
5057
+ const updatedAt = new Date().toISOString();
5058
+ hourlyState.updatedAt = updatedAt;
5059
+ cursors.hourly = hourlyState;
5060
+ cursors.roocode = { ...roocodeState, seenIds: cappedSeen, fileOffsets, updatedAt };
5061
+
5062
+ return { recordsProcessed, eventsAggregated, bucketsQueued };
5063
+ }
5064
+
5065
+ // ─────────────────────────────────────────────────────────────────────────────
5066
+ // Zed Agent (hosted models only, provider == "zed.dev")
5067
+ //
5068
+ // Data: SQLite at
5069
+ // macOS: ~/Library/Application Support/Zed/threads/threads.db
5070
+ // Linux: $XDG_DATA_HOME/zed/threads/threads.db (defaults to ~/.local/share)
5071
+ // Windows: %LOCALAPPDATA%\Zed\threads\threads.db
5072
+ //
5073
+ // `threads` table stores one row per thread with a BLOB `data` column —
5074
+ // either raw JSON or zstd-compressed JSON (governed by `data_type`). Each
5075
+ // thread's JSON carries `cumulative_token_usage` and/or
5076
+ // `request_token_usage` (a map or array of per-request usages with
5077
+ // input_tokens / output_tokens / cache_read_input_tokens /
5078
+ // cache_creation_input_tokens).
5079
+ //
5080
+ // Threads grow over multiple turns — the row is rewritten with a larger
5081
+ // cumulative on every send, so naive dedup-by-id would freeze our count at
5082
+ // whatever the thread looked like the first time we saw it. We mirror the
5083
+ // antigravity cumulative-delta pattern: keep last-seen totals per thread in
5084
+ // `cursors.zed.threadTotals`, emit (current - previous) on each sync.
5085
+ //
5086
+ // External ACP agents are skipped (their CLIs already report to us through
5087
+ // their own parsers — counting them via the Zed UI would double-count).
5088
+ // ─────────────────────────────────────────────────────────────────────────────
5089
+
5090
+ const ZED_HOSTED_PROVIDER = "zed.dev";
5091
+ const MAX_ZED_THREAD_JSON_BYTES = 32 * 1024 * 1024;
5092
+
5093
+ function resolveZedDbPath(env = process.env) {
5094
+ if (typeof env.TOKENTRACKER_ZED_DB === "string" && env.TOKENTRACKER_ZED_DB.trim()) {
5095
+ return env.TOKENTRACKER_ZED_DB.trim();
5096
+ }
5097
+ const home = env.HOME || require("node:os").homedir();
5098
+ if (process.platform === "darwin") {
5099
+ return path.join(home, "Library", "Application Support", "Zed", "threads", "threads.db");
5100
+ }
5101
+ if (process.platform === "win32") {
5102
+ const local = env.LOCALAPPDATA || path.join(home, "AppData", "Local");
5103
+ return path.join(local, "Zed", "threads", "threads.db");
5104
+ }
5105
+ const xdg = env.XDG_DATA_HOME || path.join(home, ".local", "share");
5106
+ return path.join(xdg, "zed", "threads", "threads.db");
5107
+ }
5108
+
5109
+ // Decode a row's BLOB payload into UTF-8 JSON text. Zed marks zstd-compressed
5110
+ // blobs with data_type="zstd"; older / smaller threads use data_type="json"
5111
+ // and store the bytes verbatim. Node 24+ has native zstd; Node 20 needs the
5112
+ // @mongodb-js/zstd fallback. Cap decoded size to mirror tokscale's safety net.
5113
+ async function decodeZedThreadBlob({ dataType, data }) {
5114
+ const type = (dataType || "").trim().toLowerCase();
5115
+ if (type === "json") {
5116
+ if (data.length > MAX_ZED_THREAD_JSON_BYTES) {
5117
+ throw new Error(`json blob exceeds ${MAX_ZED_THREAD_JSON_BYTES} bytes`);
5118
+ }
5119
+ return data.toString("utf8");
5120
+ }
5121
+ if (type === "zstd") {
5122
+ const zlib = require("node:zlib");
5123
+ const out =
5124
+ typeof zlib.zstdDecompressSync === "function"
5125
+ ? zlib.zstdDecompressSync(data)
5126
+ : Buffer.from(await require("@mongodb-js/zstd").decompress(data));
5127
+ if (out.length > MAX_ZED_THREAD_JSON_BYTES) {
5128
+ throw new Error(`decoded zstd blob exceeds ${MAX_ZED_THREAD_JSON_BYTES} bytes`);
5129
+ }
5130
+ return out.toString("utf8");
5131
+ }
5132
+ throw new Error(`unsupported data_type: ${dataType}`);
5133
+ }
5134
+
5135
+ // Pull the 4-tuple (input/output/cache_read/cache_write) out of one Zed
5136
+ // TokenUsage shape. Zed stores integers but some historical rows used
5137
+ // strings — match tokscale's permissive coercion.
5138
+ function readZedUsage(value) {
5139
+ if (!value || typeof value !== "object") return null;
5140
+ const coerce = (v) => {
5141
+ if (typeof v === "number") return Math.max(0, Math.floor(v));
5142
+ if (typeof v === "string") {
5143
+ const n = Number.parseInt(v, 10);
5144
+ return Number.isFinite(n) && n > 0 ? n : 0;
5145
+ }
5146
+ return 0;
5147
+ };
5148
+ return {
5149
+ input: coerce(value.input_tokens),
5150
+ output: coerce(value.output_tokens),
5151
+ cache_read: coerce(value.cache_read_input_tokens),
5152
+ cache_write: coerce(value.cache_creation_input_tokens),
5153
+ };
5154
+ }
5155
+
5156
+ function sumZedRequestUsage(value) {
5157
+ const total = { input: 0, output: 0, cache_read: 0, cache_write: 0 };
5158
+ if (!value) return total;
5159
+ const iter =
5160
+ Array.isArray(value)
5161
+ ? value
5162
+ : typeof value === "object"
5163
+ ? Object.values(value)
5164
+ : [];
5165
+ for (const entry of iter) {
5166
+ const u = readZedUsage(entry);
5167
+ if (!u) continue;
5168
+ total.input += u.input;
5169
+ total.output += u.output;
5170
+ total.cache_read += u.cache_read;
5171
+ total.cache_write += u.cache_write;
5172
+ }
5173
+ return total;
5174
+ }
5175
+
5176
+ // Extract token totals from a parsed Zed thread object. Prefer summed
5177
+ // request_token_usage (per-turn breakdown) and fall back to
5178
+ // cumulative_token_usage when the per-turn map is empty.
5179
+ function extractZedTotals(thread) {
5180
+ if (!thread || thread.imported === true) return null;
5181
+ const model = thread.model;
5182
+ if (!model || typeof model !== "object") return null;
5183
+ const provider = typeof model.provider === "string" ? model.provider.trim() : "";
5184
+ if (provider.toLowerCase() !== ZED_HOSTED_PROVIDER) return null;
5185
+ const modelId = typeof model.model === "string" ? model.model.trim() : "";
5186
+ if (!modelId) return null;
5187
+
5188
+ const request = sumZedRequestUsage(thread.request_token_usage);
5189
+ if (request.input + request.output + request.cache_read + request.cache_write > 0) {
5190
+ return { totals: request, model: modelId };
5191
+ }
5192
+ const cumulative = readZedUsage(thread.cumulative_token_usage);
5193
+ if (
5194
+ cumulative &&
5195
+ cumulative.input + cumulative.output + cumulative.cache_read + cumulative.cache_write > 0
5196
+ ) {
5197
+ return { totals: cumulative, model: modelId };
5198
+ }
5199
+ return null;
5200
+ }
5201
+
5202
+ // Build a SELECT that only references columns we know exist — Zed has shipped
5203
+ // several `threads` schemas; older versions may omit created_at /
5204
+ // folder_paths. We dynamically detect via PRAGMA so the query never fails on
5205
+ // a missing column.
5206
+ function buildZedThreadsQuery(dbPath, cursorUpdatedAt) {
5207
+ const pragma = cp.execFileSync("sqlite3", [dbPath, "PRAGMA table_info(threads)"], {
5208
+ encoding: "utf8",
5209
+ maxBuffer: 4 * 1024 * 1024,
5210
+ timeout: 10_000,
5211
+ });
5212
+ const columns = new Set(
5213
+ pragma
5214
+ .split("\n")
5215
+ .map((line) => line.split("|")[1])
5216
+ .filter(Boolean),
5217
+ );
5218
+ const optional = (col) => (columns.has(col) ? col : `NULL AS ${col}`);
5219
+ // Incremental: only fetch threads updated after the last sync watermark.
5220
+ // Without this we'd zstd-decode every thread on every sync (~250MB for a
5221
+ // 5k-thread DB on every menu-bar tick). Empty cursor → full scan (first
5222
+ // sync). updated_at is stored as ISO 8601 text, so lexical comparison ==
5223
+ // chronological comparison.
5224
+ const escaped = typeof cursorUpdatedAt === "string" && cursorUpdatedAt
5225
+ ? cursorUpdatedAt.replace(/'/g, "''")
5226
+ : null;
5227
+ const where = escaped ? ` WHERE updated_at > '${escaped}'` : "";
5228
+ return `SELECT id, updated_at, ${optional("created_at")}, data_type, hex(data) AS data_hex FROM threads${where}`;
5229
+ }
5230
+
5231
+ function readZedThreadRowsFromSqlite(dbPath, cursorUpdatedAt) {
5232
+ const query = buildZedThreadsQuery(dbPath, cursorUpdatedAt);
5233
+ let raw;
5234
+ try {
5235
+ raw = cp.execFileSync("sqlite3", ["-json", dbPath, query], {
5236
+ encoding: "utf8",
5237
+ maxBuffer: 256 * 1024 * 1024,
5238
+ timeout: 60_000,
5239
+ });
5240
+ } catch (_e) {
5241
+ return [];
5242
+ }
5243
+ if (!raw || !raw.trim()) return [];
5244
+ let rows;
5245
+ try {
5246
+ rows = JSON.parse(raw);
5247
+ } catch (_e) {
5248
+ return [];
5249
+ }
5250
+ if (!Array.isArray(rows)) return [];
5251
+ return rows;
5252
+ }
5253
+
5254
+ async function parseZedIncremental({
5255
+ dbPath,
5256
+ cursors,
5257
+ queuePath,
5258
+ onProgress,
5259
+ env,
5260
+ } = {}) {
5261
+ await ensureDir(path.dirname(queuePath));
5262
+ const resolvedDb = dbPath || resolveZedDbPath(env || process.env);
5263
+ const zedState =
5264
+ cursors.zed && typeof cursors.zed === "object" ? cursors.zed : {};
5265
+ const threadTotals =
5266
+ zedState.threadTotals && typeof zedState.threadTotals === "object"
5267
+ ? { ...zedState.threadTotals }
5268
+ : {};
5269
+ const cursorUpdatedAt = typeof zedState.lastUpdatedAt === "string" ? zedState.lastUpdatedAt : null;
5270
+ const cursorDbMtime = Number.isFinite(zedState.lastDbMtimeMs) ? zedState.lastDbMtimeMs : 0;
5271
+
5272
+ // mtime short-circuit: if the SQLite file hasn't been touched since the
5273
+ // last sync there's nothing to read — skip the ~250MB copyFile + zstd
5274
+ // round-trip entirely. We still re-stat on the next call, so a Zed write
5275
+ // is picked up within one sync interval.
5276
+ let currentMtime = 0;
5277
+ try {
5278
+ currentMtime = fssync.statSync(resolvedDb).mtimeMs;
5279
+ } catch (e) {
5280
+ if (e && e.code === "ENOENT") {
5281
+ cursors.zed = { ...zedState, threadTotals, updatedAt: new Date().toISOString() };
5282
+ return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
5283
+ }
5284
+ throw e;
5285
+ }
5286
+ if (currentMtime > 0 && currentMtime === cursorDbMtime) {
5287
+ cursors.zed = { ...zedState, threadTotals, updatedAt: new Date().toISOString() };
5288
+ return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
5289
+ }
5290
+
5291
+ // Snapshot via the shared helper so we get WAL/SHM/journal sidecar copies
5292
+ // too. Without sidecars, an active Zed write that's still in the WAL
5293
+ // would be missed (the .db has older pages until checkpoint).
5294
+ const snap = snapshotSqliteDb(resolvedDb);
5295
+ let rows = [];
5296
+ try {
5297
+ rows = readZedThreadRowsFromSqlite(snap.path, cursorUpdatedAt);
5298
+ } finally {
5299
+ snap.cleanup();
5300
+ }
5301
+
5302
+ if (rows.length === 0) {
5303
+ cursors.zed = { ...zedState, threadTotals, updatedAt: new Date().toISOString() };
5304
+ return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
5305
+ }
5306
+
5307
+ const hourlyState = normalizeHourlyState(cursors?.hourly);
5308
+ const touchedBuckets = new Set();
5309
+ const cb = typeof onProgress === "function" ? onProgress : null;
5310
+ let recordsProcessed = 0;
5311
+ let eventsAggregated = 0;
5312
+
5313
+ for (let i = 0; i < rows.length; i++) {
5314
+ const row = rows[i];
5315
+ recordsProcessed++;
5316
+ if (!row || typeof row.id !== "string" || !row.data_hex) continue;
5317
+
5318
+ let blob;
5319
+ try { blob = Buffer.from(row.data_hex, "hex"); } catch { continue; }
5320
+
5321
+ let jsonText;
5322
+ try { jsonText = await decodeZedThreadBlob({ dataType: row.data_type, data: blob }); }
5323
+ catch { continue; }
5324
+
5325
+ let thread;
5326
+ try { thread = JSON.parse(jsonText); } catch { continue; }
5327
+
5328
+ const extracted = extractZedTotals(thread);
5329
+ if (!extracted) continue;
5330
+
5331
+ const prev = threadTotals[row.id] || { input: 0, output: 0, cache_read: 0, cache_write: 0 };
5332
+ const curr = extracted.totals;
5333
+ const prevSum = prev.input + prev.output + prev.cache_read + prev.cache_write;
5334
+ const currSum = curr.input + curr.output + curr.cache_read + curr.cache_write;
5335
+ // Detect cumulative reset: a thread can be re-created with the same id
5336
+ // but lower totals (rare — Zed may purge & rewrite on import/export).
5337
+ // Naive `Math.max(0, curr - prev)` would clamp the delta to 0 and quietly
5338
+ // update the cursor to the smaller `curr`, so the next sync sees growth
5339
+ // from the reset and re-counts everything since. Treat reset as a
5340
+ // fresh-start emit of `curr`.
5341
+ const isReset = currSum > 0 && currSum < prevSum;
5342
+ const delta = isReset
5343
+ ? { ...curr }
5344
+ : {
5345
+ input: Math.max(0, curr.input - prev.input),
5346
+ output: Math.max(0, curr.output - prev.output),
5347
+ cache_read: Math.max(0, curr.cache_read - prev.cache_read),
5348
+ cache_write: Math.max(0, curr.cache_write - prev.cache_write),
5349
+ };
5350
+ const totalDelta = delta.input + delta.output + delta.cache_read + delta.cache_write;
5351
+ if (totalDelta <= 0) {
5352
+ if (
5353
+ curr.input !== prev.input ||
5354
+ curr.output !== prev.output ||
5355
+ curr.cache_read !== prev.cache_read ||
5356
+ curr.cache_write !== prev.cache_write
5357
+ ) {
5358
+ threadTotals[row.id] = curr;
5359
+ }
5360
+ continue;
5361
+ }
5362
+
5363
+ const tsIso =
5364
+ (typeof row.updated_at === "string" && row.updated_at) ||
5365
+ (typeof row.created_at === "string" && row.created_at) ||
5366
+ (typeof thread.updated_at === "string" && thread.updated_at) ||
5367
+ new Date().toISOString();
5368
+ const bucketStart = toUtcHalfHourStart(tsIso);
5369
+ if (!bucketStart) continue;
5370
+
5371
+ const bucketDelta = {
5372
+ input_tokens: delta.input,
5373
+ cached_input_tokens: delta.cache_read,
5374
+ cache_creation_input_tokens: delta.cache_write,
5375
+ output_tokens: delta.output,
5376
+ reasoning_output_tokens: 0,
5377
+ total_tokens: totalDelta,
5378
+ conversation_count: 1,
5379
+ };
5380
+
5381
+ const bucket = getHourlyBucket(hourlyState, "zed", extracted.model, bucketStart);
5382
+ addTotals(bucket.totals, bucketDelta);
5383
+ touchedBuckets.add(bucketKey("zed", extracted.model, bucketStart));
5384
+ threadTotals[row.id] = curr;
5385
+ eventsAggregated++;
5386
+
5387
+ if (cb) {
5388
+ cb({
5389
+ index: i + 1,
5390
+ total: rows.length,
5391
+ recordsProcessed,
5392
+ eventsAggregated,
5393
+ bucketsQueued: touchedBuckets.size,
5394
+ });
5395
+ }
5396
+ }
5397
+
5398
+ // Compute nextCursor BEFORE the 10k cap. If we capped first, a low-volume
5399
+ // zed.dev thread evicted in the cap step would no longer be in
5400
+ // threadTotals, so its updated_at would not advance the cursor — and the
5401
+ // next sync's WHERE filter would re-read & re-decode the same blob forever.
5402
+ // We record everything we touched this run regardless of post-cap eviction.
5403
+ let nextCursor = cursorUpdatedAt;
5404
+ for (const r of rows) {
5405
+ if (
5406
+ typeof r.updated_at === "string" &&
5407
+ threadTotals[r.id] !== undefined &&
5408
+ (nextCursor == null || r.updated_at > nextCursor)
5409
+ ) {
5410
+ nextCursor = r.updated_at;
5411
+ }
5412
+ }
5413
+
5414
+ const entries = Object.entries(threadTotals);
5415
+ if (entries.length > 10_000) {
5416
+ entries.sort((a, b) => {
5417
+ const ta = a[1].input + a[1].output + a[1].cache_read + a[1].cache_write;
5418
+ const tb = b[1].input + b[1].output + b[1].cache_read + b[1].cache_write;
5419
+ return tb - ta;
5420
+ });
5421
+ const capped = Object.fromEntries(entries.slice(0, 10_000));
5422
+ for (const k of Object.keys(threadTotals)) delete threadTotals[k];
5423
+ Object.assign(threadTotals, capped);
5424
+ }
5425
+
5426
+ const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets });
5427
+ const updatedAt = new Date().toISOString();
5428
+ hourlyState.updatedAt = updatedAt;
5429
+ cursors.hourly = hourlyState;
5430
+ cursors.zed = {
5431
+ ...zedState,
5432
+ threadTotals,
5433
+ lastUpdatedAt: nextCursor,
5434
+ lastDbMtimeMs: currentMtime,
5435
+ updatedAt,
5436
+ };
5437
+
5438
+ return { recordsProcessed, eventsAggregated, bucketsQueued };
5439
+ }
5440
+
5441
+ // ─────────────────────────────────────────────────────────────────────────────
5442
+ // Goose (Block AI agent — github.com/block/goose)
5443
+ //
5444
+ // Data: SQLite at
5445
+ // macOS: ~/Library/Application Support/goose/sessions/sessions.db
5446
+ // Linux: $XDG_DATA_HOME/goose/sessions/sessions.db (~/.local/share)
5447
+ // Legacy: ~/.local/share/Block/goose/sessions/sessions.db
5448
+ // Windows: %APPDATA%\goose\sessions\sessions.db
5449
+ // Override: $GOOSE_PATH_ROOT/data/sessions/sessions.db
5450
+ //
5451
+ // `sessions` table: one row per session, columns:
5452
+ // id, model_config_json ({"model_name":"..."}),
5453
+ // provider_name, created_at,
5454
+ // total_tokens / input_tokens / output_tokens (latest turn),
5455
+ // accumulated_total_tokens / accumulated_input_tokens /
5456
+ // accumulated_output_tokens (whole-session cumulative).
5457
+ //
5458
+ // We prefer accumulated_* (gives lifetime usage), with single-turn fallback.
5459
+ // Goose has no cache fields; if total > input+output, the excess is treated
5460
+ // as reasoning_output_tokens (same heuristic as tokscale).
5461
+ //
5462
+ // Session rows grow over time → same cumulative-delta pattern as Zed
5463
+ // (cursors.goose.sessionTotals tracks last-seen per session).
5464
+ // ─────────────────────────────────────────────────────────────────────────────
5465
+
5466
+ function resolveGooseDbPath(env = process.env) {
5467
+ if (typeof env.TOKENTRACKER_GOOSE_DB === "string" && env.TOKENTRACKER_GOOSE_DB.trim()) {
5468
+ return env.TOKENTRACKER_GOOSE_DB.trim();
5469
+ }
5470
+ const root = typeof env.GOOSE_PATH_ROOT === "string" ? env.GOOSE_PATH_ROOT.trim() : "";
5471
+ if (root) return path.join(root, "data", "sessions", "sessions.db");
5472
+ const home = env.HOME || require("node:os").homedir();
5473
+ const candidates = [];
5474
+ if (process.platform === "darwin") {
5475
+ candidates.push(
5476
+ path.join(home, "Library", "Application Support", "goose", "sessions", "sessions.db"),
5477
+ );
5478
+ } else if (process.platform === "win32") {
5479
+ const appData = env.APPDATA || path.join(home, "AppData", "Roaming");
5480
+ candidates.push(path.join(appData, "goose", "sessions", "sessions.db"));
5481
+ }
5482
+ const xdg = env.XDG_DATA_HOME || path.join(home, ".local", "share");
5483
+ candidates.push(
5484
+ path.join(xdg, "goose", "sessions", "sessions.db"),
5485
+ path.join(xdg, "Block", "goose", "sessions", "sessions.db"),
5486
+ );
5487
+ // Default to first existing; if none, return the platform-canonical path so
5488
+ // status can report it cleanly without throwing.
5489
+ for (const c of candidates) {
5490
+ if (fssync.existsSync(c)) return c;
5491
+ }
5492
+ return candidates[0];
5493
+ }
5494
+
5495
+ function parseGooseModelName(modelConfigJson) {
5496
+ if (typeof modelConfigJson !== "string" || !modelConfigJson.trim()) return null;
5497
+ try {
5498
+ const obj = JSON.parse(modelConfigJson);
5499
+ if (obj && typeof obj.model_name === "string") {
5500
+ const trimmed = obj.model_name.trim();
5501
+ return trimmed || null;
5502
+ }
5503
+ } catch (_e) { /* ignore */ }
5504
+ return null;
5505
+ }
5506
+
5507
+ // Goose stores created_at in multiple formats across versions: RFC3339
5508
+ // (preferred), "YYYY-MM-DD HH:MM:SS" (naive UTC), or bare "YYYY-MM-DD".
5509
+ // Return ISO 8601 string, or null on failure.
5510
+ function parseGooseCreatedAt(s) {
5511
+ if (typeof s !== "string" || !s.trim()) return null;
5512
+ const trimmed = s.trim();
5513
+ // Match naive UTC formats FIRST — otherwise `new Date("2026-05-21 14:30:00")`
5514
+ // is interpreted in the local zone, shifting the bucket by ±N hours.
5515
+ const dt = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})$/.exec(trimmed);
5516
+ if (dt) {
5517
+ const d = new Date(Date.UTC(+dt[1], +dt[2] - 1, +dt[3], +dt[4], +dt[5], +dt[6]));
5518
+ return d.toISOString();
5519
+ }
5520
+ const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(trimmed);
5521
+ if (dateOnly) {
5522
+ const d = new Date(Date.UTC(+dateOnly[1], +dateOnly[2] - 1, +dateOnly[3]));
5523
+ return d.toISOString();
5524
+ }
5525
+ // Anything else — RFC3339, "Z"-suffixed, "+HH:MM" — let Date handle it.
5526
+ const iso = new Date(trimmed);
5527
+ if (!Number.isNaN(iso.getTime())) return iso.toISOString();
5528
+ return null;
5529
+ }
5530
+
5531
+ function readGooseSessionsFromSqlite(dbPath) {
5532
+ // Probe columns: the `accumulated_*` fields were added in a later Goose
5533
+ // version; we keep the query forgiving so older installs still work.
5534
+ let pragma;
5535
+ try {
5536
+ pragma = cp.execFileSync("sqlite3", [dbPath, "PRAGMA table_info(sessions)"], {
5537
+ encoding: "utf8",
5538
+ maxBuffer: 4 * 1024 * 1024,
5539
+ timeout: 10_000,
5540
+ });
5541
+ } catch (_e) { return []; }
5542
+ const columns = new Set(
5543
+ pragma
5544
+ .split("\n")
5545
+ .map((line) => line.split("|")[1])
5546
+ .filter(Boolean),
5547
+ );
5548
+ const optional = (col) => (columns.has(col) ? col : `NULL AS ${col}`);
5549
+ const sql = `
5550
+ SELECT
5551
+ id,
5552
+ model_config_json,
5553
+ ${optional("provider_name")},
5554
+ created_at,
5555
+ ${optional("total_tokens")},
5556
+ ${optional("input_tokens")},
5557
+ ${optional("output_tokens")},
5558
+ ${optional("accumulated_total_tokens")},
5559
+ ${optional("accumulated_input_tokens")},
5560
+ ${optional("accumulated_output_tokens")}
5561
+ FROM sessions
5562
+ WHERE model_config_json IS NOT NULL
5563
+ AND TRIM(model_config_json) != ''
5564
+ `.trim();
5565
+ let raw;
5566
+ try {
5567
+ raw = cp.execFileSync("sqlite3", ["-json", dbPath, sql], {
5568
+ encoding: "utf8",
5569
+ maxBuffer: 64 * 1024 * 1024,
5570
+ timeout: 60_000,
5571
+ });
5572
+ } catch (_e) { return []; }
5573
+ if (!raw || !raw.trim()) return [];
5574
+ try {
5575
+ const rows = JSON.parse(raw);
5576
+ return Array.isArray(rows) ? rows : [];
5577
+ } catch (_e) {
5578
+ return [];
5579
+ }
5580
+ }
5581
+
5582
+ async function parseGooseIncremental({
5583
+ dbPath,
5584
+ cursors,
5585
+ queuePath,
5586
+ onProgress,
5587
+ env,
5588
+ } = {}) {
5589
+ await ensureDir(path.dirname(queuePath));
5590
+ const resolvedDb = dbPath || resolveGooseDbPath(env || process.env);
5591
+ const gooseState =
5592
+ cursors.goose && typeof cursors.goose === "object" ? cursors.goose : {};
5593
+ const sessionTotals =
5594
+ gooseState.sessionTotals && typeof gooseState.sessionTotals === "object"
5595
+ ? { ...gooseState.sessionTotals }
5596
+ : {};
5597
+
5598
+ const cursorDbMtime = Number.isFinite(gooseState.lastDbMtimeMs) ? gooseState.lastDbMtimeMs : 0;
5599
+ let currentMtime = 0;
5600
+ try {
5601
+ currentMtime = fssync.statSync(resolvedDb).mtimeMs;
5602
+ } catch (e) {
5603
+ if (e && e.code === "ENOENT") {
5604
+ cursors.goose = { ...gooseState, sessionTotals, updatedAt: new Date().toISOString() };
5605
+ return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
5606
+ }
5607
+ throw e;
5608
+ }
5609
+ // mtime short-circuit: skip the full sessions table scan when the DB
5610
+ // hasn't been touched since the last sync.
5611
+ if (currentMtime > 0 && currentMtime === cursorDbMtime) {
5612
+ cursors.goose = { ...gooseState, sessionTotals, updatedAt: new Date().toISOString() };
5613
+ return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
5614
+ }
5615
+
5616
+ // Snapshot via the shared helper to capture WAL/SHM sidecars — Goose
5617
+ // writes async, so without them an in-flight session would read stale.
5618
+ const snap = snapshotSqliteDb(resolvedDb);
5619
+ let rows = [];
5620
+ try {
5621
+ rows = readGooseSessionsFromSqlite(snap.path);
5622
+ } finally {
5623
+ snap.cleanup();
5624
+ }
5625
+
5626
+ if (rows.length === 0) {
5627
+ cursors.goose = { ...gooseState, sessionTotals, updatedAt: new Date().toISOString() };
5628
+ return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
5629
+ }
5630
+
5631
+ const hourlyState = normalizeHourlyState(cursors?.hourly);
5632
+ const touchedBuckets = new Set();
5633
+ const cb = typeof onProgress === "function" ? onProgress : null;
5634
+ let recordsProcessed = 0;
5635
+ let eventsAggregated = 0;
5636
+
5637
+ for (let i = 0; i < rows.length; i++) {
5638
+ const row = rows[i];
5639
+ recordsProcessed++;
5640
+ if (!row || typeof row.id !== "string") continue;
5641
+
5642
+ const model = parseGooseModelName(row.model_config_json);
5643
+ if (!model) continue;
5644
+
5645
+ // Prefer accumulated_*; fall back to single-turn columns.
5646
+ const totalNow = Math.max(
5647
+ 0,
5648
+ Number(row.accumulated_total_tokens ?? row.total_tokens ?? 0) || 0,
5649
+ );
5650
+ const inputNow = Math.max(
5651
+ 0,
5652
+ Number(row.accumulated_input_tokens ?? row.input_tokens ?? 0) || 0,
5653
+ );
5654
+ const outputNow = Math.max(
5655
+ 0,
5656
+ Number(row.accumulated_output_tokens ?? row.output_tokens ?? 0) || 0,
5657
+ );
5658
+ if (totalNow === 0 && inputNow === 0 && outputNow === 0) continue;
5659
+
5660
+ const prev = sessionTotals[row.id] || { input: 0, output: 0, total: 0 };
5661
+ // Goose can wipe a session and re-create with the same id during
5662
+ // database migration. Treat shrinking cumulative as a reset and emit
5663
+ // the full curr value, otherwise the next sync's growth would
5664
+ // double-count everything from the reset.
5665
+ const isReset = totalNow > 0 && totalNow < prev.total;
5666
+ const dInput = isReset ? inputNow : Math.max(0, inputNow - prev.input);
5667
+ const dOutput = isReset ? outputNow : Math.max(0, outputNow - prev.output);
5668
+ const dTotal = isReset ? totalNow : Math.max(0, totalNow - prev.total);
5669
+ if (dInput === 0 && dOutput === 0 && dTotal === 0) {
5670
+ if (
5671
+ prev.input !== inputNow ||
5672
+ prev.output !== outputNow ||
5673
+ prev.total !== totalNow
5674
+ ) {
5675
+ sessionTotals[row.id] = { input: inputNow, output: outputNow, total: totalNow };
5676
+ }
5677
+ continue;
5678
+ }
5679
+
5680
+ // If total grew more than (input + output), treat the excess as reasoning
5681
+ // — matches Goose's accounting (it lumps reasoning into `total_tokens`).
5682
+ const accountedDelta = dInput + dOutput;
5683
+ const reasoningDelta = Math.max(0, dTotal - accountedDelta);
5684
+
5685
+ const tsIso = parseGooseCreatedAt(row.created_at) || new Date().toISOString();
5686
+ const bucketStart = toUtcHalfHourStart(tsIso);
5687
+ if (!bucketStart) continue;
5688
+
5689
+ // Token normalization: input_tokens = non-cached input; Goose has no
5690
+ // cache fields → all input lands in input_tokens. Total stays consistent
5691
+ // with: input + output + reasoning (no cache columns).
5692
+ const bucketDelta = {
5693
+ input_tokens: dInput,
5694
+ cached_input_tokens: 0,
5695
+ cache_creation_input_tokens: 0,
5696
+ output_tokens: dOutput,
5697
+ reasoning_output_tokens: reasoningDelta,
5698
+ total_tokens: dInput + dOutput + reasoningDelta,
5699
+ conversation_count: 1,
5700
+ };
5701
+
5702
+ const bucket = getHourlyBucket(hourlyState, "goose", model, bucketStart);
5703
+ addTotals(bucket.totals, bucketDelta);
5704
+ touchedBuckets.add(bucketKey("goose", model, bucketStart));
5705
+ sessionTotals[row.id] = { input: inputNow, output: outputNow, total: totalNow };
5706
+ eventsAggregated++;
5707
+
5708
+ if (cb) {
5709
+ cb({
5710
+ index: i + 1,
5711
+ total: rows.length,
5712
+ recordsProcessed,
5713
+ eventsAggregated,
5714
+ bucketsQueued: touchedBuckets.size,
5715
+ });
5716
+ }
5717
+ }
5718
+
5719
+ // Cap cursor at 10k sessions (largest by lifetime usage).
5720
+ const entries = Object.entries(sessionTotals);
5721
+ if (entries.length > 10_000) {
5722
+ entries.sort((a, b) => b[1].total - a[1].total);
5723
+ const capped = Object.fromEntries(entries.slice(0, 10_000));
5724
+ for (const k of Object.keys(sessionTotals)) delete sessionTotals[k];
5725
+ Object.assign(sessionTotals, capped);
5726
+ }
5727
+
5728
+ const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets });
5729
+ const updatedAt = new Date().toISOString();
5730
+ hourlyState.updatedAt = updatedAt;
5731
+ cursors.hourly = hourlyState;
5732
+ cursors.goose = {
5733
+ ...gooseState,
5734
+ sessionTotals,
5735
+ lastDbMtimeMs: currentMtime,
5736
+ updatedAt,
5737
+ };
5738
+
5739
+ return { recordsProcessed, eventsAggregated, bucketsQueued };
5740
+ }
5741
+
4843
5742
  async function parseKilocodeIncremental({
4844
5743
  taskFiles,
4845
5744
  cursors,
@@ -6885,6 +7784,20 @@ module.exports = {
6885
7784
  resolveKilocodeTaskFiles,
6886
7785
  normalizeKilocodeProviderToModel,
6887
7786
  parseKilocodeIncremental,
7787
+ resolveRoocodeTaskFiles,
7788
+ readRoocodeTaskModel,
7789
+ normalizeRoocodeModel,
7790
+ parseRoocodeIncremental,
7791
+ resolveZedDbPath,
7792
+ decodeZedThreadBlob,
7793
+ extractZedTotals,
7794
+ sumZedRequestUsage,
7795
+ readZedUsage,
7796
+ parseZedIncremental,
7797
+ resolveGooseDbPath,
7798
+ parseGooseModelName,
7799
+ parseGooseCreatedAt,
7800
+ parseGooseIncremental,
6888
7801
  resolvePiHome,
6889
7802
  resolvePiAgentDir,
6890
7803
  resolvePiSessionFiles,