moshcode 0.58.0 → 0.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cost.mjs ADDED
@@ -0,0 +1,634 @@
1
+ // What the agents are spending, read out of the agent CLIs themselves.
2
+ //
3
+ // Every engine moshcode wraps already writes down what it used — Claude Code
4
+ // keeps a per-message `usage` block in ~/.claude/projects/**/<session>.jsonl,
5
+ // Codex emits cumulative `token_count` events into ~/.codex/sessions/…, and
6
+ // opencode stores a per-message `cost` it computed itself in SQLite. Nobody has
7
+ // to be instrumented and nothing has to be proxied: the numbers are on disk
8
+ // because the CLI put them there. This module reads them, normalises them into
9
+ // one usage shape, and (for the engines that record tokens and no price) prices
10
+ // them through src/cost-pricing.mjs.
11
+ //
12
+ // PREFER THE ENGINE'S OWN NUMBER. When a CLI computed a cost, that cost is
13
+ // reported as-is with `costSource: "engine"` — it knows which model actually
14
+ // served the request and what the account pays. Only when there is no such
15
+ // number do we multiply tokens by a rate card and mark it `costSource: "rates"`.
16
+ // The distinction is carried all the way to the rendered table, because one is
17
+ // a measurement and the other is an estimate.
18
+ //
19
+ // ATTRIBUTION IS A HEURISTIC AND SAYS SO. An engine's session log has no idea a
20
+ // herd exists. A run is matched to a herd session by engine, directory, and
21
+ // time — the most recently started session in that directory running that
22
+ // engine, at the time the run began. That is right for the ordinary case (one
23
+ // agent per directory) and can be wrong when two sessions of the same engine
24
+ // share a directory; `moshcode cost --json` carries the run list so the raw
25
+ // attribution is inspectable rather than implied.
26
+ import fs from "node:fs";
27
+ import path from "node:path";
28
+ import { homedir, tmpdir } from "node:os";
29
+
30
+ import { EMPTY_USAGE, addUsage, priceUsage, loadUserPricing } from "./cost-pricing.mjs";
31
+
32
+ /** Default reporting window: today's work, not the whole history on disk. */
33
+ export const DEFAULT_WINDOW_MS = 24 * 60 * 60 * 1000;
34
+
35
+ const home = () => homedir();
36
+ const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // File plumbing
40
+ // ---------------------------------------------------------------------------
41
+
42
+ function safeStat(file) {
43
+ try { return fs.statSync(file); } catch { return null; }
44
+ }
45
+
46
+ function listDir(dir) {
47
+ try { return fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; }
48
+ }
49
+
50
+ /**
51
+ * The last `bytes` of a file as whole lines.
52
+ *
53
+ * Codex writes one cumulative `token_count` event per turn, so the answer is
54
+ * always near the end of a rollout that can be tens of megabytes. Reading the
55
+ * tail keeps a cost report cheap enough to put in front of `moshcode ps`.
56
+ */
57
+ function tailLines(file, bytes = 256 * 1024) {
58
+ const stat = safeStat(file);
59
+ if (!stat) return [];
60
+ const start = Math.max(0, stat.size - bytes);
61
+ let fd;
62
+ try {
63
+ fd = fs.openSync(file, "r");
64
+ const buf = Buffer.alloc(Math.min(bytes, stat.size));
65
+ fs.readSync(fd, buf, 0, buf.length, start);
66
+ const text = buf.toString("utf8");
67
+ // A read that began mid-file almost certainly began mid-line; that first
68
+ // fragment is not parseable JSON and must not be handed on as if it were.
69
+ return (start > 0 ? text.slice(text.indexOf("\n") + 1) : text).split("\n");
70
+ } catch {
71
+ return [];
72
+ } finally {
73
+ if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* already gone */ } }
74
+ }
75
+ }
76
+
77
+ /** The first `bytes` of a file as whole lines (the trailing fragment dropped). */
78
+ function headLines(file, bytes = 512 * 1024) {
79
+ const stat = safeStat(file);
80
+ if (!stat) return [];
81
+ let fd;
82
+ try {
83
+ fd = fs.openSync(file, "r");
84
+ const buf = Buffer.alloc(Math.min(bytes, stat.size));
85
+ fs.readSync(fd, buf, 0, buf.length, 0);
86
+ const text = buf.toString("utf8");
87
+ const lines = text.split("\n");
88
+ if (stat.size > buf.length) lines.pop();
89
+ return lines;
90
+ } catch {
91
+ return [];
92
+ } finally {
93
+ if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* already gone */ } }
94
+ }
95
+ }
96
+
97
+ const parseJson = (line) => {
98
+ try { return JSON.parse(line); } catch { return null; }
99
+ };
100
+
101
+ const stamp = (value) => {
102
+ const t = typeof value === "number" ? value : Date.parse(value);
103
+ return Number.isFinite(t) ? t : null;
104
+ };
105
+
106
+ /** Paths compare after resolution, so `~/src/api` and `~/src/api/` are one place. */
107
+ const samePath = (a, b) => {
108
+ if (!a || !b) return false;
109
+ const norm = (p) => path.resolve(String(p)).replace(/\/+$/, "");
110
+ return norm(a) === norm(b);
111
+ };
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Claude Code — ~/.claude/projects/<slug>/<session>.jsonl
115
+ // ---------------------------------------------------------------------------
116
+
117
+ /**
118
+ * Claude Code names a project directory after the working directory with every
119
+ * character that isn't a letter or digit replaced by a dash, so `/home/a/.x`
120
+ * becomes `-home-a--x`. Both spellings are produced here because the exact
121
+ * character class has changed across releases and an unreadable transcript is
122
+ * indistinguishable from a free session — guessing one slug and finding nothing
123
+ * would silently report $0.
124
+ */
125
+ export function claudeProjectSlugs(cwd) {
126
+ const p = path.resolve(String(cwd || ""));
127
+ return [...new Set([p.replace(/[^A-Za-z0-9]/g, "-"), p.replace(/[/.]/g, "-")])];
128
+ }
129
+
130
+ const claudeProjectsDir = () => path.join(home(), ".claude", "projects");
131
+
132
+ /** Claude Code's stand-in model id for a turn it produced without an API call. */
133
+ const SYNTHETIC_MODEL = "<synthetic>";
134
+
135
+ function claudeUsageOf(message) {
136
+ const u = message?.usage;
137
+ if (!u) return null;
138
+ const creation = u.cache_creation || {};
139
+ // Both TTLs are recorded separately when present; the flat
140
+ // `cache_creation_input_tokens` is the older shape and prices as a 5m write.
141
+ const write5m = num(creation.ephemeral_5m_input_tokens);
142
+ const write1h = num(creation.ephemeral_1h_input_tokens);
143
+ const flat = num(u.cache_creation_input_tokens);
144
+ return {
145
+ input: num(u.input_tokens),
146
+ output: num(u.output_tokens),
147
+ cacheRead: num(u.cache_read_input_tokens),
148
+ cacheWrite5m: write5m || write1h ? write5m : flat,
149
+ cacheWrite1h: write1h,
150
+ };
151
+ }
152
+
153
+ /** One Claude Code transcript → one run, or null when it holds no usage. */
154
+ function readClaudeTranscript(file, { since }) {
155
+ let text;
156
+ try { text = fs.readFileSync(file, "utf8"); } catch { return null; }
157
+
158
+ const seen = new Set();
159
+ const byModel = new Map();
160
+ let usage = { ...EMPTY_USAGE };
161
+ let engineCost = 0;
162
+ let hasEngineCost = false;
163
+ let start = null;
164
+ let end = null;
165
+ let cwd = "";
166
+ let id = path.basename(file, ".jsonl");
167
+
168
+ for (const line of text.split("\n")) {
169
+ if (!line || line.charCodeAt(0) !== 123) continue; // fast reject: not "{"
170
+ const entry = parseJson(line);
171
+ if (!entry || entry.type !== "assistant") continue;
172
+ const at = stamp(entry.timestamp);
173
+ if (at != null && since != null && at < since) continue;
174
+
175
+ const message = entry.message;
176
+ // `<synthetic>` is Claude Code's marker for a message it wrote itself — an
177
+ // API error surfaced as an assistant turn, a cancellation notice. No
178
+ // request was made, so it is not a model anyone can price and its zero
179
+ // usage would otherwise show up as an unpriced model in the report.
180
+ if (message?.model === SYNTHETIC_MODEL) continue;
181
+ const one = claudeUsageOf(message);
182
+ if (!one) continue;
183
+
184
+ // A transcript replays the same assistant message when a session is resumed
185
+ // or a subagent's output is folded back in. Claude's own pair of ids is the
186
+ // only thing that distinguishes a genuine second request from an echo.
187
+ const key = `${message.id || ""}|${entry.requestId || ""}`;
188
+ if (key !== "|" && seen.has(key)) continue;
189
+ seen.add(key);
190
+
191
+ usage = addUsage(usage, one);
192
+ const model = message.model || "unknown";
193
+ byModel.set(model, addUsage(byModel.get(model) || EMPTY_USAGE, one));
194
+ if (Number.isFinite(Number(entry.costUSD))) { engineCost += Number(entry.costUSD); hasEngineCost = true; }
195
+ if (at != null) { start = start == null ? at : Math.min(start, at); end = end == null ? at : Math.max(end, at); }
196
+ if (entry.cwd) cwd = entry.cwd;
197
+ if (entry.sessionId) id = entry.sessionId;
198
+ }
199
+
200
+ if (!seen.size) return null;
201
+ return { engine: "claude", id, cwd, usage, byModel, start, end, engineCost: hasEngineCost ? engineCost : null };
202
+ }
203
+
204
+ function claudeRuns({ since, cwd } = {}) {
205
+ const root = claudeProjectsDir();
206
+ const dirs = cwd
207
+ ? claudeProjectSlugs(cwd).map((slug) => path.join(root, slug)).filter((d) => safeStat(d)?.isDirectory())
208
+ : listDir(root).filter((e) => e.isDirectory()).map((e) => path.join(root, e.name));
209
+
210
+ const runs = [];
211
+ for (const dir of dirs) {
212
+ for (const entry of listDir(dir)) {
213
+ if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
214
+ const file = path.join(dir, entry.name);
215
+ // mtime is the cheap gate: a transcript untouched since before the window
216
+ // cannot contain a request inside it, and there are thousands of these.
217
+ const stat = safeStat(file);
218
+ if (!stat || (since != null && stat.mtimeMs < since)) continue;
219
+ const run = readClaudeTranscript(file, { since });
220
+ if (!run) continue;
221
+ // The slug is lossy, so confirm against the cwd the transcript recorded.
222
+ if (cwd && run.cwd && !samePath(run.cwd, cwd)) continue;
223
+ runs.push({ ...run, cwd: run.cwd || cwd || "" });
224
+ }
225
+ }
226
+ return runs;
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // Codex — ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
231
+ // ---------------------------------------------------------------------------
232
+
233
+ const codexSessionsDir = () => path.join(home(), ".codex", "sessions");
234
+
235
+ /**
236
+ * Codex's `total_token_usage` is cumulative for the whole rollout, so the last
237
+ * event is the answer and earlier ones are prefixes of it. `input_tokens`
238
+ * there *includes* the cached portion, so the fresh input is the difference —
239
+ * counting both would bill the cache twice at the full input rate.
240
+ */
241
+ function codexUsageOf(info) {
242
+ const t = info?.total_token_usage || {};
243
+ const cached = num(t.cached_input_tokens);
244
+ return {
245
+ input: Math.max(0, num(t.input_tokens) - cached),
246
+ output: num(t.output_tokens),
247
+ cacheRead: cached,
248
+ cacheWrite5m: num(t.cache_write_input_tokens),
249
+ cacheWrite1h: 0,
250
+ };
251
+ }
252
+
253
+ function readCodexRollout(file, { since, cwd }) {
254
+ const head = headLines(file);
255
+ let meta = null;
256
+ let model = "";
257
+ for (const line of head) {
258
+ const entry = parseJson(line);
259
+ if (!entry) continue;
260
+ if (entry.type === "session_meta" && !meta) meta = entry.payload || {};
261
+ if (entry.type === "turn_context" && entry.payload?.model) model = entry.payload.model;
262
+ if (meta && model) break;
263
+ }
264
+ if (!meta) return null;
265
+ if (cwd && meta.cwd && !samePath(meta.cwd, cwd)) return null;
266
+
267
+ const tail = tailLines(file);
268
+ let last = null;
269
+ for (const line of tail) {
270
+ const entry = parseJson(line);
271
+ if (!entry) continue;
272
+ if (entry.payload?.type === "token_count" && entry.payload?.info) last = entry;
273
+ // The model can change mid-rollout; the last turn_context wins.
274
+ if (entry.type === "turn_context" && entry.payload?.model) model = entry.payload.model;
275
+ }
276
+ if (!last) return null;
277
+
278
+ const end = stamp(last.timestamp);
279
+ const start = stamp(meta.timestamp) ?? end;
280
+ // A rollout that finished before the window still has its cumulative total in
281
+ // it; including it would bill yesterday's work against today.
282
+ if (since != null && end != null && end < since) return null;
283
+
284
+ const usage = codexUsageOf(last.payload.info);
285
+ return {
286
+ engine: "codex",
287
+ id: meta.session_id || meta.id || path.basename(file, ".jsonl"),
288
+ cwd: meta.cwd || cwd || "",
289
+ usage,
290
+ byModel: new Map([[model || "unknown", usage]]),
291
+ start, end, engineCost: null,
292
+ };
293
+ }
294
+
295
+ function codexRuns({ since, cwd } = {}) {
296
+ const root = codexSessionsDir();
297
+ const runs = [];
298
+ // The tree is YYYY/MM/DD, which is a date filter you can walk without opening
299
+ // anything: a whole day older than the window is skipped by name.
300
+ const cutoffDay = since != null ? new Date(since - 24 * 60 * 60 * 1000).toISOString().slice(0, 10) : null;
301
+ for (const y of listDir(root)) {
302
+ if (!y.isDirectory()) continue;
303
+ for (const m of listDir(path.join(root, y.name))) {
304
+ if (!m.isDirectory()) continue;
305
+ for (const d of listDir(path.join(root, y.name, m.name))) {
306
+ if (!d.isDirectory()) continue;
307
+ if (cutoffDay && `${y.name}-${m.name}-${d.name}` < cutoffDay) continue;
308
+ const dir = path.join(root, y.name, m.name, d.name);
309
+ for (const f of listDir(dir)) {
310
+ if (!f.isFile() || !f.name.endsWith(".jsonl")) continue;
311
+ const file = path.join(dir, f.name);
312
+ const stat = safeStat(file);
313
+ if (!stat || (since != null && stat.mtimeMs < since)) continue;
314
+ const run = readCodexRollout(file, { since, cwd });
315
+ if (run) runs.push(run);
316
+ }
317
+ }
318
+ }
319
+ }
320
+ return runs;
321
+ }
322
+
323
+ // ---------------------------------------------------------------------------
324
+ // opencode / privacycode — SQLite, and it priced the messages itself
325
+ // ---------------------------------------------------------------------------
326
+
327
+ const OPENCODE_DBS = {
328
+ opencode: () => path.join(home(), ".local", "share", "opencode", "opencode.db"),
329
+ // A fork that kept the schema and the file name, under its own data dir.
330
+ privacycode: () => path.join(home(), ".local", "share", "privacycode", "opencode.db"),
331
+ };
332
+
333
+ /**
334
+ * Open a live SQLite database without disturbing it.
335
+ *
336
+ * Read-only is the first attempt and usually works. When the database is in WAL
337
+ * mode and its shared-memory file is missing, SQLite cannot open it read-only
338
+ * at all — so the fallback copies the three files somewhere private and reads
339
+ * the copy. Never write to the original: opencode may be running on it.
340
+ */
341
+ async function openReadonly(file) {
342
+ if (!safeStat(file)) return null;
343
+ let DatabaseSync;
344
+ try { ({ DatabaseSync } = await import("node:sqlite")); }
345
+ catch { return null; } // no built-in sqlite on this runtime; opencode is simply not reported
346
+ try {
347
+ return { db: new DatabaseSync(file, { readOnly: true }), cleanup: () => {} };
348
+ } catch { /* fall through to the copy */ }
349
+ let dir;
350
+ try {
351
+ dir = fs.mkdtempSync(path.join(tmpdir(), "moshcode-cost-"));
352
+ for (const suffix of ["", "-wal", "-shm"]) {
353
+ if (safeStat(file + suffix)) fs.copyFileSync(file + suffix, path.join(dir, path.basename(file) + suffix));
354
+ }
355
+ const db = new DatabaseSync(path.join(dir, path.basename(file)));
356
+ return { db, cleanup: () => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* temp */ } } };
357
+ } catch {
358
+ if (dir) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* temp */ } }
359
+ return null;
360
+ }
361
+ }
362
+
363
+ async function opencodeRuns(engine, { since, cwd } = {}) {
364
+ const handle = await openReadonly(OPENCODE_DBS[engine]());
365
+ if (!handle) return [];
366
+ const { db, cleanup } = handle;
367
+ const bySession = new Map();
368
+ try {
369
+ const rows = db.prepare(
370
+ "select session_id, time_created, data from message where time_created >= ? order by time_created",
371
+ ).all(since ?? 0);
372
+ for (const row of rows) {
373
+ const data = parseJson(row.data);
374
+ if (!data || data.role !== "assistant") continue;
375
+ if (cwd && data.path?.cwd && !samePath(data.path.cwd, cwd)) continue;
376
+ const t = data.tokens || {};
377
+ const one = {
378
+ input: num(t.input),
379
+ // Reasoning tokens are billed as output and reported separately.
380
+ output: num(t.output) + num(t.reasoning),
381
+ cacheRead: num(t.cache?.read),
382
+ cacheWrite5m: num(t.cache?.write),
383
+ cacheWrite1h: 0,
384
+ };
385
+ const key = row.session_id;
386
+ const run = bySession.get(key) || {
387
+ engine, id: key, cwd: data.path?.cwd || cwd || "",
388
+ usage: { ...EMPTY_USAGE }, byModel: new Map(),
389
+ start: null, end: null, engineCost: 0,
390
+ };
391
+ run.usage = addUsage(run.usage, one);
392
+ const model = data.modelID || "unknown";
393
+ run.byModel.set(model, addUsage(run.byModel.get(model) || EMPTY_USAGE, one));
394
+ // opencode records the price it computed per message — that is the
395
+ // authoritative number and the rate card never gets a vote on it.
396
+ run.engineCost += num(data.cost);
397
+ const at = num(row.time_created) || stamp(data.time?.created);
398
+ if (at) { run.start = run.start == null ? at : Math.min(run.start, at); run.end = run.end == null ? at : Math.max(run.end, at); }
399
+ bySession.set(key, run);
400
+ }
401
+ } catch {
402
+ // A schema that moved under us is a reason to report nothing for this
403
+ // engine, not a reason to fail the whole report.
404
+ } finally {
405
+ try { db.close(); } catch { /* already closed */ }
406
+ cleanup();
407
+ }
408
+ return [...bySession.values()];
409
+ }
410
+
411
+ // ---------------------------------------------------------------------------
412
+ // aider — it prints the running total into its own chat history
413
+ // ---------------------------------------------------------------------------
414
+
415
+ /** "12.3k" / "1.1M" / "450" as aider writes token counts. */
416
+ function parseCount(raw) {
417
+ const m = /^([\d.]+)\s*([kKmM])?$/.exec(String(raw).trim());
418
+ if (!m) return 0;
419
+ const n = Number(m[1]);
420
+ if (!Number.isFinite(n)) return 0;
421
+ const scale = m[2] ? { k: 1e3, m: 1e6 }[m[2].toLowerCase()] : 1;
422
+ return Math.round(n * scale);
423
+ }
424
+
425
+ /**
426
+ * aider keeps `.aider.chat.history.md` beside the code and writes a line like
427
+ * > Tokens: 12k sent, 1.1k received. Cost: $0.03 message, $0.24 session.
428
+ * after every exchange, with a `# aider chat started at …` banner between runs.
429
+ * The session figure is cumulative, so the last one in a segment is that run's
430
+ * total — and it is aider's own arithmetic, not ours.
431
+ */
432
+ export function parseAiderHistory(text, { since } = {}) {
433
+ const runs = [];
434
+ const lines = String(text).split("\n");
435
+ let current = null;
436
+ const close = () => { if (current && (current.engineCost || current.usage.input || current.usage.output)) runs.push(current); current = null; };
437
+
438
+ for (const line of lines) {
439
+ const banner = /^#\s*aider chat started at\s+(.+?)\s*$/i.exec(line);
440
+ if (banner) {
441
+ close();
442
+ current = {
443
+ engine: "aider", id: banner[1].trim(), cwd: "",
444
+ usage: { ...EMPTY_USAGE }, byModel: new Map(),
445
+ start: stamp(banner[1].replace(" ", "T")), end: null, engineCost: 0,
446
+ };
447
+ continue;
448
+ }
449
+ const tokens = /Tokens:\s*([\d.]+\s*[kKmM]?)\s*sent,\s*([\d.]+\s*[kKmM]?)\s*received/.exec(line);
450
+ const cost = /Cost:\s*\$([\d.]+)\s*message,\s*\$([\d.]+)\s*session/.exec(line);
451
+ if (!tokens && !cost) continue;
452
+ // A history file that starts mid-run (rotated, or hand-edited) still has
453
+ // numbers worth reporting; give them a run with an unknown start.
454
+ if (!current) current = { engine: "aider", id: "aider", cwd: "", usage: { ...EMPTY_USAGE }, byModel: new Map(), start: null, end: null, engineCost: 0 };
455
+ if (tokens) {
456
+ current.usage = addUsage(current.usage, { input: parseCount(tokens[1]), output: parseCount(tokens[2]) });
457
+ }
458
+ if (cost) current.engineCost = Number(cost[2]) || current.engineCost;
459
+ }
460
+ close();
461
+ return runs.filter((r) => since == null || r.start == null || r.start >= since);
462
+ }
463
+
464
+ function aiderRuns({ since, cwd } = {}) {
465
+ // Unlike the others, aider's record lives in the project, so there is nothing
466
+ // to scan when no directory was named.
467
+ if (!cwd) return [];
468
+ const file = path.join(cwd, ".aider.chat.history.md");
469
+ const stat = safeStat(file);
470
+ if (!stat || (since != null && stat.mtimeMs < since)) return [];
471
+ let text;
472
+ try { text = fs.readFileSync(file, "utf8"); } catch { return []; }
473
+ return parseAiderHistory(text, { since }).map((run) => ({ ...run, cwd, end: run.end ?? stat.mtimeMs }));
474
+ }
475
+
476
+ // ---------------------------------------------------------------------------
477
+ // The readers, and the runs they produce
478
+ // ---------------------------------------------------------------------------
479
+
480
+ /**
481
+ * Which engines can be costed, and how. An engine absent from here is not free
482
+ * — it is unreported, and `moshcode cost` says so rather than showing $0.
483
+ */
484
+ export const COST_READERS = {
485
+ claude: (opts) => claudeRuns(opts),
486
+ codex: (opts) => codexRuns(opts),
487
+ opencode: (opts) => opencodeRuns("opencode", opts),
488
+ privacycode: (opts) => opencodeRuns("privacycode", opts),
489
+ aider: (opts) => aiderRuns(opts),
490
+ };
491
+
492
+ /** Engines moshcode can launch but cannot cost — named so the report can say so. */
493
+ export const UNCOSTED_ENGINES = ["gemini", "kimi", "qwen", "deepseek", "openagents"];
494
+
495
+ /**
496
+ * Finish a run: price it, and record where the price came from.
497
+ *
498
+ * A run whose model has no rate keeps its tokens and reports `cost: null`. The
499
+ * caller renders that as a blank, never as zero.
500
+ */
501
+ function priceRun(run, options) {
502
+ const models = [...run.byModel.keys()];
503
+ let cost = null;
504
+ let costSource = null;
505
+ let unpriced = [];
506
+
507
+ if (run.engineCost != null && run.engineCost > 0) {
508
+ cost = run.engineCost;
509
+ costSource = "engine";
510
+ } else {
511
+ let total = 0;
512
+ let any = false;
513
+ for (const [model, usage] of run.byModel) {
514
+ const priced = priceUsage(model, usage, options);
515
+ if (priced == null) { unpriced.push(model); continue; }
516
+ total += priced;
517
+ any = true;
518
+ }
519
+ if (any) { cost = total; costSource = "rates"; }
520
+ }
521
+ return { ...run, models, model: models[0] || "unknown", cost, costSource, unpriced, byModel: undefined };
522
+ }
523
+
524
+ /**
525
+ * Every engine session on this machine inside the window, priced.
526
+ *
527
+ * `engines` narrows the readers, `cwd` narrows to one directory (which is also
528
+ * the only way aider can be read at all). Runs come back newest-last.
529
+ */
530
+ export async function engineRuns({
531
+ since = Date.now() - DEFAULT_WINDOW_MS,
532
+ cwd = null,
533
+ engines = null,
534
+ userPricing = loadUserPricing(),
535
+ } = {}) {
536
+ const wanted = engines?.length ? engines.filter((e) => Object.hasOwn(COST_READERS, e)) : Object.keys(COST_READERS);
537
+ const collected = await Promise.all(wanted.map(async (engine) => {
538
+ // One engine's data being unreadable must not cost the report the others.
539
+ try { return await COST_READERS[engine]({ since, cwd }); }
540
+ catch { return []; }
541
+ }));
542
+ return collected
543
+ .flat()
544
+ .map((run) => priceRun(run, { userPricing }))
545
+ .sort((a, b) => (a.start ?? 0) - (b.start ?? 0));
546
+ }
547
+
548
+ // ---------------------------------------------------------------------------
549
+ // Attribution
550
+ // ---------------------------------------------------------------------------
551
+
552
+ /**
553
+ * Hang each run on the herd session that produced it.
554
+ *
555
+ * The match is engine + directory + "started before this run did", newest such
556
+ * session wins. Runs that match nothing are returned separately rather than
557
+ * spread across the sessions that happen to be nearby — a total that quietly
558
+ * absorbed another terminal's work would be worse than an honest "unattributed"
559
+ * line.
560
+ */
561
+ export function attributeRuns(sessions = [], runs = []) {
562
+ const rows = sessions.map((s) => ({ ...s, runs: [], usage: { ...EMPTY_USAGE }, cost: null, costSource: null, unpriced: [] }));
563
+ const unattributed = [];
564
+
565
+ for (const run of runs) {
566
+ const at = run.start ?? run.end ?? 0;
567
+ let best = null;
568
+ for (const row of rows) {
569
+ if (row.engine !== run.engine) continue;
570
+ if (row.cwd && run.cwd && !samePath(row.cwd, run.cwd)) continue;
571
+ const created = row.created ?? 0;
572
+ // A run that predates the session belongs to whatever came before it.
573
+ if (created > (run.end ?? at)) continue;
574
+ if (!best || created > (best.created ?? 0)) best = row;
575
+ }
576
+ if (!best) { unattributed.push(run); continue; }
577
+ best.runs.push(run);
578
+ }
579
+
580
+ for (const row of rows) {
581
+ for (const run of row.runs) {
582
+ row.usage = addUsage(row.usage, run.usage);
583
+ if (run.cost != null) row.cost = (row.cost ?? 0) + run.cost;
584
+ // A session that mixes a measured price with an estimated one is
585
+ // estimated: the weaker claim is the true one for the sum.
586
+ if (run.costSource) row.costSource = row.costSource && row.costSource !== run.costSource ? "mixed" : run.costSource;
587
+ row.unpriced.push(...(run.unpriced || []));
588
+ }
589
+ row.unpriced = [...new Set(row.unpriced)];
590
+ row.models = [...new Set(row.runs.flatMap((r) => r.models))];
591
+ }
592
+
593
+ return { rows, unattributed };
594
+ }
595
+
596
+ /** Grand total across priced rows, plus what could not be priced. */
597
+ export function totals(items = []) {
598
+ let cost = null;
599
+ let usage = { ...EMPTY_USAGE };
600
+ const unpriced = new Set();
601
+ for (const item of items) {
602
+ usage = addUsage(usage, item.usage);
603
+ if (item.cost != null) cost = (cost ?? 0) + item.cost;
604
+ for (const m of item.unpriced || []) unpriced.add(m);
605
+ }
606
+ return { cost, usage, unpriced: [...unpriced] };
607
+ }
608
+
609
+ // ---------------------------------------------------------------------------
610
+ // Formatting helpers, shared by the CLI and the bar
611
+ // ---------------------------------------------------------------------------
612
+
613
+ /**
614
+ * Money, at a precision that matches the amount. Sub-cent costs are the normal
615
+ * case for a single turn, and "$0.00" for four tenths of a cent reads as free.
616
+ */
617
+ export function formatUsd(value) {
618
+ if (value == null || !Number.isFinite(value)) return "—";
619
+ if (value === 0) return "$0";
620
+ if (value < 0.01) return `$${value.toFixed(4)}`;
621
+ if (value < 1) return `$${value.toFixed(3)}`;
622
+ return `$${value.toFixed(2)}`;
623
+ }
624
+
625
+ /** 1_234_567 → "1.2M". Token columns are for scale, not for arithmetic. */
626
+ export function formatTokens(value) {
627
+ const n = num(value);
628
+ if (n < 1000) return String(n);
629
+ if (n < 1e6) return `${(n / 1e3).toFixed(n < 10e3 ? 1 : 0)}k`;
630
+ // A week of cache reads runs to billions, and "1221.0M" is a number nobody
631
+ // reads at a glance.
632
+ if (n < 1e9) return `${(n / 1e6).toFixed(1)}M`;
633
+ return `${(n / 1e9).toFixed(2)}B`;
634
+ }