bantamkit-mcp 0.26.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.
Files changed (148) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +398 -0
  3. package/assets/contracts/default.yaml +90 -0
  4. package/assets/evals/devteam/manifest.yaml +351 -0
  5. package/assets/evals/devteam/repo/HISTORY.md +18 -0
  6. package/assets/evals/devteam/repo/README.md +12 -0
  7. package/assets/evals/devteam/repo/docs/architecture.md +17 -0
  8. package/assets/evals/devteam/repo/docs/runbook.md +10 -0
  9. package/assets/evals/devteam/repo/issues/142-settlement-timeout.md +23 -0
  10. package/assets/evals/devteam/repo/patches/0009-retry-budget.patch +38 -0
  11. package/assets/evals/devteam/repo/src/ledger/__init__.py +3 -0
  12. package/assets/evals/devteam/repo/src/ledger/config.py +35 -0
  13. package/assets/evals/devteam/repo/src/ledger/errors.py +13 -0
  14. package/assets/evals/devteam/repo/src/ledger/posting.py +12 -0
  15. package/assets/evals/devteam/repo/src/ledger/registry.py +7 -0
  16. package/assets/evals/devteam/repo/src/ledger/report.py +9 -0
  17. package/assets/evals/devteam/repo/src/ledger/retry.py +17 -0
  18. package/assets/evals/devteam/repo/src/ledger/settle.py +16 -0
  19. package/assets/evals/devteam/repo/src/ledger/validate.py +14 -0
  20. package/assets/evals/devteam/repo/tests/test_posting.py +13 -0
  21. package/assets/evals/devteam/repo/tests/test_settle.py +9 -0
  22. package/assets/evals/devteam/tasks/dt-error-contract.yaml +186 -0
  23. package/assets/evals/devteam/tasks/dt-handler-map.yaml +183 -0
  24. package/assets/evals/devteam/tasks/dt-patch-before-after.yaml +182 -0
  25. package/assets/evals/devteam/tasks/dt-retry-attempts.yaml +181 -0
  26. package/assets/evals/devteam/tasks/dt-settlement-config.yaml +185 -0
  27. package/assets/evals/devteam/tasks/dt-symbol-home.yaml +181 -0
  28. package/assets/evals/devteam/tasks/dt-trace-blame.yaml +182 -0
  29. package/assets/evals/devteam/tasks/dt-unread-key.yaml +180 -0
  30. package/assets/evals/document/tasks/doc-large-in-137.yaml +38 -0
  31. package/assets/evals/document/tasks/doc-large-in-359.yaml +44 -0
  32. package/assets/evals/document/tasks/doc-large-in-372.yaml +38 -0
  33. package/assets/evals/document/tasks/doc-large-out-11764.yaml +37 -0
  34. package/assets/evals/document/tasks/doc-large-out-4137.yaml +37 -0
  35. package/assets/evals/document/tasks/doc-large-out-8022.yaml +37 -0
  36. package/assets/evals/document/tasks/doc-small-137.yaml +37 -0
  37. package/assets/evals/document/tasks/doc-small-261.yaml +37 -0
  38. package/assets/evals/document/tasks/doc-small-388.yaml +37 -0
  39. package/assets/evals/fixtures/.gitkeep +0 -0
  40. package/assets/evals/fixtures/catalog.json +6 -0
  41. package/assets/evals/perturbations/task-completion.yaml +576 -0
  42. package/assets/evals/tasks/.gitkeep +0 -0
  43. package/assets/evals/tasks/extract-contact.yaml +14 -0
  44. package/assets/evals/tasks/extract-invoice.yaml +14 -0
  45. package/assets/evals/tasks/extract-order.yaml +15 -0
  46. package/assets/evals/tasks/extract-schedule.yaml +14 -0
  47. package/assets/evals/tasks/extract-versions.yaml +17 -0
  48. package/assets/evals/tasks/nav-prod-port.yaml +84 -0
  49. package/assets/evals/tasks/nav-release-bundle.yaml +87 -0
  50. package/assets/evals/tasks/recall-audit-retention.yaml +17 -0
  51. package/assets/evals/tasks/recall-cache-ttl.yaml +13 -0
  52. package/assets/evals/tasks/recall-db-port.yaml +17 -0
  53. package/assets/evals/tasks/recall-deploy.yaml +13 -0
  54. package/assets/evals/tasks/recall-env-endpoint.yaml +18 -0
  55. package/assets/evals/tasks/recall-oncall-rotation.yaml +21 -0
  56. package/assets/evals/tasks/recall-oncall.yaml +13 -0
  57. package/assets/evals/tasks/recall-org-quota.yaml +18 -0
  58. package/assets/evals/tasks/recall-owner.yaml +13 -0
  59. package/assets/evals/tasks/shop-basket-total.yaml +10 -0
  60. package/assets/evals/tasks/shop-cheapest.yaml +9 -0
  61. package/assets/evals/tasks/shop-compare.yaml +9 -0
  62. package/assets/evals/tasks/shop-gadget-value.yaml +9 -0
  63. package/assets/evals/tasks/shop-stock-total.yaml +9 -0
  64. package/assets/evals/tasks/shop-total.yaml +9 -0
  65. package/assets/profiles/default.yaml +31 -0
  66. package/assets/profiles/patient.yaml +31 -0
  67. package/assets/rubrics/.gitkeep +0 -0
  68. package/assets/rubrics/code-quality.yaml +20 -0
  69. package/assets/rubrics/grounded-completion.yaml +37 -0
  70. package/assets/rubrics/task-completion.yaml +28 -0
  71. package/assets/schemas/shiftwork-checkpoint.json +188 -0
  72. package/assets/skills/.gitkeep +0 -0
  73. package/assets/skills/file-graph.md +7 -0
  74. package/assets/skills/memory.md +35 -0
  75. package/assets/tools/.gitkeep +0 -0
  76. package/assets/tools/bantamkit_read.json +48 -0
  77. package/assets/tools/bantamkit_status.json +25 -0
  78. package/assets/tools/build_identity.json +17 -0
  79. package/assets/tools/document_list.json +12 -0
  80. package/assets/tools/document_read.json +31 -0
  81. package/assets/tools/file_graph.json +12 -0
  82. package/assets/tools/memory_compact.json +31 -0
  83. package/assets/tools/memory_recall.json +38 -0
  84. package/assets/tools/memory_save.json +61 -0
  85. package/assets/tools/shiftwork_clock_in.json +25 -0
  86. package/assets/tools/shiftwork_clock_out.json +60 -0
  87. package/assets/tools/shiftwork_status.json +25 -0
  88. package/assets/tools/skill_audit.json +70 -0
  89. package/assets/tools/validate_json.json +31 -0
  90. package/dist/assets.d.ts +48 -0
  91. package/dist/assets.js +109 -0
  92. package/dist/charsets.d.ts +17 -0
  93. package/dist/charsets.js +580 -0
  94. package/dist/cli.d.ts +12 -0
  95. package/dist/cli.js +280 -0
  96. package/dist/contract.d.ts +145 -0
  97. package/dist/contract.js +608 -0
  98. package/dist/docread.d.ts +357 -0
  99. package/dist/docread.js +3315 -0
  100. package/dist/errors.d.ts +11 -0
  101. package/dist/errors.js +14 -0
  102. package/dist/eventlog.d.ts +137 -0
  103. package/dist/eventlog.js +294 -0
  104. package/dist/htmlentities.d.ts +2 -0
  105. package/dist/htmlentities.js +16 -0
  106. package/dist/index.d.ts +23 -0
  107. package/dist/index.js +13 -0
  108. package/dist/mcp/identity.d.ts +25 -0
  109. package/dist/mcp/identity.js +275 -0
  110. package/dist/mcp/pyargs.d.ts +80 -0
  111. package/dist/mcp/pyargs.js +362 -0
  112. package/dist/mcp/sdkjson.d.ts +56 -0
  113. package/dist/mcp/sdkjson.js +91 -0
  114. package/dist/mcp/server.d.ts +45 -0
  115. package/dist/mcp/server.js +740 -0
  116. package/dist/mcp/status.d.ts +210 -0
  117. package/dist/mcp/status.js +357 -0
  118. package/dist/mcp/transport.d.ts +68 -0
  119. package/dist/mcp/transport.js +111 -0
  120. package/dist/mcpreport.d.ts +214 -0
  121. package/dist/mcpreport.js +758 -0
  122. package/dist/memory/cli.d.ts +2 -0
  123. package/dist/memory/cli.js +396 -0
  124. package/dist/memory/component.d.ts +256 -0
  125. package/dist/memory/component.js +497 -0
  126. package/dist/memory/factfile.d.ts +120 -0
  127. package/dist/memory/factfile.js +406 -0
  128. package/dist/memory/layers.d.ts +92 -0
  129. package/dist/memory/layers.js +410 -0
  130. package/dist/memory/pyfs.d.ts +521 -0
  131. package/dist/memory/pyfs.js +1608 -0
  132. package/dist/memory/pyyaml.d.ts +155 -0
  133. package/dist/memory/pyyaml.js +868 -0
  134. package/dist/memory/store.d.ts +478 -0
  135. package/dist/memory/store.js +1034 -0
  136. package/dist/pyargparse.d.ts +141 -0
  137. package/dist/pyargparse.js +813 -0
  138. package/dist/pyjson.d.ts +110 -0
  139. package/dist/pyjson.js +524 -0
  140. package/dist/pyjsonschema.d.ts +50 -0
  141. package/dist/pyjsonschema.js +951 -0
  142. package/dist/shiftwork.d.ts +60 -0
  143. package/dist/shiftwork.js +324 -0
  144. package/dist/skillaudit.d.ts +172 -0
  145. package/dist/skillaudit.js +914 -0
  146. package/dist/statusline.d.ts +106 -0
  147. package/dist/statusline.js +281 -0
  148. package/package.json +45 -0
