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,406 @@
1
+ /**
2
+ * The fact-file codec: the bytes on disk under `<store>/facts/<name>.md`, both directions.
3
+ *
4
+ * A port of `_write_fact` and the frontmatter half of `_facts` in
5
+ * `runtime-py/src/bantamkit/memory/store.py`. The store itself — `save`, `recall`, the
6
+ * index, scoring, the budget — is NOT here.
7
+ *
8
+ * THE PROPERTY THIS FILE OWES
9
+ * ---------------------------
10
+ * For every fact, the bytes Node writes and the bytes Python writes are identical, and
11
+ * each runtime parses what the other wrote. Both directions matter: the live store already
12
+ * contains 65 files Python wrote, and `_stamp` rewrites a file on every recall hit, so a
13
+ * Node server re-emits Python's files constantly. The differential that proves it runs in
14
+ * `tools/conformance/suites/codec.mjs`; the shapes are pinned in `test/codec.test.mjs`.
15
+ */
16
+ import { constructPlain, PyScalar, safeDumpMapping } from './pyyaml.js';
17
+ export class FactParseError extends Error {
18
+ }
19
+ // ------------------------------------------------------------------------ whitespace
20
+ /**
21
+ * `str.strip()`, which is NOT `String.prototype.trim()`.
22
+ *
23
+ * Measured over all 0x110000 codepoints: Python strips 29, JS strips 25, and the two sets
24
+ * disagree on six. Python also strips U+001C..U+001F and U+0085; JS also strips U+FEFF.
25
+ * `_write_fact` ends with `fact.body.strip() + "\n"`, so each of those six is a byte of
26
+ * difference in a file the store then compares, hashes and diffs.
27
+ */
28
+ const PY_SPACE = new Set([
29
+ 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 0x85, 0xa0, 0x1680, 0x2000,
30
+ 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, 0x2007, 0x2008, 0x2009, 0x200a, 0x2028,
31
+ 0x2029, 0x202f, 0x205f, 0x3000,
32
+ ]);
33
+ export function pyStrip(text) {
34
+ const chars = [...text];
35
+ let start = 0;
36
+ let end = chars.length;
37
+ while (start < end && PY_SPACE.has(chars[start].codePointAt(0)))
38
+ start += 1;
39
+ while (end > start && PY_SPACE.has(chars[end - 1].codePointAt(0)))
40
+ end -= 1;
41
+ return chars.slice(start, end).join('');
42
+ }
43
+ /**
44
+ * The bytes of a fact file as Python's `Path.read_text(encoding="utf-8")` sees them.
45
+ *
46
+ * Text mode applies universal-newline translation: `\r\n` and a lone `\r` both become
47
+ * `\n`. `readFileSync(p, 'utf8')` does not. Measured in the prep probe: a CRLF fact file
48
+ * splits into 3 frontmatter parts in Python and 1 in Node, so without this the Node store
49
+ * calls a file malformed that the Python store reads fine.
50
+ *
51
+ * The WRITE side deliberately does not mirror Python. See `formatFact`.
52
+ */
53
+ export function decodeFactBytes(bytes) {
54
+ return new TextDecoder('utf-8').decode(bytes).replace(/\r\n?/g, '\n');
55
+ }
56
+ // ----------------------------------------------------------------------------- encode
57
+ /**
58
+ * The exact bytes `_write_fact` writes, as a string.
59
+ *
60
+ * RULING — NEWLINES ON THE WAY OUT. Python opens the file in text mode with
61
+ * `newline=None`, which translates `\n` to `os.linesep`, so on Windows every fact file
62
+ * Python writes is CRLF while every byte count the store computes (the index budget, the
63
+ * digests) is LF. This port writes LF on every platform and does not reproduce that.
64
+ * Reasons, in order: the CRLF is incidental to `newline=None` rather than intended; it
65
+ * already disagrees with the store's own arithmetic; and reproducing it would make the
66
+ * same Fact emit different bytes on two machines, which is precisely the property this
67
+ * port exists to hold. Reading stays universal-newline on both sides, so a store written
68
+ * by either runtime is read identically by both. On macOS and Linux `os.linesep` is `\n`
69
+ * and there is no divergence at all — the conformance suite measures byte identity there.
70
+ */
71
+ export function formatFact(fact) {
72
+ const front = safeDumpMapping([
73
+ ['name', fact.name],
74
+ ['description', fact.description],
75
+ ['type', fact.type],
76
+ ['created', fact.created ?? null],
77
+ ['last_recalled', fact.last_recalled ?? null],
78
+ ['links', fact.links],
79
+ ]);
80
+ return '---\n' + front + '---\n\n' + pyStrip(fact.body) + '\n';
81
+ }
82
+ // ----------------------------------------------------------------------------- decode
83
+ /**
84
+ * `text.split("---\n", 2)` plus `yaml.safe_load(front)`, and the `body.strip()` after it.
85
+ *
86
+ * Python unpacks the split into three names, so fewer than three parts is a `ValueError`
87
+ * that `_facts` turns into `MemoryValidationError`. Here it is a `FactParseError`; the
88
+ * sentence the model finally sees is the store's to phrase, not this module's.
89
+ */
90
+ export function parseFactText(text) {
91
+ const parts = pySplit(text, '---\n', 2);
92
+ if (parts.length !== 3) {
93
+ throw new FactParseError(`expected 3 parts from split('---\\n', 2), got ${parts.length}`);
94
+ }
95
+ return { meta: parseFrontmatter(parts[1]), body: pyStrip(parts[2]) };
96
+ }
97
+ /**
98
+ * `str.split(sep, maxsplit)` — JS's `String.split` has no maxsplit and would over-split.
99
+ *
100
+ * Exported because `store._facts` does this split ITSELF in the reference, and needs the
101
+ * part count to build Python's own `ValueError` text for a truncated file. Two spellings of
102
+ * one split is how the two would drift.
103
+ */
104
+ export function pySplit(text, sep, maxsplit) {
105
+ const out = [];
106
+ let from = 0;
107
+ while (out.length < maxsplit) {
108
+ const at = text.indexOf(sep, from);
109
+ if (at === -1)
110
+ break;
111
+ out.push(text.slice(from, at));
112
+ from = at + sep.length;
113
+ }
114
+ out.push(text.slice(from));
115
+ return out;
116
+ }
117
+ /**
118
+ * The frontmatter mapping, for the language `safeDumpMapping` emits and no more.
119
+ *
120
+ * RULING — SCOPE. This is not a YAML parser and does not try to be. It accepts a
121
+ * root-level block mapping of plain keys whose values are plain, single-quoted or
122
+ * double-quoted scalars (wrapped or not), `[]`, or a block sequence of such scalars at
123
+ * column 0 — which is exactly what PyYAML emits for a `Fact` at these dump settings.
124
+ * Anything else RAISES instead of guessing. The reason is the failure mode this whole unit
125
+ * exists for: a parser that silently produces something plausible for input it does not
126
+ * understand turns a hand-edited fact file into a wrong `Fact`, and nothing anywhere
127
+ * reports it. A raise is visible; `_facts` already has a sentence for it.
128
+ *
129
+ * KNOWN NON-IDENTICAL BEHAVIOUR, registered rather than hidden: for input outside that
130
+ * language, Python and Node both fail but with different message text (PyYAML's scanner
131
+ * error vs this module's). `_facts` interpolates that text into
132
+ * `malformed fact file {name}: {e}`, so the string a model sees for a corrupt file is not
133
+ * byte-identical across runtimes. Making it identical would mean porting PyYAML's scanner
134
+ * diagnostics, which is a far larger surface than the codec, and no test in
135
+ * `runtime-py/tests` pins those strings. Owner: whichever unit takes `_facts`.
136
+ */
137
+ export function parseFrontmatter(front) {
138
+ const meta = {};
139
+ let pos = 0;
140
+ while (pos < front.length) {
141
+ const key = /^([A-Za-z_][A-Za-z0-9_-]*):/.exec(front.slice(pos, front.indexOf('\n', pos) + 1 || undefined));
142
+ if (!key) {
143
+ throw new FactParseError(`frontmatter line is not a plain "key:" mapping entry: ${line(front, pos)}`);
144
+ }
145
+ pos += key[0].length;
146
+ let value;
147
+ if (front[pos] === '\n') {
148
+ pos += 1;
149
+ if (/^-(?: |\n|$)/.test(front.slice(pos))) {
150
+ [value, pos] = scanBlockSequence(front, pos);
151
+ }
152
+ else {
153
+ value = null; // `key:` with nothing after it is null in YAML
154
+ }
155
+ }
156
+ else if (front[pos] === ' ') {
157
+ pos += 1;
158
+ [value, pos] = scanNode(front, pos);
159
+ if (pos < front.length && front[pos] !== '\n') {
160
+ throw new FactParseError(`trailing content after the value of "${key[1]}": ${line(front, pos)}`);
161
+ }
162
+ pos += 1;
163
+ }
164
+ else {
165
+ throw new FactParseError(`expected " " or end of line after "${key[1]}:"`);
166
+ }
167
+ meta[key[1]] = value;
168
+ }
169
+ return meta;
170
+ }
171
+ function line(text, pos) {
172
+ const end = text.indexOf('\n', pos);
173
+ return JSON.stringify(text.slice(pos, end === -1 ? undefined : end));
174
+ }
175
+ /** A single-line-or-wrapped scalar starting at `pos`. Returns the value and the index after it. */
176
+ function scanNode(front, pos) {
177
+ const ch = front[pos];
178
+ if (ch === "'")
179
+ return scanQuoted(front, pos, "'");
180
+ if (ch === '"')
181
+ return scanQuoted(front, pos, '"');
182
+ if (ch === '[') {
183
+ const m = /^\[\s*\]/.exec(front.slice(pos));
184
+ if (!m) {
185
+ throw new FactParseError(`only the empty flow sequence "[]" is in this codec's language: ${line(front, pos)}`);
186
+ }
187
+ return [[], pos + m[0].length];
188
+ }
189
+ return scanPlain(front, pos);
190
+ }
191
+ /**
192
+ * A plain scalar and its wrapped continuation lines.
193
+ *
194
+ * A continuation line is one that starts with a space; a new mapping key and a sequence
195
+ * dash both sit at column 0, so nothing else is needed to find the end. Line breaks fold
196
+ * to a single space, which is the inverse of the emitter dropping the space it broke at.
197
+ */
198
+ function scanPlain(front, pos) {
199
+ const pieces = [];
200
+ let i = pos;
201
+ for (;;) {
202
+ const eol = front.indexOf('\n', i);
203
+ const raw = eol === -1 ? front.slice(i) : front.slice(i, eol);
204
+ // A ` #` starts a comment in a plain scalar. PyYAML's emitter never produces one — it
205
+ // quotes instead — so this only matters for a hand-edited file, where matching what
206
+ // Python reads is the point.
207
+ const hash = raw.indexOf(' #');
208
+ const cut = hash === -1 ? raw : raw.slice(0, hash);
209
+ const piece = cut.replace(/[ \t]+$/, '').replace(/^[ \t]+/, '');
210
+ if (pieces.length > 0 && piece === '') {
211
+ throw new FactParseError('a blank line inside a plain scalar is outside this codec\'s language');
212
+ }
213
+ pieces.push(piece);
214
+ const next = eol === -1 ? front.length : eol + 1;
215
+ if (hash !== -1) {
216
+ // The comment ran to end of line; the value ends here.
217
+ i = eol === -1 ? front.length : eol;
218
+ break;
219
+ }
220
+ if (next >= front.length || front[next] !== ' ') {
221
+ i = eol === -1 ? front.length : eol;
222
+ break;
223
+ }
224
+ i = next;
225
+ }
226
+ const value = pieces.join(' ');
227
+ if (/(^|[ \t]):([ \t]|$)/.test(value)) {
228
+ throw new FactParseError(`a plain scalar containing ": " is not readable YAML — PyYAML rejects it too: ${JSON.stringify(value)}`);
229
+ }
230
+ return [resolvePlain(value), i];
231
+ }
232
+ /**
233
+ * RULING — WHAT A PLAIN SCALAR RESOLVES TO. Decided by N10; the reason the old ruling stood
234
+ * on was measured false by N8 and is deleted with the behaviour it justified.
235
+ *
236
+ * PyYAML resolves a plain scalar by pattern, so a hand-edited `created: 2026-08-23` comes
237
+ * back as a `datetime.date` and `links:\n- 12` as an `int`. This function used to RAISE for
238
+ * every tag but `str` and `null`, on the belief — written into the comment as if it were a
239
+ * ruling — that Python "reaches the model as a `TypeError` several frames away". It does
240
+ * not. Measured against the reference over 26 shapes, CPython raises on FOUR (three
241
+ * out-of-range timestamps and the two constructor-less tags) and answers on the rest: it
242
+ * interpolates the value, writes a working `index.md` line, answers `memory_recall`, stamps
243
+ * the file and accepts the next `memory_save`. A bare `created: 2026-08-23` round-trips
244
+ * through `_stamp` unquoted.
245
+ *
246
+ * What the raise COST was not a wrong sentence, it was the store: one such file made
247
+ * `memory_recall`, `memory_save` and the index rebuild all raise `MemoryValidationError`
248
+ * naming that one file, while a Python server bound to the same directory kept working.
249
+ * Fact files are Markdown a human is invited to edit, so the shape is reachable — 13 of 17
250
+ * scalar shapes N8 tried did it. Nothing this codec WRITES is affected: every value it
251
+ * emits is quoted when quoting is what keeps the tag.
252
+ *
253
+ * So: `constructPlain` builds what `SafeConstructor` builds, and the store renders it with
254
+ * `pyText`. The full width — which tags are constructed, which raise, and the three shapes
255
+ * where CPython and this codec genuinely disagree because this codec has no SCANNER — is
256
+ * written at `constructPlain` in `pyyaml.ts` and re-measured by the `frontmatter scalar`
257
+ * scenarios in `tools/conformance/suites/store.mjs` on every run.
258
+ */
259
+ function resolvePlain(value) {
260
+ // The constructor lives with the emitter and the resolver: the quoting decision on the
261
+ // way out is the same question this asks on the way in.
262
+ return constructPlain(value);
263
+ }
264
+ /**
265
+ * A quoted scalar, from its opening quote to its closing one, across as many lines as it
266
+ * takes. Line folding is YAML's: one break becomes a space, n>1 breaks become n-1
267
+ * newlines, and whitespace either side of a break is dropped. That is the exact inverse of
268
+ * `write_single_quoted`'s `breaks` arm, which emits one extra break so that a `\n` in the
269
+ * value survives as a blank line on disk.
270
+ */
271
+ function scanQuoted(front, pos, quote) {
272
+ const out = [];
273
+ let i = pos + 1;
274
+ for (;;) {
275
+ if (i >= front.length) {
276
+ throw new FactParseError(`unterminated ${quote === "'" ? 'single' : 'double'}-quoted scalar`);
277
+ }
278
+ const ch = front[i];
279
+ if (ch === quote) {
280
+ if (quote === "'" && front[i + 1] === "'") {
281
+ out.push("'");
282
+ i += 2;
283
+ continue;
284
+ }
285
+ return [out.join(''), i + 1];
286
+ }
287
+ if (quote === '"' && ch === '\\') {
288
+ const next = front[i + 1];
289
+ if (next === '\n') {
290
+ // An escaped line break contributes nothing, and the indent after it is not content.
291
+ i += 2;
292
+ while (front[i] === ' ' || front[i] === '\t')
293
+ i += 1;
294
+ continue;
295
+ }
296
+ const [text, used] = unescape(front, i);
297
+ out.push(text);
298
+ i += used;
299
+ continue;
300
+ }
301
+ if (ch === '\n') {
302
+ while (out.length > 0 && /^[ \t]$/.test(out[out.length - 1]))
303
+ out.pop();
304
+ let breaks = 0;
305
+ while (front[i] === '\n') {
306
+ breaks += 1;
307
+ i += 1;
308
+ while (front[i] === ' ' || front[i] === '\t')
309
+ i += 1;
310
+ }
311
+ out.push(breaks === 1 ? ' ' : '\n'.repeat(breaks - 1));
312
+ continue;
313
+ }
314
+ out.push(ch);
315
+ i += 1;
316
+ }
317
+ }
318
+ /** The reader's half of `ESCAPE_REPLACEMENTS`, plus the numeric escapes. */
319
+ const UNESCAPE = new Map([
320
+ ['0', '\0'],
321
+ ['a', '\x07'],
322
+ ['b', '\b'],
323
+ ['t', '\t'],
324
+ ['\t', '\t'],
325
+ ['n', '\n'],
326
+ ['v', '\v'],
327
+ ['f', '\f'],
328
+ ['r', '\r'],
329
+ ['e', '\x1b'],
330
+ [' ', ' '],
331
+ ['"', '"'],
332
+ ['/', '/'],
333
+ ['\\', '\\'],
334
+ ['N', '\x85'],
335
+ ['_', '\xa0'],
336
+ ['L', '\u2028'],
337
+ ['P', '\u2029'],
338
+ ]);
339
+ const NUMERIC_ESCAPE = { x: 2, u: 4, U: 8 };
340
+ function unescape(front, at) {
341
+ const code = front[at + 1];
342
+ if (code === undefined)
343
+ throw new FactParseError('a double-quoted scalar ends in a backslash');
344
+ const simple = UNESCAPE.get(code);
345
+ if (simple !== undefined)
346
+ return [simple, 2];
347
+ const width = NUMERIC_ESCAPE[code];
348
+ if (width === undefined)
349
+ throw new FactParseError(`unknown escape \\${code}`);
350
+ const digits = front.slice(at + 2, at + 2 + width);
351
+ if (!new RegExp(`^[0-9A-Fa-f]{${width}}$`).test(digits)) {
352
+ throw new FactParseError(`malformed \\${code} escape: ${JSON.stringify(digits)}`);
353
+ }
354
+ return [String.fromCodePoint(parseInt(digits, 16)), 2 + width];
355
+ }
356
+ /**
357
+ * A block sequence whose dashes are at column 0 — the only shape PyYAML emits here.
358
+ *
359
+ * An item resolves exactly as a mapping value does, so `links:\n- 12` is a one-element list
360
+ * holding an `int` in both runtimes. It used to raise `a sequence item must be a string`,
361
+ * which took the whole store down for a file Python read.
362
+ */
363
+ function scanBlockSequence(front, pos) {
364
+ const items = [];
365
+ let i = pos;
366
+ while (i < front.length && front[i] === '-') {
367
+ if (front[i + 1] === '\n' || i + 1 === front.length) {
368
+ // `- ` with nothing after it is a null item, which `list()` keeps and `represent_none`
369
+ // writes back as `- null`. Python has no complaint about it, so neither does this.
370
+ items.push(null);
371
+ i += 2;
372
+ continue;
373
+ }
374
+ if (front[i + 1] !== ' ') {
375
+ throw new FactParseError(`expected "- " at the start of a sequence item: ${line(front, i)}`);
376
+ }
377
+ const [value, next] = scanNode(front, i + 2);
378
+ if (Array.isArray(value)) {
379
+ // A nested block sequence is outside the emitter's language and outside this codec's.
380
+ throw new FactParseError('a nested sequence item is not in this codec\'s language');
381
+ }
382
+ items.push(value);
383
+ if (next < front.length && front[next] !== '\n') {
384
+ throw new FactParseError(`trailing content after a sequence item: ${line(front, next)}`);
385
+ }
386
+ i = next + 1;
387
+ }
388
+ return [items, i];
389
+ }
390
+ // ------------------------------------------------------------------------------ clock
391
+ /**
392
+ * `date.today().isoformat()`, which is LOCAL.
393
+ *
394
+ * `new Date().toISOString().slice(0, 10)` is UTC and is a different date for part of every
395
+ * day — measured against CPython under four zones and twelve instants, the local getters
396
+ * agreed in all twelve and the UTC slice disagreed in four. `created` and `last_recalled`
397
+ * are written with this, while shift-work's `ts` is UTC; they are not the same clock and
398
+ * the store must not borrow one for the other.
399
+ *
400
+ * The parameter exists so a caller can freeze it. Every comparison against Python is
401
+ * otherwise a race across midnight, and `_stamp` fires on every recall hit.
402
+ */
403
+ export function todayLocal(now = new Date()) {
404
+ const pad = (n, w = 2) => String(n).padStart(w, '0');
405
+ return `${pad(now.getFullYear(), 4)}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
406
+ }
@@ -0,0 +1,92 @@
1
+ /** `Path(".bantamkit") / "memory"`, kept as its components so `pyJoin` builds it. */
2
+ export declare const PROJECT_STORE: readonly string[];
3
+ export declare const CONFIG_NAME = "config.yaml";
4
+ /**
5
+ * The operator's one lever over binding, and the only one they actually hold: an MCP
6
+ * server's cwd is chosen by the HOST, not by the user, so every rule derived from cwd is a
7
+ * rule the operator cannot control. The name is spelled here and nowhere else — including in
8
+ * the tests and in the sentences `component.Memory` builds — because a magic string
9
+ * duplicated between a resolver and its message is how the two come to name different
10
+ * variables.
11
+ */
12
+ export declare const MEMORY_DIR_ENV = "BANTAMKIT_MEMORY_DIR";
13
+ /**
14
+ * Which store the walk bound to, and whether that store can answer at all.
15
+ *
16
+ * `state` is one of three, and they are three because two of them were one: "populated"
17
+ * (found, holds facts), "empty" (found, holds none — a recall against it returns nothing,
18
+ * and that nothing means "wrong filing cabinet" rather than "no match"), and "designated"
19
+ * (no store anywhere up the tree; `path` is where one WOULD go and nothing was created).
20
+ *
21
+ * `searchedFrom` is the resolved directory the walk started at, and is `null` when no walk
22
+ * ran. `origin` is "walk" or "pin": a pinned binding that still reported `searchedFrom`
23
+ * would be asserting an ascent that never happened, and a diagnostic field that lies is the
24
+ * same defect as a state field that lies.
25
+ *
26
+ * The two field names are camelCase where Python's are `fact_count` and `searched_from`.
27
+ * Nothing serialises this record — unlike `Fact`, whose snake_case names are YAML keys on
28
+ * disk — so the port follows the language it is written in.
29
+ */
30
+ export interface StoreBinding {
31
+ path: string;
32
+ state: 'populated' | 'empty' | 'designated';
33
+ factCount: number;
34
+ searchedFrom: string | null;
35
+ origin: 'walk' | 'pin';
36
+ }
37
+ /**
38
+ * Walk up from `start` (default cwd) looking for an existing `.bantamkit/memory`.
39
+ *
40
+ * Returns the nearest existing store dir; if none exists anywhere up the tree, designates
41
+ * `start/.bantamkit/memory` without creating anything. The ancestor path is fully resolved;
42
+ * the returned store path is not resolved further — a symlinked store keeps its config
43
+ * beside the symlink. `MEMORY_DIR_ENV` outranks all of it.
44
+ */
45
+ export declare function discoverProjectStore(start?: string | null): string;
46
+ /**
47
+ * The same walk, plus what is actually in the store.
48
+ *
49
+ * Read-only in both directions. An unreadable `facts/` raises `MemoryValidationError` rather
50
+ * than reporting "empty" — reporting a store you could not read as a store with nothing in
51
+ * it is the exact conflation this exists to end. A pin never yields "designated":
52
+ * `pinnedStore` has already established that the pinned directory is there, or raised.
53
+ */
54
+ export declare function resolveProjectStore(start?: string | null): StoreBinding;
55
+ /**
56
+ * How many `facts/*.md` a store holds, counted so that unreadable is never zero.
57
+ *
58
+ * A directory scan that fails RAISES and each caller decides what to do with it; nobody gets
59
+ * to be told "empty" by accident. The name filter mirrors `MemoryStore._facts`' `glob("*.md")`
60
+ * exactly — dotfiles and directories included — because a count that disagrees with the read
61
+ * is the other half of the same lie.
62
+ *
63
+ * THIS DECIDES DIFFERENTLY FROM `store._listing` AND THAT IS A KNOWN DEFECT, DEFERRED
64
+ * UPSTREAM. `_listing` keys "first run" on `os.path.lexists`, so a `facts/` that is a
65
+ * DANGLING SYMLINK is unreadable there; this catches `FileNotFoundError` and only that, so
66
+ * the same shape counts 0 and the binding answers `state="empty"`. runtime-py pins the
67
+ * disagreement with an unconditional `xfail(strict=True)` on
68
+ * `test_a_dangling_facts_symlink_is_unreadable_to_both_layers`, and job37/W5 measured what it
69
+ * costs: on a read-only GRANT layer `Memory.recall` then says "nothing is saved in any layer
70
+ * bound here" where the same store at `0o311` correctly says "could not be read".
71
+ *
72
+ * DO NOT FIX IT HERE. A port that quietly does the right thing where the reference does the
73
+ * wrong thing is a port that disagrees with production. The conformance case
74
+ * `count_facts: a dangling facts/ symlink is 0 — THE DEFERRED DEFECT` compares this against
75
+ * the running Python, so the day `count_facts` grows the check, that case goes red and
76
+ * somebody has to come here.
77
+ *
78
+ * On Windows the shape splits in two and only one half is this one: a FILE reparse point
79
+ * raises `NotADirectoryError` (errno 20, winerror 267) and the layers AGREE, while a
80
+ * DIRECTORY reparse point raises `FileNotFoundError` (errno 2, winerror 3) and they do not.
81
+ * On POSIX a symlink has no type and both collapse to the second. NOT MEASURED on Windows.
82
+ */
83
+ export declare function countFacts(root: string): number;
84
+ /**
85
+ * Read extra read-only store paths from the config beside the project store.
86
+ *
87
+ * Missing config -> no grants. A config that exists but is wrong — unparsable, not a
88
+ * mapping, non-list/non-str `extra_stores`, or a listed path that is not an existing
89
+ * directory — raises `MemoryValidationError`: a grant you wrote that is wrong is a mistake to
90
+ * surface at construction, not silently drop. Returned grant paths are fully resolved.
91
+ */
92
+ export declare function loadGrants(projectStore: string): string[];