bareloop 0.31.0 → 0.32.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,156 @@
1
+ // PANEL-BUILD.md P4a item 1 — the keys file loader (`~/.config/bareloop/.env`).
2
+ //
3
+ // Key VALUES live only in this file (chmod 600, outside any repo) and in the shell
4
+ // environment — never in config.json, the spine, runs.jsonl, a log, or a page.
5
+ // This is the ONE reader of that file. Plain `NAME=value` lines (an optional
6
+ // `export ` prefix, `#` full-line comments, one pair of matching quotes around the
7
+ // value); no dependency.
8
+ //
9
+ // Precedence: the SHELL wins. An explicit `export FOO=…` beats the file; the file
10
+ // only fills names the shell leaves unset (or empty). The merged env is a NEW
11
+ // object — `process.env` is never mutated.
12
+ //
13
+ // Values leave this module in exactly one place: the `env` of `loadKeysEnv`, which
14
+ // goes to provider construction. Everything else it returns (`names`, `warning`) is
15
+ // value-free by construction, and a parse problem never quotes the offending line.
16
+ import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { homedir } from 'node:os';
19
+
20
+ /**
21
+ * `~/.config/bareloop` (or the injected override) — same home every other
22
+ * per-person file (runs.jsonl, config.json) lives in.
23
+ * @param {string} [home] test seam; production never passes it
24
+ * @returns {string}
25
+ */
26
+ export function keysHome(home) {
27
+ return home ?? join(homedir(), '.config', 'bareloop');
28
+ }
29
+
30
+ /**
31
+ * @param {string} [home]
32
+ * @returns {string} `<home>/.env`
33
+ */
34
+ export function keysFilePath(home) {
35
+ return join(keysHome(home), '.env');
36
+ }
37
+
38
+ const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
39
+
40
+ /**
41
+ * Parse the file's text. Lines that are not `NAME=value` are skipped silently
42
+ * (never echoed — a malformed line may hold a secret).
43
+ * @param {string} text
44
+ * @returns {Record<string,string>}
45
+ */
46
+ export function parseKeysText(text) {
47
+ /** @type {Record<string,string>} */
48
+ const out = {};
49
+ for (const raw of text.split(/\r?\n/)) {
50
+ const line = raw.trim();
51
+ if (line === '' || line.startsWith('#')) continue;
52
+ const body = line.startsWith('export ') ? line.slice(7).trimStart() : line;
53
+ const eq = body.indexOf('=');
54
+ if (eq <= 0) continue;
55
+ const name = body.slice(0, eq).trim();
56
+ if (!NAME_RE.test(name)) continue;
57
+ let value = body.slice(eq + 1).trim();
58
+ if (value.length >= 2 && (value[0] === '"' || value[0] === "'") && value.endsWith(value[0])) {
59
+ value = value.slice(1, -1);
60
+ }
61
+ out[name] = value;
62
+ }
63
+ return out;
64
+ }
65
+
66
+ /**
67
+ * The NAMES in the file that carry a non-empty value, in file order — the Providers rows
68
+ * (an empty preset line is not a row). Names only; a missing/unreadable file = none.
69
+ * @param {string} [home]
70
+ * @returns {string[]}
71
+ */
72
+ export function filledKeyNames(home) {
73
+ try {
74
+ return Object.entries(parseKeysText(readFileSync(keysFilePath(home), 'utf8'))).filter(([, v]) => v !== '').map(([n]) => n);
75
+ } catch { return []; }
76
+ }
77
+
78
+ /**
79
+ * Create the keys file with the five empty preset lines when — and only when — it does not
80
+ * exist (mode 600). An existing file is never touched.
81
+ * @param {readonly string[]} presetNames the names to seed, in order
82
+ * @param {string} [home]
83
+ * @returns {boolean} true if it was created
84
+ */
85
+ export function ensureKeysFile(presetNames, home) {
86
+ const file = keysFilePath(home);
87
+ if (existsSync(file)) return false;
88
+ mkdirSync(keysHome(home), { recursive: true });
89
+ try {
90
+ writeFileSync(file, presetNames.map((n) => `${n}=\n`).join(''), { mode: 0o600, flag: 'wx' });
91
+ } catch (/** @type {any} */ e) {
92
+ if (e?.code === 'EEXIST') return false; // lost a race: the file is there, untouched
93
+ throw e;
94
+ }
95
+ return true;
96
+ }
97
+
98
+ /**
99
+ * @typedef {object} KeysLoad
100
+ * @property {boolean} exists the file is there
101
+ * @property {string[]} names the NAMES the file defines (sorted; never values)
102
+ * @property {string|null} warning a plain-words warning (file readable by others, or
103
+ * unreadable), else null — a warning, never a refusal
104
+ * @property {Record<string,string|undefined>} env shell env with the file filling the
105
+ * names the shell does not set. THE ONLY place values leave this module.
106
+ */
107
+
108
+ /**
109
+ * Load the keys file over an env. Missing file is fine (no names, env unchanged).
110
+ * @param {{ env?: Record<string,string|undefined>, home?: string }} [opts]
111
+ * @returns {KeysLoad}
112
+ */
113
+ export function loadKeysEnv(opts = {}) {
114
+ const base = opts.env ?? process.env;
115
+ const file = keysFilePath(opts.home);
116
+ if (!existsSync(file)) return { exists: false, names: [], warning: null, env: { ...base } };
117
+ /** @type {string|null} */
118
+ let warning = null;
119
+ try {
120
+ const mode = statSync(file).mode & 0o777;
121
+ if ((mode & 0o077) !== 0) {
122
+ warning = `${file} is readable by other users (mode ${mode.toString(8)}) — run: chmod 600 ${file}`;
123
+ }
124
+ } catch { /* the read below reports it */ }
125
+ /** @type {Record<string,string>} */
126
+ let parsed = {};
127
+ try {
128
+ parsed = parseKeysText(readFileSync(file, 'utf8'));
129
+ } catch (/** @type {any} */ e) {
130
+ return { exists: true, names: [], warning: `${file} could not be read (${e?.code ?? 'error'})`, env: { ...base } };
131
+ }
132
+ /** @type {Record<string,string|undefined>} */
133
+ const env = { ...base };
134
+ for (const [name, value] of Object.entries(parsed)) {
135
+ if (!env[name]) env[name] = value;
136
+ }
137
+ return { exists: true, names: Object.keys(parsed).sort(), warning, env };
138
+ }
139
+
140
+ /**
141
+ * The door helper the CLI and the panel call: load the file only when the caller is
142
+ * on the REAL environment (no injected env) or names a keys home explicitly — so a
143
+ * test that injects `env` alone never reads the real `~/.config/bareloop/.env`.
144
+ * @param {{ env?: Record<string,string|undefined>, keysHome?: string }} deps
145
+ * @returns {KeysLoad & { env: Record<string,string|undefined>, home: string|undefined }}
146
+ * `home` = the home the file was loaded from (the real `~/.config/bareloop` when none was
147
+ * injected) — or `undefined` when loading was skipped (injected env, no keysHome). The
148
+ * door passes it on so config.json (src/config.js) is read from the SAME place, and never
149
+ * from the real home in a test.
150
+ */
151
+ export function keysForDoor(deps) {
152
+ if (deps.env !== undefined && deps.keysHome === undefined) {
153
+ return { exists: false, names: [], warning: null, env: deps.env, home: undefined };
154
+ }
155
+ return { ...loadKeysEnv({ env: deps.env, home: deps.keysHome }), home: keysHome(deps.keysHome) };
156
+ }
package/src/ledger.js CHANGED
@@ -506,3 +506,29 @@ export function spendProvenance(events) {
506
506
  }
