tokentracker-cli 0.21.2 → 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.
- package/README.ja.md +457 -0
- package/README.ko.md +457 -0
- package/README.md +45 -6
- package/README.zh-CN.md +45 -6
- package/dashboard/dist/assets/{Card-BlTjrLNe.js → Card-CD18G4Ge.js} +1 -1
- package/dashboard/dist/assets/DashboardPage-DKY_Mi9v.js +64 -0
- package/dashboard/dist/assets/DevicePage-BkavlAal.js +1 -0
- package/dashboard/dist/assets/{FadeIn-BPRZGKdg.js → FadeIn-CVNJ4aZy.js} +1 -1
- package/dashboard/dist/assets/{HeaderGithubStar-DUExMcbl.js → HeaderGithubStar-COu1Xy3I.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-PLJuh2m5.js → IpCheckPage-B2HjZ3vY.js} +1 -1
- package/dashboard/dist/assets/{LandingPage-B85OvE31.js → LandingPage-9PSLFnys.js} +1 -1
- package/dashboard/dist/assets/{LeaderboardPage-dz85pWmv.js → LeaderboardPage-CQT5dBHU.js} +1 -1
- package/dashboard/dist/assets/{LeaderboardProfilePage-BVntzReT.js → LeaderboardProfilePage-aPP-Raey.js} +1 -1
- package/dashboard/dist/assets/{LimitsPage-BSmYsOGT.js → LimitsPage-eFrAHmoA.js} +2 -2
- package/dashboard/dist/assets/{LoginPage-YxDKzTXr.js → LoginPage-CczTNZ_P.js} +1 -1
- package/dashboard/dist/assets/{PopoverPopup-C_Cq5Cd8.js → PopoverPopup-CEvWSWgZ.js} +2 -2
- package/dashboard/dist/assets/{ProviderIcon-rOxGmW9Z.js → ProviderIcon-ewev19y3.js} +1 -1
- package/dashboard/dist/assets/SettingsPage-taBxq6ux.js +1 -0
- package/dashboard/dist/assets/SkillsPage-cqHO3rMB.js +1 -0
- package/dashboard/dist/assets/{WidgetsPage-CHnlcaHs.js → WidgetsPage-B53b1hwG.js} +1 -1
- package/dashboard/dist/assets/WrappedPage-DwAhprTa.js +1 -0
- package/dashboard/dist/assets/check-JnFJsHgI.js +1 -0
- package/dashboard/dist/assets/{chevron-down-CrDKy3YX.js → chevron-down-zOKEzHdv.js} +1 -1
- package/dashboard/dist/assets/{download-oH8QYt7L.js → download-BZZ4vKc1.js} +1 -1
- package/dashboard/dist/assets/{leaderboard-columns-B3psEJVP.js → leaderboard-columns-BNGlMUsD.js} +1 -1
- package/dashboard/dist/assets/main-A_x5MMU-.css +1 -0
- package/dashboard/dist/assets/{main-DFkO2vMJ.js → main-D0Irg9xR.js} +62 -17
- package/dashboard/dist/assets/{use-limits-display-prefs-DyeDzQ-s.js → use-limits-display-prefs-BTuSZo27.js} +1 -1
- package/dashboard/dist/assets/{use-native-settings-CxdbRzEd.js → use-native-settings-BPXVZrWe.js} +1 -1
- package/dashboard/dist/assets/{use-reduced-motion-BPcu3IT5.js → use-reduced-motion-HUOV_JD1.js} +1 -1
- package/dashboard/dist/assets/{use-usage-limits-CmEZ5jjP.js → use-usage-limits-G6-vCBcN.js} +1 -1
- package/dashboard/dist/index.html +2 -2
- package/dashboard/dist/share.html +2 -2
- package/package.json +3 -2
- package/src/cli.js +11 -0
- package/src/commands/device-login.js +161 -0
- package/src/commands/status.js +199 -1
- package/src/commands/sync.js +85 -2
- package/src/commands/wrapped.js +150 -0
- package/src/lib/local-api.js +37 -2
- package/src/lib/passive-mode.js +185 -0
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/rollout.js +913 -0
- package/src/lib/wrapped-aggregator.js +225 -0
- package/dashboard/dist/assets/DashboardPage-Dn3eiHhn.js +0 -1
- package/dashboard/dist/assets/SettingsPage-DzaUSufR.js +0 -1
- package/dashboard/dist/assets/SkillsPage-BoKJH6Il.js +0 -1
- package/dashboard/dist/assets/main-DX38hz5f.css +0 -1
package/src/lib/rollout.js
CHANGED
|
@@ -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,
|