bantamkit-mcp 0.30.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.
package/dist/shiftwork.js CHANGED
@@ -31,6 +31,20 @@
31
31
  * `pyjson.ts` already carries. The three entry points therefore return `PyValue`, and the
32
32
  * MCP layer converts with `pyjson.toJs` at the last moment.
33
33
  *
34
+ * THE AS-2 REFUSAL IS A SENTENCE, AND THE SENTENCE IS THE PORT. `clockOut` refuses an
35
+ * accounting entry naming a model the unit's role is not allowed (`job.roles`, J46-7). The
36
+ * rule is four lines of comparison and none of the risk is in them: it is in the two
37
+ * refusal strings, which exist once per runtime and are copied, so the differential half of
38
+ * the harness compares Node to Python and cannot see a change made to both. They are
39
+ * therefore pinned as per-side literals in each runtime's own tests. The check runs at
40
+ * validation time — after the cursor check, before the first mutation, and so before the
41
+ * log-then-commit pair below — because a refusal taken after the append would leave an
42
+ * orphan accounting line claiming a model that was rejected. J47-5: a role the map DOES
43
+ * name whose value is not a list of model identifiers is refused there too — an unreadable
44
+ * declaration allows no model, and the refusal is the same structured one, taken in the
45
+ * same place. That makes three refusal strings, not two, and the third is likewise a
46
+ * per-side literal.
47
+ *
34
48
  * WHAT IS NOT PORTED, DELIBERATELY. The `depends_on` field is ignored on cursor advance
35
49
  * (v1-linear, the Python module's own ruling), there is no lock (the MCP topology has one
36
50
  * orchestrator by construction), and log-then-commit ordering is preserved exactly: the
@@ -40,7 +54,7 @@
40
54
  import { loadSchema } from './assets.js';
41
55
  import { schemaError } from './contract.js';
42
56
  import { pyAppendText, pyJoin, PyOSError, pyReplace, pySuffix, pyUnlink, pyWithSuffix, pyWriteText, pyReadText, } from './memory/pyfs.js';
43
- import { dumpJson, fromJs, parseJson, PyJSONDecodeError } from './pyjson.js';
57
+ import { dumpJson, fromJs, parseJson, PyJSONDecodeError, reprValue } from './pyjson.js';
44
58
  export const SCHEMA_NAME = 'shiftwork-checkpoint';
45
59
  /** The driver's SUCCESS test. */
46
60
  export const TERMINAL_UNIT_STATUS = new Set(['done', 'dropped']);
@@ -201,13 +215,108 @@ export function clockIn(checkpoint) {
201
215
  ]);
202
216
  }
203
217
  // ---------------------------------------------------------------------------- clock_out
