repospend 0.0.3 → 0.0.5

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/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to RepoSpend will be documented in this file.
4
4
 
5
+ ## 0.0.5
6
+
7
+ - Fix Codex token under-count on long sessions by summing per-turn `last_token_usage` instead of taking the max of `total_token_usage`, which Codex resets on each context compaction.
8
+ - Track Codex context compactions per session (auto vs. manual `/compact`) and surface them in the session detail panel.
9
+ - Filter Codex startup-context blobs (AGENTS.md/INSTRUCTIONS/environment_context) out of the prompt timeline and prompt counts.
10
+ - Dedupe Claude Code token usage across resumed `.jsonl` files using `requestId` + message `id`, so replayed assistant turns are not double-billed.
11
+ - Label Codex sub-agent sessions as "Codex subagent" instead of folding them into "Codex app".
12
+ - Show the running RepoSpend version in the dashboard footer.
13
+ - Publish a GitHub release with the matching `CHANGELOG.md` section whenever a `v*.*.*` tag is pushed.
14
+
15
+ ## 0.0.4
16
+
17
+ - Automatically retry Codex SQLite access after attempting to rebuild `better-sqlite3` when an `npx` or local install has a Node native module ABI mismatch.
18
+
5
19
  ## 0.0.3
6
20
 
7
21
  - Add initial Claude Code support for local JSONL session transcripts, including projects, timestamps, models, token usage when available, and source status.
