tokentracker-cli 0.12.0 → 0.13.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.md +4 -2
- package/dashboard/dist/assets/{main-CjKIAPGE.js → main-Dc116CZl.js} +3 -1
- package/dashboard/dist/brand-logos/kilo.svg +4 -0
- package/dashboard/dist/index.html +1 -1
- package/dashboard/dist/share.html +1 -1
- package/package.json +2 -2
- package/src/commands/init.js +28 -0
- package/src/commands/status.js +18 -0
- package/src/commands/sync.js +81 -8
- package/src/lib/claude-categorizer.js +5 -8
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/rollout.js +272 -6
package/src/lib/rollout.js
CHANGED
|
@@ -899,10 +899,8 @@ async function parseClaudeFile({
|
|
|
899
899
|
if (!usage || typeof usage !== "object") continue;
|
|
900
900
|
|
|
901
901
|
if (seenMessageHashes) {
|
|
902
|
-
const
|
|
903
|
-
|
|
904
|
-
if (msgId && reqId) {
|
|
905
|
-
const hash = `${msgId}:${reqId}`;
|
|
902
|
+
const hash = claudeMessageDedupKey(obj);
|
|
903
|
+
if (hash) {
|
|
906
904
|
if (seenMessageHashes.has(hash)) continue;
|
|
907
905
|
seenMessageHashes.add(hash);
|
|
908
906
|
}
|
|
@@ -2260,6 +2258,24 @@ function normalizeUsage(u) {
|
|
|
2260
2258
|
return out;
|
|
2261
2259
|
}
|
|
2262
2260
|
|
|
2261
|
+
// Stable dedup key for one Claude jsonl entry. Anthropic's official protocol
|
|
2262
|
+
// guarantees `message.id` is globally unique per response, so msgId alone is a
|
|
2263
|
+
// valid dedup key. Older code required both msgId AND requestId, which short-
|
|
2264
|
+
// circuited dedup entirely for jsonl entries where `requestId` is absent
|
|
2265
|
+
// (DeepSeek/Kimi/Mimo/MiniMax anthropic-compatible endpoints don't return the
|
|
2266
|
+
// `request-id` HTTP header, and Claude Code's sub-agent / thinking transport
|
|
2267
|
+
// paths drop the field too). The short-circuit caused 1.6–3.7x overcounting on
|
|
2268
|
+
// every affected provider — see issue #64. Falling back to msgId-only keeps
|
|
2269
|
+
// backward compatibility for the (msgId, reqId) format already persisted in
|
|
2270
|
+
// cursors.claudeHashes (msgId strings don't contain `:`, so the two formats
|
|
2271
|
+
// share the same Set without collision).
|
|
2272
|
+
function claudeMessageDedupKey(obj) {
|
|
2273
|
+
const msgId = typeof obj?.message?.id === "string" && obj.message.id ? obj.message.id : null;
|
|
2274
|
+
if (!msgId) return null;
|
|
2275
|
+
const reqId = typeof obj?.requestId === "string" && obj.requestId ? obj.requestId : null;
|
|
2276
|
+
return reqId ? `${msgId}:${reqId}` : msgId;
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2263
2279
|
function normalizeClaudeUsage(u) {
|
|
2264
2280
|
const inputTokens = toNonNegativeInt(u?.input_tokens);
|
|
2265
2281
|
const outputTokens = toNonNegativeInt(u?.output_tokens);
|
|
@@ -2411,6 +2427,7 @@ async function parseOpencodeDbIncremental({
|
|
|
2411
2427
|
projectQueuePath,
|
|
2412
2428
|
onProgress,
|
|
2413
2429
|
source,
|
|
2430
|
+
cursorKey,
|
|
2414
2431
|
publicRepoResolver,
|
|
2415
2432
|
}) {
|
|
2416
2433
|
await ensureDir(path.dirname(queuePath));
|
|
@@ -2426,7 +2443,8 @@ async function parseOpencodeDbIncremental({
|
|
|
2426
2443
|
const projectTouchedBuckets = projectEnabled ? new Set() : null;
|
|
2427
2444
|
const projectMetaCache = projectEnabled ? new Map() : null;
|
|
2428
2445
|
const publicRepoCache = projectEnabled ? new Map() : null;
|
|
2429
|
-
const
|
|
2446
|
+
const cursorNamespace = typeof cursorKey === "string" && cursorKey.length > 0 ? cursorKey : "opencode";
|
|
2447
|
+
const opencodeState = normalizeOpencodeState(cursors?.[cursorNamespace]);
|
|
2430
2448
|
const messageIndex = opencodeState.messages;
|
|
2431
2449
|
const touchedBuckets = new Set();
|
|
2432
2450
|
const defaultSource = normalizeSourceInput(source) || "opencode";
|
|
@@ -2545,7 +2563,7 @@ async function parseOpencodeDbIncremental({
|
|
|
2545
2563
|
hourlyState.updatedAt = new Date().toISOString();
|
|
2546
2564
|
cursors.hourly = hourlyState;
|
|
2547
2565
|
opencodeState.updatedAt = new Date().toISOString();
|
|
2548
|
-
cursors
|
|
2566
|
+
cursors[cursorNamespace] = opencodeState;
|
|
2549
2567
|
if (projectState) {
|
|
2550
2568
|
projectState.updatedAt = new Date().toISOString();
|
|
2551
2569
|
cursors.projectHourly = projectState;
|
|
@@ -4553,6 +4571,249 @@ function resolveOmpDefaultModel() {
|
|
|
4553
4571
|
return "omp-unknown";
|
|
4554
4572
|
}
|
|
4555
4573
|
|
|
4574
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
4575
|
+
// Kilo Code VS Code extension — passive reader for VS Code-family
|
|
4576
|
+
// globalStorage/kilocode.kilo-code/tasks/<uuid>/ui_messages.json files.
|
|
4577
|
+
//
|
|
4578
|
+
// Each task folder contains a ui_messages.json (JSON array, not JSONL). Token
|
|
4579
|
+
// usage records are messages where `say == "api_req_started"`; the `text`
|
|
4580
|
+
// field is a JSON-stringified payload:
|
|
4581
|
+
//
|
|
4582
|
+
// {
|
|
4583
|
+
// "apiProtocol": "openai" | "anthropic" | ...,
|
|
4584
|
+
// "tokensIn": 28673, // request input (already excludes cache)
|
|
4585
|
+
// "tokensOut": 31, // completion
|
|
4586
|
+
// "cacheWrites": 0,
|
|
4587
|
+
// "cacheReads": 5120,
|
|
4588
|
+
// "cost": 0,
|
|
4589
|
+
// "usageMissing": false,
|
|
4590
|
+
// "inferenceProvider": "Moonshot AI" | "minimax" | ...,
|
|
4591
|
+
// }
|
|
4592
|
+
//
|
|
4593
|
+
// We scan every supported VS Code-family install (Cursor, Code, CodeBuddy,
|
|
4594
|
+
// Windsurf, …) under both Library/Application Support (macOS) and Linux/Win
|
|
4595
|
+
// equivalents. Files are small (median ~30KB) and rewritten on each turn — we
|
|
4596
|
+
// can't byte-tail them, so we read the whole file on every sync and dedupe by
|
|
4597
|
+
// (taskId, ts). Per-file mtime caching skips unchanged files.
|
|
4598
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
4599
|
+
|
|
4600
|
+
function resolveKilocodeRoots(env = process.env) {
|
|
4601
|
+
if (typeof env.TOKENTRACKER_KILOCODE_ROOTS === "string" && env.TOKENTRACKER_KILOCODE_ROOTS.trim()) {
|
|
4602
|
+
return env.TOKENTRACKER_KILOCODE_ROOTS.split(":")
|
|
4603
|
+
.map((r) => r.trim())
|
|
4604
|
+
.filter(Boolean);
|
|
4605
|
+
}
|
|
4606
|
+
const home = env.HOME || require("node:os").homedir();
|
|
4607
|
+
const candidates = [];
|
|
4608
|
+
if (process.platform === "darwin") {
|
|
4609
|
+
const base = path.join(home, "Library", "Application Support");
|
|
4610
|
+
candidates.push(
|
|
4611
|
+
path.join(base, "Code"),
|
|
4612
|
+
path.join(base, "Code - Insiders"),
|
|
4613
|
+
path.join(base, "Cursor"),
|
|
4614
|
+
path.join(base, "CodeBuddy"),
|
|
4615
|
+
path.join(base, "Windsurf"),
|
|
4616
|
+
path.join(base, "VSCodium"),
|
|
4617
|
+
path.join(base, "Trae"),
|
|
4618
|
+
path.join(base, "Trae CN"),
|
|
4619
|
+
);
|
|
4620
|
+
} else if (process.platform === "win32") {
|
|
4621
|
+
const appData = env.APPDATA || path.join(home, "AppData", "Roaming");
|
|
4622
|
+
candidates.push(
|
|
4623
|
+
path.join(appData, "Code"),
|
|
4624
|
+
path.join(appData, "Code - Insiders"),
|
|
4625
|
+
path.join(appData, "Cursor"),
|
|
4626
|
+
path.join(appData, "CodeBuddy"),
|
|
4627
|
+
path.join(appData, "Windsurf"),
|
|
4628
|
+
path.join(appData, "VSCodium"),
|
|
4629
|
+
);
|
|
4630
|
+
} else {
|
|
4631
|
+
const xdg = env.XDG_CONFIG_HOME || path.join(home, ".config");
|
|
4632
|
+
candidates.push(
|
|
4633
|
+
path.join(xdg, "Code"),
|
|
4634
|
+
path.join(xdg, "Code - Insiders"),
|
|
4635
|
+
path.join(xdg, "Cursor"),
|
|
4636
|
+
path.join(xdg, "CodeBuddy"),
|
|
4637
|
+
path.join(xdg, "Windsurf"),
|
|
4638
|
+
path.join(xdg, "VSCodium"),
|
|
4639
|
+
);
|
|
4640
|
+
}
|
|
4641
|
+
return candidates;
|
|
4642
|
+
}
|
|
4643
|
+
|
|
4644
|
+
function resolveKilocodeTaskFiles(env = process.env) {
|
|
4645
|
+
const roots = resolveKilocodeRoots(env);
|
|
4646
|
+
const out = [];
|
|
4647
|
+
for (const root of roots) {
|
|
4648
|
+
const tasksDir = path.join(root, "User", "globalStorage", "kilocode.kilo-code", "tasks");
|
|
4649
|
+
if (!fssync.existsSync(tasksDir)) continue;
|
|
4650
|
+
let entries;
|
|
4651
|
+
try { entries = fssync.readdirSync(tasksDir); } catch { continue; }
|
|
4652
|
+
for (const taskUuid of entries) {
|
|
4653
|
+
const filePath = path.join(tasksDir, taskUuid, "ui_messages.json");
|
|
4654
|
+
if (!fssync.existsSync(filePath)) continue;
|
|
4655
|
+
out.push({ filePath, taskUuid, ide: path.basename(root) });
|
|
4656
|
+
}
|
|
4657
|
+
}
|
|
4658
|
+
out.sort((a, b) => a.filePath.localeCompare(b.filePath));
|
|
4659
|
+
return out;
|
|
4660
|
+
}
|
|
4661
|
+
|
|
4662
|
+
// Kilo Code only persists the inference provider (e.g. "minimax",
|
|
4663
|
+
// "Moonshot AI", "Stealth") in ui_messages.json — the actual model id is
|
|
4664
|
+
// stored in workspace state but isn't attributed to individual turns and may
|
|
4665
|
+
// change across sessions, so we cannot map a row back to a model id reliably.
|
|
4666
|
+
// We surface the provider explicitly so the dashboard's Model column doesn't
|
|
4667
|
+
// imply this is a model.
|
|
4668
|
+
function normalizeKilocodeProviderToModel(providerName) {
|
|
4669
|
+
if (typeof providerName !== "string" || !providerName.trim()) return "provider:unknown";
|
|
4670
|
+
const slug = providerName
|
|
4671
|
+
.trim()
|
|
4672
|
+
.toLowerCase()
|
|
4673
|
+
.replace(/\s+/g, "-")
|
|
4674
|
+
.replace(/[^a-z0-9._-]/g, "");
|
|
4675
|
+
// A slug consisting only of separators (dashes/dots/underscores) carries no
|
|
4676
|
+
// information — treat it as unknown.
|
|
4677
|
+
if (!slug || !/[a-z0-9]/.test(slug)) return "provider:unknown";
|
|
4678
|
+
return `provider:${slug}`;
|
|
4679
|
+
}
|
|
4680
|
+
|
|
4681
|
+
async function parseKilocodeIncremental({
|
|
4682
|
+
taskFiles,
|
|
4683
|
+
cursors,
|
|
4684
|
+
queuePath,
|
|
4685
|
+
onProgress,
|
|
4686
|
+
env,
|
|
4687
|
+
} = {}) {
|
|
4688
|
+
await ensureDir(path.dirname(queuePath));
|
|
4689
|
+
const kilocodeState =
|
|
4690
|
+
cursors.kilocode && typeof cursors.kilocode === "object" ? cursors.kilocode : {};
|
|
4691
|
+
const seenIds = new Set(
|
|
4692
|
+
Array.isArray(kilocodeState.seenIds) ? kilocodeState.seenIds : [],
|
|
4693
|
+
);
|
|
4694
|
+
const fileOffsets =
|
|
4695
|
+
kilocodeState.fileOffsets && typeof kilocodeState.fileOffsets === "object"
|
|
4696
|
+
? { ...kilocodeState.fileOffsets }
|
|
4697
|
+
: {};
|
|
4698
|
+
|
|
4699
|
+
const files = Array.isArray(taskFiles)
|
|
4700
|
+
? taskFiles
|
|
4701
|
+
: resolveKilocodeTaskFiles(env || process.env);
|
|
4702
|
+
|
|
4703
|
+
if (files.length === 0) {
|
|
4704
|
+
cursors.kilocode = {
|
|
4705
|
+
...kilocodeState,
|
|
4706
|
+
seenIds: Array.from(seenIds),
|
|
4707
|
+
fileOffsets,
|
|
4708
|
+
updatedAt: new Date().toISOString(),
|
|
4709
|
+
};
|
|
4710
|
+
return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
|
|
4711
|
+
}
|
|
4712
|
+
|
|
4713
|
+
const hourlyState = normalizeHourlyState(cursors?.hourly);
|
|
4714
|
+
const touchedBuckets = new Set();
|
|
4715
|
+
const cb = typeof onProgress === "function" ? onProgress : null;
|
|
4716
|
+
let recordsProcessed = 0;
|
|
4717
|
+
let eventsAggregated = 0;
|
|
4718
|
+
|
|
4719
|
+
for (let fileIdx = 0; fileIdx < files.length; fileIdx++) {
|
|
4720
|
+
const entry = files[fileIdx];
|
|
4721
|
+
const { filePath, taskUuid } = entry;
|
|
4722
|
+
let stat;
|
|
4723
|
+
try { stat = fssync.statSync(filePath); } catch { continue; }
|
|
4724
|
+
|
|
4725
|
+
const prevEntry = fileOffsets[filePath];
|
|
4726
|
+
if (
|
|
4727
|
+
prevEntry &&
|
|
4728
|
+
Number(prevEntry.size) === stat.size &&
|
|
4729
|
+
Number(prevEntry.mtimeMs) === stat.mtimeMs
|
|
4730
|
+
) {
|
|
4731
|
+
continue;
|
|
4732
|
+
}
|
|
4733
|
+
|
|
4734
|
+
let raw;
|
|
4735
|
+
try { raw = fssync.readFileSync(filePath, "utf8"); } catch { continue; }
|
|
4736
|
+
let data;
|
|
4737
|
+
try { data = JSON.parse(raw); } catch { continue; }
|
|
4738
|
+
if (!Array.isArray(data)) continue;
|
|
4739
|
+
|
|
4740
|
+
for (const msg of data) {
|
|
4741
|
+
if (!msg || typeof msg !== "object") continue;
|
|
4742
|
+
// `api_req_started` is the live billing record; `api_req_deleted` keeps
|
|
4743
|
+
// the same payload when a user removes a turn from the task (Cline-style
|
|
4744
|
+
// edit-and-retry) — tokens were already consumed by the provider, so we
|
|
4745
|
+
// still count them.
|
|
4746
|
+
if (msg.say !== "api_req_started" && msg.say !== "api_req_deleted") continue;
|
|
4747
|
+
if (typeof msg.text !== "string" || !msg.text.startsWith("{")) continue;
|
|
4748
|
+
|
|
4749
|
+
let payload;
|
|
4750
|
+
try { payload = JSON.parse(msg.text); } catch { continue; }
|
|
4751
|
+
if (!payload || typeof payload !== "object") continue;
|
|
4752
|
+
|
|
4753
|
+
const ts = Number(msg.ts);
|
|
4754
|
+
if (!Number.isFinite(ts) || ts <= 0) continue;
|
|
4755
|
+
|
|
4756
|
+
const dedupKey = `${taskUuid}:${ts}`;
|
|
4757
|
+
recordsProcessed++;
|
|
4758
|
+
if (seenIds.has(dedupKey)) continue;
|
|
4759
|
+
|
|
4760
|
+
const tokensIn = toNonNegativeInt(payload.tokensIn);
|
|
4761
|
+
const tokensOut = toNonNegativeInt(payload.tokensOut);
|
|
4762
|
+
const cacheReads = toNonNegativeInt(payload.cacheReads);
|
|
4763
|
+
const cacheWrites = toNonNegativeInt(payload.cacheWrites);
|
|
4764
|
+
if (tokensIn === 0 && tokensOut === 0 && cacheReads === 0 && cacheWrites === 0) {
|
|
4765
|
+
seenIds.add(dedupKey);
|
|
4766
|
+
continue;
|
|
4767
|
+
}
|
|
4768
|
+
|
|
4769
|
+
const tsIso = new Date(ts).toISOString();
|
|
4770
|
+
const bucketStart = toUtcHalfHourStart(tsIso);
|
|
4771
|
+
if (!bucketStart) continue;
|
|
4772
|
+
|
|
4773
|
+
const delta = {
|
|
4774
|
+
input_tokens: tokensIn,
|
|
4775
|
+
cached_input_tokens: cacheReads,
|
|
4776
|
+
cache_creation_input_tokens: cacheWrites,
|
|
4777
|
+
output_tokens: tokensOut,
|
|
4778
|
+
reasoning_output_tokens: 0,
|
|
4779
|
+
total_tokens: tokensIn + tokensOut + cacheReads + cacheWrites,
|
|
4780
|
+
conversation_count: 1,
|
|
4781
|
+
};
|
|
4782
|
+
|
|
4783
|
+
const model = normalizeKilocodeProviderToModel(payload.inferenceProvider);
|
|
4784
|
+
const bucket = getHourlyBucket(hourlyState, "kilo-code", model, bucketStart);
|
|
4785
|
+
addTotals(bucket.totals, delta);
|
|
4786
|
+
touchedBuckets.add(bucketKey("kilo-code", model, bucketStart));
|
|
4787
|
+
seenIds.add(dedupKey);
|
|
4788
|
+
eventsAggregated++;
|
|
4789
|
+
}
|
|
4790
|
+
|
|
4791
|
+
fileOffsets[filePath] = { size: stat.size, mtimeMs: stat.mtimeMs, ino: stat.ino };
|
|
4792
|
+
|
|
4793
|
+
if (cb) {
|
|
4794
|
+
cb({
|
|
4795
|
+
index: fileIdx + 1,
|
|
4796
|
+
total: files.length,
|
|
4797
|
+
recordsProcessed,
|
|
4798
|
+
eventsAggregated,
|
|
4799
|
+
bucketsQueued: touchedBuckets.size,
|
|
4800
|
+
});
|
|
4801
|
+
}
|
|
4802
|
+
}
|
|
4803
|
+
|
|
4804
|
+
// Cap seenIds to last 50k to bound cursor state size
|
|
4805
|
+
const seenArr = Array.from(seenIds);
|
|
4806
|
+
const cappedSeen = seenArr.length > 50_000 ? seenArr.slice(seenArr.length - 50_000) : seenArr;
|
|
4807
|
+
|
|
4808
|
+
const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets });
|
|
4809
|
+
const updatedAt = new Date().toISOString();
|
|
4810
|
+
hourlyState.updatedAt = updatedAt;
|
|
4811
|
+
cursors.hourly = hourlyState;
|
|
4812
|
+
cursors.kilocode = { ...kilocodeState, seenIds: cappedSeen, fileOffsets, updatedAt };
|
|
4813
|
+
|
|
4814
|
+
return { recordsProcessed, eventsAggregated, bucketsQueued };
|
|
4815
|
+
}
|
|
4816
|
+
|
|
4556
4817
|
async function parseOmpIncremental({
|
|
4557
4818
|
sessionFiles,
|
|
4558
4819
|
cursors,
|
|
@@ -5526,6 +5787,10 @@ module.exports = {
|
|
|
5526
5787
|
resolveOmpSessionFiles,
|
|
5527
5788
|
resolveOmpDefaultModel,
|
|
5528
5789
|
parseOmpIncremental,
|
|
5790
|
+
resolveKilocodeRoots,
|
|
5791
|
+
resolveKilocodeTaskFiles,
|
|
5792
|
+
normalizeKilocodeProviderToModel,
|
|
5793
|
+
parseKilocodeIncremental,
|
|
5529
5794
|
resolvePiHome,
|
|
5530
5795
|
resolvePiAgentDir,
|
|
5531
5796
|
resolvePiSessionFiles,
|
|
@@ -5546,5 +5811,6 @@ module.exports = {
|
|
|
5546
5811
|
// same key format sync uses elsewhere.
|
|
5547
5812
|
bucketKey,
|
|
5548
5813
|
totalsKey,
|
|
5814
|
+
claudeMessageDedupKey,
|
|
5549
5815
|
groupBucketKey,
|
|
5550
5816
|
};
|