tokentracker-cli 0.12.1 → 0.14.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,
@@ -5327,7 +5572,7 @@ async function parseCraftIncremental({
5327
5572
  // ─────────────────────────────────────────────────────────────────────────────
5328
5573
 
5329
5574
  function resolveCopilotOtelPaths(env = process.env) {
5330
- const home = require("node:os").homedir();
5575
+ const home = env.HOME || require("node:os").homedir();
5331
5576
  const paths = new Set();
5332
5577
  const defaultDir = path.join(home, ".copilot", "otel");
5333
5578
  if (fssync.existsSync(defaultDir)) {
@@ -5345,10 +5590,17 @@ function resolveCopilotOtelPaths(env = process.env) {
5345
5590
  }
5346
5591
 
5347
5592
  function isCopilotChatSpan(record) {
5348
- if (!record || record.type !== "span") return false;
5593
+ if (!record || typeof record !== "object") return false;
5594
+ // Skip metric records (resource + scopeMetrics) which have no chat usage data
5595
+ if (record.scopeMetrics) return false;
5349
5596
  const opName = record?.attributes?.["gen_ai.operation.name"];
5597
+ // Both Copilot CLI (Span shape with type:"span") and Copilot Chat extension
5598
+ // (OTEL JS SDK LogRecord shape with event.name:"gen_ai.client.inference.operation.details")
5599
+ // mark chat completions with gen_ai.operation.name === "chat".
5350
5600
  if (opName === "chat") return true;
5351
- if (typeof record.name === "string" && record.name.startsWith("chat ")) return true;
5601
+ if (record.type === "span" && typeof record.name === "string" && record.name.startsWith("chat ")) {
5602
+ return true;
5603
+ }
5352
5604
  return false;
5353
5605
  }
5354
5606
 
@@ -5369,20 +5621,116 @@ function pickCopilotModel(attrs) {
5369
5621
  return null;
5370
5622
  }
5371
5623
 
5624
+ const COPILOT_PARSER_VERSION = 2;
5625
+
5626
+ function isCopilotV1ChatSpan(record) {
5627
+ if (!record || record.type !== "span") return false;
5628
+ const opName = record?.attributes?.["gen_ai.operation.name"];
5629
+ if (opName === "chat") return true;
5630
+ return typeof record.name === "string" && record.name.startsWith("chat ");
5631
+ }
5632
+
5633
+ function copilotLineHash(line) {
5634
+ return crypto.createHash("sha256").update(line).digest("hex");
5635
+ }
5636
+
5637
+ function incrementMapCount(map, key, amount = 1) {
5638
+ map.set(key, (map.get(key) || 0) + amount);
5639
+ }
5640
+
5641
+ function getCopilotDedupKey(record, attrs = record?.attributes || {}) {
5642
+ const traceId = record?.traceId || record?.spanContext?.traceId || "";
5643
+ const spanId = record?.spanId || record?.spanContext?.spanId || "";
5644
+ const responseId =
5645
+ typeof attrs["gen_ai.response.id"] === "string" ? attrs["gen_ai.response.id"] : "";
5646
+ return traceId && spanId ? `${traceId}:${spanId}` : responseId ? `resp:${responseId}` : null;
5647
+ }
5648
+
5649
+ // Migration helper: stream the bytes v1 already saw (0 -> prevSize), classify
5650
+ // whether the file contains old CLI spans v1 processed, and whether it also
5651
+ // contains v2-only chat records v1 skipped. Mixed files must be replayed, but
5652
+ // their old CLI lines are skipped by hash so history does not double-count.
5653
+ async function scanCopilotV1MigrationFile(filePath, maxBytes) {
5654
+ const result = {
5655
+ v1Processed: false,
5656
+ v2OnlyChat: false,
5657
+ v1LineHashes: new Map(),
5658
+ };
5659
+ if (!maxBytes || maxBytes <= 0) return result;
5660
+ try {
5661
+ const stream = fssync.createReadStream(filePath, {
5662
+ encoding: "utf8",
5663
+ start: 0,
5664
+ end: maxBytes - 1,
5665
+ });
5666
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
5667
+ try {
5668
+ for await (const line of rl) {
5669
+ if (!line || !line.trim()) continue;
5670
+ let record;
5671
+ try {
5672
+ record = JSON.parse(line);
5673
+ } catch (_e) {
5674
+ continue;
5675
+ }
5676
+ // Must mirror v1's isCopilotChatSpan exactly: BOTH the
5677
+ // gen_ai.operation.name path AND the legacy name-prefix fallback.
5678
+ // Missing the second path lets metric-free files of name-only CLI spans
5679
+ // look like "v1 skipped" -> offset reset -> re-read -> double-count.
5680
+ if (isCopilotV1ChatSpan(record)) {
5681
+ result.v1Processed = true;
5682
+ incrementMapCount(result.v1LineHashes, copilotLineHash(line));
5683
+ } else if (isCopilotChatSpan(record)) {
5684
+ result.v2OnlyChat = true;
5685
+ }
5686
+ }
5687
+ } finally {
5688
+ rl.close();
5689
+ stream.destroy();
5690
+ }
5691
+ } catch (_e) {}
5692
+ return result;
5693
+ }
5694
+
5372
5695
  async function parseCopilotIncremental({ otelPaths, cursors, queuePath, onProgress, env } = {}) {
5373
5696
  await ensureDir(path.dirname(queuePath));
5374
5697
  const copilotState = cursors.copilot && typeof cursors.copilot === "object" ? cursors.copilot : {};
5375
5698
  const seenIds = new Set(Array.isArray(copilotState.seenIds) ? copilotState.seenIds : []);
5376
- const fileOffsets =
5699
+ const priorVersion = Number(copilotState.version) || 1;
5700
+ const fileOffsetsRaw =
5377
5701
  copilotState.fileOffsets && typeof copilotState.fileOffsets === "object"
5378
- ? { ...copilotState.fileOffsets }
5702
+ ? copilotState.fileOffsets
5379
5703
  : {};
5704
+ const fileOffsets = { ...fileOffsetsRaw };
5705
+ const migrationSkipLineHashes = new Map();
5706
+ // One-shot v1->v2 migration:
5707
+ // - pure v2-only files: clear offset and re-read all skipped Chat records
5708
+ // - pure v1 CLI files: preserve offset to avoid replaying history beyond seenIds
5709
+ // - mixed files: clear offset, but skip old v1 CLI lines by hash during replay
5710
+ if (priorVersion < COPILOT_PARSER_VERSION) {
5711
+ for (const filePath of Object.keys(fileOffsets)) {
5712
+ const prevSize = Number(fileOffsets[filePath]?.size) || 0;
5713
+ const scan = await scanCopilotV1MigrationFile(filePath, prevSize);
5714
+ if (!scan.v1Processed) {
5715
+ delete fileOffsets[filePath];
5716
+ } else if (scan.v2OnlyChat) {
5717
+ delete fileOffsets[filePath];
5718
+ migrationSkipLineHashes.set(filePath, scan.v1LineHashes);
5719
+ }
5720
+ }
5721
+ }
5380
5722
 
5381
5723
  const files = Array.isArray(otelPaths) && otelPaths.length > 0
5382
5724
  ? otelPaths
5383
5725
  : resolveCopilotOtelPaths(env || process.env);
5384
5726
  if (files.length === 0) {
5385
- cursors.copilot = { ...copilotState, seenIds: Array.from(seenIds), fileOffsets, updatedAt: new Date().toISOString() };
5727
+ cursors.copilot = {
5728
+ ...copilotState,
5729
+ version: COPILOT_PARSER_VERSION,
5730
+ seenIds: Array.from(seenIds),
5731
+ fileOffsets,
5732
+ updatedAt: new Date().toISOString(),
5733
+ };
5386
5734
  return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
5387
5735
  }
5388
5736
 
@@ -5421,6 +5769,16 @@ async function parseCopilotIncremental({ otelPaths, cursors, queuePath, onProgre
5421
5769
 
5422
5770
  for await (const line of rl) {
5423
5771
  if (!line || !line.trim()) continue;
5772
+ const skipLineHashes = migrationSkipLineHashes.get(filePath);
5773
+ if (skipLineHashes && skipLineHashes.size > 0) {
5774
+ const lineHash = copilotLineHash(line);
5775
+ const skipCount = skipLineHashes.get(lineHash) || 0;
5776
+ if (skipCount > 0) {
5777
+ if (skipCount === 1) skipLineHashes.delete(lineHash);
5778
+ else skipLineHashes.set(lineHash, skipCount - 1);
5779
+ continue;
5780
+ }
5781
+ }
5424
5782
  let record;
5425
5783
  try {
5426
5784
  record = JSON.parse(line);
@@ -5430,25 +5788,37 @@ async function parseCopilotIncremental({ otelPaths, cursors, queuePath, onProgre
5430
5788
  recordsProcessed++;
5431
5789
  if (!isCopilotChatSpan(record)) continue;
5432
5790
 
5433
- const traceId = record?.traceId || "";
5434
- const spanId = record?.spanId || "";
5435
- const dedupKey = traceId && spanId ? `${traceId}:${spanId}` : null;
5791
+ const attrs = record.attributes || {};
5792
+ // Dedup: CLI puts traceId/spanId at the top level; the Chat extension
5793
+ // file exporter writes LogRecord-shaped entries without either, but
5794
+ // gen_ai.response.id is per-LLM-call unique.
5795
+ const dedupKey = getCopilotDedupKey(record, attrs);
5436
5796
  if (dedupKey && seenIds.has(dedupKey)) continue;
5437
5797
 
5438
- const attrs = record.attributes || {};
5439
5798
  const inputRaw = toNonNegativeInt(attrs["gen_ai.usage.input_tokens"]);
5440
5799
  const output = toNonNegativeInt(attrs["gen_ai.usage.output_tokens"]);
5441
5800
  const cacheRead = toNonNegativeInt(attrs["gen_ai.usage.cache_read.input_tokens"]);
5442
- const cacheWrite = toNonNegativeInt(attrs["gen_ai.usage.cache_write.input_tokens"]);
5443
- const reasoning = toNonNegativeInt(attrs["gen_ai.usage.reasoning.output_tokens"]);
5801
+ // Copilot CLI: cache_write.input_tokens; Copilot Chat extension: cache_creation.input_tokens
5802
+ const cacheWrite = toNonNegativeInt(
5803
+ attrs["gen_ai.usage.cache_write.input_tokens"] ??
5804
+ attrs["gen_ai.usage.cache_creation.input_tokens"],
5805
+ );
5806
+ // Copilot CLI: reasoning.output_tokens; Copilot Chat extension: reasoning_tokens
5807
+ const reasoning = toNonNegativeInt(
5808
+ attrs["gen_ai.usage.reasoning.output_tokens"] ?? attrs["gen_ai.usage.reasoning_tokens"],
5809
+ );
5444
5810
  // OTEL input_tokens INCLUDES cache_read — subtract per project convention
5445
5811
  const cacheReadClamped = Math.min(cacheRead, inputRaw);
5446
5812
  const input = Math.max(0, inputRaw - cacheReadClamped);
5447
5813
  const totalInteresting = input + output + cacheReadClamped + cacheWrite + reasoning;
5448
- // Drop empty rows unless cache-only
5449
5814
  if (totalInteresting === 0) continue;
5450
5815
 
5451
- const tsMs = copilotOtelTimeToMs(record.endTime) || copilotOtelTimeToMs(record.startTime);
5816
+ // CLI Span uses endTime/startTime; Chat extension LogRecord uses hrTime/hrTimeObserved.
5817
+ const tsMs =
5818
+ copilotOtelTimeToMs(record.endTime) ||
5819
+ copilotOtelTimeToMs(record.startTime) ||
5820
+ copilotOtelTimeToMs(record.hrTime) ||
5821
+ copilotOtelTimeToMs(record.hrTimeObserved);
5452
5822
  if (!tsMs) continue;
5453
5823
  const tsIso = new Date(tsMs).toISOString();
5454
5824
  const bucketStart = toUtcHalfHourStart(tsIso);
@@ -5502,7 +5872,13 @@ async function parseCopilotIncremental({ otelPaths, cursors, queuePath, onProgre
5502
5872
  const updatedAt = new Date().toISOString();
5503
5873
  hourlyState.updatedAt = updatedAt;
5504
5874
  cursors.hourly = hourlyState;
5505
- cursors.copilot = { ...copilotState, seenIds: cappedSeen, fileOffsets, updatedAt };
5875
+ cursors.copilot = {
5876
+ ...copilotState,
5877
+ version: COPILOT_PARSER_VERSION,
5878
+ seenIds: cappedSeen,
5879
+ fileOffsets,
5880
+ updatedAt,
5881
+ };
5506
5882
 
5507
5883
  return { recordsProcessed, eventsAggregated, bucketsQueued };
5508
5884
  }
@@ -5542,6 +5918,10 @@ module.exports = {
5542
5918
  resolveOmpSessionFiles,
5543
5919
  resolveOmpDefaultModel,
5544
5920
  parseOmpIncremental,
5921
+ resolveKilocodeRoots,
5922
+ resolveKilocodeTaskFiles,
5923
+ normalizeKilocodeProviderToModel,
5924
+ parseKilocodeIncremental,
5545
5925
  resolvePiHome,
5546
5926
  resolvePiAgentDir,
5547
5927
  resolvePiSessionFiles,