@@ -0,0 +1,608 @@
1
+ /**
2
+ * Layer 2 — the model contract: every string the model reads, every parse of what it writes.
3
+ *
4
+ * A port of the lines of `runtime-py/src/bantamkit/contract.py` that the MCP surface
5
+ * reaches. Measured by `sys.settrace` over a real stdio server, that was 44 lines while the
6
+ * surface was nine tools (served-tools: dated) and none of the `document_*` renderers —
7
+ * before `bantamkit_read` (job43)
8
+ * put the reader on the wire, so `document_manifest`, `document_page`, `document_error`,
9
+ * `document_offset_past_end` and the two sentences that are that tool's own are here now,
10
+ * mirrored from `contract.py` line for line — including `_omission_line`'s subject switch
11
+ * and its printed fallback. The layer rule (`docs/architecture.md`) puts the WORDING in
12
+ * `assets/contracts/default.yaml` and the PARSE in this file, and neither half is duplicated
13
+ * here: the sentences are read from the asset at call time, exactly as Python reads them.
14
+ *
15
+ * THE RENDERERS TAKE PRIMITIVES, as the reference's do: `contract.py` may not import
16
+ * `docread` (`test_layers.py::test_import_direction`) and this file does not import
17
+ * `docread.ts`. A rendered row is already a string by the time a reader has one, and an
18
+ * omission arrives as `Omission.asDict()`'s plain object.
19
+ *
20
+ * THE PROPERTY THIS FILE EXISTS FOR: for every validation failure bantamkit can produce,
21
+ * Node emits the byte-identical string Python emits. Two of these are pinned in the Python
22
+ * suite — `test_layers.py:592` and `test_structured.py:82` — and every one of them reaches
23
+ * either the model's retry turn or a shift-work checkpoint's refusal reason.
24
+ *
25
+ * The two library seams live next door and not in here, the way `memory/pyyaml.ts` and
26
+ * `memory/pyfs.ts` sit next to `memory/store.ts`: `pyjson.ts` is CPython's `json` decoder
27
+ * plus `repr()`, `pyjsonschema.ts` is `jsonschema`'s Draft 2020-12 validator plus
28
+ * `best_match`. Neither is a bantamkit layer; both are third-party behaviour this runtime
29
+ * has to reproduce because its output is quoted verbatim into Layer 2's sentences.
30
+ */
31
+ import { existsSync } from 'node:fs';
32
+ import { join } from 'node:path';
33
+ import { assetsRoot, AssetNotFound } from './assets.js';
34
+ import { BantamError } from './errors.js';
35
+ import { pyStrip } from './memory/factfile.js';
36
+ import { pyReadText } from './memory/pyfs.js';
37
+ import { resolveImplicitTag } from './memory/pyyaml.js';
38
+ import { fromJs, PyValueError, rawDecode } from './pyjson.js';
39
+ import { absolutePath, validate } from './pyjsonschema.js';
40
+ /**
41
+ * `contract.REQUIRED_KEYS`, in Python's order — the order the error message lists them.
42
+ *
43
+ * `bantamkit_read_page_next` and `bantamkit_read_unknown_part` are deliberately NOT here:
44
+ * the reference's tuple does not carry them (R1 added the sentences to the asset and not to
45
+ * the tuple), and `missing key(s): ...` lists this tuple verbatim, so a Node list that was
46
+ * two keys longer would refuse a thin contract with a sentence Python does not print. The
47
+ * two renderers below fetch their key at call time and raise by name if it is absent, which
48
+ * is the `KeyError` the reference would raise at the same moment.
49
+ */
50
+ export const REQUIRED_KEYS = [
51
+ 'schema_instruction',
52
+ 'schema_retry',
53
+ 'critique_feedback',
54
+ 'parse_error',
55
+ 'validation_error',
56
+ 'json_answer_retry',
57
+ 'loop_note',
58
+ 'loop_warn',
59
+ 'evidence_line',
60
+ 'evidence_no_observation',
61
+ 'evidence_empty',
62
+ 'document_manifest_empty',
63
+ 'document_manifest_part',
64
+ 'document_manifest_header_row',
65
+ 'document_manifest_first_row',
66
+ 'document_manifest_last_row',
67
+ 'document_manifest_package_media',
68
+ 'document_manifest_omitted_media',
69
+ 'document_manifest_omitted_blank',
70
+ 'document_manifest_omitted_format',
71
+ 'document_manifest_omitted_unread_page',
72
+ 'document_manifest_unread_no_operator',
73
+ 'document_manifest_unread_unmapped',
74
+ 'document_manifest_unread_whitespace',
75
+ 'document_manifest_unread_no_character',
76
+ 'document_manifest_omitted_unmapped',
77
+ 'document_manifest_omitted_other',
78
+ 'document_page_header',
79
+ 'document_page_next',
80
+ 'document_page_end',
81
+ 'document_page_truncated',
82
+ 'document_unknown',
83
+ 'document_offset_past_end',
84
+ 'document_error',
85
+ 'document_paste_preamble',
86
+ 'document_paste_part',
87
+ 'document_paste_complete',
88
+ 'document_paste_truncated',
89
+ 'document_paste_none',
90
+ 'tool_failed',
91
+ 'tool_arguments',
92
+ 'tool_arguments_none',
93
+ 'tool_argument_types',
94
+ 'tool_argument_type',
95
+ ];
96
+ export class ContractParseError extends BantamError {
97
+ }
98
+ // ------------------------------------------------------------------- reading the asset
99
+ /**
100
+ * `yaml.safe_load` reduced to the document `assets/contracts/default.yaml` actually is.
101
+ *
102
+ * HOW default.yaml IS READ, AND WHY THAT IS SAFE WITH NO YAML ENGINE IN THE PACKAGE.
103
+ * N4's `parseConfigDocument` reads a DIFFERENT document — a hand-written `config.yaml` whose
104
+ * values are paths and lists — and this one has different needs, so it gets its own reader
105
+ * rather than a widened one. Measured shape of the shipped file (86 lines, 9,194 bytes):
106
+ * 40 whole-line comments, 1 blank line, 1 `key: <plain scalar>` (`name: default`), and 44
107
+ * `key: "<double-quoted scalar>"` on ONE line each. No block scalars, no wraps, no nesting,
108
+ * no sequences, no anchors, no tags, no multi-document stream. Every value is a string; the
109
+ * only escapes present are `\n` (3) and `\"` (16).
110
+ *
111
+ * So this reads exactly that: comments, blanks, an optional leading `---`, and root-level
112
+ * entries whose value is either a double-quoted scalar closing on its own line or a plain
113
+ * scalar. The double-quoted escape table is YAML 1.1's in full, so a contract that gains a
114
+ * `\t` or a `é` tomorrow is already read; the plain scalar goes through
115
+ * `resolveImplicitTag`, the SAME resolver the fact-file emitter uses, so the two halves of
116
+ * this package cannot disagree about what `null` or `2026-08-23` means.
117
+ *
118
+ * SAFE, in the sense the word carries for YAML, is structural: no tag handling, no anchor or
119
+ * alias expansion, no merge key, no constructor dispatch. `yaml.safe_load` earns the name by
120
+ * refusing `!!python/object`; this refuses every `!`, `&` and `*` by not implementing them.
121
+ * One left-to-right pass, no backtracking, so a hostile file costs time linear in its length.
122
+ *
123
+ * ANYTHING OUTSIDE THAT LANGUAGE RAISES. The failure mode of guessing is a contract key
124
+ * whose value is subtly wrong, which reaches the model as a malformed sentence nobody
125
+ * notices; the failure mode of raising is `AssetNotFound`-shaped and loud. The gate for this
126
+ * claim is not the reader's own opinion: `tools/conformance/suites/validate.mjs` compares
127
+ * this parse of the SHIPPED asset against `yaml.safe_load`'s, key for key and byte for byte,
128
+ * and `test/contract.test.mjs` refuses the constructs above by name.
129
+ */
130
+ export function parseContractDocument(text) {
131
+ const lines = text.split('\n');
132
+ if (lines[lines.length - 1] === '')
133
+ lines.pop();
134
+ const entries = new Map();
135
+ let i = 0;
136
+ const blank = (line) => pyStrip(line) === '' || /^\s*#/.test(line);
137
+ while (i < lines.length && blank(lines[i]))
138
+ i += 1;
139
+ if (i < lines.length && lines[i].trimEnd() === '---')
140
+ i += 1;
141
+ for (; i < lines.length; i += 1) {
142
+ const line = lines[i];
143
+ if (blank(line))
144
+ continue;
145
+ if (/^\s*(?:[!&*]|<<|---|\.\.\.)/.test(line)) {
146
+ throw new ContractParseError(`line ${i + 1} uses a tag, an anchor, an alias or a second document, which is ` +
147
+ 'outside this reader');
148
+ }
149
+ const head = /^([^\s#:'"[\]{}&*!][^:]*):(?:[ \t]+|$)/.exec(line);
150
+ if (head === null || line.startsWith(' ') || line.startsWith('\t')) {
151
+ throw new ContractParseError(`line ${i + 1} is not a root "key: value" entry, which is outside this reader`);
152
+ }
153
+ const key = pyStrip(head[1]);
154
+ const rest = line.slice(head[0].length);
155
+ entries.set(key, readScalar(rest, i + 1));
156
+ }
157
+ return entries;
158
+ }
159
+ /** One value, on one line: a double-quoted scalar or a plain one. */
160
+ function readScalar(rest, lineNumber) {
161
+ const body = rest.trimEnd();
162
+ if (body === '' || body.startsWith('|') || body.startsWith('>')) {
163
+ throw new ContractParseError(`line ${lineNumber} opens a block scalar or an empty value, which is outside this reader`);
164
+ }
165
+ if (/^['[{!&*]|^\?\s|^:\s/.test(body)) {
166
+ throw new ContractParseError(`line ${lineNumber} opens the value with a single quote, a flow collection, a tag, an ` +
167
+ 'anchor or an alias, which is outside this reader');
168
+ }
169
+ if (!body.startsWith('"')) {
170
+ // A plain scalar ends at ` #`. `resolveImplicitTag` decides whether it is a string.
171
+ const cut = body.indexOf(' #');
172
+ const plain = pyStrip(cut === -1 ? body : body.slice(0, cut));
173
+ const tag = resolveImplicitTag(plain);
174
+ if (tag !== 'tag:yaml.org,2002:str') {
175
+ throw new ContractParseError(`line ${lineNumber} is a plain scalar resolving to ${tag}, and every contract value ` +
176
+ 'is a string; that is outside this reader');
177
+ }
178
+ return plain;
179
+ }
180
+ const [value, end] = readDoubleQuoted(body, lineNumber);
181
+ const tail = pyStrip(body.slice(end));
182
+ if (tail !== '' && !tail.startsWith('#')) {
183
+ throw new ContractParseError(`line ${lineNumber} has content after the closing quote, which is outside this reader`);
184
+ }
185
+ return value;
186
+ }
187
+ /** YAML 1.1's double-quoted escape table, which is a superset of JSON's. */
188
+ const YAML_ESCAPES = {
189
+ '0': '\0',
190
+ a: '\x07',
191
+ b: '\b',
192
+ t: '\t',
193
+ '\t': '\t',
194
+ n: '\n',
195
+ v: '\v',
196
+ f: '\f',
197
+ r: '\r',
198
+ e: '\x1b',
199
+ ' ': ' ',
200
+ '"': '"',
201
+ '/': '/',
202
+ '\\': '\\',
203
+ N: '\x85',
204
+ _: '\xa0',
205
+ L: '
',
206
+ P: '
',
207
+ };
208
+ const YAML_HEX = { x: 2, u: 4, U: 8 };
209
+ function readDoubleQuoted(body, lineNumber) {
210
+ let out = '';
211
+ let i = 1;
212
+ while (i < body.length) {
213
+ const ch = body[i];
214
+ if (ch === '"')
215
+ return [out, i + 1];
216
+ if (ch !== '\\') {
217
+ out += ch;
218
+ i += 1;
219
+ continue;
220
+ }
221
+ const esc = body[i + 1];
222
+ if (esc === undefined)
223
+ break; // a line-continuation fold: multi-line, outside this reader
224
+ const width = YAML_HEX[esc];
225
+ if (width !== undefined) {
226
+ const digits = body.slice(i + 2, i + 2 + width);
227
+ if (digits.length !== width || !/^[0-9a-fA-F]+$/.test(digits)) {
228
+ throw new ContractParseError(`line ${lineNumber} has a malformed \\${esc} escape, which is outside this reader`);
229
+ }
230
+ out += String.fromCodePoint(Number.parseInt(digits, 16));
231
+ i += 2 + width;
232
+ continue;
233
+ }
234
+ const mapped = YAML_ESCAPES[esc];
235
+ if (mapped === undefined) {
236
+ throw new ContractParseError(`line ${lineNumber} has an unknown escape \\${esc}, which is outside this reader`);
237
+ }
238
+ out += mapped;
239
+ i += 2;
240
+ }
241
+ throw new ContractParseError(`line ${lineNumber} has a double-quoted scalar that does not close on its own line, ` +
242
+ 'which is outside this reader');
243
+ }
244
+ /**
245
+ * `load_contract`. Reads the asset EVERY call, as Python does — the prep probe measured that
246
+ * `contracts/default.yaml` is touched only on a validation failure, so a cache would buy
247
+ * nothing and would make a hot-edited contract silently stale.
248
+ */
249
+ export function loadContract(name = 'default') {
250
+ const path = join(assetsRoot(), 'contracts', `${name}.yaml`);
251
+ if (!existsSync(path))
252
+ throw new AssetNotFound(`contract asset not found: ${path}`);
253
+ const data = parseContractDocument(pyReadText(path));
254
+ const missing = REQUIRED_KEYS.filter((k) => !data.has(k));
255
+ if (missing.length > 0) {
256
+ throw new BantamError(`contract '${name}' missing key(s): ${missing.join(', ')}`);
257
+ }
258
+ return Object.fromEntries(data);
259
+ }
260
+ // --------------------------------------------------------------------------- the strings
261
+ /**
262
+ * `str.format(**fields)`, and it is here rather than as a `String.replace` for one measured
263
+ * reason: JS gives `$` a meaning in the REPLACEMENT string. `'{detail}'.replace('{detail}',
264
+ * "'A1' does not match '^[a-z]+$'")` drops the `$'` and emits
265
+ * `'A1' does not match '^[a-z]+`, because `$'` means "everything after the match". Every
266
+ * `pattern` sentence and every regex a model writes back would arrive truncated. Caught by
267
+ * the differential, not by review.
268
+ *
269
+ * `{{` and `}}` are Python's literal braces; a field the caller did not supply is a
270
+ * `KeyError` there and a thrown `BantamError` here, rather than a brace left in the text.
271
+ */
272
+ function pyFormat(template, fields) {
273
+ let out = '';
274
+ for (let i = 0; i < template.length; i += 1) {
275
+ const ch = template[i];
276
+ if (ch === '{' && template[i + 1] === '{') {
277
+ out += '{';
278
+ i += 1;
279
+ }
280
+ else if (ch === '}' && template[i + 1] === '}') {
281
+ out += '}';
282
+ i += 1;
283
+ }
284
+ else if (ch === '{') {
285
+ const close = template.indexOf('}', i + 1);
286
+ if (close === -1)
287
+ throw new BantamError(`unmatched '{' in contract template`);
288
+ const key = template.slice(i + 1, close);
289
+ const value = fields[key];
290
+ if (value === undefined)
291
+ throw new BantamError(`contract template wants field '${key}'`);
292
+ out += value;
293
+ i = close;
294
+ }
295
+ else {
296
+ out += ch;
297
+ }
298
+ }
299
+ return out;
300
+ }
301
+ export function schemaRetryFeedback(error) {
302
+ return pyFormat(loadContract()['schema_retry'], { error });
303
+ }
304
+ export function parseErrorMessage(detail) {
305
+ const text = detail instanceof Error ? detail.message : String(detail);
306
+ return pyFormat(loadContract()['parse_error'], { detail: text });
307
+ }
308
+ export function validationErrorMessage(where, detail) {
309
+ return pyFormat(loadContract()['validation_error'], { where, detail });
310
+ }
311
+ /** `str(int)` for a template field — every number in these sentences is an `int`. */
312
+ const num = (n) => String(n);
313
+ /** A contract key the reference reads with `contract[key]`: a `KeyError` there, a raise here. */
314
+ function sentence(contract, key) {
315
+ const template = contract[key];
316
+ if (template === undefined)
317
+ throw new BantamError(`contract missing key: ${key}`);
318
+ return template;
319
+ }
320
+ /**
321
+ * `_omission_line`: one omission, as one line. An unknown subject is PRINTED, never dropped.
322
+ *
323
+ * The branch order is `contract.py`'s, and the `unread-page` arm chooses between the four
324
+ * reasons in the order `docread._pdf_refusal` chooses, so the page-grain sentence and the
325
+ * document-grain refusal cannot disagree about a file.
326
+ */
327
+ function omissionLine(contract, entry, omission) {
328
+ const subject = omission.subject;
329
+ if (subject === 'media') {
330
+ return pyFormat(sentence(contract, 'document_manifest_omitted_media'), {
331
+ count: num(omission.count),
332
+ bytes: num(omission.size ?? 0),
333
+ });
334
+ }
335
+ if (subject === 'blank-rows') {
336
+ return pyFormat(sentence(contract, 'document_manifest_omitted_blank'), {
337
+ count: num(omission.count),
338
+ rows: num(entry.row_count),
339
+ });
340
+ }
341
+ if (subject === 'number-format') {
342
+ return pyFormat(sentence(contract, 'document_manifest_omitted_format'), {
343
+ where: (omission.where ?? []).join(', '),
344
+ count: num(omission.count),
345
+ what: omission.what ?? '',
346
+ });
347
+ }
348
+ if (subject === 'unread-page') {
349
+ const facts = omission.facts ?? {};
350
+ const showOps = Math.trunc(facts['show_ops'] ?? 0);
351
+ const vouched = Math.trunc(facts['vouched'] ?? 0);
352
+ const unmapped = Math.trunc(facts['unmapped'] ?? 0);
353
+ let why;
354
+ if (showOps === 0) {
355
+ why = sentence(contract, 'document_manifest_unread_no_operator');
356
+ }
357
+ else if (vouched === 0 && unmapped) {
358
+ why = pyFormat(sentence(contract, 'document_manifest_unread_unmapped'), {
359
+ show_ops: num(showOps),
360
+ unmapped: num(unmapped),
361
+ });
362
+ }
363
+ else if (vouched) {
364
+ why = pyFormat(sentence(contract, 'document_manifest_unread_whitespace'), {
365
+ show_ops: num(showOps),
366
+ vouched: num(vouched),
367
+ });
368
+ }
369
+ else {
370
+ why = pyFormat(sentence(contract, 'document_manifest_unread_no_character'), {
371
+ show_ops: num(showOps),
372
+ });
373
+ }
374
+ return pyFormat(sentence(contract, 'document_manifest_omitted_unread_page'), {
375
+ why,
376
+ count: num(Math.trunc(facts['images'] ?? 0)),
377
+ bytes: num(Math.trunc(facts['image_bytes'] ?? 0)),
378
+ });
379
+ }
380
+ if (subject === 'unmapped-text') {
381
+ return pyFormat(sentence(contract, 'document_manifest_omitted_unmapped'), {
382
+ count: num(omission.count),
383
+ what: omission.what ?? '',
384
+ });
385
+ }
386
+ return pyFormat(sentence(contract, 'document_manifest_omitted_other'), {
387
+ count: num(omission.count),
388
+ subject,
389
+ what: omission.what ?? '',
390
+ });
391
+ }
392
+ function packageMediaLine(contract, document, omission) {
393
+ return pyFormat(sentence(contract, 'document_manifest_package_media'), {
394
+ document,
395
+ count: num(omission.count),
396
+ what: omission.what ?? '',
397
+ bytes: num(omission.size ?? 0),
398
+ });
399
+ }
400
+ /**
401
+ * `document_manifest`: the answer to "what exists", as one observation — per part, the row
402
+ * count, the numbering, the header, the first and the last data row, and every omission the
403
+ * rendering made. A document's own omissions print before its first part; one whose
404
+ * omissions matched no part is still said, after every part, because `pop` makes that
405
+ * reachable on the reference and dropping the line would put the silence back one level up.
406
+ */
407
+ export function documentManifest(parts, documents = null) {
408
+ const contract = loadContract();
409
+ if (parts.length === 0)
410
+ return sentence(contract, 'document_manifest_empty');
411
+ // `{entry["document"]: entry.get("omissions") or []}` — a later duplicate wins, as in a dict.
412
+ const pkg = new Map();
413
+ for (const entry of documents ?? [])
414
+ pkg.set(entry.document, entry.omissions ?? []);
415
+ const lines = [];
416
+ for (const entry of parts) {
417
+ const rows = entry.rows;
418
+ const last = entry.row_count - 1;
419
+ const pending = pkg.get(entry.document) ?? [];
420
+ pkg.delete(entry.document);
421
+ for (const omission of pending) {
422
+ lines.push(omission.subject === 'media'
423
+ ? packageMediaLine(contract, entry.document, omission)
424
+ : omissionLine(contract, entry, omission));
425
+ }
426
+ lines.push(pyFormat(sentence(contract, 'document_manifest_part'), {
427
+ document: entry.document,
428
+ kind: entry.kind,
429
+ index: num(entry.index),
430
+ part: entry.part,
431
+ rows: num(entry.row_count),
432
+ last: num(last),
433
+ }));
434
+ if (rows.length > 0) {
435
+ lines.push(pyFormat(sentence(contract, 'document_manifest_header_row'), { row: rows[0] }));
436
+ }
437
+ if (rows.length > 1) {
438
+ lines.push(pyFormat(sentence(contract, 'document_manifest_first_row'), { row: rows[1] }));
439
+ }
440
+ if (rows.length > 2) {
441
+ lines.push(pyFormat(sentence(contract, 'document_manifest_last_row'), {
442
+ index: num(last),
443
+ row: rows[rows.length - 1],
444
+ }));
445
+ }
446
+ for (const omission of entry.omissions ?? [])
447
+ lines.push(omissionLine(contract, entry, omission));
448
+ }
449
+ for (const [document, omissions] of pkg) {
450
+ for (const omission of omissions) {
451
+ lines.push(omission.subject === 'media'
452
+ ? packageMediaLine(contract, document, omission)
453
+ : omissionLine(contract, { row_count: 0 }, omission));
454
+ }
455
+ }
456
+ return lines.join('\n');
457
+ }
458
+ /**
459
+ * `document_page`: one page, with its own coordinates and its continuation, both in band.
460
+ *
461
+ * `nextKey` names the contract sentence for the continuation, because that sentence names
462
+ * the TOOL to call again: `document_read` for the eval pair, `bantamkit_read` for the MCP
463
+ * reader. Every other line of a page is the same bytes on both.
464
+ */
465
+ export function documentPage(document, part, offset, rows, rowCount, nextOffset, truncatedBytes = 0, nextKey = 'document_page_next') {
466
+ const contract = loadContract();
467
+ const end = rows.length > 0 ? offset + rows.length - 1 : offset;
468
+ const lines = [
469
+ pyFormat(sentence(contract, 'document_page_header'), {
470
+ document,
471
+ part,
472
+ start: num(offset),
473
+ end: num(end),
474
+ rows: num(rowCount),
475
+ }),
476
+ ];
477
+ rows.forEach((row, i) => lines.push(`${offset + i}\t${row}`));
478
+ if (truncatedBytes) {
479
+ lines.push(pyFormat(sentence(contract, 'document_page_truncated'), {
480
+ index: num(end),
481
+ dropped: num(truncatedBytes),
482
+ }));
483
+ }
484
+ if (nextOffset === null) {
485
+ lines.push(pyFormat(sentence(contract, 'document_page_end'), { part }));
486
+ }
487
+ else {
488
+ lines.push(pyFormat(sentence(contract, nextKey), { next_offset: num(nextOffset) }));
489
+ }
490
+ return lines.join('\n');
491
+ }
492
+ export function documentOffsetPastEnd(part, offset, rowCount) {
493
+ return pyFormat(sentence(loadContract(), 'document_offset_past_end'), {
494
+ part,
495
+ offset: num(offset),
496
+ rows: num(rowCount),
497
+ last: num(rowCount - 1),
498
+ });
499
+ }
500
+ /**
501
+ * `bantamkit_read_unknown_part`: a part the file does not have — a fact about the FILE
502
+ * ("in {path}; it has: ...") and not about "this task", which is why it is not
503
+ * `document_unknown`'s sentence.
504
+ */
505
+ export function bantamkitReadUnknownPart(part, path, available) {
506
+ return pyFormat(sentence(loadContract(), 'bantamkit_read_unknown_part'), {
507
+ part,
508
+ path,
509
+ available: available.join(', '),
510
+ });
511
+ }
512
+ /**
513
+ * `document_error`: a reader failure, kept in the reader's own words, with the `error:`
514
+ * prefix every other tool observation in the harness uses. `str(detail)` on the reference;
515
+ * an `Error`'s `message` is that string here (`DocumentReadError` and `PyOSError` both put
516
+ * the whole sentence there).
517
+ */
518
+ export function documentError(detail) {
519
+ const text = detail instanceof Error ? detail.message : String(detail);
520
+ return pyFormat(sentence(loadContract(), 'document_error'), { detail: text });
521
+ }
522
+ /**
523
+ * `tool_failed`: a handler raised, and `detail` is the handler's OWN words.
524
+ *
525
+ * The argument-shaped failures are settled before the handler is entered (`validateArguments`
526
+ * here, pydantic on the reference), so what reaches this is an exception from INSIDE one —
527
+ * `skill_audit`'s three refusals are the only ones today. `str(detail)` on the reference; an
528
+ * `Error`'s `message` is that string here, which is what `SkillAuditError` carries.
529
+ */
530
+ export function toolFailed(tool, detail) {
531
+ const text = detail instanceof Error ? detail.message : String(detail);
532
+ return pyFormat(sentence(loadContract(), 'tool_failed'), { tool, detail: text });
533
+ }
534
+ // ------------------------------------------------------------------------- extract_json
535
+ /**
536
+ * `extract_json`: a fenced block if there is one, otherwise the first `{`/`[` onward.
537
+ *
538
+ * Both arms are here because both reach the model. The fence is `re.search` with `DOTALL`
539
+ * and a non-greedy body, so it takes the FIRST fence and prose on either side is discarded;
540
+ * with no fence, `find` takes the earliest brace or bracket — including one inside a string
541
+ * of prose, which is faithful and occasionally wrong in exactly Python's way. `raw_decode`
542
+ * then stops at the end of that one value, which is why `{"a": 1} — anything else?` parses.
543
+ */
544
+ export function extractJson(text) {
545
+ let body = pyStrip(text);
546
+ // `re.DOTALL` — JS needs `[\s\S]`, and `\s*` after the opening fence is Python's `\s*`.
547
+ const fence = /```(?:json)?[\s\S]*?```/.exec(body);
548
+ if (fence !== null) {
549
+ const inner = /```(?:json)?\s*([\s\S]*?)```/.exec(body);
550
+ body = pyStrip(inner[1]);
551
+ }
552
+ const startBrace = body.indexOf('{');
553
+ const startBracket = body.indexOf('[');
554
+ let start;
555
+ if (startBrace === -1 && startBracket === -1) {
556
+ throw new PyValueError('no JSON object found in output');
557
+ }
558
+ else if (startBrace === -1) {
559
+ start = startBracket;
560
+ }
561
+ else if (startBracket === -1) {
562
+ start = startBrace;
563
+ }
564
+ else {
565
+ start = Math.min(startBrace, startBracket);
566
+ }
567
+ return rawDecode(body.slice(start))[0];
568
+ }
569
+ // -------------------------------------------------------------------------- schema_error
570
+ /**
571
+ * `schema_error`: a pointed validation error for `output`, or `null` if it satisfies
572
+ * `schema`.
573
+ *
574
+ * `where` is `"/".join(e.absolute_path)` with `"root"` standing in for the empty path, and
575
+ * `detail` is `ValidationError.message` VERBATIM. The error is the one `best_match` chose,
576
+ * not the first one found — see `pyjsonschema.bestMatch`.
577
+ *
578
+ * The schema may be a `PyValue` (exact — use `pyjson.parseJson` when the raw text is still
579
+ * in hand) or a plain JS value, which is lifted with `pyjson.fromJs` and its one documented
580
+ * loss.
581
+ */
582
+ export function schemaError(output, schema) {
583
+ let data;
584
+ try {
585
+ data = extractJson(output);
586
+ }
587
+ catch (e) {
588
+ // `except ValueError` — and `json.JSONDecodeError` IS a `ValueError`, which is how
589
+ // CPython's decoder text reaches the model.
590
+ if (e instanceof Error && (e.name === 'PyValueError' || e.name === 'PyJSONDecodeError')) {
591
+ return parseErrorMessage(e);
592
+ }
593
+ throw e;
594
+ }
595
+ const asPy = isPyValue(schema) ? schema : fromJs(schema);
596
+ const error = validate(data, asPy);
597
+ if (error === null)
598
+ return null;
599
+ const where = absolutePath(error).map(String).join('/') || 'root';
600
+ return validationErrorMessage(where, error.message);
601
+ }
602
+ function isPyValue(value) {
603
+ return (typeof value === 'object' &&
604
+ value !== null &&
605
+ 't' in value &&
606
+ typeof value.t === 'string' &&
607
+ ['null', 'bool', 'int', 'float', 'str', 'list', 'dict'].includes(value.t));
608
+ }