507
507
  return out;
508
508
  }
509
+
510
+ /**
511
+ * The priced-rounds spend floor + first-record→last-record wall floor,
512
+ * derived straight off the spine's raw records — the same derivation a
513
+ * died run's "at least …" figures use, factored out so build item 6
514
+ * (2026-09-28) can hand a genuinely-still-running spine the identical
515
+ * floor instead of "unknown" on the Run tab, without a second copy of this
516
+ * math.
517
+ * @param {any[]} records raw parsed spine records
518
+ * @returns {{spendFloorUsd: number|null, wallFloorMs: number|null}}
519
+ */
520
+ export function floorsFromRecords(records) {
521
+ let spendSum = 0;
522
+ let pricedCount = 0;
523
+ for (const r of records) {
524
+ if (!r || typeof r !== 'object' || !SPEND_RECORD_TYPES.includes(r.type)) continue; // worker-result echoes excluded by construction — not a spend type
525
+ if (typeof r.costUsd === 'number' && Number.isFinite(r.costUsd)) { spendSum += r.costUsd; pricedCount += 1; }
526
+ }
527
+ const spendFloorUsd = pricedCount > 0 ? spendSum : null;
528
+
529
+ const withTs = records.filter((r) => r && typeof r === 'object' && typeof r.ts === 'string');
530
+ const firstMs = withTs.length ? Date.parse(withTs[0].ts) : NaN;
531
+ const lastMs = withTs.length ? Date.parse(withTs[withTs.length - 1].ts) : NaN;
532
+ const wallFloorMs = Number.isFinite(firstMs) && Number.isFinite(lastMs) && lastMs >= firstMs ? lastMs - firstMs : null;
533
+ return { spendFloorUsd, wallFloorMs };
534
+ }
package/src/monthly.js ADDED
@@ -0,0 +1,352 @@
1
+ // PANEL-BUILD.md P4a item 2 — the monthly $ limit (R1, R2, R3).
2
+ //
3
+ // `monthSpend` sums what this local calendar month's runs spent, read off each run's own
4
+ // spine (the run list `~/.config/bareloop/runs.jsonl` carries no money). `checkMonthlyRoom`
5
+ // asks whether a run's $ cap fits in what is left. It is called at the ONE run-start seam
6
+ // both the CLI (`bareloop run-u`) and the panel (which spawns `run-u`) pass through
7
+ // (`execute` in src/userrun.js), before any token spends, and by the panel's cap note.
8
+ // The limit REFUSES, never warns; the page and the chat can never raise it (a person
9
+ // edits it by hand in Settings — src/config.js).
10
+ //
11
+ // A run still IN FLIGHT (a spine with `job-start`, no `job-end`, and a spine file written to
12
+ // within DIED_MTIME_MS — the panel's own died rule) is counted at its full leg cap, not its
13
+ // spend so far: `checkMonthlyRoom` reserves what the run may still spend, so two runs cannot
14
+ // both start against a limit only one of them fits. A died run (stale spine) counts its floor.
15
+ // The one exception is the spine a RESUME continues (`resumingSpine`): it counts its real spend
16
+ // only, because the resume's own leg cap is the remainder and already covers its unspent cap.
17
+ // Only the refusal check reserves; the Money tab keeps showing real spend (`usd`).
18
+ //
19
+ // Honesty: a run whose spend is not fully known (a died/still-running spine, an unpriced
20
+ // round, a missing spine file) makes the month total an "at least" figure — never a clean
21
+ // number. The refusal text stays exactly `Max $X (monthly limit)`; `atLeast` travels beside it.
22
+ import { existsSync, statSync } from 'node:fs';
23
+ import { resolve } from 'node:path';
24
+ import { readConfig, configPath, ConfigError } from './config.js';
25
+ import { readRunList, DIED_MTIME_MS } from './runlist.js';
26
+ import { parseJsonl } from './replayio.js';
27
+ import { SPEND_RECORD_TYPES, spendProvenance, floorsFromRecords } from './ledger.js';
28
+ import { findRow } from './providerrows.js';
29
+
30
+ // The Money tab's per-provider breakdown groups a run by the endpoint it ran on and names
31
+ // the groups it knows the vendor of. This is HISTORY labelling for spend already recorded
32
+ // (an archived run keeps its provider whatever Settings holds today) — not the Providers
33
+ // rows, which come from the keys file (src/providerrows.js).
34
+ /** @type {readonly {id: string, name: string}[]} */
35
+ const MONEY_LABELS = Object.freeze([
36
+ { id: 'anthropic', name: 'Anthropic' }, { id: 'openai', name: 'OpenAI' },
37
+ { id: 'gemini', name: 'Gemini' }, { id: 'deepseek', name: 'DeepSeek' },
38
+ ]);
39
+
40
+ /**
41
+ * Which known vendor a run's `(provider, baseUrl)` belongs to. `openai-api` with no override
42
+ * is OpenAI; with DeepSeek's host it is DeepSeek; with any OTHER override it is none (null —
43
+ * never silently pooled into OpenAI's figures).
44
+ * @param {string|null|undefined} provider
45
+ * @param {string|null|undefined} baseUrl
46
+ * @returns {string|null} a MONEY_LABELS id, or null
47
+ */
48
+ function rowIdFor(provider, baseUrl) {
49
+ if (provider === 'anthropic-api') return 'anthropic';
50
+ if (provider === 'gemini-api') return 'gemini';
51
+ if (provider === 'openai-api') {
52
+ if (!baseUrl) return 'openai';
53
+ try {
54
+ const host = new URL(baseUrl).hostname;
55
+ if (host === 'api.deepseek.com') return 'deepseek';
56
+ } catch { /* an unparseable override is no known vendor */ }
57
+ return null;
58
+ }
59
+ return null;
60
+ }
61
+
62
+ /**
63
+ * One run's own spend, off its spine records: this LEG's figure (never the chain fold),
64
+ * plus the drafting spend the run was signed with (counted once — on the first leg only).
65
+ * @param {any[]} records
66
+ * @returns {{ usd: number, complete: boolean }}
67
+ */
68
+ export function legSpend(records) {
69
+ const jobStart = records.find((r) => r && r.type === 'job-start') ?? null;
70
+ const jobEnd = records.findLast((r) => r && r.type === 'job-end') ?? null;
71
+ const rounds = records.filter((r) => r && SPEND_RECORD_TYPES.includes(r.type));
72
+ const pricedSum = rounds.reduce((a, r) => (typeof r.costUsd === 'number' && Number.isFinite(r.costUsd) ? a + r.costUsd : a), 0);
73
+ const unpriced = rounds.filter((r) => !(typeof r.costUsd === 'number' && Number.isFinite(r.costUsd))).length;
74
+ let usd = pricedSum;
75
+ let complete = false;
76
+ if (jobEnd) {
77
+ const eng = jobEnd.engagementSpentUsd;
78
+ usd = typeof eng === 'number' && Number.isFinite(eng) ? eng : pricedSum;
79
+ complete = jobEnd.spendComplete === true && unpriced === 0;
80
+ }
81
+ // drafting spend: a RESUMED / door-rerun leg carries the flag again but the first leg already counted it
82
+ const resumed = !!jobStart && 'priorSpentUsd' in jobStart;
83
+ if (jobStart && !resumed && typeof jobStart.draftSpentUsd === 'number' && Number.isFinite(jobStart.draftSpentUsd) && jobStart.draftSpentUsd > 0) {
84
+ usd += jobStart.draftSpentUsd;
85
+ if (jobStart.draftSpendComplete === false) complete = false;
86
+ }
87
+ return { usd, complete };
88
+ }
89
+
90
+ /**
91
+ * One run's wall time off its spine: job-start -> job-end when the run ended (exact), else the
92
+ * first -> last record floor (the SAME floor a died / still-running run's "at least" wall uses,
93
+ * `floorsFromRecords`). `ms` null = unknown — never 0.
94
+ * @param {any[]} records
95
+ * @returns {{ ms: number|null, complete: boolean }}
96
+ */
97
+ export function legWall(records) {
98
+ const jobStart = records.find((r) => r && r.type === 'job-start') ?? null;
99
+ const jobEnd = records.findLast((r) => r && r.type === 'job-end') ?? null;
100
+ const a = Date.parse(jobStart?.ts);
101
+ const b = Date.parse(jobEnd?.ts);
102
+ if (jobEnd && Number.isFinite(a) && Number.isFinite(b) && b >= a) return { ms: b - a, complete: true };
103
+ return { ms: floorsFromRecords(records).wallFloorMs, complete: false };
104
+ }
105
+
106
+ /**
107
+ * Tokens a spine's rounds processed (input + output + cache read + cache write — all four,
108
+ * so a cache-heavy run is not under-reported), off the real `usage` field names.
109
+ * @param {any[]} records
110
+ * @returns {number}
111
+ */
112
+ export function legTokens(records) {
113
+ let n = 0;
114
+ for (const r of records) {
115
+ if (!r || !SPEND_RECORD_TYPES.includes(r.type) || !r.usage) continue;
116
+ for (const k of ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheCreationTokens']) {
117
+ const v = r.usage[k];
118
+ if (typeof v === 'number' && Number.isFinite(v)) n += v;
119
+ }
120
+ }
121
+ return n;
122
+ }
123
+
124
+ /**
125
+ * @typedef {object} Leg
126
+ * @property {Date} at when the run was listed
127
+ * @property {string|null} provider job-start's provider (null = an older spine that carried none)
128
+ * @property {string|null} baseUrl
129
+ * @property {number} usd this leg's spend (a floor when `!complete`)
130
+ * @property {boolean} complete every figure behind `usd` is known
131
+ * @property {number} tokens
132
+ * @property {string|null} model job-start's model (older spines carry a model but no provider)
133
+ * @property {number|null} wallMs this leg's wall time (null = unknown; a floor when `!wallComplete`)
134
+ * @property {boolean} wallComplete the wall is the exact job-start -> job-end span
135
+ * @property {number} vouchedRounds rounds priced by a rate somebody vouched for (`provider`/`caller`)
136
+ * @property {number} otherRounds every other round: a built-in guess, unpriced, or no provenance on record
137
+ * @property {number} reservedUsd what the refusal check counts: `usd`, or the leg's cap when the run is in flight
138
+ * @property {boolean} unreadable the spine was missing/unreadable or the listed date was bad (spend unknown)
139
+ */
140
+
141
+ /**
142
+ * What the refusal check counts for one run: its spend, unless it is IN FLIGHT (`job-start`, no
143
+ * `job-end`, spine written to within DIED_MTIME_MS of `nowMs`), when it is the larger of that
144
+ * spend and the leg's own cap — the signed `budgetUsd` on the spine's `job-start` less the fold
145
+ * a resumed leg inherited (`priorSpentUsd`), the same figure the run-start seam computes
146
+ * (`legCapUsd`, src/userrun.js). A spine that records no `budgetUsd` reserves nothing extra.
147
+ * @param {any[]} records
148
+ * @param {string} spinePath
149
+ * @param {number} nowMs
150
+ * @param {number} usd the leg's spend so far
151
+ * @returns {number}
152
+ */
153
+ function inFlightCapUsd(records, spinePath, nowMs, usd) {
154
+ if (records.some((r) => r && r.type === 'job-end')) return usd;
155
+ const start = records.find((r) => r && r.type === 'job-start') ?? null;
156
+ if (!start || !(typeof start.budgetUsd === 'number' && Number.isFinite(start.budgetUsd))) return usd;
157
+ let mtimeMs;
158
+ try { mtimeMs = statSync(spinePath).mtimeMs; } catch { return usd; }
159
+ if (nowMs - mtimeMs > DIED_MTIME_MS) return usd; // stale — died, counts its floor
160
+ const fold = typeof start.priorSpentUsd === 'number' && Number.isFinite(start.priorSpentUsd) ? start.priorSpentUsd : 0;
161
+ return Math.max(usd, Math.max(0, start.budgetUsd - fold));
162
+ }
163
+
164
+ /**
165
+ * Every listed run as one {@link Leg}. The ONE reader the month total, the all-time total
166
+ * and the per-provider figures share.
167
+ * @param {{ home?: string, now?: () => number, resumingSpine?: string|null }} [opts] `now` = the clock for the in-flight test; `resumingSpine` = the spine a resume continues: that leg reserves its spend only, never its unspent cap (the resume's own leg cap is that remainder)
168
+ * @returns {Leg[]}
169
+ */
170
+ export function readLegs(opts = {}) {
171
+ const nowMs = (opts.now ?? Date.now)();
172
+ const { rows } = readRunList({ home: opts.home });
173
+ const resuming = typeof opts.resumingSpine === 'string' ? resolve(opts.resumingSpine) : null;
174
+ /** @type {Leg[]} */
175
+ const legs = [];
176
+ for (const row of rows) {
177
+ const at = new Date(row.at);
178
+ const base = { at, provider: null, baseUrl: null, model: null, wallMs: null, wallComplete: false, usd: 0, complete: false, tokens: 0, vouchedRounds: 0, otherRounds: 0, reservedUsd: 0, unreadable: true };
179
+ if (Number.isNaN(at.getTime()) || !existsSync(row.spine)) { legs.push(base); continue; }
180
+ /** @type {any[]} */
181
+ let records;
182
+ try { records = parseJsonl(row.spine).records; } catch { legs.push(base); continue; }
183
+ const start = records.find((r) => r && r.type === 'job-start') ?? null;
184
+ const leg = legSpend(records);
185
+ const prov = spendProvenance(records);
186
+ const wall = legWall(records);
187
+ legs.push({
188
+ at,
189
+ model: typeof start?.model === 'string' ? start.model : null,
190
+ wallMs: wall.ms,
191
+ wallComplete: wall.complete,
192
+ provider: typeof start?.provider === 'string' ? start.provider : null,
193
+ baseUrl: typeof start?.baseUrl === 'string' ? start.baseUrl : null,
194
+ usd: leg.usd,
195
+ complete: leg.complete,
196
+ tokens: legTokens(records),
197
+ vouchedRounds: prov.vouched.rounds,
198
+ otherRounds: prov.guessed.rounds + prov.unpriced.rounds + prov.unknown.rounds,
199
+ reservedUsd: resuming !== null && resolve(row.spine) === resuming ? leg.usd : inFlightCapUsd(records, row.spine, nowMs, leg.usd),
200
+ unreadable: false,
201
+ });
202
+ }
203
+ return legs;
204
+ }
205
+
206
+ /**
207
+ * @param {Date} at
208
+ * @param {Date} now
209
+ * @returns {boolean} same LOCAL calendar month
210
+ */
211
+ const sameLocalMonth = (at, now) => at.getFullYear() === now.getFullYear() && at.getMonth() === now.getMonth();
212
+
213
+ /**
214
+ * @param {{ home?: string, now?: () => number, resumingSpine?: string|null }} [opts] `resumingSpine`: see {@link readLegs}
215
+ * @returns {{ usd: number, atLeast: boolean, runs: number, reservedUsd: number }} `usd` = real spend (the Money tab's figure); `reservedUsd` = the same with in-flight runs at their cap (the refusal check's)
216
+ */
217
+ export function monthSpend(opts = {}) {
218
+ const nowDate = new Date((opts.now ?? Date.now)());
219
+ let usd = 0;
220
+ let reservedUsd = 0;
221
+ let atLeast = false;
222
+ let runs = 0;
223
+ for (const leg of readLegs({ home: opts.home, now: opts.now, resumingSpine: opts.resumingSpine })) {
224
+ // an unreadable date could belong to this month — unknown, never dropped
225
+ if (Number.isNaN(leg.at.getTime())) { atLeast = true; continue; }
226
+ if (!sameLocalMonth(leg.at, nowDate)) continue;
227
+ runs += 1;
228
+ if (leg.unreadable) { atLeast = true; continue; }
229
+ usd += leg.usd;
230
+ reservedUsd += leg.reservedUsd;
231
+ if (!leg.complete) atLeast = true;
232
+ }
233
+ return { usd, atLeast, runs, reservedUsd };
234
+ }
235
+
236
+ /**
237
+ * The Money tab's figures: all-time and this-month spend, and per-provider breakdown
238
+ * (rows from `rowIdFor`; a run on no known row lands under its own provider name, never
239
+ * pooled into another row). Any leg with unknown spend makes the figure it belongs to
240
+ * an "at least".
241
+ * @param {{ home?: string, now?: () => number, rows?: readonly import('./providerrows.js').KeyRow[] }} [opts] `rows` = the Providers rows: when given, `tokensByRow` sums each row's tokens
242
+ * @returns {{ tokensByRow: Record<string, number>, total: {usd: number, atLeast: boolean, tokens: number}, month: {usd: number, atLeast: boolean, tokens: number},
243
+ * byProvider: Record<string, {label: string, monthUsd: number, monthAtLeast: boolean, totalUsd: number, totalAtLeast: boolean, tokens: number, vouchedRounds: number, otherRounds: number, monthWallMs: number|null, monthWallAtLeast: boolean, totalWallMs: number|null, totalWallAtLeast: boolean}> }}
244
+ */
245
+ export function spendSummary(opts = {}) {
246
+ const nowDate = new Date((opts.now ?? Date.now)());
247
+ const total = { usd: 0, atLeast: false, tokens: 0 };
248
+ const month = { usd: 0, atLeast: false, tokens: 0 };
249
+ /** @type {Record<string, {label: string, monthUsd: number, monthAtLeast: boolean, totalUsd: number, totalAtLeast: boolean, tokens: number, vouchedRounds: number, otherRounds: number, monthWallMs: number|null, monthWallAtLeast: boolean, totalWallMs: number|null, totalWallAtLeast: boolean}>} */
250
+ const byProvider = {};
251
+ /** runs per provider in each scope, and how many had a known wall — unknown is never 0
252
+ * @type {Record<string, {monthRuns: number, monthKnown: number, totalKnown: number}>} */
253
+ const wallSeen = {};
254
+ /** total tokens of the runs each Providers row served (matched by shape + endpoint + model) @type {Record<string, number>} */
255
+ const tokensByRow = {};
256
+ for (const leg of readLegs({ home: opts.home })) {
257
+ let served = opts.rows ? findRow(opts.rows, { provider: leg.provider, baseUrl: leg.baseUrl, model: leg.model }) : null;
258
+ // an older spine records a model but no provider: it counts toward the row whose Name EXACTLY
259
+ // equals that model. Two rows sharing the Name make it ambiguous, so it counts toward neither.
260
+ if (opts.rows && leg.provider === null && leg.model !== null) {
261
+ const named = opts.rows.filter((r) => r.name === leg.model);
262
+ served = named.length === 1 ? named[0] : null;
263
+ }
264
+ if (served) tokensByRow[served.envName] = (tokensByRow[served.envName] ?? 0) + leg.tokens;
265
+ const inMonth = Number.isNaN(leg.at.getTime()) ? true : sameLocalMonth(leg.at, nowDate);
266
+ const id = rowIdFor(leg.provider, leg.baseUrl);
267
+ // an older spine carries a model but no provider: say so honestly, one row per model —
268
+ // never a provider guessed from the model's name
269
+ const notRecorded = id === null && leg.provider === null;
270
+ const key = id ?? (notRecorded ? `not-recorded:${leg.model ?? ''}` : `other:${leg.provider}${leg.baseUrl ? ` @ ${leg.baseUrl}` : ''}`);
271
+ const label = id ? (MONEY_LABELS.find((r) => r.id === id)?.name ?? id)
272
+ : (notRecorded ? (leg.model ? `not recorded (model ${leg.model})` : 'not recorded') : key.slice('other:'.length));
273
+ const p = (byProvider[key] ??= { label, monthUsd: 0, monthAtLeast: false, totalUsd: 0, totalAtLeast: false, tokens: 0, vouchedRounds: 0, otherRounds: 0, monthWallMs: 0, monthWallAtLeast: false, totalWallMs: 0, totalWallAtLeast: false });
274
+ const w = (wallSeen[key] ??= { monthRuns: 0, monthKnown: 0, totalKnown: 0 });
275
+ const unknown = leg.unreadable || !leg.complete;
276
+ const wallKnown = !leg.unreadable && leg.wallMs !== null;
277
+ const wallAtLeast = !wallKnown || !leg.wallComplete;
278
+ total.usd += leg.usd; total.tokens += leg.tokens; if (unknown) total.atLeast = true;
279
+ p.totalUsd += leg.usd; p.tokens += leg.tokens; p.vouchedRounds += leg.vouchedRounds; p.otherRounds += leg.otherRounds; if (unknown) p.totalAtLeast = true;
280
+ if (wallKnown) { p.totalWallMs = /** @type {number} */ (p.totalWallMs) + /** @type {number} */ (leg.wallMs); w.totalKnown += 1; }
281
+ if (wallAtLeast) p.totalWallAtLeast = true;
282
+ if (inMonth) {
283
+ month.usd += leg.usd; month.tokens += leg.tokens; if (unknown) month.atLeast = true;
284
+ p.monthUsd += leg.usd; if (unknown) p.monthAtLeast = true;
285
+ w.monthRuns += 1;
286
+ if (wallKnown) { p.monthWallMs = /** @type {number} */ (p.monthWallMs) + /** @type {number} */ (leg.wallMs); w.monthKnown += 1; }
287
+ if (wallAtLeast) p.monthWallAtLeast = true;
288
+ }
289
+ }
290
+ // runs in scope but not one with a known wall = unknown (null), never a clean 0
291
+ for (const [key, p] of Object.entries(byProvider)) {
292
+ const w = wallSeen[key];
293
+ if (w.totalKnown === 0) p.totalWallMs = null;
294
+ if (w.monthRuns > 0 && w.monthKnown === 0) p.monthWallMs = null;
295
+ }
296
+ return { tokensByRow, total, month, byProvider };
297
+ }
298
+
299
+ /**
300
+ * @typedef {object} MonthlyRoom
301
+ * @property {boolean} ok
302
+ * @property {number|null} leftUsd what is left this month (null = no limit set)
303
+ * @property {number|null} limitUsd the person's monthly limit (null = none)
304
+ * @property {boolean} atLeast the month's spend is a floor, so `leftUsd` is a ceiling
305
+ */
306
+
307
+ /**
308
+ * The person's monthly limit off a parsed config.json: key absent (or null) = no limit (null);
309
+ * a number above 0 = the limit; ANY other value present is a broken setting and throws
310
+ * `ConfigError` — a limit the file names but this code cannot read never reads as "no limit".
311
+ * @param {Record<string, any>} config
312
+ * @param {string} [home]
313
+ * @returns {number|null}
314
+ */
315
+ export function monthlyLimitOf(config, home) {
316
+ const limit = config.monthlyLimitUsd;
317
+ if (limit === undefined || limit === null) return null;
318
+ if (typeof limit === 'number' && Number.isFinite(limit) && limit > 0) return limit;
319
+ const shown = typeof limit === 'number' ? String(limit) : JSON.stringify(limit);
320
+ throw new ConfigError(`${configPath(home)} has monthlyLimitUsd = ${shown}, which is not a number above 0 — fix it or remove the line`);
321
+ }
322
+
323
+ /**
324
+ * Does a run with this $ cap fit in what is left this month (running jobs counted at their full cap, except the spine a resume continues — `resumingSpine` — which counts its real spend)? No limit set (key absent or null) = ok, no check; a limit that is present but not a
325
+ * number above 0 throws `ConfigError`.
326
+ * Compared in whole cents. Also throws `ConfigError` when config.json is unreadable — a gate
327
+ * whose own instrument is broken refuses; it never silently runs with no limit.
328
+ * @param {{ capUsd: number, home?: string, now?: () => number, resumingSpine?: string|null }} args
329
+ * @returns {MonthlyRoom}
330
+ */
331
+ export function checkMonthlyRoom({ capUsd, home, now, resumingSpine }) {
332
+ const cfg = readConfig({ home });
333
+ if (cfg.problem) throw new ConfigError(cfg.problem);
334
+ const limit = monthlyLimitOf(cfg.config, home);
335
+ if (limit === null) {
336
+ return { ok: true, leftUsd: null, limitUsd: null, atLeast: false };
337
+ }
338
+ const spent = monthSpend({ home, now, resumingSpine });
339
+ const leftCents = Math.max(0, Math.floor((limit - spent.reservedUsd) * 100 + 1e-6));
340
+ const capCents = Math.ceil((Number.isFinite(capUsd) ? capUsd : 0) * 100 - 1e-6);
341
+ return { ok: capCents <= leftCents, leftUsd: leftCents / 100, limitUsd: limit, atLeast: spent.atLeast };
342
+ }
343
+
344
+ /**
345
+ * The ONE refusal text, everywhere (CLI, panel cap note, panel Sign refusal).
346
+ * @param {MonthlyRoom} room
347
+ * @returns {string|null} null when the run fits
348
+ */
349
+ export function monthlyRefusalText(room) {
350
+ if (room.ok || room.leftUsd === null) return null;
351
+ return `Max $${room.leftUsd.toFixed(2)} (monthly limit)`;
352
+ }