moshcode 0.58.0 → 0.60.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.
@@ -0,0 +1,232 @@
1
+ // `moshcode cost` — what the herd is spending, right now.
2
+ //
3
+ // The roster answers "which agent is blocked". This answers the other question
4
+ // you have at 2am with six agents running: "what is this costing me". Same
5
+ // shape as `moshcode ps` on purpose — one row per session, one line of totals —
6
+ // because it is the same list of sessions seen through a different column.
7
+ //
8
+ // Everything it prints comes from the engines' own session logs (src/cost.mjs).
9
+ // Nothing is sampled, nothing is proxied, and a number the engine itself
10
+ // computed is never overwritten by our arithmetic.
11
+ import {
12
+ DEFAULT_WINDOW_MS, UNCOSTED_ENGINES, attributeRuns, engineRuns,
13
+ formatTokens, formatUsd, totals,
14
+ } from "./cost.mjs";
15
+ import { pricingFile } from "./cost-pricing.mjs";
16
+ import { EXIT, humanAge, roster } from "./herd-cli.mjs";
17
+ import { acid, ash, bone, dim, err, info, table, warn } from "./ui.mjs";
18
+
19
+ const tilde = (p) => {
20
+ const home = process.env.HOME || "";
21
+ return home && p.startsWith(home) ? `~${p.slice(home.length)}` : p;
22
+ };
23
+
24
+ /** "30m", "6h", "3d", "90s" — the same vocabulary `moshcode wait --timeout` takes. */
25
+ export function parseWindow(raw, fallback = DEFAULT_WINDOW_MS) {
26
+ const m = /^(\d+(?:\.\d+)?)\s*([smhd])?$/.exec(String(raw ?? "").trim());
27
+ if (!m) return fallback;
28
+ const n = Number(m[1]);
29
+ if (!Number.isFinite(n) || n <= 0) return fallback;
30
+ return n * { s: 1e3, m: 60e3, h: 3600e3, d: 86400e3 }[m[2] || "h"];
31
+ }
32
+
33
+ function flagValue(argv, name) {
34
+ const i = argv.indexOf(name);
35
+ if (i === -1) return null;
36
+ const next = argv[i + 1];
37
+ return next && !next.startsWith("-") ? next : null;
38
+ }
39
+
40
+ /**
41
+ * How confident the dollar figure is, in one character.
42
+ *
43
+ * `~` means we multiplied tokens by a published rate card; no marker means the
44
+ * engine handed us the price. On a subscription the estimate is what the same
45
+ * work would cost on the API — worth watching, not worth invoicing.
46
+ */
47
+ function costCell(cost, source) {
48
+ if (cost == null) return ash("—");
49
+ const text = formatUsd(cost);
50
+ if (source === "engine") return bone(text);
51
+ return `${bone(text)}${dim("~")}`;
52
+ }
53
+
54
+ /**
55
+ * Cache tokens get their own column rather than folding into `in`.
56
+ *
57
+ * On a long agent session they are most of the traffic and a tenth of the price
58
+ * — a single "in" number that mixes them makes a $3 session look like a $60
59
+ * one, and hides the thing you would actually act on.
60
+ */
61
+ const cacheTokens = (u) => u.cacheRead + u.cacheWrite5m + u.cacheWrite1h;
62
+
63
+ /** The per-session table, shared by the one-shot report and `--watch`. */
64
+ export function renderCost(rows, { indent = " " } = {}) {
65
+ if (!rows.length) return "";
66
+ return table(
67
+ rows.map((r) => [
68
+ bone(r.name),
69
+ ash(String(r.engine)),
70
+ ash(r.models?.length ? r.models.join(",") : "—"),
71
+ dim(formatTokens(r.usage.input)),
72
+ dim(formatTokens(r.usage.output)),
73
+ dim(formatTokens(cacheTokens(r.usage))),
74
+ costCell(r.cost, r.costSource),
75
+ dim(humanAge(r.age)),
76
+ ]),
77
+ { columns: ["session", "engine", "model", "in", "out", "cache", "cost", "age"], header: true, indent: indent.length },
78
+ );
79
+ }
80
+
81
+ /** The `--all` table: engine sessions as the engines recorded them. */
82
+ function renderRuns(runs, { indent = " " } = {}) {
83
+ if (!runs.length) return "";
84
+ return table(
85
+ runs.map((r) => [
86
+ bone(String(r.id).slice(0, 8)),
87
+ ash(r.engine),
88
+ ash(r.models?.length ? r.models.join(",") : "—"),
89
+ ash(tilde(r.cwd || "")),
90
+ dim(formatTokens(r.usage.input)),
91
+ dim(formatTokens(r.usage.output)),
92
+ dim(formatTokens(cacheTokens(r.usage))),
93
+ costCell(r.cost, r.costSource),
94
+ ]),
95
+ { columns: ["run", "engine", "model", "cwd", "in", "out", "cache", "cost"], header: true, indent: indent.length },
96
+ );
97
+ }
98
+
99
+ /**
100
+ * Gather everything once: the roster, the engine runs in the window, and the
101
+ * attribution between them. Returned whole so `--json`, the table, and the
102
+ * herd bar all read the same numbers.
103
+ */
104
+ export async function costReport({ since, cwd = null, engines = null } = {}) {
105
+ const sessions = roster();
106
+ const runs = await engineRuns({ since, cwd, engines });
107
+ const { rows, unattributed } = attributeRuns(sessions, runs);
108
+ return { sessions, runs, rows, unattributed, since };
109
+ }
110
+
111
+ /**
112
+ * The total is the total of the table above it, and anything the table left out
113
+ * is its own line. A single grand total over rows that are not all shown reads
114
+ * as "your herd cost $850" when the herd cost nothing and another terminal did.
115
+ */
116
+ function footer(report, write, { attribution = true } = {}) {
117
+ const shown = totals(report.rows);
118
+ const loose = totals(report.unattributed);
119
+ const all = totals([...report.rows, ...report.unattributed]);
120
+
121
+ write("");
122
+ write(` ${bone("total")} ${costCell(shown.cost, "rates")} ${dim(`${formatTokens(shown.usage.input)} in · ${formatTokens(shown.usage.output)} out · ${formatTokens(cacheTokens(shown.usage))} cached`)}`);
123
+ if (attribution && report.unattributed.length) {
124
+ write(info(`plus ${formatUsd(loose.cost)} in ${report.unattributed.length} engine session(s) outside the herd — ${acid("moshcode cost --all")} shows them.`));
125
+ }
126
+ if (shown.cost != null || loose.cost != null) {
127
+ write(dim(` ~ estimated from published rates; unmarked figures are the engine's own.`));
128
+ }
129
+ if (all.unpriced.length) {
130
+ write(warn(`no rate for ${all.unpriced.join(", ")} — tokens counted, cost omitted.`));
131
+ write(info(`price them in ${tilde(pricingFile())}: { "${all.unpriced[0]}": { "input": 1.25, "output": 10 } }`));
132
+ }
133
+ }
134
+
135
+ /**
136
+ * `moshcode cost [name] [--all] [--since 6h] [--engine <name>] [--json] [--watch [secs]]`
137
+ */
138
+ export async function costCommand(argv = [], { write = console.log } = {}) {
139
+ const asJson = argv.includes("--json");
140
+ const all = argv.includes("--all");
141
+ const since = Date.now() - parseWindow(flagValue(argv, "--since"));
142
+ const engineFlag = flagValue(argv, "--engine");
143
+ const engines = engineFlag ? engineFlag.split(",").map((s) => s.trim()).filter(Boolean) : null;
144
+ const watch = argv.includes("--watch");
145
+ const flagWords = new Set([flagValue(argv, "--since"), flagValue(argv, "--engine"), flagValue(argv, "--watch")]);
146
+ const name = argv.find((a) => !a.startsWith("-") && !flagWords.has(a)) || null;
147
+
148
+ if (watch && asJson) {
149
+ write(err("--watch and --json do not go together — pipe repeated `moshcode cost --json` instead."));
150
+ return EXIT.usage;
151
+ }
152
+
153
+ const once = async () => {
154
+ const report = await costReport({ since, engines });
155
+ let rows = report.rows;
156
+ if (name) {
157
+ rows = rows.filter((r) => r.name === name);
158
+ if (!rows.length) {
159
+ write(err(`no session named ${JSON.stringify(name)} — ${acid("moshcode ps")}`));
160
+ return EXIT.gone;
161
+ }
162
+ }
163
+
164
+ if (asJson) {
165
+ write(JSON.stringify({
166
+ since,
167
+ sessions: rows.map(({ name: n, engine, cwd, state, models, usage, cost, costSource, unpriced, runs }) => ({
168
+ name: n, engine, cwd, state, models, usage, cost, costSource, unpriced,
169
+ runs: runs.map((r) => ({ id: r.id, model: r.model, usage: r.usage, cost: r.cost, costSource: r.costSource, start: r.start, end: r.end })),
170
+ })),
171
+ unattributed: report.unattributed.map((r) => ({
172
+ id: r.id, engine: r.engine, cwd: r.cwd, model: r.model, usage: r.usage, cost: r.cost, costSource: r.costSource, start: r.start, end: r.end,
173
+ })),
174
+ totals: totals([...rows, ...report.unattributed]),
175
+ }, null, 2));
176
+ return EXIT.matched;
177
+ }
178
+
179
+ if (all) {
180
+ const runs = name ? rows.flatMap((r) => r.runs) : report.runs;
181
+ if (!runs.length) {
182
+ write(info("no engine sessions on disk in this window — widen it with `--since 7d`."));
183
+ return EXIT.matched;
184
+ }
185
+ write(renderRuns(runs));
186
+ // The runs ARE the rows here, so they are what the total totals. And the
187
+ // "not tied to a herd session" note would be describing the whole table
188
+ // back at itself, so it stays off.
189
+ footer({ ...report, rows: runs, unattributed: [] }, write, { attribution: false });
190
+ return EXIT.matched;
191
+ }
192
+
193
+ if (!rows.length) {
194
+ write(info("the herd is empty — `moshcode herd start claude` puts something in it."));
195
+ write(info(`already ran an agent outside the herd? ${acid("moshcode cost --all")}`));
196
+ return EXIT.matched;
197
+ }
198
+
199
+ write(renderCost(rows));
200
+ footer({ ...report, rows }, write);
201
+ if (UNCOSTED_ENGINES.some((e) => rows.some((r) => r.engine === e))) {
202
+ write(info(`${UNCOSTED_ENGINES.join(", ")} keep no usage log moshcode can read — those rows show no cost, not zero cost.`));
203
+ }
204
+ return EXIT.matched;
205
+ };
206
+
207
+ if (!watch) return once();
208
+
209
+ // --watch is the "ongoing" part: the same report, re-read on an interval,
210
+ // because the interesting thing about a running agent's cost is the slope.
211
+ const every = Math.max(2, Number(flagValue(argv, "--watch") || 10)) * 1000;
212
+ let stop = false;
213
+ const onSigint = () => { stop = true; };
214
+ process.on("SIGINT", onSigint);
215
+ try {
216
+ while (!stop) {
217
+ if (process.stdout.isTTY) process.stdout.write("\x1b[2J\x1b[H");
218
+ await once();
219
+ write(dim(` refreshing every ${Math.round(every / 1000)}s · ctrl-c to stop`));
220
+ await new Promise((resolve) => {
221
+ const timer = setTimeout(resolve, every);
222
+ // A timer must not hold the process open past a ctrl-c.
223
+ timer.unref?.();
224
+ const poll = setInterval(() => { if (stop) { clearTimeout(timer); clearInterval(poll); resolve(); } }, 100);
225
+ poll.unref?.();
226
+ });
227
+ }
228
+ } finally {
229
+ process.off("SIGINT", onSigint);
230
+ }
231
+ return EXIT.matched;
232
+ }
@@ -0,0 +1,159 @@
1
+ // What a million tokens costs, per model, so `moshcode cost` can turn the token
2
+ // counts an engine wrote down into dollars.
3
+ //
4
+ // TOKENS ARE THE MEASUREMENT; DOLLARS ARE THE ESTIMATE. Only some engines
5
+ // record a price of their own (opencode computes one per message, aider prints
6
+ // a running session total). The rest — Claude Code, Codex — record usage and
7
+ // nothing else, because the person running them is usually on a subscription
8
+ // where the marginal request costs nothing extra. What this table produces for
9
+ // those is "what these tokens would have cost at published API rates", which is
10
+ // the number worth watching while a herd of agents burns through a repo, and is
11
+ // not a bill. src/cost.mjs keeps the two apart: `costSource` is "engine" when
12
+ // the engine priced it and "rates" when this table did.
13
+ //
14
+ // A model with no entry is NOT guessed at. Its tokens are still counted and
15
+ // reported, its cost comes back null, and `moshcode cost` names it under the
16
+ // table so you can price it yourself. A wrong number that looks authoritative is worse
17
+ // than an honest blank — especially for engines whose vendors we don't track.
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import { homedir } from "node:os";
21
+
22
+ /**
23
+ * Published rates, USD per million tokens.
24
+ *
25
+ * `input`/`output` are the base rates. `cacheRead` and `cacheWrite` are
26
+ * optional; when absent they are derived from `input` by CACHE_MULTIPLIERS
27
+ * below, which is Anthropic's published relationship (and the only vendor whose
28
+ * cache pricing this file claims to know).
29
+ *
30
+ * Anthropic rates as published 2026-06; Sonnet 5's introductory $2/$10 runs
31
+ * through 2026-08-31 and is deliberately not encoded — an intro rate that
32
+ * expires silently would make this table wrong on a date nobody is watching.
33
+ */
34
+ export const PRICING = {
35
+ "claude-fable-5": { input: 10, output: 50 },
36
+ "claude-mythos-5": { input: 10, output: 50 },
37
+ "claude-opus-5": { input: 5, output: 25 },
38
+ "claude-opus-4-8": { input: 5, output: 25 },
39
+ "claude-opus-4-7": { input: 5, output: 25 },
40
+ "claude-opus-4-6": { input: 5, output: 25 },
41
+ "claude-opus-4-5": { input: 5, output: 25 },
42
+ "claude-sonnet-5": { input: 3, output: 15 },
43
+ "claude-sonnet-4-6": { input: 3, output: 15 },
44
+ "claude-sonnet-4-5": { input: 3, output: 15 },
45
+ "claude-haiku-4-5": { input: 1, output: 5 },
46
+ // No OpenAI, Google, Moonshot or Alibaba entries on purpose. Their coding
47
+ // CLIs are sold as subscriptions with model names that do not appear on a
48
+ // public price list (`gpt-5.6-sol` is what a Codex rollout actually records),
49
+ // so anything written here would be invented. Price them yourself:
50
+ //
51
+ // ~/.moshcode/pricing.json
52
+ // { "gpt-5.6-sol": { "input": 1.25, "output": 10 } }
53
+ };
54
+
55
+ /**
56
+ * Cache tokens as a multiple of the input rate: a read is a tenth of a fresh
57
+ * input token, a five-minute write is a quarter more, a one-hour write is
58
+ * double. Claude Code's transcript distinguishes the two write TTLs
59
+ * (`ephemeral_5m_input_tokens` / `ephemeral_1h_input_tokens`), so both are here
60
+ * rather than one blended guess.
61
+ */
62
+ export const CACHE_MULTIPLIERS = { read: 0.1, write5m: 1.25, write1h: 2 };
63
+
64
+ /** Where a user's own rates live. Merged over PRICING, never under it. */
65
+ export const pricingFile = () => path.join(homedir(), ".moshcode", "pricing.json");
66
+
67
+ /**
68
+ * User overrides. Never throws — a malformed pricing file must not take down a
69
+ * cost report, and reporting tokens with no price is already a supported state.
70
+ */
71
+ export function loadUserPricing(file = pricingFile()) {
72
+ let raw;
73
+ try { raw = JSON.parse(fs.readFileSync(file, "utf8")); }
74
+ catch { return {}; }
75
+ if (!raw || typeof raw !== "object") return {};
76
+ const out = {};
77
+ for (const [model, rate] of Object.entries(raw)) {
78
+ if (!rate || typeof rate !== "object") continue;
79
+ const input = Number(rate.input);
80
+ const output = Number(rate.output);
81
+ if (!Number.isFinite(input) || !Number.isFinite(output)) continue;
82
+ const entry = { input, output };
83
+ if (Number.isFinite(Number(rate.cacheRead))) entry.cacheRead = Number(rate.cacheRead);
84
+ if (Number.isFinite(Number(rate.cacheWrite))) entry.cacheWrite = Number(rate.cacheWrite);
85
+ out[String(model).toLowerCase()] = entry;
86
+ }
87
+ return out;
88
+ }
89
+
90
+ /**
91
+ * The rate card for one model id, or null when nobody has priced it.
92
+ *
93
+ * Matching is deliberately forgiving, in three steps, because the string an
94
+ * engine writes down is not always the string a price list uses:
95
+ * 1. exact, lowercased — `claude-opus-5`
96
+ * 2. dated snapshot stripped — `claude-haiku-4-5-20251001` → `claude-haiku-4-5`
97
+ * 3. longest table key that the model id starts with — so a provider prefix
98
+ * (`anthropic/claude-opus-5`, `us.anthropic.claude-opus-5-v1`) still finds
99
+ * its rate rather than reading as an unknown model.
100
+ * A user entry wins at every step: pricing you wrote down beats pricing we
101
+ * shipped, including for a model we already know.
102
+ */
103
+ export function rateFor(model, { userPricing = loadUserPricing() } = {}) {
104
+ if (!model) return null;
105
+ const id = String(model).trim().toLowerCase();
106
+ if (!id) return null;
107
+ const table = { ...PRICING, ...userPricing };
108
+
109
+ if (Object.hasOwn(table, id)) return table[id];
110
+
111
+ const undated = id.replace(/-\d{8}$/, "");
112
+ if (undated !== id && Object.hasOwn(table, undated)) return table[undated];
113
+
114
+ let best = null;
115
+ for (const key of Object.keys(table)) {
116
+ if (!id.includes(key)) continue;
117
+ if (!best || key.length > best.length) best = key;
118
+ }
119
+ return best ? table[best] : null;
120
+ }
121
+
122
+ /**
123
+ * Dollars for one usage bundle, or null when the model has no rate.
124
+ *
125
+ * `usage` is the shape src/cost.mjs normalises every engine into:
126
+ * { input, output, cacheRead, cacheWrite5m, cacheWrite1h }. Missing fields are
127
+ * zero — an engine that never reports cache tokens must not price as NaN.
128
+ */
129
+ export function priceUsage(model, usage = {}, options = {}) {
130
+ const rate = rateFor(model, options);
131
+ if (!rate) return null;
132
+ const m = 1e6;
133
+ const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
134
+ const readRate = rate.cacheRead ?? rate.input * CACHE_MULTIPLIERS.read;
135
+ const writeRate = rate.cacheWrite ?? rate.input * CACHE_MULTIPLIERS.write5m;
136
+ const write1hRate = rate.cacheWrite ?? rate.input * CACHE_MULTIPLIERS.write1h;
137
+ return (
138
+ (num(usage.input) * rate.input
139
+ + num(usage.output) * rate.output
140
+ + num(usage.cacheRead) * readRate
141
+ + num(usage.cacheWrite5m) * writeRate
142
+ + num(usage.cacheWrite1h) * write1hRate) / m
143
+ );
144
+ }
145
+
146
+ /** Sum usage bundles into one. */
147
+ export function addUsage(a = {}, b = {}) {
148
+ const keys = ["input", "output", "cacheRead", "cacheWrite5m", "cacheWrite1h"];
149
+ const out = {};
150
+ for (const k of keys) out[k] = (Number(a[k]) || 0) + (Number(b[k]) || 0);
151
+ return out;
152
+ }
153
+
154
+ export const EMPTY_USAGE = { input: 0, output: 0, cacheRead: 0, cacheWrite5m: 0, cacheWrite1h: 0 };
155
+
156
+ /** Every token in a bundle, cache included — the "how much did it read" number. */
157
+ export const totalTokens = (u = {}) =>
158
+ (Number(u.input) || 0) + (Number(u.output) || 0)
159
+ + (Number(u.cacheRead) || 0) + (Number(u.cacheWrite5m) || 0) + (Number(u.cacheWrite1h) || 0);