218
+ /**
219
+ * `f"{value}"` — `str()`, which is `repr()` for everything except a string itself.
220
+ *
221
+ * `accounting` carries no schema, so `model` is whatever the orchestrator sent. Python
222
+ * interpolates it with `str()` and prints `5`, `5.0`, `True`, `None`; a Node port that
223
+ * reached for the tagged `.v` would print `5` for both floats and ints and `true` for a
224
+ * bool. `reprValue` already spells all six the way CPython does.
225
+ */
226
+ function pyFormat(value) {
227
+ return value.t === 'str' ? value.v : reprValue(value);
228
+ }
229
+ /**
230
+ * AS-2: a role named in `job.roles` may only report a model on its list.
231
+ *
232
+ * Returns the refusal sentence, or `null` when the clock-out may proceed. A role the map
233
+ * does not name — and a checkpoint carrying no map at all — is unconstrained: that is the
234
+ * schema's shape (J46-7) and it is what lets a checkpoint written before this feature clock
235
+ * out unchanged, model and all.
236
+ *
237
+ * A role the map DOES name must say which model it ran: a missing `model` is refused with
238
+ * the same force as a wrong one, because a rule you can escape by omitting a field is
239
+ * enforced only against the honest. `accounting: null`, `{}` and `{"model": null}` are one
240
+ * case in Python (`(accounting or {}).get("model") is None`) and three values here.
241
+ *
242
+ * Models compare exactly — no normalisation, no prefix match, no strip-the-suffix rule. The
243
+ * map's whole value is that it is the literal list of the spellings a session logs, so a
244
+ * spelling this job has never produced is a finding to rule on, not a string to massage.
245
+ *
246
+ * The two sentences are BYTE-IDENTICAL to `runtime-py`'s and are copied, never paraphrased.
247
+ *
248
+ * THE TEST IS KEY PRESENCE, NOT TRUTHINESS, AND THAT IS A RULING (J46-10). Both runtimes
249
+ * spelled this `if not allowed`, which reads `roles: {implementer: []}` as unconstrained and
250
+ * so makes an empty list a silent opt-out of the rule the checkpoint just declared. Today
251
+ * `minItems: 1` refuses such a document during the read and nothing reaches here — but the
252
+ * schema is a SHARED asset, the class job46 has measured three times as invisible to the
253
+ * differential half of the harness, and a check whose safety rests on another layer's
254
+ * keyword fails open the day that keyword moves. The DECLARATION is the key: a role the map
255
+ * names is held to its list, and a list of nothing allows nothing. `names` is then the empty
256
+ * string and the sentence says so. Reachable, and therefore measured: `BANTAMKIT_ASSETS` is
257
+ * honoured by both runtimes, so the tests and the conformance corpus drive this branch
258
+ * through a pack whose schema has lost `minItems`.
259
+ *
260
+ * AND A DECLARATION THIS CODE CANNOT READ IS NOT A LICENCE (J47-4, ported here as J47-5).
261
+ * The same ruling, one step further out: a role the map names is constrained by what it
262
+ * NAMES, and a value that is not a list of model identifiers names nothing, so it allows
263
+ * nothing. This port failed open five ways and mangled the declaration two more. MEASURED
264
+ * end to end under a pack whose `job.roles.additionalProperties` is `true`, with
265
+ * `model: 'haiku'`: a string, a dict, a number, a null and a bool all came back
266
+ * `{"result":"ok"}` with the status set, the cursor advanced and an accounting line
267
+ * written — the clock-out COMPLETED — while `[5]` and `['ok', null]` refused with the
268
+ * unreadable value rendered into the sentence as `5` and `ok, None`. A list holding a
269
+ * non-string is the shape a bare kind test cannot catch, which is why the property is
270
+ * LIST OF STRINGS and not merely list; the reference raised `TypeError: sequence item 0`
271
+ * on exactly that input. Fail CLOSED, through the same structured `_error` return, saying
272
+ * only what is true — the declaration cannot be read, therefore no model is allowed —
273
+ * naming no type (a Python type name would not port: `int` against `number`) and
274
+ * rendering no part of the value. ONE sentence for both accounting shapes: which model
275
+ * was reported cannot matter when the declaration that would judge it is unreadable.
276
+ * `[]` is untouched by this — an empty list IS a list of model identifiers and keeps the
277
+ * sentence J46-10 pinned.
278
+ */
279
+ function modelRefusal(document, unitId, unit, accounting) {
280
+ const role = text(field(unit, 'role'));
281
+ const roles = subDict(document, 'job').v.get('roles');
282
+ const allowed = roles === undefined || roles.t !== 'dict' ? undefined : roles.v.get(role);
283
+ // `role not in roles`. The `t !== 'list'` arm is a TYPE guard and not a policy: the schema
284
+ // pins the value to an array, so the only way past it is a document no read would accept.
285
+ // AMENDED 2026-09-11 (J47-5): the two lines above stand as written and are now FALSE as a
286
+ // description of this code. A document no read would accept is exactly what a shared asset
287
+ // drifting produces, the arm answered `unconstrained` for every such document and the
288
+ // clock-out completed, and `t === 'list'` was never the property anyway — `['ok', null]`
289
+ // passed the kind test and mangled `None` into the sentence. The arm below is a POLICY.
290
+ if (allowed === undefined)
291
+ return null;
292
+ if (allowed.t !== 'list' || !allowed.v.every((model) => model.t === 'str')) {
293
+ return `unit ${unitId} in role ${role} cannot clock out: job.roles.${role} is not a list of model identifiers, so it allows no model`;
294
+ }
295
+ const names = allowed.v.map((model) => pyFormat(model)).join(', ');
296
+ const offered = asPatch(accounting).get('model');
297
+ if (offered === undefined || offered.t === 'null') {
298
+ return `unit ${unitId} in role ${role} reported no model, but job.roles.${role} allows only: ${names}`;
299
+ }
300
+ // `offered not in allowed`. The schema pins every entry to a non-empty STRING, so the
301
+ // only equality Python's `in` can find here is string-to-string: a `{"model": 5}` is
302
+ // unequal to every entry and is refused, with `5` in the sentence.
303
+ if (!allowed.v.some((model) => model.t === 'str' && offered.t === 'str' && model.v === offered.v)) {
304
+ return `unit ${unitId} in role ${role} reported model ${pyFormat(offered)}, which job.roles.${role} does not allow: ${names}`;
305
+ }
306
+ return null;
307
+ }
204
308
  /**
205
309
  * Apply the cursor unit's result, validate the WHOLE mutated document, write atomically.
206
310
  *
207
311
  * `unitId` must name the cursor unit — the contract is execute-the-cursor (driver parity),
208
- * never pick-a-unit. Mutations: set the unit's status, advance `plan.cursor` to the first
209
- * non-terminal unit in PLAN order (`depends_on` is ignored), shallow-merge `handoffPatch`
210
- * into `handoff`, push `historyEntry` onto the 5-entry ring.
312
+ * never pick-a-unit. When `job.roles` names the unit's role, `accounting.model` must be one
313
+ * of that role's models, spelled exactly; a wrong or missing model is refused here, before
314
+ * any mutation and before the accounting line. Extended 2026-09-11 (job47): so is a
315
+ * `job.roles` value for that role that is not a list of model identifiers — an unreadable
316
+ * declaration allows no model, and it is refused in the same place, by the same structured
317
+ * return, writing nothing. Mutations: set the unit's status, advance
318
+ * `plan.cursor` to the first non-terminal unit in PLAN order (`depends_on` is ignored),
319
+ * shallow-merge `handoffPatch` into `handoff`, push `historyEntry` onto the 5-entry ring.
211
320
  *
212
321
  * VALIDATE BEFORE WRITING, AND WRITE NOTHING ON REFUSAL. The mutated document goes through
213
322
  * the full schema before the first byte leaves; a failure returns `refused to write: …` and
@@ -232,6 +341,9 @@ export function clockOut(checkpoint, unitId, status, handoffPatch, historyEntry,
232
341
  const cursor = text(field(plan, 'cursor'));
233
342
  if (unitId !== cursor)
234
343
  return errorResult(`unit ${unitId} is not the cursor unit ${cursor}`);
344
+ const wrongModel = modelRefusal(doc, unitId, unit, accounting);
345
+ if (wrongModel !== null)
346
+ return errorResult(wrongModel);
235
347
  unit.v.set('status', str(status));
236
348
  const remaining = subList(plan, 'units').v.filter((u) => !TERMINAL_UNIT_STATUS.has(text(u.v.get('status'))));
237
349
  plan.v.set('cursor', remaining.length > 0 ? field(remaining[0], 'id') : str(unitId));
@@ -0,0 +1,91 @@
1
+ import { BantamError } from './errors.js';
2
+ import { type PriceResult, type TokenClass } from './pricing.js';
3
+ /**
4
+ * Every omission subject, in the order they are reported. Fixed here rather than derived from
5
+ * what a run happened to see, so two corpora produce comparable documents and a subject that
6
+ * never fires is visibly absent rather than merely unmentioned.
7
+ */
8
+ export declare const OMIT_UNDECODABLE = "undecodable-file";
9
+ export declare const OMIT_UNPARSED = "unparsed-line";
10
+ export declare const OMIT_NOT_OBJECT = "not-an-object";
11
+ export declare const OMIT_NO_SESSION = "no-session-id";
12
+ export declare const OMIT_NOT_ASSISTANT = "not-an-assistant-record";
13
+ export declare const OMIT_NO_USAGE = "no-usage";
14
+ export declare const OMIT_MALFORMED_USAGE = "malformed-usage";
15
+ export declare const OMIT_NO_REQUEST_ID = "no-request-id";
16
+ export declare const OMIT_DUPLICATE_REQUEST = "duplicate-request";
17
+ export declare const OMISSION_ORDER: readonly ["undecodable-file", "unparsed-line", "not-an-object", "no-session-id", "not-an-assistant-record", "no-usage", "malformed-usage", "no-request-id", "duplicate-request"];
18
+ /**
19
+ * The transcript extension the host writes. Anything else under `root` is not read and is not
20
+ * counted -- it is not a transcript, so it is not an omission either.
21
+ */
22
+ export declare const TRANSCRIPT_SUFFIX = ".jsonl";
23
+ /**
24
+ * The scan could not be run at all: the message names what was wrong with the request.
25
+ *
26
+ * Reserved for a failure of the SCAN, exactly as `SkillAuditError` is. A file that will not
27
+ * decode, a line that will not parse and a record with no usage are omissions and are counted.
28
+ */
29
+ export declare class TokenLedgerError extends BantamError {
30
+ }
31
+ export interface Omission {
32
+ subject: string;
33
+ count: number;
34
+ what: string;
35
+ }
36
+ export interface SessionRow {
37
+ session: string;
38
+ cwd: string;
39
+ first: string;
40
+ last: string;
41
+ requests: number;
42
+ sidechain_requests: number;
43
+ input_tokens: number;
44
+ cache_creation_input_tokens: number;
45
+ cache_read_input_tokens: number;
46
+ output_tokens: number;
47
+ }
48
+ export interface Ledger {
49
+ root: string;
50
+ transcripts: number;
51
+ lines: number;
52
+ requests: number;
53
+ totals: Record<TokenClass, number>;
54
+ sessions: SessionRow[];
55
+ omissions: Omission[];
56
+ cost?: PriceResult;
57
+ }
58
+ /**
59
+ * Every `*.jsonl` under `base`, in one deterministic order.
60
+ *
61
+ * Sorted by entry NAME in code-point order at every level, directories and files in the same
62
+ * listing, so the sequence is a property of the tree and not of the filesystem. It has to be:
63
+ * the walk order decides which copy of a duplicated `requestId` is the one that counts.
64
+ */
65
+ export declare function walk(base: string): Array<[string, string]>;
66
+ /**
67
+ * Walk `root` and answer the ledger.
68
+ *
69
+ * An empty `root` is refused rather than resolved -- `statSync('')` throws here and
70
+ * `Path('')` is `Path('.')` on the reference, so the two would answer a refusal and a
71
+ * CWD-relative ledger for one input. `model` omitted is not `model` empty: an absent model
72
+ * asks for no cost and gets no `cost` key, and an empty string is refused rather than looked
73
+ * up and reported as "no rate recorded for model ''".
74
+ */
75
+ export declare function read(root: string, options?: {
76
+ model?: string | null;
77
+ prices?: string | null;
78
+ }): Ledger;
79
+ /**
80
+ * The document, in the reference's key order.
81
+ *
82
+ * Built explicitly rather than by serialising `Ledger`, because the interface's declaration
83
+ * order is a type and not a runtime fact, and `sessions[]` interleaves the four token classes
84
+ * after `sidechain_requests` -- an order a structural copy would not reproduce.
85
+ */
86
+ export declare function asDict(ledger: Ledger): Record<string, unknown>;
87
+ /**
88
+ * Two-space indent and no ASCII escaping, so this and `Ledger.as_json()` on the reference are
89
+ * the same bytes and a conformance case can compare them.
90
+ */
91
+ export declare function asJson(ledger: Ledger): string;
@@ -0,0 +1,372 @@
1
+ /**
2
+ * What a session ACTUALLY cost, read off the host's own transcripts -- on the surface.
3
+ *
4
+ * The port of `runtime-py/src/bantamkit/tokenledger.py`, which carries the full argument for
5
+ * why this exists, what it deliberately leaves in `tools/ledger/token-ledger.mjs`, and the
6
+ * correction it makes over that script's per-FILE `requestId` dedupe. What follows is what is
7
+ * specific to THIS side.
8
+ *
9
+ * THREE PLACES THIS RUNTIME WOULD DIVERGE ON ITS OWN, AND WHAT IS DONE ABOUT EACH
10
+ * ------------------------------------------------------------------------------
11
+ * 1. **`readFileSync(p, 'utf8')` does not fail on bytes that are not UTF-8.** It substitutes
12
+ * U+FFFD and hands back a string, while `Path.read_text(encoding='utf-8')` raises. Left
13
+ * alone, a transcript holding one stray `0xFF` is an `undecodable-file` omission on the
14
+ * reference and a bag of replacement characters here -- silently parsed, silently counted,
15
+ * silently different. So the file is read as BYTES and decoded through a `TextDecoder` in
16
+ * `fatal` mode, which is the only decoder in this runtime that refuses the way CPython's
17
+ * does. The fixture corpus carries such a file for exactly this reason.
18
+ *
19
+ * 2. **`.sort()` is UTF-16 code-unit order.** Sessions are tie-broken by id and directory
20
+ * entries are ordered by name, and both must be CODE-POINT order or a non-BMP name sorts
21
+ * on the far side of every character above U+FFFF. `cmpCodepoint` is the same comparator
22
+ * `pricing.ts` uses, for the same reason and out of the same module.
23
+ *
24
+ * 3. **A JSON number is a `number`.** `json.loads('7.0')` is a float on the reference and
25
+ * `7` is indistinguishable from `7.0` here, and `true` is an `int` there and a boolean
26
+ * here. `isCount` holds both holes shut, and it is `pricing.ts`'s rule restated -- this
27
+ * module counts tokens that module later prices, so a count either would accept and the
28
+ * other would not is a divergence waiting for a rate to be recorded.
29
+ *
30
+ * The 2**53 ceiling is checked as a SUBTRACTION before each addition (`count > MAX_SAFE_INT -
31
+ * total`), never as a comparison afterwards: a `number` past 2**53 is already rounded, so the
32
+ * check would be reading a value this side had got wrong.
33
+ */
34
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
35
+ import { join } from 'node:path';
36
+ import { BantamError } from './errors.js';
37
+ import { MAX_SAFE_INT, TOKEN_CLASSES, loadPriceTable, priceTokens, } from './pricing.js';
38
+ import { cmpCodepoint } from './pysem.js';
39
+ /**
40
+ * Every omission subject, in the order they are reported. Fixed here rather than derived from
41
+ * what a run happened to see, so two corpora produce comparable documents and a subject that
42
+ * never fires is visibly absent rather than merely unmentioned.
43
+ */
44
+ export const OMIT_UNDECODABLE = 'undecodable-file';
45
+ export const OMIT_UNPARSED = 'unparsed-line';
46
+ export const OMIT_NOT_OBJECT = 'not-an-object';
47
+ export const OMIT_NO_SESSION = 'no-session-id';
48
+ export const OMIT_NOT_ASSISTANT = 'not-an-assistant-record';
49
+ export const OMIT_NO_USAGE = 'no-usage';
50
+ export const OMIT_MALFORMED_USAGE = 'malformed-usage';
51
+ export const OMIT_NO_REQUEST_ID = 'no-request-id';
52
+ export const OMIT_DUPLICATE_REQUEST = 'duplicate-request';
53
+ export const OMISSION_ORDER = [
54
+ OMIT_UNDECODABLE,
55
+ OMIT_UNPARSED,
56
+ OMIT_NOT_OBJECT,
57
+ OMIT_NO_SESSION,
58
+ OMIT_NOT_ASSISTANT,
59
+ OMIT_NO_USAGE,
60
+ OMIT_MALFORMED_USAGE,
61
+ OMIT_NO_REQUEST_ID,
62
+ OMIT_DUPLICATE_REQUEST,
63
+ ];
64
+ /**
65
+ * The transcript extension the host writes. Anything else under `root` is not read and is not
66
+ * counted -- it is not a transcript, so it is not an omission either.
67
+ */
68
+ export const TRANSCRIPT_SUFFIX = '.jsonl';
69
+ /**
70
+ * The scan could not be run at all: the message names what was wrong with the request.
71
+ *
72
+ * Reserved for a failure of the SCAN, exactly as `SkillAuditError` is. A file that will not
73
+ * decode, a line that will not parse and a record with no usage are omissions and are counted.
74
+ */
75
+ export class TokenLedgerError extends BantamError {
76
+ }
77
+ /**
78
+ * A non-negative integer below `2**53` both runtimes hold exactly, however JSON spelled it.
79
+ * `pricing.ts`'s `isCount`, restated because it is this module's rule too.
80
+ */
81
+ function isCount(value) {
82
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= MAX_SAFE_INT;
83
+ }
84
+ function isPlainObject(value) {
85
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
86
+ }
87
+ /**
88
+ * One transcript line, decoded the way `json.loads(..., parse_constant=raise)` decodes it.
89
+ *
90
+ * `JSON.parse` is already the strict side -- it rejects the bare `NaN` / `Infinity` /
91
+ * `-Infinity` tokens CPython accepts -- so this is a plain parse, and the reference is what
92
+ * had to move. It exists as a named arm anyway, because a rule living in one of two runtimes
93
+ * is a rule the conformance harness cannot reach.
94
+ */
95
+ function decodeLine(text) {
96
+ return JSON.parse(text);
97
+ }
98
+ /**
99
+ * Every `*.jsonl` under `base`, in one deterministic order.
100
+ *
101
+ * Sorted by entry NAME in code-point order at every level, directories and files in the same
102
+ * listing, so the sequence is a property of the tree and not of the filesystem. It has to be:
103
+ * the walk order decides which copy of a duplicated `requestId` is the one that counts.
104
+ */
105
+ export function walk(base) {
106
+ const found = [];
107
+ const visit = (directory, prefix) => {
108
+ let names;
109
+ try {
110
+ names = readdirSync(directory).sort(cmpCodepoint);
111
+ }
112
+ catch {
113
+ return;
114
+ }
115
+ for (const name of names) {
116
+ const child = join(directory, name);
117
+ const rel = `${prefix}${name}`;
118
+ let isDirectory = false;
119
+ try {
120
+ isDirectory = statSync(child).isDirectory();
121
+ }
122
+ catch {
123
+ // A dangling symlink is not a directory. `Path.is_dir()` answers False for one too,
124
+ // so it falls through to the suffix test and is read (and fails to be read) here
125
+ // exactly as it is there.
126
+ isDirectory = false;
127
+ }
128
+ if (isDirectory)
129
+ visit(child, `${rel}/`);
130
+ else if (name.endsWith(TRANSCRIPT_SUFFIX))
131
+ found.push([child, rel]);
132
+ }
133
+ };
134
+ visit(base, '');
135
+ return found;
136
+ }
137
+ /**
138
+ * The strict UTF-8 decode. `readFileSync(p, 'utf8')` substitutes U+FFFD where the reference
139
+ * raises, which would make an unreadable transcript a counted one on this side alone.
140
+ */
141
+ function readUtf8Strict(path) {
142
+ const decoder = new TextDecoder('utf-8', { fatal: true });
143
+ return decoder.decode(readFileSync(path));
144
+ }
145
+ function usageOf(record) {
146
+ const message = record.message;
147
+ if (!isPlainObject(message))
148
+ return undefined;
149
+ return message.usage;
150
+ }
151
+ /** `<relpath>:<line>` for the first site, plus `and N more` when there are more. */
152
+ function whatOf(count, site) {
153
+ if (count <= 1)
154
+ return site;
155
+ return `${site} and ${count - 1} more`;
156
+ }
157
+ /**
158
+ * Walk `root` and answer the ledger.
159
+ *
160
+ * An empty `root` is refused rather than resolved -- `statSync('')` throws here and
161
+ * `Path('')` is `Path('.')` on the reference, so the two would answer a refusal and a
162
+ * CWD-relative ledger for one input. `model` omitted is not `model` empty: an absent model
163
+ * asks for no cost and gets no `cost` key, and an empty string is refused rather than looked
164
+ * up and reported as "no rate recorded for model ''".
165
+ */
166
+ export function read(root, options = {}) {
167
+ const model = options.model ?? null;
168
+ const prices = options.prices ?? null;
169
+ if (root === '') {
170
+ throw new TokenLedgerError('root must not be empty; name the directory of transcripts to read');
171
+ }
172
+ if (model !== null && model === '') {
173
+ throw new TokenLedgerError('model must not be empty; name the model to price, or omit it');
174
+ }
175
+ if (!existsSync(root))
176
+ throw new TokenLedgerError(`no such directory: ${root}`);
177
+ if (!statSync(root).isDirectory()) {
178
+ throw new TokenLedgerError(`${root} is a file, not a directory of transcripts`);
179
+ }
180
+ const files = walk(root);
181
+ const sessions = new Map();
182
+ const counts = new Map();
183
+ const sites = new Map();
184
+ // Spelled out rather than built in a loop: `noUncheckedIndexedAccess` makes an index into a
185
+ // `Record<string, number>` a `number | undefined`, and the ceiling arithmetic below must not
186
+ // be reading a `?? 0` that would silently restart a total.
187
+ const totals = {
188
+ input_tokens: 0,
189
+ cache_creation_input_tokens: 0,
190
+ cache_read_input_tokens: 0,
191
+ output_tokens: 0,
192
+ };
193
+ const seenRequests = new Set();
194
+ let lines = 0;
195
+ const omit = (subject, site) => {
196
+ const seen = counts.get(subject) ?? 0;
197
+ if (seen === 0)
198
+ sites.set(subject, site);
199
+ counts.set(subject, seen + 1);
200
+ };
201
+ for (const [path, relpath] of files) {
202
+ let text;
203
+ try {
204
+ text = readUtf8Strict(path);
205
+ }
206
+ catch {
207
+ // The file exists and is a transcript by name; it is one line of nothing anyone can
208
+ // read. Counted as ONE omission and not as N, because N is not knowable.
209
+ lines += 1;
210
+ omit(OMIT_UNDECODABLE, `${relpath}:1`);
211
+ continue;
212
+ }
213
+ const rows = text.split('\n');
214
+ for (const [index, line] of rows.entries()) {
215
+ // Not a record. The last one is the file's trailing newline; counting it would put a
216
+ // permanent off-by-one in `lines == requests + sum(counts)`.
217
+ if (line === '')
218
+ continue;
219
+ lines += 1;
220
+ const site = `${relpath}:${index + 1}`;
221
+ let record;
222
+ try {
223
+ record = decodeLine(line);
224
+ }
225
+ catch {
226
+ omit(OMIT_UNPARSED, site);
227
+ continue;
228
+ }
229
+ if (!isPlainObject(record)) {
230
+ omit(OMIT_NOT_OBJECT, site);
231
+ continue;
232
+ }
233
+ const sessionId = record.sessionId;
234
+ if (typeof sessionId !== 'string' || sessionId === '') {
235
+ omit(OMIT_NO_SESSION, site);
236
+ continue;
237
+ }
238
+ if (record.type !== 'assistant') {
239
+ omit(OMIT_NOT_ASSISTANT, site);
240
+ continue;
241
+ }
242
+ const usage = usageOf(record);
243
+ if (usage === undefined || usage === null) {
244
+ omit(OMIT_NO_USAGE, site);
245
+ continue;
246
+ }
247
+ if (!isPlainObject(usage) || !TOKEN_CLASSES.every((cls) => isCount(usage[cls]))) {
248
+ // A real `usage` also carries `service_tier`, `iterations` and friends. Those are the
249
+ // host's and are ignored; what is required is that all FOUR classes this ledger
250
+ // reports are present and are counts. A class silently defaulted to zero is a token
251
+ // silently invented.
252
+ omit(OMIT_MALFORMED_USAGE, site);
253
+ continue;
254
+ }
255
+ const requestId = record.requestId;
256
+ if (typeof requestId !== 'string' || requestId === '') {
257
+ omit(OMIT_NO_REQUEST_ID, site);
258
+ continue;
259
+ }
260
+ if (seenRequests.has(requestId)) {
261
+ omit(OMIT_DUPLICATE_REQUEST, site);
262
+ continue;
263
+ }
264
+ seenRequests.add(requestId);
265
+ let row = sessions.get(sessionId);
266
+ if (row === undefined) {
267
+ row = {
268
+ session: sessionId,
269
+ cwd: '',
270
+ first: '',
271
+ last: '',
272
+ requests: 0,
273
+ sidechain_requests: 0,
274
+ input_tokens: 0,
275
+ cache_creation_input_tokens: 0,
276
+ cache_read_input_tokens: 0,
277
+ output_tokens: 0,
278
+ };
279
+ sessions.set(sessionId, row);
280
+ }
281
+ const cwd = record.cwd;
282
+ if (row.cwd === '' && typeof cwd === 'string')
283
+ row.cwd = cwd;
284
+ const stamp = record.timestamp;
285
+ if (typeof stamp === 'string' && stamp !== '') {
286
+ if (row.first === '' || cmpCodepoint(stamp, row.first) < 0)
287
+ row.first = stamp;
288
+ if (cmpCodepoint(stamp, row.last) > 0)
289
+ row.last = stamp;
290
+ }
291
+ row.requests += 1;
292
+ if (record.isSidechain === true)
293
+ row.sidechain_requests += 1;
294
+ for (const cls of TOKEN_CLASSES) {
295
+ const count = usage[cls];
296
+ if (count > MAX_SAFE_INT - totals[cls]) {
297
+ throw new TokenLedgerError(`${cls} total exceeds 2**53-1, which is the largest integer both runtimes ` +
298
+ 'represent exactly');
299
+ }
300
+ row[cls] += count;
301
+ totals[cls] += count;
302
+ }
303
+ }
304
+ }
305
+ const ordered = [...sessions.values()].sort((a, b) => cmpCodepoint(a.first, b.first) || cmpCodepoint(a.session, b.session));
306
+ const omissions = [];
307
+ for (const subject of OMISSION_ORDER) {
308
+ const count = counts.get(subject) ?? 0;
309
+ if (count > 0)
310
+ omissions.push({ subject, count, what: whatOf(count, sites.get(subject) ?? '') });
311
+ }
312
+ const ledger = {
313
+ root,
314
+ transcripts: files.length,
315
+ lines,
316
+ requests: seenRequests.size,
317
+ totals,
318
+ sessions: ordered,
319
+ omissions,
320
+ };
321
+ if (model !== null) {
322
+ ledger.cost = priceTokens(loadPriceTable(prices ?? undefined), model, { ...totals });
323
+ }
324
+ return ledger;
325
+ }
326
+ /**
327
+ * The document, in the reference's key order.
328
+ *
329
+ * Built explicitly rather than by serialising `Ledger`, because the interface's declaration
330
+ * order is a type and not a runtime fact, and `sessions[]` interleaves the four token classes
331
+ * after `sidechain_requests` -- an order a structural copy would not reproduce.
332
+ */
333
+ export function asDict(ledger) {
334
+ const totals = {};
335
+ for (const cls of TOKEN_CLASSES)
336
+ totals[cls] = ledger.totals[cls];
337
+ const doc = {
338
+ root: ledger.root,
339
+ transcripts: ledger.transcripts,
340
+ lines: ledger.lines,
341
+ requests: ledger.requests,
342
+ totals,
343
+ sessions: ledger.sessions.map((s) => {
344
+ const row = {
345
+ session: s.session,
346
+ cwd: s.cwd,
347
+ first: s.first,
348
+ last: s.last,
349
+ requests: s.requests,
350
+ sidechain_requests: s.sidechain_requests,
351
+ };
352
+ for (const cls of TOKEN_CLASSES)
353
+ row[cls] = s[cls];
354
+ return row;
355
+ }),
356
+ omissions: ledger.omissions.map((o) => ({
357
+ subject: o.subject,
358
+ count: o.count,
359
+ what: o.what,
360
+ })),
361
+ };
362
+ if (ledger.cost !== undefined)
363
+ doc.cost = ledger.cost;
364
+ return doc;
365
+ }
366
+ /**
367
+ * Two-space indent and no ASCII escaping, so this and `Ledger.as_json()` on the reference are
368
+ * the same bytes and a conformance case can compare them.
369
+ */
370
+ export function asJson(ledger) {
371
+ return JSON.stringify(asDict(ledger), null, 2);
372
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bantamkit-mcp",
3
- "version": "0.30.0",
3
+ "version": "0.32.0",
4
4
  "description": "bantamkit MCP server — pure Node, runnable with npx",
5
5
  "type": "module",
6
6
  "bin": {