repospend 0.0.5 → 0.0.6
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 +10 -0
- package/README.md +25 -0
- package/dist/cli.js +257 -49
- package/package.json +1 -1
- package/web-dist/assets/index-BxuOiBnq.js +265 -0
- package/web-dist/assets/index-KkDIavlL.css +1 -0
- package/web-dist/index.html +2 -2
- package/web-dist/assets/index-D6eYRahs.css +0 -1
- package/web-dist/assets/index-DcroX3EF.js +0 -260
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to RepoSpend will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## 0.0.6
|
|
6
|
+
|
|
7
|
+
- Improve Codex session metadata recovery from rollout logs, including standalone Codex Desktop sessions and imported Claude model metadata.
|
|
8
|
+
- Add an optional source-scoped app display mode so shared surfaces can be shown as source-specific entries such as "Codex on VS Code".
|
|
9
|
+
- Improve sparse timeline charts and RTK unavailable-state guidance in the dashboard.
|
|
10
|
+
- Document why RepoSpend token totals can differ from `ccusage` while API-equivalent costs still match.
|
|
11
|
+
- Show a git-branch icon next to repos with a confirmed git root and a folder icon for path-inferred repos, giving a quick visual signal of data confidence.
|
|
12
|
+
- Fix Windows extended-length path prefix (\\?\) handling in git root detection, which caused repos on Windows to incorrectly appear as unverified.
|
|
13
|
+
|
|
14
|
+
|
|
5
15
|
## 0.0.5
|
|
6
16
|
|
|
7
17
|
- 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.
|
package/README.md
CHANGED
|
@@ -156,6 +156,31 @@ Claude Code cost may show as unknown when local files do not include token count
|
|
|
156
156
|
or a model name. RepoSpend shows those sessions with unknown tokens/cost rather
|
|
157
157
|
than guessing.
|
|
158
158
|
|
|
159
|
+
### Why RepoSpend's token totals can look lower than `ccusage`
|
|
160
|
+
|
|
161
|
+
If you compare RepoSpend to `ccusage` you may see matching costs but different
|
|
162
|
+
"total tokens", especially for Codex where cache reads dominate. This is
|
|
163
|
+
expected.
|
|
164
|
+
|
|
165
|
+
RepoSpend treats `cached_input_tokens` as a **subset of** `input_tokens`, which
|
|
166
|
+
is how the OpenAI and Anthropic APIs document the field. The "total tokens"
|
|
167
|
+
number reflects the tokens the model actually processed. `ccusage` adds cached
|
|
168
|
+
tokens as a separate line item in its "Total Tokens" column, which inflates the
|
|
169
|
+
total but does not change billing.
|
|
170
|
+
|
|
171
|
+
The dollar cost is the source of truth and should agree between the two tools
|
|
172
|
+
within rounding. If costs diverge, that is a real discrepancy worth
|
|
173
|
+
investigating; token-count divergence on its own usually is not.
|
|
174
|
+
|
|
175
|
+
### Codex Desktop on Windows
|
|
176
|
+
|
|
177
|
+
RepoSpend captures Codex **CLI** usage on Windows from `~/.codex/`. The Codex
|
|
178
|
+
**Desktop app** (installed as the MSIX package
|
|
179
|
+
`OpenAI.Codex_<id>` under `%LOCALAPPDATA%\Packages\`) does not persist session
|
|
180
|
+
transcripts or per-turn token usage to disk. Only Electron caches and debug
|
|
181
|
+
logs are stored locally. Real session data lives server-side. Neither RepoSpend
|
|
182
|
+
nor `ccusage` can report on Codex Desktop usage from local files alone.
|
|
183
|
+
|
|
159
184
|
## What It Reads
|
|
160
185
|
|
|
161
186
|
RepoSpend only reads local files. It does not edit Codex, Claude Code, or RTK
|
package/dist/cli.js
CHANGED
|
@@ -44,10 +44,14 @@ function summarize(sessions) {
|
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
46
|
function groupByRepo(sessions) {
|
|
47
|
-
|
|
47
|
+
const groups = groupBy(sessions, (session) => session.repoRoot, (session) => session.repoName);
|
|
48
|
+
return groups.map((group) => ({
|
|
49
|
+
...group,
|
|
50
|
+
verified: sessions.some((s) => s.repoRoot === group.id && !s.warnings.includes("repo_unverified_no_git_root"))
|
|
51
|
+
}));
|
|
48
52
|
}
|
|
49
53
|
function groupByModel(sessions) {
|
|
50
|
-
return groupBy(sessions, (session) => session.model ?? "unknown-model", (session) => session.model ?? "
|
|
54
|
+
return groupBy(sessions, (session) => session.model ?? "unknown-model", (session) => session.model ?? "Model not recorded");
|
|
51
55
|
}
|
|
52
56
|
function groupBySourceApp(sessions) {
|
|
53
57
|
return groupBy(sessions, (session) => session.sourceApp, (session) => session.sourceApp);
|
|
@@ -344,7 +348,7 @@ function filterUsage(sessions, filters) {
|
|
|
344
348
|
if (!matchesAny(session.sourceClient, filters.source)) return false;
|
|
345
349
|
if (!matchesAny(session.sourceApp, filters.sourceApp)) return false;
|
|
346
350
|
if (!matchesRepo(session, filters.repo)) return false;
|
|
347
|
-
if (!
|
|
351
|
+
if (!matchesModel(session.model, filters.model)) return false;
|
|
348
352
|
const startedAt = timestamp(session.startedAt);
|
|
349
353
|
const hasDateFilter = Boolean(filters.from || filters.to);
|
|
350
354
|
if (hasDateFilter && startedAt === void 0) return false;
|
|
@@ -365,6 +369,11 @@ function matchesRepo(session, filter) {
|
|
|
365
369
|
if (!values.length) return true;
|
|
366
370
|
return values.includes(session.repoName) || values.includes(session.repoRoot);
|
|
367
371
|
}
|
|
372
|
+
function matchesModel(value, filter) {
|
|
373
|
+
const values = filterValues(filter);
|
|
374
|
+
if (!values.length) return true;
|
|
375
|
+
return value !== void 0 ? values.includes(value) : values.includes("unknown-model");
|
|
376
|
+
}
|
|
368
377
|
function filterValues(filter) {
|
|
369
378
|
if (!filter) return [];
|
|
370
379
|
return Array.isArray(filter) ? filter.filter(Boolean) : filter.split(",").map((value) => value.trim()).filter(Boolean);
|
|
@@ -849,8 +858,12 @@ function isDashboardVisibleSession(session) {
|
|
|
849
858
|
// packages/core/src/repo.ts
|
|
850
859
|
import fs3 from "node:fs";
|
|
851
860
|
import path3 from "node:path";
|
|
861
|
+
function normalizeRoot(p) {
|
|
862
|
+
const stripped = p.startsWith("\\\\?\\") ? p.slice(4) : p;
|
|
863
|
+
return stripped.replace(/^([a-zA-Z]):/, (_, letter) => letter.toUpperCase() + ":");
|
|
864
|
+
}
|
|
852
865
|
function findGitRoot(cwd, fileSystem = fs3) {
|
|
853
|
-
let current = path3.resolve(cwd);
|
|
866
|
+
let current = normalizeRoot(path3.resolve(cwd));
|
|
854
867
|
while (true) {
|
|
855
868
|
const gitPath = path3.join(current, ".git");
|
|
856
869
|
if (fileSystem.existsSync(gitPath)) {
|
|
@@ -897,8 +910,8 @@ function safeFindGitRoot(cwd, fileSystem) {
|
|
|
897
910
|
}
|
|
898
911
|
}
|
|
899
912
|
function fallbackWorkspaceRoot(cwd) {
|
|
900
|
-
const resolved = path3.resolve(cwd || process.cwd());
|
|
901
|
-
const home = process.env.HOME ? path3.resolve(process.env.HOME) : void 0;
|
|
913
|
+
const resolved = normalizeRoot(path3.resolve(cwd || process.cwd()));
|
|
914
|
+
const home = process.env.HOME ? normalizeRoot(path3.resolve(process.env.HOME)) : void 0;
|
|
902
915
|
if (home && (resolved === home || resolved.startsWith(`${home}${path3.sep}`))) {
|
|
903
916
|
const relative = path3.relative(home, resolved).split(path3.sep).filter(Boolean);
|
|
904
917
|
const first = relative[0];
|
|
@@ -1354,12 +1367,9 @@ function scanCodex(options) {
|
|
|
1354
1367
|
if (stateExists) {
|
|
1355
1368
|
const stateSessions = readThreads(statePath, sessionFiles, options, warnings);
|
|
1356
1369
|
sessions.push(...stateSessions);
|
|
1357
|
-
if (!stateSessions.length && sessionsExists) {
|
|
1358
|
-
sessions.push(...readStandaloneSessions(sessionFiles, options));
|
|
1359
|
-
}
|
|
1360
1370
|
}
|
|
1361
|
-
if (
|
|
1362
|
-
sessions.push(...readStandaloneSessions(sessionFiles, options));
|
|
1371
|
+
if (sessionsExists) {
|
|
1372
|
+
sessions.push(...readStandaloneSessions(sessionFiles, options).filter((session) => !hasImportedSession(sessions, session)));
|
|
1363
1373
|
}
|
|
1364
1374
|
return {
|
|
1365
1375
|
source: {
|
|
@@ -1402,8 +1412,10 @@ function readThreads(statePath, sessionFiles, options, warnings) {
|
|
|
1402
1412
|
warnings.push("Codex threads table has no recognized columns.");
|
|
1403
1413
|
return [];
|
|
1404
1414
|
}
|
|
1415
|
+
const importedModels = readImportedModelMap(options.codexHome ?? path4.join(os2.homedir(), ".codex"), warnings);
|
|
1405
1416
|
const rows = db.prepare(`SELECT ${selectedColumns.map((column) => `"${column}"`).join(", ")} FROM threads`).all();
|
|
1406
|
-
|
|
1417
|
+
const sessions = rows.map((row, index) => threadToUsage(row, index, sessionFiles, options, importedModels));
|
|
1418
|
+
return attributeSubagentsToParentSurfaces(sessions, rows);
|
|
1407
1419
|
} catch (error) {
|
|
1408
1420
|
warnings.push(`Unable to read Codex SQLite state at ${statePath}: ${errorMessage(error)}`);
|
|
1409
1421
|
return [];
|
|
@@ -1411,6 +1423,37 @@ function readThreads(statePath, sessionFiles, options, warnings) {
|
|
|
1411
1423
|
db?.close();
|
|
1412
1424
|
}
|
|
1413
1425
|
}
|
|
1426
|
+
function attributeSubagentsToParentSurfaces(sessions, rows) {
|
|
1427
|
+
const sessionsById = new Map(sessions.map((session) => [session.id, session]));
|
|
1428
|
+
const parentIdsByChildId = /* @__PURE__ */ new Map();
|
|
1429
|
+
for (const row of rows) {
|
|
1430
|
+
const childId = stringValue2(row.id);
|
|
1431
|
+
const parentId = codexParentThreadId(stringValue2(row.source));
|
|
1432
|
+
if (childId && parentId) parentIdsByChildId.set(childId, parentId);
|
|
1433
|
+
}
|
|
1434
|
+
return sessions.map((session) => {
|
|
1435
|
+
if (session.sourceApp !== "Codex subagent") return session;
|
|
1436
|
+
const parent = sessionsById.get(parentIdsByChildId.get(session.id) ?? "");
|
|
1437
|
+
if (!parent || parent.sourceApp === "Codex subagent") return session;
|
|
1438
|
+
return {
|
|
1439
|
+
...session,
|
|
1440
|
+
sourceApp: parent.sourceApp,
|
|
1441
|
+
sourceAppRaw: session.sourceAppRaw,
|
|
1442
|
+
surfaceReason: `subagent attributed to parent thread ${parent.id}: ${parent.surfaceReason}`
|
|
1443
|
+
};
|
|
1444
|
+
});
|
|
1445
|
+
}
|
|
1446
|
+
function codexParentThreadId(source) {
|
|
1447
|
+
if (!source?.trim().startsWith("{")) return void 0;
|
|
1448
|
+
try {
|
|
1449
|
+
const object = asRecord(JSON.parse(source));
|
|
1450
|
+
const subagent = asRecord(object?.subagent);
|
|
1451
|
+
const threadSpawn = asRecord(subagent?.thread_spawn);
|
|
1452
|
+
return stringValue2(threadSpawn?.parent_thread_id);
|
|
1453
|
+
} catch {
|
|
1454
|
+
return void 0;
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1414
1457
|
function openCodexStateDatabase(statePath, warnings) {
|
|
1415
1458
|
try {
|
|
1416
1459
|
return new Database(statePath, { readonly: true, fileMustExist: true });
|
|
@@ -1475,24 +1518,30 @@ function isNativeSqliteMismatch(error) {
|
|
|
1475
1518
|
function exitLabel(status) {
|
|
1476
1519
|
return status === null ? "" : ` with exit code ${status}`;
|
|
1477
1520
|
}
|
|
1478
|
-
function threadToUsage(row, index, sessionFiles, options) {
|
|
1521
|
+
function threadToUsage(row, index, sessionFiles, options, importedModels = /* @__PURE__ */ new Map()) {
|
|
1522
|
+
const id = stringValue2(row.id) ?? `codex-thread-${index}`;
|
|
1479
1523
|
const sourcePath = stringValue2(row.rollout_path) ?? matchSessionPath(stringValue2(row.id), sessionFiles) ?? "";
|
|
1480
1524
|
const sessionBreakdown = sourcePath ? readSessionBreakdown(sourcePath) : emptyBreakdown();
|
|
1481
1525
|
const dbTokens = parseTokenTotal(row.tokens_used);
|
|
1482
|
-
const cwd = stringValue2(row.cwd) ?? fallbackCwdFromPath(sourcePath);
|
|
1526
|
+
const cwd = stringValue2(row.cwd) ?? sessionBreakdown.cwd ?? fallbackCwdFromPath(sourcePath);
|
|
1483
1527
|
const repo = resolveRepoInfo(cwd, options.config);
|
|
1528
|
+
const inferredModel = stringValue2(row.model) || sessionBreakdown.model ? void 0 : importedModels.get(id);
|
|
1529
|
+
const model = stringValue2(row.model) ?? sessionBreakdown.model ?? inferredModel?.model;
|
|
1530
|
+
const provider = sessionBreakdown.provider ?? inferredModel?.provider ?? stringValue2(row.model_provider);
|
|
1484
1531
|
const inputTokens = sessionBreakdown.inputTokens;
|
|
1485
1532
|
const cachedInputTokens = sessionBreakdown.cachedInputTokens;
|
|
1486
1533
|
const outputTokens = sessionBreakdown.outputTokens;
|
|
1487
1534
|
const reasoningTokens = sessionBreakdown.reasoningTokens;
|
|
1488
1535
|
const totalTokens = sessionBreakdown.totalTokens || dbTokens || inputTokens + outputTokens + reasoningTokens;
|
|
1489
1536
|
const hasPricedBreakdown = inputTokens + cachedInputTokens + outputTokens + reasoningTokens > 0;
|
|
1490
|
-
const warnings = [...repo.warnings, ...sessionBreakdown.warnings];
|
|
1537
|
+
const warnings = [...repo.warnings, ...sessionBreakdown.warnings, ...inferredModel ? ["model_inferred_from_import_source"] : []];
|
|
1491
1538
|
const threadSource = stringValue2(row.thread_source) ?? sessionBreakdown.threadSource;
|
|
1492
|
-
const sourceAppRaw = stringValue2(row.source)
|
|
1539
|
+
const sourceAppRaw = codexSourceRaw(stringValue2(row.source), sessionBreakdown.source, sessionBreakdown.originator, threadSource);
|
|
1493
1540
|
const surface = detectSurface(sourceAppRaw, threadSource);
|
|
1541
|
+
const startedAt = dateValue2(row.created_at) ?? sessionBreakdown.startedAt;
|
|
1542
|
+
const endedAt = dateValue2(row.updated_at) ?? sessionBreakdown.endedAt;
|
|
1494
1543
|
const usage = {
|
|
1495
|
-
id
|
|
1544
|
+
id,
|
|
1496
1545
|
sourceClient: "codex",
|
|
1497
1546
|
sourceApp: codexSourceAppLabel(sourceAppRaw, threadSource),
|
|
1498
1547
|
sourceAppRaw,
|
|
@@ -1502,11 +1551,11 @@ function threadToUsage(row, index, sessionFiles, options) {
|
|
|
1502
1551
|
cwd,
|
|
1503
1552
|
gitRemoteUrl: repo.gitRemoteUrl,
|
|
1504
1553
|
gitBranch: repo.gitBranch,
|
|
1505
|
-
title: stringValue2(row.title),
|
|
1506
|
-
startedAt
|
|
1507
|
-
endedAt
|
|
1508
|
-
model
|
|
1509
|
-
provider
|
|
1554
|
+
title: stringValue2(row.title) ?? sessionBreakdown.title,
|
|
1555
|
+
startedAt,
|
|
1556
|
+
endedAt,
|
|
1557
|
+
model,
|
|
1558
|
+
provider,
|
|
1510
1559
|
inputTokens,
|
|
1511
1560
|
cachedInputTokens,
|
|
1512
1561
|
outputTokens,
|
|
@@ -1521,7 +1570,7 @@ function threadToUsage(row, index, sessionFiles, options) {
|
|
|
1521
1570
|
rawTokenTotal: dbTokens || sessionBreakdown.totalTokens || void 0,
|
|
1522
1571
|
warnings,
|
|
1523
1572
|
codexHome: options.codexHome ?? path4.join(os2.homedir(), ".codex"),
|
|
1524
|
-
durationMs: durationMs(
|
|
1573
|
+
durationMs: durationMs(startedAt, endedAt),
|
|
1525
1574
|
rawEventCount: sessionBreakdown.rawEventCount,
|
|
1526
1575
|
parseStatus: sessionBreakdown.parseStatus,
|
|
1527
1576
|
parseErrors: sessionBreakdown.parseErrors,
|
|
@@ -1559,26 +1608,28 @@ function threadToUsage(row, index, sessionFiles, options) {
|
|
|
1559
1608
|
function readStandaloneSessions(sessionFiles, options) {
|
|
1560
1609
|
return [...sessionFiles.entries()].map(([id, sourcePath]) => {
|
|
1561
1610
|
const breakdown = readSessionBreakdown(sourcePath);
|
|
1562
|
-
const cwd = fallbackCwdFromPath(sourcePath);
|
|
1611
|
+
const cwd = breakdown.cwd ?? fallbackCwdFromPath(sourcePath);
|
|
1563
1612
|
const repo = resolveRepoInfo(cwd, options.config);
|
|
1564
|
-
const model =
|
|
1565
|
-
const
|
|
1566
|
-
|
|
1567
|
-
|
|
1613
|
+
const model = breakdown.model;
|
|
1614
|
+
const provider = breakdown.provider ?? providerForModel(model ?? "");
|
|
1615
|
+
const sourceAppRaw = codexSourceRaw(void 0, breakdown.source, breakdown.originator, breakdown.threadSource);
|
|
1616
|
+
const surface = detectSurface(sourceAppRaw, breakdown.threadSource);
|
|
1617
|
+
const usage = {
|
|
1618
|
+
id: breakdown.id ?? id,
|
|
1568
1619
|
sourceClient: "codex",
|
|
1569
|
-
sourceApp: codexSourceAppLabel(
|
|
1570
|
-
sourceAppRaw
|
|
1620
|
+
sourceApp: codexSourceAppLabel(sourceAppRaw, breakdown.threadSource),
|
|
1621
|
+
sourceAppRaw,
|
|
1571
1622
|
sourcePath,
|
|
1572
1623
|
repoRoot: repo.repoRoot,
|
|
1573
1624
|
repoName: repo.repoName,
|
|
1574
1625
|
cwd,
|
|
1575
1626
|
gitRemoteUrl: repo.gitRemoteUrl,
|
|
1576
1627
|
gitBranch: repo.gitBranch,
|
|
1577
|
-
title:
|
|
1578
|
-
startedAt:
|
|
1579
|
-
endedAt:
|
|
1628
|
+
title: breakdown.title,
|
|
1629
|
+
startedAt: breakdown.startedAt,
|
|
1630
|
+
endedAt: breakdown.endedAt,
|
|
1580
1631
|
model,
|
|
1581
|
-
provider
|
|
1632
|
+
provider,
|
|
1582
1633
|
inputTokens: breakdown.inputTokens,
|
|
1583
1634
|
cachedInputTokens: breakdown.cachedInputTokens,
|
|
1584
1635
|
outputTokens: breakdown.outputTokens,
|
|
@@ -1591,8 +1642,9 @@ function readStandaloneSessions(sessionFiles, options) {
|
|
|
1591
1642
|
estimatedCostUsd: void 0,
|
|
1592
1643
|
messageCount: breakdown.messageCount,
|
|
1593
1644
|
rawTokenTotal: breakdown.totalTokens || void 0,
|
|
1594
|
-
warnings: [...repo.warnings, ...breakdown.warnings
|
|
1645
|
+
warnings: [...repo.warnings, ...breakdown.warnings],
|
|
1595
1646
|
codexHome: options.codexHome ?? path4.join(os2.homedir(), ".codex"),
|
|
1647
|
+
durationMs: durationMs(breakdown.startedAt, breakdown.endedAt),
|
|
1596
1648
|
rawEventCount: breakdown.rawEventCount,
|
|
1597
1649
|
parseStatus: breakdown.parseStatus,
|
|
1598
1650
|
parseErrors: breakdown.parseErrors,
|
|
@@ -1619,8 +1671,87 @@ function readStandaloneSessions(sessionFiles, options) {
|
|
|
1619
1671
|
sessionOutcome: inferOutcome(breakdown),
|
|
1620
1672
|
compaction: breakdown.compaction
|
|
1621
1673
|
};
|
|
1674
|
+
const hasPricedBreakdown = breakdown.inputTokens + breakdown.cachedInputTokens + breakdown.outputTokens + breakdown.reasoningTokens > 0;
|
|
1675
|
+
const cost = hasPricedBreakdown ? calculateCostUsd(usage, options.pricing) : void 0;
|
|
1676
|
+
return {
|
|
1677
|
+
...usage,
|
|
1678
|
+
estimatedCostUsd: cost,
|
|
1679
|
+
warnings: cost === void 0 ? [...usage.warnings, hasPricedBreakdown ? "unknown_pricing" : "missing_token_breakdown"] : usage.warnings
|
|
1680
|
+
};
|
|
1622
1681
|
});
|
|
1623
1682
|
}
|
|
1683
|
+
function hasImportedSession(sessions, candidate) {
|
|
1684
|
+
const candidatePath = comparableSourcePath(candidate.sourcePath);
|
|
1685
|
+
return sessions.some((session) => {
|
|
1686
|
+
if (session.id && session.id === candidate.id) return true;
|
|
1687
|
+
const sessionPath = comparableSourcePath(session.sourcePath);
|
|
1688
|
+
return Boolean(candidatePath && sessionPath && candidatePath === sessionPath);
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
1691
|
+
function readImportedModelMap(codexHome, warnings) {
|
|
1692
|
+
const importsPath = path4.join(codexHome, "external_agent_session_imports.json");
|
|
1693
|
+
const models = /* @__PURE__ */ new Map();
|
|
1694
|
+
if (!fs4.existsSync(importsPath)) return models;
|
|
1695
|
+
let parsed;
|
|
1696
|
+
try {
|
|
1697
|
+
parsed = JSON.parse(fs4.readFileSync(importsPath, "utf8"));
|
|
1698
|
+
} catch (error) {
|
|
1699
|
+
warnings.push(`Unable to read Codex external import index at ${importsPath}: ${errorMessage(error)}`);
|
|
1700
|
+
return models;
|
|
1701
|
+
}
|
|
1702
|
+
const records = Array.isArray(asRecord(parsed)?.records) ? asRecord(parsed)?.records : [];
|
|
1703
|
+
for (const record of records) {
|
|
1704
|
+
const item = asRecord(record);
|
|
1705
|
+
const importedThreadId = stringValue2(item?.imported_thread_id);
|
|
1706
|
+
const sourcePath = normalizeVerbatimPath(stringValue2(item?.source_path));
|
|
1707
|
+
if (!importedThreadId || !sourcePath || !fs4.existsSync(sourcePath)) continue;
|
|
1708
|
+
const model = readImportedSourceModel(sourcePath, warnings);
|
|
1709
|
+
if (model) models.set(importedThreadId, { model, provider: providerForModel(model) });
|
|
1710
|
+
}
|
|
1711
|
+
return models;
|
|
1712
|
+
}
|
|
1713
|
+
function readImportedSourceModel(sourcePath, warnings) {
|
|
1714
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1715
|
+
let content;
|
|
1716
|
+
try {
|
|
1717
|
+
content = fs4.readFileSync(sourcePath, "utf8");
|
|
1718
|
+
} catch (error) {
|
|
1719
|
+
warnings.push(`Unable to read Codex external import source at ${sourcePath}: ${errorMessage(error)}`);
|
|
1720
|
+
return void 0;
|
|
1721
|
+
}
|
|
1722
|
+
for (const [index, line] of content.split(/\r?\n/).entries()) {
|
|
1723
|
+
if (!line.trim()) continue;
|
|
1724
|
+
try {
|
|
1725
|
+
const model = modelFromImportedRecord(JSON.parse(line));
|
|
1726
|
+
if (model) counts.set(model, (counts.get(model) ?? 0) + 1);
|
|
1727
|
+
} catch (error) {
|
|
1728
|
+
warnings.push(`Unable to parse Codex external import source ${sourcePath} line ${index + 1}: ${errorMessage(error)}`);
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0];
|
|
1732
|
+
}
|
|
1733
|
+
function modelFromImportedRecord(record) {
|
|
1734
|
+
const object = asRecord(record);
|
|
1735
|
+
const type = stringValue2(object?.type) ?? stringValue2(asRecord(object?.payload)?.type);
|
|
1736
|
+
const message = asRecord(object?.message) ?? asRecord(asRecord(object?.payload)?.message);
|
|
1737
|
+
const model = stringValue2(message?.model) ?? stringValue2(object?.model);
|
|
1738
|
+
if (!model || model === "<synthetic>") return void 0;
|
|
1739
|
+
if (type && type !== "assistant") return void 0;
|
|
1740
|
+
return model;
|
|
1741
|
+
}
|
|
1742
|
+
function providerForModel(model) {
|
|
1743
|
+
const normalized = model.toLowerCase();
|
|
1744
|
+
if (!normalized) return void 0;
|
|
1745
|
+
if (normalized.startsWith("claude")) return "anthropic";
|
|
1746
|
+
if (normalized.startsWith("gpt") || normalized.startsWith("o")) return "openai";
|
|
1747
|
+
return void 0;
|
|
1748
|
+
}
|
|
1749
|
+
function normalizeVerbatimPath(value) {
|
|
1750
|
+
return value?.replace(/^\\\\\?\\/, "");
|
|
1751
|
+
}
|
|
1752
|
+
function comparableSourcePath(value) {
|
|
1753
|
+
return normalizeVerbatimPath(value)?.replace(/\\/g, "/");
|
|
1754
|
+
}
|
|
1624
1755
|
function indexSessionFiles(sessionsPath, warnings) {
|
|
1625
1756
|
const files = /* @__PURE__ */ new Map();
|
|
1626
1757
|
try {
|
|
@@ -1657,6 +1788,7 @@ function readSessionBreakdown(filePath) {
|
|
|
1657
1788
|
let responseMessageCount = 0;
|
|
1658
1789
|
let lastUserMessageText;
|
|
1659
1790
|
for (const record of records) {
|
|
1791
|
+
applySessionBounds(breakdown, record);
|
|
1660
1792
|
applySessionMetadata(breakdown, record);
|
|
1661
1793
|
applyPromptTimeline(breakdown, record);
|
|
1662
1794
|
if (isCodexEventMessage(record)) eventMessageCount += 1;
|
|
@@ -1739,6 +1871,13 @@ function matchSessionPath(id, sessionFiles) {
|
|
|
1739
1871
|
}
|
|
1740
1872
|
function emptyBreakdown() {
|
|
1741
1873
|
return {
|
|
1874
|
+
id: void 0,
|
|
1875
|
+
cwd: void 0,
|
|
1876
|
+
title: void 0,
|
|
1877
|
+
startedAt: void 0,
|
|
1878
|
+
endedAt: void 0,
|
|
1879
|
+
model: void 0,
|
|
1880
|
+
provider: void 0,
|
|
1742
1881
|
inputTokens: 0,
|
|
1743
1882
|
cachedInputTokens: 0,
|
|
1744
1883
|
outputTokens: 0,
|
|
@@ -1783,10 +1922,26 @@ function applySessionMetadata(breakdown, record) {
|
|
|
1783
1922
|
if (!record || typeof record !== "object") return;
|
|
1784
1923
|
const object = record;
|
|
1785
1924
|
const payload = firstObject3(object.payload) ?? object;
|
|
1925
|
+
const collaborationMode = firstObject3(payload.collaboration_mode);
|
|
1926
|
+
const collaborationSettings = firstObject3(collaborationMode?.settings);
|
|
1927
|
+
breakdown.id ??= stringValue2(payload.id);
|
|
1928
|
+
breakdown.cwd ??= stringValue2(payload.cwd);
|
|
1929
|
+
breakdown.title ??= stringValue2(payload.title);
|
|
1930
|
+
breakdown.model ??= stringValue2(payload.model) ?? stringValue2(collaborationSettings?.model);
|
|
1931
|
+
breakdown.provider ??= stringValue2(payload.model_provider);
|
|
1786
1932
|
breakdown.source ??= stringValue2(payload.source);
|
|
1787
1933
|
breakdown.originator ??= stringValue2(payload.originator);
|
|
1788
1934
|
breakdown.threadSource ??= stringValue2(payload.thread_source);
|
|
1789
1935
|
}
|
|
1936
|
+
function applySessionBounds(breakdown, record) {
|
|
1937
|
+
if (!record || typeof record !== "object") return;
|
|
1938
|
+
const object = record;
|
|
1939
|
+
const payload = firstObject3(object.payload);
|
|
1940
|
+
const timestamp3 = dateValue2(object.timestamp) ?? dateValue2(payload?.timestamp);
|
|
1941
|
+
const sessionStartedAt = object.type === "session_meta" ? dateValue2(payload?.timestamp) ?? timestamp3 : timestamp3;
|
|
1942
|
+
if (sessionStartedAt && (!breakdown.startedAt || sessionStartedAt < breakdown.startedAt)) breakdown.startedAt = sessionStartedAt;
|
|
1943
|
+
if (timestamp3 && (!breakdown.endedAt || timestamp3 > breakdown.endedAt)) breakdown.endedAt = timestamp3;
|
|
1944
|
+
}
|
|
1790
1945
|
function isCodexEventMessage(record) {
|
|
1791
1946
|
if (!record || typeof record !== "object") return false;
|
|
1792
1947
|
const object = record;
|
|
@@ -1887,9 +2042,14 @@ function hasNearbyDuplicatePromptTimelineItem(items, item) {
|
|
|
1887
2042
|
return Math.abs(existingTime - itemTime) <= 5e3;
|
|
1888
2043
|
});
|
|
1889
2044
|
}
|
|
2045
|
+
function codexSourceRaw(source, sessionSource, originator, threadSource) {
|
|
2046
|
+
if (originator && /codex\s+desktop/i.test(originator)) return originator;
|
|
2047
|
+
return source ?? sessionSource ?? originator ?? threadSource;
|
|
2048
|
+
}
|
|
1890
2049
|
function detectSurface(source, threadSource) {
|
|
1891
2050
|
const raw = `${source ?? ""} ${threadSource ?? ""}`.toLowerCase();
|
|
1892
2051
|
if (threadSource === "subagent") return { surface: "codex_exec", confidence: "medium", reason: "thread_source is subagent" };
|
|
2052
|
+
if (raw.includes("codex desktop")) return { surface: "local_agent", confidence: "high", reason: "originator metadata identifies Codex Desktop" };
|
|
1893
2053
|
if (raw.includes("vscode")) return { surface: "vscode_extension", confidence: "high", reason: "source metadata includes vscode" };
|
|
1894
2054
|
if (raw.includes("cli") || raw.includes("terminal") || raw.includes("tui")) return { surface: "terminal_cli", confidence: "high", reason: "source metadata indicates CLI/terminal" };
|
|
1895
2055
|
if (raw.includes("exec")) return { surface: "codex_exec", confidence: "medium", reason: "source metadata includes exec" };
|
|
@@ -1913,9 +2073,13 @@ function durationMs(startedAt, endedAt) {
|
|
|
1913
2073
|
function stringValue2(value) {
|
|
1914
2074
|
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
1915
2075
|
}
|
|
2076
|
+
function asRecord(value) {
|
|
2077
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
|
|
2078
|
+
}
|
|
1916
2079
|
function codexSourceAppLabel(source, threadSource) {
|
|
1917
2080
|
if (threadSource === "subagent") return "Codex subagent";
|
|
1918
2081
|
if (!source) return "Codex";
|
|
2082
|
+
if (/codex\s+desktop/i.test(source)) return "Codex Desktop";
|
|
1919
2083
|
if (source === "vscode" || source === "codex_vscode" || source === "codex-vscode") return "VS Code";
|
|
1920
2084
|
if (source === "cli" || source === "terminal" || source === "codex-tui") return "Terminal";
|
|
1921
2085
|
if (source === "codex_app" || source === "codex-app") return "Codex app";
|
|
@@ -2067,13 +2231,14 @@ function claudeFileToUsage(file, index, options, seenUsageKeys) {
|
|
|
2067
2231
|
const cwd = breakdown.cwd ?? fallbackCwd;
|
|
2068
2232
|
const repo = resolveRepoInfo(cwd, options.config);
|
|
2069
2233
|
const warnings = [...repo.warnings, ...breakdown.warnings];
|
|
2234
|
+
const entrypoint = breakdown.entrypoint ?? inferClaudeEntrypointFromPath(file.filePath);
|
|
2070
2235
|
if (!breakdown.cwd) warnings.push("repo_inferred_from_claude_project_dir");
|
|
2071
2236
|
const hasTokenBreakdown = breakdown.totalTokens > 0 && breakdown.usageCount > 0;
|
|
2072
2237
|
const usage = {
|
|
2073
2238
|
id,
|
|
2074
2239
|
sourceClient: "claude",
|
|
2075
|
-
sourceApp: claudeSourceAppLabel(
|
|
2076
|
-
sourceAppRaw:
|
|
2240
|
+
sourceApp: claudeSourceAppLabel(entrypoint),
|
|
2241
|
+
sourceAppRaw: entrypoint,
|
|
2077
2242
|
sourcePath: file.filePath,
|
|
2078
2243
|
repoRoot: repo.repoRoot,
|
|
2079
2244
|
repoName: repo.repoName,
|
|
@@ -2103,9 +2268,9 @@ function claudeFileToUsage(file, index, options, seenUsageKeys) {
|
|
|
2103
2268
|
rawEventCount: breakdown.rawEventCount,
|
|
2104
2269
|
parseStatus: breakdown.parseStatus,
|
|
2105
2270
|
parseErrors: breakdown.parseErrors,
|
|
2106
|
-
detectedSurface: claudeSurface(
|
|
2271
|
+
detectedSurface: claudeSurface(entrypoint),
|
|
2107
2272
|
surfaceConfidence: "medium",
|
|
2108
|
-
surfaceReason:
|
|
2273
|
+
surfaceReason: entrypoint ? `Claude Code entrypoint: ${entrypoint}` : "Claude Code transcript",
|
|
2109
2274
|
userPromptCount: breakdown.userPromptCount,
|
|
2110
2275
|
assistantMessageCount: breakdown.assistantMessageCount,
|
|
2111
2276
|
toolCallCount: breakdown.toolCallCount,
|
|
@@ -2132,6 +2297,9 @@ function claudeFileToUsage(file, index, options, seenUsageKeys) {
|
|
|
2132
2297
|
warnings: cost === void 0 ? [...usage.warnings, "unknown_pricing"] : usage.warnings
|
|
2133
2298
|
};
|
|
2134
2299
|
}
|
|
2300
|
+
function inferClaudeEntrypointFromPath(filePath) {
|
|
2301
|
+
return filePath.split(path5.sep).includes("local-agent-mode-sessions") ? "local-agent" : void 0;
|
|
2302
|
+
}
|
|
2135
2303
|
function readClaudeBreakdown(filePath, seenUsageKeys) {
|
|
2136
2304
|
const breakdown = emptyBreakdown2();
|
|
2137
2305
|
try {
|
|
@@ -2308,13 +2476,13 @@ function inferOutcome2(breakdown) {
|
|
|
2308
2476
|
function claudeSurface(entrypoint) {
|
|
2309
2477
|
const value = entrypoint?.toLowerCase() ?? "";
|
|
2310
2478
|
if (value.includes("vscode")) return "vscode_extension";
|
|
2311
|
-
if (value.includes("local-agent")) return "local_agent";
|
|
2479
|
+
if (value.includes("local-agent") || value.includes("desktop")) return "local_agent";
|
|
2312
2480
|
return "terminal_cli";
|
|
2313
2481
|
}
|
|
2314
2482
|
function claudeSourceAppLabel(entrypoint) {
|
|
2315
2483
|
const value = entrypoint?.toLowerCase() ?? "";
|
|
2316
2484
|
if (value.includes("vscode")) return "VS Code";
|
|
2317
|
-
if (value.includes("local-agent")) return "Claude
|
|
2485
|
+
if (value.includes("local-agent") || value.includes("desktop")) return "Claude Desktop App";
|
|
2318
2486
|
if (value === "cli" || value.includes("terminal")) return "Terminal";
|
|
2319
2487
|
return "Claude Code";
|
|
2320
2488
|
}
|
|
@@ -2434,9 +2602,9 @@ function scanUsageSources(options) {
|
|
|
2434
2602
|
import fs6 from "node:fs";
|
|
2435
2603
|
var scanCacheTtlMs = 1e4;
|
|
2436
2604
|
var scanCache;
|
|
2437
|
-
function readDashboardData(filters = {}) {
|
|
2605
|
+
function readDashboardData(filters = {}, options = {}) {
|
|
2438
2606
|
const raw = readRawUsageData();
|
|
2439
|
-
return buildDashboardSnapshot({ ...raw, filters });
|
|
2607
|
+
return buildDashboardSnapshot({ ...raw, sessions: displaySessions(raw.sessions, options), filters });
|
|
2440
2608
|
}
|
|
2441
2609
|
function clearScanCache() {
|
|
2442
2610
|
scanCache = void 0;
|
|
@@ -2473,6 +2641,11 @@ function parseFilters(query) {
|
|
|
2473
2641
|
if (to) filters.to = to;
|
|
2474
2642
|
return filters;
|
|
2475
2643
|
}
|
|
2644
|
+
function parseDashboardOptions(query) {
|
|
2645
|
+
return {
|
|
2646
|
+
splitSourceApps: query.splitSourceApps === "true" || query.splitSourceApps === "1"
|
|
2647
|
+
};
|
|
2648
|
+
}
|
|
2476
2649
|
function readPricingData() {
|
|
2477
2650
|
const config = loadConfig();
|
|
2478
2651
|
const pricingPath = resolvePricingPath(config);
|
|
@@ -2512,6 +2685,39 @@ function listQuery(value) {
|
|
|
2512
2685
|
const parsed = values.flatMap((item) => typeof item === "string" ? item.split(",") : []).map((item) => item.trim()).filter(Boolean);
|
|
2513
2686
|
return parsed.length ? parsed : void 0;
|
|
2514
2687
|
}
|
|
2688
|
+
function displaySessions(sessions, options) {
|
|
2689
|
+
if (!options.splitSourceApps) return sessions;
|
|
2690
|
+
return sessions.map((session) => {
|
|
2691
|
+
const sourceApp = sourceScopedAppLabel(session);
|
|
2692
|
+
return sourceApp === session.sourceApp ? session : { ...session, sourceApp };
|
|
2693
|
+
});
|
|
2694
|
+
}
|
|
2695
|
+
function sourceScopedAppLabel(session) {
|
|
2696
|
+
const app = session.sourceApp || fallbackSurfaceLabel(session.detectedSurface);
|
|
2697
|
+
if (!app || app === "Unknown") return sourceClientLabel(session.sourceClient);
|
|
2698
|
+
if (!shouldScopeApp(app)) return app;
|
|
2699
|
+
return `${sourceClientLabel(session.sourceClient)} on ${app}`;
|
|
2700
|
+
}
|
|
2701
|
+
function shouldScopeApp(app) {
|
|
2702
|
+
const normalized = app.toLowerCase();
|
|
2703
|
+
return normalized.includes("vs code") || normalized.includes("vscode") || normalized.includes("terminal") || normalized.includes("cli");
|
|
2704
|
+
}
|
|
2705
|
+
function sourceClientLabel(source) {
|
|
2706
|
+
if (source === "codex") return "Codex";
|
|
2707
|
+
if (source === "claude") return "Claude Code";
|
|
2708
|
+
if (source === "gemini-cli") return "Gemini CLI";
|
|
2709
|
+
if (source === "opencode") return "OpenCode";
|
|
2710
|
+
if (source === "cursor") return "Cursor";
|
|
2711
|
+
return "Unknown";
|
|
2712
|
+
}
|
|
2713
|
+
function fallbackSurfaceLabel(surface) {
|
|
2714
|
+
if (surface === "terminal_cli") return "Terminal";
|
|
2715
|
+
if (surface === "vscode_extension") return "VS Code";
|
|
2716
|
+
if (surface === "local_agent") return "Desktop local agent";
|
|
2717
|
+
if (surface === "codex_exec") return "Codex";
|
|
2718
|
+
if (surface === "codex_app_cloud") return "Codex app";
|
|
2719
|
+
return "Unknown";
|
|
2720
|
+
}
|
|
2515
2721
|
function runtimeCwd() {
|
|
2516
2722
|
return process.env.INIT_CWD || process.cwd();
|
|
2517
2723
|
}
|
|
@@ -2534,11 +2740,11 @@ var demoPricing = {
|
|
|
2534
2740
|
function demoModeEnabled() {
|
|
2535
2741
|
return process.env.REPOSPEND_DEMO_DATA === "lotr";
|
|
2536
2742
|
}
|
|
2537
|
-
function readDemoDashboardData(filters = {}) {
|
|
2743
|
+
function readDemoDashboardData(filters = {}, options = {}) {
|
|
2538
2744
|
return buildDashboardSnapshot({
|
|
2539
2745
|
sources: demoSources,
|
|
2540
2746
|
sourceStats: demoSourceStats,
|
|
2541
|
-
sessions: demoSessions,
|
|
2747
|
+
sessions: displaySessions(demoSessions, options),
|
|
2542
2748
|
filters
|
|
2543
2749
|
});
|
|
2544
2750
|
}
|
|
@@ -2796,7 +3002,7 @@ var demoInputs = [
|
|
|
2796
3002
|
repo: "rivendell-dashboard",
|
|
2797
3003
|
title: "Refactor the council-of-elrond planner",
|
|
2798
3004
|
sourceClient: "claude",
|
|
2799
|
-
sourceApp: "Claude
|
|
3005
|
+
sourceApp: "Claude Desktop App",
|
|
2800
3006
|
sourceAppRaw: "claude-desktop-local-agent",
|
|
2801
3007
|
model: "claude-sonnet-4-6",
|
|
2802
3008
|
provider: "anthropic",
|
|
@@ -3106,7 +3312,7 @@ function detectedSurfaceForApp(sourceApp) {
|
|
|
3106
3312
|
if (sourceApp === "VS Code") return "vscode_extension";
|
|
3107
3313
|
if (sourceApp === "Terminal") return "terminal_cli";
|
|
3108
3314
|
if (sourceApp === "Codex app") return "codex_app_cloud";
|
|
3109
|
-
if (sourceApp === "Claude
|
|
3315
|
+
if (sourceApp === "Claude Desktop App") return "local_agent";
|
|
3110
3316
|
return "terminal_cli";
|
|
3111
3317
|
}
|
|
3112
3318
|
function timestamp2(hour, minute) {
|
|
@@ -3326,9 +3532,11 @@ function createServer(options = {}) {
|
|
|
3326
3532
|
const app = Fastify({ logger: false });
|
|
3327
3533
|
app.get("/api/health", async () => ({ ok: true, app: "RepoSpend", version: readPackageVersion() }));
|
|
3328
3534
|
app.get("/api/dashboard", async (request) => {
|
|
3329
|
-
const
|
|
3535
|
+
const query = request.query;
|
|
3536
|
+
const filters = parseFilters(query);
|
|
3537
|
+
const dashboardOptions = parseDashboardOptions(query);
|
|
3330
3538
|
const demoMode = demoModeEnabled();
|
|
3331
|
-
const data = demoMode ? readDemoDashboardData(filters) : readDashboardData(filters);
|
|
3539
|
+
const data = demoMode ? readDemoDashboardData(filters, dashboardOptions) : readDashboardData(filters, dashboardOptions);
|
|
3332
3540
|
const rtkGain = demoMode ? readDemoRtkGain() : await readRtkGain();
|
|
3333
3541
|
return {
|
|
3334
3542
|
...data,
|
package/package.json
CHANGED