repospend 0.0.1

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 ADDED
@@ -0,0 +1,2167 @@
1
+ #!/usr/bin/env node
2
+
3
+ // apps/server/src/cli.ts
4
+ import { spawn } from "node:child_process";
5
+
6
+ // packages/core/src/aggregate.ts
7
+ function summarize(sessions) {
8
+ const repos = groupByRepo(sessions);
9
+ const cost = sumKnownCost(sessions);
10
+ const inputTokens = sum(sessions, "inputTokens");
11
+ const cachedInputTokens = sum(sessions, "cachedInputTokens");
12
+ return {
13
+ estimatedCostUsd: cost.knownCount > 0 ? cost.value : void 0,
14
+ knownCostSessions: cost.knownCount,
15
+ totalTokens: sum(sessions, "totalTokens"),
16
+ cachedInputTokens,
17
+ inputTokens,
18
+ outputTokens: sum(sessions, "outputTokens"),
19
+ reasoningTokens: sum(sessions, "reasoningTokens"),
20
+ cachedInputPct: inputTokens > 0 ? cachedInputTokens / inputTokens : 0,
21
+ sessionCount: sessions.length,
22
+ repoCount: repos.length,
23
+ modelCount: new Set(sessions.map((session) => session.model).filter(Boolean)).size,
24
+ mostExpensiveRepo: repos.filter((repo) => repo.estimatedCostUsd !== void 0).sort((a, b) => (b.estimatedCostUsd ?? 0) - (a.estimatedCostUsd ?? 0))[0],
25
+ unknownCostSessions: sessions.filter((session) => session.estimatedCostUsd === void 0).length,
26
+ userPromptCount: optionalSum(sessions, "userPromptCount"),
27
+ assistantMessageCount: optionalSum(sessions, "assistantMessageCount"),
28
+ toolCallCount: optionalSum(sessions, "toolCallCount"),
29
+ shellCommandCount: optionalSum(sessions, "shellCommandCount"),
30
+ failedToolCallCount: optionalSum(sessions, "failedToolCallCount"),
31
+ nonZeroCommandEvents: optionalSum(sessions, "nonZeroCommandEvents"),
32
+ importantCommandFailures: optionalSum(sessions, "importantCommandFailures"),
33
+ harmlessNonZeroEvents: optionalSum(sessions, "harmlessNonZeroEvents"),
34
+ exploratoryMisses: optionalSum(sessions, "exploratoryMisses"),
35
+ repeatedFailureClusters: optionalSum(sessions, "repeatedFailureClusters"),
36
+ fileReadCount: optionalSum(sessions, "fileReadCount"),
37
+ fileEditCount: optionalSum(sessions, "fileEditCount"),
38
+ rawEventCount: optionalSum(sessions, "rawEventCount"),
39
+ parseFailureCount: sessions.filter((session) => session.parseStatus === "failed" || (session.parseErrors?.length ?? 0) > 0).length,
40
+ unknownSurfaceSessions: sessions.filter((session) => session.detectedSurface === "unknown").length,
41
+ noCodeChangeSessions: sessions.filter((session) => (session.fileEditCount ?? 0) === 0).length,
42
+ researchOnlySessions: sessions.filter((session) => session.sessionOutcome === "research_only").length,
43
+ warnings: unique(sessions.flatMap((session) => session.warnings))
44
+ };
45
+ }
46
+ function groupByRepo(sessions) {
47
+ return groupBy(sessions, (session) => session.repoRoot, (session) => session.repoName);
48
+ }
49
+ function groupByModel(sessions) {
50
+ return groupBy(sessions, (session) => session.model ?? "unknown-model", (session) => session.model ?? "Unknown model");
51
+ }
52
+ function groupBySourceApp(sessions) {
53
+ return groupBy(sessions, (session) => session.sourceApp, (session) => session.sourceApp);
54
+ }
55
+ function groupByDay(sessions) {
56
+ return groupBy(sessions, (session) => dateKey(session.startedAt), (session) => dateKey(session.startedAt));
57
+ }
58
+ function groupByHour(sessions) {
59
+ return groupBy(sessions, (session) => hourKey(session.startedAt), (session) => hourKey(session.startedAt));
60
+ }
61
+ function groupBy(sessions, keyFn, labelFn) {
62
+ const groups = /* @__PURE__ */ new Map();
63
+ for (const session of sessions) {
64
+ const id = keyFn(session);
65
+ const existing = groups.get(id);
66
+ const group = existing ?? {
67
+ id,
68
+ label: labelFn(session),
69
+ estimatedCostUsd: void 0,
70
+ inputTokens: 0,
71
+ cachedInputTokens: 0,
72
+ outputTokens: 0,
73
+ reasoningTokens: 0,
74
+ totalTokens: 0,
75
+ sessionCount: 0,
76
+ messageCount: 0,
77
+ warnings: []
78
+ };
79
+ group.inputTokens += session.inputTokens;
80
+ group.cachedInputTokens += session.cachedInputTokens;
81
+ group.outputTokens += session.outputTokens;
82
+ group.reasoningTokens += session.reasoningTokens;
83
+ group.totalTokens += session.totalTokens;
84
+ group.sessionCount += 1;
85
+ group.messageCount += session.messageCount;
86
+ group.warnings = unique([...group.warnings, ...session.warnings]);
87
+ if (session.estimatedCostUsd !== void 0) {
88
+ group.estimatedCostUsd = roundCurrency((group.estimatedCostUsd ?? 0) + session.estimatedCostUsd);
89
+ }
90
+ groups.set(id, group);
91
+ }
92
+ return [...groups.values()].sort((a, b) => (b.estimatedCostUsd ?? 0) - (a.estimatedCostUsd ?? 0) || b.totalTokens - a.totalTokens);
93
+ }
94
+ function toCsv(sessions) {
95
+ const columns = [
96
+ "id",
97
+ "sourceClient",
98
+ "sourceApp",
99
+ "sourceAppRaw",
100
+ "sourcePath",
101
+ "repoRoot",
102
+ "repoName",
103
+ "cwd",
104
+ "gitRemoteUrl",
105
+ "gitBranch",
106
+ "title",
107
+ "startedAt",
108
+ "endedAt",
109
+ "model",
110
+ "provider",
111
+ "inputTokens",
112
+ "cachedInputTokens",
113
+ "outputTokens",
114
+ "reasoningTokens",
115
+ "reasoningOutputTokens",
116
+ "totalTokens",
117
+ "tokenAggregationMethod",
118
+ "tokenConfidence",
119
+ "tokenSnapshotCount",
120
+ "estimatedCostUsd",
121
+ "messageCount",
122
+ "rawTokenTotal",
123
+ "warnings",
124
+ "codexHome",
125
+ "durationMs",
126
+ "rawEventCount",
127
+ "parseStatus",
128
+ "parseErrors",
129
+ "detectedSurface",
130
+ "surfaceConfidence",
131
+ "surfaceReason",
132
+ "userPromptCount",
133
+ "assistantMessageCount",
134
+ "toolCallCount",
135
+ "shellCommandCount",
136
+ "failedToolCallCount",
137
+ "nonZeroCommandEvents",
138
+ "importantCommandFailures",
139
+ "harmlessNonZeroEvents",
140
+ "exploratoryMisses",
141
+ "repeatedFailureClusters",
142
+ "commandIssueSeverity",
143
+ "commandIssueImpact",
144
+ "topFailureType",
145
+ "fileReadCount",
146
+ "fileEditCount",
147
+ "sessionOutcome"
148
+ ];
149
+ const rows = sessions.map((session) => columns.map((column) => csvEscape(Array.isArray(session[column]) ? session[column].join(";") : session[column])));
150
+ return [columns.join(","), ...rows.map((row) => row.join(","))].join("\n");
151
+ }
152
+ function csvEscape(value) {
153
+ if (value === void 0 || value === null) return "";
154
+ const text = String(value);
155
+ return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
156
+ }
157
+ function sum(sessions, key) {
158
+ return sessions.reduce((total, session) => total + session[key], 0);
159
+ }
160
+ function optionalSum(sessions, key) {
161
+ return sessions.reduce((total, session) => total + (session[key] ?? 0), 0);
162
+ }
163
+ function sumKnownCost(sessions) {
164
+ return sessions.reduce(
165
+ (state, session) => ({
166
+ value: roundCurrency(state.value + (session.estimatedCostUsd ?? 0)),
167
+ knownCount: state.knownCount + (session.estimatedCostUsd === void 0 ? 0 : 1)
168
+ }),
169
+ { value: 0, knownCount: 0 }
170
+ );
171
+ }
172
+ function dateKey(date) {
173
+ return date ? date.slice(0, 10) : "unknown-date";
174
+ }
175
+ function hourKey(date) {
176
+ if (!date) return "unknown-hour";
177
+ const parsed = new Date(date);
178
+ if (Number.isNaN(parsed.getTime())) return "unknown-hour";
179
+ return parsed.toISOString().slice(0, 13) + ":00:00.000Z";
180
+ }
181
+ function roundCurrency(value) {
182
+ return Number(value.toFixed(6));
183
+ }
184
+ function unique(values) {
185
+ return [...new Set(values)].sort();
186
+ }
187
+
188
+ // packages/core/src/config.ts
189
+ import fs2 from "node:fs";
190
+ import path2 from "node:path";
191
+
192
+ // packages/core/src/pricing.ts
193
+ import fs from "node:fs";
194
+ import os from "node:os";
195
+ import path from "node:path";
196
+ var pricingInfo = {
197
+ sourceName: "OpenAI API pricing",
198
+ sourceUrl: "https://developers.openai.com/api/docs/pricing",
199
+ unit: "USD per 1M tokens",
200
+ 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."
202
+ };
203
+ var defaultPricing = {
204
+ "gpt-5.5": { inputPerMillion: 5, cachedInputPerMillion: 0.5, outputPerMillion: 30, reasoningOutputPerMillion: 30 },
205
+ "gpt-5.5-pro": { inputPerMillion: 30, outputPerMillion: 180, reasoningOutputPerMillion: 180 },
206
+ "gpt-5.4": { inputPerMillion: 2.5, cachedInputPerMillion: 0.25, outputPerMillion: 15, reasoningOutputPerMillion: 15 },
207
+ "gpt-5.4-mini": { inputPerMillion: 0.75, cachedInputPerMillion: 0.075, outputPerMillion: 4.5, reasoningOutputPerMillion: 4.5 },
208
+ "gpt-5.4-nano": { inputPerMillion: 0.2, cachedInputPerMillion: 0.02, outputPerMillion: 1.25, reasoningOutputPerMillion: 1.25 },
209
+ "gpt-5.4-pro": { inputPerMillion: 30, outputPerMillion: 180, reasoningOutputPerMillion: 180 },
210
+ "gpt-5.2": { inputPerMillion: 1.75, cachedInputPerMillion: 0.175, outputPerMillion: 14, reasoningOutputPerMillion: 14 },
211
+ "gpt-5.2-chat-latest": { inputPerMillion: 1.75, cachedInputPerMillion: 0.175, outputPerMillion: 14, reasoningOutputPerMillion: 14 },
212
+ "gpt-5.2-codex": { inputPerMillion: 1.75, cachedInputPerMillion: 0.175, outputPerMillion: 14, reasoningOutputPerMillion: 14 },
213
+ "gpt-5.2-pro": { inputPerMillion: 21, outputPerMillion: 168, reasoningOutputPerMillion: 168 },
214
+ "gpt-5.1": { inputPerMillion: 1.25, cachedInputPerMillion: 0.125, outputPerMillion: 10, reasoningOutputPerMillion: 10 },
215
+ "gpt-5.1-chat-latest": { inputPerMillion: 1.25, cachedInputPerMillion: 0.125, outputPerMillion: 10, reasoningOutputPerMillion: 10 },
216
+ "gpt-5.1-codex": { inputPerMillion: 1.25, cachedInputPerMillion: 0.125, outputPerMillion: 10, reasoningOutputPerMillion: 10 },
217
+ "gpt-5.1-codex-max": { inputPerMillion: 1.25, cachedInputPerMillion: 0.125, outputPerMillion: 10, reasoningOutputPerMillion: 10 },
218
+ "gpt-5": { inputPerMillion: 1.25, cachedInputPerMillion: 0.125, outputPerMillion: 10, reasoningOutputPerMillion: 10 },
219
+ "gpt-5-chat-latest": { inputPerMillion: 1.25, cachedInputPerMillion: 0.125, outputPerMillion: 10, reasoningOutputPerMillion: 10 },
220
+ "gpt-5-codex": { inputPerMillion: 1.25, cachedInputPerMillion: 0.125, outputPerMillion: 10, reasoningOutputPerMillion: 10 },
221
+ "gpt-5-pro": { inputPerMillion: 15, outputPerMillion: 120, reasoningOutputPerMillion: 120 },
222
+ "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 }
224
+ };
225
+ function loadPricingTable(pricingPath) {
226
+ if (!pricingPath) {
227
+ return defaultPricing;
228
+ }
229
+ try {
230
+ const resolved = path.resolve(pricingPath);
231
+ return { ...defaultPricing, ...JSON.parse(fs.readFileSync(resolved, "utf8")) };
232
+ } catch {
233
+ return defaultPricing;
234
+ }
235
+ }
236
+ function resolvePricingPath(config = {}) {
237
+ return config.pricingPath ? path.resolve(repospendHome(), config.pricingPath) : path.join(repospendHome(), "pricing.json");
238
+ }
239
+ function savePricingTable(pricingPath, pricing) {
240
+ fs.mkdirSync(path.dirname(pricingPath), { recursive: true });
241
+ fs.writeFileSync(pricingPath, `${JSON.stringify(pricing, null, 2)}
242
+ `);
243
+ }
244
+ function repospendHome() {
245
+ return process.env.REPOSPEND_HOME || path.join(os.homedir(), ".repospend");
246
+ }
247
+ function calculateCostUsd(usage, pricing) {
248
+ if (!usage.model) {
249
+ return void 0;
250
+ }
251
+ const modelPricing = pricing[usage.model] ?? pricing[usage.model.toLowerCase()];
252
+ if (!modelPricing) {
253
+ return void 0;
254
+ }
255
+ const billableInput = Math.max(usage.inputTokens - usage.cachedInputTokens, 0);
256
+ const cachedRate = modelPricing.cachedInputPerMillion ?? modelPricing.inputPerMillion;
257
+ 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;
259
+ return Number(cost.toFixed(6));
260
+ }
261
+
262
+ // packages/core/src/config.ts
263
+ function configPath() {
264
+ return path2.join(repospendHome(), "config.json");
265
+ }
266
+ function loadConfig() {
267
+ return loadConfigWithWarnings().config;
268
+ }
269
+ function loadConfigWithWarnings() {
270
+ const localConfigPath = configPath();
271
+ if (!fs2.existsSync(localConfigPath)) {
272
+ return { config: {}, warnings: [] };
273
+ }
274
+ try {
275
+ return { config: JSON.parse(fs2.readFileSync(localConfigPath, "utf8")), warnings: [] };
276
+ } catch (error) {
277
+ const message = error instanceof Error ? error.message : String(error);
278
+ return { config: {}, warnings: [`Unable to parse ${localConfigPath}: ${message}`] };
279
+ }
280
+ }
281
+ function normalizePathForMatch(inputPath) {
282
+ return path2.resolve(inputPath).replace(/\/+$/, "");
283
+ }
284
+ function aliasForPath(repoRoot, config) {
285
+ const resolvedRoot = normalizePathForMatch(repoRoot);
286
+ const match = config.repos?.find(
287
+ (repo) => repo.paths.some((repoPath) => {
288
+ const candidate = normalizePathForMatch(repoPath);
289
+ return resolvedRoot === candidate || resolvedRoot.startsWith(`${candidate}${path2.sep}`);
290
+ })
291
+ );
292
+ return match?.name;
293
+ }
294
+
295
+ // packages/core/src/filters.ts
296
+ function filterUsage(sessions, filters) {
297
+ return sessions.filter((session) => {
298
+ if (!matchesAny(session.sourceClient, filters.source)) return false;
299
+ if (!matchesAny(session.sourceApp, filters.sourceApp)) return false;
300
+ if (!matchesRepo(session, filters.repo)) return false;
301
+ if (!matchesAny(session.model, filters.model)) return false;
302
+ const startedAt = timestamp(session.startedAt);
303
+ const hasDateFilter = Boolean(filters.from || filters.to);
304
+ if (hasDateFilter && startedAt === void 0) return false;
305
+ const from = filters.from ? lowerBound(filters.from) : void 0;
306
+ const to = filters.to ? upperBound(filters.to) : void 0;
307
+ if (from !== void 0 && startedAt !== void 0 && startedAt < from) return false;
308
+ if (to !== void 0 && startedAt !== void 0 && startedAt > to) return false;
309
+ return true;
310
+ });
311
+ }
312
+ function matchesAny(value, filter) {
313
+ const values = filterValues(filter);
314
+ if (!values.length) return true;
315
+ return value !== void 0 && values.includes(value);
316
+ }
317
+ function matchesRepo(session, filter) {
318
+ const values = filterValues(filter);
319
+ if (!values.length) return true;
320
+ return values.includes(session.repoName) || values.includes(session.repoRoot);
321
+ }
322
+ function filterValues(filter) {
323
+ if (!filter) return [];
324
+ return Array.isArray(filter) ? filter.filter(Boolean) : filter.split(",").map((value) => value.trim()).filter(Boolean);
325
+ }
326
+ function timestamp(value) {
327
+ if (!value) return void 0;
328
+ const parsed = new Date(value).getTime();
329
+ return Number.isNaN(parsed) ? void 0 : parsed;
330
+ }
331
+ function lowerBound(value) {
332
+ return timestamp(value.includes("T") ? value : `${value}T00:00:00.000Z`);
333
+ }
334
+ function upperBound(value) {
335
+ return timestamp(value.includes("T") ? value : `${value}T23:59:59.999Z`);
336
+ }
337
+
338
+ // packages/core/src/repo-rollup.ts
339
+ function buildRepoRollups(sessions) {
340
+ return groupByRepo(sessions).map((group) => {
341
+ const repoSessions = sessions.filter((session) => session.repoRoot === group.id);
342
+ return rollupFromGroup(group, repoSessions);
343
+ });
344
+ }
345
+ function rollupFromGroup(group, sessions) {
346
+ const fileEditCount = sessions.reduce((sum2, session) => sum2 + (session.fileEditCount ?? 0), 0);
347
+ const failedCommandCount = sessions.reduce((sum2, session) => sum2 + (session.importantCommandFailures ?? session.failedToolCallCount ?? 0), 0);
348
+ const tokenRoiTokensPerEdit = fileEditCount > 0 ? Math.round(group.totalTokens / fileEditCount) : void 0;
349
+ const warnings = unique2([...group.warnings, ...sessions.flatMap((session) => session.warnings), ...repoWarnings(group, sessions)]);
350
+ return {
351
+ ...group,
352
+ sessions,
353
+ warnings,
354
+ fileEditCount,
355
+ failedCommandCount,
356
+ tokenRoiTokensPerEdit,
357
+ tokenRoiLabel: tokenRoiTokensPerEdit === void 0 ? "No edits" : `${compactNumber(tokenRoiTokensPerEdit)} tokens / edit`,
358
+ tokenRoiTitle: tokenRoiTokensPerEdit === void 0 ? "Token ROI cannot be computed because no file edits were detected for this repo." : "Token ROI estimates how much useful engineering activity was produced per token. Lower tokens per edit is usually better."
359
+ };
360
+ }
361
+ function repoWarnings(group, sessions) {
362
+ const warnings = [];
363
+ if (sessions.some((session) => (session.fileEditCount ?? 0) === 0 && session.totalTokens >= 1e6)) warnings.push("high-token no-edit");
364
+ if (sessions.some((session) => (session.importantCommandFailures ?? session.failedToolCallCount ?? 0) > 0)) warnings.push("command issues");
365
+ if (group.estimatedCostUsd === void 0 && group.totalTokens > 0) warnings.push("unknown pricing");
366
+ if (group.inputTokens > 0 && group.cachedInputTokens / group.inputTokens < 0.1) warnings.push("low cache rate");
367
+ return warnings;
368
+ }
369
+ function compactNumber(value) {
370
+ return new Intl.NumberFormat(void 0, { maximumFractionDigits: 1, notation: "compact" }).format(value);
371
+ }
372
+ function unique2(values) {
373
+ return [...new Set(values)].sort();
374
+ }
375
+
376
+ // packages/core/src/usage-health.ts
377
+ function buildUsageHealth(input) {
378
+ const signals = buildHealthSignals(input);
379
+ const affectedSessionCount = new Set(signals.flatMap((signal) => signal.affectedSessionIds)).size;
380
+ return {
381
+ keyInsights: buildKeyInsights(input),
382
+ signals,
383
+ wasteSignals: buildWasteSignals(input.sessions),
384
+ healthyCount: signals.filter((signal) => signal.tone === "good").length,
385
+ attentionCount: signals.filter((signal) => signal.tone === "attention").length,
386
+ criticalCount: signals.filter((signal) => signal.critical).length,
387
+ affectedSessionCount
388
+ };
389
+ }
390
+ function buildKeyInsights({ sessions, summary, repos, sourceApps }) {
391
+ const insights = [];
392
+ const topTokenRepo = [...repos].sort((a, b) => b.totalTokens - a.totalTokens)[0];
393
+ const topCostRepo = [...repos].filter((repo) => repo.estimatedCostUsd !== void 0).sort((a, b) => (b.estimatedCostUsd ?? 0) - (a.estimatedCostUsd ?? 0))[0];
394
+ const mostUsedApp = sourceApps[0];
395
+ const mostExpensiveSession = [...sessions].filter((session) => session.estimatedCostUsd !== void 0).sort((a, b) => (b.estimatedCostUsd ?? 0) - (a.estimatedCostUsd ?? 0))[0];
396
+ if (topTokenRepo) insights.push({ label: "Most-used repo", text: `${topTokenRepo.label} used the most tokens in this period.`, metric: tokens(topTokenRepo.totalTokens), tone: "neutral" });
397
+ if (topCostRepo) insights.push({ label: "Highest API-equivalent cost", text: `${topCostRepo.label} had the highest estimated API-equivalent cost.`, metric: money(topCostRepo.estimatedCostUsd), tone: "attention" });
398
+ if (summary.noCodeChangeSessions === 0 && summary.sessionCount > 0) {
399
+ insights.push({ label: "No-edit sessions", text: "No no-edit sessions were detected.", metric: "0", tone: "good" });
400
+ } else if (summary.noCodeChangeSessions > 0) {
401
+ insights.push({ label: "No-edit sessions", text: `${count(summary.noCodeChangeSessions, "session")} used tokens without detected file edits.`, metric: count(summary.noCodeChangeSessions, "session"), tone: "attention" });
402
+ }
403
+ if (mostUsedApp) insights.push({ label: "Most-used app", text: `${mostUsedApp.label} accounts for most detected local Codex usage.`, metric: tokens(mostUsedApp.totalTokens), tone: "neutral" });
404
+ if (summary.inputTokens > 0) {
405
+ const tone = summary.cachedInputPct >= 0.5 ? "good" : "attention";
406
+ insights.push({ label: "Cache reuse", text: `Cached input represents ${percent(summary.cachedInputPct)} of input tokens.`, metric: percent(summary.cachedInputPct), tone });
407
+ }
408
+ if (mostExpensiveSession) {
409
+ insights.push({ label: "Session to inspect", text: `The most expensive session was ${money(mostExpensiveSession.estimatedCostUsd)} API-equivalent.`, metric: money(mostExpensiveSession.estimatedCostUsd), tone: "attention" });
410
+ }
411
+ return insights.slice(0, 6);
412
+ }
413
+ function buildHealthSignals(input) {
414
+ const context = healthSignalContext(input);
415
+ return healthSignalDetectors.flatMap((detector) => {
416
+ const signal = detector.detect(context);
417
+ return signal ? [signal] : [];
418
+ });
419
+ }
420
+ function healthSignalContext(input) {
421
+ const { sessions, summary } = input;
422
+ const totalKnownCost = sessions.reduce((sum2, session) => sum2 + (session.estimatedCostUsd ?? 0), 0);
423
+ const topSession = [...sessions].filter((session) => session.estimatedCostUsd !== void 0).sort((a, b) => (b.estimatedCostUsd ?? 0) - (a.estimatedCostUsd ?? 0))[0];
424
+ const topSessionShare = topSession?.estimatedCostUsd && totalKnownCost > 0 ? topSession.estimatedCostUsd / totalKnownCost : 0;
425
+ const totals = tokenTotals(sessions);
426
+ return {
427
+ ...input,
428
+ topSession,
429
+ topSessionShare,
430
+ totals,
431
+ outputShare: totals.totalTokens > 0 ? totals.outputTokens / totals.totalTokens : 0,
432
+ unverifiedRepos: sessions.filter((session) => session.warnings.includes("repo_unverified_no_git_root")),
433
+ commandIssueSessions: sessions.filter((session) => importantCommandFailures(session) > 0),
434
+ unknownSurfaceSessions: sessions.filter((session) => session.detectedSurface === "unknown"),
435
+ unknownCostSessions: sessions.filter((session) => session.estimatedCostUsd === void 0),
436
+ parseFailureSessions: sessions.filter((session) => session.parseStatus === "failed" || (session.parseErrors?.length ?? 0) > 0),
437
+ noCodeSessions: sessions.filter((session) => session.fileEditCount === 0),
438
+ commandIssueRate: summary.shellCommandCount > 0 ? (summary.importantCommandFailures || summary.failedToolCallCount) / summary.shellCommandCount : 0,
439
+ noCodeRate: summary.sessionCount > 0 ? summary.noCodeChangeSessions / summary.sessionCount : 0
440
+ };
441
+ }
442
+ var healthSignalDetectors = [
443
+ {
444
+ id: "repo-grouping",
445
+ detect({ scan }) {
446
+ if (scan.repoCount <= 0) return void 0;
447
+ return {
448
+ id: "repo-grouping",
449
+ tone: "good",
450
+ title: "Repo grouping is working",
451
+ detail: `RepoSpend discovered ${count(scan.repoCount, "repo")} and merges nested working directories into repo roots.`,
452
+ metric: count(scan.sessionCount, "session"),
453
+ affectedSessionIds: []
454
+ };
455
+ }
456
+ },
457
+ {
458
+ id: "app-attribution",
459
+ detect({ scan }) {
460
+ if (scan.sourceAppCount <= 1) return void 0;
461
+ return {
462
+ id: "app-attribution",
463
+ tone: "good",
464
+ title: "App attribution is visible",
465
+ detail: `Usage is split across ${count(scan.sourceAppCount, "source app")}, so VS Code, terminal, and subagent activity can be compared.`,
466
+ metric: count(scan.sourceAppCount, "app"),
467
+ affectedSessionIds: []
468
+ };
469
+ }
470
+ },
471
+ {
472
+ id: "priced-sessions",
473
+ detect({ summary }) {
474
+ if (summary.unknownCostSessions !== 0 || summary.sessionCount <= 0) return void 0;
475
+ return {
476
+ id: "priced-sessions",
477
+ tone: "good",
478
+ title: "All loaded sessions are priced",
479
+ detail: "Every filtered session with tokens matched the local pricing table and has an API-equivalent cost estimate.",
480
+ metric: `${compactNumber2(summary.sessionCount)} priced`,
481
+ affectedSessionIds: []
482
+ };
483
+ }
484
+ },
485
+ {
486
+ id: "cache-reuse",
487
+ detect({ summary }) {
488
+ if (summary.cachedInputPct < 0.75) return void 0;
489
+ return {
490
+ id: "cache-reuse",
491
+ tone: "good",
492
+ title: "Cache reuse is strong",
493
+ detail: "Most input tokens are cached, which usually means repeated project context is being reused efficiently.",
494
+ metric: percent(summary.cachedInputPct),
495
+ affectedSessionIds: []
496
+ };
497
+ }
498
+ },
499
+ {
500
+ id: "token-roi-measurable",
501
+ detect({ summary }) {
502
+ if (summary.fileEditCount <= 0 || summary.totalTokens <= 0) return void 0;
503
+ return {
504
+ id: "token-roi-measurable",
505
+ tone: "good",
506
+ title: "Token ROI can be measured",
507
+ detail: "RepoSpend detected file edits in local events, so tokens per edit and no-edit sessions can be tracked.",
508
+ metric: `${tokens(Math.round(summary.totalTokens / summary.fileEditCount))} / edit`,
509
+ affectedSessionIds: []
510
+ };
511
+ }
512
+ },
513
+ {
514
+ id: "empty-filter",
515
+ detect({ summary }) {
516
+ if (summary.sessionCount !== 0) return void 0;
517
+ return {
518
+ id: "empty-filter",
519
+ tone: "attention",
520
+ title: "No sessions in this view",
521
+ detail: "The current filters do not include any token-bearing Codex sessions.",
522
+ action: "Widen the date range or clear repo, app, and model filters.",
523
+ affectedSessionIds: []
524
+ };
525
+ }
526
+ },
527
+ {
528
+ id: "command-friction",
529
+ detect({ commandIssueRate, commandIssueSessions }) {
530
+ if (commandIssueRate <= 0.2) return void 0;
531
+ return {
532
+ id: "command-friction",
533
+ tone: "attention",
534
+ title: "Important command issue rate looks high",
535
+ detail: `${percent(commandIssueRate)} of detected shell commands look like blocking, repeated, or token-expensive issues in this filtered view.`,
536
+ action: "Look for repeated setup, dependency, permission, test, or build issues in the Sessions page.",
537
+ actionTarget: "sessions",
538
+ actionLabel: "Open affected sessions",
539
+ metric: percent(commandIssueRate),
540
+ affectedSessionIds: commandIssueSessions.map((session) => session.id)
541
+ };
542
+ }
543
+ },
544
+ {
545
+ id: "no-code-sessions",
546
+ detect({ noCodeRate, noCodeSessions, summary }) {
547
+ if (noCodeRate <= 0.5 || summary.sessionCount < 3) return void 0;
548
+ return {
549
+ id: "no-code-sessions",
550
+ tone: "attention",
551
+ title: "Many sessions have no detected edits",
552
+ detail: `${count(summary.noCodeChangeSessions, "session")} used tokens without detected file edits.`,
553
+ action: "Some may be research-only, but high-token no-edit sessions are worth reviewing.",
554
+ actionTarget: "sessions",
555
+ actionLabel: "Open affected sessions",
556
+ metric: percent(noCodeRate),
557
+ affectedSessionIds: noCodeSessions.map((session) => session.id)
558
+ };
559
+ }
560
+ },
561
+ {
562
+ id: "unknown-surfaces",
563
+ detect({ unknownSurfaceSessions }) {
564
+ if (unknownSurfaceSessions.length === 0) return void 0;
565
+ return {
566
+ id: "unknown-surfaces",
567
+ tone: "attention",
568
+ title: "Some surfaces are unknown",
569
+ detail: `${count(unknownSurfaceSessions.length, "session")} did not expose enough local metadata to classify VS Code, terminal, exec, or app usage.`,
570
+ action: "Open Sessions and check the surface reason column for those sessions.",
571
+ actionTarget: "sessions",
572
+ actionLabel: "Open affected sessions",
573
+ metric: count(unknownSurfaceSessions.length, "session"),
574
+ affectedSessionIds: unknownSurfaceSessions.map((session) => session.id)
575
+ };
576
+ }
577
+ },
578
+ {
579
+ id: "parse-failures",
580
+ detect({ parseFailureSessions }) {
581
+ if (parseFailureSessions.length === 0) return void 0;
582
+ return {
583
+ id: "parse-failures",
584
+ tone: "attention",
585
+ title: "Some session files parsed partially",
586
+ detail: `${count(parseFailureSessions.length, "session")} had parse errors or unreadable local log data.`,
587
+ action: "Check Local Data Coverage and the Sessions parse column before trusting derived command/file metrics.",
588
+ actionTarget: "settings",
589
+ actionLabel: "View data sources",
590
+ metric: count(parseFailureSessions.length, "issue"),
591
+ critical: true,
592
+ affectedSessionIds: parseFailureSessions.map((session) => session.id)
593
+ };
594
+ }
595
+ },
596
+ {
597
+ id: "unknown-cost",
598
+ detect({ unknownCostSessions }) {
599
+ if (unknownCostSessions.length === 0) return void 0;
600
+ return {
601
+ id: "unknown-cost",
602
+ tone: "attention",
603
+ title: "Some sessions cannot be priced",
604
+ detail: `${count(unknownCostSessions.length, "session")} are missing a model price or detailed token split.`,
605
+ action: "Open Settings and add model rates, or inspect older Codex sessions that only expose raw totals.",
606
+ actionTarget: "settings",
607
+ actionLabel: "Open settings",
608
+ metric: count(unknownCostSessions.length, "session"),
609
+ critical: true,
610
+ affectedSessionIds: unknownCostSessions.map((session) => session.id)
611
+ };
612
+ }
613
+ },
614
+ {
615
+ id: "low-cache",
616
+ detect({ sessions, summary, totals }) {
617
+ if (totals.inputTokens <= 0 || summary.cachedInputPct >= 0.5) return void 0;
618
+ return {
619
+ id: "low-cache",
620
+ tone: "attention",
621
+ title: "Cache rate could be better",
622
+ detail: "A low cached-input share usually means repeated context is being resent as fresh input.",
623
+ action: "Prefer continuing existing sessions for related work and avoid repeatedly pasting large project context.",
624
+ metric: percent(summary.cachedInputPct),
625
+ affectedSessionIds: sessions.map((session) => session.id)
626
+ };
627
+ }
628
+ },
629
+ {
630
+ id: "output-heavy",
631
+ detect({ outputShare, sessions }) {
632
+ if (outputShare <= 0.35) return void 0;
633
+ return {
634
+ id: "output-heavy",
635
+ tone: "attention",
636
+ title: "Output-heavy sessions are driving tokens",
637
+ detail: "A large share of tokens are assistant output, which can happen with broad generation, long reviews, or verbose command output.",
638
+ action: "Ask for concise diffs, summaries, or scoped files when the task does not need full output.",
639
+ metric: percent(outputShare),
640
+ affectedSessionIds: sessions.filter((session) => session.outputTokens / Math.max(session.totalTokens, 1) > 0.35).map((session) => session.id)
641
+ };
642
+ }
643
+ },
644
+ {
645
+ id: "cost-concentration",
646
+ detect({ topSession, topSessionShare }) {
647
+ if (topSessionShare <= 0.35 || !topSession) return void 0;
648
+ return {
649
+ id: "cost-concentration",
650
+ tone: "attention",
651
+ title: "Spend is concentrated in one session",
652
+ detail: `"${topSession.title ?? topSession.id}" accounts for a large share of estimated API-equivalent cost in this view.`,
653
+ action: "Inspect that session and consider splitting very broad work into smaller passes.",
654
+ metric: percent(topSessionShare),
655
+ affectedSessionIds: [topSession.id]
656
+ };
657
+ }
658
+ },
659
+ {
660
+ id: "unverified-repos",
661
+ detect({ unverifiedRepos }) {
662
+ if (unverifiedRepos.length === 0) return void 0;
663
+ return {
664
+ id: "unverified-repos",
665
+ tone: "attention",
666
+ title: "Some repos are unverified",
667
+ detail: `${count(unverifiedRepos.length, "session")} did not resolve to a Git root, so grouping may be less precise.`,
668
+ action: "Add repo aliases in repospend.config.json or run Codex from inside Git worktrees.",
669
+ actionTarget: "settings",
670
+ actionLabel: "View data sources",
671
+ metric: count(unverifiedRepos.length, "session"),
672
+ affectedSessionIds: unverifiedRepos.map((session) => session.id)
673
+ };
674
+ }
675
+ },
676
+ {
677
+ id: "zero-token-skipped",
678
+ detect({ scan }) {
679
+ if (scan.zeroTokenSessionCount <= 0) return void 0;
680
+ return {
681
+ id: "zero-token-skipped",
682
+ tone: "attention",
683
+ title: "Zero-token placeholders were skipped",
684
+ detail: `${count(scan.zeroTokenSessionCount, "local Codex record")} had no token usage and were excluded from analytics.`,
685
+ action: "This is usually harmless, but it explains why raw local session counts may differ from dashboard counts.",
686
+ metric: `${compactNumber2(scan.zeroTokenSessionCount)} skipped`,
687
+ affectedSessionIds: []
688
+ };
689
+ }
690
+ }
691
+ ];
692
+ function buildWasteSignals(sessions) {
693
+ return wasteSignalDetectors.flatMap((detector) => {
694
+ const signal = detector.detect(sessions);
695
+ return signal ? [signal] : [];
696
+ });
697
+ }
698
+ var wasteSignalDetectors = [
699
+ wasteSignalDetector("high-token-no-edit", "High-token no-edit sessions", "Large sessions where local events did not show file edits.", (session) => (session.fileEditCount ?? 0) === 0 && session.totalTokens >= 1e6),
700
+ wasteSignalDetector("high-token-low-output", "High-token low-output sessions", "Large sessions where most tokens were input or cached context.", (session) => session.totalTokens >= 1e6 && session.outputTokens / Math.max(session.totalTokens, 1) < 0.03),
701
+ wasteSignalDetector("command-issue-sessions", "Command issue sessions", "Sessions with blocking, repeated, or token-expensive command issues.", (session) => importantCommandFailures(session) > 0),
702
+ wasteSignalDetector("unknown-repos", "Unknown repo sessions", "Repo root could not be verified from local Git metadata.", (session) => session.warnings.includes("repo_unverified_no_git_root")),
703
+ wasteSignalDetector("unknown-surfaces", "Unknown surface sessions", "Local logs did not identify VS Code, terminal, exec, or app surface.", (session) => (session.detectedSurface ?? "unknown") === "unknown"),
704
+ wasteSignalDetector("repeated-errors", "Repeated error signals", "Repeated-error hints found in session warnings.", (session) => session.warnings.some((warning) => warning.includes("repeated_error")))
705
+ ];
706
+ function wasteSignalDetector(id, title, detail, matches) {
707
+ return {
708
+ id,
709
+ detect(sessions) {
710
+ const affected = sessions.filter(matches);
711
+ if (affected.length === 0) return void 0;
712
+ return {
713
+ id,
714
+ title,
715
+ value: count(affected.length, "session"),
716
+ detail,
717
+ tone: "attention",
718
+ affectedSessionIds: affected.map((session) => session.id)
719
+ };
720
+ }
721
+ };
722
+ }
723
+ function importantCommandFailures(session) {
724
+ return session.importantCommandFailures ?? session.failedToolCallCount ?? 0;
725
+ }
726
+ function tokenTotals(sessions) {
727
+ return sessions.reduce(
728
+ (totals, session) => ({
729
+ inputTokens: totals.inputTokens + session.inputTokens,
730
+ cachedInputTokens: totals.cachedInputTokens + session.cachedInputTokens,
731
+ outputTokens: totals.outputTokens + session.outputTokens,
732
+ reasoningTokens: totals.reasoningTokens + session.reasoningTokens,
733
+ totalTokens: totals.totalTokens + session.totalTokens
734
+ }),
735
+ { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningTokens: 0, totalTokens: 0 }
736
+ );
737
+ }
738
+ function money(value) {
739
+ if (value === void 0) return "Not priced";
740
+ if (value > 0 && value < 0.01) return "<$0.01 USD";
741
+ return `${currency(value, 2, 2)} USD`;
742
+ }
743
+ function tokens(value) {
744
+ return `${compactNumber2(value)} ${pluralize("token", value)}`;
745
+ }
746
+ function count(value, noun) {
747
+ return `${compactNumber2(value)} ${pluralize(noun, value)}`;
748
+ }
749
+ function percent(value) {
750
+ return `${new Intl.NumberFormat(void 0, { maximumFractionDigits: 1 }).format(value * 100)}%`;
751
+ }
752
+ function compactNumber2(value) {
753
+ return new Intl.NumberFormat(void 0, { maximumFractionDigits: 1, notation: "compact" }).format(value);
754
+ }
755
+ function currency(value, minimumFractionDigits, maximumFractionDigits) {
756
+ return `$${new Intl.NumberFormat(void 0, { maximumFractionDigits, minimumFractionDigits }).format(value)}`;
757
+ }
758
+ function pluralize(noun, value) {
759
+ if (value === 1) return noun;
760
+ if (noun.endsWith("s")) return noun;
761
+ return `${noun}s`;
762
+ }
763
+
764
+ // packages/core/src/dashboard.ts
765
+ function buildDashboardSnapshot(input) {
766
+ const allSessions = input.sessions.filter((session) => session.totalTokens > 0);
767
+ const filteredSessions = filterUsage(input.sessions, input.filters ?? {});
768
+ const sessions = filteredSessions.filter((session) => session.totalTokens > 0);
769
+ const summary = summarize(sessions);
770
+ const repos = buildRepoRollups(sessions);
771
+ const sourceApps = groupBySourceApp(sessions);
772
+ const scan = {
773
+ sessionCount: allSessions.length,
774
+ zeroTokenSessionCount: input.sessions.length - allSessions.length,
775
+ repoCount: buildRepoRollups(allSessions).length,
776
+ modelCount: groupByModel(allSessions).length,
777
+ sourceAppCount: groupBySourceApp(allSessions).length,
778
+ rawSessionCount: input.sessions.length,
779
+ rawEventCount: allSessions.reduce((sum2, session) => sum2 + (session.rawEventCount ?? 0), 0),
780
+ parseFailureCount: allSessions.filter((session) => session.parseStatus === "failed" || (session.parseErrors?.length ?? 0) > 0).length,
781
+ sessionFileCount: input.sourceStats.reduce((sum2, source) => sum2 + source.sessionFileCount, 0),
782
+ lastScannedAt: input.sourceStats.map((source) => source.lastScannedAt).sort().at(-1) ?? (/* @__PURE__ */ new Date()).toISOString()
783
+ };
784
+ return {
785
+ sources: input.sources,
786
+ sourceStats: input.sourceStats,
787
+ scan,
788
+ sessions,
789
+ skippedZeroTokenSessions: filteredSessions.length - sessions.length,
790
+ summary,
791
+ repos,
792
+ days: groupByDay(sessions),
793
+ hours: groupByHour(sessions),
794
+ models: groupByModel(sessions),
795
+ sourceApps,
796
+ health: buildUsageHealth({ sessions, summary, repos, sourceApps, scan })
797
+ };
798
+ }
799
+
800
+ // packages/core/src/repo.ts
801
+ import fs3 from "node:fs";
802
+ import path3 from "node:path";
803
+ function findGitRoot(cwd, fileSystem = fs3) {
804
+ let current = path3.resolve(cwd);
805
+ while (true) {
806
+ const gitPath = path3.join(current, ".git");
807
+ if (fileSystem.existsSync(gitPath)) {
808
+ try {
809
+ const stat = fileSystem.statSync(gitPath);
810
+ if (stat.isDirectory() || stat.isFile()) {
811
+ return current;
812
+ }
813
+ } catch {
814
+ return current;
815
+ }
816
+ }
817
+ const parent = path3.dirname(current);
818
+ if (parent === current) {
819
+ return void 0;
820
+ }
821
+ current = parent;
822
+ }
823
+ }
824
+ function resolveRepoInfo(cwd, config = {}, fileSystem = fs3) {
825
+ const warnings = [];
826
+ const gitRoot = safeFindGitRoot(cwd, fileSystem);
827
+ const repoRoot = gitRoot ?? fallbackWorkspaceRoot(cwd);
828
+ const alias = aliasForPath(repoRoot, config);
829
+ const repoName = alias ?? (path3.basename(repoRoot) || "unknown-workspace");
830
+ const gitMeta = gitRoot ? readGitMetadata(gitRoot, fileSystem) : { remote: void 0, branch: void 0 };
831
+ if (!gitRoot) {
832
+ warnings.push("repo_unverified_no_git_root");
833
+ }
834
+ return {
835
+ repoRoot,
836
+ repoName,
837
+ gitRemoteUrl: gitMeta.remote,
838
+ gitBranch: gitMeta.branch,
839
+ verified: Boolean(gitRoot),
840
+ warnings
841
+ };
842
+ }
843
+ function safeFindGitRoot(cwd, fileSystem) {
844
+ try {
845
+ return findGitRoot(cwd, fileSystem);
846
+ } catch {
847
+ return void 0;
848
+ }
849
+ }
850
+ function fallbackWorkspaceRoot(cwd) {
851
+ const resolved = path3.resolve(cwd || process.cwd());
852
+ const home = process.env.HOME ? path3.resolve(process.env.HOME) : void 0;
853
+ if (home && (resolved === home || resolved.startsWith(`${home}${path3.sep}`))) {
854
+ const relative = path3.relative(home, resolved).split(path3.sep).filter(Boolean);
855
+ const first = relative[0];
856
+ return first ? path3.join(home, first) : home;
857
+ }
858
+ const parts = resolved.split(path3.sep).filter(Boolean);
859
+ return `${path3.sep}${parts.slice(0, Math.min(parts.length, 3)).join(path3.sep)}`;
860
+ }
861
+ function readGitMetadata(repoRoot, fileSystem) {
862
+ const gitPath = path3.join(repoRoot, ".git");
863
+ try {
864
+ const stat = fileSystem.statSync(gitPath);
865
+ const gitDir = stat.isFile() ? resolveGitFile(repoRoot, fileSystem.readFileSync(gitPath, "utf8")) : gitPath;
866
+ const configPath2 = path3.join(gitDir, "config");
867
+ const headPath = path3.join(gitDir, "HEAD");
868
+ const remote = fileSystem.existsSync(configPath2) ? parseRemote(fileSystem.readFileSync(configPath2, "utf8")) : void 0;
869
+ const branch = fileSystem.existsSync(headPath) ? parseBranch(fileSystem.readFileSync(headPath, "utf8")) : void 0;
870
+ return { remote, branch };
871
+ } catch {
872
+ return { remote: void 0, branch: void 0 };
873
+ }
874
+ }
875
+ function resolveGitFile(repoRoot, contents) {
876
+ const match = contents.match(/^gitdir:\s*(.+)$/m);
877
+ if (!match?.[1]) {
878
+ return path3.join(repoRoot, ".git");
879
+ }
880
+ return path3.resolve(repoRoot, match[1].trim());
881
+ }
882
+ function parseRemote(config) {
883
+ const originMatch = config.match(/\[remote "origin"\][\s\S]*?\n\s*url\s*=\s*(.+)/);
884
+ return originMatch?.[1]?.trim();
885
+ }
886
+ function parseBranch(head) {
887
+ const match = head.trim().match(/^ref:\s+refs\/heads\/(.+)$/);
888
+ return match?.[1] ?? head.trim().slice(0, 12);
889
+ }
890
+
891
+ // packages/core/src/sources/codex.ts
892
+ import fs4 from "node:fs";
893
+ import os2 from "node:os";
894
+ import path4 from "node:path";
895
+ import Database from "better-sqlite3";
896
+
897
+ // packages/core/src/sources/agent-friction.ts
898
+ function analyzeAgentFriction(records, totalTokens) {
899
+ const analysis = emptyAgentFrictionAnalysis();
900
+ for (const record of records) {
901
+ applyActionMetadata(analysis, record);
902
+ }
903
+ finalizeCommandIssues(analysis, totalTokens);
904
+ return analysis;
905
+ }
906
+ function emptyAgentFrictionAnalysis() {
907
+ return {
908
+ commandIssues: [],
909
+ shellCommandCount: 0,
910
+ failedToolCallCount: 0,
911
+ nonZeroCommandEvents: 0,
912
+ importantCommandFailures: 0,
913
+ harmlessNonZeroEvents: 0,
914
+ exploratoryMisses: 0,
915
+ repeatedFailureClusters: 0,
916
+ commandIssueSeverity: "none",
917
+ commandIssueImpact: "none",
918
+ topFailureType: void 0,
919
+ commandIssueSamples: [],
920
+ fileReadCount: 0,
921
+ fileEditCount: 0,
922
+ toolCallCount: 0,
923
+ userPromptCount: 0,
924
+ assistantMessageCount: 0
925
+ };
926
+ }
927
+ function applyActionMetadata(analysis, record) {
928
+ if (!record || typeof record !== "object") return;
929
+ const object = record;
930
+ const payload = firstObject(object.payload) ?? object;
931
+ const type = stringValue(object.type) ?? stringValue(payload.type);
932
+ const role = stringValue(payload.role);
933
+ if (payload.type === "user_message" || role === "user") analysis.userPromptCount += 1;
934
+ if (payload.type === "agent_message" || role === "assistant") analysis.assistantMessageCount += 1;
935
+ const text = JSON.stringify(record).toLowerCase();
936
+ const isTool = type?.includes("tool") || type?.includes("function_call") || text.includes('"tool_call"') || text.includes('"function_call"');
937
+ if (isTool) analysis.toolCallCount += 1;
938
+ const isNonZeroCommand = text.includes("exit_code") && !text.includes('"exit_code":0') && !text.includes('"exit_code": 0');
939
+ if (isNonZeroCommand) analysis.commandIssues.push(classifyCommandIssue(record, analysis.commandIssues));
940
+ if (/\b(exec_command|shell|bash|zsh|command)\b/.test(text)) analysis.shellCommandCount += 1;
941
+ if (/\b(read_file|view_image|cat |sed |rg |grep |open_file)\b/.test(text)) analysis.fileReadCount += 1;
942
+ if (/\b(apply_patch|write_file|edit|patch|created|deleted)\b/.test(text)) analysis.fileEditCount += 1;
943
+ }
944
+ function finalizeCommandIssues(analysis, totalTokens) {
945
+ 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);
949
+ }
950
+ const repeatedFailures = [...repeatedCommands.entries()].filter(([, count2]) => count2 >= 3);
951
+ if (repeatedFailures.length > 0) {
952
+ 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";
958
+ }
959
+ }
960
+ }
961
+ if (totalTokens >= 1e6 && analysis.commandIssues.length >= 5) {
962
+ for (const issue of analysis.commandIssues) {
963
+ if (issue.severity === "warning") issue.impact = "high";
964
+ }
965
+ }
966
+ 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;
970
+ analysis.failedToolCallCount = analysis.importantCommandFailures;
971
+ analysis.commandIssueSeverity = strongestSeverity(analysis.commandIssues);
972
+ analysis.commandIssueImpact = strongestImpact(analysis.commandIssues);
973
+ analysis.topFailureType = topFailureType(analysis.commandIssues);
974
+ analysis.commandIssueSamples = sampledCommandIssues(analysis.commandIssues);
975
+ }
976
+ function sampledCommandIssues(issues) {
977
+ return [...issues].sort((a, b) => issuePriority(b) - issuePriority(a)).slice(0, 12).map((issue) => ({
978
+ ...issue,
979
+ reason: commandIssueReason(issue)
980
+ }));
981
+ }
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;
985
+ return severityScore * 10 + impactScore;
986
+ }
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.";
992
+ return "Non-zero command event with limited local context.";
993
+ }
994
+ function classifyCommandIssue(record, previousIssues) {
995
+ const raw = JSON.stringify(record);
996
+ const text = raw.toLowerCase();
997
+ const command2 = extractCommandText(record) ?? compactCommandText(raw);
998
+ const category = commandCategory(text);
999
+ const commandKey = normalizeCommand(command2);
1000
+ const repeated = previousIssues.filter((issue) => issue.command === commandKey).length + 1 >= 3;
1001
+ if (isHarmlessNonZero(text)) {
1002
+ return { command: commandKey, category, classification: isExploratoryMiss(text) ? "exploratory_miss" : "harmless_nonzero", severity: "ignored", impact: "low" };
1003
+ }
1004
+ if (isImportantFailure(text, category) || repeated) {
1005
+ return { command: commandKey, category, classification: "blocking_failure", severity: repeated ? "critical" : importantSeverity(text, category), impact: repeated ? "high" : importantImpact(category) };
1006
+ }
1007
+ if (category === "search" || category === "filesystem") {
1008
+ return { command: commandKey, category, classification: "exploratory_miss", severity: "ignored", impact: "low" };
1009
+ }
1010
+ return { command: commandKey, category, classification: "unknown", severity: "info", impact: "low" };
1011
+ }
1012
+ function isHarmlessNonZero(text) {
1013
+ return /\b(rg|grep)\b/.test(text) || /git\s+(diff|status)/.test(text) || /\bfind\b/.test(text) || /\btest\s+(-f|-d|-e)\b/.test(text) || /\b(ls|cat)\b/.test(text) && /(optional|exists|existence|not found|no such file)/.test(text);
1014
+ }
1015
+ function isExploratoryMiss(text) {
1016
+ return /\b(rg|grep|find)\b/.test(text) || /(no matches|not found|no such file|existence)/.test(text);
1017
+ }
1018
+ function isImportantFailure(text, category) {
1019
+ return category === "install" || category === "build" || category === "test" || category === "lint" || category === "typecheck" || category === "deploy" || category === "database" || category === "auth_env" || /(command not found|permission denied|authentication|unauthorized|missing environment|missing env|database connection|connection refused|deployment failed|tsc\b|vite build|next build)/.test(text);
1020
+ }
1021
+ function commandCategory(text) {
1022
+ if (/\b(pnpm|npm|yarn|bun)\s+(install|add|i)\b/.test(text)) return "install";
1023
+ if (/\b(pnpm|npm|yarn|bun)\s+[^"']*build\b|\b(vite|next)\s+build\b/.test(text)) return "build";
1024
+ if (/\b(pnpm|npm|yarn|bun)\s+[^"']*test\b|\b(vitest|jest|playwright)\b/.test(text)) return "test";
1025
+ if (/\b(pnpm|npm|yarn|bun)\s+[^"']*lint\b|\beslint\b/.test(text)) return "lint";
1026
+ if (/\b(pnpm|npm|yarn|bun)\s+[^"']*typecheck\b|\btsc\b/.test(text)) return "typecheck";
1027
+ if (/\b(vercel|deploy|netlify|railway|flyctl)\b/.test(text)) return "deploy";
1028
+ if (/\bgit\b/.test(text)) return "git";
1029
+ if (/\b(rg|grep|find)\b/.test(text)) return "search";
1030
+ if (/\b(ls|cat|sed|test|stat|mkdir|rm|cp|mv)\b/.test(text)) return "filesystem";
1031
+ if (/\b(pnpm|npm|yarn|bun|npx)\b/.test(text)) return "package_manager";
1032
+ if (/\b(dev|serve|localhost|vite --host|next dev)\b/.test(text)) return "dev_server";
1033
+ if (/\b(sqlite|postgres|mysql|database|db:|prisma|drizzle)\b/.test(text)) return "database";
1034
+ if (/\b(auth|token|api key|environment variable|env var|permission denied|unauthorized)\b/.test(text)) return "auth_env";
1035
+ return "unknown";
1036
+ }
1037
+ function importantSeverity(text, category) {
1038
+ if (category === "deploy" || category === "database" || category === "auth_env" || /(command not found|permission denied|authentication|unauthorized)/.test(text)) return "critical";
1039
+ return "warning";
1040
+ }
1041
+ function importantImpact(category) {
1042
+ return category === "install" || category === "build" || category === "test" || category === "typecheck" || category === "deploy" ? "high" : "medium";
1043
+ }
1044
+ 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";
1049
+ return "none";
1050
+ }
1051
+ 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";
1055
+ return "none";
1056
+ }
1057
+ function topFailureType(issues) {
1058
+ const important = issues.filter((issue) => issue.severity === "critical" || issue.severity === "warning");
1059
+ const source = important.length ? important : issues;
1060
+ 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);
1062
+ return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
1063
+ }
1064
+ function extractCommandText(record) {
1065
+ if (!record || typeof record !== "object") return void 0;
1066
+ const seen = /* @__PURE__ */ new Set();
1067
+ const stack = [record];
1068
+ while (stack.length) {
1069
+ const current = stack.pop();
1070
+ if (!current || typeof current !== "object" || seen.has(current)) continue;
1071
+ seen.add(current);
1072
+ const object = current;
1073
+ for (const key of ["command", "cmd", "argv", "args"]) {
1074
+ const value = object[key];
1075
+ if (typeof value === "string" && value.length) return value;
1076
+ if (Array.isArray(value) && value.every((item) => typeof item === "string")) return value.join(" ");
1077
+ }
1078
+ stack.push(...Object.values(object));
1079
+ }
1080
+ return void 0;
1081
+ }
1082
+ function normalizeCommand(command2) {
1083
+ return command2.replace(/\s+/g, " ").trim().slice(0, 160) || "unknown";
1084
+ }
1085
+ function compactCommandText(text) {
1086
+ return normalizeCommand(text.toLowerCase().replace(/\\n/g, " ").slice(0, 160));
1087
+ }
1088
+ function firstObject(...values) {
1089
+ return values.find((value) => Boolean(value) && typeof value === "object" && !Array.isArray(value));
1090
+ }
1091
+ function stringValue(value) {
1092
+ return typeof value === "string" && value.length > 0 ? value : void 0;
1093
+ }
1094
+
1095
+ // packages/core/src/sources/codex-token.ts
1096
+ function aggregateTokens(records) {
1097
+ const accumulator = emptyTokenAccumulator();
1098
+ for (const record of records) {
1099
+ collectRecordTokens(accumulator, record);
1100
+ }
1101
+ finalizeTokenAccumulator(accumulator);
1102
+ return {
1103
+ inputTokens: accumulator.inputTokens,
1104
+ cachedInputTokens: accumulator.cachedInputTokens,
1105
+ outputTokens: accumulator.outputTokens,
1106
+ reasoningTokens: accumulator.reasoningTokens,
1107
+ totalTokens: accumulator.totalTokens,
1108
+ tokenAggregationMethod: accumulator.tokenAggregationMethod,
1109
+ tokenConfidence: accumulator.tokenConfidence,
1110
+ tokenSnapshotCount: accumulator.tokenSnapshotCount,
1111
+ warnings: accumulator.warnings
1112
+ };
1113
+ }
1114
+ function emptyTokenAccumulator() {
1115
+ return {
1116
+ inputTokens: 0,
1117
+ cachedInputTokens: 0,
1118
+ outputTokens: 0,
1119
+ reasoningTokens: 0,
1120
+ totalTokens: 0,
1121
+ tokenAggregationMethod: "unknown",
1122
+ tokenConfidence: "low",
1123
+ tokenSnapshotCount: 0,
1124
+ warnings: [],
1125
+ cumulativeTokenSnapshots: [],
1126
+ directTokenUsages: []
1127
+ };
1128
+ }
1129
+ function collectRecordTokens(accumulator, record) {
1130
+ const usage = extractRecordTokens(record);
1131
+ if (!usage) return;
1132
+ accumulator.tokenSnapshotCount += 1;
1133
+ if (usage.cumulative) {
1134
+ accumulator.cumulativeTokenSnapshots.push(usage);
1135
+ } else {
1136
+ accumulator.directTokenUsages.push(usage);
1137
+ }
1138
+ }
1139
+ function finalizeTokenAccumulator(accumulator) {
1140
+ const invalidSnapshots = [...accumulator.cumulativeTokenSnapshots, ...accumulator.directTokenUsages].filter((usage) => !isValidTokenUsage(usage)).length;
1141
+ if (invalidSnapshots > 0) accumulator.warnings.push(`invalid_token_snapshots:${invalidSnapshots}`);
1142
+ const cumulative = accumulator.cumulativeTokenSnapshots.filter((snapshot) => isValidTokenUsage(snapshot));
1143
+ if (cumulative.length > 0) {
1144
+ const finalSnapshot = cumulative.reduce((best, snapshot) => snapshot.totalTokens >= best.totalTokens ? snapshot : best, cumulative[0]);
1145
+ applyFinalUsage(accumulator, finalSnapshot);
1146
+ accumulator.tokenAggregationMethod = "final_snapshot";
1147
+ accumulator.tokenConfidence = "high";
1148
+ if (accumulator.directTokenUsages.length > 0) accumulator.warnings.push("token_direct_usage_ignored_after_cumulative_snapshot");
1149
+ return;
1150
+ }
1151
+ const direct = accumulator.directTokenUsages.filter((usage) => isValidTokenUsage(usage));
1152
+ if (direct.length > 0) {
1153
+ const total = direct.reduce(
1154
+ (sum2, usage) => ({
1155
+ inputTokens: sum2.inputTokens + usage.inputTokens,
1156
+ cachedInputTokens: sum2.cachedInputTokens + usage.cachedInputTokens,
1157
+ outputTokens: sum2.outputTokens + usage.outputTokens,
1158
+ reasoningTokens: sum2.reasoningTokens + usage.reasoningTokens,
1159
+ totalTokens: sum2.totalTokens + usage.totalTokens
1160
+ }),
1161
+ { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningTokens: 0, totalTokens: 0 }
1162
+ );
1163
+ applyFinalUsage(accumulator, total);
1164
+ accumulator.tokenAggregationMethod = "direct_usage";
1165
+ accumulator.tokenConfidence = "medium";
1166
+ return;
1167
+ }
1168
+ accumulator.tokenAggregationMethod = "unknown";
1169
+ accumulator.tokenConfidence = "low";
1170
+ }
1171
+ function extractRecordTokens(record) {
1172
+ if (!record || typeof record !== "object") return void 0;
1173
+ const object = record;
1174
+ const payload = firstObject2(object.payload);
1175
+ const payloadInfo = firstObject2(payload?.info);
1176
+ const codexTotalUsage = firstObject2(payloadInfo?.total_token_usage);
1177
+ if (codexTotalUsage) {
1178
+ return tokenUsageFromObject(codexTotalUsage, true);
1179
+ }
1180
+ const response = firstObject2(object.response);
1181
+ const usage = firstObject2(object.usage, object.token_usage, object.tokens, response?.usage, payload?.usage, payload?.token_usage, payload?.tokens);
1182
+ return tokenUsageFromObject(usage ?? object, false);
1183
+ }
1184
+ function tokenUsageFromObject(target, cumulative) {
1185
+ const input = numberFromKeys(target, ["input_tokens", "inputTokens", "prompt_tokens", "promptTokens"]);
1186
+ const cached = numberFromKeys(target, ["cached_input_tokens", "cachedInputTokens", "cached_tokens", "cachedTokens", "cache_read_input_tokens"]);
1187
+ const outputWithReasoning = numberFromKeys(target, ["output_tokens", "outputTokens", "completion_tokens", "completionTokens"]);
1188
+ const reasoning = numberFromKeys(target, ["reasoning_tokens", "reasoningTokens", "reasoning_output_tokens", "reasoningOutputTokens"]);
1189
+ const output = Math.max(outputWithReasoning - reasoning, 0);
1190
+ const total = numberFromKeys(target, ["total_tokens", "totalTokens", "tokens_used", "tokensUsed"]);
1191
+ if (input + cached + output + reasoning + total === 0) return void 0;
1192
+ return {
1193
+ inputTokens: input,
1194
+ cachedInputTokens: cached,
1195
+ outputTokens: output,
1196
+ reasoningTokens: reasoning,
1197
+ totalTokens: total || input + output + reasoning,
1198
+ cumulative
1199
+ };
1200
+ }
1201
+ function applyFinalUsage(accumulator, usage) {
1202
+ accumulator.inputTokens = usage.inputTokens;
1203
+ accumulator.cachedInputTokens = usage.cachedInputTokens;
1204
+ accumulator.outputTokens = usage.outputTokens;
1205
+ accumulator.reasoningTokens = usage.reasoningTokens;
1206
+ accumulator.totalTokens = usage.totalTokens || usage.inputTokens + usage.outputTokens + usage.reasoningTokens;
1207
+ }
1208
+ function isValidTokenUsage(usage) {
1209
+ return usage.inputTokens >= 0 && usage.cachedInputTokens >= 0 && usage.outputTokens >= 0 && usage.reasoningTokens >= 0 && usage.totalTokens >= 0;
1210
+ }
1211
+ function firstObject2(...values) {
1212
+ return values.find((value) => Boolean(value) && typeof value === "object" && !Array.isArray(value));
1213
+ }
1214
+ function numberFromKeys(object, keys) {
1215
+ for (const key of keys) {
1216
+ const value = object[key];
1217
+ if (typeof value === "number" && Number.isFinite(value)) return value;
1218
+ if (typeof value === "string" && Number.isFinite(Number(value))) return Number(value);
1219
+ }
1220
+ return 0;
1221
+ }
1222
+
1223
+ // packages/core/src/sources/codex.ts
1224
+ function scanCodex(options) {
1225
+ const codexHome = options.codexHome ?? path4.join(os2.homedir(), ".codex");
1226
+ const statePath = path4.join(codexHome, "state_5.sqlite");
1227
+ const sessionsPath = path4.join(codexHome, "sessions");
1228
+ const warnings = [];
1229
+ const sessions = [];
1230
+ const stateExists = fs4.existsSync(statePath);
1231
+ const sessionsExists = fs4.existsSync(sessionsPath);
1232
+ if (!stateExists) {
1233
+ warnings.push(`Codex SQLite state not found at ${statePath}`);
1234
+ }
1235
+ if (!sessionsExists) {
1236
+ warnings.push(`Codex sessions directory not found at ${sessionsPath}`);
1237
+ }
1238
+ const sessionFiles = sessionsExists ? indexSessionFiles(sessionsPath, warnings) : /* @__PURE__ */ new Map();
1239
+ if (stateExists) {
1240
+ const stateSessions = readThreads(statePath, sessionFiles, options, warnings);
1241
+ sessions.push(...stateSessions);
1242
+ if (!stateSessions.length && sessionsExists) {
1243
+ sessions.push(...readStandaloneSessions(sessionFiles, options));
1244
+ }
1245
+ }
1246
+ if (!stateExists && sessionsExists) {
1247
+ sessions.push(...readStandaloneSessions(sessionFiles, options));
1248
+ }
1249
+ return {
1250
+ source: {
1251
+ id: "codex",
1252
+ label: "Codex",
1253
+ available: stateExists || sessionsExists,
1254
+ paths: [statePath, sessionsPath],
1255
+ warnings
1256
+ },
1257
+ sessions,
1258
+ stats: {
1259
+ codexHome,
1260
+ statePath,
1261
+ sessionsPath,
1262
+ stateExists,
1263
+ sessionsExists,
1264
+ sessionFileCount: sessionFiles.size,
1265
+ sessionsImported: sessions.length,
1266
+ parseFailureCount: sessions.filter((session) => session.parseStatus === "failed").length,
1267
+ unreadableFileCount: sessions.filter((session) => session.parseErrors?.some((error) => error.startsWith("unable_to_read_session_file:"))).length,
1268
+ lastScannedAt: (/* @__PURE__ */ new Date()).toISOString()
1269
+ }
1270
+ };
1271
+ }
1272
+ function readThreads(statePath, sessionFiles, options, warnings) {
1273
+ let db;
1274
+ try {
1275
+ db = new Database(statePath, { readonly: true, fileMustExist: true });
1276
+ const tableNames = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all();
1277
+ if (!tableNames.some((table) => table.name === "threads")) {
1278
+ warnings.push("Codex SQLite state does not contain a threads table.");
1279
+ return [];
1280
+ }
1281
+ const columns = db.prepare("PRAGMA table_info(threads)").all().map((column) => column.name);
1282
+ const selectedColumns = ["id", "rollout_path", "created_at", "updated_at", "cwd", "title", "model_provider", "model", "tokens_used", "source", "thread_source"].filter((column) => columns.includes(column));
1283
+ if (!selectedColumns.length) {
1284
+ warnings.push("Codex threads table has no recognized columns.");
1285
+ return [];
1286
+ }
1287
+ const rows = db.prepare(`SELECT ${selectedColumns.map((column) => `"${column}"`).join(", ")} FROM threads`).all();
1288
+ return rows.map((row, index) => threadToUsage(row, index, sessionFiles, options));
1289
+ } catch (error) {
1290
+ warnings.push(`Unable to read Codex SQLite state at ${statePath}: ${errorMessage(error)}`);
1291
+ return [];
1292
+ } finally {
1293
+ db?.close();
1294
+ }
1295
+ }
1296
+ function threadToUsage(row, index, sessionFiles, options) {
1297
+ const sourcePath = stringValue2(row.rollout_path) ?? matchSessionPath(stringValue2(row.id), sessionFiles) ?? "";
1298
+ const sessionBreakdown = sourcePath ? readSessionBreakdown(sourcePath) : emptyBreakdown();
1299
+ const dbTokens = parseTokenTotal(row.tokens_used);
1300
+ const cwd = stringValue2(row.cwd) ?? fallbackCwdFromPath(sourcePath);
1301
+ const repo = resolveRepoInfo(cwd, options.config);
1302
+ const inputTokens = sessionBreakdown.inputTokens;
1303
+ const cachedInputTokens = sessionBreakdown.cachedInputTokens;
1304
+ const outputTokens = sessionBreakdown.outputTokens;
1305
+ const reasoningTokens = sessionBreakdown.reasoningTokens;
1306
+ const totalTokens = sessionBreakdown.totalTokens || dbTokens || inputTokens + cachedInputTokens + outputTokens + reasoningTokens;
1307
+ const hasPricedBreakdown = inputTokens + cachedInputTokens + outputTokens + reasoningTokens > 0;
1308
+ const warnings = [...repo.warnings, ...sessionBreakdown.warnings];
1309
+ const threadSource = stringValue2(row.thread_source) ?? sessionBreakdown.threadSource;
1310
+ const sourceAppRaw = stringValue2(row.source) ?? sessionBreakdown.source ?? sessionBreakdown.originator ?? threadSource;
1311
+ const surface = detectSurface(sourceAppRaw, threadSource);
1312
+ const usage = {
1313
+ id: stringValue2(row.id) ?? `codex-thread-${index}`,
1314
+ sourceClient: "codex",
1315
+ sourceApp: codexSourceAppLabel(sourceAppRaw, threadSource),
1316
+ sourceAppRaw,
1317
+ sourcePath,
1318
+ repoRoot: repo.repoRoot,
1319
+ repoName: repo.repoName,
1320
+ cwd,
1321
+ gitRemoteUrl: repo.gitRemoteUrl,
1322
+ gitBranch: repo.gitBranch,
1323
+ title: stringValue2(row.title),
1324
+ startedAt: dateValue(row.created_at),
1325
+ endedAt: dateValue(row.updated_at),
1326
+ model: stringValue2(row.model),
1327
+ provider: stringValue2(row.model_provider),
1328
+ inputTokens,
1329
+ cachedInputTokens,
1330
+ outputTokens,
1331
+ reasoningTokens,
1332
+ reasoningOutputTokens: reasoningTokens,
1333
+ totalTokens,
1334
+ tokenAggregationMethod: sessionBreakdown.tokenAggregationMethod === "unknown" && dbTokens > 0 ? "estimated" : sessionBreakdown.tokenAggregationMethod,
1335
+ tokenConfidence: sessionBreakdown.tokenAggregationMethod === "unknown" && dbTokens > 0 ? "low" : sessionBreakdown.tokenConfidence,
1336
+ tokenSnapshotCount: sessionBreakdown.tokenSnapshotCount,
1337
+ estimatedCostUsd: void 0,
1338
+ messageCount: sessionBreakdown.messageCount,
1339
+ rawTokenTotal: dbTokens || sessionBreakdown.totalTokens || void 0,
1340
+ warnings,
1341
+ codexHome: options.codexHome ?? path4.join(os2.homedir(), ".codex"),
1342
+ durationMs: durationMs(dateValue(row.created_at), dateValue(row.updated_at)),
1343
+ rawEventCount: sessionBreakdown.rawEventCount,
1344
+ parseStatus: sessionBreakdown.parseStatus,
1345
+ parseErrors: sessionBreakdown.parseErrors,
1346
+ detectedSurface: surface.surface,
1347
+ surfaceConfidence: surface.confidence,
1348
+ surfaceReason: surface.reason,
1349
+ userPromptCount: sessionBreakdown.userPromptCount,
1350
+ assistantMessageCount: sessionBreakdown.assistantMessageCount,
1351
+ toolCallCount: sessionBreakdown.toolCallCount,
1352
+ shellCommandCount: sessionBreakdown.shellCommandCount,
1353
+ failedToolCallCount: sessionBreakdown.failedToolCallCount,
1354
+ nonZeroCommandEvents: sessionBreakdown.nonZeroCommandEvents,
1355
+ importantCommandFailures: sessionBreakdown.importantCommandFailures,
1356
+ harmlessNonZeroEvents: sessionBreakdown.harmlessNonZeroEvents,
1357
+ exploratoryMisses: sessionBreakdown.exploratoryMisses,
1358
+ repeatedFailureClusters: sessionBreakdown.repeatedFailureClusters,
1359
+ commandIssueSeverity: sessionBreakdown.commandIssueSeverity,
1360
+ commandIssueImpact: sessionBreakdown.commandIssueImpact,
1361
+ topFailureType: sessionBreakdown.topFailureType,
1362
+ commandIssueSamples: sessionBreakdown.commandIssueSamples,
1363
+ fileReadCount: sessionBreakdown.fileReadCount,
1364
+ fileEditCount: sessionBreakdown.fileEditCount,
1365
+ promptTimeline: sessionBreakdown.promptTimeline,
1366
+ sessionOutcome: inferOutcome(sessionBreakdown)
1367
+ };
1368
+ const cost = hasPricedBreakdown ? calculateCostUsd(usage, options.pricing) : void 0;
1369
+ const costWarnings = hasPricedBreakdown ? warnings : [...warnings, "missing_token_breakdown"];
1370
+ return {
1371
+ ...usage,
1372
+ estimatedCostUsd: cost,
1373
+ warnings: cost === void 0 ? [...costWarnings, "unknown_pricing"] : warnings
1374
+ };
1375
+ }
1376
+ function readStandaloneSessions(sessionFiles, options) {
1377
+ return [...sessionFiles.entries()].map(([id, sourcePath]) => {
1378
+ const breakdown = readSessionBreakdown(sourcePath);
1379
+ const cwd = fallbackCwdFromPath(sourcePath);
1380
+ const repo = resolveRepoInfo(cwd, options.config);
1381
+ const model = void 0;
1382
+ const surface = detectSurface(breakdown.source ?? breakdown.originator, breakdown.threadSource);
1383
+ return {
1384
+ id,
1385
+ sourceClient: "codex",
1386
+ sourceApp: codexSourceAppLabel(breakdown.source ?? breakdown.originator, breakdown.threadSource),
1387
+ sourceAppRaw: breakdown.source ?? breakdown.originator ?? breakdown.threadSource,
1388
+ sourcePath,
1389
+ repoRoot: repo.repoRoot,
1390
+ repoName: repo.repoName,
1391
+ cwd,
1392
+ gitRemoteUrl: repo.gitRemoteUrl,
1393
+ gitBranch: repo.gitBranch,
1394
+ title: void 0,
1395
+ startedAt: void 0,
1396
+ endedAt: void 0,
1397
+ model,
1398
+ provider: void 0,
1399
+ inputTokens: breakdown.inputTokens,
1400
+ cachedInputTokens: breakdown.cachedInputTokens,
1401
+ outputTokens: breakdown.outputTokens,
1402
+ reasoningTokens: breakdown.reasoningTokens,
1403
+ reasoningOutputTokens: breakdown.reasoningTokens,
1404
+ totalTokens: breakdown.totalTokens,
1405
+ tokenAggregationMethod: breakdown.tokenAggregationMethod,
1406
+ tokenConfidence: breakdown.tokenConfidence,
1407
+ tokenSnapshotCount: breakdown.tokenSnapshotCount,
1408
+ estimatedCostUsd: void 0,
1409
+ messageCount: breakdown.messageCount,
1410
+ rawTokenTotal: breakdown.totalTokens || void 0,
1411
+ warnings: [...repo.warnings, ...breakdown.warnings, "unknown_pricing"],
1412
+ codexHome: options.codexHome ?? path4.join(os2.homedir(), ".codex"),
1413
+ rawEventCount: breakdown.rawEventCount,
1414
+ parseStatus: breakdown.parseStatus,
1415
+ parseErrors: breakdown.parseErrors,
1416
+ detectedSurface: surface.surface,
1417
+ surfaceConfidence: surface.confidence,
1418
+ surfaceReason: surface.reason,
1419
+ userPromptCount: breakdown.userPromptCount,
1420
+ assistantMessageCount: breakdown.assistantMessageCount,
1421
+ toolCallCount: breakdown.toolCallCount,
1422
+ shellCommandCount: breakdown.shellCommandCount,
1423
+ failedToolCallCount: breakdown.failedToolCallCount,
1424
+ nonZeroCommandEvents: breakdown.nonZeroCommandEvents,
1425
+ importantCommandFailures: breakdown.importantCommandFailures,
1426
+ harmlessNonZeroEvents: breakdown.harmlessNonZeroEvents,
1427
+ exploratoryMisses: breakdown.exploratoryMisses,
1428
+ repeatedFailureClusters: breakdown.repeatedFailureClusters,
1429
+ commandIssueSeverity: breakdown.commandIssueSeverity,
1430
+ commandIssueImpact: breakdown.commandIssueImpact,
1431
+ topFailureType: breakdown.topFailureType,
1432
+ commandIssueSamples: breakdown.commandIssueSamples,
1433
+ fileReadCount: breakdown.fileReadCount,
1434
+ fileEditCount: breakdown.fileEditCount,
1435
+ promptTimeline: breakdown.promptTimeline,
1436
+ sessionOutcome: inferOutcome(breakdown)
1437
+ };
1438
+ });
1439
+ }
1440
+ function indexSessionFiles(sessionsPath, warnings) {
1441
+ const files = /* @__PURE__ */ new Map();
1442
+ try {
1443
+ walk(sessionsPath, (filePath) => {
1444
+ if (!/\.(jsonl|json|log)$/i.test(filePath)) return;
1445
+ const id = path4.basename(filePath).replace(/\.(jsonl|json|log)$/i, "");
1446
+ files.set(id, filePath);
1447
+ });
1448
+ } catch (error) {
1449
+ warnings.push(`Unable to read Codex sessions at ${sessionsPath}: ${errorMessage(error)}`);
1450
+ }
1451
+ return files;
1452
+ }
1453
+ function walk(root, onFile) {
1454
+ for (const entry of fs4.readdirSync(root, { withFileTypes: true })) {
1455
+ const fullPath = path4.join(root, entry.name);
1456
+ if (entry.isDirectory()) {
1457
+ walk(fullPath, onFile);
1458
+ } else if (entry.isFile()) {
1459
+ onFile(fullPath);
1460
+ }
1461
+ }
1462
+ }
1463
+ function readSessionBreakdown(filePath) {
1464
+ const breakdown = emptyBreakdown();
1465
+ try {
1466
+ const text = fs4.readFileSync(filePath, "utf8");
1467
+ const parse = parseSessionRecords(text);
1468
+ const records = parse.records;
1469
+ breakdown.rawEventCount = records.length;
1470
+ breakdown.parseErrors = parse.errors;
1471
+ breakdown.parseStatus = parse.errors.length ? records.length ? "partial" : "failed" : "ok";
1472
+ let eventMessageCount = 0;
1473
+ let responseMessageCount = 0;
1474
+ for (const record of records) {
1475
+ applySessionMetadata(breakdown, record);
1476
+ applyPromptTimeline(breakdown, record);
1477
+ if (isCodexEventMessage(record)) eventMessageCount += 1;
1478
+ if (isResponseMessage(record)) responseMessageCount += 1;
1479
+ }
1480
+ breakdown.messageCount = eventMessageCount || responseMessageCount;
1481
+ const tokenAggregation = aggregateTokens(records);
1482
+ Object.assign(breakdown, {
1483
+ ...tokenAggregation,
1484
+ warnings: [...breakdown.warnings, ...tokenAggregation.warnings]
1485
+ });
1486
+ Object.assign(breakdown, analyzeAgentFriction(records, breakdown.totalTokens));
1487
+ } catch (error) {
1488
+ const message = `unable_to_read_session_file:${errorMessage(error)}`;
1489
+ breakdown.parseStatus = "failed";
1490
+ breakdown.parseErrors.push(message);
1491
+ breakdown.warnings.push(message);
1492
+ }
1493
+ return breakdown;
1494
+ }
1495
+ function parseSessionRecords(text) {
1496
+ const trimmed = text.trim();
1497
+ if (!trimmed) return { records: [], errors: [] };
1498
+ if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
1499
+ try {
1500
+ const parsed = JSON.parse(trimmed);
1501
+ return { records: Array.isArray(parsed) ? parsed : [parsed], errors: [] };
1502
+ } catch {
1503
+ return parseJsonLines(text);
1504
+ }
1505
+ }
1506
+ return parseJsonLines(text);
1507
+ }
1508
+ function parseJsonLines(text, firstError) {
1509
+ const records = [];
1510
+ const errors = firstError ? [`json_parse:${firstError}`] : [];
1511
+ text.split(/\r?\n/).map((line, index) => ({ line: line.trim(), index })).filter(({ line }) => Boolean(line)).forEach(({ line, index }) => {
1512
+ try {
1513
+ records.push(JSON.parse(line));
1514
+ } catch (error) {
1515
+ errors.push(`jsonl_line_${index + 1}:${errorMessage(error)}`);
1516
+ }
1517
+ });
1518
+ return { records, errors };
1519
+ }
1520
+ function firstObject3(...values) {
1521
+ return values.find((value) => Boolean(value) && typeof value === "object" && !Array.isArray(value));
1522
+ }
1523
+ function numberFromKeys2(object, keys) {
1524
+ for (const key of keys) {
1525
+ const value = object[key];
1526
+ if (typeof value === "number" && Number.isFinite(value)) return value;
1527
+ if (typeof value === "string" && Number.isFinite(Number(value))) return Number(value);
1528
+ }
1529
+ return 0;
1530
+ }
1531
+ function parseTokenTotal(value) {
1532
+ if (typeof value === "number") return value;
1533
+ if (typeof value === "string") {
1534
+ const numeric = Number(value);
1535
+ if (Number.isFinite(numeric)) return numeric;
1536
+ try {
1537
+ const parsed = JSON.parse(value);
1538
+ if (typeof parsed === "number") return parsed;
1539
+ if (parsed && typeof parsed === "object") {
1540
+ return numberFromKeys2(parsed, ["total_tokens", "totalTokens", "tokens_used", "tokensUsed"]);
1541
+ }
1542
+ } catch {
1543
+ return 0;
1544
+ }
1545
+ }
1546
+ return 0;
1547
+ }
1548
+ function matchSessionPath(id, sessionFiles) {
1549
+ if (!id) return void 0;
1550
+ return sessionFiles.get(id) ?? [...sessionFiles.entries()].find(([key]) => key.includes(id) || id.includes(key))?.[1];
1551
+ }
1552
+ function emptyBreakdown() {
1553
+ return {
1554
+ inputTokens: 0,
1555
+ cachedInputTokens: 0,
1556
+ outputTokens: 0,
1557
+ reasoningTokens: 0,
1558
+ totalTokens: 0,
1559
+ tokenAggregationMethod: "unknown",
1560
+ tokenConfidence: "low",
1561
+ tokenSnapshotCount: 0,
1562
+ warnings: [],
1563
+ messageCount: 0,
1564
+ source: void 0,
1565
+ originator: void 0,
1566
+ threadSource: void 0,
1567
+ rawEventCount: 0,
1568
+ parseStatus: "ok",
1569
+ parseErrors: [],
1570
+ userPromptCount: 0,
1571
+ assistantMessageCount: 0,
1572
+ toolCallCount: 0,
1573
+ shellCommandCount: 0,
1574
+ failedToolCallCount: 0,
1575
+ commandIssues: [],
1576
+ nonZeroCommandEvents: 0,
1577
+ importantCommandFailures: 0,
1578
+ harmlessNonZeroEvents: 0,
1579
+ exploratoryMisses: 0,
1580
+ repeatedFailureClusters: 0,
1581
+ commandIssueSeverity: "none",
1582
+ commandIssueImpact: "none",
1583
+ topFailureType: void 0,
1584
+ commandIssueSamples: [],
1585
+ fileReadCount: 0,
1586
+ fileEditCount: 0,
1587
+ promptTimeline: []
1588
+ };
1589
+ }
1590
+ function fallbackCwdFromPath(filePath) {
1591
+ return filePath ? path4.dirname(filePath) : process.cwd();
1592
+ }
1593
+ function applySessionMetadata(breakdown, record) {
1594
+ if (!record || typeof record !== "object") return;
1595
+ const object = record;
1596
+ const payload = firstObject3(object.payload) ?? object;
1597
+ breakdown.source ??= stringValue2(payload.source);
1598
+ breakdown.originator ??= stringValue2(payload.originator);
1599
+ breakdown.threadSource ??= stringValue2(payload.thread_source);
1600
+ }
1601
+ function isCodexEventMessage(record) {
1602
+ if (!record || typeof record !== "object") return false;
1603
+ const object = record;
1604
+ if (object.type !== "event_msg") return false;
1605
+ const payload = firstObject3(object.payload);
1606
+ return payload?.type === "user_message" || payload?.type === "agent_message";
1607
+ }
1608
+ function isResponseMessage(record) {
1609
+ if (!record || typeof record !== "object") return false;
1610
+ const object = record;
1611
+ if (object.type !== "response_item") return false;
1612
+ const payload = firstObject3(object.payload);
1613
+ return payload?.type === "message" && (payload.role === "user" || payload.role === "assistant");
1614
+ }
1615
+ function applyPromptTimeline(breakdown, record) {
1616
+ if (breakdown.promptTimeline.length >= 80 || !record || typeof record !== "object") return;
1617
+ const object = record;
1618
+ const payload = firstObject3(object.payload) ?? object;
1619
+ const payloadType = stringValue2(payload.type);
1620
+ const roleValue = stringValue2(payload.role);
1621
+ const role = payloadType === "user_message" || roleValue === "user" ? "user" : payloadType === "agent_message" || roleValue === "assistant" ? "assistant" : void 0;
1622
+ if (!role) return;
1623
+ const text = extractMessageText(payload) ?? extractMessageText(object);
1624
+ if (!text) return;
1625
+ breakdown.promptTimeline.push({
1626
+ role,
1627
+ text: normalizeTimelineText(text),
1628
+ timestamp: dateValue(payload.timestamp) ?? dateValue(object.timestamp) ?? dateValue(payload.created_at) ?? dateValue(object.created_at)
1629
+ });
1630
+ }
1631
+ function extractMessageText(record) {
1632
+ for (const key of ["text", "message", "content", "input"]) {
1633
+ const value = record[key];
1634
+ if (typeof value === "string" && value.trim()) return value;
1635
+ if (Array.isArray(value)) {
1636
+ const parts = value.map((item) => {
1637
+ if (typeof item === "string") return item;
1638
+ if (item && typeof item === "object") {
1639
+ const object = item;
1640
+ return stringValue2(object.text) ?? stringValue2(object.content);
1641
+ }
1642
+ return void 0;
1643
+ }).filter((item) => Boolean(item?.trim()));
1644
+ if (parts.length) return parts.join("\n");
1645
+ }
1646
+ }
1647
+ return void 0;
1648
+ }
1649
+ function normalizeTimelineText(text) {
1650
+ return text.replace(/\s+/g, " ").trim().slice(0, 1e3);
1651
+ }
1652
+ function detectSurface(source, threadSource) {
1653
+ const raw = `${source ?? ""} ${threadSource ?? ""}`.toLowerCase();
1654
+ if (threadSource === "subagent") return { surface: "codex_exec", confidence: "medium", reason: "thread_source is subagent" };
1655
+ if (raw.includes("vscode")) return { surface: "vscode_extension", confidence: "high", reason: "source metadata includes vscode" };
1656
+ if (raw.includes("cli") || raw.includes("terminal") || raw.includes("tui")) return { surface: "terminal_cli", confidence: "high", reason: "source metadata indicates CLI/terminal" };
1657
+ if (raw.includes("exec")) return { surface: "codex_exec", confidence: "medium", reason: "source metadata includes exec" };
1658
+ if (raw.includes("codex_app") || raw.includes("codex-app")) return { surface: "codex_app_cloud", confidence: "low", reason: "source metadata references codex app; local logs may be incomplete" };
1659
+ return { surface: "unknown", confidence: "low", reason: "no recognized local surface metadata" };
1660
+ }
1661
+ function inferOutcome(breakdown) {
1662
+ if (breakdown.parseStatus === "failed") return "failed";
1663
+ if (breakdown.failedToolCallCount > 0) return "partial";
1664
+ if (breakdown.fileEditCount > 0) return "completed";
1665
+ if (breakdown.shellCommandCount > 0) return "setup_debugging";
1666
+ if (breakdown.userPromptCount > 0 || breakdown.assistantMessageCount > 0) return "research_only";
1667
+ return "unknown";
1668
+ }
1669
+ function durationMs(startedAt, endedAt) {
1670
+ if (!startedAt || !endedAt) return void 0;
1671
+ const start = new Date(startedAt).getTime();
1672
+ const end = new Date(endedAt).getTime();
1673
+ return Number.isFinite(start) && Number.isFinite(end) && end >= start ? end - start : void 0;
1674
+ }
1675
+ function stringValue2(value) {
1676
+ return typeof value === "string" && value.length > 0 ? value : void 0;
1677
+ }
1678
+ function codexSourceAppLabel(source, threadSource) {
1679
+ if (threadSource === "subagent") return "Codex subagent";
1680
+ if (!source) return "Codex";
1681
+ if (source === "vscode" || source === "codex_vscode" || source === "codex-vscode") return "VS Code";
1682
+ if (source === "cli" || source === "terminal" || source === "codex-tui") return "Terminal";
1683
+ if (source === "codex_app" || source === "codex-app") return "Codex app";
1684
+ if (source.trim().startsWith("{")) return threadSource === "subagent" ? "Codex subagent" : "Codex";
1685
+ return source.replace(/^codex[_-]/, "").split(/[_-]/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
1686
+ }
1687
+ function dateValue(value) {
1688
+ if (typeof value === "number") {
1689
+ return new Date(value > 1e10 ? value : value * 1e3).toISOString();
1690
+ }
1691
+ if (typeof value === "string") {
1692
+ const date = new Date(value);
1693
+ return Number.isNaN(date.getTime()) ? void 0 : date.toISOString();
1694
+ }
1695
+ return void 0;
1696
+ }
1697
+ function errorMessage(error) {
1698
+ const message = error instanceof Error ? error.message : String(error);
1699
+ if (message.includes("NODE_MODULE_VERSION") && message.includes("better_sqlite3.node")) {
1700
+ const builtAbi = message.match(/NODE_MODULE_VERSION\s+(\d+)/)?.[1] ?? "unknown";
1701
+ const requiredAbi = message.match(/requires\s+NODE_MODULE_VERSION\s+(\d+)/)?.[1] ?? "unknown";
1702
+ return `SQLite native module mismatch: better-sqlite3 was built for Node ABI ${builtAbi}, but RepoSpend is running with ABI ${requiredAbi}. Run "node -p \\"process.version + ' ABI=' + process.versions.modules\\"" in the same terminal you use for RepoSpend, then reinstall or rebuild better-sqlite3 with that same Node version. From source, run "pnpm rebuild:native". For a global npm install, run "npm rebuild -g better-sqlite3" or reinstall RepoSpend.`;
1703
+ }
1704
+ return message;
1705
+ }
1706
+
1707
+ // apps/server/src/data.ts
1708
+ import fs5 from "node:fs";
1709
+ var scanCacheTtlMs = 1e4;
1710
+ var scanCache;
1711
+ function readDashboardData(filters = {}) {
1712
+ const raw = readRawUsageData();
1713
+ return buildDashboardSnapshot({ ...raw, filters });
1714
+ }
1715
+ function clearScanCache() {
1716
+ scanCache = void 0;
1717
+ }
1718
+ function readRawUsageData() {
1719
+ const cwd = runtimeCwd();
1720
+ if (scanCache && scanCache.cwd === cwd && scanCache.expiresAt > Date.now()) {
1721
+ return scanCache.data;
1722
+ }
1723
+ const { config, warnings: configWarnings } = loadConfigWithWarnings();
1724
+ const pricing = loadPricingTable(resolvePricingPath(config));
1725
+ const codex = scanCodex({ config, pricing });
1726
+ const data = {
1727
+ sources: [{ ...codex.source, warnings: [...codex.source.warnings, ...configWarnings] }],
1728
+ sourceStats: [codex.stats],
1729
+ sessions: codex.sessions
1730
+ };
1731
+ scanCache = { cwd, data, expiresAt: Date.now() + scanCacheTtlMs };
1732
+ return data;
1733
+ }
1734
+ function parseFilters(query) {
1735
+ const filters = {};
1736
+ const source = listQuery(query.source);
1737
+ const sourceApp = listQuery(query.sourceApp);
1738
+ const repo = listQuery(query.repo);
1739
+ const model = listQuery(query.model);
1740
+ const from = stringQuery(query.from);
1741
+ const to = stringQuery(query.to);
1742
+ if (source) filters.source = source;
1743
+ if (sourceApp) filters.sourceApp = sourceApp;
1744
+ if (repo) filters.repo = repo;
1745
+ if (model) filters.model = model;
1746
+ if (from) filters.from = from;
1747
+ if (to) filters.to = to;
1748
+ return filters;
1749
+ }
1750
+ function readPricingData() {
1751
+ const config = loadConfig();
1752
+ const pricingPath = resolvePricingPath(config);
1753
+ return {
1754
+ path: pricingPath,
1755
+ models: loadPricingTable(pricingPath)
1756
+ };
1757
+ }
1758
+ function writePricingData(models) {
1759
+ const config = loadConfig();
1760
+ const pricingPath = resolvePricingPath(config);
1761
+ savePricingTable(pricingPath, models);
1762
+ clearScanCache();
1763
+ return {
1764
+ path: pricingPath,
1765
+ models: loadPricingTable(pricingPath)
1766
+ };
1767
+ }
1768
+ function clearRepoSpendLocalData() {
1769
+ const target = repospendHome();
1770
+ const removed = fs5.existsSync(target);
1771
+ fs5.rmSync(target, { recursive: true, force: true });
1772
+ clearScanCache();
1773
+ return { path: target, removed };
1774
+ }
1775
+ function exportJson() {
1776
+ return JSON.stringify(readDashboardData().sessions, null, 2);
1777
+ }
1778
+ function exportCsv() {
1779
+ return toCsv(readDashboardData().sessions);
1780
+ }
1781
+ function stringQuery(value) {
1782
+ return typeof value === "string" && value.length > 0 ? value : void 0;
1783
+ }
1784
+ function listQuery(value) {
1785
+ const values = Array.isArray(value) ? value : typeof value === "string" ? [value] : [];
1786
+ const parsed = values.flatMap((item) => typeof item === "string" ? item.split(",") : []).map((item) => item.trim()).filter(Boolean);
1787
+ return parsed.length ? parsed : void 0;
1788
+ }
1789
+ function runtimeCwd() {
1790
+ return process.env.INIT_CWD || process.cwd();
1791
+ }
1792
+
1793
+ // apps/server/src/server.ts
1794
+ import fs6 from "node:fs";
1795
+ import path5 from "node:path";
1796
+ import { fileURLToPath } from "node:url";
1797
+ import fastifyStatic from "@fastify/static";
1798
+ import Fastify from "fastify";
1799
+
1800
+ // apps/server/src/rtk.ts
1801
+ import { execFile } from "node:child_process";
1802
+ import { promisify } from "node:util";
1803
+ var execFileAsync = promisify(execFile);
1804
+ async function readRtkGain() {
1805
+ try {
1806
+ const { stdout } = await execFileAsync("rtk", ["gain", "--history"], {
1807
+ timeout: 5e3,
1808
+ maxBuffer: 1024 * 1024
1809
+ });
1810
+ const version = await readRtkVersion();
1811
+ const discover = await readRtkDiscover();
1812
+ return parseRtkGain(stdout, version, discover);
1813
+ } catch (error) {
1814
+ return {
1815
+ available: false,
1816
+ rtkDetected: false,
1817
+ rtkVersion: void 0,
1818
+ rtkCodexHookStatus: "not_detected",
1819
+ rtkLastActivityAt: void 0,
1820
+ rtkDataSource: void 0,
1821
+ totalCommands: void 0,
1822
+ inputTokens: void 0,
1823
+ outputTokens: void 0,
1824
+ tokensSaved: void 0,
1825
+ savedPercent: void 0,
1826
+ totalExecTime: void 0,
1827
+ averageExecTime: void 0,
1828
+ rtkTokensSaved: void 0,
1829
+ rtkSavingsRate: void 0,
1830
+ rtkCommandsProcessed: void 0,
1831
+ rtkAverageCommandRuntime: void 0,
1832
+ rtkTopSavingsCommands: [],
1833
+ rtkRecentActivity: [],
1834
+ rtkDiscoverAvailable: false,
1835
+ rtkDiscoverError: void 0,
1836
+ rtkDiscoverSummary: void 0,
1837
+ rtkCoverageGaps: [],
1838
+ rtkUnhandledCommands: [],
1839
+ topCommands: [],
1840
+ recentCommands: [],
1841
+ raw: "",
1842
+ error: error instanceof Error ? error.message : String(error)
1843
+ };
1844
+ }
1845
+ }
1846
+ async function readRtkDiscover() {
1847
+ try {
1848
+ const { stdout } = await execFileAsync("rtk", ["discover", "--all", "--since", "7"], {
1849
+ timeout: 8e3,
1850
+ maxBuffer: 1024 * 1024
1851
+ });
1852
+ return parseRtkDiscover(stdout);
1853
+ } catch (error) {
1854
+ return {
1855
+ rtkDiscoverAvailable: false,
1856
+ rtkDiscoverError: error instanceof Error ? error.message : String(error),
1857
+ rtkDiscoverSummary: void 0,
1858
+ rtkCoverageGaps: [],
1859
+ rtkUnhandledCommands: []
1860
+ };
1861
+ }
1862
+ }
1863
+ async function readRtkVersion() {
1864
+ try {
1865
+ const { stdout } = await execFileAsync("rtk", ["--version"], {
1866
+ timeout: 1500,
1867
+ maxBuffer: 128 * 1024
1868
+ });
1869
+ return stdout.trim() || void 0;
1870
+ } catch {
1871
+ return void 0;
1872
+ }
1873
+ }
1874
+ function parseRtkGain(raw, version, discover) {
1875
+ const lines = raw.split(/\r?\n/);
1876
+ const savedLine = valueAfterLabel(lines, "Tokens saved");
1877
+ const execLine = valueAfterLabel(lines, "Total exec time");
1878
+ const totalCommands = numberFromText(valueAfterLabel(lines, "Total commands"));
1879
+ const tokensSaved = savedLine?.replace(/\s+\(.+\)$/, "");
1880
+ const savedPercent = percentFromText(savedLine);
1881
+ const averageExecTime = execLine?.match(/\(avg\s+(.+)\)/)?.[1];
1882
+ const topCommands = commandRows(lines);
1883
+ const recentCommands = recentCommandLines(lines);
1884
+ return {
1885
+ available: true,
1886
+ rtkDetected: true,
1887
+ rtkVersion: version,
1888
+ rtkCodexHookStatus: "unknown",
1889
+ rtkLastActivityAt: void 0,
1890
+ rtkDataSource: "rtk gain --history output",
1891
+ totalCommands,
1892
+ inputTokens: valueAfterLabel(lines, "Input tokens"),
1893
+ outputTokens: valueAfterLabel(lines, "Output tokens"),
1894
+ tokensSaved,
1895
+ savedPercent,
1896
+ totalExecTime: execLine?.replace(/\s+\(.+\)$/, ""),
1897
+ averageExecTime,
1898
+ rtkTokensSaved: tokensSaved,
1899
+ rtkSavingsRate: savedPercent,
1900
+ rtkCommandsProcessed: totalCommands,
1901
+ rtkAverageCommandRuntime: averageExecTime,
1902
+ rtkTopSavingsCommands: topCommands,
1903
+ rtkRecentActivity: recentCommands,
1904
+ ...discover,
1905
+ topCommands,
1906
+ recentCommands,
1907
+ raw,
1908
+ error: void 0
1909
+ };
1910
+ }
1911
+ function parseRtkDiscover(raw) {
1912
+ const lines = raw.split(/\r?\n/);
1913
+ return {
1914
+ rtkDiscoverAvailable: true,
1915
+ rtkDiscoverError: void 0,
1916
+ rtkDiscoverSummary: lines.find((line) => line.trim().startsWith("Total:"))?.trim(),
1917
+ rtkCoverageGaps: coverageGapRows(lines),
1918
+ rtkUnhandledCommands: unhandledCommandRows(lines)
1919
+ };
1920
+ }
1921
+ function valueAfterLabel(lines, label) {
1922
+ const line = lines.find((candidate) => candidate.trim().startsWith(`${label}:`));
1923
+ return line?.split(":").slice(1).join(":").trim();
1924
+ }
1925
+ function numberFromText(value) {
1926
+ if (!value) return void 0;
1927
+ const number = Number(value.replaceAll(",", "").match(/\d+/)?.[0]);
1928
+ return Number.isFinite(number) ? number : void 0;
1929
+ }
1930
+ function percentFromText(value) {
1931
+ if (!value) return void 0;
1932
+ const match = value.match(/([\d.]+)%/);
1933
+ return match?.[1] ? Number(match[1]) : void 0;
1934
+ }
1935
+ function commandRows(lines) {
1936
+ const start = lines.findIndex((line) => line.trim() === "By Command");
1937
+ const end = lines.findIndex((line, index) => index > start && line.trim() === "Recent Commands");
1938
+ if (start < 0) return [];
1939
+ return lines.slice(start + 1, end > start ? end : void 0).map((line) => line.trim()).filter((line) => /^\d+\.\s+/.test(line)).flatMap((line) => {
1940
+ const withoutRank = line.replace(/^\d+\.\s+/, "");
1941
+ const columns = withoutRank.split(/\s{2,}/).filter(Boolean);
1942
+ const [command2, count2, saved, averageSavedPercent, time] = columns;
1943
+ if (!command2) return [];
1944
+ return [{
1945
+ command: command2,
1946
+ count: numberFromText(count2),
1947
+ saved,
1948
+ averageSavedPercent: percentFromText(averageSavedPercent),
1949
+ time
1950
+ }];
1951
+ });
1952
+ }
1953
+ function coverageGapRows(lines) {
1954
+ const start = lines.findIndex((line) => line.trim().startsWith("MISSED SAVINGS"));
1955
+ const end = lines.findIndex((line, index) => index > start && line.trim().startsWith("Total:"));
1956
+ if (start < 0) return [];
1957
+ return lines.slice(start + 3, end > start ? end : void 0).map((line) => line.trim()).filter((line) => line && !line.startsWith("\u2500") && !line.startsWith("Command")).flatMap((line) => {
1958
+ const match = line.match(/^(.+?)\s{2,}(\d+)\s{2,}(.+?)\s{2,}(.+?)\s{2,}(.+)$/);
1959
+ if (!match?.[1]) return [];
1960
+ return [{
1961
+ command: match[1].trim(),
1962
+ count: numberFromText(match[2]),
1963
+ rtkEquivalent: match[3]?.trim(),
1964
+ status: match[4]?.trim(),
1965
+ estimatedSavings: match[5]?.replace(/^~/, "").trim()
1966
+ }];
1967
+ });
1968
+ }
1969
+ function unhandledCommandRows(lines) {
1970
+ const start = lines.findIndex((line) => line.trim().startsWith("TOP UNHANDLED COMMANDS"));
1971
+ const end = lines.findIndex((line, index) => index > start && line.trim().startsWith("->"));
1972
+ if (start < 0) return [];
1973
+ return lines.slice(start + 3, end > start ? end : void 0).map((line) => line.trim()).filter((line) => line && !line.startsWith("\u2500") && !line.startsWith("Command")).flatMap((line) => {
1974
+ const match = line.match(/^(.+?)\s{2,}(\d+)\s{2,}(.+)$/);
1975
+ if (!match?.[1]) return [];
1976
+ return [{
1977
+ command: match[1].trim(),
1978
+ count: numberFromText(match[2]),
1979
+ example: match[3]?.trim()
1980
+ }];
1981
+ });
1982
+ }
1983
+ function recentCommandLines(lines) {
1984
+ const start = lines.findIndex((line) => line.trim() === "Recent Commands");
1985
+ if (start < 0) return [];
1986
+ return lines.slice(start + 2).map((line) => line.trim()).filter((line) => line && !line.startsWith("\u2500")).slice(0, 6);
1987
+ }
1988
+
1989
+ // apps/server/src/server.ts
1990
+ function createServer(options = {}) {
1991
+ const app = Fastify({ logger: false });
1992
+ app.get("/api/health", async () => ({ ok: true, app: "RepoSpend", version: readPackageVersion() }));
1993
+ app.get("/api/dashboard", async (request) => {
1994
+ const data = readDashboardData(parseFilters(request.query));
1995
+ const rtkGain = await readRtkGain();
1996
+ return {
1997
+ ...data,
1998
+ summary: { ...data.summary, skippedZeroTokenSessions: data.skippedZeroTokenSessions },
1999
+ pricing: {
2000
+ info: pricingInfo,
2001
+ ...readPricingData()
2002
+ },
2003
+ rtkGain
2004
+ };
2005
+ });
2006
+ app.put("/api/settings/pricing", async (request) => ({
2007
+ info: pricingInfo,
2008
+ ...writePricingData(validatePricingBody(request.body))
2009
+ }));
2010
+ app.delete("/api/settings/local-data", async () => clearRepoSpendLocalData());
2011
+ app.get("/api/export.json", async (_request, reply) => {
2012
+ reply.header("content-type", "application/json; charset=utf-8");
2013
+ return exportJson();
2014
+ });
2015
+ app.get("/api/export.csv", async (_request, reply) => {
2016
+ reply.header("content-type", "text/csv; charset=utf-8");
2017
+ reply.header("content-disposition", 'attachment; filename="repospend-export.csv"');
2018
+ return exportCsv();
2019
+ });
2020
+ 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");
2024
+ const webDist = fsExists(packagedWebDist) ? packagedWebDist : workspaceWebDist;
2025
+ if (!fsExists(webDist)) {
2026
+ 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.`);
2027
+ }
2028
+ app.register(fastifyStatic, {
2029
+ root: webDist,
2030
+ prefix: "/",
2031
+ wildcard: false
2032
+ });
2033
+ app.setNotFoundHandler((request, reply) => {
2034
+ if (request.url.startsWith("/api/")) {
2035
+ reply.code(404).send({ error: "API route not found" });
2036
+ return;
2037
+ }
2038
+ reply.sendFile("index.html");
2039
+ });
2040
+ }
2041
+ return app;
2042
+ }
2043
+ function fsExists(inputPath) {
2044
+ return Boolean(inputPath) && path5.isAbsolute(inputPath) && fs6.existsSync(inputPath);
2045
+ }
2046
+ function readPackageVersion() {
2047
+ const serverDist = path5.dirname(fileURLToPath(import.meta.url));
2048
+ const packagePath = path5.resolve(serverDist, "../package.json");
2049
+ try {
2050
+ const parsed = JSON.parse(fs6.readFileSync(packagePath, "utf8"));
2051
+ return typeof parsed.version === "string" ? parsed.version : "unknown";
2052
+ } catch {
2053
+ return "unknown";
2054
+ }
2055
+ }
2056
+ async function startServer(options = {}) {
2057
+ const port = options.port ?? Number(process.env.REPOSPEND_PORT ?? 2005);
2058
+ const host = options.host ?? "127.0.0.1";
2059
+ const app = createServer(options);
2060
+ await app.listen({ host, port });
2061
+ return { app, url: `http://localhost:${port}` };
2062
+ }
2063
+ function validatePricingBody(body) {
2064
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
2065
+ throw new Error("Expected JSON body with a models object.");
2066
+ }
2067
+ const models = body.models;
2068
+ if (!models || typeof models !== "object" || Array.isArray(models)) {
2069
+ throw new Error("Expected JSON body with a models object.");
2070
+ }
2071
+ const validated = {};
2072
+ for (const [model, value] of Object.entries(models)) {
2073
+ if (!model.trim() || !value || typeof value !== "object" || Array.isArray(value)) continue;
2074
+ const row = value;
2075
+ const inputPerMillion = finiteNumber(row.inputPerMillion);
2076
+ const outputPerMillion = finiteNumber(row.outputPerMillion);
2077
+ if (inputPerMillion === void 0 || outputPerMillion === void 0) continue;
2078
+ const modelPricing = {
2079
+ inputPerMillion,
2080
+ outputPerMillion
2081
+ };
2082
+ const cachedInputPerMillion = finiteNumber(row.cachedInputPerMillion);
2083
+ const reasoningOutputPerMillion = finiteNumber(row.reasoningOutputPerMillion);
2084
+ if (cachedInputPerMillion !== void 0) modelPricing.cachedInputPerMillion = cachedInputPerMillion;
2085
+ if (reasoningOutputPerMillion !== void 0) modelPricing.reasoningOutputPerMillion = reasoningOutputPerMillion;
2086
+ if (typeof row.note === "string") modelPricing.note = row.note;
2087
+ validated[model] = modelPricing;
2088
+ }
2089
+ return validated;
2090
+ }
2091
+ function finiteNumber(value) {
2092
+ if (value === "" || value === null || value === void 0) return void 0;
2093
+ const parsed = typeof value === "number" ? value : Number(value);
2094
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : void 0;
2095
+ }
2096
+
2097
+ // apps/server/src/cli.ts
2098
+ var [command = "serve", ...args] = process.argv.slice(2);
2099
+ try {
2100
+ if (command === "serve") {
2101
+ const { url } = await startServer({ port: Number(process.env.REPOSPEND_PORT ?? 2005), serveWeb: true });
2102
+ console.log(`RepoSpend dashboard: ${url}`);
2103
+ openBrowser(url);
2104
+ } else if (command === "scan") {
2105
+ const data = readDashboardData();
2106
+ console.log(JSON.stringify({ sources: data.sources, sessionCount: data.sessions.length, summary: data.summary }, null, 2));
2107
+ } else if (command === "by-repo") {
2108
+ printGroups(groupByRepo(readDashboardData().sessions));
2109
+ } else if (command === "by-day") {
2110
+ printGroups(groupByDay(readDashboardData().sessions));
2111
+ } else if (command === "by-hour") {
2112
+ printGroups(groupByHour(readDashboardData().sessions));
2113
+ } else if (command === "by-model") {
2114
+ printGroups(groupByModel(readDashboardData().sessions));
2115
+ } else if (command === "by-app") {
2116
+ printGroups(groupBySourceApp(readDashboardData().sessions));
2117
+ } else if (command === "export") {
2118
+ const format = valueAfter(args, "--format") ?? "json";
2119
+ const sessions = readDashboardData().sessions;
2120
+ console.log(format === "csv" ? toCsv(sessions) : exportJson());
2121
+ } else {
2122
+ console.error(`Unknown command: ${command}`);
2123
+ printHelp();
2124
+ process.exitCode = 1;
2125
+ }
2126
+ } catch (error) {
2127
+ console.error(error instanceof Error ? error.message : String(error));
2128
+ process.exitCode = 1;
2129
+ }
2130
+ function openBrowser(url) {
2131
+ if (process.env.REPOSPEND_NO_OPEN === "1" || process.env.CI === "true") return;
2132
+ const command2 = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
2133
+ const args2 = process.platform === "win32" ? ["/c", "start", "", url] : [url];
2134
+ const child = spawn(command2, args2, { detached: true, stdio: "ignore" });
2135
+ child.on("error", () => {
2136
+ });
2137
+ child.unref();
2138
+ }
2139
+ function printGroups(groups) {
2140
+ if (!groups.length) {
2141
+ console.log("No usage found. Scanned ~/.codex/state_5.sqlite and ~/.codex/sessions.");
2142
+ return;
2143
+ }
2144
+ const rows = groups.map((group) => ({
2145
+ name: group.label,
2146
+ cost: group.estimatedCostUsd === void 0 ? "unknown" : `$${group.estimatedCostUsd.toFixed(4)}`,
2147
+ tokens: group.totalTokens,
2148
+ sessions: group.sessionCount
2149
+ }));
2150
+ console.table(rows);
2151
+ }
2152
+ function valueAfter(args2, flag) {
2153
+ const index = args2.indexOf(flag);
2154
+ return index >= 0 ? args2[index + 1] : void 0;
2155
+ }
2156
+ function printHelp() {
2157
+ console.log(`RepoSpend commands:
2158
+ repospend serve
2159
+ repospend scan
2160
+ repospend by-repo
2161
+ repospend by-day
2162
+ repospend by-hour
2163
+ repospend by-model
2164
+ repospend by-app
2165
+ repospend export --format json
2166
+ repospend export --format csv`);
2167
+ }