tokentracker-cli 0.12.1 → 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.
@@ -2427,6 +2427,7 @@ async function parseOpencodeDbIncremental({
2427
2427
  projectQueuePath,
2428
2428
  onProgress,
2429
2429
  source,
2430
+ cursorKey,
2430
2431
  publicRepoResolver,
2431
2432
  }) {
2432
2433
  await ensureDir(path.dirname(queuePath));
@@ -2442,7 +2443,8 @@ async function parseOpencodeDbIncremental({
2442
2443
  const projectTouchedBuckets = projectEnabled ? new Set() : null;
2443
2444
  const projectMetaCache = projectEnabled ? new Map() : null;
2444
2445
  const publicRepoCache = projectEnabled ? new Map() : null;
2445
- const opencodeState = normalizeOpencodeState(cursors?.opencode);
2446
+ const cursorNamespace = typeof cursorKey === "string" && cursorKey.length > 0 ? cursorKey : "opencode";
2447
+ const opencodeState = normalizeOpencodeState(cursors?.[cursorNamespace]);
2446
2448
  const messageIndex = opencodeState.messages;
2447
2449
  const touchedBuckets = new Set();
2448
2450
  const defaultSource = normalizeSourceInput(source) || "opencode";
@@ -2561,7 +2563,7 @@ async function parseOpencodeDbIncremental({
2561
2563
  hourlyState.updatedAt = new Date().toISOString();
2562
2564
  cursors.hourly = hourlyState;
2563
2565
  opencodeState.updatedAt = new Date().toISOString();
2564
- cursors.opencode = opencodeState;
2566
+ cursors[cursorNamespace] = opencodeState;
2565
2567
  if (projectState) {
2566
2568
  projectState.updatedAt = new Date().toISOString();
2567
2569
  cursors.projectHourly = projectState;
@@ -4569,6 +4571,249 @@ function resolveOmpDefaultModel() {
4569
4571
  return "omp-unknown";
4570
4572
  }
4571
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
+
4572
4817
  async function parseOmpIncremental({
4573
4818
  sessionFiles,
4574
4819
  cursors,
@@ -5542,6 +5787,10 @@ module.exports = {
5542
5787
  resolveOmpSessionFiles,
5543
5788
  resolveOmpDefaultModel,
5544
5789
  parseOmpIncremental,
5790
+ resolveKilocodeRoots,
5791
+ resolveKilocodeTaskFiles,
5792
+ normalizeKilocodeProviderToModel,
5793
+ parseKilocodeIncremental,
5545
5794
  resolvePiHome,
5546
5795
  resolvePiAgentDir,
5547
5796
  resolvePiSessionFiles,