repospend 0.0.8 → 0.1.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/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // apps/server/src/cli.ts
4
- import { spawn } from "node:child_process";
4
+ import { spawn as spawn2 } from "node:child_process";
5
5
 
6
6
  // packages/core/src/aggregate.ts
7
7
  function summarize(sessions) {
@@ -51,7 +51,12 @@ function groupByRepo(sessions) {
51
51
  }));
52
52
  }
53
53
  function groupByModel(sessions) {
54
- return groupBy(sessions, (session) => session.model ?? "unknown-model", (session) => session.model ?? "Model not recorded");
54
+ const groups = groupBy(sessions, (session) => session.model ?? "unknown-model", (session) => session.model ?? "Model not recorded");
55
+ const unknownModelSessions = sessions.filter((session) => !session.model);
56
+ if (unknownModelSessions.length > 0 && unknownModelSessions.every(isClaudeSyntheticZeroUsageSession)) {
57
+ return groups.map((group) => group.id === "unknown-model" ? { ...group, label: "Claude synthetic / no usage" } : group);
58
+ }
59
+ return groups;
55
60
  }
56
61
  function groupBySourceApp(sessions) {
57
62
  return groupBy(sessions, (session) => session.sourceApp, (session) => session.sourceApp);
@@ -100,6 +105,9 @@ function groupBy(sessions, keyFn, labelFn) {
100
105
  }
101
106
  return [...groups.values()].sort((a, b) => (b.estimatedCostUsd ?? 0) - (a.estimatedCostUsd ?? 0) || b.totalTokens - a.totalTokens);
102
107
  }
