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.
@@ -899,10 +899,8 @@ async function parseClaudeFile({
899
899
  if (!usage || typeof usage !== "object") continue;
900
900
 
901
901
  if (seenMessageHashes) {
902
- const msgId = obj?.message?.id;
903
- const reqId = obj?.requestId;
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 opencodeState = normalizeOpencodeState(cursors?.opencode);
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.opencode = opencodeState;
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
  };