@sakk-dev/sakk 0.0.0-stage → 0.4.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/spend.mjs ADDED
@@ -0,0 +1,443 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ //
3
+ // Spend from local logs (specs/spend/spec.md). Reads what Claude Code already
4
+ // writes — ~/.claude/projects/**/*.jsonl — incrementally, and prices it.
5
+ // Spend never goes down: a log that shrinks or is rewritten keeps what was
6
+ // counted (LOG_REWRITTEN). The guard's cap check reads only today's changes.
7
+ //
8
+ // Index under SAKK_HOME/spend/:
9
+ // files.json { "<log path>": { offset, size, mtimeMs, head } }
10
+ // days/<YYYY-MM-DD>.json { "<message.id>|<requestId>": { model, usage, ts } }
11
+ // Writes are atomic (temp + rename); entries merge by maximum per field, so two
12
+ // processes updating at once can re-read lines but never lose or double them.
13
+
14
+ import { createHash } from 'node:crypto';
15
+ import {
16
+ closeSync,
17
+ lstatSync,
18
+ mkdirSync,
19
+ openSync,
20
+ readFileSync,
21
+ readSync,
22
+ readdirSync,
23
+ statSync,
24
+ } from 'node:fs';
25
+ import { homedir } from 'node:os';
26
+ import { join } from 'node:path';
27
+ import { writeFileAtomic } from './append.mjs';
28
+ import { costOf, priceOf } from './prices.mjs';
29
+
30
+ export const MAX_LINE = 1024 * 1024;
31
+ export const MAX_FILES = 50_000;
32
+ const HEAD = 4096;
33
+
34
+ export class SpendError extends Error {
35
+ constructor(code, message) {
36
+ super(`${code}: ${message}`);
37
+ this.code = code;
38
+ }
39
+ }
40
+
41
+ /** Where Claude Code writes its transcripts: fixed when the cap was set, not read from the agent. */
42
+ export const claudeLogDir = (env = process.env) =>
43
+ join(env.CLAUDE_CONFIG_DIR || join(env.HOME || homedir(), '.claude'), 'projects');
44
+
45
+ /** The calendar day of a time in a zone, as YYYY-MM-DD. */
46
+ export function dayOf(ms, timeZone) {
47
+ const f = new Intl.DateTimeFormat('en-CA', {
48
+ timeZone,
49
+ year: 'numeric',
50
+ month: '2-digit',
51
+ day: '2-digit',
52
+ });
53
+ return f.format(new Date(ms));
54
+ }
55
+ export const systemZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
56
+
57
+ /**
58
+ * Every *.jsonl under `dir`, without following links out of it; bounded.
59
+ * A directory that exists but cannot be listed is NOT "no logs": it is pushed
60
+ * onto `unlistable` as { path, code } so the caller can fail closed — read as
61
+ * absent, it would read as $0 spend (F-204, UAT S-REL-002).
62
+ */
63
+ export function listLogs(dir, unlistable = []) {
64
+ const out = [];
65
+ const walk = (d) => {
66
+ let entries;
67
+ try {
68
+ entries = readdirSync(d, { withFileTypes: true });
69
+ } catch (e) {
70
+ unlistable.push({ path: d, code: e.code ?? 'UNREADABLE' });
71
+ return;
72
+ }
73
+ for (const e of entries) {
74
+ if (out.length >= MAX_FILES)
75
+ throw new SpendError(
76
+ 'SPEND_TOO_MANY_LOGS',
77
+ `more than ${MAX_FILES} log files under ${dir}`,
78
+ );
79
+ const p = join(d, e.name);
80
+ if (e.isDirectory()) walk(p);
81
+ else if (e.isFile() && e.name.endsWith('.jsonl')) out.push(p);
82
+ }
83
+ };
84
+ walk(dir);
85
+ return out;
86
+ }
87
+
88
+ const finiteNonNeg = (x) => typeof x === 'number' && Number.isFinite(x) && x >= 0;
89
+ // Token counts must be whole numbers: costOf rejects a fractional count deep in
90
+ // pricing, so screen it here at the parse boundary instead (F-170).
91
+ const tokenCount = (x) => finiteNonNeg(x) && Number.isInteger(x);
92
+ const USAGE_FIELDS = [
93
+ 'input_tokens',
94
+ 'output_tokens',
95
+ 'cache_creation_input_tokens',
96
+ 'cache_read_input_tokens',
97
+ ];
98
+ const CACHE_SPLIT_FIELDS = ['ephemeral_5m_input_tokens', 'ephemeral_1h_input_tokens'];
99
+
100
+ /**
101
+ * One transcript line → null (not a priced call), { call } or { unreadable }.
102
+ * A call is keyed by (message.id, requestId); a line with neither is keyed by
103
+ * its own content hash and flagged UNKEYED.
104
+ */
105
+ export function parseLine(line) {
106
+ if (line.length > MAX_LINE) return { unreadable: 'line over 1 MiB' };
107
+ let o;
108
+ try {
109
+ o = JSON.parse(line);
110
+ } catch {
111
+ return { unreadable: 'not JSON' };
112
+ }
113
+ if (o?.type !== 'assistant' || o.message?.usage === undefined) return null;
114
+ const u = o.message.usage;
115
+ if (u === null || typeof u !== 'object') return { unreadable: 'usage is not an object' };
116
+ for (const k of USAGE_FIELDS)
117
+ if (u[k] !== undefined && u[k] !== null && !tokenCount(u[k]))
118
+ return { unreadable: `${k} is not a whole-token count` };
119
+ const cc = u.cache_creation;
120
+ if (cc !== undefined && cc !== null) {
121
+ if (typeof cc !== 'object' || Array.isArray(cc))
122
+ return { unreadable: 'cache_creation is not an object' };
123
+ // Read as 0, a malformed split field would drop those writes from the bill
124
+ // whenever the aggregate is missing (F-179).
125
+ for (const k of CACHE_SPLIT_FIELDS)
126
+ if (cc[k] !== undefined && cc[k] !== null && !tokenCount(cc[k]))
127
+ return { unreadable: `cache_creation.${k} is not a whole-token count` };
128
+ }
129
+ const ts = Date.parse(o.timestamp);
130
+ if (!Number.isFinite(ts)) return { unreadable: 'no timestamp' };
131
+ const id = typeof o.message.id === 'string' ? o.message.id : null;
132
+ const req = typeof o.requestId === 'string' ? o.requestId : null;
133
+ const key =
134
+ id || req
135
+ ? `${id ?? ''}|${req ?? ''}`
136
+ : `unkeyed:${createHash('sha256').update(line).digest('hex').slice(0, 16)}`;
137
+ const usage = {};
138
+ for (const k of USAGE_FIELDS) if (tokenCount(u[k])) usage[k] = u[k];
139
+ if (cc !== undefined && cc !== null)
140
+ usage.cache_creation = Object.fromEntries(CACHE_SPLIT_FIELDS.map((k) => [k, cc[k] ?? 0]));
141
+ for (const k of ['service_tier', 'speed', 'inference_geo'])
142
+ if (typeof u[k] === 'string') usage[k] = u[k];
143
+ return {
144
+ call: {
145
+ key,
146
+ model: typeof o.message.model === 'string' ? o.message.model : null,
147
+ usage,
148
+ ts,
149
+ unkeyed: !(id || req),
150
+ },
151
+ };
152
+ }
153
+
154
+ /** Merge two observations of one call: every count at its maximum (streaming writes grow). */
155
+ export function mergeCall(a, b) {
156
+ if (!a) return b;
157
+ const usage = { ...a.usage };
158
+ for (const k of USAGE_FIELDS) usage[k] = Math.max(a.usage[k] ?? 0, b.usage[k] ?? 0);
159
+ if (a.usage.cache_creation || b.usage.cache_creation)
160
+ usage.cache_creation = {
161
+ ephemeral_5m_input_tokens: Math.max(
162
+ a.usage.cache_creation?.ephemeral_5m_input_tokens ?? 0,
163
+ b.usage.cache_creation?.ephemeral_5m_input_tokens ?? 0,
164
+ ),
165
+ ephemeral_1h_input_tokens: Math.max(
166
+ a.usage.cache_creation?.ephemeral_1h_input_tokens ?? 0,
167
+ b.usage.cache_creation?.ephemeral_1h_input_tokens ?? 0,
168
+ ),
169
+ };
170
+ for (const k of ['service_tier', 'speed', 'inference_geo']) usage[k] = b.usage[k] ?? a.usage[k];
171
+ const merged = { ...a, model: a.model ?? b.model, usage, ts: Math.min(a.ts, b.ts) };
172
+ if (a.future || b.future) merged.future = true; // once flagged future-dated, stays flagged (F-205)
173
+ if (a.backdated || b.backdated) merged.backdated = true; // once flagged backdated, stays flagged (F-210)
174
+ return merged;
175
+ }
176
+
177
+ // A re-read of an already-counted call: the stored row covers the line when
178
+ // every count is at least the line's, so merging could not grow it. A line
179
+ // that would grow the row — a new key included — is new money, and new money
180
+ // claiming an old day must not ride that day's already-checked bucket (F-210).
181
+ const covers = (prev, c) => {
182
+ if (!prev) return false;
183
+ for (const k of USAGE_FIELDS) if ((c.usage[k] ?? 0) > (prev.usage?.[k] ?? 0)) return false;
184
+ for (const k of CACHE_SPLIT_FIELDS)
185
+ if ((c.usage.cache_creation?.[k] ?? 0) > (prev.usage?.cache_creation?.[k] ?? 0)) return false;
186
+ return true;
187
+ };
188
+
189
+ const readJson = (p, empty) => {
190
+ try {
191
+ return JSON.parse(readFileSync(p, 'utf8'));
192
+ } catch (e) {
193
+ if (e.code === 'ENOENT') return empty;
194
+ throw new SpendError('SPEND_INDEX_UNREADABLE', `${p}: ${e.message}`);
195
+ }
196
+ };
197
+ // the spend index is a derived cache, but use the one hardened writer (fsync +
198
+ // atomic rename, no write-through a planted symlink) the ledger uses
199
+ const writeAtomic = (p, value) => writeFileAtomic(p, JSON.stringify(value));
200
+ const headOf = (file) => {
201
+ const fd = openSync(file, 'r');
202
+ try {
203
+ const b = Buffer.alloc(HEAD);
204
+ const n = readSync(fd, b, 0, HEAD, 0);
205
+ return createHash('sha256').update(b.subarray(0, n)).digest('hex').slice(0, 16);
206
+ } finally {
207
+ closeSync(fd);
208
+ }
209
+ };
210
+
211
+ /**
212
+ * Bring the index up to date. `since` (ms) skips files not modified since —
213
+ * the hook passes the start of today. `now` is the observing clock: a call
214
+ * whose own timestamp lies beyond it is booked to the day of observation and
215
+ * flagged `future` — an agent-authored timestamp must not move spend out of
216
+ * today's cap window (F-205, UAT S-REL-005; spec row "counted today, flagged").
217
+ * The same rule runs backward once a baseline is sealed: the first complete
218
+ * scan books everything already on disk to its claimed day (history that
219
+ * predates sakk's watch) and seals `baseline.json`; after that, a line first
220
+ * seen claiming an earlier day books the observation day, flagged `backdated`,
221
+ * unless the claimed day's bucket already covers it (a pure re-read) (F-210).
222
+ * Returns { rewritten: [paths], unreadable: n, unlistable: [{ path, code }] }.
223
+ */
224
+ export function updateIndex({ home, logDir, zone, since = 0, now = Date.now() }) {
225
+ const dir = join(home, 'spend');
226
+ mkdirSync(join(dir, 'days'), { recursive: true });
227
+ const filesPath = join(dir, 'files.json');
228
+ const files = readJson(filesPath, {});
229
+ const baselinePath = join(dir, 'baseline.json');
230
+ const sealed = readJson(baselinePath, null) !== null;
231
+ if (!sealed) since = 0; // the sealing scan must be whole, never today-only
232
+ const today = dayOf(now, zone);
233
+ const days = new Map();
234
+ const day = (d) => {
235
+ if (!days.has(d)) days.set(d, readJson(join(dir, 'days', `${d}.json`), {}));
236
+ return days.get(d);
237
+ };
238
+ const rewritten = [];
239
+ let unreadable = 0;
240
+ const unlistable = [];
241
+ // The root itself is read like any subdirectory: only a root that is truly
242
+ // absent (ENOENT with no link entry in the way) is an honest "$0, no logs".
243
+ // An unreachable parent, a dangling link, a loop — anything else — is a
244
+ // failed reading, and read as absence it would read as $0 spend (F-208).
245
+ let rootPresent = false;
246
+ try {
247
+ statSync(logDir);
248
+ rootPresent = true;
249
+ } catch (e) {
250
+ if (e.code === 'ENOENT') {
251
+ let entryExists = false;
252
+ try {
253
+ lstatSync(logDir);
254
+ entryExists = true;
255
+ } catch {
256
+ /* nothing there at all: absence is not an error */
257
+ }
258
+ if (entryExists) unlistable.push({ path: logDir, code: 'ENOENT' });
259
+ } else {
260
+ unlistable.push({ path: logDir, code: e.code ?? 'UNREADABLE' });
261
+ }
262
+ }
263
+ for (const file of rootPresent ? listLogs(logDir, unlistable) : []) {
264
+ let st;
265
+ try {
266
+ st = statSync(file);
267
+ } catch (e) {
268
+ // listed but not statable: an absent reading, not zero spend (F-204)
269
+ unlistable.push({ path: file, code: e.code ?? 'UNREADABLE' });
270
+ continue;
271
+ }
272
+ const prev = files[file];
273
+ // mtime is agent-touchable (utimes), so `since` is an optimization, never a
274
+ // trust boundary: only a file the index already knows at an unchanged size
275
+ // may be skipped — a planted file faking an old mtime is still read (F-211)
276
+ if (st.mtimeMs < since && prev && prev.size === st.size) continue;
277
+ if (prev && prev.size === st.size && prev.mtimeMs === st.mtimeMs) continue;
278
+ let offset = prev?.offset ?? 0;
279
+ let head;
280
+ let text;
281
+ let wasRewritten = false;
282
+ // one try covers every read of this file: a file that lists and stats but
283
+ // cannot be opened (mode 000, a lock, a vanished link) is a named, absent
284
+ // reading like an unstatable one, never a crash of the whole walk (F-192)
285
+ try {
286
+ head = headOf(file);
287
+ if (prev && (st.size < prev.offset || head !== prev.head)) {
288
+ wasRewritten = true;
289
+ offset = 0;
290
+ }
291
+ const fd = openSync(file, 'r');
292
+ try {
293
+ const len = st.size - offset;
294
+ const b = Buffer.alloc(Math.max(0, len));
295
+ readSync(fd, b, 0, len, offset);
296
+ text = b.toString('utf8');
297
+ } finally {
298
+ closeSync(fd);
299
+ }
300
+ } catch (e) {
301
+ unlistable.push({ path: file, code: e.code ?? 'UNREADABLE' });
302
+ continue;
303
+ }
304
+ // counted spend stays; the file is read again from the start
305
+ if (wasRewritten) rewritten.push(file);
306
+ const lastNl = text.lastIndexOf('\n');
307
+ const complete = lastNl < 0 ? '' : text.slice(0, lastNl); // a partial last line waits for its end
308
+ for (const line of complete.split('\n')) {
309
+ if (line.trim() === '') continue;
310
+ const r = parseLine(line);
311
+ if (r === null) continue;
312
+ if (r.unreadable) {
313
+ unreadable += 1;
314
+ const d = day(dayOf(st.mtimeMs, zone));
315
+ d['~unreadable'] = (d['~unreadable'] ?? 0) + 1;
316
+ continue;
317
+ }
318
+ // a timestamp beyond the observing clock books to the observation day,
319
+ // flagged — never ahead (F-205) — and once the baseline is sealed,
320
+ // never behind either: new money claiming an earlier day books the
321
+ // observation day, flagged (F-210); YYYY-MM-DD compares as day order
322
+ if (r.call.ts > now) r.call.future = true;
323
+ let bookDay = dayOf(r.call.future ? now : r.call.ts, zone);
324
+ if (
325
+ sealed &&
326
+ !r.call.future &&
327
+ bookDay < today &&
328
+ !covers(day(bookDay)[r.call.key], r.call)
329
+ ) {
330
+ r.call.backdated = true;
331
+ bookDay = today;
332
+ }
333
+ const d = day(bookDay);
334
+ d[r.call.key] = mergeCall(d[r.call.key], r.call);
335
+ }
336
+ files[file] = {
337
+ offset: offset + Buffer.byteLength(complete) + (lastNl < 0 ? 0 : 1),
338
+ size: st.size,
339
+ mtimeMs: st.mtimeMs,
340
+ head,
341
+ };
342
+ }
343
+ for (const [d, calls] of days) writeAtomic(join(dir, 'days', `${d}.json`), calls);
344
+ writeAtomic(filesPath, files);
345
+ // seal only a whole scan: a directory that could not be listed holds history
346
+ // this scan never saw, and freezing now would strip that history's amnesty
347
+ if (!sealed && unlistable.length === 0) writeAtomic(baselinePath, { sealedAt: now, day: today });
348
+ return { rewritten, unreadable, unlistable };
349
+ }
350
+
351
+ /** One day's spend: total of priced calls, and what could not be priced or read. */
352
+ export function daySpend({ home, day, table }) {
353
+ const calls = readJson(join(home, 'spend', 'days', `${day}.json`), {});
354
+ let usd = 0;
355
+ const unpriced = new Map();
356
+ const byModel = {};
357
+ let n = 0;
358
+ let future = 0;
359
+ let backdated = 0;
360
+ let unreadable = calls['~unreadable'] ?? 0;
361
+ for (const [key, c] of Object.entries(calls)) {
362
+ if (key === '~unreadable') continue;
363
+ n += 1;
364
+ if (c.future) future += 1;
365
+ if (c.backdated) backdated += 1;
366
+ const entry = priceOf(table, c.model);
367
+ if (!entry) {
368
+ unpriced.set(c.model ?? '(no model)', (unpriced.get(c.model ?? '(no model)') ?? 0) + 1);
369
+ continue;
370
+ }
371
+ let cost;
372
+ try {
373
+ cost = costOf(entry, c.usage);
374
+ } catch {
375
+ // a stored row that cannot be priced (e.g. a malformed count that reached
376
+ // the index through some other writer) is one unreadable line, not a
377
+ // crashed command — the cap then fails closed via SPEND_UNREADABLE (F-170)
378
+ n -= 1;
379
+ unreadable += 1;
380
+ continue;
381
+ }
382
+ usd += cost;
383
+ byModel[c.model] = (byModel[c.model] ?? 0) + cost;
384
+ }
385
+ return {
386
+ day,
387
+ usd,
388
+ calls: n,
389
+ future,
390
+ backdated,
391
+ unpriced: Object.fromEntries(unpriced),
392
+ unreadable,
393
+ byModel,
394
+ };
395
+ }
396
+
397
+ /**
398
+ * The guard's step 2 (spec "Cap"): null when there is no cap or spend is under
399
+ * it; otherwise { deny: true, reason }. Fails closed: unpriced or unreadable
400
+ * spend today, a log folder that cannot be listed, or an index that cannot
401
+ * be read, is a deny.
402
+ */
403
+ export function capCheck({ home, config, table, now = Date.now(), env = process.env }) {
404
+ if (config.capUsd === undefined || config.capUsd === null) return null;
405
+ const cap = config.capUsd;
406
+ if (!finiteNonNeg(cap))
407
+ return { deny: true, reason: 'SPEND_CAP_INVALID: the cap in config.json is not a number' };
408
+ const zone = config.timeZone || systemZone();
409
+ const today = dayOf(now, zone);
410
+ const logDir = config.claudeLogDir || claudeLogDir(env);
411
+ // files untouched since the start of today hold no new spend for today
412
+ const startOfToday = now - 36 * 3600 * 1000; // generous: any zone's midnight lies within 36 h
413
+ const idx = updateIndex({ home, logDir, zone, since: startOfToday, now });
414
+ if (idx.unlistable.length) {
415
+ const first = idx.unlistable[0];
416
+ return {
417
+ deny: true,
418
+ reason: `SPEND_LOGS_UNLISTABLE: ${idx.unlistable.length} path(s) under ${logDir} could not be read (${first.path}: ${first.code}), so today's spend cannot be counted — the cap fails closed until a person fixes the permissions`,
419
+ };
420
+ }
421
+ const s = daySpend({ home, day: today, table });
422
+ const models = Object.keys(s.unpriced);
423
+ if (models.length)
424
+ return {
425
+ deny: true,
426
+ reason: `PRICE_UNKNOWN: ${models.join(', ')} has no price, so today's spend cannot be shown to be under the cap — add the model to ${home}/prices.json (copy prices/snapshot.json there first)`,
427
+ };
428
+ if (s.unreadable)
429
+ return {
430
+ deny: true,
431
+ reason: `SPEND_UNREADABLE: ${s.unreadable} log line(s) today could not be read, so the cap cannot be checked`,
432
+ };
433
+ if (s.usd >= cap) {
434
+ const back = s.backdated
435
+ ? ` — includes ${s.backdated} line(s) first seen today claiming an earlier day, counted today (F-210)`
436
+ : '';
437
+ return {
438
+ deny: true,
439
+ reason: `SPEND_CAP_REACHED: $${s.usd.toFixed(2)} ≥ $${cap.toFixed(2)} today (${today}, ${zone}, from local logs)${back} — a person raises it with sakk cap set <usd>; it resets at midnight`,
440
+ };
441
+ }
442
+ return null;
443
+ }