package/dist/cli.js CHANGED
@@ -212,6 +212,8 @@ var defaultPricing = {
212
212
  "gpt-5.4-mini": { inputPerMillion: 0.75, cachedInputPerMillion: 0.075, outputPerMillion: 4.5, reasoningOutputPerMillion: 4.5 },
213
213
  "gpt-5.4-nano": { inputPerMillion: 0.2, cachedInputPerMillion: 0.02, outputPerMillion: 1.25, reasoningOutputPerMillion: 1.25 },
214
214
  "gpt-5.4-pro": { inputPerMillion: 30, outputPerMillion: 180, reasoningOutputPerMillion: 180 },
215
+ "gpt-5.3-codex": { inputPerMillion: 1.75, cachedInputPerMillion: 0.175, outputPerMillion: 14, reasoningOutputPerMillion: 14 },
216
+ "gpt-5.3-codex-spark": { inputPerMillion: 1.75, cachedInputPerMillion: 0.175, outputPerMillion: 14, reasoningOutputPerMillion: 14, note: "Research preview pricing is not final in the OpenAI Codex rate card; this API-equivalent rate matches the practical ccusage/LiteLLM-style estimate." },
215
217
  "gpt-5.2": { inputPerMillion: 1.75, cachedInputPerMillion: 0.175, outputPerMillion: 14, reasoningOutputPerMillion: 14 },
216
218
  "gpt-5.2-chat-latest": { inputPerMillion: 1.75, cachedInputPerMillion: 0.175, outputPerMillion: 14, reasoningOutputPerMillion: 14 },
217
219
  "gpt-5.2-codex": { inputPerMillion: 1.75, cachedInputPerMillion: 0.175, outputPerMillion: 14, reasoningOutputPerMillion: 14 },
@@ -936,16 +938,19 @@ function parseBranch(head) {
936
938
  }
937
939
 
938
940
  // packages/core/src/sources/codex.ts
941
+ import { spawnSync } from "node:child_process";
939
942
  import fs4 from "node:fs";
940
943
  import os2 from "node:os";
941
944
  import path4 from "node:path";
945
+ import { fileURLToPath } from "node:url";
942
946
  import Database from "better-sqlite3";
943
947
 
944
948
  // packages/core/src/sources/agent-friction.ts
945
949
  function analyzeAgentFriction(records, totalTokens) {
946
950
  const analysis = emptyAgentFrictionAnalysis();
951
+ const messages = [];
947
952
  for (const record of records) {
948
- applyActionMetadata(analysis, record);
953
+ applyActionMetadata(analysis, record, messages);
949
954
  }
950
955
  finalizeCommandIssues(analysis, totalTokens);
951
956
  return analysis;
@@ -971,14 +976,14 @@ function emptyAgentFrictionAnalysis() {
971
976
  assistantMessageCount: 0
972
977
  };
973
978
  }
974
- function applyActionMetadata(analysis, record) {
979
+ function applyActionMetadata(analysis, record, messages) {
975
980
  if (!record || typeof record !== "object") return;
976
981
  const object = record;
977
982
  const payload = firstObject(object.payload) ?? object;
978
983
  const type = stringValue(object.type) ?? stringValue(payload.type);
979
984
  const role = stringValue(payload.role);
980
- if (payload.type === "user_message" || role === "user") analysis.userPromptCount += 1;
981
- if (payload.type === "agent_message" || role === "assistant") analysis.assistantMessageCount += 1;
985
+ const messageRole = payload.type === "user_message" || role === "user" ? "user" : payload.type === "agent_message" || role === "assistant" ? "assistant" : void 0;
986
+ if (messageRole) applyMessageCount(analysis, messages, messageRole, payload, object);
982
987
  const text = JSON.stringify(record).toLowerCase();
983
988
  const isTool = type?.includes("tool") || type?.includes("function_call") || text.includes('"tool_call"') || text.includes('"function_call"');
984
989
  if (isTool) analysis.toolCallCount += 1;
@@ -1138,6 +1143,64 @@ function firstObject(...values) {
1138
1143
  function stringValue(value) {
1139
1144
  return typeof value === "string" && value.length > 0 ? value : void 0;
1140
1145
  }
1146
+ function applyMessageCount(analysis, messages, role, payload, object) {
1147
+ const message = extractMessageText(payload) ?? extractMessageText(object);
1148
+ if (!message) return;
1149
+ const item = {
1150
+ role,
1151
+ text: compactText(message),
1152
+ timestamp: dateValue(payload.timestamp) ?? dateValue(object.timestamp) ?? dateValue(payload.created_at) ?? dateValue(object.created_at)
1153
+ };
1154
+ if (!item.text || isCodexStartupContext(item.text) || hasNearbyDuplicateMessage(messages, item)) return;
1155
+ messages.push(item);
1156
+ if (role === "user") analysis.userPromptCount += 1;
1157
+ if (role === "assistant") analysis.assistantMessageCount += 1;
1158
+ }
1159
+ function extractMessageText(record) {
1160
+ for (const key of ["text", "message", "content", "input"]) {
1161
+ const value = record[key];
1162
+ if (typeof value === "string" && value.trim()) return value;
1163
+ if (Array.isArray(value)) {
1164
+ const parts = value.map((item) => {
1165
+ if (typeof item === "string") return item;
1166
+ if (item && typeof item === "object") {
1167
+ const object = item;
1168
+ return stringValue(object.text) ?? stringValue(object.content);
1169
+ }
1170
+ return void 0;
1171
+ }).filter((item) => Boolean(item?.trim()));
1172
+ if (parts.length) return parts.join("\n");
1173
+ }
1174
+ }
1175
+ return void 0;
1176
+ }
1177
+ function compactText(text) {
1178
+ return text.replace(/\s+/g, " ").trim();
1179
+ }
1180
+ function isCodexStartupContext(text) {
1181
+ return text.includes("<environment_context>") && (text.includes("# AGENTS.md instructions") || text.includes("<INSTRUCTIONS>"));
1182
+ }
1183
+ function hasNearbyDuplicateMessage(items, item) {
1184
+ return items.some((existing) => {
1185
+ if (existing.role !== item.role || existing.text !== item.text) return false;
1186
+ if (!existing.timestamp || !item.timestamp) return true;
1187
+ const existingTime = new Date(existing.timestamp).getTime();
1188
+ const itemTime = new Date(item.timestamp).getTime();
1189
+ if (!Number.isFinite(existingTime) || !Number.isFinite(itemTime)) return false;
1190
+ return Math.abs(existingTime - itemTime) <= 5e3;
1191
+ });
1192
+ }
1193
+ function dateValue(value) {
1194
+ if (typeof value === "number") {
1195
+ if (!Number.isFinite(value)) return void 0;
1196
+ return new Date(value > 1e10 ? value : value * 1e3).toISOString();
1197
+ }
1198
+ if (typeof value === "string") {
1199
+ const date = new Date(value);
1200
+ return Number.isNaN(date.getTime()) ? void 0 : date.toISOString();
1201
+ }
1202
+ return void 0;
1203
+ }
1141
1204
 
1142
1205
  // packages/core/src/sources/codex-token.ts
1143
1206
  function aggregateTokens(records) {
@@ -1208,8 +1271,8 @@ function finalizeTokenAccumulator(accumulator) {
1208
1271
  { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningTokens: 0, totalTokens: 0 }
1209
1272
  );
1210
1273
  applyFinalUsage(accumulator, total);
1211
- accumulator.tokenAggregationMethod = "direct_usage";
1212
- accumulator.tokenConfidence = "medium";
1274
+ accumulator.tokenAggregationMethod = "delta_sum";
1275
+ accumulator.tokenConfidence = "high";
1213
1276
  return;
1214
1277
  }
1215
1278
  accumulator.tokenAggregationMethod = "unknown";
@@ -1220,6 +1283,10 @@ function extractRecordTokens(record) {
1220
1283
  const object = record;
1221
1284
  const payload = firstObject2(object.payload);
1222
1285
  const payloadInfo = firstObject2(payload?.info);
1286
+ const codexLastUsage = firstObject2(payloadInfo?.last_token_usage);
1287
+ if (codexLastUsage) {
1288
+ return tokenUsageFromObject(codexLastUsage, false);
1289
+ }
1223
1290
  const codexTotalUsage = firstObject2(payloadInfo?.total_token_usage);
1224
1291
  if (codexTotalUsage) {
1225
1292
  return tokenUsageFromObject(codexTotalUsage, true);
@@ -1268,6 +1335,7 @@ function numberFromKeys(object, keys) {
1268
1335
  }
1269
1336
 
1270
1337
  // packages/core/src/sources/codex.ts
1338
+ var nativeSqliteRebuildAttempted = false;
1271
1339
  function scanCodex(options) {
1272
1340
  const codexHome = options.codexHome ?? path4.join(os2.homedir(), ".codex");
1273
1341
  const statePath = path4.join(codexHome, "state_5.sqlite");
@@ -1322,7 +1390,7 @@ function scanCodex(options) {
1322
1390
  function readThreads(statePath, sessionFiles, options, warnings) {
1323
1391
  let db;
1324
1392
  try {
1325
- db = new Database(statePath, { readonly: true, fileMustExist: true });
1393
+ db = openCodexStateDatabase(statePath, warnings);
1326
1394
  const tableNames = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all();
1327
1395
  if (!tableNames.some((table) => table.name === "threads")) {
1328
1396
  warnings.push("Codex SQLite state does not contain a threads table.");
@@ -1343,6 +1411,70 @@ function readThreads(statePath, sessionFiles, options, warnings) {
1343
1411
  db?.close();
1344
1412
  }
1345
1413
  }
1414
+ function openCodexStateDatabase(statePath, warnings) {
1415
+ try {
1416
+ return new Database(statePath, { readonly: true, fileMustExist: true });
1417
+ } catch (error) {
1418
+ if (isNativeSqliteMismatch(error) && attemptNativeSqliteRebuild(warnings)) {
1419
+ return new Database(statePath, { readonly: true, fileMustExist: true });
1420
+ }
1421
+ throw error;
1422
+ }
1423
+ }
1424
+ function attemptNativeSqliteRebuild(warnings) {
1425
+ if (nativeSqliteRebuildAttempted || process.env.REPOSPEND_SKIP_NATIVE_REBUILD === "1") return false;
1426
+ nativeSqliteRebuildAttempted = true;
1427
+ const packageRoot = findRepoSpendPackageRoot();
1428
+ if (!packageRoot) {
1429
+ warnings.push("Codex SQLite native module mismatch detected, but RepoSpend could not locate its package root for an automatic rebuild.");
1430
+ return false;
1431
+ }
1432
+ const packageManager = fs4.existsSync(path4.join(packageRoot, "pnpm-lock.yaml")) ? "pnpm" : "npm";
1433
+ const result = spawnSync(packageManager, ["rebuild", "better-sqlite3"], {
1434
+ cwd: packageRoot,
1435
+ encoding: "utf8",
1436
+ stdio: "pipe",
1437
+ timeout: 12e4
1438
+ });
1439
+ if (result.status === 0) return true;
1440
+ const detail = [result.stderr, result.stdout].filter(Boolean).join("\n").split("\n").map((line) => line.trim()).filter(Boolean).slice(-3).join(" ");
1441
+ 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}` : ""}`);
1442
+ return false;
1443
+ }
1444
+ function findRepoSpendPackageRoot() {
1445
+ const starts = [
1446
+ process.argv[1] ? path4.dirname(fs4.realpathSync(process.argv[1])) : void 0,
1447
+ path4.dirname(fileURLToPath(import.meta.url)),
1448
+ process.cwd()
1449
+ ].filter((item) => Boolean(item));
1450
+ for (const start of starts) {
1451
+ const root = findPackageRootNamedRepoSpend(start);
1452
+ if (root) return root;
1453
+ }
1454
+ return void 0;
1455
+ }
1456
+ function findPackageRootNamedRepoSpend(start) {
1457
+ let current = path4.resolve(start);
1458
+ while (true) {
1459
+ const packagePath = path4.join(current, "package.json");
1460
+ if (fs4.existsSync(packagePath)) {
1461
+ try {
1462
+ const packageJson = JSON.parse(fs4.readFileSync(packagePath, "utf8"));
1463
+ if (packageJson.name === "repospend") return current;
1464
+ } catch {
1465
+ }
1466
+ }
1467
+ const parent = path4.dirname(current);
1468
+ if (parent === current) return void 0;
1469
+ current = parent;
1470
+ }
1471
+ }
1472
+ function isNativeSqliteMismatch(error) {
1473
+ return errorMessage(error).includes("NODE_MODULE_VERSION");
1474
+ }
1475
+ function exitLabel(status) {
1476
+ return status === null ? "" : ` with exit code ${status}`;
1477
+ }
1346
1478
  function threadToUsage(row, index, sessionFiles, options) {
1347
1479
  const sourcePath = stringValue2(row.rollout_path) ?? matchSessionPath(stringValue2(row.id), sessionFiles) ?? "";
1348
1480
  const sessionBreakdown = sourcePath ? readSessionBreakdown(sourcePath) : emptyBreakdown();
@@ -1371,8 +1503,8 @@ function threadToUsage(row, index, sessionFiles, options) {
1371
1503
  gitRemoteUrl: repo.gitRemoteUrl,
1372
1504
  gitBranch: repo.gitBranch,
1373
1505
  title: stringValue2(row.title),
1374
- startedAt: dateValue(row.created_at),
1375
- endedAt: dateValue(row.updated_at),
1506
+ startedAt: dateValue2(row.created_at),
1507
+ endedAt: dateValue2(row.updated_at),
1376
1508
  model: stringValue2(row.model),
1377
1509
  provider: stringValue2(row.model_provider),
1378
1510
  inputTokens,
@@ -1389,7 +1521,7 @@ function threadToUsage(row, index, sessionFiles, options) {
1389
1521
  rawTokenTotal: dbTokens || sessionBreakdown.totalTokens || void 0,
1390
1522
  warnings,
1391
1523
  codexHome: options.codexHome ?? path4.join(os2.homedir(), ".codex"),
1392
- durationMs: durationMs(dateValue(row.created_at), dateValue(row.updated_at)),
1524
+ durationMs: durationMs(dateValue2(row.created_at), dateValue2(row.updated_at)),
1393
1525
  rawEventCount: sessionBreakdown.rawEventCount,
1394
1526
  parseStatus: sessionBreakdown.parseStatus,
1395
1527
  parseErrors: sessionBreakdown.parseErrors,
@@ -1413,7 +1545,8 @@ function threadToUsage(row, index, sessionFiles, options) {
1413
1545
  fileReadCount: sessionBreakdown.fileReadCount,
1414
1546
  fileEditCount: sessionBreakdown.fileEditCount,
1415
1547
  promptTimeline: sessionBreakdown.promptTimeline,
1416
- sessionOutcome: inferOutcome(sessionBreakdown)
1548
+ sessionOutcome: inferOutcome(sessionBreakdown),
1549
+ compaction: sessionBreakdown.compaction
1417
1550
  };
1418
1551
  const cost = hasPricedBreakdown ? calculateCostUsd(usage, options.pricing) : void 0;
1419
1552
  const costWarnings = hasPricedBreakdown ? warnings : [...warnings, "missing_token_breakdown"];
@@ -1483,7 +1616,8 @@ function readStandaloneSessions(sessionFiles, options) {
1483
1616
  fileReadCount: breakdown.fileReadCount,
1484
1617
  fileEditCount: breakdown.fileEditCount,
1485
1618
  promptTimeline: breakdown.promptTimeline,
1486
- sessionOutcome: inferOutcome(breakdown)
1619
+ sessionOutcome: inferOutcome(breakdown),
1620
+ compaction: breakdown.compaction
1487
1621
  };
1488
1622
  });
1489
1623
  }
@@ -1521,11 +1655,15 @@ function readSessionBreakdown(filePath) {
1521
1655
  breakdown.parseStatus = parse.errors.length ? records.length ? "partial" : "failed" : "ok";
1522
1656
  let eventMessageCount = 0;
1523
1657
  let responseMessageCount = 0;
1658
+ let lastUserMessageText;
1524
1659
  for (const record of records) {
1525
1660
  applySessionMetadata(breakdown, record);
1526
1661
  applyPromptTimeline(breakdown, record);
1527
1662
  if (isCodexEventMessage(record)) eventMessageCount += 1;
1528
1663
  if (isResponseMessage(record)) responseMessageCount += 1;
1664
+ const userText = extractUserMessageText(record);
1665
+ if (userText !== void 0) lastUserMessageText = userText;
1666
+ applyCompactionEvent(breakdown, record, lastUserMessageText);
1529
1667
  }
1530
1668
  breakdown.messageCount = eventMessageCount || responseMessageCount;
1531
1669
  const tokenAggregation = aggregateTokens(records);
@@ -1634,7 +1772,8 @@ function emptyBreakdown() {
1634
1772
  commandIssueSamples: [],
1635
1773
  fileReadCount: 0,
1636
1774
  fileEditCount: 0,
1637
- promptTimeline: []
1775
+ promptTimeline: [],
1776
+ compaction: { count: 0, autoCount: 0, manualCount: 0, events: [] }
1638
1777
  };
1639
1778
  }
1640
1779
  function fallbackCwdFromPath(filePath) {
@@ -1662,6 +1801,35 @@ function isResponseMessage(record) {
1662
1801
  const payload = firstObject3(object.payload);
1663
1802
  return payload?.type === "message" && (payload.role === "user" || payload.role === "assistant");
1664
1803
  }
1804
+ function extractUserMessageText(record) {
1805
+ if (!record || typeof record !== "object") return void 0;
1806
+ const object = record;
1807
+ const payload = firstObject3(object.payload) ?? object;
1808
+ const payloadType = stringValue2(payload.type);
1809
+ const roleValue = stringValue2(payload.role);
1810
+ const isUser = payloadType === "user_message" || object.type === "response_item" && payloadType === "message" && roleValue === "user";
1811
+ if (!isUser) return void 0;
1812
+ return extractMessageText2(payload) ?? extractMessageText2(object);
1813
+ }
1814
+ function applyCompactionEvent(breakdown, record, lastUserMessageText) {
1815
+ if (!record || typeof record !== "object") return;
1816
+ const object = record;
1817
+ if (object.type !== "event_msg") return;
1818
+ const payload = firstObject3(object.payload);
1819
+ if (payload?.type !== "context_compacted") return;
1820
+ const trigger = isManualCompactCommand(lastUserMessageText) ? "manual" : "auto";
1821
+ breakdown.compaction.count += 1;
1822
+ if (trigger === "manual") breakdown.compaction.manualCount += 1;
1823
+ else breakdown.compaction.autoCount += 1;
1824
+ breakdown.compaction.events.push({
1825
+ timestamp: dateValue2(object.timestamp) ?? dateValue2(payload.timestamp),
1826
+ trigger
1827
+ });
1828
+ }
1829
+ function isManualCompactCommand(text) {
1830
+ if (!text) return false;
1831
+ return /^\s*\/compact\b/.test(text);
1832
+ }
1665
1833
  function applyPromptTimeline(breakdown, record) {
1666
1834
  if (breakdown.promptTimeline.length >= 80 || !record || typeof record !== "object") return;
1667
1835
  const object = record;
@@ -1670,15 +1838,19 @@ function applyPromptTimeline(breakdown, record) {
1670
1838
  const roleValue = stringValue2(payload.role);
1671
1839
  const role = payloadType === "user_message" || roleValue === "user" ? "user" : payloadType === "agent_message" || roleValue === "assistant" ? "assistant" : void 0;
1672
1840
  if (!role) return;
1673
- const text = extractMessageText(payload) ?? extractMessageText(object);
1841
+ const text = extractMessageText2(payload) ?? extractMessageText2(object);
1674
1842
  if (!text) return;
1675
- breakdown.promptTimeline.push({
1843
+ const timelineText = compactTimelineText(text);
1844
+ if (!timelineText || isCodexStartupContext2(timelineText)) return;
1845
+ const item = {
1676
1846
  role,
1677
- text: normalizeTimelineText(text),
1678
- timestamp: dateValue(payload.timestamp) ?? dateValue(object.timestamp) ?? dateValue(payload.created_at) ?? dateValue(object.created_at)
1679
- });
1847
+ text: normalizeTimelineText(timelineText),
1848
+ timestamp: dateValue2(payload.timestamp) ?? dateValue2(object.timestamp) ?? dateValue2(payload.created_at) ?? dateValue2(object.created_at)
1849
+ };
1850
+ if (hasNearbyDuplicatePromptTimelineItem(breakdown.promptTimeline, item)) return;
1851
+ breakdown.promptTimeline.push(item);
1680
1852
  }
1681
- function extractMessageText(record) {
1853
+ function extractMessageText2(record) {
1682
1854
  for (const key of ["text", "message", "content", "input"]) {
1683
1855
  const value = record[key];
1684
1856
  if (typeof value === "string" && value.trim()) return value;
@@ -1696,8 +1868,24 @@ function extractMessageText(record) {
1696
1868
  }
1697
1869
  return void 0;
1698
1870
  }
1871
+ function compactTimelineText(text) {
1872
+ return text.replace(/\s+/g, " ").trim();
1873
+ }
1699
1874
  function normalizeTimelineText(text) {
1700
- return text.replace(/\s+/g, " ").trim().slice(0, 1e3);
1875
+ return compactTimelineText(text).slice(0, 1e3);
1876
+ }
1877
+ function isCodexStartupContext2(text) {
1878
+ return text.includes("<environment_context>") && (text.includes("# AGENTS.md instructions") || text.includes("<INSTRUCTIONS>"));
1879
+ }
1880
+ function hasNearbyDuplicatePromptTimelineItem(items, item) {
1881
+ return items.some((existing) => {
1882
+ if (existing.role !== item.role || existing.text !== item.text) return false;
1883
+ if (!existing.timestamp || !item.timestamp) return true;
1884
+ const existingTime = new Date(existing.timestamp).getTime();
1885
+ const itemTime = new Date(item.timestamp).getTime();
1886
+ if (!Number.isFinite(existingTime) || !Number.isFinite(itemTime)) return false;
1887
+ return Math.abs(existingTime - itemTime) <= 5e3;
1888
+ });
1701
1889
  }
1702
1890
  function detectSurface(source, threadSource) {
1703
1891
  const raw = `${source ?? ""} ${threadSource ?? ""}`.toLowerCase();
@@ -1726,15 +1914,15 @@ function stringValue2(value) {
1726
1914
  return typeof value === "string" && value.length > 0 ? value : void 0;
1727
1915
  }
1728
1916
  function codexSourceAppLabel(source, threadSource) {
1729
- if (threadSource === "subagent") return "Codex app";
1917
+ if (threadSource === "subagent") return "Codex subagent";
1730
1918
  if (!source) return "Codex";
1731
1919
  if (source === "vscode" || source === "codex_vscode" || source === "codex-vscode") return "VS Code";
1732
1920
  if (source === "cli" || source === "terminal" || source === "codex-tui") return "Terminal";
1733
1921
  if (source === "codex_app" || source === "codex-app") return "Codex app";
1734
- if (source.trim().startsWith("{")) return threadSource === "subagent" ? "Codex app" : "Codex";
1922
+ if (source.trim().startsWith("{")) return threadSource === "subagent" ? "Codex subagent" : "Codex";
1735
1923
  return source.replace(/^codex[_-]/, "").split(/[_-]/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
1736
1924
  }
1737
- function dateValue(value) {
1925
+ function dateValue2(value) {
1738
1926
  if (typeof value === "number") {
1739
1927
  return new Date(value > 1e10 ? value : value * 1e3).toISOString();
1740
1928
  }
@@ -1764,7 +1952,9 @@ function scanClaude(options) {
1764
1952
  const warnings = [];
1765
1953
  const discovery = discoverClaudeSessionFiles(claudeHome, warnings, Boolean(options.claudeHome));
1766
1954
  const historyExists = fs5.existsSync(historyPath);
1767
- const sessions = discovery.files.map((file, index) => claudeFileToUsage(file, index, options));
1955
+ const seenUsageKeys = /* @__PURE__ */ new Set();
1956
+ const orderedFiles = discovery.files.map((file) => ({ file, mtimeMs: safeMtimeMs(file.filePath) })).sort((a, b) => a.mtimeMs - b.mtimeMs);
1957
+ const sessions = orderedFiles.map(({ file }, index) => claudeFileToUsage(file, index, options, seenUsageKeys));
1768
1958
  const historyEntryCount = countHistoryEntries(historyPath);
1769
1959
  const parseFailureCount = sessions.filter((session) => session.parseStatus === "failed" || (session.parseErrors?.length ?? 0) > 0).length;
1770
1960
  const unreadableFileCount = sessions.filter((session) => session.parseErrors?.some((error) => error.startsWith("unable_to_read_session_file:"))).length;
@@ -1870,8 +2060,8 @@ function walk2(root, maxDepth, onFile, depth = 0) {
1870
2060
  }
1871
2061
  }
1872
2062
  }
1873
- function claudeFileToUsage(file, index, options) {
1874
- const breakdown = readClaudeBreakdown(file.filePath);
2063
+ function claudeFileToUsage(file, index, options, seenUsageKeys) {
2064
+ const breakdown = readClaudeBreakdown(file.filePath, seenUsageKeys);
1875
2065
  const id = breakdown.id ?? (path5.basename(file.filePath).replace(/\.jsonl$/i, "") || `claude-session-${index}`);
1876
2066
  const fallbackCwd = inferProjectPath(file.projectDir, file.projectsPath);
1877
2067
  const cwd = breakdown.cwd ?? fallbackCwd;
@@ -1942,7 +2132,7 @@ function claudeFileToUsage(file, index, options) {
1942
2132
  warnings: cost === void 0 ? [...usage.warnings, "unknown_pricing"] : usage.warnings
1943
2133
  };
1944
2134
  }
1945
- function readClaudeBreakdown(filePath) {
2135
+ function readClaudeBreakdown(filePath, seenUsageKeys) {
1946
2136
  const breakdown = emptyBreakdown2();
1947
2137
  try {
1948
2138
  const text = fs5.readFileSync(filePath, "utf8");
@@ -1951,7 +2141,7 @@ function readClaudeBreakdown(filePath) {
1951
2141
  breakdown.parseErrors = parse.errors;
1952
2142
  breakdown.parseStatus = parse.errors.length ? parse.records.length ? "partial" : "failed" : "ok";
1953
2143
  for (const record of parse.records) {
1954
- applyClaudeRecord(breakdown, record);
2144
+ applyClaudeRecord(breakdown, record, seenUsageKeys);
1955
2145
  }
1956
2146
  breakdown.totalTokens = breakdown.inputTokens + breakdown.outputTokens;
1957
2147
  } catch (error) {
@@ -1962,8 +2152,8 @@ function readClaudeBreakdown(filePath) {
1962
2152
  }
1963
2153
  return breakdown;
1964
2154
  }
1965
- function applyClaudeRecord(breakdown, record) {
1966
- const timestamp3 = dateValue2(record.timestamp);
2155
+ function applyClaudeRecord(breakdown, record, seenUsageKeys) {
2156
+ const timestamp3 = dateValue3(record.timestamp);
1967
2157
  if (timestamp3) {
1968
2158
  breakdown.startedAt = minDate(breakdown.startedAt, timestamp3);
1969
2159
  breakdown.endedAt = maxDate(breakdown.endedAt, timestamp3);
@@ -1985,11 +2175,22 @@ function applyClaudeRecord(breakdown, record) {
1985
2175
  const usageTokens = usage ? usageTotal(usage) : 0;
1986
2176
  const model = normalizeClaudeModel(message.model);
1987
2177
  if (model && (usageTokens > 0 || !breakdown.model)) breakdown.model = model;
1988
- if (usage && usageTokens > 0) applyUsage(breakdown, usage);
2178
+ if (usage && usageTokens > 0 && !alreadySeenUsage(record, message, seenUsageKeys)) {
2179
+ applyUsage(breakdown, usage);
2180
+ }
1989
2181
  applyContentSignals(breakdown, message.content);
1990
2182
  }
1991
2183
  if (looksFailedToolResult(record.toolUseResult)) breakdown.failedToolCallCount += 1;
1992
2184
  }
2185
+ function alreadySeenUsage(record, message, seenUsageKeys) {
2186
+ const messageId = stringValue3(message.id);
2187
+ const requestId = stringValue3(record.requestId);
2188
+ if (!messageId || !requestId) return false;
2189
+ const key = `${requestId}:${messageId}`;
2190
+ if (seenUsageKeys.has(key)) return true;
2191
+ seenUsageKeys.add(key);
2192
+ return false;
2193
+ }
1993
2194
  function applyUsage(breakdown, usage) {
1994
2195
  const input = numberValue(usage.input_tokens);
1995
2196
  const cacheRead = numberValue(usage.cache_read_input_tokens);
@@ -2173,7 +2374,7 @@ function normalizeClaudeModel(value) {
2173
2374
  if (!model || model === "<synthetic>") return void 0;
2174
2375
  return model;
2175
2376
  }
2176
- function dateValue2(value) {
2377
+ function dateValue3(value) {
2177
2378
  if (typeof value !== "string") return void 0;
2178
2379
  const date = new Date(value);
2179
2380
  return Number.isNaN(date.getTime()) ? void 0 : date.toISOString();
@@ -2202,6 +2403,13 @@ function looksFailedToolResult(value) {
2202
2403
  function errorMessage2(error) {
2203
2404
  return error instanceof Error ? error.message : String(error);
2204
2405
  }
2406
+ function safeMtimeMs(filePath) {
2407
+ try {
2408
+ return fs5.statSync(filePath).mtimeMs;
2409
+ } catch {
2410
+ return 0;
2411
+ }
2412
+ }
2205
2413
 
2206
2414
  // packages/core/src/sources/index.ts
2207
2415
  function scanUsageSources(options) {
@@ -2311,7 +2519,7 @@ function runtimeCwd() {
2311
2519
  // apps/server/src/server.ts
2312
2520
  import fs7 from "node:fs";
2313
2521
  import path6 from "node:path";
2314
- import { fileURLToPath } from "node:url";
2522
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
2315
2523
  import fastifyStatic from "@fastify/static";
2316
2524
  import Fastify from "fastify";
2317
2525
 
@@ -2320,7 +2528,7 @@ var baseDate = "2026-05-18";
2320
2528
  var demoPricing = {
2321
2529
  ...defaultPricing,
2322
2530
  "gpt-5.3-codex": { inputPerMillion: 1.75, cachedInputPerMillion: 0.175, outputPerMillion: 14, reasoningOutputPerMillion: 14 },
2323
- "gpt-5.3-codex-spark": { inputPerMillion: 0.75, cachedInputPerMillion: 0.075, outputPerMillion: 4.5, reasoningOutputPerMillion: 4.5 },
2531
+ "gpt-5.3-codex-spark": { inputPerMillion: 1.75, cachedInputPerMillion: 0.175, outputPerMillion: 14, reasoningOutputPerMillion: 14 },
2324
2532
  "claude-haiku-4-5-20251001": { inputPerMillion: 1, cacheCreationInputPerMillion: 1.25, cachedInputPerMillion: 0.1, outputPerMillion: 5 }
2325
2533
  };
2326
2534
  function demoModeEnabled() {
@@ -3124,6 +3332,7 @@ function createServer(options = {}) {
3124
3332
  const rtkGain = demoMode ? readDemoRtkGain() : await readRtkGain();
3125
3333
  return {
3126
3334
  ...data,
3335
+ appVersion: readPackageVersion(),
3127
3336
  summary: { ...data.summary, skippedZeroTokenSessions: data.skippedZeroTokenSessions },
3128
3337
  pricing: {
3129
3338
  info: pricingInfo,
@@ -3147,7 +3356,7 @@ function createServer(options = {}) {
3147
3356
  return exportCsv();
3148
3357
  });
3149
3358
  if (options.serveWeb !== false) {
3150
- const serverDist = path6.dirname(fileURLToPath(import.meta.url));
3359
+ const serverDist = path6.dirname(fileURLToPath2(import.meta.url));
3151
3360
  const packagedWebDist = path6.resolve(serverDist, "../web-dist");
3152
3361
  const workspaceWebDist = path6.resolve(serverDist, "../../web/dist");
3153
3362
  const webDist = fsExists(packagedWebDist) ? packagedWebDist : workspaceWebDist;
@@ -3173,7 +3382,7 @@ function fsExists(inputPath) {
3173
3382
  return Boolean(inputPath) && path6.isAbsolute(inputPath) && fs7.existsSync(inputPath);
3174
3383
  }
3175
3384
  function readPackageVersion() {
3176
- const serverDist = path6.dirname(fileURLToPath(import.meta.url));
3385
+ const serverDist = path6.dirname(fileURLToPath2(import.meta.url));
3177
3386
  const packagePath = path6.resolve(serverDist, "../package.json");
3178
3387
  try {
3179
3388
  const parsed = JSON.parse(fs7.readFileSync(packagePath, "utf8"));
@@ -44,7 +44,10 @@ git tag v0.0.3
44
44
  git push origin main v0.0.3
45
45
  ```
46
46
 
47
- The publish workflow runs when a `v*.*.*` tag is pushed.
47
+ The publish workflow runs when a `v*.*.*` tag is pushed. After npm publishes
48
+ successfully, the workflow also creates a GitHub release for the tag using the
49
+ matching section from `CHANGELOG.md`. For example, tag `v0.0.4` uses the
50
+ `## 0.0.4` changelog section.
48
51
 
49
52
  ## npm Trusted Publishing
50
53
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "repospend",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "description": "Local-first dashboard for tracking AI coding token usage by repo, source, session, model, and day.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Mehmet Mustafa Demir",