bantamkit-mcp 0.30.0 → 0.31.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,16 @@
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.
43
+ *
34
44
  * WHAT IS NOT PORTED, DELIBERATELY. The `depends_on` field is ignored on cursor advance
35
45
  * (v1-linear, the Python module's own ruling), there is no lock (the MCP topology has one
36
46
  * orchestrator by construction), and log-then-commit ordering is preserved exactly: the
@@ -40,7 +50,7 @@
40
50
  import { loadSchema } from './assets.js';
41
51
  import { schemaError } from './contract.js';
42
52
  import { pyAppendText, pyJoin, PyOSError, pyReplace, pySuffix, pyUnlink, pyWithSuffix, pyWriteText, pyReadText, } from './memory/pyfs.js';
43
- import { dumpJson, fromJs, parseJson, PyJSONDecodeError } from './pyjson.js';
53
+ import { dumpJson, fromJs, parseJson, PyJSONDecodeError, reprValue } from './pyjson.js';
44
54
  export const SCHEMA_NAME = 'shiftwork-checkpoint';
45
55
  /** The driver's SUCCESS test. */
46
56
  export const TERMINAL_UNIT_STATUS = new Set(['done', 'dropped']);
@@ -201,13 +211,78 @@ export function clockIn(checkpoint) {
201
211
  ]);
202
212
  }
203
213
  // ---------------------------------------------------------------------------- clock_out
214
+ /**
215
+ * `f"{value}"` — `str()`, which is `repr()` for everything except a string itself.
216
+ *
217
+ * `accounting` carries no schema, so `model` is whatever the orchestrator sent. Python
218
+ * interpolates it with `str()` and prints `5`, `5.0`, `True`, `None`; a Node port that
219
+ * reached for the tagged `.v` would print `5` for both floats and ints and `true` for a
220
+ * bool. `reprValue` already spells all six the way CPython does.
221
+ */
222
+ function pyFormat(value) {
223
+ return value.t === 'str' ? value.v : reprValue(value);
224
+ }
225
+ /**
226
+ * AS-2: a role named in `job.roles` may only report a model on its list.
227
+ *
228
+ * Returns the refusal sentence, or `null` when the clock-out may proceed. A role the map
229
+ * does not name — and a checkpoint carrying no map at all — is unconstrained: that is the
230
+ * schema's shape (J46-7) and it is what lets a checkpoint written before this feature clock
231
+ * out unchanged, model and all.
232
+ *
233
+ * A role the map DOES name must say which model it ran: a missing `model` is refused with
234
+ * the same force as a wrong one, because a rule you can escape by omitting a field is
235
+ * enforced only against the honest. `accounting: null`, `{}` and `{"model": null}` are one
236
+ * case in Python (`(accounting or {}).get("model") is None`) and three values here.
237
+ *
238
+ * Models compare exactly — no normalisation, no prefix match, no strip-the-suffix rule. The
239
+ * map's whole value is that it is the literal list of the spellings a session logs, so a
240
+ * spelling this job has never produced is a finding to rule on, not a string to massage.
241
+ *
242
+ * The two sentences are BYTE-IDENTICAL to `runtime-py`'s and are copied, never paraphrased.
243
+ *
244
+ * THE TEST IS KEY PRESENCE, NOT TRUTHINESS, AND THAT IS A RULING (J46-10). Both runtimes
245
+ * spelled this `if not allowed`, which reads `roles: {implementer: []}` as unconstrained and
246
+ * so makes an empty list a silent opt-out of the rule the checkpoint just declared. Today
247
+ * `minItems: 1` refuses such a document during the read and nothing reaches here — but the
248
+ * schema is a SHARED asset, the class job46 has measured three times as invisible to the
249
+ * differential half of the harness, and a check whose safety rests on another layer's
250
+ * keyword fails open the day that keyword moves. The DECLARATION is the key: a role the map
251
+ * names is held to its list, and a list of nothing allows nothing. `names` is then the empty
252
+ * string and the sentence says so. Reachable, and therefore measured: `BANTAMKIT_ASSETS` is
253
+ * honoured by both runtimes, so the tests and the conformance corpus drive this branch
254
+ * through a pack whose schema has lost `minItems`.
255
+ */
256
+ function modelRefusal(document, unitId, unit, accounting) {
257
+ const role = text(field(unit, 'role'));
258
+ const roles = subDict(document, 'job').v.get('roles');
259
+ const allowed = roles === undefined || roles.t !== 'dict' ? undefined : roles.v.get(role);
260
+ // `role not in roles`. The `t !== 'list'` arm is a TYPE guard and not a policy: the schema
261
+ // pins the value to an array, so the only way past it is a document no read would accept.
262
+ if (allowed === undefined || allowed.t !== 'list')
263
+ return null;
264
+ const names = allowed.v.map((model) => pyFormat(model)).join(', ');
265
+ const offered = asPatch(accounting).get('model');
266
+ if (offered === undefined || offered.t === 'null') {
267
+ return `unit ${unitId} in role ${role} reported no model, but job.roles.${role} allows only: ${names}`;
268
+ }
269
+ // `offered not in allowed`. The schema pins every entry to a non-empty STRING, so the
270
+ // only equality Python's `in` can find here is string-to-string: a `{"model": 5}` is
271
+ // unequal to every entry and is refused, with `5` in the sentence.
272
+ if (!allowed.v.some((model) => model.t === 'str' && offered.t === 'str' && model.v === offered.v)) {
273
+ return `unit ${unitId} in role ${role} reported model ${pyFormat(offered)}, which job.roles.${role} does not allow: ${names}`;
274
+ }
275
+ return null;
276
+ }
204
277
  /**
205
278
  * Apply the cursor unit's result, validate the WHOLE mutated document, write atomically.
206
279
  *
207
280
  * `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.
281
+ * never pick-a-unit. When `job.roles` names the unit's role, `accounting.model` must be one
282
+ * of that role's models, spelled exactly; a wrong or missing model is refused here, before
283
+ * any mutation and before the accounting line. Mutations: set the unit's status, advance
284
+ * `plan.cursor` to the first non-terminal unit in PLAN order (`depends_on` is ignored),
285
+ * shallow-merge `handoffPatch` into `handoff`, push `historyEntry` onto the 5-entry ring.
211
286
  *
212
287
  * VALIDATE BEFORE WRITING, AND WRITE NOTHING ON REFUSAL. The mutated document goes through
213
288
  * the full schema before the first byte leaves; a failure returns `refused to write: …` and
@@ -232,6 +307,9 @@ export function clockOut(checkpoint, unitId, status, handoffPatch, historyEntry,
232
307
  const cursor = text(field(plan, 'cursor'));
233
308
  if (unitId !== cursor)
234
309
  return errorResult(`unit ${unitId} is not the cursor unit ${cursor}`);
310
+ const wrongModel = modelRefusal(doc, unitId, unit, accounting);
311
+ if (wrongModel !== null)
312
+ return errorResult(wrongModel);
235
313
  unit.v.set('status', str(status));
236
314
  const remaining = subList(plan, 'units').v.filter((u) => !TERMINAL_UNIT_STATUS.has(text(u.v.get('status'))));
237
315
  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.31.0",
4
4
  "description": "bantamkit MCP server — pure Node, runnable with npx",
5
5
  "type": "module",
6
6
  "bin": {