repospend 0.0.2 → 0.0.4

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
@@ -110,6 +110,7 @@ function toCsv(sessions) {
110
110
  "provider",
111
111
  "inputTokens",
112
112
  "cachedInputTokens",
113
+ "cacheCreationInputTokens",
113
114
  "outputTokens",
114
115
  "reasoningTokens",
115
116
  "reasoningOutputTokens",
@@ -194,11 +195,15 @@ import fs from "node:fs";
194
195
  import os from "node:os";
195
196
  import path from "node:path";
196
197
  var pricingInfo = {
197
- sourceName: "OpenAI API pricing",
198
+ sourceName: "Bundled OpenAI and Claude API pricing",
198
199
  sourceUrl: "https://developers.openai.com/api/docs/pricing",
200
+ sourceUrls: [
201
+ { label: "OpenAI pricing reference", url: "https://developers.openai.com/api/docs/pricing" },
202
+ { label: "Claude pricing reference", url: "https://platform.claude.com/docs/en/about-claude/pricing" }
203
+ ],
199
204
  unit: "USD per 1M tokens",
200
205
  updatedAt: "2026-05-18",
201
- note: "RepoSpend estimates API-equivalent cost from local token counts and public API-style Standard short-context pricing. This is not your actual ChatGPT/Codex bill; subscriptions, credits, provider terms, long-context pricing, regional processing, or other billing factors can make your real cost different."
206
+ note: "RepoSpend estimates API-equivalent cost from local token counts and public API-style Standard pricing. This is not your actual bill; subscriptions, credits, provider terms, cache behavior, regional processing, or other billing factors can make your real cost different."
202
207
  };
203
208
  var defaultPricing = {
204
209
  "gpt-5.5": { inputPerMillion: 5, cachedInputPerMillion: 0.5, outputPerMillion: 30, reasoningOutputPerMillion: 30 },
@@ -220,7 +225,17 @@ var defaultPricing = {
220
225
  "gpt-5-codex": { inputPerMillion: 1.25, cachedInputPerMillion: 0.125, outputPerMillion: 10, reasoningOutputPerMillion: 10 },
221
226
  "gpt-5-pro": { inputPerMillion: 15, outputPerMillion: 120, reasoningOutputPerMillion: 120 },
222
227
  "gpt-5-mini": { inputPerMillion: 0.25, cachedInputPerMillion: 0.025, outputPerMillion: 2, reasoningOutputPerMillion: 2 },
223
- "gpt-5-nano": { inputPerMillion: 0.05, cachedInputPerMillion: 5e-3, outputPerMillion: 0.4, reasoningOutputPerMillion: 0.4 }
228
+ "gpt-5-nano": { inputPerMillion: 0.05, cachedInputPerMillion: 5e-3, outputPerMillion: 0.4, reasoningOutputPerMillion: 0.4 },
229
+ "claude-opus-4-7": { inputPerMillion: 5, cacheCreationInputPerMillion: 6.25, cachedInputPerMillion: 0.5, outputPerMillion: 25 },
230
+ "claude-opus-4-6": { inputPerMillion: 5, cacheCreationInputPerMillion: 6.25, cachedInputPerMillion: 0.5, outputPerMillion: 25 },
231
+ "claude-opus-4-5": { inputPerMillion: 5, cacheCreationInputPerMillion: 6.25, cachedInputPerMillion: 0.5, outputPerMillion: 25 },
232
+ "claude-opus-4-1": { inputPerMillion: 15, cacheCreationInputPerMillion: 18.75, cachedInputPerMillion: 1.5, outputPerMillion: 75 },
233
+ "claude-opus-4": { inputPerMillion: 15, cacheCreationInputPerMillion: 18.75, cachedInputPerMillion: 1.5, outputPerMillion: 75 },
234
+ "claude-sonnet-4-6": { inputPerMillion: 3, cacheCreationInputPerMillion: 3.75, cachedInputPerMillion: 0.3, outputPerMillion: 15 },
235
+ "claude-sonnet-4-5": { inputPerMillion: 3, cacheCreationInputPerMillion: 3.75, cachedInputPerMillion: 0.3, outputPerMillion: 15 },
236
+ "claude-sonnet-4": { inputPerMillion: 3, cacheCreationInputPerMillion: 3.75, cachedInputPerMillion: 0.3, outputPerMillion: 15 },
237
+ "claude-haiku-4-5": { inputPerMillion: 1, cacheCreationInputPerMillion: 1.25, cachedInputPerMillion: 0.1, outputPerMillion: 5 },
238
+ "claude-3-5-haiku": { inputPerMillion: 0.8, cacheCreationInputPerMillion: 1, cachedInputPerMillion: 0.08, outputPerMillion: 4 }
224
239
  };
225
240
  function loadPricingTable(pricingPath) {
226
241
  if (!pricingPath) {
@@ -248,16 +263,45 @@ function calculateCostUsd(usage, pricing) {
248
263
  if (!usage.model) {
249
264
  return void 0;
250
265
  }
251
- const modelPricing = pricing[usage.model] ?? pricing[usage.model.toLowerCase()];
266
+ const modelPricing = findModelPricing(usage.model, pricing);
252
267
  if (!modelPricing) {
253
268
  return void 0;
254
269
  }
255
- const billableInput = Math.max(usage.inputTokens - usage.cachedInputTokens, 0);
270
+ const cacheCreationInput = usage.cacheCreationInputTokens ?? 0;
271
+ const billableInput = Math.max(usage.inputTokens - usage.cachedInputTokens - cacheCreationInput, 0);
256
272
  const cachedRate = modelPricing.cachedInputPerMillion ?? modelPricing.inputPerMillion;
273
+ const cacheCreationRate = modelPricing.cacheCreationInputPerMillion ?? modelPricing.inputPerMillion;
257
274
  const reasoningRate = modelPricing.reasoningOutputPerMillion ?? modelPricing.outputPerMillion;
258
- const cost = billableInput / 1e6 * modelPricing.inputPerMillion + usage.cachedInputTokens / 1e6 * cachedRate + usage.outputTokens / 1e6 * modelPricing.outputPerMillion + usage.reasoningTokens / 1e6 * reasoningRate;
275
+ const cost = billableInput / 1e6 * modelPricing.inputPerMillion + usage.cachedInputTokens / 1e6 * cachedRate + cacheCreationInput / 1e6 * cacheCreationRate + usage.outputTokens / 1e6 * modelPricing.outputPerMillion + usage.reasoningTokens / 1e6 * reasoningRate;
259
276
  return Number(cost.toFixed(6));
260
277
  }
278
+ function findModelPricing(model, pricing) {
279
+ const lower = model.toLowerCase();
280
+ const exactPricing = pricing[model] ?? pricing[lower];
281
+ if (isUsablePricing(exactPricing)) return exactPricing;
282
+ const familyPricing = claudeFamilyPricing(lower, pricing);
283
+ if (isUsablePricing(familyPricing)) return familyPricing;
284
+ return void 0;
285
+ }
286
+ function claudeFamilyPricing(model, pricing) {
287
+ const families = [
288
+ "claude-opus-4-7",
289
+ "claude-opus-4-6",
290
+ "claude-opus-4-5",
291
+ "claude-opus-4-1",
292
+ "claude-opus-4",
293
+ "claude-sonnet-4-6",
294
+ "claude-sonnet-4-5",
295
+ "claude-sonnet-4",
296
+ "claude-haiku-4-5",
297
+ "claude-3-5-haiku"
298
+ ];
299
+ const family = families.find((candidate) => model === candidate || model.startsWith(`${candidate}-`));
300
+ return family ? pricing[family] : void 0;
301
+ }
302
+ function isUsablePricing(pricing) {
303
+ return typeof pricing?.inputPerMillion === "number" && pricing.inputPerMillion > 0 && typeof pricing.outputPerMillion === "number" && pricing.outputPerMillion > 0;
304
+ }
261
305
 
262
306
  // packages/core/src/config.ts
263
307
  function configPath() {
@@ -763,9 +807,9 @@ function pluralize(noun, value) {
763
807
 
764
808
  // packages/core/src/dashboard.ts
765
809
  function buildDashboardSnapshot(input) {
766
- const allSessions = input.sessions.filter((session) => session.totalTokens > 0);
810
+ const allSessions = input.sessions.filter(isDashboardVisibleSession);
767
811
  const filteredSessions = filterUsage(input.sessions, input.filters ?? {});
768
- const sessions = filteredSessions.filter((session) => session.totalTokens > 0);
812
+ const sessions = filteredSessions.filter(isDashboardVisibleSession);
769
813
  const summary = summarize(sessions);
770
814
  const repos = buildRepoRollups(sessions);
771
815
  const sourceApps = groupBySourceApp(sessions);
@@ -796,6 +840,9 @@ function buildDashboardSnapshot(input) {
796
840
  health: buildUsageHealth({ sessions, summary, repos, sourceApps, scan })
797
841
  };
798
842
  }
843
+ function isDashboardVisibleSession(session) {
844
+ return session.totalTokens > 0 || session.sourceClient === "claude";
845
+ }
799
846
 
800
847
  // packages/core/src/repo.ts
801
848
  import fs3 from "node:fs";
@@ -889,9 +936,11 @@ function parseBranch(head) {
889
936
  }
890
937
 
891
938
  // packages/core/src/sources/codex.ts
939
+ import { spawnSync } from "node:child_process";
892
940
  import fs4 from "node:fs";
893
941
  import os2 from "node:os";
894
942
  import path4 from "node:path";
943
+ import { fileURLToPath } from "node:url";
895
944
  import Database from "better-sqlite3";
896
945
 
897
946
  // packages/core/src/sources/agent-friction.ts
@@ -943,30 +992,30 @@ function applyActionMetadata(analysis, record) {
943
992
  }
944
993
  function finalizeCommandIssues(analysis, totalTokens) {
945
994
  const repeatedCommands = /* @__PURE__ */ new Map();
946
- const repeatableIssues = analysis.commandIssues.filter((issue) => issue.classification !== "harmless_nonzero" && issue.classification !== "exploratory_miss");
947
- for (const issue of repeatableIssues) {
948
- repeatedCommands.set(issue.command, (repeatedCommands.get(issue.command) ?? 0) + 1);
995
+ const repeatableIssues = analysis.commandIssues.filter((issue2) => issue2.classification !== "harmless_nonzero" && issue2.classification !== "exploratory_miss");
996
+ for (const issue2 of repeatableIssues) {
997
+ repeatedCommands.set(issue2.command, (repeatedCommands.get(issue2.command) ?? 0) + 1);
949
998
  }
950
999
  const repeatedFailures = [...repeatedCommands.entries()].filter(([, count2]) => count2 >= 3);
951
1000
  if (repeatedFailures.length > 0) {
952
1001
  analysis.repeatedFailureClusters = repeatedFailures.length;
953
- for (const issue of analysis.commandIssues) {
954
- if (repeatedCommands.get(issue.command) >= 3 && issue.classification !== "harmless_nonzero" && issue.classification !== "exploratory_miss") {
955
- issue.classification = "blocking_failure";
956
- issue.severity = "critical";
957
- issue.impact = "high";
1002
+ for (const issue2 of analysis.commandIssues) {
1003
+ if (repeatedCommands.get(issue2.command) >= 3 && issue2.classification !== "harmless_nonzero" && issue2.classification !== "exploratory_miss") {
1004
+ issue2.classification = "blocking_failure";
1005
+ issue2.severity = "critical";
1006
+ issue2.impact = "high";
958
1007
  }
959
1008
  }
960
1009
  }
961
1010
  if (totalTokens >= 1e6 && analysis.commandIssues.length >= 5) {
962
- for (const issue of analysis.commandIssues) {
963
- if (issue.severity === "warning") issue.impact = "high";
1011
+ for (const issue2 of analysis.commandIssues) {
1012
+ if (issue2.severity === "warning") issue2.impact = "high";
964
1013
  }
965
1014
  }
966
1015
  analysis.nonZeroCommandEvents = analysis.commandIssues.length;
967
- analysis.importantCommandFailures = analysis.commandIssues.filter((issue) => issue.severity === "critical" || issue.severity === "warning").length;
968
- analysis.harmlessNonZeroEvents = analysis.commandIssues.filter((issue) => issue.classification === "harmless_nonzero").length;
969
- analysis.exploratoryMisses = analysis.commandIssues.filter((issue) => issue.classification === "exploratory_miss").length;
1016
+ analysis.importantCommandFailures = analysis.commandIssues.filter((issue2) => issue2.severity === "critical" || issue2.severity === "warning").length;
1017
+ analysis.harmlessNonZeroEvents = analysis.commandIssues.filter((issue2) => issue2.classification === "harmless_nonzero").length;
1018
+ analysis.exploratoryMisses = analysis.commandIssues.filter((issue2) => issue2.classification === "exploratory_miss").length;
970
1019
  analysis.failedToolCallCount = analysis.importantCommandFailures;
971
1020
  analysis.commandIssueSeverity = strongestSeverity(analysis.commandIssues);
972
1021
  analysis.commandIssueImpact = strongestImpact(analysis.commandIssues);
@@ -974,21 +1023,21 @@ function finalizeCommandIssues(analysis, totalTokens) {
974
1023
  analysis.commandIssueSamples = sampledCommandIssues(analysis.commandIssues);
975
1024
  }
976
1025
  function sampledCommandIssues(issues) {
977
- return [...issues].sort((a, b) => issuePriority(b) - issuePriority(a)).slice(0, 12).map((issue) => ({
978
- ...issue,
979
- reason: commandIssueReason(issue)
1026
+ return [...issues].sort((a, b) => issuePriority(b) - issuePriority(a)).slice(0, 12).map((issue2) => ({
1027
+ ...issue2,
1028
+ reason: commandIssueReason(issue2)
980
1029
  }));
981
1030
  }
982
- function issuePriority(issue) {
983
- const severityScore = issue.severity === "critical" ? 4 : issue.severity === "warning" ? 3 : issue.severity === "info" ? 2 : issue.severity === "ignored" ? 1 : 0;
984
- const impactScore = issue.impact === "high" ? 3 : issue.impact === "medium" ? 2 : issue.impact === "low" ? 1 : 0;
1031
+ function issuePriority(issue2) {
1032
+ const severityScore = issue2.severity === "critical" ? 4 : issue2.severity === "warning" ? 3 : issue2.severity === "info" ? 2 : issue2.severity === "ignored" ? 1 : 0;
1033
+ const impactScore = issue2.impact === "high" ? 3 : issue2.impact === "medium" ? 2 : issue2.impact === "low" ? 1 : 0;
985
1034
  return severityScore * 10 + impactScore;
986
1035
  }
987
- function commandIssueReason(issue) {
988
- if (issue.severity === "critical") return "Repeated or blocking command issue that may have stopped progress.";
989
- if (issue.severity === "warning") return "Non-zero command event classified as important for this session.";
990
- if (issue.classification === "exploratory_miss") return "Likely exploratory command that found no result.";
991
- if (issue.classification === "harmless_nonzero") return "Likely harmless non-zero exit during normal shell exploration.";
1036
+ function commandIssueReason(issue2) {
1037
+ if (issue2.severity === "critical") return "Repeated or blocking command issue that may have stopped progress.";
1038
+ if (issue2.severity === "warning") return "Non-zero command event classified as important for this session.";
1039
+ if (issue2.classification === "exploratory_miss") return "Likely exploratory command that found no result.";
1040
+ if (issue2.classification === "harmless_nonzero") return "Likely harmless non-zero exit during normal shell exploration.";
992
1041
  return "Non-zero command event with limited local context.";
993
1042
  }
994
1043
  function classifyCommandIssue(record, previousIssues) {
@@ -997,7 +1046,7 @@ function classifyCommandIssue(record, previousIssues) {
997
1046
  const command2 = extractCommandText(record) ?? compactCommandText(raw);
998
1047
  const category = commandCategory(text);
999
1048
  const commandKey = normalizeCommand(command2);
1000
- const repeated = previousIssues.filter((issue) => issue.command === commandKey).length + 1 >= 3;
1049
+ const repeated = previousIssues.filter((issue2) => issue2.command === commandKey).length + 1 >= 3;
1001
1050
  if (isHarmlessNonZero(text)) {
1002
1051
  return { command: commandKey, category, classification: isExploratoryMiss(text) ? "exploratory_miss" : "harmless_nonzero", severity: "ignored", impact: "low" };
1003
1052
  }
@@ -1042,23 +1091,23 @@ function importantImpact(category) {
1042
1091
  return category === "install" || category === "build" || category === "test" || category === "typecheck" || category === "deploy" ? "high" : "medium";
1043
1092
  }
1044
1093
  function strongestSeverity(issues) {
1045
- if (issues.some((issue) => issue.severity === "critical")) return "critical";
1046
- if (issues.some((issue) => issue.severity === "warning")) return "warning";
1047
- if (issues.some((issue) => issue.severity === "info")) return "info";
1048
- if (issues.some((issue) => issue.severity === "ignored")) return "ignored";
1094
+ if (issues.some((issue2) => issue2.severity === "critical")) return "critical";
1095
+ if (issues.some((issue2) => issue2.severity === "warning")) return "warning";
1096
+ if (issues.some((issue2) => issue2.severity === "info")) return "info";
1097
+ if (issues.some((issue2) => issue2.severity === "ignored")) return "ignored";
1049
1098
  return "none";
1050
1099
  }
1051
1100
  function strongestImpact(issues) {
1052
- if (issues.some((issue) => issue.impact === "high")) return "high";
1053
- if (issues.some((issue) => issue.impact === "medium")) return "medium";
1054
- if (issues.some((issue) => issue.impact === "low")) return "low";
1101
+ if (issues.some((issue2) => issue2.impact === "high")) return "high";
1102
+ if (issues.some((issue2) => issue2.impact === "medium")) return "medium";
1103
+ if (issues.some((issue2) => issue2.impact === "low")) return "low";
1055
1104
  return "none";
1056
1105
  }
1057
1106
  function topFailureType(issues) {
1058
- const important = issues.filter((issue) => issue.severity === "critical" || issue.severity === "warning");
1107
+ const important = issues.filter((issue2) => issue2.severity === "critical" || issue2.severity === "warning");
1059
1108
  const source = important.length ? important : issues;
1060
1109
  const counts = /* @__PURE__ */ new Map();
1061
- for (const issue of source) counts.set(issue.category === "unknown" ? issue.classification : issue.category, (counts.get(issue.category === "unknown" ? issue.classification : issue.category) ?? 0) + 1);
1110
+ for (const issue2 of source) counts.set(issue2.category === "unknown" ? issue2.classification : issue2.category, (counts.get(issue2.category === "unknown" ? issue2.classification : issue2.category) ?? 0) + 1);
1062
1111
  return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
1063
1112
  }
1064
1113
  function extractCommandText(record) {
@@ -1221,6 +1270,7 @@ function numberFromKeys(object, keys) {
1221
1270
  }
1222
1271
 
1223
1272
  // packages/core/src/sources/codex.ts
1273
+ var nativeSqliteRebuildAttempted = false;
1224
1274
  function scanCodex(options) {
1225
1275
  const codexHome = options.codexHome ?? path4.join(os2.homedir(), ".codex");
1226
1276
  const statePath = path4.join(codexHome, "state_5.sqlite");
@@ -1256,6 +1306,9 @@ function scanCodex(options) {
1256
1306
  },
1257
1307
  sessions,
1258
1308
  stats: {
1309
+ sourceId: "codex",
1310
+ sourceLabel: "Codex",
1311
+ homePath: codexHome,
1259
1312
  codexHome,
1260
1313
  statePath,
1261
1314
  sessionsPath,
@@ -1272,7 +1325,7 @@ function scanCodex(options) {
1272
1325
  function readThreads(statePath, sessionFiles, options, warnings) {
1273
1326
  let db;
1274
1327
  try {
1275
- db = new Database(statePath, { readonly: true, fileMustExist: true });
1328
+ db = openCodexStateDatabase(statePath, warnings);
1276
1329
  const tableNames = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all();
1277
1330
  if (!tableNames.some((table) => table.name === "threads")) {
1278
1331
  warnings.push("Codex SQLite state does not contain a threads table.");
@@ -1293,6 +1346,70 @@ function readThreads(statePath, sessionFiles, options, warnings) {
1293
1346
  db?.close();
1294
1347
  }
1295
1348
  }
1349
+ function openCodexStateDatabase(statePath, warnings) {
1350
+ try {
1351
+ return new Database(statePath, { readonly: true, fileMustExist: true });
1352
+ } catch (error) {
1353
+ if (isNativeSqliteMismatch(error) && attemptNativeSqliteRebuild(warnings)) {
1354
+ return new Database(statePath, { readonly: true, fileMustExist: true });
1355
+ }
1356
+ throw error;
1357
+ }
1358
+ }
1359
+ function attemptNativeSqliteRebuild(warnings) {
1360
+ if (nativeSqliteRebuildAttempted || process.env.REPOSPEND_SKIP_NATIVE_REBUILD === "1") return false;
1361
+ nativeSqliteRebuildAttempted = true;
1362
+ const packageRoot = findRepoSpendPackageRoot();
1363
+ if (!packageRoot) {
1364
+ warnings.push("Codex SQLite native module mismatch detected, but RepoSpend could not locate its package root for an automatic rebuild.");
1365
+ return false;
1366
+ }
1367
+ const packageManager = fs4.existsSync(path4.join(packageRoot, "pnpm-lock.yaml")) ? "pnpm" : "npm";
1368
+ const result = spawnSync(packageManager, ["rebuild", "better-sqlite3"], {
1369
+ cwd: packageRoot,
1370
+ encoding: "utf8",
1371
+ stdio: "pipe",
1372
+ timeout: 12e4
1373
+ });
1374
+ if (result.status === 0) return true;
1375
+ const detail = [result.stderr, result.stdout].filter(Boolean).join("\n").split("\n").map((line) => line.trim()).filter(Boolean).slice(-3).join(" ");
1376
+ warnings.push(`Codex SQLite native module mismatch detected. RepoSpend tried to rebuild better-sqlite3 automatically with ${packageManager}, but it failed${exitLabel(result.status)}.${detail ? ` ${detail}` : ""}`);
1377
+ return false;
1378
+ }
1379
+ function findRepoSpendPackageRoot() {
1380
+ const starts = [
1381
+ process.argv[1] ? path4.dirname(fs4.realpathSync(process.argv[1])) : void 0,
1382
+ path4.dirname(fileURLToPath(import.meta.url)),
1383
+ process.cwd()
1384
+ ].filter((item) => Boolean(item));
1385
+ for (const start of starts) {
1386
+ const root = findPackageRootNamedRepoSpend(start);
1387
+ if (root) return root;
1388
+ }
1389
+ return void 0;
1390
+ }
1391
+ function findPackageRootNamedRepoSpend(start) {
1392
+ let current = path4.resolve(start);
1393
+ while (true) {
1394
+ const packagePath = path4.join(current, "package.json");
1395
+ if (fs4.existsSync(packagePath)) {
1396
+ try {
1397
+ const packageJson = JSON.parse(fs4.readFileSync(packagePath, "utf8"));
1398
+ if (packageJson.name === "repospend") return current;
1399
+ } catch {
1400
+ }
1401
+ }
1402
+ const parent = path4.dirname(current);
1403
+ if (parent === current) return void 0;
1404
+ current = parent;
1405
+ }
1406
+ }
1407
+ function isNativeSqliteMismatch(error) {
1408
+ return errorMessage(error).includes("NODE_MODULE_VERSION");
1409
+ }
1410
+ function exitLabel(status) {
1411
+ return status === null ? "" : ` with exit code ${status}`;
1412
+ }
1296
1413
  function threadToUsage(row, index, sessionFiles, options) {
1297
1414
  const sourcePath = stringValue2(row.rollout_path) ?? matchSessionPath(stringValue2(row.id), sessionFiles) ?? "";
1298
1415
  const sessionBreakdown = sourcePath ? readSessionBreakdown(sourcePath) : emptyBreakdown();
@@ -1303,7 +1420,7 @@ function threadToUsage(row, index, sessionFiles, options) {
1303
1420
  const cachedInputTokens = sessionBreakdown.cachedInputTokens;
1304
1421
  const outputTokens = sessionBreakdown.outputTokens;
1305
1422
  const reasoningTokens = sessionBreakdown.reasoningTokens;
1306
- const totalTokens = sessionBreakdown.totalTokens || dbTokens || inputTokens + cachedInputTokens + outputTokens + reasoningTokens;
1423
+ const totalTokens = sessionBreakdown.totalTokens || dbTokens || inputTokens + outputTokens + reasoningTokens;
1307
1424
  const hasPricedBreakdown = inputTokens + cachedInputTokens + outputTokens + reasoningTokens > 0;
1308
1425
  const warnings = [...repo.warnings, ...sessionBreakdown.warnings];
1309
1426
  const threadSource = stringValue2(row.thread_source) ?? sessionBreakdown.threadSource;
@@ -1676,12 +1793,12 @@ function stringValue2(value) {
1676
1793
  return typeof value === "string" && value.length > 0 ? value : void 0;
1677
1794
  }
1678
1795
  function codexSourceAppLabel(source, threadSource) {
1679
- if (threadSource === "subagent") return "Codex subagent";
1796
+ if (threadSource === "subagent") return "Codex app";
1680
1797
  if (!source) return "Codex";
1681
1798
  if (source === "vscode" || source === "codex_vscode" || source === "codex-vscode") return "VS Code";
1682
1799
  if (source === "cli" || source === "terminal" || source === "codex-tui") return "Terminal";
1683
1800
  if (source === "codex_app" || source === "codex-app") return "Codex app";
1684
- if (source.trim().startsWith("{")) return threadSource === "subagent" ? "Codex subagent" : "Codex";
1801
+ if (source.trim().startsWith("{")) return threadSource === "subagent" ? "Codex app" : "Codex";
1685
1802
  return source.replace(/^codex[_-]/, "").split(/[_-]/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
1686
1803
  }
1687
1804
  function dateValue(value) {
@@ -1704,8 +1821,476 @@ function errorMessage(error) {
1704
1821
  return message;
1705
1822
  }
1706
1823
 
1707
- // apps/server/src/data.ts
1824
+ // packages/core/src/sources/claude.ts
1708
1825
  import fs5 from "node:fs";
1826
+ import os3 from "node:os";
1827
+ import path5 from "node:path";
1828
+ function scanClaude(options) {
1829
+ const claudeHome = options.claudeHome ?? defaultClaudeHome();
1830
+ const historyPath = path5.join(claudeHome, "history.jsonl");
1831
+ const warnings = [];
1832
+ const discovery = discoverClaudeSessionFiles(claudeHome, warnings, Boolean(options.claudeHome));
1833
+ const historyExists = fs5.existsSync(historyPath);
1834
+ const sessions = discovery.files.map((file, index) => claudeFileToUsage(file, index, options));
1835
+ const historyEntryCount = countHistoryEntries(historyPath);
1836
+ const parseFailureCount = sessions.filter((session) => session.parseStatus === "failed" || (session.parseErrors?.length ?? 0) > 0).length;
1837
+ const unreadableFileCount = sessions.filter((session) => session.parseErrors?.some((error) => error.startsWith("unable_to_read_session_file:"))).length;
1838
+ const malformedFileCount = sessions.filter((session) => session.parseErrors?.some((error) => error.startsWith("jsonl_line_"))).length;
1839
+ const projectsPath = path5.join(claudeHome, "projects");
1840
+ if (!discovery.projectsPaths.some((candidate) => fs5.existsSync(candidate))) {
1841
+ warnings.push(`Claude Code projects directory not found at ${projectsPath}`);
1842
+ }
1843
+ return {
1844
+ source: {
1845
+ id: "claude",
1846
+ label: "Claude Code",
1847
+ available: discovery.files.length > 0 || historyExists,
1848
+ paths: unique3([...discovery.projectsPaths, historyPath, ...discovery.desktopSessionRoots]),
1849
+ warnings
1850
+ },
1851
+ sessions,
1852
+ stats: {
1853
+ sourceId: "claude",
1854
+ sourceLabel: "Claude Code",
1855
+ homePath: claudeHome,
1856
+ claudeHome,
1857
+ projectsPath,
1858
+ historyPath,
1859
+ projectsExists: discovery.projectsPaths.some((candidate) => fs5.existsSync(candidate)),
1860
+ historyExists,
1861
+ sessionFileCount: discovery.files.length,
1862
+ projectDirCount: discovery.projectDirs.size,
1863
+ sessionsImported: sessions.length,
1864
+ parseFailureCount,
1865
+ unreadableFileCount,
1866
+ malformedFileCount,
1867
+ historyEntryCount,
1868
+ lastScannedAt: (/* @__PURE__ */ new Date()).toISOString()
1869
+ }
1870
+ };
1871
+ }
1872
+ function discoverClaudeSessionFiles(claudeHome, warnings, explicitHome) {
1873
+ const files = [];
1874
+ const projectDirs = /* @__PURE__ */ new Set();
1875
+ const projectsPaths = explicitHome ? [path5.join(claudeHome, "projects")] : unique3([...configuredClaudeHomes(claudeHome).map((root) => path5.join(root, "projects")), path5.join(os3.homedir(), ".config", "claude", "projects")]);
1876
+ for (const projectsPath of projectsPaths) {
1877
+ collectProjectFiles(projectsPath, files, projectDirs, warnings);
1878
+ }
1879
+ const desktopSessionRoots = explicitHome ? [] : defaultDesktopSessionRoots();
1880
+ for (const root of desktopSessionRoots) {
1881
+ collectDesktopSessionFiles(root, files, projectDirs, warnings);
1882
+ }
1883
+ return {
1884
+ files: uniqueSessionFiles(files),
1885
+ projectDirs,
1886
+ projectsPaths,
1887
+ desktopSessionRoots
1888
+ };
1889
+ }
1890
+ function configuredClaudeHomes(primaryHome) {
1891
+ if (process.env.CLAUDE_CONFIG_DIR) {
1892
+ return process.env.CLAUDE_CONFIG_DIR.split(",").map((item) => item.trim()).filter(Boolean).map((item) => path5.resolve(item));
1893
+ }
1894
+ return [primaryHome];
1895
+ }
1896
+ function collectProjectFiles(projectsPath, files, projectDirs, warnings) {
1897
+ if (!fs5.existsSync(projectsPath)) return;
1898
+ try {
1899
+ for (const entry of fs5.readdirSync(projectsPath, { withFileTypes: true })) {
1900
+ if (!entry.isDirectory()) continue;
1901
+ const projectDir = path5.join(projectsPath, entry.name);
1902
+ projectDirs.add(projectDir);
1903
+ walk2(projectDir, 5, (filePath) => {
1904
+ if (filePath.endsWith(".jsonl")) files.push({ filePath, projectDir, projectsPath });
1905
+ });
1906
+ }
1907
+ } catch (error) {
1908
+ warnings.push(`Unable to read Claude Code projects at ${projectsPath}: ${errorMessage2(error)}`);
1909
+ }
1910
+ }
1911
+ function collectDesktopSessionFiles(root, files, projectDirs, warnings) {
1912
+ if (!fs5.existsSync(root)) return;
1913
+ try {
1914
+ walk2(root, 8, (filePath) => {
1915
+ if (!filePath.endsWith(".jsonl")) return;
1916
+ const parts = filePath.split(path5.sep);
1917
+ const projectsIndex = parts.lastIndexOf("projects");
1918
+ const hasProjectSegment = projectsIndex >= 0 && projectsIndex < parts.length - 2;
1919
+ const projectsPath = hasProjectSegment ? parts.slice(0, projectsIndex + 1).join(path5.sep) || path5.sep : root;
1920
+ const projectDir = hasProjectSegment ? parts.slice(0, projectsIndex + 2).join(path5.sep) || path5.sep : path5.dirname(filePath);
1921
+ projectDirs.add(projectDir);
1922
+ files.push({ filePath, projectDir, projectsPath });
1923
+ });
1924
+ } catch (error) {
1925
+ warnings.push(`Unable to read Claude Code desktop sessions at ${root}: ${errorMessage2(error)}`);
1926
+ }
1927
+ }
1928
+ function walk2(root, maxDepth, onFile, depth = 0) {
1929
+ if (depth > maxDepth) return;
1930
+ for (const entry of fs5.readdirSync(root, { withFileTypes: true })) {
1931
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
1932
+ const fullPath = path5.join(root, entry.name);
1933
+ if (entry.isDirectory()) {
1934
+ walk2(fullPath, maxDepth, onFile, depth + 1);
1935
+ } else if (entry.isFile()) {
1936
+ onFile(fullPath);
1937
+ }
1938
+ }
1939
+ }
1940
+ function claudeFileToUsage(file, index, options) {
1941
+ const breakdown = readClaudeBreakdown(file.filePath);
1942
+ const id = breakdown.id ?? (path5.basename(file.filePath).replace(/\.jsonl$/i, "") || `claude-session-${index}`);
1943
+ const fallbackCwd = inferProjectPath(file.projectDir, file.projectsPath);
1944
+ const cwd = breakdown.cwd ?? fallbackCwd;
1945
+ const repo = resolveRepoInfo(cwd, options.config);
1946
+ const warnings = [...repo.warnings, ...breakdown.warnings];
1947
+ if (!breakdown.cwd) warnings.push("repo_inferred_from_claude_project_dir");
1948
+ const hasTokenBreakdown = breakdown.totalTokens > 0 && breakdown.usageCount > 0;
1949
+ const usage = {
1950
+ id,
1951
+ sourceClient: "claude",
1952
+ sourceApp: claudeSourceAppLabel(breakdown.entrypoint),
1953
+ sourceAppRaw: breakdown.entrypoint,
1954
+ sourcePath: file.filePath,
1955
+ repoRoot: repo.repoRoot,
1956
+ repoName: repo.repoName,
1957
+ cwd,
1958
+ gitRemoteUrl: repo.gitRemoteUrl,
1959
+ gitBranch: repo.gitBranch ?? breakdown.gitBranch,
1960
+ title: breakdown.title,
1961
+ startedAt: breakdown.startedAt,
1962
+ endedAt: breakdown.endedAt,
1963
+ model: breakdown.model,
1964
+ provider: "anthropic",
1965
+ inputTokens: breakdown.inputTokens,
1966
+ cachedInputTokens: breakdown.cachedInputTokens,
1967
+ cacheCreationInputTokens: breakdown.cacheCreationInputTokens,
1968
+ outputTokens: breakdown.outputTokens,
1969
+ reasoningTokens: 0,
1970
+ reasoningOutputTokens: 0,
1971
+ totalTokens: breakdown.totalTokens,
1972
+ tokenAggregationMethod: hasTokenBreakdown ? "direct_usage" : "unknown",
1973
+ tokenConfidence: hasTokenBreakdown ? "high" : "low",
1974
+ tokenSnapshotCount: breakdown.usageCount,
1975
+ estimatedCostUsd: void 0,
1976
+ messageCount: breakdown.messageCount,
1977
+ rawTokenTotal: hasTokenBreakdown ? breakdown.totalTokens : void 0,
1978
+ warnings: hasTokenBreakdown ? warnings : [...warnings, "missing_token_breakdown"],
1979
+ durationMs: durationMs2(breakdown.startedAt, breakdown.endedAt),
1980
+ rawEventCount: breakdown.rawEventCount,
1981
+ parseStatus: breakdown.parseStatus,
1982
+ parseErrors: breakdown.parseErrors,
1983
+ detectedSurface: claudeSurface(breakdown.entrypoint),
1984
+ surfaceConfidence: "medium",
1985
+ surfaceReason: breakdown.entrypoint ? `Claude Code entrypoint: ${breakdown.entrypoint}` : "Claude Code transcript",
1986
+ userPromptCount: breakdown.userPromptCount,
1987
+ assistantMessageCount: breakdown.assistantMessageCount,
1988
+ toolCallCount: breakdown.toolCallCount,
1989
+ shellCommandCount: breakdown.shellCommandCount,
1990
+ failedToolCallCount: breakdown.failedToolCallCount,
1991
+ nonZeroCommandEvents: 0,
1992
+ importantCommandFailures: 0,
1993
+ harmlessNonZeroEvents: 0,
1994
+ exploratoryMisses: 0,
1995
+ repeatedFailureClusters: 0,
1996
+ commandIssueSeverity: breakdown.failedToolCallCount > 0 ? "warning" : "none",
1997
+ commandIssueImpact: breakdown.failedToolCallCount > 0 ? "medium" : "none",
1998
+ topFailureType: breakdown.failedToolCallCount > 0 ? "unknown" : void 0,
1999
+ commandIssueSamples: [],
2000
+ fileReadCount: breakdown.fileReadCount,
2001
+ fileEditCount: breakdown.fileEditCount,
2002
+ promptTimeline: breakdown.promptTimeline,
2003
+ sessionOutcome: inferOutcome2(breakdown)
2004
+ };
2005
+ const cost = hasTokenBreakdown ? calculateCostUsd(usage, options.pricing) : void 0;
2006
+ return {
2007
+ ...usage,
2008
+ estimatedCostUsd: cost,
2009
+ warnings: cost === void 0 ? [...usage.warnings, "unknown_pricing"] : usage.warnings
2010
+ };
2011
+ }
2012
+ function readClaudeBreakdown(filePath) {
2013
+ const breakdown = emptyBreakdown2();
2014
+ try {
2015
+ const text = fs5.readFileSync(filePath, "utf8");
2016
+ const parse = parseJsonLines2(text);
2017
+ breakdown.rawEventCount = parse.records.length;
2018
+ breakdown.parseErrors = parse.errors;
2019
+ breakdown.parseStatus = parse.errors.length ? parse.records.length ? "partial" : "failed" : "ok";
2020
+ for (const record of parse.records) {
2021
+ applyClaudeRecord(breakdown, record);
2022
+ }
2023
+ breakdown.totalTokens = breakdown.inputTokens + breakdown.outputTokens;
2024
+ } catch (error) {
2025
+ const message = `unable_to_read_session_file:${errorMessage2(error)}`;
2026
+ breakdown.parseStatus = "failed";
2027
+ breakdown.parseErrors.push(message);
2028
+ breakdown.warnings.push(message);
2029
+ }
2030
+ return breakdown;
2031
+ }
2032
+ function applyClaudeRecord(breakdown, record) {
2033
+ const timestamp3 = dateValue2(record.timestamp);
2034
+ if (timestamp3) {
2035
+ breakdown.startedAt = minDate(breakdown.startedAt, timestamp3);
2036
+ breakdown.endedAt = maxDate(breakdown.endedAt, timestamp3);
2037
+ }
2038
+ breakdown.id ??= stringValue3(record.sessionId);
2039
+ breakdown.cwd ??= stringValue3(record.cwd);
2040
+ breakdown.gitBranch ??= stringValue3(record.gitBranch);
2041
+ breakdown.entrypoint ??= stringValue3(record.entrypoint);
2042
+ breakdown.version ??= stringValue3(record.version);
2043
+ breakdown.title ??= stringValue3(record.aiTitle);
2044
+ const message = firstObject4(record.message);
2045
+ const role = stringValue3(message?.role) ?? stringValue3(record.type);
2046
+ if (role === "user") breakdown.userPromptCount += 1;
2047
+ if (role === "assistant") breakdown.assistantMessageCount += 1;
2048
+ if (role === "user" || role === "assistant") breakdown.messageCount += 1;
2049
+ if (message) {
2050
+ applyPromptTimeline2(breakdown, role, message.content, timestamp3);
2051
+ const usage = firstObject4(message.usage);
2052
+ const usageTokens = usage ? usageTotal(usage) : 0;
2053
+ const model = normalizeClaudeModel(message.model);
2054
+ if (model && (usageTokens > 0 || !breakdown.model)) breakdown.model = model;
2055
+ if (usage && usageTokens > 0) applyUsage(breakdown, usage);
2056
+ applyContentSignals(breakdown, message.content);
2057
+ }
2058
+ if (looksFailedToolResult(record.toolUseResult)) breakdown.failedToolCallCount += 1;
2059
+ }
2060
+ function applyUsage(breakdown, usage) {
2061
+ const input = numberValue(usage.input_tokens);
2062
+ const cacheRead = numberValue(usage.cache_read_input_tokens);
2063
+ const cacheCreation = numberValue(usage.cache_creation_input_tokens);
2064
+ const output = numberValue(usage.output_tokens);
2065
+ breakdown.inputTokens += input + cacheRead + cacheCreation;
2066
+ breakdown.cachedInputTokens += cacheRead;
2067
+ breakdown.cacheCreationInputTokens += cacheCreation;
2068
+ breakdown.outputTokens += output;
2069
+ breakdown.usageCount += 1;
2070
+ }
2071
+ function usageTotal(usage) {
2072
+ return numberValue(usage.input_tokens) + numberValue(usage.cache_read_input_tokens) + numberValue(usage.cache_creation_input_tokens) + numberValue(usage.output_tokens);
2073
+ }
2074
+ function applyContentSignals(breakdown, content) {
2075
+ if (!Array.isArray(content)) return;
2076
+ for (const item of content) {
2077
+ const object = firstObject4(item);
2078
+ if (!object || object.type !== "tool_use") continue;
2079
+ breakdown.toolCallCount += 1;
2080
+ const name = stringValue3(object.name)?.toLowerCase() ?? "";
2081
+ if (name === "bash") breakdown.shellCommandCount += 1;
2082
+ if (name === "read") breakdown.fileReadCount += 1;
2083
+ if (["edit", "multiedit", "write", "notebookedit"].includes(name)) breakdown.fileEditCount += 1;
2084
+ }
2085
+ }
2086
+ function parseJsonLines2(text) {
2087
+ const records = [];
2088
+ const errors = [];
2089
+ text.split(/\r?\n/).map((line, index) => ({ line: line.trim(), index })).filter(({ line }) => Boolean(line)).forEach(({ line, index }) => {
2090
+ try {
2091
+ const parsed = JSON.parse(line);
2092
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
2093
+ records.push(parsed);
2094
+ }
2095
+ } catch (error) {
2096
+ errors.push(`jsonl_line_${index + 1}:${errorMessage2(error)}`);
2097
+ }
2098
+ });
2099
+ return { records, errors };
2100
+ }
2101
+ function emptyBreakdown2() {
2102
+ return {
2103
+ id: void 0,
2104
+ title: void 0,
2105
+ cwd: void 0,
2106
+ gitBranch: void 0,
2107
+ entrypoint: void 0,
2108
+ version: void 0,
2109
+ startedAt: void 0,
2110
+ endedAt: void 0,
2111
+ model: void 0,
2112
+ inputTokens: 0,
2113
+ cachedInputTokens: 0,
2114
+ cacheCreationInputTokens: 0,
2115
+ outputTokens: 0,
2116
+ totalTokens: 0,
2117
+ usageCount: 0,
2118
+ messageCount: 0,
2119
+ userPromptCount: 0,
2120
+ assistantMessageCount: 0,
2121
+ toolCallCount: 0,
2122
+ shellCommandCount: 0,
2123
+ failedToolCallCount: 0,
2124
+ fileReadCount: 0,
2125
+ fileEditCount: 0,
2126
+ rawEventCount: 0,
2127
+ parseStatus: "ok",
2128
+ parseErrors: [],
2129
+ promptTimeline: [],
2130
+ warnings: []
2131
+ };
2132
+ }
2133
+ function applyPromptTimeline2(breakdown, role, content, timestamp3) {
2134
+ if (breakdown.promptTimeline.length >= 80 || role !== "user" && role !== "assistant") return;
2135
+ const text = extractClaudeMessageText(content);
2136
+ if (!text) return;
2137
+ breakdown.promptTimeline.push({
2138
+ role,
2139
+ text: normalizeTimelineText2(text),
2140
+ timestamp: timestamp3
2141
+ });
2142
+ }
2143
+ function extractClaudeMessageText(content) {
2144
+ if (typeof content === "string" && content.trim()) return content;
2145
+ if (!Array.isArray(content)) return void 0;
2146
+ const parts = content.flatMap((item) => {
2147
+ if (typeof item === "string") return [item];
2148
+ const object = firstObject4(item);
2149
+ if (!object || object.type !== "text") return [];
2150
+ const text = stringValue3(object.text);
2151
+ return text ? [text] : [];
2152
+ }).filter((item) => item.trim());
2153
+ return parts.length ? parts.join("\n") : void 0;
2154
+ }
2155
+ function normalizeTimelineText2(text) {
2156
+ return text.replace(/\s+/g, " ").trim().slice(0, 1e3);
2157
+ }
2158
+ function inferProjectPath(projectDir, projectsPath) {
2159
+ const name = path5.basename(projectDir);
2160
+ if (name.startsWith("-")) {
2161
+ return `${path5.sep}${name.slice(1).split("-").filter(Boolean).join(path5.sep)}`;
2162
+ }
2163
+ const relative = path5.relative(projectsPath, projectDir);
2164
+ return relative && !relative.startsWith("..") ? path5.join(projectsPath, relative) : projectDir;
2165
+ }
2166
+ function inferOutcome2(breakdown) {
2167
+ if (breakdown.parseStatus === "failed") return "failed";
2168
+ if (breakdown.failedToolCallCount > 0) return "partial";
2169
+ if (breakdown.fileEditCount > 0) return "completed";
2170
+ if (breakdown.shellCommandCount > 0) return "setup_debugging";
2171
+ if (breakdown.userPromptCount > 0 || breakdown.assistantMessageCount > 0) return "research_only";
2172
+ return "unknown";
2173
+ }
2174
+ function claudeSurface(entrypoint) {
2175
+ const value = entrypoint?.toLowerCase() ?? "";
2176
+ if (value.includes("vscode")) return "vscode_extension";
2177
+ if (value.includes("local-agent")) return "local_agent";
2178
+ return "terminal_cli";
2179
+ }
2180
+ function claudeSourceAppLabel(entrypoint) {
2181
+ const value = entrypoint?.toLowerCase() ?? "";
2182
+ if (value.includes("vscode")) return "VS Code";
2183
+ if (value.includes("local-agent")) return "Claude desktop local agent";
2184
+ if (value === "cli" || value.includes("terminal")) return "Terminal";
2185
+ return "Claude Code";
2186
+ }
2187
+ function defaultClaudeHome() {
2188
+ return process.env.CLAUDE_CONFIG_DIR?.split(",").map((item) => item.trim()).filter(Boolean)[0] ?? path5.join(os3.homedir(), ".claude");
2189
+ }
2190
+ function defaultDesktopSessionRoots() {
2191
+ const home = os3.homedir();
2192
+ const xdg = process.env.XDG_CONFIG_HOME || path5.join(home, ".config");
2193
+ return [
2194
+ path5.join(home, "Library", "Application Support", "Claude", "local-agent-mode-sessions"),
2195
+ path5.join(xdg, "Claude", "local-agent-mode-sessions")
2196
+ ];
2197
+ }
2198
+ function uniqueSessionFiles(files) {
2199
+ const seen = /* @__PURE__ */ new Set();
2200
+ return files.filter((file) => {
2201
+ const key = path5.resolve(file.filePath);
2202
+ if (seen.has(key)) return false;
2203
+ seen.add(key);
2204
+ return true;
2205
+ });
2206
+ }
2207
+ function unique3(values) {
2208
+ return [...new Set(values)];
2209
+ }
2210
+ function countHistoryEntries(historyPath) {
2211
+ if (!fs5.existsSync(historyPath)) return 0;
2212
+ let entryCount = 0;
2213
+ try {
2214
+ for (const line of fs5.readFileSync(historyPath, "utf8").split(/\r?\n/).filter(Boolean)) {
2215
+ try {
2216
+ JSON.parse(line);
2217
+ entryCount += 1;
2218
+ } catch {
2219
+ continue;
2220
+ }
2221
+ }
2222
+ } catch {
2223
+ return entryCount;
2224
+ }
2225
+ return entryCount;
2226
+ }
2227
+ function firstObject4(value) {
2228
+ return value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
2229
+ }
2230
+ function stringValue3(value) {
2231
+ return typeof value === "string" && value.length > 0 ? value : void 0;
2232
+ }
2233
+ function numberValue(value) {
2234
+ if (typeof value === "number" && Number.isFinite(value)) return value;
2235
+ if (typeof value === "string" && Number.isFinite(Number(value))) return Number(value);
2236
+ return 0;
2237
+ }
2238
+ function normalizeClaudeModel(value) {
2239
+ const model = stringValue3(value);
2240
+ if (!model || model === "<synthetic>") return void 0;
2241
+ return model;
2242
+ }
2243
+ function dateValue2(value) {
2244
+ if (typeof value !== "string") return void 0;
2245
+ const date = new Date(value);
2246
+ return Number.isNaN(date.getTime()) ? void 0 : date.toISOString();
2247
+ }
2248
+ function minDate(current, candidate) {
2249
+ return !current || candidate < current ? candidate : current;
2250
+ }
2251
+ function maxDate(current, candidate) {
2252
+ return !current || candidate > current ? candidate : current;
2253
+ }
2254
+ function durationMs2(startedAt, endedAt) {
2255
+ if (!startedAt || !endedAt) return void 0;
2256
+ const start = new Date(startedAt).getTime();
2257
+ const end = new Date(endedAt).getTime();
2258
+ return Number.isFinite(start) && Number.isFinite(end) && end >= start ? end - start : void 0;
2259
+ }
2260
+ function looksFailedToolResult(value) {
2261
+ const object = firstObject4(value);
2262
+ if (!object) return false;
2263
+ if (object.is_error === true || object.error === true) return true;
2264
+ const exitCode = numberValue(object.exit_code ?? object.exitCode ?? object.status_code ?? object.statusCode);
2265
+ if (exitCode !== 0) return true;
2266
+ const status = stringValue3(object.status)?.toLowerCase();
2267
+ return status === "failed" || status === "error";
2268
+ }
2269
+ function errorMessage2(error) {
2270
+ return error instanceof Error ? error.message : String(error);
2271
+ }
2272
+
2273
+ // packages/core/src/sources/index.ts
2274
+ function scanUsageSources(options) {
2275
+ const codex = scanCodex({
2276
+ ...options.codexHome ? { codexHome: options.codexHome } : {},
2277
+ ...options.config ? { config: options.config } : {},
2278
+ pricing: options.pricing
2279
+ });
2280
+ const claude = scanClaude({
2281
+ ...options.claudeHome ? { claudeHome: options.claudeHome } : {},
2282
+ ...options.config ? { config: options.config } : {},
2283
+ pricing: options.pricing
2284
+ });
2285
+ return {
2286
+ sources: [codex.source, claude.source],
2287
+ sourceStats: [codex.stats, claude.stats],
2288
+ sessions: [...codex.sessions, ...claude.sessions]
2289
+ };
2290
+ }
2291
+
2292
+ // apps/server/src/data.ts
2293
+ import fs6 from "node:fs";
1709
2294
  var scanCacheTtlMs = 1e4;
1710
2295
  var scanCache;
1711
2296
  function readDashboardData(filters = {}) {
@@ -1722,11 +2307,11 @@ function readRawUsageData() {
1722
2307
  }
1723
2308
  const { config, warnings: configWarnings } = loadConfigWithWarnings();
1724
2309
  const pricing = loadPricingTable(resolvePricingPath(config));
1725
- const codex = scanCodex({ config, pricing });
2310
+ const scan = scanUsageSources({ config, pricing });
1726
2311
  const data = {
1727
- sources: [{ ...codex.source, warnings: [...codex.source.warnings, ...configWarnings] }],
1728
- sourceStats: [codex.stats],
1729
- sessions: codex.sessions
2312
+ sources: scan.sources.map((source) => ({ ...source, warnings: [...source.warnings, ...configWarnings] })),
2313
+ sourceStats: scan.sourceStats,
2314
+ sessions: scan.sessions
1730
2315
  };
1731
2316
  scanCache = { cwd, data, expiresAt: Date.now() + scanCacheTtlMs };
1732
2317
  return data;
@@ -1767,8 +2352,8 @@ function writePricingData(models) {
1767
2352
  }
1768
2353
  function clearRepoSpendLocalData() {
1769
2354
  const target = repospendHome();
1770
- const removed = fs5.existsSync(target);
1771
- fs5.rmSync(target, { recursive: true, force: true });
2355
+ const removed = fs6.existsSync(target);
2356
+ fs6.rmSync(target, { recursive: true, force: true });
1772
2357
  clearScanCache();
1773
2358
  return { path: target, removed };
1774
2359
  }
@@ -1791,12 +2376,621 @@ function runtimeCwd() {
1791
2376
  }
1792
2377
 
1793
2378
  // apps/server/src/server.ts
1794
- import fs6 from "node:fs";
1795
- import path5 from "node:path";
1796
- import { fileURLToPath } from "node:url";
2379
+ import fs7 from "node:fs";
2380
+ import path6 from "node:path";
2381
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
1797
2382
  import fastifyStatic from "@fastify/static";
1798
2383
  import Fastify from "fastify";
1799
2384
 
2385
+ // apps/server/src/demo-data.ts
2386
+ var baseDate = "2026-05-18";
2387
+ var demoPricing = {
2388
+ ...defaultPricing,
2389
+ "gpt-5.3-codex": { inputPerMillion: 1.75, cachedInputPerMillion: 0.175, outputPerMillion: 14, reasoningOutputPerMillion: 14 },
2390
+ "gpt-5.3-codex-spark": { inputPerMillion: 0.75, cachedInputPerMillion: 0.075, outputPerMillion: 4.5, reasoningOutputPerMillion: 4.5 },
2391
+ "claude-haiku-4-5-20251001": { inputPerMillion: 1, cacheCreationInputPerMillion: 1.25, cachedInputPerMillion: 0.1, outputPerMillion: 5 }
2392
+ };
2393
+ function demoModeEnabled() {
2394
+ return process.env.REPOSPEND_DEMO_DATA === "lotr";
2395
+ }
2396
+ function readDemoDashboardData(filters = {}) {
2397
+ return buildDashboardSnapshot({
2398
+ sources: demoSources,
2399
+ sourceStats: demoSourceStats,
2400
+ sessions: demoSessions,
2401
+ filters
2402
+ });
2403
+ }
2404
+ function readDemoRtkGain() {
2405
+ return {
2406
+ available: true,
2407
+ rtkDetected: true,
2408
+ rtkVersion: "rtk-demo 1.0.0",
2409
+ rtkCodexHookStatus: "active",
2410
+ rtkLastActivityAt: "2026-05-18T18:42:00.000Z",
2411
+ rtkDataSource: "fictional demo data",
2412
+ totalCommands: 248,
2413
+ inputTokens: "18.4M",
2414
+ outputTokens: "2.6M",
2415
+ tokensSaved: "5.8M",
2416
+ savedPercent: 23.9,
2417
+ totalExecTime: "41m",
2418
+ averageExecTime: "9.9s",
2419
+ rtkTokensSaved: "5.8M",
2420
+ rtkSavingsRate: 23.9,
2421
+ rtkCommandsProcessed: 248,
2422
+ rtkAverageCommandRuntime: "9.9s",
2423
+ rtkTopSavingsCommands: [
2424
+ { command: "rg", count: 74, saved: "1.9M", averageSavedPercent: 31.2, time: "8m" },
2425
+ { command: "pnpm test", count: 18, saved: "840K", averageSavedPercent: 17.4, time: "12m" },
2426
+ { command: "git diff", count: 39, saved: "620K", averageSavedPercent: 26.8, time: "2m" }
2427
+ ],
2428
+ rtkRecentActivity: [
2429
+ "rg council-of-elrond planner",
2430
+ "pnpm test --filter gondor-api",
2431
+ "git diff -- shire-mobile"
2432
+ ],
2433
+ rtkDiscoverAvailable: true,
2434
+ rtkDiscoverError: void 0,
2435
+ rtkDiscoverSummary: "Total: 248 fictional command events scanned.",
2436
+ rtkCoverageGaps: [
2437
+ { command: "cat", count: 21, rtkEquivalent: "rtk sed", status: "easy win", estimatedSavings: "410K" },
2438
+ { command: "grep", count: 13, rtkEquivalent: "rtk rg", status: "easy win", estimatedSavings: "360K" }
2439
+ ],
2440
+ rtkUnhandledCommands: [
2441
+ { command: "palantirctl inspect", count: 5, example: "palantirctl inspect eye-of-sauron-alerts" }
2442
+ ],
2443
+ topCommands: [
2444
+ { command: "rg", count: 74, saved: "1.9M", averageSavedPercent: 31.2, time: "8m" },
2445
+ { command: "git", count: 46, saved: "720K", averageSavedPercent: 18.3, time: "3m" },
2446
+ { command: "pnpm", count: 31, saved: "1.1M", averageSavedPercent: 16.8, time: "17m" }
2447
+ ],
2448
+ recentCommands: [
2449
+ "rtk rg auth gates-of-moria",
2450
+ "rtk pnpm test --filter palantir-observability",
2451
+ "rtk git diff -- one-ring-infra"
2452
+ ],
2453
+ raw: "RepoSpend LOTR demo RTK output",
2454
+ error: void 0
2455
+ };
2456
+ }
2457
+ var demoSources = [
2458
+ {
2459
+ id: "codex",
2460
+ label: "Codex",
2461
+ available: true,
2462
+ paths: ["/demo/middle-earth/codex/state.sqlite", "/demo/middle-earth/codex/sessions"],
2463
+ warnings: []
2464
+ },
2465
+ {
2466
+ id: "claude",
2467
+ label: "Claude Code",
2468
+ available: true,
2469
+ paths: ["/demo/middle-earth/claude/projects"],
2470
+ warnings: []
2471
+ }
2472
+ ];
2473
+ var demoSourceStats = [
2474
+ {
2475
+ sourceId: "codex",
2476
+ sourceLabel: "Codex",
2477
+ homePath: "/demo/middle-earth/codex",
2478
+ statePath: "/demo/middle-earth/codex/state.sqlite",
2479
+ sessionsPath: "/demo/middle-earth/codex/sessions",
2480
+ stateExists: true,
2481
+ sessionsExists: true,
2482
+ sessionFileCount: 15,
2483
+ sessionsImported: 15,
2484
+ parseFailureCount: 0,
2485
+ unreadableFileCount: 0,
2486
+ malformedFileCount: 0,
2487
+ lastScannedAt: "2026-05-18T19:00:00.000Z"
2488
+ },
2489
+ {
2490
+ sourceId: "claude",
2491
+ sourceLabel: "Claude Code",
2492
+ homePath: "/demo/middle-earth/claude",
2493
+ projectsPath: "/demo/middle-earth/claude/projects",
2494
+ projectsExists: true,
2495
+ sessionFileCount: 7,
2496
+ sessionsImported: 7,
2497
+ parseFailureCount: 0,
2498
+ unreadableFileCount: 0,
2499
+ malformedFileCount: 0,
2500
+ projectDirCount: 4,
2501
+ lastScannedAt: "2026-05-18T19:01:00.000Z"
2502
+ }
2503
+ ];
2504
+ var demoInputs = [
2505
+ {
2506
+ id: "lotr-session-001",
2507
+ repo: "one-ring-infra",
2508
+ title: "Why is one-ring-infra spending 3x more than shire-mobile?",
2509
+ sourceClient: "codex",
2510
+ sourceApp: "Codex app",
2511
+ model: "GPT-5.5",
2512
+ provider: "openai",
2513
+ hour: 9,
2514
+ minute: 10,
2515
+ durationMinutes: 84,
2516
+ inputTokens: 285e4,
2517
+ cachedInputTokens: 32e4,
2518
+ outputTokens: 41e4,
2519
+ reasoningTokens: 26e4,
2520
+ prompts: 9,
2521
+ replies: 18,
2522
+ tools: 86,
2523
+ commands: 34,
2524
+ edits: 3,
2525
+ reads: 92,
2526
+ outcome: "completed",
2527
+ branch: "audit/token-fire",
2528
+ importantFailures: 2,
2529
+ harmlessNonZero: 4,
2530
+ repeatedClusters: 1,
2531
+ topFailureType: "test",
2532
+ warnings: ["repeated_error:test-suite"],
2533
+ issueSamples: [
2534
+ issue("pnpm test --filter one-ring-infra", "test", "warning", "medium", "Repeated failing shard while auditing token spend."),
2535
+ issue("terraform plan -var region=mordor", "deploy", "critical", "high", "Infrastructure plan failed before the safer dry-run path was found.")
2536
+ ]
2537
+ },
2538
+ {
2539
+ id: "lotr-session-002",
2540
+ repo: "mordor-ci",
2541
+ title: "Reduce token burn in mordor-ci",
2542
+ sourceClient: "codex",
2543
+ sourceApp: "Codex app",
2544
+ model: "GPT-5.4",
2545
+ provider: "openai",
2546
+ hour: 10,
2547
+ minute: 35,
2548
+ durationMinutes: 62,
2549
+ inputTokens: 178e4,
2550
+ cachedInputTokens: 95e3,
2551
+ outputTokens: 28e4,
2552
+ reasoningTokens: 185e3,
2553
+ prompts: 7,
2554
+ replies: 12,
2555
+ tools: 58,
2556
+ commands: 28,
2557
+ edits: 2,
2558
+ reads: 51,
2559
+ outcome: "partial",
2560
+ branch: "ci/doom-cache",
2561
+ importantFailures: 3,
2562
+ harmlessNonZero: 1,
2563
+ repeatedClusters: 2,
2564
+ topFailureType: "build",
2565
+ warnings: ["low_cache_rate"],
2566
+ issueSamples: [
2567
+ issue("pnpm build --filter mordor-ci", "build", "critical", "high", "Build failed repeatedly after the Mount Doom runner image changed.")
2568
+ ]
2569
+ },
2570
+ {
2571
+ id: "lotr-session-003",
2572
+ repo: "shire-mobile",
2573
+ title: "Investigate token spike after second breakfast",
2574
+ sourceClient: "claude",
2575
+ sourceApp: "Claude Code",
2576
+ sourceAppRaw: "claude-code",
2577
+ model: "claude-opus-4-7",
2578
+ provider: "anthropic",
2579
+ hour: 11,
2580
+ minute: 5,
2581
+ durationMinutes: 46,
2582
+ inputTokens: 142e4,
2583
+ cachedInputTokens: 21e4,
2584
+ outputTokens: 185e3,
2585
+ reasoningTokens: 0,
2586
+ prompts: 5,
2587
+ replies: 9,
2588
+ tools: 31,
2589
+ commands: 12,
2590
+ edits: 0,
2591
+ reads: 44,
2592
+ outcome: "research_only",
2593
+ branch: "analytics/second-breakfast",
2594
+ warnings: [],
2595
+ detectedSurface: "terminal_cli",
2596
+ tokenConfidence: "high"
2597
+ },
2598
+ {
2599
+ id: "lotr-session-004",
2600
+ repo: "palantir-observability",
2601
+ title: "Fix race condition in the palantir event stream",
2602
+ sourceClient: "codex",
2603
+ sourceApp: "VS Code",
2604
+ sourceAppRaw: "vscode",
2605
+ model: "GPT-5.4",
2606
+ provider: "openai",
2607
+ hour: 12,
2608
+ minute: 20,
2609
+ durationMinutes: 73,
2610
+ inputTokens: 126e4,
2611
+ cachedInputTokens: 455e3,
2612
+ outputTokens: 21e4,
2613
+ reasoningTokens: 17e4,
2614
+ prompts: 6,
2615
+ replies: 14,
2616
+ tools: 64,
2617
+ commands: 24,
2618
+ edits: 6,
2619
+ reads: 66,
2620
+ outcome: "completed",
2621
+ branch: "stream/palantir-race",
2622
+ importantFailures: 1,
2623
+ harmlessNonZero: 3,
2624
+ topFailureType: "test",
2625
+ issueSamples: [
2626
+ issue("pnpm test palantir-stream.spec.ts", "test", "warning", "medium", "One flaky stream-order assertion needed a deterministic clock.")
2627
+ ]
2628
+ },
2629
+ {
2630
+ id: "lotr-session-005",
2631
+ repo: "gondor-api",
2632
+ title: "Add auth flow for gates-of-moria",
2633
+ sourceClient: "codex",
2634
+ sourceApp: "Codex app",
2635
+ model: "GPT-5.4-Mini",
2636
+ provider: "openai",
2637
+ hour: 13,
2638
+ minute: 0,
2639
+ durationMinutes: 58,
2640
+ inputTokens: 86e4,
2641
+ cachedInputTokens: 28e4,
2642
+ outputTokens: 13e4,
2643
+ reasoningTokens: 74e3,
2644
+ prompts: 5,
2645
+ replies: 10,
2646
+ tools: 42,
2647
+ commands: 18,
2648
+ edits: 8,
2649
+ reads: 39,
2650
+ outcome: "completed",
2651
+ branch: "auth/mellon"
2652
+ },
2653
+ {
2654
+ id: "lotr-session-006",
2655
+ repo: "rivendell-dashboard",
2656
+ title: "Refactor the council-of-elrond planner",
2657
+ sourceClient: "claude",
2658
+ sourceApp: "Claude desktop local agent",
2659
+ sourceAppRaw: "claude-desktop-local-agent",
2660
+ model: "claude-sonnet-4-6",
2661
+ provider: "anthropic",
2662
+ hour: 14,
2663
+ minute: 15,
2664
+ durationMinutes: 67,
2665
+ inputTokens: 92e4,
2666
+ cachedInputTokens: 36e4,
2667
+ outputTokens: 15e4,
2668
+ reasoningTokens: 0,
2669
+ prompts: 6,
2670
+ replies: 11,
2671
+ tools: 37,
2672
+ commands: 15,
2673
+ edits: 7,
2674
+ reads: 48,
2675
+ outcome: "completed",
2676
+ branch: "planner/elrond-v2"
2677
+ },
2678
+ {
2679
+ id: "lotr-session-007",
2680
+ repo: "palantir-observability",
2681
+ title: "Optimize observability for eye-of-sauron alerts",
2682
+ sourceClient: "codex",
2683
+ sourceApp: "Codex app",
2684
+ model: "GPT-5.3-Codex",
2685
+ provider: "openai",
2686
+ hour: 15,
2687
+ minute: 40,
2688
+ durationMinutes: 51,
2689
+ inputTokens: 74e4,
2690
+ cachedInputTokens: 29e4,
2691
+ outputTokens: 96e3,
2692
+ reasoningTokens: 88e3,
2693
+ prompts: 4,
2694
+ replies: 8,
2695
+ tools: 44,
2696
+ commands: 17,
2697
+ edits: 5,
2698
+ reads: 36,
2699
+ outcome: "completed",
2700
+ branch: "alerts/eye-slo",
2701
+ harmlessNonZero: 2,
2702
+ exploratoryMisses: 3
2703
+ },
2704
+ {
2705
+ id: "lotr-session-008",
2706
+ repo: "mordor-ci",
2707
+ title: "Clean up dead code in orc-deployment-worker",
2708
+ sourceClient: "codex",
2709
+ sourceApp: "Terminal",
2710
+ sourceAppRaw: "terminal",
2711
+ model: "GPT-5.3-Codex-Spark",
2712
+ provider: "openai",
2713
+ hour: 16,
2714
+ minute: 10,
2715
+ durationMinutes: 39,
2716
+ inputTokens: 54e4,
2717
+ cachedInputTokens: 18e4,
2718
+ outputTokens: 84e3,
2719
+ reasoningTokens: 64e3,
2720
+ prompts: 3,
2721
+ replies: 7,
2722
+ tools: 35,
2723
+ commands: 14,
2724
+ edits: 9,
2725
+ reads: 31,
2726
+ outcome: "completed",
2727
+ branch: "workers/orc-cleanup",
2728
+ detectedSurface: "terminal_cli"
2729
+ },
2730
+ {
2731
+ id: "lotr-session-009",
2732
+ repo: "minas-tirith-admin",
2733
+ title: "Tighten city-gate roles before the beacons launch",
2734
+ sourceClient: "claude",
2735
+ sourceApp: "Claude Code",
2736
+ sourceAppRaw: "claude-code",
2737
+ model: "claude-haiku-4-5-20251001",
2738
+ provider: "anthropic",
2739
+ hour: 17,
2740
+ minute: 5,
2741
+ durationMinutes: 32,
2742
+ inputTokens: 39e4,
2743
+ cachedInputTokens: 12e4,
2744
+ outputTokens: 61e3,
2745
+ reasoningTokens: 0,
2746
+ prompts: 4,
2747
+ replies: 6,
2748
+ tools: 25,
2749
+ commands: 9,
2750
+ edits: 5,
2751
+ reads: 23,
2752
+ outcome: "completed",
2753
+ branch: "rbac/beacons"
2754
+ },
2755
+ {
2756
+ id: "lotr-session-010",
2757
+ repo: "fellowship-docs",
2758
+ title: "Document why nobody should commit the ring config",
2759
+ sourceClient: "codex",
2760
+ sourceApp: "Codex app",
2761
+ model: "GPT-5.2",
2762
+ provider: "openai",
2763
+ hour: 18,
2764
+ minute: 25,
2765
+ durationMinutes: 24,
2766
+ inputTokens: 21e4,
2767
+ cachedInputTokens: 75e3,
2768
+ outputTokens: 38e3,
2769
+ reasoningTokens: 22e3,
2770
+ prompts: 2,
2771
+ replies: 5,
2772
+ tools: 18,
2773
+ commands: 6,
2774
+ edits: 4,
2775
+ reads: 16,
2776
+ outcome: "completed",
2777
+ branch: "docs/ring-config"
2778
+ },
2779
+ {
2780
+ id: "lotr-session-011",
2781
+ repo: "one-ring-infra",
2782
+ title: "Cap runaway apply retries in black-gate provisioner",
2783
+ sourceClient: "codex",
2784
+ sourceApp: "Terminal",
2785
+ sourceAppRaw: "terminal",
2786
+ model: "GPT-5.4",
2787
+ provider: "openai",
2788
+ hour: 18,
2789
+ minute: 55,
2790
+ durationMinutes: 44,
2791
+ inputTokens: 98e4,
2792
+ cachedInputTokens: 14e4,
2793
+ outputTokens: 148e3,
2794
+ reasoningTokens: 96e3,
2795
+ prompts: 4,
2796
+ replies: 9,
2797
+ tools: 41,
2798
+ commands: 21,
2799
+ edits: 4,
2800
+ reads: 45,
2801
+ outcome: "completed",
2802
+ branch: "infra/black-gate-retries",
2803
+ importantFailures: 1,
2804
+ harmlessNonZero: 2,
2805
+ topFailureType: "deploy",
2806
+ detectedSurface: "terminal_cli",
2807
+ issueSamples: [
2808
+ issue("terraform apply -auto-approve", "deploy", "critical", "high", "The demo caught a risky retry loop and switched to a dry-run plan.")
2809
+ ]
2810
+ },
2811
+ {
2812
+ id: "lotr-session-012",
2813
+ repo: "rivendell-dashboard",
2814
+ title: "Make elvish table sorting less mysterious",
2815
+ sourceClient: "codex",
2816
+ sourceApp: "VS Code",
2817
+ sourceAppRaw: "vscode",
2818
+ model: "GPT-5.4-Mini",
2819
+ provider: "openai",
2820
+ hour: 19,
2821
+ minute: 20,
2822
+ durationMinutes: 29,
2823
+ inputTokens: 31e4,
2824
+ cachedInputTokens: 13e4,
2825
+ outputTokens: 49e3,
2826
+ reasoningTokens: 35e3,
2827
+ prompts: 3,
2828
+ replies: 6,
2829
+ tools: 22,
2830
+ commands: 8,
2831
+ edits: 3,
2832
+ reads: 18,
2833
+ outcome: "completed",
2834
+ branch: "tables/less-elvish"
2835
+ },
2836
+ {
2837
+ id: "lotr-session-013",
2838
+ repo: "gondor-api",
2839
+ title: "Trim noisy auth logs from white-tree callbacks",
2840
+ sourceClient: "codex",
2841
+ sourceApp: "Codex app",
2842
+ model: "GPT-5.3-Codex",
2843
+ provider: "openai",
2844
+ hour: 20,
2845
+ minute: 5,
2846
+ durationMinutes: 35,
2847
+ inputTokens: 42e4,
2848
+ cachedInputTokens: 16e4,
2849
+ outputTokens: 66e3,
2850
+ reasoningTokens: 54e3,
2851
+ prompts: 3,
2852
+ replies: 6,
2853
+ tools: 28,
2854
+ commands: 11,
2855
+ edits: 4,
2856
+ reads: 27,
2857
+ outcome: "completed",
2858
+ branch: "logs/white-tree"
2859
+ },
2860
+ {
2861
+ id: "lotr-session-014",
2862
+ repo: "shire-mobile",
2863
+ title: "Make pantry sync work offline in Bag End",
2864
+ sourceClient: "claude",
2865
+ sourceApp: "Claude Code",
2866
+ sourceAppRaw: "claude-code",
2867
+ model: "claude-haiku-4-5-20251001",
2868
+ provider: "anthropic",
2869
+ hour: 20,
2870
+ minute: 45,
2871
+ durationMinutes: 28,
2872
+ inputTokens: 26e4,
2873
+ cachedInputTokens: 9e4,
2874
+ outputTokens: 42e3,
2875
+ reasoningTokens: 0,
2876
+ prompts: 3,
2877
+ replies: 5,
2878
+ tools: 19,
2879
+ commands: 7,
2880
+ edits: 5,
2881
+ reads: 17,
2882
+ outcome: "completed",
2883
+ branch: "offline/bag-end"
2884
+ }
2885
+ ];
2886
+ var demoSessions = demoInputs.map(toSession);
2887
+ function toSession(input) {
2888
+ const start = timestamp2(input.hour, input.minute);
2889
+ const end = new Date(new Date(start).getTime() + input.durationMinutes * 6e4).toISOString();
2890
+ const repoRoot = `/demo/middle-earth/${input.repo}`;
2891
+ const totalTokens = input.inputTokens + input.outputTokens + input.reasoningTokens;
2892
+ const importantFailures = input.importantFailures ?? 0;
2893
+ const harmlessNonZero = input.harmlessNonZero ?? 0;
2894
+ const exploratoryMisses = input.exploratoryMisses ?? 0;
2895
+ const repeatedClusters = input.repeatedClusters ?? 0;
2896
+ const failedToolCalls = importantFailures + repeatedClusters;
2897
+ const nonZeroCommandEvents = importantFailures + harmlessNonZero + exploratoryMisses;
2898
+ const cost = calculateCostUsd({
2899
+ model: input.model,
2900
+ inputTokens: input.inputTokens,
2901
+ cachedInputTokens: input.cachedInputTokens,
2902
+ outputTokens: input.outputTokens,
2903
+ reasoningTokens: input.reasoningTokens
2904
+ }, demoPricing);
2905
+ return {
2906
+ id: input.id,
2907
+ sourceClient: input.sourceClient,
2908
+ sourceApp: input.sourceApp,
2909
+ sourceAppRaw: input.sourceAppRaw ?? input.sourceApp.toLowerCase(),
2910
+ sourcePath: `/demo/middle-earth/${input.sourceClient}/sessions/${input.id}.jsonl`,
2911
+ repoRoot,
2912
+ repoName: input.repo,
2913
+ cwd: `${repoRoot}/worktree`,
2914
+ gitRemoteUrl: `https://example.invalid/middle-earth/${input.repo}.git`,
2915
+ gitBranch: input.branch,
2916
+ title: input.title,
2917
+ startedAt: start,
2918
+ endedAt: end,
2919
+ model: input.model,
2920
+ provider: input.provider,
2921
+ durationMs: input.durationMinutes * 6e4,
2922
+ rawEventCount: input.tools + input.replies + input.prompts + 8,
2923
+ parseStatus: "ok",
2924
+ parseErrors: [],
2925
+ detectedSurface: input.detectedSurface ?? detectedSurfaceForApp(input.sourceApp),
2926
+ surfaceConfidence: "high",
2927
+ surfaceReason: "Demo fixture includes an explicit fictional source app.",
2928
+ promptTimeline: [
2929
+ { role: "user", text: input.title, timestamp: start },
2930
+ { role: "assistant", text: demoReply(input.repo, input.edits, importantFailures), timestamp: timestamp2(input.hour, Math.min(input.minute + 7, 59)) }
2931
+ ],
2932
+ sessionOutcome: input.outcome,
2933
+ inputTokens: input.inputTokens,
2934
+ cachedInputTokens: input.cachedInputTokens,
2935
+ outputTokens: input.outputTokens,
2936
+ reasoningTokens: input.reasoningTokens,
2937
+ reasoningOutputTokens: input.reasoningTokens,
2938
+ totalTokens,
2939
+ tokenAggregationMethod: "direct_usage",
2940
+ tokenConfidence: input.tokenConfidence ?? "high",
2941
+ tokenSnapshotCount: Math.max(2, Math.round(input.durationMinutes / 18)),
2942
+ estimatedCostUsd: cost,
2943
+ messageCount: input.prompts + input.replies,
2944
+ rawTokenTotal: totalTokens,
2945
+ warnings: input.warnings ?? [],
2946
+ userPromptCount: input.prompts,
2947
+ assistantMessageCount: input.replies,
2948
+ toolCallCount: input.tools,
2949
+ shellCommandCount: input.commands,
2950
+ failedToolCallCount: failedToolCalls,
2951
+ nonZeroCommandEvents,
2952
+ importantCommandFailures: importantFailures,
2953
+ harmlessNonZeroEvents: harmlessNonZero,
2954
+ exploratoryMisses,
2955
+ repeatedFailureClusters: repeatedClusters,
2956
+ commandIssueSeverity: importantFailures > 1 ? "critical" : importantFailures > 0 ? "warning" : "none",
2957
+ commandIssueImpact: importantFailures > 1 ? "high" : importantFailures > 0 ? "medium" : "none",
2958
+ topFailureType: input.topFailureType,
2959
+ commandIssueSamples: input.issueSamples ?? [],
2960
+ fileReadCount: input.reads,
2961
+ fileEditCount: input.edits
2962
+ };
2963
+ }
2964
+ function detectedSurfaceForApp(sourceApp) {
2965
+ if (sourceApp === "VS Code") return "vscode_extension";
2966
+ if (sourceApp === "Terminal") return "terminal_cli";
2967
+ if (sourceApp === "Codex app") return "codex_app_cloud";
2968
+ if (sourceApp === "Claude desktop local agent") return "local_agent";
2969
+ return "terminal_cli";
2970
+ }
2971
+ function timestamp2(hour, minute) {
2972
+ return `${baseDate}T${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}:00.000Z`;
2973
+ }
2974
+ function issue(command2, category, severity, impact, reason) {
2975
+ return {
2976
+ command: command2,
2977
+ category,
2978
+ classification: severity === "critical" ? "blocking_failure" : "warning",
2979
+ severity,
2980
+ impact,
2981
+ reason
2982
+ };
2983
+ }
2984
+ function demoReply(repo, edits, importantFailures) {
2985
+ if (importantFailures > 0) {
2986
+ return `Found the noisy path in ${repo}, separated the risky failures from ordinary exploration, and left a short list of follow-up checks.`;
2987
+ }
2988
+ if (edits === 0) {
2989
+ return `Mapped the spend pattern in ${repo} without changing files, which is exactly the kind of research-only trail RepoSpend should make visible.`;
2990
+ }
2991
+ return `Updated ${repo} with focused changes and enough local checks to make the token spend easy to explain.`;
2992
+ }
2993
+
1800
2994
  // apps/server/src/rtk.ts
1801
2995
  import { execFile } from "node:child_process";
1802
2996
  import { promisify } from "node:util";
@@ -1991,8 +3185,10 @@ function createServer(options = {}) {
1991
3185
  const app = Fastify({ logger: false });
1992
3186
  app.get("/api/health", async () => ({ ok: true, app: "RepoSpend", version: readPackageVersion() }));
1993
3187
  app.get("/api/dashboard", async (request) => {
1994
- const data = readDashboardData(parseFilters(request.query));
1995
- const rtkGain = await readRtkGain();
3188
+ const filters = parseFilters(request.query);
3189
+ const demoMode = demoModeEnabled();
3190
+ const data = demoMode ? readDemoDashboardData(filters) : readDashboardData(filters);
3191
+ const rtkGain = demoMode ? readDemoRtkGain() : await readRtkGain();
1996
3192
  return {
1997
3193
  ...data,
1998
3194
  summary: { ...data.summary, skippedZeroTokenSessions: data.skippedZeroTokenSessions },
@@ -2018,9 +3214,9 @@ function createServer(options = {}) {
2018
3214
  return exportCsv();
2019
3215
  });
2020
3216
  if (options.serveWeb !== false) {
2021
- const serverDist = path5.dirname(fileURLToPath(import.meta.url));
2022
- const packagedWebDist = path5.resolve(serverDist, "../web-dist");
2023
- const workspaceWebDist = path5.resolve(serverDist, "../../web/dist");
3217
+ const serverDist = path6.dirname(fileURLToPath2(import.meta.url));
3218
+ const packagedWebDist = path6.resolve(serverDist, "../web-dist");
3219
+ const workspaceWebDist = path6.resolve(serverDist, "../../web/dist");
2024
3220
  const webDist = fsExists(packagedWebDist) ? packagedWebDist : workspaceWebDist;
2025
3221
  if (!fsExists(webDist)) {
2026
3222
  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.`);
@@ -2041,13 +3237,13 @@ function createServer(options = {}) {
2041
3237
  return app;
2042
3238
  }
2043
3239
  function fsExists(inputPath) {
2044
- return Boolean(inputPath) && path5.isAbsolute(inputPath) && fs6.existsSync(inputPath);
3240
+ return Boolean(inputPath) && path6.isAbsolute(inputPath) && fs7.existsSync(inputPath);
2045
3241
  }
2046
3242
  function readPackageVersion() {
2047
- const serverDist = path5.dirname(fileURLToPath(import.meta.url));
2048
- const packagePath = path5.resolve(serverDist, "../package.json");
3243
+ const serverDist = path6.dirname(fileURLToPath2(import.meta.url));
3244
+ const packagePath = path6.resolve(serverDist, "../package.json");
2049
3245
  try {
2050
- const parsed = JSON.parse(fs6.readFileSync(packagePath, "utf8"));
3246
+ const parsed = JSON.parse(fs7.readFileSync(packagePath, "utf8"));
2051
3247
  return typeof parsed.version === "string" ? parsed.version : "unknown";
2052
3248
  } catch {
2053
3249
  return "unknown";
@@ -2080,8 +3276,10 @@ function validatePricingBody(body) {
2080
3276
  outputPerMillion
2081
3277
  };
2082
3278
  const cachedInputPerMillion = finiteNumber(row.cachedInputPerMillion);
3279
+ const cacheCreationInputPerMillion = finiteNumber(row.cacheCreationInputPerMillion);
2083
3280
  const reasoningOutputPerMillion = finiteNumber(row.reasoningOutputPerMillion);
2084
3281
  if (cachedInputPerMillion !== void 0) modelPricing.cachedInputPerMillion = cachedInputPerMillion;
3282
+ if (cacheCreationInputPerMillion !== void 0) modelPricing.cacheCreationInputPerMillion = cacheCreationInputPerMillion;
2085
3283
  if (reasoningOutputPerMillion !== void 0) modelPricing.reasoningOutputPerMillion = reasoningOutputPerMillion;
2086
3284
  if (typeof row.note === "string") modelPricing.note = row.note;
2087
3285
  validated[model] = modelPricing;
@@ -2097,27 +3295,30 @@ function finiteNumber(value) {
2097
3295
  // apps/server/src/cli.ts
2098
3296
  var [command = "serve", ...args] = process.argv.slice(2);
2099
3297
  try {
2100
- if (command === "serve") {
3298
+ if (command === "--help" || command === "-h" || command === "help") {
3299
+ printHelp();
3300
+ } else if (command === "serve") {
2101
3301
  const { url } = await startServer({ port: Number(process.env.REPOSPEND_PORT ?? 2005), serveWeb: true });
2102
3302
  console.log(`RepoSpend dashboard: ${url}`);
2103
3303
  openBrowser(url);
2104
3304
  } else if (command === "scan") {
2105
- const data = readDashboardData();
3305
+ const data = readDashboardData(cliFilters(args));
2106
3306
  console.log(JSON.stringify({ sources: data.sources, sessionCount: data.sessions.length, summary: data.summary }, null, 2));
2107
3307
  } else if (command === "by-repo") {
2108
- printGroups(groupByRepo(readDashboardData().sessions));
3308
+ printGroups(groupByRepo(readDashboardData(cliFilters(args)).sessions));
2109
3309
  } else if (command === "by-day") {
2110
- printGroups(groupByDay(readDashboardData().sessions));
3310
+ printGroups(groupByDay(readDashboardData(cliFilters(args)).sessions));
2111
3311
  } else if (command === "by-hour") {
2112
- printGroups(groupByHour(readDashboardData().sessions));
3312
+ printGroups(groupByHour(readDashboardData(cliFilters(args)).sessions));
2113
3313
  } else if (command === "by-model") {
2114
- printGroups(groupByModel(readDashboardData().sessions));
3314
+ printGroups(groupByModel(readDashboardData(cliFilters(args)).sessions));
2115
3315
  } else if (command === "by-app") {
2116
- printGroups(groupBySourceApp(readDashboardData().sessions));
3316
+ printGroups(groupBySourceApp(readDashboardData(cliFilters(args)).sessions));
2117
3317
  } else if (command === "export") {
2118
3318
  const format = valueAfter(args, "--format") ?? "json";
2119
- const sessions = readDashboardData().sessions;
2120
- console.log(format === "csv" ? toCsv(sessions) : exportJson());
3319
+ const filters = cliFilters(args);
3320
+ const sessions = readDashboardData(filters).sessions;
3321
+ console.log(format === "csv" ? toCsv(sessions) : JSON.stringify(sessions, null, 2));
2121
3322
  } else {
2122
3323
  console.error(`Unknown command: ${command}`);
2123
3324
  printHelp();
@@ -2138,7 +3339,7 @@ function openBrowser(url) {
2138
3339
  }
2139
3340
  function printGroups(groups) {
2140
3341
  if (!groups.length) {
2141
- console.log("No usage found. Scanned ~/.codex/state_5.sqlite and ~/.codex/sessions.");
3342
+ console.log("No usage found. Scanned Codex and Claude Code local data paths.");
2142
3343
  return;
2143
3344
  }
2144
3345
  const rows = groups.map((group) => ({
@@ -2149,6 +3350,11 @@ function printGroups(groups) {
2149
3350
  }));
2150
3351
  console.table(rows);
2151
3352
  }
3353
+ function cliFilters(args2) {
3354
+ const source = valueAfter(args2, "--source");
3355
+ if (!source || source === "all") return {};
3356
+ return { source };
3357
+ }
2152
3358
  function valueAfter(args2, flag) {
2153
3359
  const index = args2.indexOf(flag);
2154
3360
  return index >= 0 ? args2[index + 1] : void 0;
@@ -2163,5 +3369,8 @@ function printHelp() {
2163
3369
  repospend by-model
2164
3370
  repospend by-app
2165
3371
  repospend export --format json
2166
- repospend export --format csv`);
3372
+ repospend export --format csv
3373
+
3374
+ Options:
3375
+ --source all|codex|claude`);
2167
3376
  }