108
+ function isClaudeSyntheticZeroUsageSession(session) {
109
+ return session.warnings.includes("claude_synthetic_zero_usage");
110
+ }
103
111
  function toCsv(sessions) {
104
112
  const columns = [
105
113
  "id",
@@ -195,7 +203,8 @@ function unique(values) {
195
203
  return [...new Set(values)].sort();
196
204
  }
197
205
 
198
- // packages/core/src/config.ts
206
+ // packages/core/src/cache.ts
207
+ import crypto from "node:crypto";
199
208
  import fs2 from "node:fs";
200
209
  import path2 from "node:path";
201
210
 
@@ -203,12 +212,99 @@ import path2 from "node:path";
203
212
  import fs from "node:fs";
204
213
  import os from "node:os";
205
214
  import path from "node:path";
215
+
216
+ // packages/types/src/index.ts
217
+ function normalizePricingModelId(model) {
218
+ return model.toLowerCase().replace(/^github_copilot\//, "").replace(/^github-copilot\//, "").replace(/^copilot\//, "").replace(/claude-(opus|sonnet|haiku)-(\d+)\.(\d+)(?=$|[-.])/, "claude-$1-$2-$3");
219
+ }
220
+ function claudePricingFamilyModel(model, isUsable = () => true) {
221
+ const families = [
222
+ "claude-opus-4-7",
223
+ "claude-opus-4-6",
224
+ "claude-opus-4-5",
225
+ "claude-opus-4-1",
226
+ "claude-opus-4",
227
+ "claude-sonnet-4-6",
228
+ "claude-sonnet-4-5",
229
+ "claude-sonnet-4",
230
+ "claude-haiku-4-5",
231
+ "claude-3-5-haiku"
232
+ ];
233
+ const normalized = normalizePricingModelId(model);
234
+ if (normalized === "claude-opus-4-6-fast-mode") return void 0;
235
+ return families.find((candidate) => (normalized === candidate || normalized.startsWith(`${candidate}-`) || normalized.startsWith(`${candidate}.`)) && isUsable(candidate));
236
+ }
237
+ function copilotPricingFamilyModel(model, isUsable = () => true) {
238
+ const normalized = normalizePricingModelId(model);
239
+ const claudeFamily = claudePricingFamilyModel(normalized, isUsable);
240
+ if (claudeFamily) return claudeFamily;
241
+ if ((normalized === "gpt-4.1" || normalized.startsWith("gpt-4.1-") || normalized.startsWith("gpt-41")) && isUsable("gpt-4.1")) return "gpt-4.1";
242
+ if ((normalized === "gemini-2.5-pro" || normalized.startsWith("gemini-2.5-pro-")) && isUsable("gemini-2.5-pro")) return "gemini-2.5-pro";
243
+ if ((normalized === "gemini-3-flash" || normalized.startsWith("gemini-3-flash-")) && isUsable("gemini-3-flash")) return "gemini-3-flash";
244
+ if ((normalized === "gemini-3.1-pro" || normalized.startsWith("gemini-3.1-pro-")) && isUsable("gemini-3.1-pro")) return "gemini-3.1-pro";
245
+ if ((normalized === "gemini-3.5-flash" || normalized.startsWith("gemini-3.5-flash-")) && isUsable("gemini-3.5-flash")) return "gemini-3.5-flash";
246
+ if ((normalized === "raptor-mini" || normalized.startsWith("raptor-mini-") || normalized.startsWith("oswe-vscode")) && isUsable("raptor-mini")) return "raptor-mini";
247
+ if ((normalized === "lark" || normalized.startsWith("lark-")) && isUsable("lark")) return "lark";
248
+ if ((normalized === "goldeneye" || normalized.startsWith("goldeneye-")) && isUsable("goldeneye")) return "goldeneye";
249
+ return void 0;
250
+ }
251
+ function parseVersionedModel(normalized) {
252
+ if (normalized.includes("fast-mode")) return void 0;
253
+ const claude = normalized.match(/^(claude-(?:opus|sonnet|haiku))-(\d+)(?:-(\d+))?/);
254
+ if (claude) {
255
+ const minor = claude[3] ? Number(claude[3]) : 0;
256
+ return { tier: claude[1], version: Number(claude[2]) + minor / 100 };
257
+ }
258
+ const gpt = normalized.match(/^(gpt)-(\d)(?:\.(\d+))?(-[a-z][a-z0-9-]*)?$/);
259
+ if (gpt) {
260
+ const minor = gpt[3] ? Number(gpt[3]) : 0;
261
+ return { tier: `gpt${gpt[4] ?? ""}`, version: Number(gpt[2]) + minor / 100 };
262
+ }
263
+ return void 0;
264
+ }
265
+ function versionedFallbackModel(model, knownModels, isUsable = () => true) {
266
+ const target = parseVersionedModel(normalizePricingModelId(model));
267
+ if (!target) return void 0;
268
+ let best;
269
+ for (const candidate of knownModels) {
270
+ const parsed = parseVersionedModel(normalizePricingModelId(candidate));
271
+ if (!parsed || parsed.tier !== target.tier || parsed.version > target.version) continue;
272
+ if (!isUsable(candidate)) continue;
273
+ if (!best || parsed.version > best.version) best = { id: candidate, version: parsed.version };
274
+ }
275
+ return best?.id;
276
+ }
277
+ function isCopilotAliasModel(model) {
278
+ const normalized = normalizePricingModelId(model);
279
+ return normalized.startsWith("raptor-mini") || normalized.startsWith("oswe-vscode") || normalized === "lark" || normalized.startsWith("lark-") || normalized.startsWith("goldeneye");
280
+ }
281
+ function positiveRate(value) {
282
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
283
+ }
284
+ function isUsableModelPricing(pricing) {
285
+ return positiveRate(pricing?.inputPerMillion) && positiveRate(pricing?.outputPerMillion);
286
+ }
287
+ function findModelPricing(model, pricing) {
288
+ const lower = normalizePricingModelId(model);
289
+ const exact = pricing[model] ?? pricing[lower];
290
+ if (isUsableModelPricing(exact)) return exact;
291
+ const versioned = versionedFallbackModel(lower, Object.keys(pricing), (candidate) => isUsableModelPricing(pricing[candidate]));
292
+ if (versioned) return pricing[versioned];
293
+ const claudeFamily = claudePricingFamilyModel(lower, (candidate) => isUsableModelPricing(pricing[candidate]));
294
+ if (claudeFamily) return pricing[claudeFamily];
295
+ const copilotFamily = copilotPricingFamilyModel(lower, (candidate) => isUsableModelPricing(pricing[candidate]));
296
+ if (copilotFamily) return pricing[copilotFamily];
297
+ return void 0;
298
+ }
299
+
300
+ // packages/core/src/pricing.ts
206
301
  var pricingInfo = {
207
- sourceName: "Bundled OpenAI and Claude API pricing",
302
+ sourceName: "Bundled API-equivalent model pricing",
208
303
  sourceUrl: "https://developers.openai.com/api/docs/pricing",
209
304
  sourceUrls: [
210
305
  { label: "OpenAI pricing reference", url: "https://developers.openai.com/api/docs/pricing" },
211
- { label: "Claude pricing reference", url: "https://platform.claude.com/docs/en/about-claude/pricing" }
306
+ { label: "Claude pricing reference", url: "https://platform.claude.com/docs/en/about-claude/pricing" },
307
+ { label: "GitHub Copilot model pricing reference", url: "https://docs.github.com/en/copilot/reference/copilot-billing/models-and-pricing" }
212
308
  ],
213
309
  unit: "USD per 1M tokens",
214
310
  updatedAt: "2026-05-18",
@@ -237,8 +333,19 @@ var defaultPricing = {
237
333
  "gpt-5-pro": { inputPerMillion: 15, outputPerMillion: 120, reasoningOutputPerMillion: 120 },
238
334
  "gpt-5-mini": { inputPerMillion: 0.25, cachedInputPerMillion: 0.025, outputPerMillion: 2, reasoningOutputPerMillion: 2 },
239
335
  "gpt-5-nano": { inputPerMillion: 0.05, cachedInputPerMillion: 5e-3, outputPerMillion: 0.4, reasoningOutputPerMillion: 0.4 },
336
+ "gpt-4.1": { inputPerMillion: 2, cachedInputPerMillion: 0.5, outputPerMillion: 8 },
337
+ "gemini-2.5-pro": { inputPerMillion: 1.25, cachedInputPerMillion: 0.125, outputPerMillion: 10, note: "GitHub Copilot supported Google-hosted model; API-equivalent estimate, not a Copilot bill." },
338
+ "gemini-3-flash": { inputPerMillion: 0.5, cachedInputPerMillion: 0.05, outputPerMillion: 3, note: "GitHub Copilot supported Google-hosted model; API-equivalent estimate, not a Copilot bill." },
339
+ "gemini-3.1-pro": { inputPerMillion: 2, cachedInputPerMillion: 0.2, outputPerMillion: 12, note: "GitHub Copilot supported Google-hosted model; API-equivalent estimate, not a Copilot bill." },
340
+ "gemini-3.5-flash": { inputPerMillion: 1.5, cachedInputPerMillion: 0.15, outputPerMillion: 9, note: "GitHub Copilot supported Google-hosted model; API-equivalent estimate, not a Copilot bill." },
341
+ "raptor-mini": { inputPerMillion: 0.25, cachedInputPerMillion: 0.025, outputPerMillion: 2, note: "GitHub Copilot fine-tuned model using GPT-5 mini pricing." },
342
+ "oswe-vscode-prime": { inputPerMillion: 0.25, cachedInputPerMillion: 0.025, outputPerMillion: 2, note: "GitHub Copilot Raptor mini internal model id." },
343
+ "lark": { inputPerMillion: 0.25, cachedInputPerMillion: 0.025, outputPerMillion: 2, note: "GitHub Copilot preview model; estimated with lightweight Copilot pricing." },
344
+ "goldeneye": { inputPerMillion: 1.25, cachedInputPerMillion: 0.125, outputPerMillion: 10, note: "GitHub Copilot fine-tuned model using GPT-5.1-Codex pricing." },
345
+ "claude-opus-4-8": { inputPerMillion: 5, cacheCreationInputPerMillion: 6.25, cachedInputPerMillion: 0.5, outputPerMillion: 25 },
240
346
  "claude-opus-4-7": { inputPerMillion: 5, cacheCreationInputPerMillion: 6.25, cachedInputPerMillion: 0.5, outputPerMillion: 25 },
241
347
  "claude-opus-4-6": { inputPerMillion: 5, cacheCreationInputPerMillion: 6.25, cachedInputPerMillion: 0.5, outputPerMillion: 25 },
348
+ "claude-opus-4-6-fast-mode": { inputPerMillion: 0, cacheCreationInputPerMillion: 0, cachedInputPerMillion: 0, outputPerMillion: 0, note: "GitHub Copilot supported model, but public per-token pricing is not listed separately. RepoSpend leaves API-equivalent pricing unset." },
242
349
  "claude-opus-4-5": { inputPerMillion: 5, cacheCreationInputPerMillion: 6.25, cachedInputPerMillion: 0.5, outputPerMillion: 25 },
243
350
  "claude-opus-4-1": { inputPerMillion: 15, cacheCreationInputPerMillion: 18.75, cachedInputPerMillion: 1.5, outputPerMillion: 75 },
244
351
  "claude-opus-4": { inputPerMillion: 15, cacheCreationInputPerMillion: 18.75, cachedInputPerMillion: 1.5, outputPerMillion: 75 },
@@ -286,67 +393,266 @@ function calculateCostUsd(usage, pricing) {
286
393
  const cost = billableInput / 1e6 * modelPricing.inputPerMillion + usage.cachedInputTokens / 1e6 * cachedRate + cacheCreationInput / 1e6 * cacheCreationRate + usage.outputTokens / 1e6 * modelPricing.outputPerMillion + usage.reasoningTokens / 1e6 * reasoningRate;
287
394
  return Number(cost.toFixed(6));
288
395
  }
289
- function findModelPricing(model, pricing) {
290
- const lower = model.toLowerCase();
291
- const exactPricing = pricing[model] ?? pricing[lower];
292
- if (isUsablePricing(exactPricing)) return exactPricing;
293
- const familyPricing = claudeFamilyPricing(lower, pricing);
294
- if (isUsablePricing(familyPricing)) return familyPricing;
295
- return void 0;
396
+
397
+ // packages/core/src/cache.ts
398
+ var activeCacheVersion = "parse-v1";
399
+ var preparedCacheRoot;
400
+ function parseCacheRoot() {
401
+ return path2.join(repospendHome(), "cache", activeCacheVersion);
402
+ }
403
+ function readJsonCache(namespace, keyParts) {
404
+ const cachePath = cacheEntryPath(namespace, keyParts);
405
+ if (!cachePath || !fs2.existsSync(cachePath)) return void 0;
406
+ try {
407
+ return JSON.parse(fs2.readFileSync(cachePath, "utf8"));
408
+ } catch {
409
+ fs2.rmSync(cachePath, { force: true });
410
+ return void 0;
411
+ }
296
412
  }
297
- function claudeFamilyPricing(model, pricing) {
298
- const families = [
299
- "claude-opus-4-7",
300
- "claude-opus-4-6",
301
- "claude-opus-4-5",
302
- "claude-opus-4-1",
303
- "claude-opus-4",
304
- "claude-sonnet-4-6",
305
- "claude-sonnet-4-5",
306
- "claude-sonnet-4",
307
- "claude-haiku-4-5",
308
- "claude-3-5-haiku"
309
- ];
310
- const family = families.find((candidate) => model === candidate || model.startsWith(`${candidate}-`));
311
- return family ? pricing[family] : void 0;
413
+ function writeJsonCache(namespace, keyParts, value) {
414
+ const cachePath = cacheEntryPath(namespace, keyParts);
415
+ if (!cachePath) return;
416
+ try {
417
+ fs2.mkdirSync(path2.dirname(cachePath), { recursive: true });
418
+ fs2.writeFileSync(cachePath, `${JSON.stringify(value)}
419
+ `);
420
+ } catch {
421
+ }
422
+ }
423
+ function clearRepoSpendCache() {
424
+ const target = path2.join(repospendHome(), "cache");
425
+ const removed = fs2.existsSync(target);
426
+ fs2.rmSync(target, { recursive: true, force: true });
427
+ preparedCacheRoot = void 0;
428
+ return { path: target, removed };
429
+ }
430
+ function cacheEntryPath(namespace, keyParts) {
431
+ prepareCacheRoot();
432
+ const safeNamespace = namespace.replace(/[^a-z0-9_-]/gi, "-");
433
+ const key = crypto.createHash("sha256").update(JSON.stringify(keyParts)).digest("hex");
434
+ return path2.join(parseCacheRoot(), safeNamespace, `${key}.json`);
312
435
  }
313
- function isUsablePricing(pricing) {
314
- return typeof pricing?.inputPerMillion === "number" && pricing.inputPerMillion > 0 && typeof pricing.outputPerMillion === "number" && pricing.outputPerMillion > 0;
436
+ function prepareCacheRoot() {
437
+ const root = path2.join(repospendHome(), "cache");
438
+ if (preparedCacheRoot === root) return;
439
+ preparedCacheRoot = root;
440
+ try {
441
+ fs2.mkdirSync(parseCacheRoot(), { recursive: true });
442
+ for (const entry of fs2.readdirSync(root, { withFileTypes: true })) {
443
+ if (entry.isDirectory() && entry.name !== activeCacheVersion) {
444
+ fs2.rmSync(path2.join(root, entry.name), { recursive: true, force: true });
445
+ }
446
+ }
447
+ } catch {
448
+ }
315
449
  }
316
450
 
317
451
  // packages/core/src/config.ts
452
+ import fs3 from "node:fs";
453
+ import path3 from "node:path";
318
454
  function configPath() {
319
- return path2.join(repospendHome(), "config.json");
455
+ return path3.join(repospendHome(), "config.json");
320
456
  }
321
457
  function loadConfig() {
322
458
  return loadConfigWithWarnings().config;
323
459
  }
324
460
  function loadConfigWithWarnings() {
325
461
  const localConfigPath = configPath();
326
- if (!fs2.existsSync(localConfigPath)) {
462
+ if (!fs3.existsSync(localConfigPath)) {
327
463
  return { config: {}, warnings: [] };
328
464
  }
329
465
  try {
330
- return { config: JSON.parse(fs2.readFileSync(localConfigPath, "utf8")), warnings: [] };
466
+ return { config: JSON.parse(fs3.readFileSync(localConfigPath, "utf8")), warnings: [] };
331
467
  } catch (error) {
332
468
  const message = error instanceof Error ? error.message : String(error);
333
469
  return { config: {}, warnings: [`Unable to parse ${localConfigPath}: ${message}`] };
334
470
  }
335
471
  }
472
+ function saveConfig(config) {
473
+ const localConfigPath = configPath();
474
+ fs3.mkdirSync(path3.dirname(localConfigPath), { recursive: true });
475
+ fs3.writeFileSync(localConfigPath, `${JSON.stringify(config, null, 2)}
476
+ `);
477
+ return loadConfig();
478
+ }
336
479
  function normalizePathForMatch(inputPath) {
337
- return path2.resolve(inputPath).replace(/\/+$/, "");
480
+ return path3.resolve(inputPath).replace(/\/+$/, "");
338
481
  }
339
482
  function aliasForPath(repoRoot, config) {
340
483
  const resolvedRoot = normalizePathForMatch(repoRoot);
341
484
  const match = config.repos?.find(
342
485
  (repo) => repo.paths.some((repoPath) => {
343
486
  const candidate = normalizePathForMatch(repoPath);
344
- return resolvedRoot === candidate || resolvedRoot.startsWith(`${candidate}${path2.sep}`);
487
+ return resolvedRoot === candidate || resolvedRoot.startsWith(`${candidate}${path3.sep}`);
345
488
  })
346
489
  );
347
490
  return match?.name;
348
491
  }
349
492
 
493
+ // packages/core/src/data-confidence.ts
494
+ function buildDataConfidence(input) {
495
+ const sessions = input.sessions;
496
+ const availableSources = input.sources.filter((source) => source.available);
497
+ const importedSessions = input.sourceStats.reduce((sum2, source) => sum2 + (source.sessionsImported ?? 0), 0);
498
+ const tokenDataSessions = sessions.filter((session) => session.totalTokens > 0);
499
+ const missingTokenSessions = sessions.filter((session) => session.totalTokens === 0 || session.warnings.includes("missing_token_breakdown"));
500
+ const pricedTokenSessions = tokenDataSessions.filter((session) => session.estimatedCostUsd !== void 0);
501
+ const unpricedTokenSessions = tokenDataSessions.filter((session) => session.estimatedCostUsd === void 0);
502
+ const highConfidenceTokenSessions = tokenDataSessions.filter((session) => session.tokenConfidence === "high");
503
+ const unknownRepoSessions = sessions.filter((session) => session.warnings.includes("repo_unverified_no_git_root"));
504
+ const verifiedRepoSessions = sessions.filter((session) => !unknownRepoSessions.includes(session));
505
+ const unknownSurfaceSessions = sessions.filter((session) => (session.detectedSurface ?? "unknown") === "unknown");
506
+ const parseIssueCount = Math.max(
507
+ input.scan.parseFailureCount ?? 0,
508
+ input.sourceStats.reduce((sum2, source) => sum2 + (source.parseFailureCount ?? 0), 0)
509
+ );
510
+ const sourceWarningCount = input.sources.reduce((sum2, source) => sum2 + source.warnings.length, 0);
511
+ const tokenDataPct = ratio(tokenDataSessions.length, sessions.length);
512
+ const pricingCoveragePct = tokenDataSessions.length > 0 ? ratio(pricedTokenSessions.length, tokenDataSessions.length) : sessions.length > 0 ? 0 : 1;
513
+ const highConfidenceTokenPct = tokenDataSessions.length > 0 ? ratio(highConfidenceTokenSessions.length, tokenDataSessions.length) : sessions.length > 0 ? 0 : 1;
514
+ const verifiedRepoPct = ratio(verifiedRepoSessions.length, sessions.length);
515
+ const parseHealthPct = parseIssueCount === 0 ? 1 : 1 - ratio(parseIssueCount, Math.max(input.scan.rawSessionCount ?? sessions.length, sessions.length, 1));
516
+ const rawScore = Math.round(100 * (tokenDataPct * 0.3 + pricingCoveragePct * 0.25 + verifiedRepoPct * 0.18 + highConfidenceTokenPct * 0.12 + Math.max(parseHealthPct, 0) * 0.15));
517
+ const score = emptyDataScore(availableSources.length, importedSessions) ?? rawScore;
518
+ const issues = dataConfidenceIssues({
519
+ sessions,
520
+ availableSources,
521
+ importedSessions,
522
+ tokenDataSessions,
523
+ missingTokenSessions,
524
+ unpricedTokenSessions,
525
+ unknownRepoSessions,
526
+ unknownSurfaceSessions,
527
+ parseIssueCount,
528
+ sourceWarningCount
529
+ });
530
+ return {
531
+ score,
532
+ label: score >= 80 ? "High" : score >= 55 ? "Medium" : "Low",
533
+ sessionCount: sessions.length,
534
+ sourceCount: availableSources.length,
535
+ tokenDataSessions: tokenDataSessions.length,
536
+ missingTokenSessions: missingTokenSessions.length,
537
+ pricedTokenSessions: pricedTokenSessions.length,
538
+ unpricedTokenSessions: unpricedTokenSessions.length,
539
+ highConfidenceTokenSessions: highConfidenceTokenSessions.length,
540
+ verifiedRepoSessions: verifiedRepoSessions.length,
541
+ unknownRepoSessions: unknownRepoSessions.length,
542
+ unknownSurfaceSessions: unknownSurfaceSessions.length,
543
+ parseIssueCount,
544
+ sourceWarningCount,
545
+ tokenDataPct,
546
+ pricingCoveragePct,
547
+ highConfidenceTokenPct,
548
+ verifiedRepoPct,
549
+ issues
550
+ };
551
+ }
552
+ function dataConfidenceIssues({
553
+ sessions,
554
+ availableSources,
555
+ importedSessions,
556
+ tokenDataSessions,
557
+ missingTokenSessions,
558
+ unpricedTokenSessions,
559
+ unknownRepoSessions,
560
+ unknownSurfaceSessions,
561
+ parseIssueCount,
562
+ sourceWarningCount
563
+ }) {
564
+ const issues = [];
565
+ if (availableSources.length === 0) {
566
+ issues.push({
567
+ id: "no-local-sources",
568
+ title: "No local source data found",
569
+ detail: "RepoSpend did not find enabled local Codex, Claude Code, GitHub Copilot, or experimental Cursor data paths.",
570
+ tone: "critical",
571
+ affectedSessionIds: []
572
+ });
573
+ } else if (sessions.length === 0 && importedSessions === 0) {
574
+ issues.push({
575
+ id: "no-sessions-imported",
576
+ title: "No sessions imported",
577
+ detail: "Source paths exist, but no local sessions were imported for the current scan window.",
578
+ tone: "attention",
579
+ affectedSessionIds: []
580
+ });
581
+ }
582
+ if (tokenDataSessions.length === 0 && sessions.length > 0) {
583
+ issues.push({
584
+ id: "no-token-data",
585
+ title: "No token-bearing sessions",
586
+ detail: "Sessions were imported, but local files did not include token counts that RepoSpend can price or roll up.",
587
+ tone: "critical",
588
+ affectedSessionIds: sessions.map((session) => session.id)
589
+ });
590
+ } else if (missingTokenSessions.length > 0) {
591
+ issues.push({
592
+ id: "missing-token-data",
593
+ title: "Some sessions are missing token data",
594
+ detail: `${missingTokenSessions.length} session${missingTokenSessions.length === 1 ? "" : "s"} have no token total or are missing a full token breakdown.`,
595
+ tone: "attention",
596
+ affectedSessionIds: missingTokenSessions.map((session) => session.id)
597
+ });
598
+ }
599
+ if (unpricedTokenSessions.length > 0) {
600
+ issues.push({
601
+ id: "unpriced-token-sessions",
602
+ title: "Some token-bearing sessions are unpriced",
603
+ detail: `${unpricedTokenSessions.length} token-bearing session${unpricedTokenSessions.length === 1 ? "" : "s"} cannot show API-equivalent cost because pricing or token split coverage is missing.`,
604
+ tone: "attention",
605
+ affectedSessionIds: unpricedTokenSessions.map((session) => session.id)
606
+ });
607
+ }
608
+ if (parseIssueCount > 0) {
609
+ issues.push({
610
+ id: "parse-issues",
611
+ title: "Parser issues detected",
612
+ detail: `${parseIssueCount} local file${parseIssueCount === 1 ? "" : "s"} reported parse issues or skipped records.`,
613
+ tone: "attention",
614
+ affectedSessionIds: sessions.filter((session) => session.parseStatus === "failed" || (session.parseErrors?.length ?? 0) > 0).map((session) => session.id)
615
+ });
616
+ }
617
+ if (unknownRepoSessions.length > 0) {
618
+ issues.push({
619
+ id: "unknown-repo-grouping",
620
+ title: "Some repo grouping is unverified",
621
+ detail: `${unknownRepoSessions.length} session${unknownRepoSessions.length === 1 ? "" : "s"} could not be tied to a verified Git root.`,
622
+ tone: "attention",
623
+ affectedSessionIds: unknownRepoSessions.map((session) => session.id)
624
+ });
625
+ }
626
+ if (unknownSurfaceSessions.length > 0) {
627
+ issues.push({
628
+ id: "unknown-surfaces",
629
+ title: "Some app surfaces are unknown",
630
+ detail: `${unknownSurfaceSessions.length} session${unknownSurfaceSessions.length === 1 ? "" : "s"} did not include enough metadata to identify the app or surface.`,
631
+ tone: "attention",
632
+ affectedSessionIds: unknownSurfaceSessions.map((session) => session.id)
633
+ });
634
+ }
635
+ if (sourceWarningCount > 0) {
636
+ issues.push({
637
+ id: "source-warnings",
638
+ title: "Source warnings were reported",
639
+ detail: `${sourceWarningCount} source warning${sourceWarningCount === 1 ? " was" : "s were"} reported during local discovery.`,
640
+ tone: "attention",
641
+ affectedSessionIds: []
642
+ });
643
+ }
644
+ return issues;
645
+ }
646
+ function ratio(numerator, denominator) {
647
+ if (denominator <= 0) return 1;
648
+ return numerator / denominator;
649
+ }
650
+ function emptyDataScore(availableSourceCount, importedSessions) {
651
+ if (availableSourceCount === 0) return 0;
652
+ if (importedSessions === 0) return 50;
653
+ return void 0;
654
+ }
655
+
350
656
  // packages/core/src/filters.ts
351
657
  function filterUsage(sessions, filters) {
352
658
  return sessions.filter((session) => {
@@ -427,7 +733,20 @@ function repoWarnings(group, sessions) {
427
733
  return warnings;
428
734
  }
429
735
  function compactNumber(value) {
430
- return new Intl.NumberFormat(void 0, { maximumFractionDigits: 1, notation: "compact" }).format(value);
736
+ if (!Number.isFinite(value)) return "0";
737
+ const sign = value < 0 ? "-" : "";
738
+ const absolute = Math.abs(value);
739
+ const units = [
740
+ { threshold: 1e12, suffix: "T" },
741
+ { threshold: 1e9, suffix: "B" },
742
+ { threshold: 1e6, suffix: "M" },
743
+ { threshold: 1e3, suffix: "K" }
744
+ ];
745
+ const unit = units.find((item) => absolute >= item.threshold);
746
+ if (!unit) return new Intl.NumberFormat(void 0, { maximumFractionDigits: 1 }).format(value);
747
+ const scaled = absolute / unit.threshold;
748
+ const formatted = new Intl.NumberFormat(void 0, { maximumFractionDigits: 1 }).format(scaled);
749
+ return `${sign}${formatted}${unit.suffix}`;
431
750
  }
432
751
  function unique2(values) {
433
752
  return [...new Set(values)].sort();
@@ -591,7 +910,7 @@ var healthSignalDetectors = [
591
910
  return {
592
911
  id: "command-friction",
593
912
  tone: "attention",
594
- title: "Important command issue rate looks high",
913
+ title: "Possible failed command rate looks high",
595
914
  detail: `${percent(commandIssueRate)} of detected shell commands look like blocking, repeated, or token-expensive issues in this filtered view.`,
596
915
  action: "Look for repeated setup, dependency, permission, test, or build issues in the Sessions page.",
597
916
  actionTarget: "sessions",
@@ -662,9 +981,9 @@ var healthSignalDetectors = [
662
981
  tone: "attention",
663
982
  title: "Some sessions cannot be priced",
664
983
  detail: `${count(unknownCostSessions.length, "session")} are missing a model price or detailed token split.`,
665
- action: "Open Settings and add model rates, or inspect older Codex sessions that only expose raw totals.",
666
- actionTarget: "settings",
667
- actionLabel: "Open settings",
984
+ action: "Review the affected sessions first. Add a model rate only when the session has token details but no usable price.",
985
+ actionTarget: "sessions",
986
+ actionLabel: "Review unpriced sessions",
668
987
  metric: count(unknownCostSessions.length, "session"),
669
988
  critical: true,
670
989
  affectedSessionIds: unknownCostSessions.map((session) => session.id)
@@ -758,7 +1077,7 @@ function buildWasteSignals(sessions) {
758
1077
  var wasteSignalDetectors = [
759
1078
  wasteSignalDetector("high-token-no-edit", "High-token no-edit sessions", "Large sessions where local events did not show file edits.", (session) => (session.fileEditCount ?? 0) === 0 && session.totalTokens >= 1e6),
760
1079
  wasteSignalDetector("high-token-low-output", "High-token low-output sessions", "Large sessions where most tokens were input or cached context.", (session) => session.totalTokens >= 1e6 && session.outputTokens / Math.max(session.totalTokens, 1) < 0.03),
761
- wasteSignalDetector("command-issue-sessions", "Command issue sessions", "Sessions with blocking, repeated, or token-expensive command issues.", (session) => importantCommandFailures(session) > 0),
1080
+ wasteSignalDetector("command-issue-sessions", "Possible failed command sessions", "Sessions with blocking, repeated, or token-expensive command failures.", (session) => importantCommandFailures(session) > 0),
762
1081
  wasteSignalDetector("unknown-repos", "Unknown repo sessions", "Repo root could not be verified from local Git metadata.", (session) => session.warnings.includes("repo_unverified_no_git_root")),
763
1082
  wasteSignalDetector("unknown-surfaces", "Unknown surface sessions", "Local logs did not identify VS Code, terminal, exec, or app surface.", (session) => (session.detectedSurface ?? "unknown") === "unknown"),
764
1083
  wasteSignalDetector("repeated-errors", "Repeated error signals", "Repeated-error hints found in session warnings.", (session) => session.warnings.some((warning) => warning.includes("repeated_error")))
@@ -810,7 +1129,20 @@ function percent(value) {
810
1129
  return `${new Intl.NumberFormat(void 0, { maximumFractionDigits: 1 }).format(value * 100)}%`;
811
1130
  }
812
1131
  function compactNumber2(value) {
813
- return new Intl.NumberFormat(void 0, { maximumFractionDigits: 1, notation: "compact" }).format(value);
1132
+ if (!Number.isFinite(value)) return "0";
1133
+ const sign = value < 0 ? "-" : "";
1134
+ const absolute = Math.abs(value);
1135
+ const units = [
1136
+ { threshold: 1e12, suffix: "T" },
1137
+ { threshold: 1e9, suffix: "B" },
1138
+ { threshold: 1e6, suffix: "M" },
1139
+ { threshold: 1e3, suffix: "K" }
1140
+ ];
1141
+ const unit = units.find((item) => absolute >= item.threshold);
1142
+ if (!unit) return new Intl.NumberFormat(void 0, { maximumFractionDigits: 1 }).format(value);
1143
+ const scaled = absolute / unit.threshold;
1144
+ const formatted = new Intl.NumberFormat(void 0, { maximumFractionDigits: 1 }).format(scaled);
1145
+ return `${sign}${formatted}${unit.suffix}`;
814
1146
  }
815
1147
  function currency(value, minimumFractionDigits, maximumFractionDigits) {
816
1148
  return `$${new Intl.NumberFormat(void 0, { maximumFractionDigits, minimumFractionDigits }).format(value)}`;
@@ -853,24 +1185,25 @@ function buildDashboardSnapshot(input) {
853
1185
  hours: groupByHour(sessions),
854
1186
  models: groupByModel(sessions),
855
1187
  sourceApps,
856
- health: buildUsageHealth({ sessions, summary, repos, sourceApps, scan })
1188
+ health: buildUsageHealth({ sessions, summary, repos, sourceApps, scan }),
1189
+ confidence: buildDataConfidence({ sources: input.sources, sourceStats: input.sourceStats, sessions, scan })
857
1190
  };
858
1191
  }
859
1192
  function isDashboardVisibleSession(session) {
860
- return session.totalTokens > 0 || session.sourceClient === "claude" || session.sourceClient === "cursor";
1193
+ return session.totalTokens > 0 || session.sourceClient === "claude" || session.sourceClient === "cursor" || session.sourceClient === "copilot";
861
1194
  }
862
1195
 
863
1196
  // packages/core/src/repo.ts
864
- import fs3 from "node:fs";
865
- import path3 from "node:path";
1197
+ import fs4 from "node:fs";
1198
+ import path4 from "node:path";
866
1199
  function normalizeRoot(p) {
867
1200
  const stripped = p.startsWith("\\\\?\\") ? p.slice(4) : p;
868
1201
  return stripped.replace(/^([a-zA-Z]):/, (_, letter) => letter.toUpperCase() + ":");
869
1202
  }
870
- function findGitRoot(cwd, fileSystem = fs3) {
871
- let current = normalizeRoot(path3.resolve(cwd));
1203
+ function findGitRoot(cwd, fileSystem = fs4) {
1204
+ let current = normalizeRoot(path4.resolve(cwd));
872
1205
  while (true) {
873
- const gitPath = path3.join(current, ".git");
1206
+ const gitPath = path4.join(current, ".git");
874
1207
  if (fileSystem.existsSync(gitPath)) {
875
1208
  try {
876
1209
  const stat = fileSystem.statSync(gitPath);
@@ -881,19 +1214,19 @@ function findGitRoot(cwd, fileSystem = fs3) {
881
1214
  return current;
882
1215
  }
883
1216
  }
884
- const parent = path3.dirname(current);
1217
+ const parent = path4.dirname(current);
885
1218
  if (parent === current) {
886
1219
  return void 0;
887
1220
  }
888
1221
  current = parent;
889
1222
  }
890
1223
  }
891
- function resolveRepoInfo(cwd, config = {}, fileSystem = fs3) {
1224
+ function resolveRepoInfo(cwd, config = {}, fileSystem = fs4) {
892
1225
  const warnings = [];
893
1226
  const gitRoot = safeFindGitRoot(cwd, fileSystem);
894
1227
  const repoRoot = gitRoot ?? fallbackWorkspaceRoot(cwd);
895
1228
  const alias = aliasForPath(repoRoot, config);
896
- const repoName = alias ?? (path3.basename(repoRoot) || "unknown-workspace");
1229
+ const repoName = alias ?? (path4.basename(repoRoot) || "unknown-workspace");
897
1230
  const gitMeta = gitRoot ? readGitMetadata(gitRoot, fileSystem) : { remote: void 0, branch: void 0 };
898
1231
  if (!gitRoot) {
899
1232
  warnings.push("repo_unverified_no_git_root");
@@ -915,23 +1248,23 @@ function safeFindGitRoot(cwd, fileSystem) {
915
1248
  }
916
1249
  }
917
1250
  function fallbackWorkspaceRoot(cwd) {
918
- const resolved = normalizeRoot(path3.resolve(cwd || process.cwd()));
919
- const home = process.env.HOME ? normalizeRoot(path3.resolve(process.env.HOME)) : void 0;
920
- if (home && (resolved === home || resolved.startsWith(`${home}${path3.sep}`))) {
921
- const relative = path3.relative(home, resolved).split(path3.sep).filter(Boolean);
1251
+ const resolved = normalizeRoot(path4.resolve(cwd || process.cwd()));
1252
+ const home = process.env.HOME ? normalizeRoot(path4.resolve(process.env.HOME)) : void 0;
1253
+ if (home && (resolved === home || resolved.startsWith(`${home}${path4.sep}`))) {
1254
+ const relative = path4.relative(home, resolved).split(path4.sep).filter(Boolean);
922
1255
  const first = relative[0];
923
- return first ? path3.join(home, first) : home;
1256
+ return first ? path4.join(home, first) : home;
924
1257
  }
925
- const parts = resolved.split(path3.sep).filter(Boolean);
926
- return `${path3.sep}${parts.slice(0, Math.min(parts.length, 3)).join(path3.sep)}`;
1258
+ const parts = resolved.split(path4.sep).filter(Boolean);
1259
+ return `${path4.sep}${parts.slice(0, Math.min(parts.length, 3)).join(path4.sep)}`;
927
1260
  }
928
1261
  function readGitMetadata(repoRoot, fileSystem) {
929
- const gitPath = path3.join(repoRoot, ".git");
1262
+ const gitPath = path4.join(repoRoot, ".git");
930
1263
  try {
931
1264
  const stat = fileSystem.statSync(gitPath);
932
1265
  const gitDir = stat.isFile() ? resolveGitFile(repoRoot, fileSystem.readFileSync(gitPath, "utf8")) : gitPath;
933
- const configPath2 = path3.join(gitDir, "config");
934
- const headPath = path3.join(gitDir, "HEAD");
1266
+ const configPath2 = path4.join(gitDir, "config");
1267
+ const headPath = path4.join(gitDir, "HEAD");
935
1268
  const remote = fileSystem.existsSync(configPath2) ? parseRemote(fileSystem.readFileSync(configPath2, "utf8")) : void 0;
936
1269
  const branch = fileSystem.existsSync(headPath) ? parseBranch(fileSystem.readFileSync(headPath, "utf8")) : void 0;
937
1270
  return { remote, branch };
@@ -942,9 +1275,9 @@ function readGitMetadata(repoRoot, fileSystem) {
942
1275
  function resolveGitFile(repoRoot, contents) {
943
1276
  const match = contents.match(/^gitdir:\s*(.+)$/m);
944
1277
  if (!match?.[1]) {
945
- return path3.join(repoRoot, ".git");
1278
+ return path4.join(repoRoot, ".git");
946
1279
  }
947
- return path3.resolve(repoRoot, match[1].trim());
1280
+ return path4.resolve(repoRoot, match[1].trim());
948
1281
  }
949
1282
  function parseRemote(config) {
950
1283
  const originMatch = config.match(/\[remote "origin"\][\s\S]*?\n\s*url\s*=\s*(.+)/);
@@ -957,9 +1290,9 @@ function parseBranch(head) {
957
1290
 
958
1291
  // packages/core/src/sources/codex.ts
959
1292
  import { spawnSync } from "node:child_process";
960
- import fs4 from "node:fs";
1293
+ import fs5 from "node:fs";
961
1294
  import os2 from "node:os";
962
- import path4 from "node:path";
1295
+ import path5 from "node:path";
963
1296
  import { fileURLToPath } from "node:url";
964
1297
  import Database from "better-sqlite3";
965
1298
 
@@ -967,8 +1300,9 @@ import Database from "better-sqlite3";
967
1300
  function analyzeAgentFriction(records, totalTokens) {
968
1301
  const analysis = emptyAgentFrictionAnalysis();
969
1302
  const messages = [];
1303
+ const toolCallsById = indexToolCallsById(records);
970
1304
  for (const record of records) {
971
- applyActionMetadata(analysis, record, messages);
1305
+ applyActionMetadata(analysis, record, messages, toolCallsById);
972
1306
  }
973
1307
  finalizeCommandIssues(analysis, totalTokens);
974
1308
  return analysis;
@@ -994,7 +1328,7 @@ function emptyAgentFrictionAnalysis() {
994
1328
  assistantMessageCount: 0
995
1329
  };
996
1330
  }
997
- function applyActionMetadata(analysis, record, messages) {
1331
+ function applyActionMetadata(analysis, record, messages, toolCallsById) {
998
1332
  if (!record || typeof record !== "object") return;
999
1333
  const object = record;
1000
1334
  const payload = firstObject(object.payload) ?? object;
@@ -1005,12 +1339,73 @@ function applyActionMetadata(analysis, record, messages) {
1005
1339
  const text = JSON.stringify(record).toLowerCase();
1006
1340
  const isTool = type?.includes("tool") || type?.includes("function_call") || text.includes('"tool_call"') || text.includes('"function_call"');
1007
1341
  if (isTool) analysis.toolCallCount += 1;
1008
- const isNonZeroCommand = text.includes("exit_code") && !text.includes('"exit_code":0') && !text.includes('"exit_code": 0');
1009
- if (isNonZeroCommand) analysis.commandIssues.push(classifyCommandIssue(record, analysis.commandIssues));
1342
+ const isNonZeroCommand = hasNonZeroCommandExit(record);
1343
+ if (isNonZeroCommand) analysis.commandIssues.push(classifyCommandIssue(record, analysis.commandIssues, commandTextForToolOutput(record, toolCallsById)));
1010
1344
  if (/\b(exec_command|shell|bash|zsh|command)\b/.test(text)) analysis.shellCommandCount += 1;
1011
1345
  if (/\b(read_file|view_image|cat |sed |rg |grep |open_file)\b/.test(text)) analysis.fileReadCount += 1;
1012
1346
  if (/\b(apply_patch|write_file|edit|patch|created|deleted)\b/.test(text)) analysis.fileEditCount += 1;
1013
1347
  }
1348
+ function indexToolCallsById(records) {
1349
+ const calls = /* @__PURE__ */ new Map();
1350
+ const sessionCommands = /* @__PURE__ */ new Map();
1351
+ for (const record of records) {
1352
+ if (!record || typeof record !== "object") continue;
1353
+ const object = record;
1354
+ const payload = firstObject(object.payload) ?? object;
1355
+ const type = `${stringValue(object.type) ?? ""} ${stringValue(payload.type) ?? ""}`.toLowerCase();
1356
+ const callId = stringValue(payload.call_id ?? object.call_id ?? payload.callId ?? object.callId);
1357
+ if (type.includes("function_call_output") || type.includes("custom_tool_call_output") || type.includes("tool_result")) {
1358
+ const output = stringValue(payload.output ?? object.output);
1359
+ const sessionId = output?.match(/\bProcess running with session ID\s+(\d+)\b/i)?.[1];
1360
+ const command3 = callId ? calls.get(callId) : void 0;
1361
+ if (sessionId && command3) sessionCommands.set(sessionId, command3);
1362
+ continue;
1363
+ }
1364
+ if (!type.includes("function_call") && !type.includes("custom_tool_call") && !type.includes("tool_call")) continue;
1365
+ const command2 = toolCallCommandText(payload, sessionCommands) ?? toolCallCommandText(object, sessionCommands);
1366
+ if (callId && command2) calls.set(callId, command2);
1367
+ }
1368
+ return calls;
1369
+ }
1370
+ function commandTextForToolOutput(record, toolCallsById) {
1371
+ if (!record || typeof record !== "object") return void 0;
1372
+ const object = record;
1373
+ const payload = firstObject(object.payload) ?? object;
1374
+ const callId = stringValue(payload.call_id ?? object.call_id ?? payload.callId ?? object.callId);
1375
+ return callId ? toolCallsById.get(callId) : void 0;
1376
+ }
1377
+ function toolCallCommandText(object, sessionCommands) {
1378
+ const name = `${stringValue(object.name) ?? ""} ${stringValue(object.recipient_name) ?? ""}`.toLowerCase();
1379
+ const args2 = object.arguments ?? object.args ?? object.input;
1380
+ const parsedArgs = typeof args2 === "string" ? parseJsonObject(args2) : firstObject(args2);
1381
+ const sessionId = parsedArgs ? idValue(parsedArgs.session_id) : void 0;
1382
+ if (sessionId && sessionCommands.has(sessionId)) return sessionCommands.get(sessionId);
1383
+ const command2 = parsedArgs ? extractCommandText(parsedArgs) : void 0;
1384
+ if (command2) return command2;
1385
+ if ((name.includes("exec_command") || name.includes("shell")) && typeof args2 === "string" && args2.trim()) return args2;
1386
+ const toolName = stringValue(object.name) ?? stringValue(object.recipient_name);
1387
+ if (!toolName) return void 0;
1388
+ return sessionId ? `${toolName} session ${sessionId}` : toolName;
1389
+ }
1390
+ function hasNonZeroCommandExit(record) {
1391
+ if (!record || typeof record !== "object") return false;
1392
+ const object = record;
1393
+ const payload = firstObject(object.payload) ?? object;
1394
+ const type = `${stringValue(object.type) ?? ""} ${stringValue(payload.type) ?? ""}`.toLowerCase();
1395
+ const isCommandRecord = /(function_call_output|custom_tool_call_output|tool_result|exec_command|shell|command)/.test(type) || Boolean(extractCommandText(payload) ?? extractCommandText(object));
1396
+ const explicitExitCode = exitCodeValue(payload) ?? exitCodeValue(object);
1397
+ if (explicitExitCode !== void 0) return isCommandRecord && explicitExitCode !== 0;
1398
+ const output = stringValue(payload.output) ?? stringValue(object.output);
1399
+ if (!output || !/(function_call_output|custom_tool_call_output|tool_result|exec_command)/.test(type)) return false;
1400
+ const processExitCode = output.match(/\bProcess exited with code\s+(-?\d+)\b/i)?.[1];
1401
+ return processExitCode !== void 0 && Number(processExitCode) !== 0;
1402
+ }
1403
+ function exitCodeValue(object) {
1404
+ const value = object.exit_code ?? object.exitCode;
1405
+ if (typeof value === "number" && Number.isFinite(value)) return value;
1406
+ if (typeof value === "string" && /^-?\d+$/.test(value.trim())) return Number(value);
1407
+ return void 0;
1408
+ }
1014
1409
  function finalizeCommandIssues(analysis, totalTokens) {
1015
1410
  const repeatedCommands = /* @__PURE__ */ new Map();
1016
1411
  const repeatableIssues = analysis.commandIssues.filter((issue2) => issue2.classification !== "harmless_nonzero" && issue2.classification !== "exploratory_miss");
@@ -1055,30 +1450,38 @@ function issuePriority(issue2) {
1055
1450
  return severityScore * 10 + impactScore;
1056
1451
  }
1057
1452
  function commandIssueReason(issue2) {
1058
- if (issue2.severity === "critical") return "Repeated or blocking command issue that may have stopped progress.";
1059
- if (issue2.severity === "warning") return "Non-zero command event classified as important for this session.";
1453
+ if (issue2.severity === "critical") return "Repeated or blocking command failure that may have stopped progress.";
1454
+ if (issue2.severity === "warning") return "Non-zero command event classified as worth reviewing for this session.";
1060
1455
  if (issue2.classification === "exploratory_miss") return "Likely exploratory command that found no result.";
1061
1456
  if (issue2.classification === "harmless_nonzero") return "Likely harmless non-zero exit during normal shell exploration.";
1062
1457
  return "Non-zero command event with limited local context.";
1063
1458
  }
1064
- function classifyCommandIssue(record, previousIssues) {
1459
+ function classifyCommandIssue(record, previousIssues, commandHint) {
1065
1460
  const raw = JSON.stringify(record);
1066
1461
  const text = raw.toLowerCase();
1067
- const command2 = extractCommandText(record) ?? compactCommandText(raw);
1068
- const category = commandCategory(text);
1462
+ const command2 = commandHint ?? extractCommandText(record) ?? compactCommandText(raw);
1463
+ const commandText = command2.toLowerCase();
1464
+ const classificationText = commandHint ? `${commandText} ${commandOutputText(record) ?? ""}` : text;
1465
+ const category = commandCategory(commandHint ? commandText : text);
1069
1466
  const commandKey = normalizeCommand(command2);
1070
1467
  const repeated = previousIssues.filter((issue2) => issue2.command === commandKey).length + 1 >= 3;
1071
- if (isHarmlessNonZero(text)) {
1072
- return { command: commandKey, category, classification: isExploratoryMiss(text) ? "exploratory_miss" : "harmless_nonzero", severity: "ignored", impact: "low" };
1468
+ if (isHarmlessNonZero(classificationText)) {
1469
+ return { command: commandKey, category, classification: isExploratoryMiss(classificationText) ? "exploratory_miss" : "harmless_nonzero", severity: "ignored", impact: "low" };
1073
1470
  }
1074
- if (isImportantFailure(text, category) || repeated) {
1075
- return { command: commandKey, category, classification: "blocking_failure", severity: repeated ? "critical" : importantSeverity(text, category), impact: repeated ? "high" : importantImpact(category) };
1471
+ if (isImportantFailure(classificationText, category) || repeated) {
1472
+ return { command: commandKey, category, classification: "blocking_failure", severity: repeated ? "critical" : importantSeverity(classificationText, category), impact: repeated ? "high" : importantImpact(category) };
1076
1473
  }
1077
1474
  if (category === "search" || category === "filesystem") {
1078
1475
  return { command: commandKey, category, classification: "exploratory_miss", severity: "ignored", impact: "low" };
1079
1476
  }
1080
1477
  return { command: commandKey, category, classification: "unknown", severity: "info", impact: "low" };
1081
1478
  }
1479
+ function commandOutputText(record) {
1480
+ if (!record || typeof record !== "object") return void 0;
1481
+ const object = record;
1482
+ const payload = firstObject(object.payload) ?? object;
1483
+ return stringValue(payload.output ?? object.output)?.toLowerCase().slice(0, 2e3);
1484
+ }
1082
1485
  function isHarmlessNonZero(text) {
1083
1486
  return /\b(rg|grep)\b/.test(text) || /git\s+(diff|status)/.test(text) || /\bfind\b/.test(text) || /\btest\s+(-f|-d|-e)\b/.test(text) || /\b(ls|cat)\b/.test(text) && /(optional|exists|existence|not found|no such file)/.test(text);
1084
1487
  }
@@ -1158,9 +1561,21 @@ function compactCommandText(text) {
1158
1561
  function firstObject(...values) {
1159
1562
  return values.find((value) => Boolean(value) && typeof value === "object" && !Array.isArray(value));
1160
1563
  }
1564
+ function parseJsonObject(value) {
1565
+ try {
1566
+ return firstObject(JSON.parse(value));
1567
+ } catch {
1568
+ return void 0;
1569
+ }
1570
+ }
1161
1571
  function stringValue(value) {
1162
1572
  return typeof value === "string" && value.length > 0 ? value : void 0;
1163
1573
  }
1574
+ function idValue(value) {
1575
+ if (typeof value === "string" && value.length > 0) return value;
1576
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
1577
+ return void 0;
1578
+ }
1164
1579
  function applyMessageCount(analysis, messages, role, payload, object) {
1165
1580
  const message = extractMessageText(payload) ?? extractMessageText(object);
1166
1581
  if (!message) return;
@@ -1398,36 +1813,44 @@ function numberFromKeys(object, keys) {
1398
1813
  }
1399
1814
 
1400
1815
  // packages/core/src/sources/codex.ts
1816
+ var codexSessionCacheVersion = "codex-session-v1";
1401
1817
  var nativeSqliteRebuildAttempted = false;
1402
1818
  function scanCodex(options) {
1403
- const codexHome = options.codexHome ?? path4.join(os2.homedir(), ".codex");
1404
- const statePath = path4.join(codexHome, "state_5.sqlite");
1405
- const sessionsPath = path4.join(codexHome, "sessions");
1819
+ const codexHome = options.codexHome ?? path5.join(os2.homedir(), ".codex");
1820
+ const statePath = path5.join(codexHome, "state_5.sqlite");
1821
+ const sessionsPath = path5.join(codexHome, "sessions");
1822
+ const configPath2 = path5.join(codexHome, "config.toml");
1823
+ const configExists = fs5.existsSync(configPath2);
1824
+ const serviceTier = readCodexServiceTier(configPath2);
1406
1825
  const warnings = [];
1407
1826
  const sessions = [];
1408
- const stateExists = fs4.existsSync(statePath);
1409
- const sessionsExists = fs4.existsSync(sessionsPath);
1827
+ const stateExists = fs5.existsSync(statePath);
1828
+ const sessionsExists = fs5.existsSync(sessionsPath);
1410
1829
  if (!stateExists) {
1411
1830
  warnings.push(`Codex SQLite state not found at ${statePath}`);
1412
1831
  }
1413
1832
  if (!sessionsExists) {
1414
1833
  warnings.push(`Codex sessions directory not found at ${sessionsPath}`);
1415
1834
  }
1416
- const sessionFiles = sessionsExists ? indexSessionFiles(sessionsPath, warnings) : /* @__PURE__ */ new Map();
1835
+ const sessionFiles = sessionsExists ? indexSessionFiles(sessionsPath, warnings, options.scanWindow) : /* @__PURE__ */ new Map();
1417
1836
  if (stateExists) {
1418
1837
  const stateSessions = readThreads(statePath, sessionFiles, options, warnings);
1419
1838
  sessions.push(...stateSessions);
1420
1839
  }
1421
1840
  if (sessionsExists) {
1422
- sessions.push(...readStandaloneSessions(sessionFiles, options).filter((session) => !hasImportedSession(sessions, session)));
1841
+ sessions.push(...readStandaloneSessions(remainingStandaloneSessionFiles(sessionFiles, sessions), options).filter((session) => !hasImportedSession(sessions, session)));
1423
1842
  }
1424
1843
  return {
1425
1844
  source: {
1426
1845
  id: "codex",
1427
1846
  label: "Codex",
1428
1847
  available: stateExists || sessionsExists,
1429
- paths: [statePath, sessionsPath],
1430
- warnings
1848
+ paths: configExists ? [statePath, sessionsPath, configPath2] : [statePath, sessionsPath],
1849
+ warnings,
1850
+ serviceTier,
1851
+ serviceTierSource: serviceTier ? "current_config" : void 0,
1852
+ serviceTierConfidence: serviceTier ? "medium" : void 0,
1853
+ serviceTierDetail: serviceTier ? `Current Codex config service_tier: ${serviceTier}` : void 0
1431
1854
  },
1432
1855
  sessions,
1433
1856
  stats: {
@@ -1443,10 +1866,23 @@ function scanCodex(options) {
1443
1866
  sessionsImported: sessions.length,
1444
1867
  parseFailureCount: sessions.filter((session) => session.parseStatus === "failed").length,
1445
1868
  unreadableFileCount: sessions.filter((session) => session.parseErrors?.some((error) => error.startsWith("unable_to_read_session_file:"))).length,
1869
+ serviceTier,
1870
+ serviceTierSource: serviceTier ? "current_config" : void 0,
1871
+ serviceTierConfidence: serviceTier ? "medium" : void 0,
1872
+ serviceTierDetail: serviceTier ? `Current Codex config service_tier: ${serviceTier}` : void 0,
1446
1873
  lastScannedAt: (/* @__PURE__ */ new Date()).toISOString()
1447
1874
  }
1448
1875
  };
1449
1876
  }
1877
+ function readCodexServiceTier(configPath2) {
1878
+ try {
1879
+ const text = fs5.readFileSync(configPath2, "utf8");
1880
+ const match = text.match(/^\s*service_tier\s*=\s*["']?([^"'\s#]+)["']?/m);
1881
+ return match?.[1]?.trim().toLowerCase() || void 0;
1882
+ } catch {
1883
+ return void 0;
1884
+ }
1885
+ }
1450
1886
  function readThreads(statePath, sessionFiles, options, warnings) {
1451
1887
  let db;
1452
1888
  try {
@@ -1462,8 +1898,8 @@ function readThreads(statePath, sessionFiles, options, warnings) {
1462
1898
  warnings.push("Codex threads table has no recognized columns.");
1463
1899
  return [];
1464
1900
  }
1465
- const importedModels = readImportedModelMap(options.codexHome ?? path4.join(os2.homedir(), ".codex"), warnings);
1466
- const rows = db.prepare(`SELECT ${selectedColumns.map((column) => `"${column}"`).join(", ")} FROM threads`).all();
1901
+ const importedModels = readImportedModelMap(options.codexHome ?? path5.join(os2.homedir(), ".codex"), warnings);
1902
+ const rows = db.prepare(`SELECT ${selectedColumns.map((column) => `"${column}"`).join(", ")} FROM threads`).all().filter((row) => rowMatchesScanWindow(row, options.scanWindow));
1467
1903
  const sessions = rows.map((row, index) => threadToUsage(row, index, sessionFiles, options, importedModels));
1468
1904
  return attributeSubagentsToParentSurfaces(sessions, rows);
1469
1905
  } catch (error) {
@@ -1522,7 +1958,7 @@ function attemptNativeSqliteRebuild(warnings) {
1522
1958
  warnings.push("Codex SQLite native module mismatch detected, but RepoSpend could not locate its package root for an automatic rebuild.");
1523
1959
  return false;
1524
1960
  }
1525
- const packageManager = fs4.existsSync(path4.join(packageRoot, "pnpm-lock.yaml")) ? "pnpm" : "npm";
1961
+ const packageManager = fs5.existsSync(path5.join(packageRoot, "pnpm-lock.yaml")) ? "pnpm" : "npm";
1526
1962
  const result = spawnSync(packageManager, ["rebuild", "better-sqlite3"], {
1527
1963
  cwd: packageRoot,
1528
1964
  encoding: "utf8",
@@ -1536,8 +1972,8 @@ function attemptNativeSqliteRebuild(warnings) {
1536
1972
  }
1537
1973
  function findRepoSpendPackageRoot() {
1538
1974
  const starts = [
1539
- process.argv[1] ? path4.dirname(fs4.realpathSync(process.argv[1])) : void 0,
1540
- path4.dirname(fileURLToPath(import.meta.url)),
1975
+ process.argv[1] ? path5.dirname(fs5.realpathSync(process.argv[1])) : void 0,
1976
+ path5.dirname(fileURLToPath(import.meta.url)),
1541
1977
  process.cwd()
1542
1978
  ].filter((item) => Boolean(item));
1543
1979
  for (const start of starts) {
@@ -1547,17 +1983,17 @@ function findRepoSpendPackageRoot() {
1547
1983
  return void 0;
1548
1984
  }
1549
1985
  function findPackageRootNamedRepoSpend(start) {
1550
- let current = path4.resolve(start);
1986
+ let current = path5.resolve(start);
1551
1987
  while (true) {
1552
- const packagePath = path4.join(current, "package.json");
1553
- if (fs4.existsSync(packagePath)) {
1988
+ const packagePath = path5.join(current, "package.json");
1989
+ if (fs5.existsSync(packagePath)) {
1554
1990
  try {
1555
- const packageJson = JSON.parse(fs4.readFileSync(packagePath, "utf8"));
1991
+ const packageJson = JSON.parse(fs5.readFileSync(packagePath, "utf8"));
1556
1992
  if (packageJson.name === "repospend") return current;
1557
1993
  } catch {
1558
1994
  }
1559
1995
  }
1560
- const parent = path4.dirname(current);
1996
+ const parent = path5.dirname(current);
1561
1997
  if (parent === current) return void 0;
1562
1998
  current = parent;
1563
1999
  }
@@ -1619,7 +2055,7 @@ function threadToUsage(row, index, sessionFiles, options, importedModels = /* @_
1619
2055
  messageCount: sessionBreakdown.messageCount,
1620
2056
  rawTokenTotal: dbTokens || sessionBreakdown.totalTokens || void 0,
1621
2057
  warnings,
1622
- codexHome: options.codexHome ?? path4.join(os2.homedir(), ".codex"),
2058
+ codexHome: options.codexHome ?? path5.join(os2.homedir(), ".codex"),
1623
2059
  durationMs: durationMs(startedAt, endedAt),
1624
2060
  rawEventCount: sessionBreakdown.rawEventCount,
1625
2061
  parseStatus: sessionBreakdown.parseStatus,
@@ -1693,7 +2129,7 @@ function readStandaloneSessions(sessionFiles, options) {
1693
2129
  messageCount: breakdown.messageCount,
1694
2130
  rawTokenTotal: breakdown.totalTokens || void 0,
1695
2131
  warnings: [...repo.warnings, ...breakdown.warnings],
1696
- codexHome: options.codexHome ?? path4.join(os2.homedir(), ".codex"),
2132
+ codexHome: options.codexHome ?? path5.join(os2.homedir(), ".codex"),
1697
2133
  durationMs: durationMs(breakdown.startedAt, breakdown.endedAt),
1698
2134
  rawEventCount: breakdown.rawEventCount,
1699
2135
  parseStatus: breakdown.parseStatus,
@@ -1738,13 +2174,18 @@ function hasImportedSession(sessions, candidate) {
1738
2174
  return Boolean(candidatePath && sessionPath && candidatePath === sessionPath);
1739
2175
  });
1740
2176
  }
2177
+ function remainingStandaloneSessionFiles(sessionFiles, importedSessions) {
2178
+ const importedIds = new Set(importedSessions.map((session) => session.id).filter(Boolean));
2179
+ const importedPaths = new Set(importedSessions.map((session) => comparableSourcePath(session.sourcePath)).filter(Boolean));
2180
+ return new Map([...sessionFiles.entries()].filter(([id, sourcePath]) => !importedIds.has(id) && !importedPaths.has(comparableSourcePath(sourcePath))));
2181
+ }
1741
2182
  function readImportedModelMap(codexHome, warnings) {
1742
- const importsPath = path4.join(codexHome, "external_agent_session_imports.json");
2183
+ const importsPath = path5.join(codexHome, "external_agent_session_imports.json");
1743
2184
  const models = /* @__PURE__ */ new Map();
1744
- if (!fs4.existsSync(importsPath)) return models;
2185
+ if (!fs5.existsSync(importsPath)) return models;
1745
2186
  let parsed;
1746
2187
  try {
1747
- parsed = JSON.parse(fs4.readFileSync(importsPath, "utf8"));
2188
+ parsed = JSON.parse(fs5.readFileSync(importsPath, "utf8"));
1748
2189
  } catch (error) {
1749
2190
  warnings.push(`Unable to read Codex external import index at ${importsPath}: ${errorMessage(error)}`);
1750
2191
  return models;
@@ -1754,7 +2195,7 @@ function readImportedModelMap(codexHome, warnings) {
1754
2195
  const item = asRecord(record);
1755
2196
  const importedThreadId = stringValue2(item?.imported_thread_id);
1756
2197
  const sourcePath = normalizeVerbatimPath(stringValue2(item?.source_path));
1757
- if (!importedThreadId || !sourcePath || !fs4.existsSync(sourcePath)) continue;
2198
+ if (!importedThreadId || !sourcePath || !fs5.existsSync(sourcePath)) continue;
1758
2199
  const model = readImportedSourceModel(sourcePath, warnings);
1759
2200
  if (model) models.set(importedThreadId, { model, provider: providerForModel(model) });
1760
2201
  }
@@ -1764,7 +2205,7 @@ function readImportedSourceModel(sourcePath, warnings) {
1764
2205
  const counts = /* @__PURE__ */ new Map();
1765
2206
  let content;
1766
2207
  try {
1767
- content = fs4.readFileSync(sourcePath, "utf8");
2208
+ content = fs5.readFileSync(sourcePath, "utf8");
1768
2209
  } catch (error) {
1769
2210
  warnings.push(`Unable to read Codex external import source at ${sourcePath}: ${errorMessage(error)}`);
1770
2211
  return void 0;
@@ -1802,12 +2243,13 @@ function normalizeVerbatimPath(value) {
1802
2243
  function comparableSourcePath(value) {
1803
2244
  return normalizeVerbatimPath(value)?.replace(/\\/g, "/");
1804
2245
  }
1805
- function indexSessionFiles(sessionsPath, warnings) {
2246
+ function indexSessionFiles(sessionsPath, warnings, scanWindow) {
1806
2247
  const files = /* @__PURE__ */ new Map();
1807
2248
  try {
1808
2249
  walk(sessionsPath, (filePath) => {
1809
2250
  if (!/\.(jsonl|json|log)$/i.test(filePath)) return;
1810
- const id = path4.basename(filePath).replace(/\.(jsonl|json|log)$/i, "");
2251
+ if (!fileMayOverlapScanWindow(filePath, scanWindow)) return;
2252
+ const id = path5.basename(filePath).replace(/\.(jsonl|json|log)$/i, "");
1811
2253
  files.set(id, filePath);
1812
2254
  });
1813
2255
  } catch (error) {
@@ -1815,9 +2257,26 @@ function indexSessionFiles(sessionsPath, warnings) {
1815
2257
  }
1816
2258
  return files;
1817
2259
  }
2260
+ function rowMatchesScanWindow(row, scanWindow) {
2261
+ if (!scanWindow?.fromMs && !scanWindow?.toMs) return true;
2262
+ const updatedAt = timestampMs(row.updated_at);
2263
+ const createdAt = timestampMs(row.created_at);
2264
+ const latestTimestamp = updatedAt ?? createdAt;
2265
+ if (scanWindow.fromMs !== void 0 && latestTimestamp !== void 0 && latestTimestamp < scanWindow.fromMs) return false;
2266
+ if (scanWindow.toMs !== void 0 && createdAt !== void 0 && createdAt > scanWindow.toMs) return false;
2267
+ return true;
2268
+ }
2269
+ function fileMayOverlapScanWindow(filePath, scanWindow) {
2270
+ if (!scanWindow?.fromMs) return true;
2271
+ try {
2272
+ return fs5.statSync(filePath).mtimeMs >= scanWindow.fromMs;
2273
+ } catch {
2274
+ return true;
2275
+ }
2276
+ }
1818
2277
  function walk(root, onFile) {
1819
- for (const entry of fs4.readdirSync(root, { withFileTypes: true })) {
1820
- const fullPath = path4.join(root, entry.name);
2278
+ for (const entry of fs5.readdirSync(root, { withFileTypes: true })) {
2279
+ const fullPath = path5.join(root, entry.name);
1821
2280
  if (entry.isDirectory()) {
1822
2281
  walk(fullPath, onFile);
1823
2282
  } else if (entry.isFile()) {
@@ -1826,9 +2285,12 @@ function walk(root, onFile) {
1826
2285
  }
1827
2286
  }
1828
2287
  function readSessionBreakdown(filePath) {
2288
+ const cacheKey = sessionBreakdownCacheKey(filePath);
2289
+ const cached = cacheKey ? readJsonCache("codex-session", cacheKey) : void 0;
2290
+ if (cached) return cached;
1829
2291
  const breakdown = emptyBreakdown();
1830
2292
  try {
1831
- const text = fs4.readFileSync(filePath, "utf8");
2293
+ const text = fs5.readFileSync(filePath, "utf8");
1832
2294
  const parse = parseSessionRecords(text);
1833
2295
  const records = parse.records;
1834
2296
  breakdown.rawEventCount = records.length;
@@ -1860,8 +2322,22 @@ function readSessionBreakdown(filePath) {
1860
2322
  breakdown.parseErrors.push(message);
1861
2323
  breakdown.warnings.push(message);
1862
2324
  }
2325
+ if (cacheKey) writeJsonCache("codex-session", cacheKey, breakdown);
1863
2326
  return breakdown;
1864
2327
  }
2328
+ function sessionBreakdownCacheKey(filePath) {
2329
+ try {
2330
+ const stat = fs5.statSync(filePath);
2331
+ return {
2332
+ version: codexSessionCacheVersion,
2333
+ filePath: path5.resolve(filePath),
2334
+ size: stat.size,
2335
+ mtimeMs: stat.mtimeMs
2336
+ };
2337
+ } catch {
2338
+ return void 0;
2339
+ }
2340
+ }
1865
2341
  function parseSessionRecords(text) {
1866
2342
  const trimmed = text.trim();
1867
2343
  if (!trimmed) return { records: [], errors: [] };
@@ -1966,7 +2442,7 @@ function emptyBreakdown() {
1966
2442
  };
1967
2443
  }
1968
2444
  function fallbackCwdFromPath(filePath) {
1969
- return filePath ? path4.dirname(filePath) : process.cwd();
2445
+ return filePath ? path5.dirname(filePath) : process.cwd();
1970
2446
  }
1971
2447
  function applySessionMetadata(breakdown, record) {
1972
2448
  if (!record || typeof record !== "object") return;
@@ -2146,6 +2622,12 @@ function dateValue2(value) {
2146
2622
  }
2147
2623
  return void 0;
2148
2624
  }
2625
+ function timestampMs(value) {
2626
+ const date = dateValue2(value);
2627
+ if (!date) return void 0;
2628
+ const timestamp3 = new Date(date).getTime();
2629
+ return Number.isFinite(timestamp3) ? timestamp3 : void 0;
2630
+ }
2149
2631
  function errorMessage(error) {
2150
2632
  const message = error instanceof Error ? error.message : String(error);
2151
2633
  if (message.includes("NODE_MODULE_VERSION") && message.includes("better_sqlite3.node")) {
@@ -2157,24 +2639,25 @@ function errorMessage(error) {
2157
2639
  }
2158
2640
 
2159
2641
  // packages/core/src/sources/claude.ts
2160
- import fs5 from "node:fs";
2642
+ import fs6 from "node:fs";
2161
2643
  import os3 from "node:os";
2162
- import path5 from "node:path";
2644
+ import path6 from "node:path";
2163
2645
  function scanClaude(options) {
2164
2646
  const claudeHome = options.claudeHome ?? defaultClaudeHome();
2165
- const historyPath = path5.join(claudeHome, "history.jsonl");
2647
+ const historyPath = path6.join(claudeHome, "history.jsonl");
2166
2648
  const warnings = [];
2167
2649
  const discovery = discoverClaudeSessionFiles(claudeHome, warnings, Boolean(options.claudeHome));
2168
- const historyExists = fs5.existsSync(historyPath);
2169
- const orderedFiles = discovery.files.map((file) => ({ file, mtimeMs: safeMtimeMs(file.filePath) })).sort((a, b) => a.mtimeMs - b.mtimeMs);
2650
+ const historyExists = fs6.existsSync(historyPath);
2651
+ const orderedFiles = discovery.files.map((file) => ({ file, mtimeMs: safeMtimeMs(file.filePath) })).filter(({ mtimeMs }) => !options.scanWindow?.fromMs || mtimeMs >= options.scanWindow.fromMs).sort((a, b) => a.mtimeMs - b.mtimeMs);
2170
2652
  const usageSelection = selectClaudeUsageOccurrences(orderedFiles.map(({ file }) => file));
2171
2653
  const sessions = orderedFiles.flatMap(({ file }, index) => claudeFileToUsages(file, index, options, usageSelection));
2654
+ const serviceTierSummary = summarizeServiceTiers(sessions.map((session) => session.serviceTier), "Claude session usage");
2172
2655
  const historyEntryCount = countHistoryEntries(historyPath);
2173
2656
  const parseFailureCount = sessions.filter((session) => session.parseStatus === "failed" || (session.parseErrors?.length ?? 0) > 0).length;
2174
2657
  const unreadableFileCount = sessions.filter((session) => session.parseErrors?.some((error) => error.startsWith("unable_to_read_session_file:"))).length;
2175
2658
  const malformedFileCount = sessions.filter((session) => session.parseErrors?.some((error) => error.startsWith("jsonl_line_"))).length;
2176
- const projectsPath = path5.join(claudeHome, "projects");
2177
- if (!discovery.projectsPaths.some((candidate) => fs5.existsSync(candidate))) {
2659
+ const projectsPath = path6.join(claudeHome, "projects");
2660
+ if (!discovery.projectsPaths.some((candidate) => fs6.existsSync(candidate))) {
2178
2661
  warnings.push(`Claude Code projects directory not found at ${projectsPath}`);
2179
2662
  }
2180
2663
  return {
@@ -2183,7 +2666,8 @@ function scanClaude(options) {
2183
2666
  label: "Claude Code",
2184
2667
  available: discovery.files.length > 0 || historyExists,
2185
2668
  paths: unique3([...discovery.projectsPaths, historyPath, ...discovery.desktopSessionRoots]),
2186
- warnings
2669
+ warnings,
2670
+ ...serviceTierSummary
2187
2671
  },
2188
2672
  sessions,
2189
2673
  stats: {
@@ -2193,7 +2677,7 @@ function scanClaude(options) {
2193
2677
  claudeHome,
2194
2678
  projectsPath,
2195
2679
  historyPath,
2196
- projectsExists: discovery.projectsPaths.some((candidate) => fs5.existsSync(candidate)),
2680
+ projectsExists: discovery.projectsPaths.some((candidate) => fs6.existsSync(candidate)),
2197
2681
  historyExists,
2198
2682
  sessionFileCount: discovery.files.length,
2199
2683
  projectDirCount: discovery.projectDirs.size,
@@ -2202,6 +2686,7 @@ function scanClaude(options) {
2202
2686
  unreadableFileCount,
2203
2687
  malformedFileCount,
2204
2688
  historyEntryCount,
2689
+ ...serviceTierSummary,
2205
2690
  lastScannedAt: (/* @__PURE__ */ new Date()).toISOString()
2206
2691
  }
2207
2692
  };
@@ -2209,7 +2694,7 @@ function scanClaude(options) {
2209
2694
  function discoverClaudeSessionFiles(claudeHome, warnings, explicitHome) {
2210
2695
  const files = [];
2211
2696
  const projectDirs = /* @__PURE__ */ new Set();
2212
- const projectsPaths = explicitHome ? [path5.join(claudeHome, "projects")] : unique3([...configuredClaudeHomes(claudeHome).map((root) => path5.join(root, "projects")), path5.join(os3.homedir(), ".config", "claude", "projects")]);
2697
+ const projectsPaths = explicitHome ? [path6.join(claudeHome, "projects")] : unique3([...configuredClaudeHomes(claudeHome).map((root) => path6.join(root, "projects")), path6.join(os3.homedir(), ".config", "claude", "projects")]);
2213
2698
  for (const projectsPath of projectsPaths) {
2214
2699
  collectProjectFiles(projectsPath, files, projectDirs, warnings);
2215
2700
  }
@@ -2226,16 +2711,16 @@ function discoverClaudeSessionFiles(claudeHome, warnings, explicitHome) {
2226
2711
  }
2227
2712
  function configuredClaudeHomes(primaryHome) {
2228
2713
  if (process.env.CLAUDE_CONFIG_DIR) {
2229
- return process.env.CLAUDE_CONFIG_DIR.split(",").map((item) => item.trim()).filter(Boolean).map((item) => path5.resolve(item));
2714
+ return process.env.CLAUDE_CONFIG_DIR.split(",").map((item) => item.trim()).filter(Boolean).map((item) => path6.resolve(item));
2230
2715
  }
2231
2716
  return [primaryHome];
2232
2717
  }
2233
2718
  function collectProjectFiles(projectsPath, files, projectDirs, warnings) {
2234
- if (!fs5.existsSync(projectsPath)) return;
2719
+ if (!fs6.existsSync(projectsPath)) return;
2235
2720
  try {
2236
- for (const entry of fs5.readdirSync(projectsPath, { withFileTypes: true })) {
2721
+ for (const entry of fs6.readdirSync(projectsPath, { withFileTypes: true })) {
2237
2722
  if (!entry.isDirectory()) continue;
2238
- const projectDir = path5.join(projectsPath, entry.name);
2723
+ const projectDir = path6.join(projectsPath, entry.name);
2239
2724
  projectDirs.add(projectDir);
2240
2725
  walk2(projectDir, 5, (filePath) => {
2241
2726
  if (filePath.endsWith(".jsonl")) files.push({ filePath, projectDir, projectsPath });
@@ -2246,15 +2731,15 @@ function collectProjectFiles(projectsPath, files, projectDirs, warnings) {
2246
2731
  }
2247
2732
  }
2248
2733
  function collectDesktopSessionFiles(root, files, projectDirs, warnings) {
2249
- if (!fs5.existsSync(root)) return;
2734
+ if (!fs6.existsSync(root)) return;
2250
2735
  try {
2251
2736
  walk2(root, 8, (filePath) => {
2252
2737
  if (!filePath.endsWith(".jsonl")) return;
2253
- const parts = filePath.split(path5.sep);
2738
+ const parts = filePath.split(path6.sep);
2254
2739
  const projectsIndex = parts.lastIndexOf("projects");
2255
2740
  const hasProjectSegment = projectsIndex >= 0 && projectsIndex < parts.length - 2;
2256
- const projectsPath = hasProjectSegment ? parts.slice(0, projectsIndex + 1).join(path5.sep) || path5.sep : root;
2257
- const projectDir = hasProjectSegment ? parts.slice(0, projectsIndex + 2).join(path5.sep) || path5.sep : path5.dirname(filePath);
2741
+ const projectsPath = hasProjectSegment ? parts.slice(0, projectsIndex + 1).join(path6.sep) || path6.sep : root;
2742
+ const projectDir = hasProjectSegment ? parts.slice(0, projectsIndex + 2).join(path6.sep) || path6.sep : path6.dirname(filePath);
2258
2743
  projectDirs.add(projectDir);
2259
2744
  files.push({ filePath, projectDir, projectsPath });
2260
2745
  });
@@ -2264,9 +2749,9 @@ function collectDesktopSessionFiles(root, files, projectDirs, warnings) {
2264
2749
  }
2265
2750
  function walk2(root, maxDepth, onFile, depth = 0) {
2266
2751
  if (depth > maxDepth) return;
2267
- for (const entry of fs5.readdirSync(root, { withFileTypes: true })) {
2752
+ for (const entry of fs6.readdirSync(root, { withFileTypes: true })) {
2268
2753
  if (entry.name === "node_modules" || entry.name === ".git") continue;
2269
- const fullPath = path5.join(root, entry.name);
2754
+ const fullPath = path6.join(root, entry.name);
2270
2755
  if (entry.isDirectory()) {
2271
2756
  walk2(fullPath, maxDepth, onFile, depth + 1);
2272
2757
  } else if (entry.isFile()) {
@@ -2280,14 +2765,17 @@ function claudeFileToUsages(file, index, options, usageSelection) {
2280
2765
  return segments.map(({ breakdown: segment, suffix }) => claudeBreakdownToUsage(file, index, options, segment, suffix));
2281
2766
  }
2282
2767
  function claudeBreakdownToUsage(file, index, options, breakdown, idSuffix = "") {
2283
- const id = breakdown.id ?? (path5.basename(file.filePath).replace(/\.jsonl$/i, "") || `claude-session-${index}`);
2768
+ const id = breakdown.id ?? (path6.basename(file.filePath).replace(/\.jsonl$/i, "") || `claude-session-${index}`);
2284
2769
  const fallbackCwd = inferProjectPath(file.projectDir, file.projectsPath);
2285
2770
  const cwd = breakdown.cwd ?? fallbackCwd;
2286
2771
  const repo = resolveRepoInfo(cwd, options.config);
2287
2772
  const warnings = [...repo.warnings, ...breakdown.warnings];
2288
2773
  const entrypoint = breakdown.entrypoint ?? inferClaudeEntrypointFromPath(file.filePath);
2289
2774
  if (!breakdown.cwd) warnings.push("repo_inferred_from_claude_project_dir");
2775
+ if (!breakdown.model && breakdown.usageCount === 0 && breakdown.syntheticZeroUsageCount > 0) warnings.push("claude_synthetic_zero_usage");
2290
2776
  const hasTokenBreakdown = breakdown.totalTokens > 0 && breakdown.usageCount > 0;
2777
+ const serviceTierSummary = summarizeClaudeServiceTier(breakdown.serviceTiers, breakdown.serviceSpeeds);
2778
+ const serviceSpeedSummary = summarizeServiceTiers(breakdown.serviceSpeeds, "Claude usage.speed");
2291
2779
  const usage = {
2292
2780
  id: `${id}${idSuffix}`,
2293
2781
  sourceClient: "claude",
@@ -2325,6 +2813,18 @@ function claudeBreakdownToUsage(file, index, options, breakdown, idSuffix = "")
2325
2813
  detectedSurface: claudeSurface(entrypoint),
2326
2814
  surfaceConfidence: "medium",
2327
2815
  surfaceReason: entrypoint ? `Claude Code entrypoint: ${entrypoint}` : "Claude Code transcript",
2816
+ serviceTier: serviceTierSummary.serviceTier,
2817
+ serviceTierSource: serviceTierSummary.serviceTier ? "session_usage" : void 0,
2818
+ serviceTierConfidence: serviceTierSummary.serviceTier ? "high" : void 0,
2819
+ serviceTierDetail: serviceTierSummary.serviceTierDetail,
2820
+ sourceMetadata: breakdown.serviceTiers.length || breakdown.serviceSpeeds.length ? {
2821
+ claude: {
2822
+ serviceTiers: breakdown.serviceTiers,
2823
+ speeds: breakdown.serviceSpeeds,
2824
+ serviceTier: serviceTierSummary.serviceTier,
2825
+ speed: serviceSpeedSummary.serviceTier
2826
+ }
2827
+ } : void 0,
2328
2828
  userPromptCount: breakdown.userPromptCount,
2329
2829
  assistantMessageCount: breakdown.assistantMessageCount,
2330
2830
  toolCallCount: breakdown.toolCallCount,
@@ -2352,12 +2852,12 @@ function claudeBreakdownToUsage(file, index, options, breakdown, idSuffix = "")
2352
2852
  };
2353
2853
  }
2354
2854
  function inferClaudeEntrypointFromPath(filePath) {
2355
- return filePath.split(path5.sep).includes("local-agent-mode-sessions") ? "local-agent" : void 0;
2855
+ return filePath.split(path6.sep).includes("local-agent-mode-sessions") ? "local-agent" : void 0;
2356
2856
  }
2357
2857
  function readClaudeBreakdown(filePath, usageSelection) {
2358
2858
  const breakdown = emptyBreakdown2();
2359
2859
  try {
2360
- const text = fs5.readFileSync(filePath, "utf8");
2860
+ const text = fs6.readFileSync(filePath, "utf8");
2361
2861
  const parse = parseJsonLines2(text);
2362
2862
  breakdown.rawEventCount = parse.records.length;
2363
2863
  breakdown.parseErrors = parse.errors;
@@ -2395,6 +2895,8 @@ function applyClaudeRecord(breakdown, record, usageSelection, usageOccurrenceKey
2395
2895
  applyPromptTimeline2(breakdown, role, message.content, timestamp3);
2396
2896
  const usage = firstObject4(message.usage);
2397
2897
  const usageTokens = usage ? usageTotal(usage) : 0;
2898
+ const rawModel = stringValue3(message.model);
2899
+ if (rawModel === "<synthetic>" && usage && usageTokens === 0) breakdown.syntheticZeroUsageCount += 1;
2398
2900
  const model = normalizeClaudeModel(message.model);
2399
2901
  if (model && (usageTokens > 0 || !breakdown.model)) breakdown.model = model;
2400
2902
  const selectedUsage = usageTokens > 0 ? usageSelection.usageByOccurrence.get(usageOccurrenceKey) : void 0;
@@ -2412,7 +2914,7 @@ function selectClaudeUsageOccurrences(files) {
2412
2914
  for (const file of files) {
2413
2915
  let parse;
2414
2916
  try {
2415
- parse = parseJsonLines2(fs5.readFileSync(file.filePath, "utf8"));
2917
+ parse = parseJsonLines2(fs6.readFileSync(file.filePath, "utf8"));
2416
2918
  } catch {
2417
2919
  continue;
2418
2920
  }
@@ -2446,14 +2948,16 @@ function usageDedupKey(record, message) {
2446
2948
  return `${requestId}:${messageId}`;
2447
2949
  }
2448
2950
  function occurrenceKey(filePath, recordIndex) {
2449
- return `${path5.resolve(filePath)}:${recordIndex}`;
2951
+ return `${path6.resolve(filePath)}:${recordIndex}`;
2450
2952
  }
2451
2953
  function cloneUsage(usage) {
2452
2954
  return {
2453
2955
  input_tokens: numberValue(usage.input_tokens),
2454
2956
  cache_read_input_tokens: numberValue(usage.cache_read_input_tokens),
2455
2957
  cache_creation_input_tokens: numberValue(usage.cache_creation_input_tokens),
2456
- output_tokens: numberValue(usage.output_tokens)
2958
+ output_tokens: numberValue(usage.output_tokens),
2959
+ service_tier: stringValue3(usage.service_tier),
2960
+ speed: stringValue3(usage.speed)
2457
2961
  };
2458
2962
  }
2459
2963
  function mergeUsageMax(target, usage) {
@@ -2461,6 +2965,8 @@ function mergeUsageMax(target, usage) {
2461
2965
  target.cache_read_input_tokens = Math.max(numberValue(target.cache_read_input_tokens), numberValue(usage.cache_read_input_tokens));
2462
2966
  target.cache_creation_input_tokens = Math.max(numberValue(target.cache_creation_input_tokens), numberValue(usage.cache_creation_input_tokens));
2463
2967
  target.output_tokens = Math.max(numberValue(target.output_tokens), numberValue(usage.output_tokens));
2968
+ target.service_tier ??= stringValue3(usage.service_tier);
2969
+ target.speed ??= stringValue3(usage.speed);
2464
2970
  }
2465
2971
  function applyUsage(breakdown, usage) {
2466
2972
  const input = numberValue(usage.input_tokens);
@@ -2472,6 +2978,8 @@ function applyUsage(breakdown, usage) {
2472
2978
  breakdown.cacheCreationInputTokens += cacheCreation;
2473
2979
  breakdown.outputTokens += output;
2474
2980
  breakdown.usageCount += 1;
2981
+ pushUnique(breakdown.serviceTiers, normalizedServiceTier(usage.service_tier));
2982
+ pushUnique(breakdown.serviceSpeeds, normalizedServiceTier(usage.speed));
2475
2983
  }
2476
2984
  function segmentClaudeBreakdownByDayAndModel(breakdown) {
2477
2985
  const events = breakdown.usageEvents.filter((event) => event.timestamp);
@@ -2581,9 +3089,46 @@ function emptyBreakdown2() {
2581
3089
  parseErrors: [],
2582
3090
  promptTimeline: [],
2583
3091
  warnings: [],
2584
- usageEvents: []
3092
+ usageEvents: [],
3093
+ serviceTiers: [],
3094
+ serviceSpeeds: [],
3095
+ syntheticZeroUsageCount: 0
3096
+ };
3097
+ }
3098
+ function summarizeClaudeServiceTier(serviceTiers, speeds) {
3099
+ const tierSummary = summarizeServiceTiers(serviceTiers, "Claude usage.service_tier");
3100
+ return tierSummary.serviceTier ? tierSummary : summarizeServiceTiers(speeds, "Claude usage.speed");
3101
+ }
3102
+ function summarizeServiceTiers(values, source) {
3103
+ const uniqueValues = unique3(values.filter((value) => Boolean(value)));
3104
+ if (uniqueValues.length === 0) return {};
3105
+ if (uniqueValues.length === 1) {
3106
+ const serviceTier = uniqueValues[0];
3107
+ if (!serviceTier) return {};
3108
+ return {
3109
+ serviceTier,
3110
+ serviceTierSource: "session_usage",
3111
+ serviceTierConfidence: "high",
3112
+ serviceTierDetail: serviceTierDetail(source, uniqueValues)
3113
+ };
3114
+ }
3115
+ return {
3116
+ serviceTier: "mixed",
3117
+ serviceTierSource: "session_usage",
3118
+ serviceTierConfidence: "high",
3119
+ serviceTierDetail: serviceTierDetail(source, uniqueValues)
2585
3120
  };
2586
3121
  }
3122
+ function serviceTierDetail(source, values) {
3123
+ return `${source}: ${unique3(values).join(", ")}`;
3124
+ }
3125
+ function normalizedServiceTier(value) {
3126
+ const tier = stringValue3(value)?.trim().toLowerCase();
3127
+ return tier || void 0;
3128
+ }
3129
+ function pushUnique(target, value) {
3130
+ if (value && !target.includes(value)) target.push(value);
3131
+ }
2587
3132
  function applyPromptTimeline2(breakdown, role, content, timestamp3) {
2588
3133
  if (breakdown.promptTimeline.length >= 80 || role !== "user" && role !== "assistant") return;
2589
3134
  const text = extractClaudeMessageText(content);
@@ -2610,12 +3155,12 @@ function normalizeTimelineText2(text) {
2610
3155
  return text.replace(/\s+/g, " ").trim().slice(0, 1e3);
2611
3156
  }
2612
3157
  function inferProjectPath(projectDir, projectsPath) {
2613
- const name = path5.basename(projectDir);
3158
+ const name = path6.basename(projectDir);
2614
3159
  if (name.startsWith("-")) {
2615
- return `${path5.sep}${name.slice(1).split("-").filter(Boolean).join(path5.sep)}`;
3160
+ return `${path6.sep}${name.slice(1).split("-").filter(Boolean).join(path6.sep)}`;
2616
3161
  }
2617
- const relative = path5.relative(projectsPath, projectDir);
2618
- return relative && !relative.startsWith("..") ? path5.join(projectsPath, relative) : projectDir;
3162
+ const relative = path6.relative(projectsPath, projectDir);
3163
+ return relative && !relative.startsWith("..") ? path6.join(projectsPath, relative) : projectDir;
2619
3164
  }
2620
3165
  function inferOutcome2(breakdown) {
2621
3166
  if (breakdown.parseStatus === "failed") return "failed";
@@ -2639,20 +3184,20 @@ function claudeSourceAppLabel(entrypoint) {
2639
3184
  return "Claude Code";
2640
3185
  }
2641
3186
  function defaultClaudeHome() {
2642
- return process.env.CLAUDE_CONFIG_DIR?.split(",").map((item) => item.trim()).filter(Boolean)[0] ?? path5.join(os3.homedir(), ".claude");
3187
+ return process.env.CLAUDE_CONFIG_DIR?.split(",").map((item) => item.trim()).filter(Boolean)[0] ?? path6.join(os3.homedir(), ".claude");
2643
3188
  }
2644
3189
  function defaultDesktopSessionRoots() {
2645
3190
  const home = os3.homedir();
2646
- const xdg = process.env.XDG_CONFIG_HOME || path5.join(home, ".config");
3191
+ const xdg = process.env.XDG_CONFIG_HOME || path6.join(home, ".config");
2647
3192
  return [
2648
- path5.join(home, "Library", "Application Support", "Claude", "local-agent-mode-sessions"),
2649
- path5.join(xdg, "Claude", "local-agent-mode-sessions")
3193
+ path6.join(home, "Library", "Application Support", "Claude", "local-agent-mode-sessions"),
3194
+ path6.join(xdg, "Claude", "local-agent-mode-sessions")
2650
3195
  ];
2651
3196
  }
2652
3197
  function uniqueSessionFiles(files) {
2653
3198
  const seen = /* @__PURE__ */ new Set();
2654
3199
  return files.filter((file) => {
2655
- const key = path5.resolve(file.filePath);
3200
+ const key = path6.resolve(file.filePath);
2656
3201
  if (seen.has(key)) return false;
2657
3202
  seen.add(key);
2658
3203
  return true;
@@ -2662,10 +3207,10 @@ function unique3(values) {
2662
3207
  return [...new Set(values)];
2663
3208
  }
2664
3209
  function countHistoryEntries(historyPath) {
2665
- if (!fs5.existsSync(historyPath)) return 0;
3210
+ if (!fs6.existsSync(historyPath)) return 0;
2666
3211
  let entryCount = 0;
2667
3212
  try {
2668
- for (const line of fs5.readFileSync(historyPath, "utf8").split(/\r?\n/).filter(Boolean)) {
3213
+ for (const line of fs6.readFileSync(historyPath, "utf8").split(/\r?\n/).filter(Boolean)) {
2669
3214
  try {
2670
3215
  JSON.parse(line);
2671
3216
  entryCount += 1;
@@ -2725,62 +3270,65 @@ function errorMessage2(error) {
2725
3270
  }
2726
3271
  function safeMtimeMs(filePath) {
2727
3272
  try {
2728
- return fs5.statSync(filePath).mtimeMs;
3273
+ return fs6.statSync(filePath).mtimeMs;
2729
3274
  } catch {
2730
3275
  return 0;
2731
3276
  }
2732
3277
  }
2733
3278
 
2734
- // packages/core/src/sources/cursor.ts
2735
- import fs6 from "node:fs";
3279
+ // packages/core/src/sources/copilot.ts
3280
+ import fs7 from "node:fs";
2736
3281
  import os4 from "node:os";
2737
- import path6 from "node:path";
2738
- import Database2 from "better-sqlite3";
2739
- var parserVersion = "cursor-v1";
2740
- var maxJsonlBytes = 20 * 1024 * 1024;
2741
- var maxDatabaseBytes = 100 * 1024 * 1024;
2742
- var cursorStoragePrefixes = ["cursor.", "anysphere."];
2743
- var interestingTerms = ["cursor", "composer", "aichat", "ai_chat", "aiservice", "agent-transcript", "prompttokenbreakdown"];
2744
- function scanCursor(options) {
2745
- const cursorHome = options.cursorHome ?? path6.join(os4.homedir(), ".cursor");
2746
- const discovery = discoverCursorFiles(cursorHome, Boolean(options.cursorHome));
2747
- const jsonlCandidates = discovery.jsonlFiles.map((filePath) => cursorJsonlToCandidate(filePath, cursorHome));
2748
- const databaseCandidates = discovery.databaseFiles.flatMap((filePath) => cursorDatabaseToCandidates(filePath, cursorHome, discovery.warnings));
2749
- const candidates = dedupeCandidates([...jsonlCandidates, ...databaseCandidates]);
2750
- const sessions = candidates.map((candidate, index) => candidateToUsage(candidate, index, options));
2751
- const parseFailureCount = sessions.filter((session) => session.parseStatus === "failed" || (session.parseErrors?.length ?? 0) > 0).length;
2752
- const unreadableFileCount = sessions.filter((session) => session.parseErrors?.some((error) => error.startsWith("unable_to_read_"))).length;
2753
- const malformedFileCount = sessions.filter((session) => session.parseErrors?.some((error) => error.startsWith("jsonl_line_") || error.startsWith("sqlite_json_parse:"))).length;
2754
- if (!fs6.existsSync(cursorHome)) {
2755
- discovery.warnings.push(`Cursor home directory not found at ${cursorHome}`);
2756
- }
2757
- if (!fs6.existsSync(discovery.statePath)) {
2758
- discovery.warnings.push(`Cursor global state database not found at ${discovery.statePath}`);
3282
+ import path7 from "node:path";
3283
+ var maxJsonlBytes = 50 * 1024 * 1024;
3284
+ var copilotOtelEnv = "COPILOT_OTEL_FILE_EXPORTER_PATH";
3285
+ function scanCopilot(options) {
3286
+ const copilotHome = options.copilotHome ?? path7.join(os4.homedir(), ".copilot");
3287
+ const discovery = discoverCopilotFiles(copilotHome, Boolean(options.copilotHome), options.codeUserRoots);
3288
+ const files = discovery.files.filter((file) => fileMayOverlapScanWindow2(file.filePath, options.scanWindow));
3289
+ const sessionMap = /* @__PURE__ */ new Map();
3290
+ const fileStats = [];
3291
+ for (const [index, file] of files.entries()) {
3292
+ const read = readCopilotFile(file, index);
3293
+ fileStats.push(read.stats);
3294
+ for (const breakdown of read.breakdowns) {
3295
+ mergeBreakdown(sessionMap, breakdown);
3296
+ }
2759
3297
  }
3298
+ applyModelHints(sessionMap, discovery.modelHints);
3299
+ const sessions = [...sessionMap.values()].sort((a, b) => (a.startedAt ?? "").localeCompare(b.startedAt ?? "")).map((breakdown, index) => breakdownToUsage(breakdown, index, options));
3300
+ const parseFailureCount = fileStats.filter((stats) => stats.parseFailed).length;
3301
+ const unreadableFileCount = fileStats.filter((stats) => stats.unreadable).length;
3302
+ const malformedFileCount = fileStats.filter((stats) => stats.malformed).length;
3303
+ if (!fs7.existsSync(copilotHome)) discovery.warnings.push(`GitHub Copilot home directory not found at ${copilotHome}`);
3304
+ if (!fs7.existsSync(discovery.otelPath)) discovery.warnings.push(`GitHub Copilot OpenTelemetry directory not found at ${discovery.otelPath}`);
3305
+ const otelFiles = files.filter((file) => file.kind === "otel").length;
3306
+ const debugLogFiles = files.filter((file) => file.kind === "debug").length;
3307
+ const transcriptFiles = files.filter((file) => file.kind === "transcript").length;
3308
+ const sessionStateFiles = files.filter((file) => file.kind === "session_state").length;
2760
3309
  return {
2761
3310
  source: {
2762
- id: "cursor",
2763
- label: "Cursor",
2764
- available: discovery.jsonlFiles.length > 0 || discovery.databaseFiles.length > 0,
2765
- paths: unique4([cursorHome, discovery.projectsPath, discovery.statePath, discovery.workspaceStoragePath, ...discovery.roots]),
3311
+ id: "copilot",
3312
+ label: "GitHub Copilot",
3313
+ available: files.length > 0,
3314
+ paths: unique4([copilotHome, discovery.otelPath, discovery.workspaceStoragePath, ...discovery.codeUserRoots, ...files.map((file) => file.filePath), ...[...discovery.modelHints.values()].map((hint) => hint.sourcePath)]),
2766
3315
  warnings: discovery.warnings
2767
3316
  },
2768
3317
  sessions,
2769
3318
  stats: {
2770
- sourceId: "cursor",
2771
- sourceLabel: "Cursor",
2772
- homePath: cursorHome,
2773
- cursorHome,
2774
- globalStoragePath: discovery.globalStoragePath,
3319
+ sourceId: "copilot",
3320
+ sourceLabel: "GitHub Copilot",
3321
+ homePath: copilotHome,
3322
+ copilotHome,
3323
+ otelPath: discovery.otelPath,
2775
3324
  workspaceStoragePath: discovery.workspaceStoragePath,
2776
- projectsPath: discovery.projectsPath,
2777
- statePath: discovery.statePath,
2778
- projectsExists: fs6.existsSync(discovery.projectsPath),
2779
- stateExists: fs6.existsSync(discovery.statePath),
2780
- sessionsExists: discovery.jsonlFiles.length > 0,
2781
- sessionFileCount: discovery.jsonlFiles.length,
2782
- databaseFileCount: discovery.databaseFiles.length,
2783
- skippedFileCount: discovery.skippedFileCount,
3325
+ otelExists: fs7.existsSync(discovery.otelPath),
3326
+ sessionsExists: files.length > 0,
3327
+ sessionFileCount: files.length,
3328
+ otelFileCount: otelFiles,
3329
+ debugLogFileCount: debugLogFiles,
3330
+ transcriptFileCount: transcriptFiles,
3331
+ sessionStateFileCount: sessionStateFiles,
2784
3332
  sessionsImported: sessions.length,
2785
3333
  parseFailureCount,
2786
3334
  unreadableFileCount,
@@ -2789,141 +3337,966 @@ function scanCursor(options) {
2789
3337
  }
2790
3338
  };
2791
3339
  }
2792
- function discoverCursorFiles(cursorHome, explicitHome) {
3340
+ function discoverCopilotFiles(copilotHome, explicitHome, explicitCodeUserRoots) {
2793
3341
  const warnings = [];
2794
- const appPaths = defaultCursorAppPaths();
2795
- const roots = explicitHome ? [cursorHome] : unique4([cursorHome, appPaths.statePath, ...cursorOwnedGlobalStorageRoots(appPaths.globalStoragePath), ...cursorOwnedWorkspaceStorageRoots(appPaths.workspaceStoragePath)]);
2796
- const jsonlFiles = [];
2797
- const databaseFiles = [];
2798
- let skippedFileCount = 0;
2799
- for (const root of roots) {
2800
- if (!fs6.existsSync(root)) continue;
2801
- try {
2802
- if (isReadableRegularFile(root, maxDatabaseBytes)) {
2803
- databaseFiles.push(root);
2804
- continue;
2805
- }
2806
- walk3(root, 10, (filePath) => {
2807
- const lower = filePath.toLowerCase();
2808
- if (lower.endsWith(".jsonl")) {
2809
- if (isReadableRegularFile(filePath, maxJsonlBytes)) jsonlFiles.push(filePath);
2810
- else skippedFileCount += 1;
2811
- return;
2812
- }
2813
- if (/\.(sqlite|db|vscdb)$/i.test(filePath)) {
2814
- if (isReadableRegularFile(filePath, maxDatabaseBytes)) databaseFiles.push(filePath);
2815
- else skippedFileCount += 1;
2816
- }
2817
- });
2818
- } catch (error) {
2819
- warnings.push(`Unable to inspect Cursor path ${root}: ${errorMessage3(error)}`);
2820
- }
3342
+ const otelPath = path7.join(copilotHome, "otel");
3343
+ const codeUserRoots = explicitCodeUserRoots ?? (explicitHome ? [] : defaultCodeUserRoots());
3344
+ const workspaceStoragePath = codeUserRoots[0] ? path7.join(codeUserRoots[0], "workspaceStorage") : "";
3345
+ const files = [];
3346
+ const modelHints = /* @__PURE__ */ new Map();
3347
+ collectJsonlFiles(otelPath, "otel", "cli", void 0, files, warnings);
3348
+ collectCopilotSessionStateFiles(copilotHome, files, warnings);
3349
+ const explicitOtelPath = process.env[copilotOtelEnv]?.trim();
3350
+ if (explicitOtelPath && isReadableRegularFile(explicitOtelPath, maxJsonlBytes)) {
3351
+ files.push({ filePath: path7.resolve(explicitOtelPath), kind: "otel", surface: "cli", fallbackCwd: void 0 });
3352
+ }
3353
+ for (const userRoot of codeUserRoots) {
3354
+ collectCodeCopilotFiles(userRoot, files, modelHints, warnings);
2821
3355
  }
2822
3356
  return {
2823
- roots,
2824
- projectsPath: path6.join(cursorHome, "projects"),
2825
- globalStoragePath: appPaths.globalStoragePath,
2826
- workspaceStoragePath: appPaths.workspaceStoragePath,
2827
- statePath: appPaths.statePath,
2828
- jsonlFiles: unique4(jsonlFiles).sort(cursorJsonlSort),
2829
- databaseFiles: unique4(databaseFiles).sort(),
2830
- skippedFileCount,
3357
+ otelPath,
3358
+ workspaceStoragePath,
3359
+ files: uniqueFiles(files),
3360
+ codeUserRoots,
3361
+ modelHints,
2831
3362
  warnings
2832
3363
  };
2833
3364
  }
2834
- function cursorOwnedGlobalStorageRoots(globalStoragePath) {
2835
- return cursorOwnedChildDirs(globalStoragePath);
3365
+ function collectCopilotSessionStateFiles(copilotHome, files, warnings) {
3366
+ const sessionStateRoot = path7.join(copilotHome, "session-state");
3367
+ if (!fs7.existsSync(sessionStateRoot)) return;
3368
+ try {
3369
+ for (const entry of fs7.readdirSync(sessionStateRoot, { withFileTypes: true })) {
3370
+ if (!entry.isDirectory()) continue;
3371
+ const sessionDir = path7.join(sessionStateRoot, entry.name);
3372
+ const eventsPath = path7.join(sessionDir, "events.jsonl");
3373
+ if (isReadableRegularFile(eventsPath, maxJsonlBytes)) {
3374
+ files.push({ filePath: eventsPath, kind: "session_state", surface: "cli", fallbackCwd: cwdFromWorkspaceYaml(path7.join(sessionDir, "workspace.yaml")) });
3375
+ }
3376
+ }
3377
+ } catch (error) {
3378
+ warnings.push(`Unable to read GitHub Copilot session state at ${sessionStateRoot}: ${errorMessage3(error)}`);
3379
+ }
2836
3380
  }
2837
- function cursorOwnedWorkspaceStorageRoots(workspaceStoragePath) {
2838
- if (!fs6.existsSync(workspaceStoragePath)) return [];
2839
- const roots = [];
3381
+ function collectCodeCopilotFiles(userRoot, files, modelHints, warnings) {
3382
+ const workspaceStorage = path7.join(userRoot, "workspaceStorage");
3383
+ if (!fs7.existsSync(workspaceStorage)) return;
2840
3384
  try {
2841
- for (const workspace of fs6.readdirSync(workspaceStoragePath, { withFileTypes: true })) {
3385
+ for (const workspace of fs7.readdirSync(workspaceStorage, { withFileTypes: true })) {
2842
3386
  if (!workspace.isDirectory()) continue;
2843
- roots.push(...cursorOwnedChildDirs(path6.join(workspaceStoragePath, workspace.name)));
3387
+ const workspaceRoot = path7.join(workspaceStorage, workspace.name);
3388
+ const copilotRoot = path7.join(workspaceRoot, "GitHub.copilot-chat");
3389
+ if (!fs7.existsSync(copilotRoot)) continue;
3390
+ const fallbackCwd = workspaceFolderFromStorageJson(path7.join(workspaceRoot, "workspace.json"));
3391
+ collectJsonlFiles(path7.join(copilotRoot, "debug-logs"), "debug", "vscode", fallbackCwd, files, warnings);
3392
+ collectJsonlFiles(path7.join(copilotRoot, "transcripts"), "transcript", "vscode", fallbackCwd, files, warnings);
3393
+ collectCodeChatSessionModelHints(path7.join(workspaceRoot, "chatSessions"), modelHints, warnings);
2844
3394
  }
2845
- } catch {
2846
- return [];
3395
+ } catch (error) {
3396
+ warnings.push(`Unable to read GitHub Copilot VS Code storage at ${workspaceStorage}: ${errorMessage3(error)}`);
2847
3397
  }
2848
- return roots;
2849
3398
  }
2850
- function cursorOwnedChildDirs(root) {
2851
- if (!fs6.existsSync(root)) return [];
3399
+ function collectCodeChatSessionModelHints(root, modelHints, warnings) {
3400
+ if (!fs7.existsSync(root)) return;
2852
3401
  try {
2853
- return fs6.readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory() && cursorStoragePrefixes.some((prefix) => entry.name.toLowerCase().startsWith(prefix))).map((entry) => path6.join(root, entry.name));
2854
- } catch {
3402
+ walk3(root, 2, (filePath) => {
3403
+ if (!filePath.endsWith(".jsonl") || !isReadableRegularFile(filePath, maxJsonlBytes)) return;
3404
+ const hint = readCodeChatSessionModelHint(filePath);
3405
+ if (hint) modelHints.set(hint.sessionId, { model: hint.model, sourcePath: filePath });
3406
+ });
3407
+ } catch (error) {
3408
+ warnings.push(`Unable to read VS Code chat session metadata at ${root}: ${errorMessage3(error)}`);
3409
+ }
3410
+ }
3411
+ function readCodeChatSessionModelHint(filePath) {
3412
+ const fallbackSessionId = path7.basename(filePath).replace(/\.jsonl$/i, "");
3413
+ let sessionId = fallbackSessionId;
3414
+ let model;
3415
+ try {
3416
+ for (const record of parseJsonLines3(fs7.readFileSync(filePath, "utf8")).records) {
3417
+ const body = firstObject5(record.v) ?? firstObject5(record.data) ?? record;
3418
+ const metadata = firstObject5(body.metadata);
3419
+ sessionId = stringValue4(body.sessionId) ?? stringValue4(metadata?.sessionId) ?? sessionId;
3420
+ model = chatSessionSelectedModel(body) ?? chatSessionResolvedModel(body) ?? model;
3421
+ }
3422
+ } catch {
3423
+ return void 0;
3424
+ }
3425
+ return sessionId && model ? { sessionId, model } : void 0;
3426
+ }
3427
+ function chatSessionSelectedModel(body) {
3428
+ const inputState = firstObject5(body.inputState);
3429
+ const selectedModel = firstObject5(inputState?.selectedModel) ?? firstObject5(body.selectedModel);
3430
+ const metadata = firstObject5(selectedModel?.metadata);
3431
+ return firstModelCandidate([
3432
+ stringValue4(metadata?.version),
3433
+ stringValue4(metadata?.family),
3434
+ stringValue4(selectedModel?.id),
3435
+ stringValue4(selectedModel?.identifier)
3436
+ ]);
3437
+ }
3438
+ function chatSessionResolvedModel(body) {
3439
+ const metadata = firstObject5(body.metadata);
3440
+ const response = firstObject5(body.response);
3441
+ const responseMetadata = firstObject5(response?.metadata);
3442
+ return firstModelCandidate([
3443
+ stringValue4(metadata?.resolvedModel),
3444
+ stringValue4(responseMetadata?.resolvedModel)
3445
+ ]);
3446
+ }
3447
+ function firstModelCandidate(values) {
3448
+ for (const value of values) {
3449
+ const normalized = value?.replace(/^github[-_]?copilot\//i, "").replace(/^copilot\//i, "").trim();
3450
+ if (normalized && normalized.toLowerCase() !== "auto") return normalized;
3451
+ }
3452
+ return void 0;
3453
+ }
3454
+ function collectJsonlFiles(root, kind, surface, fallbackCwd, files, warnings) {
3455
+ if (!fs7.existsSync(root)) return;
3456
+ try {
3457
+ walk3(root, 6, (filePath) => {
3458
+ if (filePath.endsWith(".jsonl") && isReadableRegularFile(filePath, maxJsonlBytes)) {
3459
+ files.push({ filePath, kind, surface, fallbackCwd });
3460
+ }
3461
+ });
3462
+ } catch (error) {
3463
+ warnings.push(`Unable to read GitHub Copilot path ${root}: ${errorMessage3(error)}`);
3464
+ }
3465
+ }
3466
+ function readCopilotFile(file, index) {
3467
+ const fallbackId = path7.basename(file.filePath).replace(/\.jsonl$/i, "") || `copilot-session-${index}`;
3468
+ const fallbackTimestamp = fileModifiedDate(file.filePath);
3469
+ const fallback = emptyBreakdown3(file, fallbackId);
3470
+ try {
3471
+ const parse = parseJsonLines3(fs7.readFileSync(file.filePath, "utf8"));
3472
+ const traceContexts = collectTraceContexts(parse.records);
3473
+ const candidates = parse.records.map((record, recordIndex) => usageCandidateFromRecord(record, recordIndex, fallbackTimestamp, traceContexts)).filter((candidate) => Boolean(candidate));
3474
+ const selectedCandidates = uniqueCandidates(candidates.filter((candidate) => shouldEmitCandidate(candidate, candidates)));
3475
+ const groups = /* @__PURE__ */ new Map();
3476
+ fallback.rawEventCount = parse.records.length;
3477
+ fallback.parseErrors = parse.errors;
3478
+ fallback.parseStatus = parse.errors.length ? parse.records.length ? "partial" : "failed" : "ok";
3479
+ if (file.kind === "transcript" || file.kind === "session_state") {
3480
+ for (const record of parse.records) applyTranscriptRecord(fallback, record, file, file.kind === "transcript" ? fallbackTimestamp : void 0);
3481
+ }
3482
+ for (const candidate of selectedCandidates) {
3483
+ const group = groups.get(candidate.sessionId) ?? emptyBreakdown3(file, candidate.sessionId);
3484
+ group.rawEventCount = parse.records.length;
3485
+ group.parseErrors = parse.errors;
3486
+ group.parseStatus = parse.errors.length ? "partial" : "ok";
3487
+ applyCandidate(group, candidate);
3488
+ groups.set(candidate.sessionId, group);
3489
+ }
3490
+ const stats = statsFromParseErrors(parse.errors);
3491
+ if (groups.size > 0) {
3492
+ return { breakdowns: [...groups.values()].map((group) => mergeActivity(group, fallback)), stats };
3493
+ }
3494
+ if (fallback.messageCount > 0 || fallback.rawEventCount > 0 || fallback.parseErrors.length > 0) return { breakdowns: [fallbackWithMissingTokenWarning(fallback)], stats };
3495
+ return { breakdowns: [], stats };
3496
+ } catch (error) {
3497
+ const breakdown = emptyBreakdown3(file, fallbackId);
3498
+ const message = `unable_to_read_copilot_file:${errorMessage3(error)}`;
3499
+ breakdown.parseStatus = "failed";
3500
+ breakdown.parseErrors.push(message);
3501
+ breakdown.warnings.push(message);
3502
+ return { breakdowns: [breakdown], stats: { parseFailed: true, unreadable: true, malformed: false } };
3503
+ }
3504
+ }
3505
+ function statsFromParseErrors(errors) {
3506
+ return { parseFailed: errors.length > 0, unreadable: false, malformed: errors.some((error) => error.startsWith("jsonl_line_")) };
3507
+ }
3508
+ function collectTraceContexts(records) {
3509
+ const contexts = /* @__PURE__ */ new Map();
3510
+ for (const record of records) {
3511
+ const traceId = traceIdFromRecord(record);
3512
+ const attributes = firstObject5(record.attributes);
3513
+ if (!traceId || !attributes) continue;
3514
+ const context = contexts.get(traceId) ?? { model: void 0, sessionId: void 0, sessionPriority: 0 };
3515
+ context.model ??= firstNonEmptyAttr(attributes, modelAttrs);
3516
+ const session = bestSessionAttr(attributes);
3517
+ if (session && session.priority > context.sessionPriority) {
3518
+ context.sessionId = session.value;
3519
+ context.sessionPriority = session.priority;
3520
+ }
3521
+ contexts.set(traceId, context);
3522
+ }
3523
+ return contexts;
3524
+ }
3525
+ function usageCandidateFromRecord(record, index, fallbackTimestamp, traceContexts) {
3526
+ const attributes = firstObject5(record.attributes);
3527
+ if (!attributes) return void 0;
3528
+ const source = usageSource(record, attributes);
3529
+ if (!source) return void 0;
3530
+ const input = numberValue2(attributes["gen_ai.usage.input_tokens"]);
3531
+ const output = numberValue2(attributes["gen_ai.usage.output_tokens"]);
3532
+ const cached = numberValue2(attributes["gen_ai.usage.cache_read.input_tokens"]);
3533
+ const cacheCreation = firstPositiveNumber(attributes, ["gen_ai.usage.cache_write.input_tokens", "gen_ai.usage.cache_creation.input_tokens"]);
3534
+ const reasoning = firstPositiveNumber(attributes, ["gen_ai.usage.reasoning.output_tokens", "gen_ai.usage.reasoning_tokens"]);
3535
+ const total = firstPositiveNumber(attributes, ["gen_ai.usage.total_tokens", "gen_ai.usage.total.token_count"]);
3536
+ const outputWithFallback = output || outputTokensFromTotal(total, input, reasoning);
3537
+ if (input + outputWithFallback + cached + cacheCreation + reasoning <= 0) return void 0;
3538
+ const traceId = traceIdFromRecord(record);
3539
+ const traceContext = traceId ? traceContexts.get(traceId) : void 0;
3540
+ const session = bestSessionAttr(attributes)?.value ?? traceContext?.sessionId ?? traceId ?? "unknown-session";
3541
+ const timestamp3 = timestampFromRecord(record) ?? fallbackTimestamp;
3542
+ return {
3543
+ source,
3544
+ traceId,
3545
+ responseId: stringValue4(attributes["gen_ai.response.id"]),
3546
+ sessionId: session,
3547
+ model: firstNonEmptyAttr(attributes, modelAttrs) ?? traceContext?.model,
3548
+ timestamp: timestamp3,
3549
+ durationMs: durationMsFromRecord(record),
3550
+ inputTokens: input,
3551
+ cachedInputTokens: cached,
3552
+ cacheCreationInputTokens: cacheCreation,
3553
+ outputTokens: outputWithFallback,
3554
+ reasoningTokens: reasoning,
3555
+ dedupKey: dedupKeyForRecord(source, record, attributes, traceId, session, timestamp3, index)
3556
+ };
3557
+ }
3558
+ function outputTokensFromTotal(total, input, reasoning) {
3559
+ return total > 0 && total >= input + reasoning ? total - input - reasoning : 0;
3560
+ }
3561
+ function usageSource(record, attributes) {
3562
+ const operation = stringValue4(attributes["gen_ai.operation.name"]);
3563
+ const eventName = stringValue4(attributes["event.name"]);
3564
+ const name = stringValue4(record.name);
3565
+ const body = stringValue4(record.body) ?? stringValue4(record._body);
3566
+ const span = isSpanRecord(record);
3567
+ if (span && (operation === "chat" || name?.startsWith("chat "))) return "chat_span";
3568
+ if (!span && (eventName === "gen_ai.client.inference.operation.details" || body?.startsWith("GenAI inference:"))) return "inference_log";
3569
+ if (!span && (eventName === "copilot_chat.agent.turn" || body?.startsWith("copilot_chat.agent.turn"))) return "agent_turn_log";
3570
+ if (span && (operation === "invoke_agent" || name?.startsWith("invoke_agent "))) return "agent_summary_span";
3571
+ return void 0;
3572
+ }
3573
+ function isSpanRecord(record) {
3574
+ const type = stringValue4(record.type);
3575
+ if (type) return type === "span";
3576
+ return Boolean(stringValue4(record.name) && (stringValue4(record.spanId) || stringValue4(record.traceId) || record.startTime || record.endTime || record.duration || record.kind));
3577
+ }
3578
+ function shouldEmitCandidate(candidate, candidates) {
3579
+ const has = (source, field, value) => Boolean(value && candidates.some((item) => item.source === source && item[field] === value));
3580
+ if (candidate.source === "chat_span") return true;
3581
+ if (candidate.source === "inference_log") return !has("chat_span", "traceId", candidate.traceId) && !has("chat_span", "responseId", candidate.responseId);
3582
+ if (candidate.source === "agent_turn_log") {
3583
+ return !has("chat_span", "traceId", candidate.traceId) && !has("inference_log", "traceId", candidate.traceId) && !has("chat_span", "responseId", candidate.responseId) && !has("inference_log", "responseId", candidate.responseId);
3584
+ }
3585
+ return !has("chat_span", "traceId", candidate.traceId) && !has("inference_log", "traceId", candidate.traceId) && !has("agent_turn_log", "traceId", candidate.traceId) && !has("chat_span", "responseId", candidate.responseId) && !has("inference_log", "responseId", candidate.responseId) && !has("agent_turn_log", "responseId", candidate.responseId);
3586
+ }
3587
+ function uniqueCandidates(candidates) {
3588
+ const seen = /* @__PURE__ */ new Set();
3589
+ return candidates.filter((candidate) => {
3590
+ if (seen.has(candidate.dedupKey)) return false;
3591
+ seen.add(candidate.dedupKey);
3592
+ return true;
3593
+ });
3594
+ }
3595
+ var modelAttrs = ["gen_ai.response.model", "gen_ai.request.model"];
3596
+ var sessionAttrs = [
3597
+ { key: "gen_ai.conversation.id", priority: 3 },
3598
+ { key: "copilot_chat.session_id", priority: 3 },
3599
+ { key: "copilot_chat.chat_session_id", priority: 3 },
3600
+ { key: "session.id", priority: 3 },
3601
+ { key: "github.copilot.interaction_id", priority: 2 },
3602
+ { key: "gen_ai.response.id", priority: 1 }
3603
+ ];
3604
+ function applyCandidate(breakdown, candidate) {
3605
+ breakdown.id = candidate.sessionId;
3606
+ breakdown.model = candidate.model ?? breakdown.model;
3607
+ const candidateStartedAt = candidate.timestamp && candidate.durationMs ? new Date(Date.parse(candidate.timestamp) - candidate.durationMs).toISOString() : candidate.timestamp;
3608
+ breakdown.startedAt = minDate2(breakdown.startedAt, candidateStartedAt);
3609
+ breakdown.endedAt = maxDate2(breakdown.endedAt, candidate.timestamp);
3610
+ applyTokenEvent(breakdown, {
3611
+ dedupKey: candidate.dedupKey,
3612
+ detailed: candidate.inputTokens > 0 || candidate.cachedInputTokens > 0 || candidate.cacheCreationInputTokens > 0 || candidate.reasoningTokens > 0,
3613
+ inputTokens: candidate.inputTokens,
3614
+ cachedInputTokens: candidate.cachedInputTokens,
3615
+ cacheCreationInputTokens: candidate.cacheCreationInputTokens,
3616
+ outputTokens: candidate.outputTokens,
3617
+ reasoningTokens: candidate.reasoningTokens
3618
+ });
3619
+ breakdown.messageCount += 1;
3620
+ breakdown.assistantMessageCount += 1;
3621
+ }
3622
+ function applyTranscriptRecord(breakdown, record, file, fallbackTimestamp) {
3623
+ const data = firstObject5(record.data);
3624
+ const timestamp3 = dateValue4(record.timestamp) ?? dateValue4(record.ts) ?? dateValue4(data?.startTime) ?? fallbackTimestamp;
3625
+ const type = stringValue4(record.type);
3626
+ const role = type?.startsWith("user.") ? "user" : type?.startsWith("assistant.") ? "assistant" : stringValue4(data?.role);
3627
+ const sessionId = stringValue4(data?.sessionId) ?? stringValue4(record.sessionId) ?? stringValue4(record.sid);
3628
+ if (sessionId) breakdown.id = sessionId;
3629
+ if (timestamp3) {
3630
+ breakdown.startedAt = minDate2(breakdown.startedAt, timestamp3);
3631
+ breakdown.endedAt = maxDate2(breakdown.endedAt, timestamp3);
3632
+ }
3633
+ breakdown.cwd ??= stringValue4(data?.cwd) ?? stringValue4(record.cwd) ?? file.fallbackCwd;
3634
+ const context = firstObject5(data?.context);
3635
+ breakdown.cwd ??= stringValue4(context?.cwd) ?? stringValue4(context?.gitRoot);
3636
+ const modelCandidate = stringValue4(data?.newModel) ?? stringValue4(data?.currentModel) ?? stringValue4(data?.model) ?? stringValue4(record.model);
3637
+ if (modelCandidate && (modelCandidate !== "auto" || !breakdown.model)) breakdown.model = modelCandidate;
3638
+ if (type === "session.start") breakdown.title ??= stringValue4(data?.title);
3639
+ const outputTokens = numberValue2(data?.outputTokens);
3640
+ if (outputTokens > 0) {
3641
+ const partialSessionId = sessionId ?? breakdown.id ?? path7.basename(file.filePath);
3642
+ applyTokenEvent(breakdown, {
3643
+ dedupKey: `transcript-output:${file.filePath}:${partialSessionId}:${timestamp3 ?? "unknown"}:${breakdown.usageCount}`,
3644
+ detailed: false,
3645
+ inputTokens: 0,
3646
+ cachedInputTokens: 0,
3647
+ cacheCreationInputTokens: 0,
3648
+ outputTokens,
3649
+ reasoningTokens: 0
3650
+ });
3651
+ }
3652
+ if (role === "user" || role === "assistant") {
3653
+ breakdown.messageCount += 1;
3654
+ if (role === "user") breakdown.userPromptCount += 1;
3655
+ if (role === "assistant") breakdown.assistantMessageCount += 1;
3656
+ applyTimeline(breakdown, role, data?.content ?? record.content, timestamp3);
3657
+ }
3658
+ const toolRequests = Array.isArray(data?.toolRequests) ? data.toolRequests : [];
3659
+ breakdown.toolCallCount += toolRequests.length;
3660
+ for (const request of toolRequests) {
3661
+ const object = firstObject5(request);
3662
+ const name = stringValue4(object?.name)?.toLowerCase() ?? "";
3663
+ const args2 = stringValue4(object?.arguments)?.toLowerCase() ?? "";
3664
+ if (name.includes("terminal") || args2.includes('"command"')) breakdown.shellCommandCount += 1;
3665
+ if (name.includes("edit") || args2.includes("edit")) breakdown.fileEditCount += 1;
3666
+ if (name.includes("read") || args2.includes("read_file")) breakdown.fileReadCount += 1;
3667
+ }
3668
+ }
3669
+ function breakdownToUsage(breakdown, index, options) {
3670
+ const id = breakdown.id ?? `copilot-session-${index}`;
3671
+ const cwd = breakdown.cwd ?? "";
3672
+ const repo = breakdown.cwd ? resolveRepoInfo(breakdown.cwd, options.config) : unknownCopilotRepoInfo();
3673
+ const hasTokenBreakdown = breakdown.inputTokens + breakdown.outputTokens + breakdown.reasoningTokens > 0 && breakdown.usageCount > 0;
3674
+ const hasPricedBreakdown = hasTokenBreakdown && breakdown.inputTokens > 0;
3675
+ const warnings = unique4([...repo.warnings, ...breakdown.warnings]);
3676
+ if (hasTokenBreakdown && !hasPricedBreakdown) warnings.push("copilot_partial_token_breakdown");
3677
+ const usage = {
3678
+ id,
3679
+ sourceClient: "copilot",
3680
+ sourceApp: breakdown.surface === "cli" ? "Copilot CLI" : breakdown.surface === "vscode" ? "VS Code" : "GitHub Copilot",
3681
+ sourceAppRaw: breakdown.surface,
3682
+ sourcePath: breakdown.sourcePaths[0] ?? "",
3683
+ repoRoot: repo.repoRoot,
3684
+ repoName: repo.repoName,
3685
+ cwd,
3686
+ gitRemoteUrl: repo.gitRemoteUrl,
3687
+ gitBranch: repo.gitBranch,
3688
+ title: breakdown.title,
3689
+ startedAt: breakdown.startedAt,
3690
+ endedAt: breakdown.endedAt,
3691
+ model: normalizeCopilotModel(breakdown.model),
3692
+ provider: providerFromModel(breakdown.model),
3693
+ inputTokens: breakdown.inputTokens,
3694
+ cachedInputTokens: breakdown.cachedInputTokens,
3695
+ cacheCreationInputTokens: breakdown.cacheCreationInputTokens,
3696
+ outputTokens: breakdown.outputTokens,
3697
+ reasoningTokens: breakdown.reasoningTokens,
3698
+ reasoningOutputTokens: breakdown.reasoningTokens,
3699
+ totalTokens: breakdown.inputTokens + breakdown.outputTokens + breakdown.reasoningTokens,
3700
+ tokenAggregationMethod: hasTokenBreakdown ? "direct_usage" : "unknown",
3701
+ tokenConfidence: hasTokenBreakdown ? "high" : "low",
3702
+ tokenSnapshotCount: breakdown.usageCount,
3703
+ estimatedCostUsd: void 0,
3704
+ messageCount: breakdown.messageCount,
3705
+ rawTokenTotal: hasTokenBreakdown ? breakdown.inputTokens + breakdown.outputTokens + breakdown.reasoningTokens : void 0,
3706
+ warnings: hasTokenBreakdown ? warnings : unique4([...warnings, "missing_token_breakdown"]),
3707
+ durationMs: durationMs3(breakdown.startedAt, breakdown.endedAt),
3708
+ rawEventCount: breakdown.rawEventCount,
3709
+ parseStatus: breakdown.parseStatus,
3710
+ parseErrors: breakdown.parseErrors,
3711
+ detectedSurface: breakdown.surface === "vscode" ? "vscode_extension" : "terminal_cli",
3712
+ surfaceConfidence: breakdown.surface === "unknown" ? "low" : "high",
3713
+ surfaceReason: breakdown.surface === "vscode" ? "GitHub Copilot VS Code workspace storage" : "GitHub Copilot OpenTelemetry export",
3714
+ promptTimeline: breakdown.promptTimeline,
3715
+ userPromptCount: breakdown.userPromptCount,
3716
+ assistantMessageCount: breakdown.assistantMessageCount,
3717
+ toolCallCount: breakdown.toolCallCount,
3718
+ shellCommandCount: breakdown.shellCommandCount,
3719
+ failedToolCallCount: breakdown.failedToolCallCount,
3720
+ nonZeroCommandEvents: 0,
3721
+ importantCommandFailures: 0,
3722
+ harmlessNonZeroEvents: 0,
3723
+ exploratoryMisses: 0,
3724
+ repeatedFailureClusters: 0,
3725
+ commandIssueSeverity: breakdown.failedToolCallCount > 0 ? "warning" : "none",
3726
+ commandIssueImpact: breakdown.failedToolCallCount > 0 ? "medium" : "none",
3727
+ topFailureType: breakdown.failedToolCallCount > 0 ? "unknown" : void 0,
3728
+ commandIssueSamples: [],
3729
+ fileReadCount: breakdown.fileReadCount,
3730
+ fileEditCount: breakdown.fileEditCount,
3731
+ sourceMetadata: { copilot: { sourcePaths: breakdown.sourcePaths, surface: breakdown.surface } }
3732
+ };
3733
+ const cost = hasPricedBreakdown ? calculateCostUsd(usage, options.pricing) : void 0;
3734
+ return { ...usage, estimatedCostUsd: cost, warnings: hasPricedBreakdown && cost === void 0 ? [...usage.warnings, "unknown_pricing"] : usage.warnings };
3735
+ }
3736
+ function unknownCopilotRepoInfo() {
3737
+ return {
3738
+ repoRoot: "unknown-copilot-repo",
3739
+ repoName: "Unknown repo/folder",
3740
+ gitRemoteUrl: void 0,
3741
+ gitBranch: void 0,
3742
+ warnings: ["repo_unverified_no_git_root", "copilot_missing_cwd"]
3743
+ };
3744
+ }
3745
+ function emptyBreakdown3(file, id) {
3746
+ return {
3747
+ id,
3748
+ title: void 0,
3749
+ cwd: file.fallbackCwd,
3750
+ startedAt: void 0,
3751
+ endedAt: void 0,
3752
+ model: void 0,
3753
+ inputTokens: 0,
3754
+ cachedInputTokens: 0,
3755
+ cacheCreationInputTokens: 0,
3756
+ outputTokens: 0,
3757
+ reasoningTokens: 0,
3758
+ usageCount: 0,
3759
+ messageCount: 0,
3760
+ userPromptCount: 0,
3761
+ assistantMessageCount: 0,
3762
+ toolCallCount: 0,
3763
+ shellCommandCount: 0,
3764
+ failedToolCallCount: 0,
3765
+ fileReadCount: 0,
3766
+ fileEditCount: 0,
3767
+ rawEventCount: 0,
3768
+ parseStatus: "ok",
3769
+ parseErrors: [],
3770
+ promptTimeline: [],
3771
+ warnings: [],
3772
+ sourcePaths: [file.filePath],
3773
+ surface: file.surface,
3774
+ tokenEvents: []
3775
+ };
3776
+ }
3777
+ function mergeBreakdown(sessions, breakdown) {
3778
+ const key = breakdown.id ?? breakdown.sourcePaths[0] ?? "unknown-session";
3779
+ const existing = sessions.get(key);
3780
+ if (!existing) {
3781
+ sessions.set(key, breakdown);
3782
+ return;
3783
+ }
3784
+ mergeActivity(existing, breakdown);
3785
+ mergeTokenData(existing, breakdown);
3786
+ existing.sourcePaths = unique4([...existing.sourcePaths, ...breakdown.sourcePaths]);
3787
+ }
3788
+ function mergeTokenData(target, source) {
3789
+ if (source.usageCount <= 0) return;
3790
+ const targetHadUsage = target.usageCount > 0;
3791
+ const targetHasDetailed = hasDetailedTokenBreakdown(target);
3792
+ const sourceHasDetailed = hasDetailedTokenBreakdown(source);
3793
+ if (targetHadUsage) target.warnings = unique4([...target.warnings, "copilot_duplicate_session_merged"]);
3794
+ if (targetHadUsage && targetHasDetailed && !sourceHasDetailed) return;
3795
+ if (targetHadUsage && !targetHasDetailed && sourceHasDetailed) {
3796
+ replaceTokenData(target, source);
3797
+ return;
3798
+ }
3799
+ if (source.tokenEvents.length > 0) {
3800
+ const existingKeys = new Set(target.tokenEvents.map((event) => event.dedupKey));
3801
+ for (const event of source.tokenEvents) {
3802
+ if (existingKeys.has(event.dedupKey)) continue;
3803
+ applyTokenEvent(target, event);
3804
+ existingKeys.add(event.dedupKey);
3805
+ }
3806
+ return;
3807
+ }
3808
+ applyTokenEvent(target, {
3809
+ dedupKey: `merged-total:${source.sourcePaths.join("|")}:${source.usageCount}`,
3810
+ detailed: sourceHasDetailed,
3811
+ inputTokens: source.inputTokens,
3812
+ cachedInputTokens: source.cachedInputTokens,
3813
+ cacheCreationInputTokens: source.cacheCreationInputTokens,
3814
+ outputTokens: source.outputTokens,
3815
+ reasoningTokens: source.reasoningTokens
3816
+ });
3817
+ }
3818
+ function replaceTokenData(target, source) {
3819
+ target.inputTokens = source.inputTokens;
3820
+ target.cachedInputTokens = source.cachedInputTokens;
3821
+ target.cacheCreationInputTokens = source.cacheCreationInputTokens;
3822
+ target.outputTokens = source.outputTokens;
3823
+ target.reasoningTokens = source.reasoningTokens;
3824
+ target.usageCount = source.usageCount;
3825
+ target.tokenEvents = source.tokenEvents.map((event) => ({ ...event }));
3826
+ }
3827
+ function hasDetailedTokenBreakdown(breakdown) {
3828
+ return breakdown.inputTokens > 0 || breakdown.cachedInputTokens > 0 || breakdown.cacheCreationInputTokens > 0 || breakdown.reasoningTokens > 0;
3829
+ }
3830
+ function applyTokenEvent(breakdown, event) {
3831
+ if (breakdown.tokenEvents.some((existing) => existing.dedupKey === event.dedupKey)) return;
3832
+ breakdown.inputTokens += event.inputTokens;
3833
+ breakdown.cachedInputTokens += event.cachedInputTokens;
3834
+ breakdown.cacheCreationInputTokens += event.cacheCreationInputTokens;
3835
+ breakdown.outputTokens += event.outputTokens;
3836
+ breakdown.reasoningTokens += event.reasoningTokens;
3837
+ breakdown.usageCount += 1;
3838
+ breakdown.tokenEvents.push({ ...event });
3839
+ }
3840
+ function applyModelHints(sessions, modelHints) {
3841
+ for (const breakdown of sessions.values()) {
3842
+ if (!breakdown.id) continue;
3843
+ const hint = modelHints.get(breakdown.id);
3844
+ if (!hint || hasKnownModel(breakdown.model)) continue;
3845
+ breakdown.model = hint.model;
3846
+ breakdown.sourcePaths = unique4([...breakdown.sourcePaths, hint.sourcePath]);
3847
+ }
3848
+ }
3849
+ function hasKnownModel(model) {
3850
+ const normalized = model?.trim().toLowerCase().replace(/^github[-_]?copilot\//, "").replace(/^copilot\//, "");
3851
+ return Boolean(normalized && normalized !== "auto");
3852
+ }
3853
+ function mergeActivity(target, source) {
3854
+ target.title ??= source.title;
3855
+ target.cwd ??= source.cwd;
3856
+ target.model ??= source.model;
3857
+ target.startedAt = minDate2(target.startedAt, source.startedAt);
3858
+ target.endedAt = maxDate2(target.endedAt, source.endedAt);
3859
+ target.messageCount = Math.max(target.messageCount, source.messageCount);
3860
+ target.userPromptCount = Math.max(target.userPromptCount, source.userPromptCount);
3861
+ target.assistantMessageCount = Math.max(target.assistantMessageCount, source.assistantMessageCount);
3862
+ target.toolCallCount = Math.max(target.toolCallCount, source.toolCallCount);
3863
+ target.shellCommandCount = Math.max(target.shellCommandCount, source.shellCommandCount);
3864
+ target.fileReadCount = Math.max(target.fileReadCount, source.fileReadCount);
3865
+ target.fileEditCount = Math.max(target.fileEditCount, source.fileEditCount);
3866
+ target.rawEventCount = Math.max(target.rawEventCount, source.rawEventCount);
3867
+ target.parseErrors = unique4([...target.parseErrors, ...source.parseErrors]);
3868
+ target.parseStatus = target.parseErrors.length ? "partial" : target.parseStatus;
3869
+ target.promptTimeline = [...target.promptTimeline, ...source.promptTimeline].slice(0, 80);
3870
+ target.warnings = unique4([...target.warnings, ...source.warnings]);
3871
+ return target;
3872
+ }
3873
+ function fallbackWithMissingTokenWarning(breakdown) {
3874
+ if (breakdown.usageCount === 0) breakdown.warnings.push("missing_token_breakdown");
3875
+ return breakdown;
3876
+ }
3877
+ function parseJsonLines3(text) {
3878
+ const records = [];
3879
+ const errors = [];
3880
+ text.split(/\r?\n/).forEach((line, index) => {
3881
+ const trimmed = line.trim();
3882
+ if (!trimmed) return;
3883
+ try {
3884
+ const parsed = JSON.parse(trimmed);
3885
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) records.push(parsed);
3886
+ } catch (error) {
3887
+ errors.push(`jsonl_line_${index + 1}:${errorMessage3(error)}`);
3888
+ }
3889
+ });
3890
+ return { records, errors };
3891
+ }
3892
+ function applyTimeline(breakdown, role, content, timestamp3) {
3893
+ if (breakdown.promptTimeline.length >= 80) return;
3894
+ const text = typeof content === "string" ? content : void 0;
3895
+ if (!text?.trim()) return;
3896
+ breakdown.promptTimeline.push({ role, text: text.replace(/\s+/g, " ").trim().slice(0, 1e3), timestamp: timestamp3 });
3897
+ }
3898
+ function firstNonEmptyAttr(attributes, keys) {
3899
+ return keys.map((key) => stringValue4(attributes[key])).find(Boolean);
3900
+ }
3901
+ function bestSessionAttr(attributes) {
3902
+ return sessionAttrs.map((attr) => ({ value: stringValue4(attributes[attr.key]), priority: attr.priority })).filter((item) => Boolean(item.value)).sort((a, b) => b.priority - a.priority)[0];
3903
+ }
3904
+ function traceIdFromRecord(record) {
3905
+ const spanContext = firstObject5(record.spanContext);
3906
+ return stringValue4(record.traceId) ?? stringValue4(spanContext?.traceId);
3907
+ }
3908
+ function spanIdFromRecord(record) {
3909
+ const spanContext = firstObject5(record.spanContext);
3910
+ return stringValue4(record.spanId) ?? stringValue4(spanContext?.spanId);
3911
+ }
3912
+ function dedupKeyForRecord(source, record, attributes, traceId, sessionId, timestamp3, index) {
3913
+ const spanId = spanIdFromRecord(record);
3914
+ if ((source === "chat_span" || source === "agent_summary_span") && traceId && spanId) return `${traceId}:${spanId}`;
3915
+ if (source === "inference_log" && traceId && spanId) return `log:${traceId}:${spanId}`;
3916
+ if (source === "agent_turn_log") {
3917
+ const turn = stringValue4(attributes["turn.index"]) ?? stringValue4(attributes["copilot_chat.turn.index"]) ?? `idx-${index}`;
3918
+ return traceId ? `agent-turn:${traceId}:${turn}` : `agent-turn:${sessionId}:${turn}:${index}`;
3919
+ }
3920
+ return `${source}:${sessionId}:${timestamp3 ?? "unknown"}:${index}`;
3921
+ }
3922
+ function timestampFromRecord(record) {
3923
+ return timestampMsFromParts(record.endTime) ?? timestampMsFromParts(record.startTime) ?? timestampMsFromParts(record.hrTime) ?? timestampMsFromParts(record._hrTime) ?? timestampMsFromScalar(record.timestamp) ?? timestampMsFromScalar(record.observedTimestamp) ?? timestampMsFromUnixNanos(record.timeUnixNano);
3924
+ }
3925
+ function timestampMsFromParts(value) {
3926
+ if (!Array.isArray(value)) return void 0;
3927
+ const seconds = numberValue2(value[0]);
3928
+ const nanos = numberValue2(value[1]);
3929
+ if (seconds <= 0) return void 0;
3930
+ return new Date(seconds * 1e3 + Math.floor(nanos / 1e6)).toISOString();
3931
+ }
3932
+ function timestampMsFromScalar(value) {
3933
+ const raw = numberValue2(value);
3934
+ if (raw <= 0) return void 0;
3935
+ const absolute = Math.abs(raw);
3936
+ const ms = absolute >= 1e17 ? raw / 1e6 : absolute >= 1e14 ? raw / 1e3 : absolute >= 1e11 ? raw : raw * 1e3;
3937
+ return new Date(ms).toISOString();
3938
+ }
3939
+ function timestampMsFromUnixNanos(value) {
3940
+ const raw = numberValue2(value);
3941
+ return raw > 0 ? new Date(Math.floor(raw / 1e6)).toISOString() : void 0;
3942
+ }
3943
+ function durationMsFromRecord(record) {
3944
+ const start = timestampMs2(record.startTime);
3945
+ const end = timestampMs2(record.endTime);
3946
+ if (start !== void 0 && end !== void 0 && end > start) return end - start;
3947
+ const duration = numberValue2(record.duration);
3948
+ return duration > 0 ? duration >= 1e6 ? Math.floor(duration / 1e6) : duration : void 0;
3949
+ }
3950
+ function timestampMs2(value) {
3951
+ const iso = timestampMsFromParts(value) ?? timestampMsFromScalar(value);
3952
+ return iso ? Date.parse(iso) : void 0;
3953
+ }
3954
+ function defaultCodeUserRoots() {
3955
+ const home = os4.homedir();
3956
+ if (process.platform === "win32") {
3957
+ const appData = process.env.APPDATA ?? path7.join(home, "AppData", "Roaming");
3958
+ return ["Code", "Code - Insiders", "VSCodium"].map((name) => path7.join(appData, name, "User"));
3959
+ }
3960
+ if (process.platform === "darwin") {
3961
+ return ["Code", "Code - Insiders", "VSCodium"].map((name) => path7.join(home, "Library", "Application Support", name, "User"));
3962
+ }
3963
+ const xdg = process.env.XDG_CONFIG_HOME || path7.join(home, ".config");
3964
+ return ["Code", "Code - Insiders", "VSCodium"].map((name) => path7.join(xdg, name, "User"));
3965
+ }
3966
+ function workspaceFolderFromStorageJson(filePath) {
3967
+ try {
3968
+ const parsed = JSON.parse(fs7.readFileSync(filePath, "utf8"));
3969
+ const folder = stringValue4(parsed.folder);
3970
+ if (!folder) return void 0;
3971
+ return folder.startsWith("file://") ? decodeURIComponent(new URL(folder).pathname) : folder;
3972
+ } catch {
3973
+ return void 0;
3974
+ }
3975
+ }
3976
+ function cwdFromWorkspaceYaml(filePath) {
3977
+ try {
3978
+ const text = fs7.readFileSync(filePath, "utf8");
3979
+ const match = /^cwd:\s*(.+)$/m.exec(text);
3980
+ return match?.[1]?.trim();
3981
+ } catch {
3982
+ return void 0;
3983
+ }
3984
+ }
3985
+ function fileMayOverlapScanWindow2(filePath, scanWindow) {
3986
+ if (!scanWindow?.fromMs && !scanWindow?.toMs) return true;
3987
+ try {
3988
+ const mtime = fs7.statSync(filePath).mtimeMs;
3989
+ if (scanWindow.fromMs !== void 0 && mtime < scanWindow.fromMs) return false;
3990
+ if (scanWindow.toMs !== void 0 && mtime > scanWindow.toMs) return false;
3991
+ } catch {
3992
+ return true;
3993
+ }
3994
+ return true;
3995
+ }
3996
+ function walk3(root, maxDepth, onFile, depth = 0) {
3997
+ if (depth > maxDepth) return;
3998
+ for (const entry of fs7.readdirSync(root, { withFileTypes: true })) {
3999
+ const fullPath = path7.join(root, entry.name);
4000
+ if (entry.isDirectory()) walk3(fullPath, maxDepth, onFile, depth + 1);
4001
+ else if (entry.isFile()) onFile(fullPath);
4002
+ }
4003
+ }
4004
+ function isReadableRegularFile(filePath, maxBytes) {
4005
+ try {
4006
+ const stat = fs7.statSync(filePath);
4007
+ return stat.isFile() && stat.size <= maxBytes;
4008
+ } catch {
4009
+ return false;
4010
+ }
4011
+ }
4012
+ function normalizeCopilotModel(model) {
4013
+ if (!model) return void 0;
4014
+ return normalizePricingModelId(model);
4015
+ }
4016
+ function providerFromModel(model) {
4017
+ const normalized = normalizeCopilotModel(model);
4018
+ if (!normalized) return "github-copilot";
4019
+ if (normalized.includes("claude")) return "anthropic";
4020
+ if (normalized.includes("gemini")) return "google";
4021
+ if (isCopilotAliasModel(normalized)) return "github-copilot";
4022
+ if (normalized.includes("gpt")) return "openai";
4023
+ return "github-copilot";
4024
+ }
4025
+ function firstPositiveNumber(object, keys) {
4026
+ return keys.map((key) => numberValue2(object[key])).find((value) => value > 0) ?? 0;
4027
+ }
4028
+ function firstObject5(value) {
4029
+ return value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
4030
+ }
4031
+ function stringValue4(value) {
4032
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
4033
+ }
4034
+ function numberValue2(value) {
4035
+ if (typeof value === "number" && Number.isFinite(value)) return Math.max(Math.trunc(value), 0);
4036
+ if (typeof value === "string" && value.trim()) {
4037
+ const parsed = Number(value);
4038
+ return Number.isFinite(parsed) ? Math.max(Math.trunc(parsed), 0) : 0;
4039
+ }
4040
+ return 0;
4041
+ }
4042
+ function dateValue4(value) {
4043
+ if (typeof value === "string") {
4044
+ const parsed = Date.parse(value);
4045
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : void 0;
4046
+ }
4047
+ return timestampMsFromScalar(value);
4048
+ }
4049
+ function fileModifiedDate(filePath) {
4050
+ try {
4051
+ return new Date(fs7.statSync(filePath).mtimeMs).toISOString();
4052
+ } catch {
4053
+ return void 0;
4054
+ }
4055
+ }
4056
+ function minDate2(a, b) {
4057
+ if (!a) return b;
4058
+ if (!b) return a;
4059
+ return a < b ? a : b;
4060
+ }
4061
+ function maxDate2(a, b) {
4062
+ if (!a) return b;
4063
+ if (!b) return a;
4064
+ return a > b ? a : b;
4065
+ }
4066
+ function durationMs3(startedAt, endedAt) {
4067
+ if (!startedAt || !endedAt) return void 0;
4068
+ const start = Date.parse(startedAt);
4069
+ const end = Date.parse(endedAt);
4070
+ return Number.isFinite(start) && Number.isFinite(end) && end >= start ? end - start : void 0;
4071
+ }
4072
+ function unique4(items) {
4073
+ return [...new Set(items)];
4074
+ }
4075
+ function uniqueFiles(files) {
4076
+ const seen = /* @__PURE__ */ new Set();
4077
+ return files.filter((file) => {
4078
+ const key = path7.resolve(file.filePath);
4079
+ if (seen.has(key)) return false;
4080
+ seen.add(key);
4081
+ return true;
4082
+ }).sort((a, b) => a.filePath.localeCompare(b.filePath));
4083
+ }
4084
+ function errorMessage3(error) {
4085
+ return error instanceof Error ? error.message : String(error);
4086
+ }
4087
+
4088
+ // packages/core/src/sources/cursor.ts
4089
+ import fs8 from "node:fs";
4090
+ import os5 from "node:os";
4091
+ import path8 from "node:path";
4092
+ import Database2 from "better-sqlite3";
4093
+ var parserVersion = "cursor-v1";
4094
+ var maxJsonlBytes2 = 20 * 1024 * 1024;
4095
+ var maxDatabaseBytes = 100 * 1024 * 1024;
4096
+ var cursorStoragePrefixes = ["cursor.", "anysphere."];
4097
+ var interestingTerms = ["cursor", "composer", "aichat", "ai_chat", "aiservice", "agent-transcript", "prompttokenbreakdown"];
4098
+ function scanCursor(options) {
4099
+ const cursorHome = options.cursorHome ?? path8.join(os5.homedir(), ".cursor");
4100
+ const discovery = discoverCursorFiles(cursorHome, Boolean(options.cursorHome));
4101
+ const jsonlFiles = discovery.jsonlFiles.filter((filePath) => fileMayOverlapScanWindow3(filePath, options.scanWindow));
4102
+ const databaseFiles = discovery.databaseFiles.filter((filePath) => fileMayOverlapScanWindow3(filePath, options.scanWindow));
4103
+ const jsonlCandidates = jsonlFiles.map((filePath) => cursorJsonlToCandidate(filePath, cursorHome));
4104
+ const databaseCandidates = databaseFiles.flatMap((filePath) => cursorDatabaseToCandidates(filePath, cursorHome, discovery.warnings));
4105
+ const candidates = dedupeCandidates([...jsonlCandidates, ...databaseCandidates]);
4106
+ const sessions = candidates.map((candidate, index) => candidateToUsage(candidate, index, options));
4107
+ const parseFailureCount = sessions.filter((session) => session.parseStatus === "failed" || (session.parseErrors?.length ?? 0) > 0).length;
4108
+ const unreadableFileCount = sessions.filter((session) => session.parseErrors?.some((error) => error.startsWith("unable_to_read_"))).length;
4109
+ const malformedFileCount = sessions.filter((session) => session.parseErrors?.some((error) => error.startsWith("jsonl_line_") || error.startsWith("sqlite_json_parse:"))).length;
4110
+ if (!fs8.existsSync(cursorHome)) {
4111
+ discovery.warnings.push(`Cursor home directory not found at ${cursorHome}`);
4112
+ }
4113
+ if (!fs8.existsSync(discovery.statePath)) {
4114
+ discovery.warnings.push(`Cursor global state database not found at ${discovery.statePath}`);
4115
+ }
4116
+ return {
4117
+ source: {
4118
+ id: "cursor",
4119
+ label: "Cursor",
4120
+ available: discovery.jsonlFiles.length > 0 || discovery.databaseFiles.length > 0,
4121
+ paths: unique5([cursorHome, discovery.projectsPath, discovery.statePath, discovery.workspaceStoragePath, ...discovery.roots]),
4122
+ warnings: discovery.warnings
4123
+ },
4124
+ sessions,
4125
+ stats: {
4126
+ sourceId: "cursor",
4127
+ sourceLabel: "Cursor",
4128
+ homePath: cursorHome,
4129
+ cursorHome,
4130
+ globalStoragePath: discovery.globalStoragePath,
4131
+ workspaceStoragePath: discovery.workspaceStoragePath,
4132
+ projectsPath: discovery.projectsPath,
4133
+ statePath: discovery.statePath,
4134
+ projectsExists: fs8.existsSync(discovery.projectsPath),
4135
+ stateExists: fs8.existsSync(discovery.statePath),
4136
+ sessionsExists: jsonlFiles.length > 0,
4137
+ sessionFileCount: jsonlFiles.length,
4138
+ databaseFileCount: databaseFiles.length,
4139
+ skippedFileCount: discovery.skippedFileCount,
4140
+ sessionsImported: sessions.length,
4141
+ parseFailureCount,
4142
+ unreadableFileCount,
4143
+ malformedFileCount,
4144
+ lastScannedAt: (/* @__PURE__ */ new Date()).toISOString()
4145
+ }
4146
+ };
4147
+ }
4148
+ function discoverCursorFiles(cursorHome, explicitHome) {
4149
+ const warnings = [];
4150
+ const appPaths = defaultCursorAppPaths();
4151
+ const roots = explicitHome ? [cursorHome] : unique5([cursorHome, appPaths.statePath, ...cursorOwnedGlobalStorageRoots(appPaths.globalStoragePath), ...cursorOwnedWorkspaceStorageRoots(appPaths.workspaceStoragePath)]);
4152
+ const jsonlFiles = [];
4153
+ const databaseFiles = [];
4154
+ let skippedFileCount = 0;
4155
+ for (const root of roots) {
4156
+ if (!fs8.existsSync(root)) continue;
4157
+ try {
4158
+ if (isReadableRegularFile2(root, maxDatabaseBytes)) {
4159
+ databaseFiles.push(root);
4160
+ continue;
4161
+ }
4162
+ walk4(root, 10, (filePath) => {
4163
+ const lower = filePath.toLowerCase();
4164
+ if (lower.endsWith(".jsonl")) {
4165
+ if (isReadableRegularFile2(filePath, maxJsonlBytes2)) jsonlFiles.push(filePath);
4166
+ else skippedFileCount += 1;
4167
+ return;
4168
+ }
4169
+ if (/\.(sqlite|db|vscdb)$/i.test(filePath)) {
4170
+ if (isReadableRegularFile2(filePath, maxDatabaseBytes)) databaseFiles.push(filePath);
4171
+ else skippedFileCount += 1;
4172
+ }
4173
+ });
4174
+ } catch (error) {
4175
+ warnings.push(`Unable to inspect Cursor path ${root}: ${errorMessage4(error)}`);
4176
+ }
4177
+ }
4178
+ return {
4179
+ roots,
4180
+ projectsPath: path8.join(cursorHome, "projects"),
4181
+ globalStoragePath: appPaths.globalStoragePath,
4182
+ workspaceStoragePath: appPaths.workspaceStoragePath,
4183
+ statePath: appPaths.statePath,
4184
+ jsonlFiles: unique5(jsonlFiles).sort(cursorJsonlSort),
4185
+ databaseFiles: unique5(databaseFiles).sort(),
4186
+ skippedFileCount,
4187
+ warnings
4188
+ };
4189
+ }
4190
+ function cursorOwnedGlobalStorageRoots(globalStoragePath) {
4191
+ return cursorOwnedChildDirs(globalStoragePath);
4192
+ }
4193
+ function cursorOwnedWorkspaceStorageRoots(workspaceStoragePath) {
4194
+ if (!fs8.existsSync(workspaceStoragePath)) return [];
4195
+ const roots = [];
4196
+ try {
4197
+ for (const workspace of fs8.readdirSync(workspaceStoragePath, { withFileTypes: true })) {
4198
+ if (!workspace.isDirectory()) continue;
4199
+ roots.push(...cursorOwnedChildDirs(path8.join(workspaceStoragePath, workspace.name)));
4200
+ }
4201
+ } catch {
4202
+ return [];
4203
+ }
4204
+ return roots;
4205
+ }
4206
+ function cursorOwnedChildDirs(root) {
4207
+ if (!fs8.existsSync(root)) return [];
4208
+ try {
4209
+ return fs8.readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory() && cursorStoragePrefixes.some((prefix) => entry.name.toLowerCase().startsWith(prefix))).map((entry) => path8.join(root, entry.name));
4210
+ } catch {
2855
4211
  return [];
2856
4212
  }
2857
4213
  }
2858
4214
  function defaultCursorAppPaths() {
2859
- const home = os4.homedir();
4215
+ const home = os5.homedir();
2860
4216
  if (process.platform === "win32") {
2861
- const appData = process.env.APPDATA ?? path6.join(home, "AppData", "Roaming");
2862
- const globalStoragePath2 = path6.join(appData, "Cursor", "User", "globalStorage");
4217
+ const appData = process.env.APPDATA ?? path8.join(home, "AppData", "Roaming");
4218
+ const globalStoragePath2 = path8.join(appData, "Cursor", "User", "globalStorage");
2863
4219
  return {
2864
4220
  globalStoragePath: globalStoragePath2,
2865
- workspaceStoragePath: path6.join(appData, "Cursor", "User", "workspaceStorage"),
2866
- statePath: path6.join(globalStoragePath2, "state.vscdb")
4221
+ workspaceStoragePath: path8.join(appData, "Cursor", "User", "workspaceStorage"),
4222
+ statePath: path8.join(globalStoragePath2, "state.vscdb")
2867
4223
  };
2868
4224
  }
2869
4225
  if (process.platform === "darwin") {
2870
- const globalStoragePath2 = path6.join(home, "Library", "Application Support", "Cursor", "User", "globalStorage");
4226
+ const globalStoragePath2 = path8.join(home, "Library", "Application Support", "Cursor", "User", "globalStorage");
2871
4227
  return {
2872
4228
  globalStoragePath: globalStoragePath2,
2873
- workspaceStoragePath: path6.join(home, "Library", "Application Support", "Cursor", "User", "workspaceStorage"),
2874
- statePath: path6.join(globalStoragePath2, "state.vscdb")
4229
+ workspaceStoragePath: path8.join(home, "Library", "Application Support", "Cursor", "User", "workspaceStorage"),
4230
+ statePath: path8.join(globalStoragePath2, "state.vscdb")
2875
4231
  };
2876
4232
  }
2877
- const xdg = process.env.XDG_CONFIG_HOME || path6.join(home, ".config");
2878
- const globalStoragePath = path6.join(xdg, "Cursor", "User", "globalStorage");
4233
+ const xdg = process.env.XDG_CONFIG_HOME || path8.join(home, ".config");
4234
+ const globalStoragePath = path8.join(xdg, "Cursor", "User", "globalStorage");
2879
4235
  return {
2880
4236
  globalStoragePath,
2881
- workspaceStoragePath: path6.join(xdg, "Cursor", "User", "workspaceStorage"),
2882
- statePath: path6.join(globalStoragePath, "state.vscdb")
4237
+ workspaceStoragePath: path8.join(xdg, "Cursor", "User", "workspaceStorage"),
4238
+ statePath: path8.join(globalStoragePath, "state.vscdb")
2883
4239
  };
2884
4240
  }
2885
- function walk3(root, maxDepth, onFile, depth = 0) {
4241
+ function walk4(root, maxDepth, onFile, depth = 0) {
2886
4242
  if (depth > maxDepth) return;
2887
- for (const entry of fs6.readdirSync(root, { withFileTypes: true })) {
4243
+ for (const entry of fs8.readdirSync(root, { withFileTypes: true })) {
2888
4244
  if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "CachedData" || entry.name === "Cache") continue;
2889
- const fullPath = path6.join(root, entry.name);
4245
+ const fullPath = path8.join(root, entry.name);
2890
4246
  if (entry.isDirectory()) {
2891
- walk3(fullPath, maxDepth, onFile, depth + 1);
4247
+ walk4(fullPath, maxDepth, onFile, depth + 1);
2892
4248
  } else if (entry.isFile()) {
2893
4249
  onFile(fullPath);
2894
4250
  }
2895
4251
  }
2896
4252
  }
2897
- function isReadableRegularFile(filePath, maxBytes) {
4253
+ function isReadableRegularFile2(filePath, maxBytes) {
2898
4254
  try {
2899
- fs6.accessSync(filePath, fs6.constants.R_OK);
2900
- const stat = fs6.statSync(filePath);
4255
+ fs8.accessSync(filePath, fs8.constants.R_OK);
4256
+ const stat = fs8.statSync(filePath);
2901
4257
  return stat.isFile() && stat.size <= maxBytes;
2902
4258
  } catch {
2903
4259
  return false;
2904
4260
  }
2905
4261
  }
2906
4262
  function cursorJsonlSort(a, b) {
2907
- const aTranscript = a.includes(`${path6.sep}agent-transcripts${path6.sep}`) ? 0 : 1;
2908
- const bTranscript = b.includes(`${path6.sep}agent-transcripts${path6.sep}`) ? 0 : 1;
4263
+ const aTranscript = a.includes(`${path8.sep}agent-transcripts${path8.sep}`) ? 0 : 1;
4264
+ const bTranscript = b.includes(`${path8.sep}agent-transcripts${path8.sep}`) ? 0 : 1;
2909
4265
  return aTranscript - bTranscript || a.localeCompare(b);
2910
4266
  }
4267
+ function fileMayOverlapScanWindow3(filePath, scanWindow) {
4268
+ if (!scanWindow?.fromMs && !scanWindow?.toMs) return true;
4269
+ const inferredTimestamp = timestampMs3(inferDateFromCursorPath(filePath));
4270
+ if (scanWindow.fromMs !== void 0 && inferredTimestamp !== void 0 && inferredTimestamp < scanWindow.fromMs) return false;
4271
+ if (scanWindow.toMs !== void 0 && inferredTimestamp !== void 0 && inferredTimestamp > scanWindow.toMs) return false;
4272
+ if (scanWindow.fromMs === void 0) return true;
4273
+ try {
4274
+ return fs8.statSync(filePath).mtimeMs >= scanWindow.fromMs;
4275
+ } catch {
4276
+ return true;
4277
+ }
4278
+ }
2911
4279
  function cursorJsonlToCandidate(filePath, cursorHome) {
2912
- const breakdown = emptyBreakdown3();
4280
+ const breakdown = emptyBreakdown4();
2913
4281
  try {
2914
- const parse = parseJsonLines3(fs6.readFileSync(filePath, "utf8"));
4282
+ const parse = parseJsonLines4(fs8.readFileSync(filePath, "utf8"));
2915
4283
  breakdown.rawEventCount = parse.records.length;
2916
4284
  breakdown.parseErrors = parse.errors;
2917
4285
  breakdown.parseStatus = parse.errors.length ? parse.records.length ? "partial" : "failed" : "ok";
2918
4286
  for (const record of parse.records) applyCursorRecord(breakdown, record);
2919
4287
  finalizeBreakdown(breakdown);
2920
4288
  } catch (error) {
2921
- const message = `unable_to_read_cursor_jsonl:${errorMessage3(error)}`;
4289
+ const message = `unable_to_read_cursor_jsonl:${errorMessage4(error)}`;
2922
4290
  breakdown.parseStatus = "failed";
2923
4291
  breakdown.parseErrors.push(message);
2924
4292
  breakdown.warnings.push(message);
2925
4293
  }
2926
- if (!breakdown.id) breakdown.id = path6.basename(filePath).replace(/\.jsonl$/i, "") || void 0;
4294
+ if (!breakdown.id) breakdown.id = path8.basename(filePath).replace(/\.jsonl$/i, "") || void 0;
4295
+ const fallbackDate = inferDateFromCursorPath(filePath) ?? fileModifiedDate2(filePath);
4296
+ if (fallbackDate) {
4297
+ breakdown.startedAt ??= fallbackDate;
4298
+ breakdown.endedAt ??= fallbackDate;
4299
+ }
2927
4300
  return {
2928
4301
  sourcePath: filePath,
2929
4302
  surface: inferSurfaceFromPath(filePath),
@@ -2949,7 +4322,7 @@ function cursorDatabaseToCandidates(filePath, cursorHome, warnings) {
2949
4322
  }
2950
4323
  }
2951
4324
  } catch (error) {
2952
- warnings.push(`Unable to read Cursor SQLite database at ${filePath}: ${errorMessage3(error)}`);
4325
+ warnings.push(`Unable to read Cursor SQLite database at ${filePath}: ${errorMessage4(error)}`);
2953
4326
  } finally {
2954
4327
  db?.close();
2955
4328
  }
@@ -2959,7 +4332,7 @@ function safeColumns(db, table, warnings) {
2959
4332
  try {
2960
4333
  return db.prepare(`PRAGMA table_info(${quoteIdentifier(table)})`).all();
2961
4334
  } catch (error) {
2962
- warnings.push(`Unable to inspect Cursor SQLite table ${table}: ${errorMessage3(error)}`);
4335
+ warnings.push(`Unable to inspect Cursor SQLite table ${table}: ${errorMessage4(error)}`);
2963
4336
  return [];
2964
4337
  }
2965
4338
  }
@@ -2968,7 +4341,7 @@ function safeRows(db, table, columns, warnings) {
2968
4341
  const select = columns.map(quoteIdentifier).join(", ");
2969
4342
  return db.prepare(`SELECT ${select} FROM ${quoteIdentifier(table)} LIMIT 500`).all();
2970
4343
  } catch (error) {
2971
- warnings.push(`Unable to scan Cursor SQLite table ${table}: ${errorMessage3(error)}`);
4344
+ warnings.push(`Unable to scan Cursor SQLite table ${table}: ${errorMessage4(error)}`);
2972
4345
  return [];
2973
4346
  }
2974
4347
  }
@@ -2996,11 +4369,11 @@ function candidateFromDatabaseRow(filePath, cursorHome, table, row) {
2996
4369
  if (!parsed.ok && text.trim().startsWith("{")) parseErrors.push(`sqlite_json_parse:${table}.${key}:${parsed.error}`);
2997
4370
  }
2998
4371
  if (!records.length) return void 0;
2999
- const breakdown = emptyBreakdown3();
4372
+ const breakdown = emptyBreakdown4();
3000
4373
  breakdown.parseErrors = parseErrors;
3001
4374
  breakdown.parseStatus = parseErrors.length ? "partial" : "ok";
3002
4375
  breakdown.rawEventCount = records.length;
3003
- breakdown.matchedKeys = unique4(matchedKeys);
4376
+ breakdown.matchedKeys = unique5(matchedKeys);
3004
4377
  for (const record of records) applyCursorRecord(breakdown, record);
3005
4378
  finalizeBreakdown(breakdown);
3006
4379
  if (isCursorMetadataOnlyBreakdown(breakdown)) return void 0;
@@ -3025,29 +4398,29 @@ function parseJsonBlob(text) {
3025
4398
  try {
3026
4399
  return { ok: true, value: JSON.parse(trimmed) };
3027
4400
  } catch (error) {
3028
- return { ok: false, error: errorMessage3(error) };
4401
+ return { ok: false, error: errorMessage4(error) };
3029
4402
  }
3030
4403
  }
3031
- function parseJsonLines3(text) {
4404
+ function parseJsonLines4(text) {
3032
4405
  const records = [];
3033
4406
  const errors = [];
3034
4407
  text.split(/\r?\n/).map((line, index) => ({ line: line.trim(), index })).filter(({ line }) => Boolean(line)).forEach(({ line, index }) => {
3035
4408
  try {
3036
4409
  records.push(JSON.parse(line));
3037
4410
  } catch (error) {
3038
- errors.push(`jsonl_line_${index + 1}:${errorMessage3(error)}`);
4411
+ errors.push(`jsonl_line_${index + 1}:${errorMessage4(error)}`);
3039
4412
  }
3040
4413
  });
3041
4414
  return { records, errors };
3042
4415
  }
3043
4416
  function applyCursorRecord(breakdown, record) {
3044
4417
  const objects = collectObjects(record, 6, 150);
3045
- const root = firstObject5(record);
4418
+ const root = firstObject6(record);
3046
4419
  const timestamp3 = firstValueByKeys(objects, ["timestamp", "createdAt", "created_at", "lastUpdatedAt", "conversationCheckpointLastUpdatedAt", "time", "date"]);
3047
- const date = dateValue4(timestamp3);
4420
+ const date = dateValue5(timestamp3);
3048
4421
  if (date) {
3049
- breakdown.startedAt = minDate2(breakdown.startedAt, date);
3050
- breakdown.endedAt = maxDate2(breakdown.endedAt, date);
4422
+ breakdown.startedAt = minDate3(breakdown.startedAt, date);
4423
+ breakdown.endedAt = maxDate3(breakdown.endedAt, date);
3051
4424
  }
3052
4425
  breakdown.id ??= firstStringByKeys(objects, ["sessionId", "session_id", "conversationId", "conversation_id", "composerId", "composer_id", "chatId", "chat_id"]);
3053
4426
  breakdown.title ??= firstStringByKeys(objects, ["title", "name", "summary"]);
@@ -3092,13 +4465,13 @@ function collectObjects(value, maxDepth, maxObjects, objects = []) {
3092
4465
  for (const child of Object.values(object)) collectObjects(child, maxDepth - 1, maxObjects, objects);
3093
4466
  return objects;
3094
4467
  }
3095
- function firstObject5(value) {
4468
+ function firstObject6(value) {
3096
4469
  return value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
3097
4470
  }
3098
4471
  function firstStringByKeys(objects, keys) {
3099
4472
  for (const object of objects) {
3100
4473
  for (const key of keys) {
3101
- const value = stringValue4(object[key]);
4474
+ const value = stringValue5(object[key]);
3102
4475
  if (value) return value;
3103
4476
  }
3104
4477
  }
@@ -3121,7 +4494,7 @@ function firstPathByKeys(objects, keys) {
3121
4494
  function firstNumberByKeys(objects, keys) {
3122
4495
  for (const object of objects) {
3123
4496
  for (const key of keys) {
3124
- const value = numberValue2(object[key]);
4497
+ const value = numberValue3(object[key]);
3125
4498
  if (value !== void 0) return value;
3126
4499
  }
3127
4500
  }
@@ -3131,7 +4504,7 @@ function inferRole(objects, root) {
3131
4504
  const role = firstStringByKeys(objects, ["role", "author", "speaker", "from"])?.toLowerCase();
3132
4505
  if (role === "user" || role === "human") return "user";
3133
4506
  if (role === "assistant" || role === "ai" || role === "agent") return "assistant";
3134
- const type = (stringValue4(root?.type) ?? stringValue4(root?.event) ?? "").toLowerCase();
4507
+ const type = (stringValue5(root?.type) ?? stringValue5(root?.event) ?? "").toLowerCase();
3135
4508
  if (type.includes("user") || type.includes("prompt")) return "user";
3136
4509
  if (type.includes("assistant") || type.includes("response") || type.includes("completion")) return "assistant";
3137
4510
  return void 0;
@@ -3152,9 +4525,9 @@ function textFromUnknown(value) {
3152
4525
  const parts = value.map(textFromUnknown).filter((item) => Boolean(item));
3153
4526
  return parts.length ? parts.join("\n") : void 0;
3154
4527
  }
3155
- const object = firstObject5(value);
4528
+ const object = firstObject6(value);
3156
4529
  if (!object) return void 0;
3157
- return stringValue4(object.text) ?? stringValue4(object.content) ?? stringValue4(object.value);
4530
+ return stringValue5(object.text) ?? stringValue5(object.content) ?? stringValue5(object.value);
3158
4531
  }
3159
4532
  function extractUsage(objects) {
3160
4533
  const usageObjects = objects.filter((object) => Object.keys(object).some((key) => key.toLowerCase().includes("token") || key.toLowerCase() === "usage"));
@@ -3169,7 +4542,7 @@ function extractUsage(objects) {
3169
4542
  };
3170
4543
  }
3171
4544
  function applyToolSignals(breakdown, objects) {
3172
- const typeText = objects.flatMap((object) => [stringValue4(object.type), stringValue4(object.event), stringValue4(object.toolName), stringValue4(object.tool_name), stringValue4(object.name)]).filter((item) => Boolean(item)).join(" ").toLowerCase();
4545
+ const typeText = objects.flatMap((object) => [stringValue5(object.type), stringValue5(object.event), stringValue5(object.toolName), stringValue5(object.tool_name), stringValue5(object.name)]).filter((item) => Boolean(item)).join(" ").toLowerCase();
3173
4546
  if (typeText.includes("tool")) breakdown.toolCallCount += 1;
3174
4547
  if (typeText.includes("terminal") || typeText.includes("shell") || typeText.includes("command")) breakdown.shellCommandCount += 1;
3175
4548
  if (typeText.includes("read")) breakdown.fileReadCount += 1;
@@ -3178,9 +4551,9 @@ function applyToolSignals(breakdown, objects) {
3178
4551
  }
3179
4552
  function looksFailed(object) {
3180
4553
  if (object.isError === true || object.is_error === true || object.error === true) return true;
3181
- const status = stringValue4(object.status)?.toLowerCase();
4554
+ const status = stringValue5(object.status)?.toLowerCase();
3182
4555
  if (status === "failed" || status === "error") return true;
3183
- const exitCode = numberValue2(object.exitCode ?? object.exit_code ?? object.statusCode ?? object.status_code);
4556
+ const exitCode = numberValue3(object.exitCode ?? object.exit_code ?? object.statusCode ?? object.status_code);
3184
4557
  return exitCode !== void 0 && exitCode !== 0;
3185
4558
  }
3186
4559
  function finalizeBreakdown(breakdown) {
@@ -3193,7 +4566,7 @@ function finalizeBreakdown(breakdown) {
3193
4566
  }
3194
4567
  function candidateToUsage(candidate, index, options) {
3195
4568
  const breakdown = candidate.breakdown;
3196
- const id = breakdown.id ?? `${path6.basename(candidate.sourcePath).replace(/\.(jsonl|sqlite|db|vscdb)$/i, "") || "cursor-session"}-${index}`;
4569
+ const id = breakdown.id ?? `${path8.basename(candidate.sourcePath).replace(/\.(jsonl|sqlite|db|vscdb)$/i, "") || "cursor-session"}-${index}`;
3197
4570
  const cwd = breakdown.cwd ?? candidate.fallbackCwd ?? process.cwd();
3198
4571
  const repo = resolveRepoInfo(cwd, options.config);
3199
4572
  const hasTokenBreakdown = breakdown.inputTokens + breakdown.cachedInputTokens + breakdown.outputTokens + breakdown.reasoningTokens > 0;
@@ -3228,7 +4601,7 @@ function candidateToUsage(candidate, index, options) {
3228
4601
  messageCount: breakdown.messageCount,
3229
4602
  rawTokenTotal: breakdown.totalTokens || void 0,
3230
4603
  warnings: [...repo.warnings, ...breakdown.warnings, ...tokenWarnings, ...hasTokenBreakdown ? [] : ["missing_token_breakdown"]],
3231
- durationMs: durationMs3(breakdown.startedAt, breakdown.endedAt),
4604
+ durationMs: durationMs4(breakdown.startedAt, breakdown.endedAt),
3232
4605
  rawEventCount: breakdown.rawEventCount,
3233
4606
  parseStatus: breakdown.parseStatus,
3234
4607
  parseErrors: breakdown.parseErrors,
@@ -3272,21 +4645,81 @@ function candidateToUsage(candidate, index, options) {
3272
4645
  };
3273
4646
  }
3274
4647
  function inferProjectPathFromCursorPath(filePath, cursorHome) {
3275
- const parts = filePath.split(path6.sep);
4648
+ const parts = filePath.split(path8.sep);
3276
4649
  const projectsIndex = parts.lastIndexOf("projects");
3277
4650
  if (projectsIndex >= 0 && projectsIndex < parts.length - 1) {
3278
4651
  const projectId = parts[projectsIndex + 1];
4652
+ const decodedProjectPath = decodeCursorProjectId(projectId);
4653
+ if (decodedProjectPath) return decodedProjectPath;
3279
4654
  if (projectId?.startsWith("-")) {
3280
- return `${path6.sep}${projectId.slice(1).split("-").filter(Boolean).join(path6.sep)}`;
4655
+ return `${path8.sep}${projectId.slice(1).split("-").filter(Boolean).join(path8.sep)}`;
4656
+ }
4657
+ if (projectId) return path8.join(cursorHome, "projects", projectId);
4658
+ }
4659
+ return void 0;
4660
+ }
4661
+ function inferDateFromCursorPath(filePath) {
4662
+ const parts = filePath.split(path8.sep);
4663
+ for (const part of parts) {
4664
+ if (!/^\d{10,13}$/.test(part)) continue;
4665
+ const date = dateValue5(Number(part));
4666
+ if (date) return date;
4667
+ }
4668
+ return void 0;
4669
+ }
4670
+ function timestampMs3(value) {
4671
+ if (!value) return void 0;
4672
+ const timestamp3 = new Date(value).getTime();
4673
+ return Number.isFinite(timestamp3) ? timestamp3 : void 0;
4674
+ }
4675
+ function fileModifiedDate2(filePath) {
4676
+ try {
4677
+ return fs8.statSync(filePath).mtime.toISOString();
4678
+ } catch {
4679
+ return void 0;
4680
+ }
4681
+ }
4682
+ function decodeCursorProjectId(projectId) {
4683
+ if (!projectId) return void 0;
4684
+ const id = projectId.startsWith("-") ? projectId.slice(1) : projectId;
4685
+ const tokens2 = id.split("-").filter(Boolean);
4686
+ if (tokens2.length < 2) return void 0;
4687
+ const root = cursorProjectRoot(tokens2);
4688
+ if (!root) return void 0;
4689
+ const matched = matchEncodedPath(root.path, tokens2.slice(root.consumed));
4690
+ if (matched) return matched;
4691
+ return path8.join(root.path, ...tokens2.slice(root.consumed));
4692
+ }
4693
+ function cursorProjectRoot(tokens2) {
4694
+ if (tokens2[0] === "Users" && tokens2[1]) return { path: path8.join(path8.sep, "Users", tokens2[1]), consumed: 2 };
4695
+ if (tokens2[0] === "home" && tokens2[1]) return { path: path8.join(path8.sep, "home", tokens2[1]), consumed: 2 };
4696
+ if (tokens2[0] === "tmp") return { path: path8.join(path8.sep, "tmp"), consumed: 1 };
4697
+ if (/^[A-Za-z]$/.test(tokens2[0] ?? "") && tokens2[1]) return { path: `${tokens2[0]?.toUpperCase()}:\\`, consumed: 1 };
4698
+ return void 0;
4699
+ }
4700
+ function matchEncodedPath(root, tokens2) {
4701
+ if (!tokens2.length) return fs8.existsSync(root) ? root : void 0;
4702
+ if (!fs8.existsSync(root)) return void 0;
4703
+ try {
4704
+ for (const entry of fs8.readdirSync(root, { withFileTypes: true })) {
4705
+ if (!entry.isDirectory()) continue;
4706
+ const entryTokens = entry.name.split("-").filter(Boolean);
4707
+ if (!startsWithTokens(tokens2, entryTokens)) continue;
4708
+ const matched = matchEncodedPath(path8.join(root, entry.name), tokens2.slice(entryTokens.length));
4709
+ if (matched) return matched;
3281
4710
  }
3282
- if (projectId) return path6.join(cursorHome, "projects", projectId);
4711
+ } catch {
4712
+ return void 0;
3283
4713
  }
3284
4714
  return void 0;
3285
4715
  }
4716
+ function startsWithTokens(tokens2, prefix) {
4717
+ return prefix.length <= tokens2.length && prefix.every((token, index) => tokens2[index] === token);
4718
+ }
3286
4719
  function inferSurfaceFromPath(filePath) {
3287
4720
  const lower = filePath.toLowerCase();
3288
- if (lower.includes(`${path6.sep}agent-transcripts${path6.sep}`) || lower.endsWith(".jsonl")) return "cli";
3289
- if (lower.includes(`${path6.sep}workspaceStorage${path6.sep}`.toLowerCase()) || lower.includes(`${path6.sep}globalStorage${path6.sep}`.toLowerCase()) || lower.endsWith(".vscdb")) return "desktop";
4721
+ if (lower.includes(`${path8.sep}agent-transcripts${path8.sep}`) || lower.endsWith(".jsonl")) return "cli";
4722
+ if (lower.includes(`${path8.sep}workspaceStorage${path8.sep}`.toLowerCase()) || lower.includes(`${path8.sep}globalStorage${path8.sep}`.toLowerCase()) || lower.endsWith(".vscdb")) return "desktop";
3290
4723
  return "unknown";
3291
4724
  }
3292
4725
  function cursorSourceAppLabel(surface) {
@@ -3312,7 +4745,7 @@ function inferOutcome3(breakdown) {
3312
4745
  if (breakdown.userPromptCount > 0 || breakdown.assistantMessageCount > 0) return "research_only";
3313
4746
  return "unknown";
3314
4747
  }
3315
- function emptyBreakdown3() {
4748
+ function emptyBreakdown4() {
3316
4749
  return {
3317
4750
  id: void 0,
3318
4751
  title: void 0,
@@ -3395,10 +4828,10 @@ function mergeCursorBreakdowns(primary, secondary) {
3395
4828
  fileEditCount: Math.max(primary.fileEditCount, secondary.fileEditCount),
3396
4829
  rawEventCount: Math.max(primary.rawEventCount, secondary.rawEventCount),
3397
4830
  parseStatus: mergeParseStatus(primary.parseStatus, secondary.parseStatus),
3398
- parseErrors: unique4([...primary.parseErrors, ...secondary.parseErrors]),
4831
+ parseErrors: unique5([...primary.parseErrors, ...secondary.parseErrors]),
3399
4832
  promptTimeline: primary.promptTimeline.length >= secondary.promptTimeline.length ? primary.promptTimeline : secondary.promptTimeline,
3400
- warnings: unique4([...primary.warnings, ...secondary.warnings, "cursor_duplicate_session_merged"]),
3401
- matchedKeys: unique4([...primary.matchedKeys, ...secondary.matchedKeys])
4833
+ warnings: unique5([...primary.warnings, ...secondary.warnings, "cursor_duplicate_session_merged"]),
4834
+ matchedKeys: unique5([...primary.matchedKeys, ...secondary.matchedKeys])
3402
4835
  };
3403
4836
  }
3404
4837
  function mergeParseStatus(a, b) {
@@ -3407,10 +4840,10 @@ function mergeParseStatus(a, b) {
3407
4840
  return "ok";
3408
4841
  }
3409
4842
  function optionalMinDate(current, candidate) {
3410
- return candidate ? minDate2(current, candidate) : current;
4843
+ return candidate ? minDate3(current, candidate) : current;
3411
4844
  }
3412
4845
  function optionalMaxDate(current, candidate) {
3413
- return candidate ? maxDate2(current, candidate) : current;
4846
+ return candidate ? maxDate3(current, candidate) : current;
3414
4847
  }
3415
4848
  function providerForModel2(model) {
3416
4849
  const normalized = model.toLowerCase();
@@ -3419,7 +4852,7 @@ function providerForModel2(model) {
3419
4852
  if (normalized.startsWith("gpt") || normalized.startsWith("o")) return "openai";
3420
4853
  return void 0;
3421
4854
  }
3422
- function stringValue4(value) {
4855
+ function stringValue5(value) {
3423
4856
  return typeof value === "string" && value.trim() ? value : void 0;
3424
4857
  }
3425
4858
  function textValue(value) {
@@ -3427,24 +4860,24 @@ function textValue(value) {
3427
4860
  if (Buffer.isBuffer(value)) return value.toString("utf8");
3428
4861
  return void 0;
3429
4862
  }
3430
- function numberValue2(value) {
4863
+ function numberValue3(value) {
3431
4864
  if (typeof value === "number" && Number.isFinite(value)) return value;
3432
4865
  if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value);
3433
4866
  return void 0;
3434
4867
  }
3435
- function dateValue4(value) {
4868
+ function dateValue5(value) {
3436
4869
  if (typeof value === "number" && Number.isFinite(value)) return new Date(value < 1e10 ? value * 1e3 : value).toISOString();
3437
4870
  if (typeof value !== "string") return void 0;
3438
4871
  const date = new Date(value);
3439
4872
  return Number.isNaN(date.getTime()) ? void 0 : date.toISOString();
3440
4873
  }
3441
- function minDate2(current, candidate) {
4874
+ function minDate3(current, candidate) {
3442
4875
  return !current || candidate < current ? candidate : current;
3443
4876
  }
3444
- function maxDate2(current, candidate) {
4877
+ function maxDate3(current, candidate) {
3445
4878
  return !current || candidate > current ? candidate : current;
3446
4879
  }
3447
- function durationMs3(startedAt, endedAt) {
4880
+ function durationMs4(startedAt, endedAt) {
3448
4881
  if (!startedAt || !endedAt) return void 0;
3449
4882
  const start = new Date(startedAt).getTime();
3450
4883
  const end = new Date(endedAt).getTime();
@@ -3456,64 +4889,123 @@ function normalizeTimelineText3(text) {
3456
4889
  function roundCurrency2(value) {
3457
4890
  return Number(value.toFixed(6));
3458
4891
  }
3459
- function unique4(values) {
4892
+ function unique5(values) {
3460
4893
  return [...new Set(values.filter(Boolean))];
3461
4894
  }
3462
- function errorMessage3(error) {
4895
+ function errorMessage4(error) {
3463
4896
  return error instanceof Error ? error.message : String(error);
3464
4897
  }
3465
4898
 
3466
4899
  // packages/core/src/sources/index.ts
4900
+ var codexAdapter = { id: "codex", scan: scanCodex };
4901
+ var claudeAdapter = { id: "claude", scan: scanClaude };
4902
+ var copilotAdapter = { id: "copilot", scan: scanCopilot };
4903
+ var cursorAdapter = { id: "cursor", scan: scanCursor };
4904
+ var adapters = [
4905
+ { adapter: codexAdapter, homeKey: "codexHome", homeOption: "codexHome" },
4906
+ { adapter: claudeAdapter, homeKey: "claudeHome", homeOption: "claudeHome" },
4907
+ { adapter: copilotAdapter, homeKey: "copilotHome", homeOption: "copilotHome" },
4908
+ {
4909
+ adapter: cursorAdapter,
4910
+ homeKey: "cursorHome",
4911
+ homeOption: "cursorHome",
4912
+ enabled: (options) => options.config?.experimentalSources?.cursor === true || Boolean(options.cursorHome)
4913
+ }
4914
+ ];
3467
4915
  function scanUsageSources(options) {
3468
- const codex = scanCodex({
3469
- ...options.codexHome ? { codexHome: options.codexHome } : {},
3470
- ...options.config ? { config: options.config } : {},
3471
- pricing: options.pricing
3472
- });
3473
- const claude = scanClaude({
3474
- ...options.claudeHome ? { claudeHome: options.claudeHome } : {},
3475
- ...options.config ? { config: options.config } : {},
3476
- pricing: options.pricing
3477
- });
3478
- const cursor = scanCursor({
3479
- ...options.cursorHome ? { cursorHome: options.cursorHome } : {},
3480
- ...options.config ? { config: options.config } : {},
3481
- pricing: options.pricing
3482
- });
3483
- return {
3484
- sources: [codex.source, claude.source, cursor.source],
3485
- sourceStats: [codex.stats, claude.stats, cursor.stats],
3486
- sessions: [...codex.sessions, ...claude.sessions, ...cursor.sessions]
3487
- };
4916
+ const sources = [];
4917
+ const sourceStats = [];
4918
+ const sessions = [];
4919
+ for (const entry of adapters) {
4920
+ if (entry.enabled && !entry.enabled(options)) continue;
4921
+ const home = options[entry.homeKey];
4922
+ const result = entry.adapter.scan({
4923
+ ...home ? { [entry.homeOption]: home } : {},
4924
+ ...options.config ? { config: options.config } : {},
4925
+ ...options.scanWindow ? { scanWindow: options.scanWindow } : {},
4926
+ pricing: options.pricing
4927
+ });
4928
+ sources.push(result.source);
4929
+ sourceStats.push(result.stats);
4930
+ sessions.push(...result.sessions);
4931
+ }
4932
+ return { sources, sourceStats, sessions };
3488
4933
  }
3489
4934
 
3490
4935
  // apps/server/src/data.ts
3491
- import fs7 from "node:fs";
4936
+ import fs9 from "node:fs";
4937
+ import path9 from "node:path";
4938
+ import { spawn } from "node:child_process";
4939
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
3492
4940
  var scanCacheTtlMs = 1e4;
3493
4941
  var scanCache;
4942
+ var warmScanInFlight = false;
4943
+ var warmScanChild;
4944
+ var warmedScanKeys = /* @__PURE__ */ new Set();
3494
4945
  function readDashboardData(filters = {}, options = {}) {
3495
- const raw = readRawUsageData();
4946
+ const raw = readRawUsageData(filters);
3496
4947
  return buildDashboardSnapshot({ ...raw, sessions: displaySessions(raw.sessions, options), filters });
3497
4948
  }
3498
4949
  function clearScanCache() {
3499
4950
  scanCache = void 0;
4951
+ warmScanInFlight = false;
4952
+ warmScanChild?.kill();
4953
+ warmScanChild = void 0;
4954
+ warmedScanKeys.clear();
3500
4955
  }
3501
- function readRawUsageData() {
4956
+ function readRawUsageData(filters = {}) {
3502
4957
  const cwd = runtimeCwd();
3503
- if (scanCache && scanCache.cwd === cwd && scanCache.expiresAt > Date.now()) {
4958
+ const { config, warnings: configWarnings } = loadConfigWithWarnings();
4959
+ const cacheKey = scanCacheKey({ cwd, filters, cursorEnabled: config.experimentalSources?.cursor === true });
4960
+ if (scanCache && scanCache.key === cacheKey && scanCache.expiresAt > Date.now()) {
3504
4961
  return scanCache.data;
3505
4962
  }
3506
- const { config, warnings: configWarnings } = loadConfigWithWarnings();
3507
4963
  const pricing = loadPricingTable(resolvePricingPath(config));
3508
- const scan = scanUsageSources({ config, pricing });
4964
+ const scanWindow = scanWindowFromFilters(filters);
4965
+ const scan = scanUsageSources({
4966
+ config,
4967
+ pricing,
4968
+ ...scanWindow ? { scanWindow } : {}
4969
+ });
3509
4970
  const data = {
3510
4971
  sources: scan.sources.map((source) => ({ ...source, warnings: [...source.warnings, ...configWarnings] })),
3511
4972
  sourceStats: scan.sourceStats,
3512
4973
  sessions: scan.sessions
3513
4974
  };
3514
- scanCache = { cwd, data, expiresAt: Date.now() + scanCacheTtlMs };
4975
+ scanCache = { key: cacheKey, data, expiresAt: Date.now() + scanCacheTtlMs };
3515
4976
  return data;
3516
4977
  }
4978
+ function warmDashboardCache(filters = {}, options = {}) {
4979
+ const cwd = runtimeCwd();
4980
+ const config = loadConfig();
4981
+ const key = scanCacheKey({ cwd, filters, cursorEnabled: config.experimentalSources?.cursor === true });
4982
+ if (scanCache?.key === key || warmedScanKeys.has(key)) return { started: false, key, reason: "already_warm" };
4983
+ if (warmScanInFlight) return { started: false, key, reason: "warm_scan_in_flight" };
4984
+ warmScanInFlight = true;
4985
+ let child;
4986
+ try {
4987
+ child = spawn(process.execPath, [...process.execArgv, cliEntryPath(), "__warm-cache", ...warmCacheArgs(filters, options)], {
4988
+ detached: true,
4989
+ env: process.env,
4990
+ stdio: "ignore"
4991
+ });
4992
+ } catch (error) {
4993
+ warmScanInFlight = false;
4994
+ return { started: false, key, reason: error instanceof Error ? error.message : String(error) };
4995
+ }
4996
+ warmScanChild = child;
4997
+ child.once("error", () => {
4998
+ warmScanInFlight = false;
4999
+ if (warmScanChild === child) warmScanChild = void 0;
5000
+ });
5001
+ child.once("exit", (code) => {
5002
+ warmScanInFlight = false;
5003
+ if (warmScanChild === child) warmScanChild = void 0;
5004
+ if (code === 0) warmedScanKeys.add(key);
5005
+ });
5006
+ child.unref();
5007
+ return { started: true, key };
5008
+ }
3517
5009
  function parseFilters(query) {
3518
5010
  const filters = {};
3519
5011
  const source = listQuery(query.source);
@@ -3543,6 +5035,20 @@ function readPricingData() {
3543
5035
  models: loadPricingTable(pricingPath)
3544
5036
  };
3545
5037
  }
5038
+ function readConfigData() {
5039
+ return {
5040
+ path: configPath(),
5041
+ config: loadConfig()
5042
+ };
5043
+ }
5044
+ function writeConfigData(nextConfig) {
5045
+ const saved = saveConfig(nextConfig);
5046
+ clearScanCache();
5047
+ return {
5048
+ path: configPath(),
5049
+ config: saved
5050
+ };
5051
+ }
3546
5052
  function writePricingData(models) {
3547
5053
  const config = loadConfig();
3548
5054
  const pricingPath = resolvePricingPath(config);
@@ -3555,16 +5061,21 @@ function writePricingData(models) {
3555
5061
  }
3556
5062
  function clearRepoSpendLocalData() {
3557
5063
  const target = repospendHome();
3558
- const removed = fs7.existsSync(target);
3559
- fs7.rmSync(target, { recursive: true, force: true });
5064
+ const removed = fs9.existsSync(target);
5065
+ fs9.rmSync(target, { recursive: true, force: true });
3560
5066
  clearScanCache();
3561
5067
  return { path: target, removed };
3562
5068
  }
3563
- function exportJson() {
3564
- return JSON.stringify(readDashboardData().sessions, null, 2);
5069
+ function clearRepoSpendParseCache() {
5070
+ const result = clearRepoSpendCache();
5071
+ clearScanCache();
5072
+ return result;
5073
+ }
5074
+ function exportJson(filters = {}, options = {}) {
5075
+ return JSON.stringify(readDashboardData(filters, options).sessions, null, 2);
3565
5076
  }
3566
- function exportCsv() {
3567
- return toCsv(readDashboardData().sessions);
5077
+ function exportCsv(filters = {}, options = {}) {
5078
+ return toCsv(readDashboardData(filters, options).sessions);
3568
5079
  }
3569
5080
  function stringQuery(value) {
3570
5081
  return typeof value === "string" && value.length > 0 ? value : void 0;
@@ -3584,9 +5095,39 @@ function displaySessions(sessions, options) {
3584
5095
  function sourceScopedAppLabel(session) {
3585
5096
  const app = session.sourceApp || fallbackSurfaceLabel(session.detectedSurface);
3586
5097
  if (!app || app === "Unknown") return sourceClientLabel(session.sourceClient);
5098
+ if (session.sourceClient === "copilot" && app === "Copilot CLI") return "GitHub Copilot CLI";
3587
5099
  if (!shouldScopeApp(app)) return app;
3588
5100
  return `${sourceClientLabel(session.sourceClient)} on ${app}`;
3589
5101
  }
5102
+ function scanWindowFromFilters(filters) {
5103
+ const fromMs = filters.from ? lowerBound(filters.from) : void 0;
5104
+ const toMs = filters.to ? upperBound(filters.to) : void 0;
5105
+ const scanWindow = {};
5106
+ if (fromMs !== void 0) scanWindow.fromMs = fromMs;
5107
+ if (toMs !== void 0) scanWindow.toMs = toMs;
5108
+ return scanWindow.fromMs === void 0 && scanWindow.toMs === void 0 ? void 0 : scanWindow;
5109
+ }
5110
+ function scanCacheKey({ cwd, filters, cursorEnabled }) {
5111
+ return JSON.stringify({
5112
+ cwd,
5113
+ from: filters.from ?? "",
5114
+ to: filters.to ?? "",
5115
+ cursor: cursorEnabled
5116
+ });
5117
+ }
5118
+ function warmCacheArgs(filters, options) {
5119
+ const args2 = [];
5120
+ if (filters.from) args2.push("--from", filters.from);
5121
+ if (filters.to) args2.push("--to", filters.to);
5122
+ if (options.splitSourceApps) args2.push("--splitSourceApps");
5123
+ return args2;
5124
+ }
5125
+ function cliEntryPath() {
5126
+ const currentDir = path9.dirname(fileURLToPath2(import.meta.url));
5127
+ const sourceCli = path9.join(currentDir, "cli.ts");
5128
+ if (fs9.existsSync(sourceCli)) return sourceCli;
5129
+ return path9.join(currentDir, "cli.js");
5130
+ }
3590
5131
  function shouldScopeApp(app) {
3591
5132
  const normalized = app.toLowerCase();
3592
5133
  return normalized.includes("vs code") || normalized.includes("vscode") || normalized.includes("terminal") || normalized.includes("cli");
@@ -3594,6 +5135,7 @@ function shouldScopeApp(app) {
3594
5135
  function sourceClientLabel(source) {
3595
5136
  if (source === "codex") return "Codex";
3596
5137
  if (source === "claude") return "Claude Code";
5138
+ if (source === "copilot") return "GitHub Copilot";
3597
5139
  if (source === "gemini-cli") return "Gemini CLI";
3598
5140
  if (source === "opencode") return "OpenCode";
3599
5141
  if (source === "cursor") return "Cursor";
@@ -3611,14 +5153,64 @@ function runtimeCwd() {
3611
5153
  return process.env.INIT_CWD || process.cwd();
3612
5154
  }
3613
5155
 
5156
+ // apps/server/src/cli-options.ts
5157
+ function parseCliFilters(args2) {
5158
+ const filters = {};
5159
+ const source = listOption(args2, ["--source"]).filter((value) => value !== "all");
5160
+ const sourceApp = listOption(args2, ["--sourceApp", "--source-app", "--app"]);
5161
+ const repo = listOption(args2, ["--repo"]);
5162
+ const model = listOption(args2, ["--model"]);
5163
+ const from = valueAfter(args2, "--from");
5164
+ const to = valueAfter(args2, "--to");
5165
+ if (source.length) filters.source = source;
5166
+ if (sourceApp.length) filters.sourceApp = sourceApp;
5167
+ if (repo.length) filters.repo = repo;
5168
+ if (model.length) filters.model = model;
5169
+ if (from) filters.from = from;
5170
+ if (to) filters.to = to;
5171
+ return filters;
5172
+ }
5173
+ function parseCliDateFilters(args2) {
5174
+ const filters = {};
5175
+ const from = valueAfter(args2, "--from");
5176
+ const to = valueAfter(args2, "--to");
5177
+ if (from) filters.from = from;
5178
+ if (to) filters.to = to;
5179
+ return filters;
5180
+ }
5181
+ function parseCliDashboardOptions(args2) {
5182
+ return {
5183
+ splitSourceApps: args2.includes("--splitSourceApps") || args2.includes("--split-source-apps")
5184
+ };
5185
+ }
5186
+ function valueAfter(args2, flag) {
5187
+ const index = args2.indexOf(flag);
5188
+ const value = index >= 0 ? args2[index + 1] : void 0;
5189
+ return value && !value.startsWith("--") ? value : void 0;
5190
+ }
5191
+ function listOption(args2, flags) {
5192
+ const values = [];
5193
+ for (const flag of flags) {
5194
+ args2.forEach((arg, index) => {
5195
+ if (arg === flag) {
5196
+ const value = args2[index + 1];
5197
+ if (value && !value.startsWith("--")) values.push(value);
5198
+ } else if (arg.startsWith(`${flag}=`)) {
5199
+ values.push(arg.slice(flag.length + 1));
5200
+ }
5201
+ });
5202
+ }
5203
+ return [...new Set(values.flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean))];
5204
+ }
5205
+
3614
5206
  // apps/server/src/server.ts
3615
- import fs8 from "node:fs";
3616
- import path7 from "node:path";
3617
- import { fileURLToPath as fileURLToPath2 } from "node:url";
5207
+ import fs10 from "node:fs";
5208
+ import path10 from "node:path";
5209
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
3618
5210
  import fastifyStatic from "@fastify/static";
3619
5211
  import Fastify from "fastify";
3620
5212
 
3621
- // apps/server/src/demo-data.ts
5213
+ // apps/server/src/demo/fixtures.ts
3622
5214
  var baseDate = "2026-05-18";
3623
5215
  var demoPricing = {
3624
5216
  ...defaultPricing,
@@ -3626,68 +5218,14 @@ var demoPricing = {
3626
5218
  "gpt-5.3-codex-spark": { inputPerMillion: 1.75, cachedInputPerMillion: 0.175, outputPerMillion: 14, reasoningOutputPerMillion: 14 },
3627
5219
  "claude-haiku-4-5-20251001": { inputPerMillion: 1, cacheCreationInputPerMillion: 1.25, cachedInputPerMillion: 0.1, outputPerMillion: 5 }
3628
5220
  };
3629
- function demoModeEnabled() {
3630
- return process.env.REPOSPEND_DEMO_DATA === "lotr";
3631
- }
3632
- function readDemoDashboardData(filters = {}, options = {}) {
3633
- return buildDashboardSnapshot({
3634
- sources: demoSources,
3635
- sourceStats: demoSourceStats,
3636
- sessions: displaySessions(demoSessions, options),
3637
- filters
3638
- });
3639
- }
3640
- function readDemoRtkGain() {
5221
+ function issue(command2, category, severity, impact, reason) {
3641
5222
  return {
3642
- available: true,
3643
- rtkDetected: true,
3644
- rtkVersion: "rtk-demo 1.0.0",
3645
- rtkCodexHookStatus: "active",
3646
- rtkLastActivityAt: "2026-05-18T18:42:00.000Z",
3647
- rtkDataSource: "fictional demo data",
3648
- totalCommands: 248,
3649
- inputTokens: "18.4M",
3650
- outputTokens: "2.6M",
3651
- tokensSaved: "5.8M",
3652
- savedPercent: 23.9,
3653
- totalExecTime: "41m",
3654
- averageExecTime: "9.9s",
3655
- rtkTokensSaved: "5.8M",
3656
- rtkSavingsRate: 23.9,
3657
- rtkCommandsProcessed: 248,
3658
- rtkAverageCommandRuntime: "9.9s",
3659
- rtkTopSavingsCommands: [
3660
- { command: "rg", count: 74, saved: "1.9M", averageSavedPercent: 31.2, time: "8m" },
3661
- { command: "pnpm test", count: 18, saved: "840K", averageSavedPercent: 17.4, time: "12m" },
3662
- { command: "git diff", count: 39, saved: "620K", averageSavedPercent: 26.8, time: "2m" }
3663
- ],
3664
- rtkRecentActivity: [
3665
- "rg council-of-elrond planner",
3666
- "pnpm test --filter gondor-api",
3667
- "git diff -- shire-mobile"
3668
- ],
3669
- rtkDiscoverAvailable: true,
3670
- rtkDiscoverError: void 0,
3671
- rtkDiscoverSummary: "Total: 248 fictional command events scanned.",
3672
- rtkCoverageGaps: [
3673
- { command: "cat", count: 21, rtkEquivalent: "rtk sed", status: "easy win", estimatedSavings: "410K" },
3674
- { command: "grep", count: 13, rtkEquivalent: "rtk rg", status: "easy win", estimatedSavings: "360K" }
3675
- ],
3676
- rtkUnhandledCommands: [
3677
- { command: "palantirctl inspect", count: 5, example: "palantirctl inspect eye-of-sauron-alerts" }
3678
- ],
3679
- topCommands: [
3680
- { command: "rg", count: 74, saved: "1.9M", averageSavedPercent: 31.2, time: "8m" },
3681
- { command: "git", count: 46, saved: "720K", averageSavedPercent: 18.3, time: "3m" },
3682
- { command: "pnpm", count: 31, saved: "1.1M", averageSavedPercent: 16.8, time: "17m" }
3683
- ],
3684
- recentCommands: [
3685
- "rtk rg auth gates-of-moria",
3686
- "rtk pnpm test --filter palantir-observability",
3687
- "rtk git diff -- one-ring-infra"
3688
- ],
3689
- raw: "RepoSpend LOTR demo RTK output",
3690
- error: void 0
5223
+ command: command2,
5224
+ category,
5225
+ classification: severity === "critical" ? "blocking_failure" : "warning",
5226
+ severity,
5227
+ impact,
5228
+ reason
3691
5229
  };
3692
5230
  }
3693
5231
  var demoSources = [
@@ -3704,6 +5242,17 @@ var demoSources = [
3704
5242
  available: true,
3705
5243
  paths: ["/demo/middle-earth/claude/projects"],
3706
5244
  warnings: []
5245
+ },
5246
+ {
5247
+ id: "copilot",
5248
+ label: "GitHub Copilot",
5249
+ available: true,
5250
+ paths: [
5251
+ "/demo/middle-earth/copilot/otel",
5252
+ "/demo/middle-earth/copilot/session-state",
5253
+ "/demo/middle-earth/vscode/User/workspaceStorage"
5254
+ ],
5255
+ warnings: []
3707
5256
  }
3708
5257
  ];
3709
5258
  var demoSourceStats = [
@@ -3735,6 +5284,26 @@ var demoSourceStats = [
3735
5284
  malformedFileCount: 0,
3736
5285
  projectDirCount: 4,
3737
5286
  lastScannedAt: "2026-05-18T19:01:00.000Z"
5287
+ },
5288
+ {
5289
+ sourceId: "copilot",
5290
+ sourceLabel: "GitHub Copilot",
5291
+ homePath: "/demo/middle-earth/copilot",
5292
+ copilotHome: "/demo/middle-earth/copilot",
5293
+ otelPath: "/demo/middle-earth/copilot/otel",
5294
+ workspaceStoragePath: "/demo/middle-earth/vscode/User/workspaceStorage",
5295
+ otelExists: true,
5296
+ sessionsExists: true,
5297
+ otelFileCount: 5,
5298
+ transcriptFileCount: 3,
5299
+ debugLogFileCount: 2,
5300
+ sessionStateFileCount: 2,
5301
+ sessionFileCount: 10,
5302
+ sessionsImported: 3,
5303
+ parseFailureCount: 0,
5304
+ unreadableFileCount: 0,
5305
+ malformedFileCount: 0,
5306
+ lastScannedAt: "2026-05-18T19:02:00.000Z"
3738
5307
  }
3739
5308
  ];
3740
5309
  var demoInputs = [
@@ -3911,6 +5480,32 @@ var demoInputs = [
3911
5480
  outcome: "completed",
3912
5481
  branch: "planner/elrond-v2"
3913
5482
  },
5483
+ {
5484
+ id: "lotr-session-015",
5485
+ repo: "rivendell-dashboard",
5486
+ title: "Use Copilot to draft council agenda actions",
5487
+ sourceClient: "copilot",
5488
+ sourceApp: "VS Code",
5489
+ sourceAppRaw: "vscode.copilot-chat",
5490
+ model: "lark-vscode-preview",
5491
+ provider: "github-copilot",
5492
+ hour: 14,
5493
+ minute: 55,
5494
+ durationMinutes: 36,
5495
+ inputTokens: 51e4,
5496
+ cachedInputTokens: 18e4,
5497
+ outputTokens: 74e3,
5498
+ reasoningTokens: 0,
5499
+ prompts: 4,
5500
+ replies: 7,
5501
+ tools: 24,
5502
+ commands: 6,
5503
+ edits: 6,
5504
+ reads: 29,
5505
+ outcome: "completed",
5506
+ branch: "planner/copilot-actions",
5507
+ detectedSurface: "vscode_extension"
5508
+ },
3914
5509
  {
3915
5510
  id: "lotr-session-007",
3916
5511
  repo: "palantir-observability",
@@ -3937,6 +5532,39 @@ var demoInputs = [
3937
5532
  harmlessNonZero: 2,
3938
5533
  exploratoryMisses: 3
3939
5534
  },
5535
+ {
5536
+ id: "lotr-session-016",
5537
+ repo: "palantir-observability",
5538
+ title: "Trace Copilot alert summaries through the seeing-stone feed",
5539
+ sourceClient: "copilot",
5540
+ sourceApp: "Copilot CLI",
5541
+ sourceAppRaw: "copilot-cli",
5542
+ model: "copilot/gpt-5-mini",
5543
+ provider: "openai",
5544
+ hour: 16,
5545
+ minute: 35,
5546
+ durationMinutes: 43,
5547
+ inputTokens: 69e4,
5548
+ cachedInputTokens: 21e4,
5549
+ outputTokens: 88e3,
5550
+ reasoningTokens: 0,
5551
+ prompts: 5,
5552
+ replies: 8,
5553
+ tools: 30,
5554
+ commands: 13,
5555
+ edits: 3,
5556
+ reads: 34,
5557
+ outcome: "partial",
5558
+ branch: "alerts/copilot-summaries",
5559
+ warnings: ["copilot_partial_token_breakdown"],
5560
+ importantFailures: 1,
5561
+ topFailureType: "test",
5562
+ detectedSurface: "terminal_cli",
5563
+ tokenConfidence: "medium",
5564
+ issueSamples: [
5565
+ issue("pnpm test --filter palantir-alerts", "test", "warning", "medium", "A generated summary snapshot changed before the matcher was tightened.")
5566
+ ]
5567
+ },
3940
5568
  {
3941
5569
  id: "lotr-session-008",
3942
5570
  repo: "mordor-ci",
@@ -4012,6 +5640,32 @@ var demoInputs = [
4012
5640
  outcome: "completed",
4013
5641
  branch: "docs/ring-config"
4014
5642
  },
5643
+ {
5644
+ id: "lotr-session-017",
5645
+ repo: "fellowship-docs",
5646
+ title: "Ask Copilot to review the ring-config redaction guide",
5647
+ sourceClient: "copilot",
5648
+ sourceApp: "VS Code",
5649
+ sourceAppRaw: "vscode.copilot-chat",
5650
+ model: "copilot/claude-sonnet-4.5",
5651
+ provider: "anthropic",
5652
+ hour: 18,
5653
+ minute: 42,
5654
+ durationMinutes: 18,
5655
+ inputTokens: 24e4,
5656
+ cachedInputTokens: 96e3,
5657
+ outputTokens: 31e3,
5658
+ reasoningTokens: 0,
5659
+ prompts: 2,
5660
+ replies: 4,
5661
+ tools: 12,
5662
+ commands: 3,
5663
+ edits: 2,
5664
+ reads: 15,
5665
+ outcome: "completed",
5666
+ branch: "docs/copilot-redaction",
5667
+ detectedSurface: "vscode_extension"
5668
+ },
4015
5669
  {
4016
5670
  id: "lotr-session-011",
4017
5671
  repo: "one-ring-infra",
@@ -4119,6 +5773,74 @@ var demoInputs = [
4119
5773
  branch: "offline/bag-end"
4120
5774
  }
4121
5775
  ];
5776
+
5777
+ // apps/server/src/demo/rtk.ts
5778
+ function readDemoRtkGain() {
5779
+ return {
5780
+ available: true,
5781
+ rtkDetected: true,
5782
+ rtkVersion: "rtk-demo 1.0.0",
5783
+ rtkCodexHookStatus: "active",
5784
+ rtkLastActivityAt: "2026-05-18T18:42:00.000Z",
5785
+ rtkDataSource: "fictional demo data",
5786
+ totalCommands: 248,
5787
+ inputTokens: "18.4M",
5788
+ outputTokens: "2.6M",
5789
+ tokensSaved: "5.8M",
5790
+ savedPercent: 23.9,
5791
+ totalExecTime: "41m",
5792
+ averageExecTime: "9.9s",
5793
+ rtkTokensSaved: "5.8M",
5794
+ rtkSavingsRate: 23.9,
5795
+ rtkCommandsProcessed: 248,
5796
+ rtkAverageCommandRuntime: "9.9s",
5797
+ rtkTopSavingsCommands: [
5798
+ { command: "rg", count: 74, saved: "1.9M", averageSavedPercent: 31.2, time: "8m" },
5799
+ { command: "pnpm test", count: 18, saved: "840K", averageSavedPercent: 17.4, time: "12m" },
5800
+ { command: "git diff", count: 39, saved: "620K", averageSavedPercent: 26.8, time: "2m" }
5801
+ ],
5802
+ rtkRecentActivity: [
5803
+ "rg council-of-elrond planner",
5804
+ "pnpm test --filter gondor-api",
5805
+ "git diff -- shire-mobile"
5806
+ ],
5807
+ rtkDiscoverAvailable: true,
5808
+ rtkDiscoverError: void 0,
5809
+ rtkDiscoverSummary: "Total: 248 fictional command events scanned.",
5810
+ rtkCoverageGaps: [
5811
+ { command: "cat", count: 21, rtkEquivalent: "rtk sed", status: "easy win", estimatedSavings: "410K" },
5812
+ { command: "grep", count: 13, rtkEquivalent: "rtk rg", status: "easy win", estimatedSavings: "360K" }
5813
+ ],
5814
+ rtkUnhandledCommands: [
5815
+ { command: "palantirctl inspect", count: 5, example: "palantirctl inspect eye-of-sauron-alerts" }
5816
+ ],
5817
+ topCommands: [
5818
+ { command: "rg", count: 74, saved: "1.9M", averageSavedPercent: 31.2, time: "8m" },
5819
+ { command: "git", count: 46, saved: "720K", averageSavedPercent: 18.3, time: "3m" },
5820
+ { command: "pnpm", count: 31, saved: "1.1M", averageSavedPercent: 16.8, time: "17m" }
5821
+ ],
5822
+ recentCommands: [
5823
+ "rtk rg auth gates-of-moria",
5824
+ "rtk pnpm test --filter palantir-observability",
5825
+ "rtk git diff -- one-ring-infra"
5826
+ ],
5827
+ raw: "RepoSpend LOTR demo RTK output",
5828
+ error: void 0
5829
+ };
5830
+ }
5831
+
5832
+ // apps/server/src/demo/index.ts
5833
+ function demoModeEnabled() {
5834
+ return process.env.REPOSPEND_DEMO_DATA === "lotr";
5835
+ }
5836
+ function readDemoDashboardData(filters = {}, options = {}) {
5837
+ return buildDashboardSnapshot({
5838
+ sources: demoSources,
5839
+ sourceStats: demoSourceStats,
5840
+ sessions: displaySessions(demoSessions, options),
5841
+ filters
5842
+ });
5843
+ }
4122
5844
  var demoSessions = demoInputs.map(toSession);
4123
5845
  function toSession(input) {
4124
5846
  const start = timestamp2(input.hour, input.minute);
@@ -4207,16 +5929,6 @@ function detectedSurfaceForApp(sourceApp) {
4207
5929
  function timestamp2(hour, minute) {
4208
5930
  return `${baseDate}T${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}:00.000Z`;
4209
5931
  }
4210
- function issue(command2, category, severity, impact, reason) {
4211
- return {
4212
- command: command2,
4213
- category,
4214
- classification: severity === "critical" ? "blocking_failure" : "warning",
4215
- severity,
4216
- impact,
4217
- reason
4218
- };
4219
- }
4220
5932
  function demoReply(repo, edits, importantFailures) {
4221
5933
  if (importantFailures > 0) {
4222
5934
  return `Found the noisy path in ${repo}, separated the risky failures from ordinary exploration, and left a short list of follow-up checks.`;
@@ -4419,6 +6131,15 @@ function recentCommandLines(lines) {
4419
6131
  // apps/server/src/server.ts
4420
6132
  function createServer(options = {}) {
4421
6133
  const app = Fastify({ logger: false });
6134
+ app.addHook("onRequest", async (request, reply) => {
6135
+ if (!isProtectedLocalMutation(request.method, request.url)) return;
6136
+ if (!isAllowedLocalHost(request.headers.host)) {
6137
+ return reply.code(403).send({ error: "Mutating RepoSpend local settings requires a localhost Host header." });
6138
+ }
6139
+ if (!isAllowedLocalOrigin(request.headers.origin)) {
6140
+ return reply.code(403).send({ error: "Mutating RepoSpend local settings requires a localhost Origin." });
6141
+ }
6142
+ });
4422
6143
  app.get("/api/health", async () => ({ ok: true, app: "RepoSpend", version: readPackageVersion() }));
4423
6144
  app.get("/api/dashboard", async (request) => {
4424
6145
  const query = request.query;
@@ -4427,6 +6148,7 @@ function createServer(options = {}) {
4427
6148
  const demoMode = demoModeEnabled();
4428
6149
  const data = demoMode ? readDemoDashboardData(filters, dashboardOptions) : readDashboardData(filters, dashboardOptions);
4429
6150
  const rtkGain = demoMode ? readDemoRtkGain() : await readRtkGain();
6151
+ const configData = readConfigData();
4430
6152
  return {
4431
6153
  ...data,
4432
6154
  appVersion: readPackageVersion(),
@@ -4435,6 +6157,8 @@ function createServer(options = {}) {
4435
6157
  info: pricingInfo,
4436
6158
  ...readPricingData()
4437
6159
  },
6160
+ config: configData.config,
6161
+ configPath: configData.path,
4438
6162
  rtkGain
4439
6163
  };
4440
6164
  });
@@ -4442,20 +6166,32 @@ function createServer(options = {}) {
4442
6166
  info: pricingInfo,
4443
6167
  ...writePricingData(validatePricingBody(request.body))
4444
6168
  }));
6169
+ app.put("/api/settings/config", async (request) => writeConfigData(validateConfigBody(request.body)));
4445
6170
  app.delete("/api/settings/local-data", async () => clearRepoSpendLocalData());
4446
- app.get("/api/export.json", async (_request, reply) => {
6171
+ app.delete("/api/settings/cache", async () => {
6172
+ if (demoModeEnabled()) return { path: "demo mode (cache disabled)", removed: false, demo: true };
6173
+ return clearRepoSpendParseCache();
6174
+ });
6175
+ app.post("/api/cache/warm", async (request) => {
6176
+ if (demoModeEnabled()) return { started: false, reason: "demo_mode" };
6177
+ const query = request.query;
6178
+ return warmDashboardCache(warmCacheFilters(), parseDashboardOptions(query));
6179
+ });
6180
+ app.get("/api/export.json", async (request, reply) => {
6181
+ const query = request.query;
4447
6182
  reply.header("content-type", "application/json; charset=utf-8");
4448
- return exportJson();
6183
+ return exportJson(parseFilters(query), parseDashboardOptions(query));
4449
6184
  });
4450
- app.get("/api/export.csv", async (_request, reply) => {
6185
+ app.get("/api/export.csv", async (request, reply) => {
6186
+ const query = request.query;
4451
6187
  reply.header("content-type", "text/csv; charset=utf-8");
4452
6188
  reply.header("content-disposition", 'attachment; filename="repospend-export.csv"');
4453
- return exportCsv();
6189
+ return exportCsv(parseFilters(query), parseDashboardOptions(query));
4454
6190
  });
4455
6191
  if (options.serveWeb !== false) {
4456
- const serverDist = path7.dirname(fileURLToPath2(import.meta.url));
4457
- const packagedWebDist = path7.resolve(serverDist, "../web-dist");
4458
- const workspaceWebDist = path7.resolve(serverDist, "../../web/dist");
6192
+ const serverDist = path10.dirname(fileURLToPath3(import.meta.url));
6193
+ const packagedWebDist = path10.resolve(serverDist, "../web-dist");
6194
+ const workspaceWebDist = path10.resolve(serverDist, "../../web/dist");
4459
6195
  const webDist = fsExists(packagedWebDist) ? packagedWebDist : workspaceWebDist;
4460
6196
  if (!fsExists(webDist)) {
4461
6197
  throw new Error(`RepoSpend web assets are missing. Expected ${packagedWebDist} in the published package, or ${workspaceWebDist} during local development. Run "pnpm pack:prepare" before packaging.`);
@@ -4476,13 +6212,13 @@ function createServer(options = {}) {
4476
6212
  return app;
4477
6213
  }
4478
6214
  function fsExists(inputPath) {
4479
- return Boolean(inputPath) && path7.isAbsolute(inputPath) && fs8.existsSync(inputPath);
6215
+ return Boolean(inputPath) && path10.isAbsolute(inputPath) && fs10.existsSync(inputPath);
4480
6216
  }
4481
6217
  function readPackageVersion() {
4482
- const serverDist = path7.dirname(fileURLToPath2(import.meta.url));
4483
- const packagePath = path7.resolve(serverDist, "../package.json");
6218
+ const serverDist = path10.dirname(fileURLToPath3(import.meta.url));
6219
+ const packagePath = path10.resolve(serverDist, "../package.json");
4484
6220
  try {
4485
- const parsed = JSON.parse(fs8.readFileSync(packagePath, "utf8"));
6221
+ const parsed = JSON.parse(fs10.readFileSync(packagePath, "utf8"));
4486
6222
  return typeof parsed.version === "string" ? parsed.version : "unknown";
4487
6223
  } catch {
4488
6224
  return "unknown";
@@ -4514,6 +6250,33 @@ function portCandidates(startPort, fallbackCount) {
4514
6250
  function isAddressInUse(error) {
4515
6251
  return Boolean(error && typeof error === "object" && "code" in error && error.code === "EADDRINUSE");
4516
6252
  }
6253
+ function isProtectedLocalMutation(method, url) {
6254
+ if (method !== "PUT" && method !== "POST" && method !== "DELETE") return false;
6255
+ return url.startsWith("/api/settings/") || url.startsWith("/api/cache/");
6256
+ }
6257
+ function isAllowedLocalHost(hostHeader) {
6258
+ if (!hostHeader) return false;
6259
+ const host = hostFromHeader(hostHeader).toLowerCase();
6260
+ return host === "localhost" || host === "127.0.0.1" || host === "::1";
6261
+ }
6262
+ function isAllowedLocalOrigin(originHeader) {
6263
+ if (!originHeader) return true;
6264
+ try {
6265
+ return isAllowedLocalHost(new URL(originHeader).host);
6266
+ } catch {
6267
+ return false;
6268
+ }
6269
+ }
6270
+ function hostFromHeader(hostHeader) {
6271
+ const value = hostHeader.trim();
6272
+ if (value.startsWith("[")) {
6273
+ const end = value.indexOf("]");
6274
+ return end >= 0 ? value.slice(1, end) : value;
6275
+ }
6276
+ const colonCount = (value.match(/:/g) ?? []).length;
6277
+ if (colonCount === 1) return value.slice(0, value.indexOf(":"));
6278
+ return value;
6279
+ }
4517
6280
  function validatePricingBody(body) {
4518
6281
  if (!body || typeof body !== "object" || Array.isArray(body)) {
4519
6282
  throw new Error("Expected JSON body with a models object.");
@@ -4544,6 +6307,31 @@ function validatePricingBody(body) {
4544
6307
  }
4545
6308
  return validated;
4546
6309
  }
6310
+ function validateConfigBody(body) {
6311
+ if (!body || typeof body !== "object" || Array.isArray(body)) return {};
6312
+ const input = body;
6313
+ const config = {};
6314
+ if (Array.isArray(input.repos)) config.repos = input.repos;
6315
+ if (typeof input.pricingPath === "string") config.pricingPath = input.pricingPath;
6316
+ const experimentalSources = input.experimentalSources;
6317
+ if (experimentalSources && typeof experimentalSources === "object" && !Array.isArray(experimentalSources)) {
6318
+ config.experimentalSources = {
6319
+ cursor: experimentalSources.cursor === true
6320
+ };
6321
+ }
6322
+ return config;
6323
+ }
6324
+ function warmCacheFilters() {
6325
+ const days = 30;
6326
+ const now = /* @__PURE__ */ new Date();
6327
+ return {
6328
+ from: dateOnly(new Date(now.getTime() - days * 24 * 60 * 60 * 1e3)),
6329
+ to: dateOnly(now)
6330
+ };
6331
+ }
6332
+ function dateOnly(value) {
6333
+ return value.toISOString().slice(0, 10);
6334
+ }
4547
6335
  function finiteNumber(value) {
4548
6336
  if (value === "" || value === null || value === void 0) return void 0;
4549
6337
  const parsed = typeof value === "number" ? value : Number(value);
@@ -4561,23 +6349,27 @@ try {
4561
6349
  if (port !== requestedPort) console.log(`Port ${requestedPort} is already in use; using ${port} instead.`);
4562
6350
  console.log(`RepoSpend dashboard: ${url}`);
4563
6351
  openBrowser(url);
6352
+ } else if (command === "__warm-cache") {
6353
+ readDashboardData(parseCliDateFilters(args), parseCliDashboardOptions(args));
4564
6354
  } else if (command === "scan") {
4565
- const data = readDashboardData(cliFilters(args));
6355
+ const data = readDashboardData(parseCliFilters(args), parseCliDashboardOptions(args));
4566
6356
  console.log(JSON.stringify({ sources: data.sources, sessionCount: data.sessions.length, summary: data.summary }, null, 2));
4567
6357
  } else if (command === "by-repo") {
4568
- printGroups(groupByRepo(readDashboardData(cliFilters(args)).sessions));
6358
+ printGroups(groupByRepo(readDashboardData(parseCliFilters(args), parseCliDashboardOptions(args)).sessions));
4569
6359
  } else if (command === "by-day") {
4570
- printGroups(groupByDay(readDashboardData(cliFilters(args)).sessions));
6360
+ printGroups(groupByDay(readDashboardData(parseCliFilters(args), parseCliDashboardOptions(args)).sessions));
4571
6361
  } else if (command === "by-hour") {
4572
- printGroups(groupByHour(readDashboardData(cliFilters(args)).sessions));
6362
+ printGroups(groupByHour(readDashboardData(parseCliFilters(args), parseCliDashboardOptions(args)).sessions));
4573
6363
  } else if (command === "by-model") {
4574
- printGroups(groupByModel(readDashboardData(cliFilters(args)).sessions));
6364
+ printGroups(groupByModel(readDashboardData(parseCliFilters(args), parseCliDashboardOptions(args)).sessions));
4575
6365
  } else if (command === "by-app") {
4576
- printGroups(groupBySourceApp(readDashboardData(cliFilters(args)).sessions));
6366
+ printGroups(groupBySourceApp(readDashboardData(parseCliFilters(args), parseCliDashboardOptions(args)).sessions));
6367
+ } else if (command === "doctor") {
6368
+ printDoctor(readDashboardData(parseCliFilters(args), parseCliDashboardOptions(args)));
4577
6369
  } else if (command === "export") {
4578
6370
  const format = valueAfter(args, "--format") ?? "json";
4579
- const filters = cliFilters(args);
4580
- const sessions = readDashboardData(filters).sessions;
6371
+ const filters = parseCliFilters(args);
6372
+ const sessions = readDashboardData(filters, parseCliDashboardOptions(args)).sessions;
4581
6373
  console.log(format === "csv" ? toCsv(sessions) : JSON.stringify(sessions, null, 2));
4582
6374
  } else {
4583
6375
  console.error(`Unknown command: ${command}`);
@@ -4592,14 +6384,14 @@ function openBrowser(url) {
4592
6384
  if (process.env.REPOSPEND_NO_OPEN === "1" || process.env.CI === "true") return;
4593
6385
  const command2 = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
4594
6386
  const args2 = process.platform === "win32" ? ["/c", "start", "", url] : [url];
4595
- const child = spawn(command2, args2, { detached: true, stdio: "ignore" });
6387
+ const child = spawn2(command2, args2, { detached: true, stdio: "ignore" });
4596
6388
  child.on("error", () => {
4597
6389
  });
4598
6390
  child.unref();
4599
6391
  }
4600
6392
  function printGroups(groups) {
4601
6393
  if (!groups.length) {
4602
- console.log("No usage found. Scanned Codex, Claude Code, and Cursor local data paths.");
6394
+ console.log("No usage found. Scanned enabled local data paths.");
4603
6395
  return;
4604
6396
  }
4605
6397
  const rows = groups.map((group) => ({
@@ -4610,18 +6402,42 @@ function printGroups(groups) {
4610
6402
  }));
4611
6403
  console.table(rows);
4612
6404
  }
4613
- function cliFilters(args2) {
4614
- const source = valueAfter(args2, "--source");
4615
- if (!source || source === "all") return {};
4616
- return { source };
6405
+ function printDoctor(data) {
6406
+ const confidence = data.confidence;
6407
+ console.log(`RepoSpend data confidence: ${confidence.label} (${confidence.score}/100)`);
6408
+ console.table([
6409
+ { metric: "Sessions", value: confidence.sessionCount },
6410
+ { metric: "Token data coverage", value: `${Math.round(confidence.tokenDataPct * 100)}% (${confidence.tokenDataSessions}/${confidence.sessionCount})` },
6411
+ { metric: "Pricing coverage", value: `${Math.round(confidence.pricingCoveragePct * 100)}% (${confidence.pricedTokenSessions}/${confidence.tokenDataSessions})` },
6412
+ { metric: "Repo grouping", value: `${Math.round(confidence.verifiedRepoPct * 100)}% verified (${confidence.verifiedRepoSessions}/${confidence.sessionCount})` },
6413
+ { metric: "Parse issues", value: confidence.parseIssueCount },
6414
+ { metric: "Source warnings", value: confidence.sourceWarningCount }
6415
+ ]);
6416
+ if (confidence.issues.length) {
6417
+ console.table(confidence.issues.map((issue2) => ({
6418
+ severity: issue2.tone,
6419
+ issue: issue2.title,
6420
+ detail: issue2.detail,
6421
+ sessions: issue2.affectedSessionIds.length
6422
+ })));
6423
+ } else {
6424
+ console.log("No data-confidence issues detected.");
6425
+ }
6426
+ console.table(data.sourceStats.map((source) => ({
6427
+ source: source.sourceLabel ?? source.sourceId ?? "unknown",
6428
+ imported: source.sessionsImported ?? 0,
6429
+ files: source.sessionFileCount,
6430
+ parseIssues: source.parseFailureCount ?? 0,
6431
+ primaryData: sourcePrimaryDataFound(source) ? "found" : "missing"
6432
+ })));
4617
6433
  }
4618
- function valueAfter(args2, flag) {
4619
- const index = args2.indexOf(flag);
4620
- return index >= 0 ? args2[index + 1] : void 0;
6434
+ function sourcePrimaryDataFound(source) {
6435
+ return Boolean(source.stateExists || source.sessionsExists || source.projectsExists || source.historyExists || source.otelExists || (source.otelFileCount ?? 0) > 0 || (source.databaseFileCount ?? 0) > 0);
4621
6436
  }
4622
6437
  function printHelp() {
4623
6438
  console.log(`RepoSpend commands:
4624
6439
  repospend serve
6440
+ repospend doctor
4625
6441
  repospend scan
4626
6442
  repospend by-repo
4627
6443
  repospend by-day
@@ -4632,5 +6448,13 @@ function printHelp() {
4632
6448
  repospend export --format csv
4633
6449
 
4634
6450
  Options:
4635
- --source all|codex|claude|cursor`);
6451
+ --source all|codex|claude|copilot|cursor
6452
+ --sourceApp "VS Code" | --source-app "VS Code" | --app "VS Code"
6453
+ --repo <repo name or path>
6454
+ --model <model id>
6455
+ --from YYYY-MM-DD
6456
+ --to YYYY-MM-DD
6457
+ --split-source-apps
6458
+
6459
+ Cursor is experimental and only scans when enabled in ~/.repospend/config.json.`);
4636
6460
  }