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.
- 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 +66 -2
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/rollout.js +397 -17
package/src/lib/rollout.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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 "))
|
|
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
|
|
5699
|
+
const priorVersion = Number(copilotState.version) || 1;
|
|
5700
|
+
const fileOffsetsRaw =
|
|
5377
5701
|
copilotState.fileOffsets && typeof copilotState.fileOffsets === "object"
|
|
5378
|
-
?
|
|
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 = {
|
|
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
|
|
5434
|
-
|
|
5435
|
-
|
|
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
|
-
|
|
5443
|
-
const
|
|
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
|
-
|
|
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 = {
|
|
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,
|