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,497 @@
1
+ /**
2
+ * Agent-facing memory component: skill in the prompt, correctness in the store.
3
+ *
4
+ * A port of `runtime-py/src/bantamkit/memory/component.py`. Roughly half of it is SENTENCES,
5
+ * and the value of a sentence here is that it is exactly the one the Python server produces:
6
+ * these are what an agent acts on when its memory appears empty, and job37 exists because
7
+ * "empty" and "unreadable" were once the same answer. A one-word difference is a defect.
8
+ *
9
+ * WHAT IS DELIBERATELY MISSING. `setup()` and `batch()`, the only two consumers of `Agent`.
10
+ * The prep probe traced a real stdio server through the tools and both resource templates:
11
+ * neither is reachable from the MCP surface. They are absent rather than stubbed so that
12
+ * nobody reads a stub and believes the batch scope exists here. If a tool ever calls one,
13
+ * that is a refutation of the trace and wants reporting, not a quiet addition.
14
+ *
15
+ * WHAT USED TO BE MISSING AND NO LONGER IS. `compact()` was on that list, with the note that
16
+ * its store half was not ported either. The store half landed first (`MemoryStore.compact`,
17
+ * the operator CLI's `compact` verb), and job42 registered `memory_compact` as the ninth MCP
18
+ * tool on the reference — the model's half of compaction, the remedy the refused-budget
19
+ * reply now names. So `compact()` / `compactOutcome()` are ported below, sentence for
20
+ * sentence, and reach ONLY `this.store` (the writable project layer): `layered` puts grants
21
+ * and the profile store in `_layers`, never in `this.store`, so a compaction cannot see them.
22
+ *
23
+ * BOTH REGISTRATIONS ARE REAL AND BOTH ARE PORTED. `.mcp.json` and the user-scope config
24
+ * invoke the server with no `--store`, so PRODUCTION RUNS `Memory.layered` and its recall
25
+ * lines carry a `[project] ` tag that the `--store` form never emits. A port validated
26
+ * against only one of them ships a string the deployment never produces.
27
+ */
28
+ import { BantamError } from '../errors.js';
29
+ import { pyStrip } from './factfile.js';
30
+ import { countFacts, loadGrants, MEMORY_DIR_ENV, PROJECT_STORE, resolveProjectStore, } from './layers.js';
31
+ import { pyHome, pyJoin, pyName, pyParent, pyResolve, PyOSError, PyUnicodeDecodeError } from './pyfs.js';
32
+ import { DEFAULT_INDEX_BUDGET, MemoryBudgetExceeded, MemoryStore, MemoryValidationError, pyHashKey, pyText, } from './store.js';
33
+ /**
34
+ * The model's spelling adapted to the store's contract: lowercase, `_`/space -> `-`.
35
+ *
36
+ * The store's pattern does not move; the component bends the argument to it. Measured cause:
37
+ * 5 of the 7b probe runs burned their whole turn budget retrying `memory_save` with the
38
+ * snake_case names the model invents. Non-strings are handed on untouched so that store
39
+ * validation still speaks.
40
+ */
41
+ export function normalizeName(name) {
42
+ if (typeof name !== 'string')
43
+ return name;
44
+ // `str.strip()` is NOT `String.trim()` — they disagree on six codepoints, and `pyStrip`
45
+ // is N2's answer to that. `str.lower()` IS `String.toLowerCase()`, including the one place
46
+ // it was expected not to be: CPython applies the Final_Sigma condition too, so `ΑΣ`
47
+ // lowercases to `ας` in both runtimes and a per-codepoint fold would have been the thing
48
+ // that differed. MEASURED — the case `normalize_name over the strip and lowercase
49
+ // boundary` compares thirteen spellings, `ΣΟΦΟΣ` among them.
50
+ return pyStrip(name).toLowerCase().replace(/_/g, '-').replace(/ /g, '-');
51
+ }
52
+ /**
53
+ * The last layer `layered` appends, named once so other code can recognise it.
54
+ *
55
+ * It was spelled inline and nowhere else, which is how it came to be prescribed as a place
56
+ * to start a project store: nothing that wrote the advice could see that the directory it
57
+ * was recommending is also the machine-wide layer.
58
+ */
59
+ export function profileStore() {
60
+ return pyJoin(pyHome(), ...PROJECT_STORE);
61
+ }
62
+ /** The name a layer answers under: the PROJECT directory, not the store directory. */
63
+ export function layerLabel(root) {
64
+ if (pyName(pyParent(root)) === '.bantamkit')
65
+ return pyName(pyParent(pyParent(root)));
66
+ return pyName(root);
67
+ }
68
+ export class Memory {
69
+ store;
70
+ k;
71
+ layers;
72
+ showLayers = false;
73
+ /**
74
+ * How this store came to be the store, captured once. It cannot be re-derived later: the
75
+ * constructor above has already made the directory, so a store that was only DESIGNATED a
76
+ * moment ago is indistinguishable on disk from one that was found empty. `null` means the
77
+ * caller named the path outright and no resolution happened to report.
78
+ */
79
+ binding;
80
+ constructor(store, options = {}, binding = null) {
81
+ const k = options.k ?? 3;
82
+ this.store = new MemoryStore(store, {
83
+ indexBudget: options.indexBudget ?? DEFAULT_INDEX_BUDGET,
84
+ k,
85
+ ...(options.today ? { today: options.today } : {}),
86
+ });
87
+ this.k = k;
88
+ this.layers = [['project', this.store, true]];
89
+ this.binding = binding;
90
+ }
91
+ /**
92
+ * Project store (resolved) + configured read-only grants + profile store.
93
+ *
94
+ * `resolveProjectStore` rather than `discoverProjectStore`: the path both return is the
95
+ * same path, but only the binding carries WHY it is that path and whether it holds
96
+ * anything — and `recall` cannot recover either fact afterwards, because constructing the
97
+ * store creates the directory.
98
+ */
99
+ static layered(start, options = {}) {
100
+ const binding = resolveProjectStore(start);
101
+ const mem = new Memory(binding.path, options, binding);
102
+ mem.showLayers = true;
103
+ const k = options.k ?? 3;
104
+ const today = options.today;
105
+ for (const grant of loadGrants(binding.path)) {
106
+ mem.layers.push([
107
+ `extra:${layerLabel(grant)}`,
108
+ new MemoryStore(grant, { k, create: false, ...(today ? { today } : {}) }),
109
+ false,
110
+ ]);
111
+ }
112
+ mem.layers.push([
113
+ 'profile',
114
+ new MemoryStore(profileStore(), { k, create: false, ...(today ? { today } : {}) }),
115
+ false,
116
+ ]);
117
+ return mem;
118
+ }
119
+ /** The labels, in order. `_layers` is private in the reference too; this is for the tests. */
120
+ layerLabels() {
121
+ return this.layers.map(([label]) => label);
122
+ }
123
+ /** The reply the model reads. Unchanged, and still the tool's advertised return. */
124
+ save(type, name, description, body, links = null) {
125
+ return this.saveOutcome(type, name, description, body, links).reply;
126
+ }
127
+ /**
128
+ * `save`, with the branch it took carried out alongside the sentence it wrote.
129
+ *
130
+ * Same body, same order, same four exits: the only change is that each exit now NAMES
131
+ * itself. That is the whole of the narrow seam the MCP server needs to log an outcome
132
+ * without matching text, and it moves no wording and no return type — `save()` above
133
+ * still answers `string`, and `assets/tools/memory_save.json` still advertises `str`.
134
+ */
135
+ saveOutcome(type, name, description, body, links = null) {
136
+ const normalized = normalizeName(name);
137
+ const normalizedLinks = (links ?? []).map((link) => normalizeName(link));
138
+ let result;
139
+ try {
140
+ result = this.store.save(type, normalized, description, body, normalizedLinks);
141
+ }
142
+ catch (e) {
143
+ if (e instanceof MemoryValidationError) {
144
+ return { reply: `error: ${e.message}`, status: 'refused-validation' };
145
+ }
146
+ if (e instanceof MemoryBudgetExceeded) {
147
+ // Not an argument problem: retrying the same call cannot fit the index. Two
148
+ // audiences, two remedies. The store's own text names `compact()`; this reply goes
149
+ // to the MODEL, and since job42 the model HAS a compaction tool — `memory_compact`
150
+ // (`docs/memory.md`: the user ruled compaction automatic on 2026-08-24; the hook
151
+ // covers the 90 % band, the tool covers this refusal) — so the last sentence names
152
+ // it. Everything before that sentence is byte-identical to what it was when
153
+ // compaction was operator-only.
154
+ return {
155
+ reply: `error: ${e.message}. Nothing was saved and retrying will not help — shorten ` +
156
+ 'the description, or save under the name of an existing memory to replace it. ' +
157
+ 'Or call `memory_compact` to archive the stalest facts and free room — nothing is deleted.',
158
+ status: 'refused-budget',
159
+ };
160
+ }
161
+ throw e;
162
+ }
163
+ if (result.status === 'duplicate') {
164
+ return {
165
+ reply: `similar memory '${result.similar}' already exists — save under that SAME ` +
166
+ 'name to update it, or skip. Do not rename to force a copy.',
167
+ status: 'duplicate',
168
+ };
169
+ }
170
+ return { reply: `saved '${result.name}'`, status: 'saved' };
171
+ }
172
+ /**
173
+ * `k` is the model asking for MORE, never for less than the store's default.
174
+ *
175
+ * Measured cause (RB-P1, qwen2.5:14b-instruct): 57 of 60 `memory_recall` calls across 12
176
+ * seeded runs sent `k: 1`, and honouring it truncated recall to the single best-scoring
177
+ * fact. The floor is the operator's configured default, not a constant, so a consumer who
178
+ * really wants top-1 says so once at construction.
179
+ */
180
+ recall(query, k = null) {
181
+ return this.recallOutcome(query, k).reply;
182
+ }
183
+ /**
184
+ * `recall`, carrying the walk it performed as numbers rather than as prose.
185
+ *
186
+ * The reply is byte-for-byte what `recall` has always returned; every field beside it is
187
+ * counted here because it CANNOT be recovered afterwards. How far down the layer list the
188
+ * budget got, how many of the layers reached were readable, how many facts those layers
189
+ * held against how many came back, and which of the three empty verdicts fired — all of
190
+ * it is gone by the time the string exists, and the host's log records only that a
191
+ * `memory_recall` "completed successfully".
192
+ *
193
+ * `candidates` counts `facts/*.md` in the layers actually READ, via `countFacts` — the
194
+ * same filter `MemoryStore.recall` scores over, so the ratio to `returned` is between two
195
+ * counts of one population and not between two different ones. A layer whose directory
196
+ * refuses to list contributes nothing and raises the `unreadable` count instead of being
197
+ * scored as empty; that distinction is the whole subject of `nothingToReport` below and
198
+ * must not be undone here.
199
+ */
200
+ recallOutcome(query, k = null) {
201
+ const budget = k === null ? this.k : Math.max(k, this.k);
202
+ const picked = [];
203
+ const seen = new Set();
204
+ let reached = 0;
205
+ let unreadable = 0;
206
+ let candidates = 0;
207
+ for (const [label, store, writable] of this.layers) {
208
+ if (picked.length >= budget)
209
+ break; // budget spent: later layers are never even read
210
+ reached += 1;
211
+ let facts;
212
+ try {
213
+ facts = store.recall(query, budget, writable);
214
+ }
215
+ catch (e) {
216
+ if (!(e instanceof BantamError || e instanceof PyOSError || e instanceof PyUnicodeDecodeError)) {
217
+ throw e;
218
+ }
219
+ if (writable)
220
+ throw e; // the project layer failing is a real error, as in v1
221
+ unreadable += 1;
222
+ continue; // a corrupt grant/profile layer must not take down recall
223
+ }
224
+ try {
225
+ candidates += countFacts(store.root);
226
+ }
227
+ catch (e) {
228
+ // It scored a moment ago, so it is readable; a race that unlists it now must not
229
+ // turn a successful recall into a failed one for a log field.
230
+ if (!(e instanceof PyOSError))
231
+ throw e;
232
+ }
233
+ for (const fact of facts) {
234
+ // `if fact.name in seen` over a Python `set`, which is `hash`/`==` and not `str` —
235
+ // see `store.pyHashKey` for why a hand-edited numeric name makes the two differ.
236
+ const key = pyHashKey(fact.name);
237
+ if (seen.has(key) || picked.length >= budget)
238
+ continue;
239
+ seen.add(key);
240
+ picked.push([label, fact]);
241
+ }
242
+ }
243
+ const counts = {
244
+ budget,
245
+ layers: this.layers.length,
246
+ reached,
247
+ returned: picked.length,
248
+ candidates,
249
+ unreadable,
250
+ };
251
+ if (picked.length === 0) {
252
+ const [status, reply] = this.nothingToReport();
253
+ return { reply, status, source: null, ...counts };
254
+ }
255
+ return {
256
+ reply: picked.map(([label, fact]) => this.format(label, fact)).join('\n\n'),
257
+ status: 'answered',
258
+ source: picked[0][0].split(':', 1)[0],
259
+ ...counts,
260
+ };
261
+ }
262
+ /**
263
+ * Archive the stalest facts until a fact as large as the biggest one kept will fit.
264
+ *
265
+ * The reply is the ONLY place the model learns what left the index: `archive/` is a
266
+ * directory the model calling this will never look in — if the return value does not carry
267
+ * what was lost, nothing does. Ported from `Memory.compact` in `component.py`, which is
268
+ * written for a model; the tool's advertised return is this string, unchanged.
269
+ */
270
+ compact(reserve = null) {
271
+ return this.compactOutcome(reserve).reply;
272
+ }
273
+ /**
274
+ * `compact`, with the decision it took carried beside the sentence it wrote.
275
+ *
276
+ * `this.store` is the writable project layer and nothing else: `layered` appends grants
277
+ * and the profile store to `_layers` only, never to `this.store`, so this cannot reach
278
+ * either (`test/layers.test.mjs` pins a profile fact through a compaction). The reply is
279
+ * byte-for-byte what `compact` has always returned on the reference: Python `f"{int}"`
280
+ * is a plain decimal, and `result.archive_dir` is `str(Path)`, which `archiveDir` already
281
+ * is — `MemoryStore.compact` builds it with `pyJoin`.
282
+ */
283
+ compactOutcome(reserve = null) {
284
+ const result = this.store.compact(reserve);
285
+ let reply;
286
+ let status;
287
+ if (result.archived.length === 0) {
288
+ reply =
289
+ `nothing archived: the index is ${result.indexAfter} bytes against a ` +
290
+ `${result.budget}-byte budget, already at or under the ` +
291
+ `${result.target}-byte compaction target.`;
292
+ status = 'nothing-archived';
293
+ }
294
+ else {
295
+ const lost = result.archived
296
+ .map((fact) => `- ${fact.name} (${fact.type}) — ${fact.description}`)
297
+ .join('\n');
298
+ reply =
299
+ `archived ${result.archived.length} memories; the index went from ` +
300
+ `${result.indexBefore} to ${result.indexAfter} bytes against a ` +
301
+ `${result.budget}-byte budget, leaving ${result.headroom} bytes of headroom. ` +
302
+ `These moved to ${result.archiveDir} and are NOT deleted — they can be ` +
303
+ `restored by name:\n${lost}`;
304
+ status = 'archived';
305
+ }
306
+ return {
307
+ reply,
308
+ status,
309
+ archived: result.archived.length,
310
+ indexBefore: result.indexBefore,
311
+ indexAfter: result.indexAfter,
312
+ budget: result.budget,
313
+ };
314
+ }
315
+ /**
316
+ * `(index bytes, budget)` for the writable project store; bytes may be `null`.
317
+ *
318
+ * Measured exactly the way `MemoryStore.checkIndexBudget` measures — the UTF-8 length of
319
+ * `indexText()` — so the number a log records and the number a refusal was decided
320
+ * against are one number and not two that can drift apart.
321
+ *
322
+ * `null` means the store could not be read, and it is never 0: an unreadable store
323
+ * reporting an empty index is the same slander this module spends `nothingToReport`
324
+ * refusing to commit, and it would read as infinite headroom at exactly the moment there
325
+ * is none. The cost is a full parse of `facts/`, which is why nothing calls this on the
326
+ * tool path unless a log is actually enabled.
327
+ */
328
+ indexAccounting() {
329
+ try {
330
+ return [Buffer.byteLength(this.store.indexText(), 'utf8'), this.store.indexBudget];
331
+ }
332
+ catch (e) {
333
+ if (e instanceof BantamError || e instanceof PyOSError || e instanceof PyUnicodeDecodeError) {
334
+ return [null, this.store.indexBudget];
335
+ }
336
+ throw e;
337
+ }
338
+ }
339
+ // ---- the empty answer, split into the answers it was hiding ----------------
340
+ /**
341
+ * An empty recall is at least three different situations; say which one.
342
+ *
343
+ * Answers `[status, text]`. The status is the SAME BRANCH that picks the sentence, handed
344
+ * out rather than re-derived: a caller that needs to tell the three empties apart — the
345
+ * MCP event log does — would otherwise have to match the prose this comment exists to say
346
+ * is allowed to improve.
347
+ *
348
+ * Until job37 all of them returned "no memories matched. Try different words", which tells
349
+ * a person to rephrase a question against a filing cabinet that may not exist.
350
+ *
351
+ * The verdict is keyed on what could have been read RIGHT NOW, counted the way
352
+ * `MemoryStore._facts` reads, not on the construction-time binding — a store that was empty
353
+ * at construction and has been saved to since really can answer. The DIAGNOSIS is keyed on
354
+ * the binding, because "designated" stops being visible on disk the moment `MemoryStore`
355
+ * creates the directory.
356
+ */
357
+ nothingToReport() {
358
+ let status;
359
+ let verdict;
360
+ const unreadable = this.unreadableLayers();
361
+ if (this.searchableFacts()) {
362
+ status = 'empty-no-match';
363
+ verdict = 'no memories matched. Try different words, or proceed without.';
364
+ }
365
+ else if (unreadable.length > 0) {
366
+ // A layer nobody could open is not a layer that held nothing. Saying "nothing is
367
+ // saved" here is the same slander as calling an unreadable store empty, one call out.
368
+ status = 'empty-unreadable-layer';
369
+ verdict =
370
+ 'no memories matched, and that is not evidence there are none: ' +
371
+ `${unreadable.join(', ')} could not be read.`;
372
+ }
373
+ else {
374
+ status = 'empty-nothing-saved';
375
+ verdict = 'no memories to search: nothing is saved in any layer bound here.';
376
+ }
377
+ const diagnosis = this.bindingDiagnosis();
378
+ return [status, diagnosis ? `${verdict} ${diagnosis}` : verdict];
379
+ }
380
+ /**
381
+ * How many facts a store holds, or `null` when that cannot be established.
382
+ *
383
+ * A store that cannot be listed answers `null` and never 0: "I could not read it" and "it
384
+ * holds nothing" are the two answers this whole unit exists to keep apart.
385
+ */
386
+ static factCount(root) {
387
+ try {
388
+ return countFacts(root);
389
+ }
390
+ catch (e) {
391
+ if (e instanceof PyOSError)
392
+ return null;
393
+ throw e;
394
+ }
395
+ }
396
+ /** Roots of the layers that could not be listed at all — never counted as 0. */
397
+ unreadableLayers() {
398
+ return this.layers
399
+ .filter(([, store]) => Memory.factCount(store.root) === null)
400
+ .map(([, store]) => store.root);
401
+ }
402
+ /**
403
+ * Facts the layers actually consulted could have matched, counted live.
404
+ *
405
+ * An unreadable layer contributes nothing rather than counting as empty: `recall` already
406
+ * skips it, and it must not become evidence that there was nothing to read.
407
+ */
408
+ searchableFacts() {
409
+ let total = 0;
410
+ for (const [, store] of this.layers)
411
+ total += Memory.factCount(store.root) ?? 0;
412
+ return total;
413
+ }
414
+ /**
415
+ * Is this store also the machine-wide profile layer `layered` appends?
416
+ *
417
+ * Resolved on both sides because the walk returns its store path unresolved while
418
+ * `Path.home()` may itself be a symlink; a comparison that missed on a symlink would put
419
+ * the harmful advice back exactly where it does damage.
420
+ */
421
+ static isProfileStore(root) {
422
+ try {
423
+ return pyResolve(root) === pyResolve(profileStore());
424
+ }
425
+ catch {
426
+ return false; // `except (OSError, RuntimeError)` — a symlink loop lands here too
427
+ }
428
+ }
429
+ /**
430
+ * Did the walk actually leave the directory it started in?
431
+ *
432
+ * `origin === "walk"` says a walk ran, not that it climbed. A walk that terminates at step
433
+ * zero has bound `searchedFrom`'s OWN store, and the clause that reads well for an ancestor
434
+ * binding — "which has no store of its own" — is then simply false about the commonest case
435
+ * there is: a project whose store exists and is empty.
436
+ */
437
+ walkAscended() {
438
+ const binding = this.binding;
439
+ if (binding === null || binding.searchedFrom === null)
440
+ return false;
441
+ return binding.path !== pyJoin(binding.searchedFrom, ...PROJECT_STORE);
442
+ }
443
+ /**
444
+ * Why THIS store, when the project layer is the one that cannot answer.
445
+ *
446
+ * Silent while the project store holds facts: that is the case that already works, and it
447
+ * keeps its exact wording. The remedy names `MEMORY_DIR_ENV` from `layers`, never a
448
+ * literal, so the message and the resolver cannot drift onto two different variables.
449
+ */
450
+ bindingDiagnosis() {
451
+ if (Memory.factCount(this.store.root) !== 0)
452
+ return ''; // holds facts, or unreadable
453
+ const root = this.store.root;
454
+ const binding = this.binding;
455
+ let where;
456
+ if (binding === null) {
457
+ where = `The project store ${root} is empty.`;
458
+ }
459
+ else if (binding.state === 'designated') {
460
+ where =
461
+ `No memory store existed at or above ${binding.searchedFrom}, so the ` +
462
+ `empty ${root} was created for this session.`;
463
+ }
464
+ else if (binding.origin === 'pin') {
465
+ where = `The project store ${root} is empty; ${MEMORY_DIR_ENV} pinned it.`;
466
+ }
467
+ else if (this.walkAscended()) {
468
+ where =
469
+ `The project store ${root} is empty; it was bound by walking up from ` +
470
+ `${binding.searchedFrom}, which has no store of its own.`;
471
+ }
472
+ else {
473
+ where =
474
+ `The project store ${root} is empty; it is ${binding.searchedFrom}'s own ` +
475
+ 'store, bound without the walk leaving that directory.';
476
+ }
477
+ const remedy = 'That is a binding, not a search result — if your facts are in another ' +
478
+ `store, set ${MEMORY_DIR_ENV} to its absolute path and restart`;
479
+ if (Memory.isProfileStore(root)) {
480
+ // The store bound here IS the profile layer, which every project with no store of its
481
+ // own also binds. "Save a memory to start this one" would therefore start a store that
482
+ // answers for all of them.
483
+ return (`${where} ${remedy}. Do NOT save here to start it: ${root} is also the ` +
484
+ 'profile layer, so a memory saved there answers for every project on ' +
485
+ 'this machine that has no store of its own — give this project a store ' +
486
+ 'of its own instead.');
487
+ }
488
+ return `${where} ${remedy}; otherwise save a memory to start this one.`;
489
+ }
490
+ format(label, fact) {
491
+ // `pyText` for the three interpolated fields, not template-literal coercion: a
492
+ // hand-edited `name:` with nothing after it is `None` in Python's sentence and would be
493
+ // `null` in this one. The store already reproduces it in the index line and on disk.
494
+ const tag = this.showLayers ? `[${label}] ` : '';
495
+ return `${tag}[${pyText(fact.name)}] (${pyText(fact.type)}) ${pyText(fact.description)}\n${fact.body}`;
496
+ }
497
+ }
@@ -0,0 +1,120 @@
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 { PyScalar } from './pyyaml.js';
17
+ export declare class FactParseError extends Error {
18
+ }
19
+ /**
20
+ * What one frontmatter value can be.
21
+ *
22
+ * `PyScalar` is not a widening for its own sake: `_facts` in the reference puts
23
+ * `yaml.safe_load`'s answer into the dataclass with no type check, so a hand-edited
24
+ * `description: 2026` really does give the Python store an `int` in a `str` field and the
25
+ * store really does keep working. The three-way is the whole ruling — see `constructPlain`
26
+ * in `pyyaml.ts` for what lands in which arm, and `store.pyText` for how it is rendered.
27
+ */
28
+ export type FactValue = string | PyScalar | null;
29
+ /** `store.Fact`. `created` is `null` only before a Fact's first write. */
30
+ export interface Fact {
31
+ name: FactValue;
32
+ description: FactValue;
33
+ type: FactValue;
34
+ body: string;
35
+ links: FactValue[];
36
+ last_recalled: FactValue;
37
+ created?: FactValue;
38
+ }
39
+ /** The frontmatter as it comes off disk — exactly the keys the file carries. */
40
+ export type FactMeta = Record<string, FactValue | FactValue[]>;
41
+ export declare function pyStrip(text: string): string;
42
+ /**
43
+ * The bytes of a fact file as Python's `Path.read_text(encoding="utf-8")` sees them.
44
+ *
45
+ * Text mode applies universal-newline translation: `\r\n` and a lone `\r` both become
46
+ * `\n`. `readFileSync(p, 'utf8')` does not. Measured in the prep probe: a CRLF fact file
47
+ * splits into 3 frontmatter parts in Python and 1 in Node, so without this the Node store
48
+ * calls a file malformed that the Python store reads fine.
49
+ *
50
+ * The WRITE side deliberately does not mirror Python. See `formatFact`.
51
+ */
52
+ export declare function decodeFactBytes(bytes: Uint8Array): string;
53
+ /**
54
+ * The exact bytes `_write_fact` writes, as a string.
55
+ *
56
+ * RULING — NEWLINES ON THE WAY OUT. Python opens the file in text mode with
57
+ * `newline=None`, which translates `\n` to `os.linesep`, so on Windows every fact file
58
+ * Python writes is CRLF while every byte count the store computes (the index budget, the
59
+ * digests) is LF. This port writes LF on every platform and does not reproduce that.
60
+ * Reasons, in order: the CRLF is incidental to `newline=None` rather than intended; it
61
+ * already disagrees with the store's own arithmetic; and reproducing it would make the
62
+ * same Fact emit different bytes on two machines, which is precisely the property this
63
+ * port exists to hold. Reading stays universal-newline on both sides, so a store written
64
+ * by either runtime is read identically by both. On macOS and Linux `os.linesep` is `\n`
65
+ * and there is no divergence at all — the conformance suite measures byte identity there.
66
+ */
67
+ export declare function formatFact(fact: Fact): string;
68
+ /**
69
+ * `text.split("---\n", 2)` plus `yaml.safe_load(front)`, and the `body.strip()` after it.
70
+ *
71
+ * Python unpacks the split into three names, so fewer than three parts is a `ValueError`
72
+ * that `_facts` turns into `MemoryValidationError`. Here it is a `FactParseError`; the
73
+ * sentence the model finally sees is the store's to phrase, not this module's.
74
+ */
75
+ export declare function parseFactText(text: string): {
76
+ meta: FactMeta;
77
+ body: string;
78
+ };
79
+ /**
80
+ * `str.split(sep, maxsplit)` — JS's `String.split` has no maxsplit and would over-split.
81
+ *
82
+ * Exported because `store._facts` does this split ITSELF in the reference, and needs the
83
+ * part count to build Python's own `ValueError` text for a truncated file. Two spellings of
84
+ * one split is how the two would drift.
85
+ */
86
+ export declare function pySplit(text: string, sep: string, maxsplit: number): string[];
87
+ /**
88
+ * The frontmatter mapping, for the language `safeDumpMapping` emits and no more.
89
+ *
90
+ * RULING — SCOPE. This is not a YAML parser and does not try to be. It accepts a
91
+ * root-level block mapping of plain keys whose values are plain, single-quoted or
92
+ * double-quoted scalars (wrapped or not), `[]`, or a block sequence of such scalars at
93
+ * column 0 — which is exactly what PyYAML emits for a `Fact` at these dump settings.
94
+ * Anything else RAISES instead of guessing. The reason is the failure mode this whole unit
95
+ * exists for: a parser that silently produces something plausible for input it does not
96
+ * understand turns a hand-edited fact file into a wrong `Fact`, and nothing anywhere
97
+ * reports it. A raise is visible; `_facts` already has a sentence for it.
98
+ *
99
+ * KNOWN NON-IDENTICAL BEHAVIOUR, registered rather than hidden: for input outside that
100
+ * language, Python and Node both fail but with different message text (PyYAML's scanner
101
+ * error vs this module's). `_facts` interpolates that text into
102
+ * `malformed fact file {name}: {e}`, so the string a model sees for a corrupt file is not
103
+ * byte-identical across runtimes. Making it identical would mean porting PyYAML's scanner
104
+ * diagnostics, which is a far larger surface than the codec, and no test in
105
+ * `runtime-py/tests` pins those strings. Owner: whichever unit takes `_facts`.
106
+ */
107
+ export declare function parseFrontmatter(front: string): FactMeta;
108
+ /**
109
+ * `date.today().isoformat()`, which is LOCAL.
110
+ *
111
+ * `new Date().toISOString().slice(0, 10)` is UTC and is a different date for part of every
112
+ * day — measured against CPython under four zones and twelve instants, the local getters
113
+ * agreed in all twelve and the UTC slice disagreed in four. `created` and `last_recalled`
114
+ * are written with this, while shift-work's `ts` is UTC; they are not the same clock and
115
+ * the store must not borrow one for the other.
116
+ *
117
+ * The parameter exists so a caller can freeze it. Every comparison against Python is
118
+ * otherwise a race across midnight, and `_stamp` fires on every recall hit.
119
+ */
120
+ export declare function todayLocal(now?: Date): string;