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/README.md +179 -0
- package/bin/moshcode.mjs +2 -2
- package/examples/account.mosh +48 -0
- package/examples/aliases.mosh +56 -0
- package/examples/research-desk.mosh +49 -0
- package/package.json +1 -1
- package/src/auth.mjs +59 -64
- package/src/cli-schema.mjs +35 -0
- package/src/commands.mjs +305 -29
- package/src/cost-cli.mjs +232 -0
- package/src/cost-pricing.mjs +159 -0
- package/src/cost.mjs +634 -0
- package/src/games-breakout.mjs +64 -10
- package/src/games-paddle.mjs +128 -0
- package/src/games-pong.mjs +53 -4
- package/src/games.mjs +164 -12
- package/src/herd-cli.mjs +4 -0
- package/src/tui.mjs +1 -0
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
|
+
}
|