bantamkit-mcp 0.29.1 → 0.30.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.
@@ -22,7 +22,7 @@
22
22
  * indistinguishable from a typo: a manifest renamed or a surface dropped would silently
23
23
  * shrink the served set, and the field would be a comment.
24
24
  */
25
- import { existsSync, readFileSync } from 'node:fs';
25
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
26
26
  import { dirname, join } from 'node:path';
27
27
  import { fileURLToPath } from 'node:url';
28
28
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
@@ -31,6 +31,7 @@ import { AssetNotFound, assetsRoot, loadSkill, loadToolAsset } from '../assets.j
31
31
  import { BantamError } from '../errors.js';
32
32
  import { bantamkitReadUnknownPart, documentError, documentManifest, documentOffsetPastEnd, documentPage, schemaError, toolFailed, } from '../contract.js';
33
33
  import * as docread from '../docread.js';
34
+ import * as repomap from '../repomap.js';
34
35
  import * as skillaudit from '../skillaudit.js';
35
36
  import { EventLog } from '../eventlog.js';
36
37
  import { PyOSError, asPyOSError, pyReadText } from '../memory/pyfs.js';
@@ -41,13 +42,14 @@ import { ARG_MODELS, PyValidationFailure, validateArguments } from './pyargs.js'
41
42
  import { sdkJson } from './sdkjson.js';
42
43
  import { degradedConditions, degradedNotice, SERVED_PROMPTS, SERVED_RESOURCE_TEMPLATES, statusReport, STATUS_NAME, STATUS_PROMPT_DESCRIPTION, STATUS_PROMPT_TAIL, STATUS_PROMPT_TITLE, } from './status.js';
43
44
  /**
44
- * The eleven, in the order `build_server` lists them — which is the order `tools/list` emits.
45
+ * The thirteen, in the order `build_server` lists them — which is the order `tools/list` emits.
45
46
  *
46
47
  * `bantamkit_status` went LAST rather than first, `memory_compact` after it rather than
47
- * beside `memory_save` where a reader would look for it, `bantamkit_read` after that and
48
- * `skill_audit` after that, for the same reason the reference appends all four: registration
49
- * order IS the served order, and appending is the only edit that leaves the other ten where
50
- * every existing declaration says they are.
48
+ * beside `memory_save` where a reader would look for it, `bantamkit_read` after that,
49
+ * `skill_audit` after that, `memory_dream` after that and `repo_map` after that, for the
50
+ * same reason the reference appends all six: registration order IS the served order, and
51
+ * appending is the only edit that leaves the other twelve where every existing declaration
52
+ * says they are.
51
53
  */
52
54
  export const MCP_TOOLS = [
53
55
  'memory_save',
@@ -61,6 +63,8 @@ export const MCP_TOOLS = [
61
63
  'memory_compact',
62
64
  'bantamkit_read',
63
65
  'skill_audit',
66
+ 'memory_dream',
67
+ 'repo_map',
64
68
  ];
65
69
  /**
66
70
  * The advertisement for one tool, out of its manifest, with the surface checked.
@@ -147,6 +151,65 @@ const asText = (value) => (value && value.t === 'str' ? value.v : '');
147
151
  function asInt(value) {
148
152
  return value && value.t === 'int' ? Number(value.v) : null;
149
153
  }
154
+ /**
155
+ * The errno family `Path.exists()` and `Path.is_dir()` answer `False` for instead of
156
+ * raising — CPython `pathlib`'s `_IGNORED_ERRNOS`, spelled as Node's `code` strings.
157
+ *
158
+ * Not a guess and not a superset: anything OUTSIDE this set makes both predicates raise on
159
+ * the reference, so it must reach `recordRaise` here too. A `statSync` wrapped in a bare
160
+ * `catch { }` would turn a permission failure into "no such directory", which is a
161
+ * different sentence about a different problem.
162
+ */
163
+ const EXISTS_IGNORED = new Set(['ENOENT', 'ENOTDIR', 'EBADF', 'ELOOP']);
164
+ /**
165
+ * The last paragraph of every `repo_map` reply, refusal excepted. FIXED AND MANDATORY, and
166
+ * byte-identical to the reference's `REPO_MAP_TAIL`.
167
+ *
168
+ * Roadmap row 10's build gate was "build only after #4 shows discovery tokens dominate",
169
+ * and #4 REFUTED it: discovery is 0.114 % of real prompt tokens because 97.8 % of the bill
170
+ * is `cache_read`. The feature ships on an explicit ruling to build it anyway, as a
171
+ * PRECISION feature. A surface that let a caller believe the map is a token saving would
172
+ * say the one thing the measurement forbids, so the refutation travels with every answer
173
+ * rather than living only in a doc nobody reads at call time.
174
+ *
175
+ * The second sentence is the budget's unit, for the same reason: `DEFAULT_BUDGET = 4000` is
176
+ * "1 K tokens" only at the char/4 convention, whose error bar is unmeasured because
177
+ * measuring it needs the tokenizer the pure-node ruling forbids.
178
+ */
179
+ export const REPO_MAP_TAIL = 'This is a precision pass, not a token saving: this feature\'s build gate was REFUTED ' +
180
+ 'by measurement — discovery is 0.114% of real prompt tokens, because 97.8% of the ' +
181
+ 'bill is cache_read — so a map does not make a session cheaper. What it buys is the ' +
182
+ 'right file found sooner.\n' +
183
+ 'The budget above is UTF-8 BYTES of listing, not tokens: neither runtime carries a ' +
184
+ 'model tokenizer and this tool will not pretend to one.';
185
+ /** What a listing says when there is nothing to list. The reference's `REPO_MAP_EMPTY`. */
186
+ export const REPO_MAP_EMPTY = '(nothing listed: no file under this root scanned into a definition)';
187
+ /**
188
+ * The `repo_map` tool's prose, byte for byte, from the structured result.
189
+ *
190
+ * Split out of the handler for the same reason the reference splits `repo_map_reply` out of
191
+ * its own: one shape to reproduce rather than a format string buried in a `case`, and the
192
+ * `repomap` conformance suite compares the rendered reply without standing up a server. No
193
+ * float is ever rendered here — `repomap`'s trap (8); every number on the header line is an
194
+ * integer, and `String(1.0)` is `'1'` where `repr(1.0)` is `'1.0'`.
195
+ */
196
+ export function repoMapReply(result) {
197
+ const focus = result.focus.length > 0
198
+ ? result.focus.join(', ')
199
+ : '(none) — plain centrality over the whole tree';
200
+ const head = `repo map: ${result.nodes} files scanned, ${result.definitions} definitions, ` +
201
+ `${result.edges} edges.\n` +
202
+ `focus: ${focus}\n` +
203
+ `budget: ${result.budget} UTF-8 bytes; listing ${result.listingBytes} bytes; ` +
204
+ `rendered ${result.filesRendered} files, ` +
205
+ `${result.definitionsRendered} definitions.`;
206
+ const body = result.text !== '' ? result.text : REPO_MAP_EMPTY;
207
+ return `${head}\n\n${body}\n\n${REPO_MAP_TAIL}`;
208
+ }
209
+ /** `bool | None` after validation: the tagged bool, or `null` for the default. */
210
+ function asBool(value) {
211
+ return value && value.t === 'bool' ? value.v : null;
212
+ }
150
213
  /**
151
214
  * Run `body` and, if it throws, record the exception's TYPE before letting it fly on.
152
215
  *
@@ -173,6 +236,87 @@ function recordRaise(log, tool, body) {
173
236
  throw e;
174
237
  }
175
238
  }
239
+ /**
240
+ * The last `docread.extract` result this server produced, and what it was OF.
241
+ *
242
+ * The port of `mcpserver._DocumentCache` (register entry (i), `docs/roadmap-toolbox.md` row
243
+ * 8). `bantamkit_read` re-parsed the whole document on EVERY call, so a caller paging a
244
+ * 12,001-row sheet in 200-row pages parsed the workbook once per page — paging was O(N^2) in
245
+ * the row window. MEASURED on this machine over a 1,553,944-byte 12,001-row xlsx driven
246
+ * through a real client with `docread.extract` counted by a loader hook: 68 tool calls, **68**
247
+ * parses, 4.128 s for the walk. After, over the same fixture and the same counter: **1**
248
+ * parse, 0.104 s (0.103 / 0.108 on two repeats). The committed, rerunnable form of the same
249
+ * count is `test/document-cache-and-bounds.test.mjs`, at 7 calls rather than 68.
250
+ *
251
+ * ONE entry, deliberately, and for the reference's reason. A single entry evicts whenever two
252
+ * callers alternate between two documents, and that case then costs exactly one parse per
253
+ * call — which is what the code this replaces cost for EVERY case, so the cache cannot make
254
+ * any caller slower than it was (`test/document-cache-and-bounds.test.mjs` counts the
255
+ * alternating walk and pins the number). What more entries would cost is resident memory: a
256
+ * `Document` holds its whole rendering as strings, bounded per document by
257
+ * `docread.TEXT_MAX_BYTES` and `docread.XLSX_MAX_TEXT_BYTES` (16 MiB each), and every extra
258
+ * entry multiplies that ceiling by one.
259
+ *
260
+ * **The key is (realpath, size, mtimeNs), and it has one hole — stated, not implied.** A
261
+ * rewrite that lands inside a single filesystem timestamp tick AND leaves the byte count
262
+ * unchanged is indistinguishable from no rewrite at all, and would be served from the stale
263
+ * parse. `mtimeNs` is nanosecond-SHAPED and not nanosecond-GRAINED: what it reports is
264
+ * whatever the filesystem stored, which on HFS+ is one second and on APFS/ext4 is finer but
265
+ * not unbounded. The alternative — hashing the bytes — would re-read the file this cache
266
+ * exists to avoid re-reading, which is the whole cost on the large documents that motivate
267
+ * it. So the hole stays, and the test that proves the key works changes the SIZE rather than
268
+ * racing the clock, because a test that raced it would be measuring the filesystem.
269
+ *
270
+ * THE CACHED VALUE IS THE RESULT, NEVER A READER. `docread.extract` hands its own `ZipReader`
271
+ * down to `extractXlsx`/`extractDocx` so an archive is read from disk once instead of twice,
272
+ * and that reader's lifetime ends with the `extract` call that opened it. This sits one layer
273
+ * above and holds a finished `Document`; no file handle outlives a call because of it.
274
+ */
275
+ class DocumentCache {
276
+ key = null;
277
+ doc = null;
278
+ /** The cached document for `key`, or `null` — a `null` key never matches. */
279
+ get(key) {
280
+ if (key === null || key !== this.key) {
281
+ return null;
282
+ }
283
+ return this.doc;
284
+ }
285
+ put(key, doc) {
286
+ if (key === null) {
287
+ return;
288
+ }
289
+ this.key = key;
290
+ this.doc = doc;
291
+ }
292
+ }
293
+ /**
294
+ * What identifies the bytes at `path` — or `null`, which means "do not cache".
295
+ *
296
+ * `statSync` FOLLOWS symlinks, which is the same file the reader is about to open, and
297
+ * `{bigint: true}` is not decoration: `Stats.mtimeMs` is a float and has already lost the
298
+ * sub-millisecond digits the reference's `st_mtime_ns` keeps, so a key built from it would
299
+ * have a coarser hole than the one documented above. `size` and `mtimeNs` are read from the
300
+ * bigint view for the same reason.
301
+ *
302
+ * Any error here returns `null` rather than refusing: this is a cache key, and the refusal a
303
+ * caller reads must be the one `docread.extract` raises in the reader's own words, not one
304
+ * this function invented from a different syscall's errno. That covers the one shape the
305
+ * reference does not have — `os.path.realpath` is non-strict in CPython and answers even for
306
+ * a path that does not resolve, while `realpathSync` throws. A path that stats but will not
307
+ * resolve is therefore uncached here and cached there; nothing a client can observe moves,
308
+ * because the reply is the reader's either way.
309
+ */
310
+ function documentKey(path) {
311
+ try {
312
+ const stat = statSync(path, { bigint: true });
313
+ // NUL cannot occur in a path on either platform, so the join is unambiguous.
314
+ return `${realpathSync(path)}\0${stat.size}\0${stat.mtimeNs}`;
315
+ }
316
+ catch {
317
+ return null;
318
+ }
319
+ }
176
320
  /**
177
321
  * Run a shiftwork handler and record its `result` — the register's OWN verdict.
178
322
  *
@@ -230,7 +374,7 @@ function recordResult(log, tool, call) {
230
374
  * byte-compares, and would make the log move when nothing the tool did moved. `noted` and
231
375
  * `notedDict` below therefore run last, on the way out.
232
376
  */
233
- function runTool(name, memory, args, version, log) {
377
+ function runTool(name, memory, args, version, log, documents) {
234
378
  /**
235
379
  * The footer for right now — recomputed per call, never cached.
236
380
  *
@@ -367,6 +511,29 @@ function runTool(name, memory, args, version, log) {
367
511
  });
368
512
  return { value: { t: 'str', v: noted(outcome.reply) }, wrapped: true };
369
513
  }
514
+ case 'memory_dream': {
515
+ // Consolidate what the project layer and the machine-wide profile layer both hold
516
+ // under one name (job45, roadmap row 5). `dryRun` DEFAULTS TO TRUE and the default
517
+ // lives HERE rather than in the component, exactly as the reference's handler carries
518
+ // it: a client that omits the argument sends nothing, `asBool` answers `null`, and
519
+ // turning that into the SAFE answer is this handler's job. It is the only tool on
520
+ // this surface that writes into the user's home directory, and the only one whose
521
+ // effect is machine-wide — a fact archived out of the profile store stops answering
522
+ // for every other project on this machine with no store of its own.
523
+ //
524
+ // THE RECORD IS A DECISION. `DreamOutcome.status` is read off `DreamResult.applied`,
525
+ // `.overBudget` and `.changes` — never off the reply, whose prose is allowed to
526
+ // improve without moving a single byte of the event log.
527
+ const dryRun = asBool(args.get('dry_run'));
528
+ const outcome = recordRaise(log, 'memory_dream', () => memory.dreamOutcome(dryRun === null ? true : dryRun));
529
+ log.record('memory_dream', outcome.status, {
530
+ absolutised: outcome.absolutised,
531
+ consumed: outcome.consumed,
532
+ dry_run: outcome.dryRun,
533
+ merged: outcome.merged,
534
+ });
535
+ return { value: { t: 'str', v: noted(outcome.reply) }, wrapped: true };
536
+ }
370
537
  case 'bantamkit_read': {
371
538
  // The reader on the MCP surface (job43): `docread` digests, `contract` words it. The
372
539
  // handler makes the SAME `contract` calls the reference's `bantamkit_read` makes,
@@ -403,37 +570,46 @@ function runTool(name, memory, args, version, log) {
403
570
  // `document_error` like the encrypted one. Until job43 G2 this arm ran `extract`
404
571
  // outside `recordRaise` with a hand-copied catch.
405
572
  return recordRaise(log, 'bantamkit_read', () => {
406
- let doc;
407
- try {
408
- doc = docread.extract(path);
409
- }
410
- catch (e) {
411
- // `except (docread.DocumentReadError, OSError)`. A Node fs error is CPython's
412
- // `OSError` with the sentence rebuilt by `asPyOSError` — `[Errno 13] Permission
413
- // denied: '<path>'` for a file this process may not open — through the CRT arm,
414
- // because `open()` is the call the reference makes. A `BadZipFile` never reaches
415
- // this arm on either side: `docread.ts`'s `zipKind`/`openZip` catch it exactly
416
- // where `docread.py`'s `_zip_kind`/`_open` do, and what leaves them is the
417
- // sentence naming what the reader saw, never the zip module's.
418
- // A `PyOSError` the reader raised itself is already the reference's sentence —
419
- // `[Errno 22] Invalid argument` from a zip whose member offset is negative, with
420
- // NO filename, because the `seek` that fails there has none. Re-wrapping it would
421
- // append `: '<path>'`.
422
- // ONLY a filesystem rejection is an `OSError`: numeric `errno` AND the `syscall`
423
- // it came from. A string `code` alone is not one — `Z_DATA_ERROR`,
424
- // `ERR_INVALID_ARG_VALUE` and `ERR_STRING_TOO_LONG` all carry one, and until job43
425
- // G2 each was printed as a fabricated `[Errno 0] …` sentence where the reference
426
- // raises (the corrupt stream) or says `no such file` (the NUL byte, which
427
- // `docread.statPath` now answers before this arm is reached).
428
- const refusal = e instanceof docread.DocumentReadError || e instanceof PyOSError
429
- ? e
430
- : isFsError(e)
431
- ? asPyOSError(e, path, undefined, 'crt')
432
- : null;
433
- if (refusal === null)
434
- throw e;
435
- log.record('bantamkit_read', 'refused-unreadable');
436
- return { value: { t: 'str', v: noted(documentError(refusal)) }, wrapped: true };
573
+ // (i): the parse is cached on (realpath, size, mtimeNs), so a paging walk over one
574
+ // unchanged document parses it once. `documentKey` answering `null` means "do not
575
+ // cache" and leaves this arm exactly as it was.
576
+ const key = documentKey(path);
577
+ let doc = documents.get(key);
578
+ if (doc === null) {
579
+ try {
580
+ doc = docread.extract(path);
581
+ }
582
+ catch (e) {
583
+ // `except (docread.DocumentReadError, OSError)`. A Node fs error is CPython's
584
+ // `OSError` with the sentence rebuilt by `asPyOSError` — `[Errno 13] Permission
585
+ // denied: '<path>'` for a file this process may not open — through the CRT arm,
586
+ // because `open()` is the call the reference makes. A `BadZipFile` never reaches
587
+ // this arm on either side: `docread.ts`'s `zipKind`/`openZip` catch it exactly
588
+ // where `docread.py`'s `_zip_kind`/`_open` do, and what leaves them is the
589
+ // sentence naming what the reader saw, never the zip module's.
590
+ // A `PyOSError` the reader raised itself is already the reference's sentence —
591
+ // `[Errno 22] Invalid argument` from a zip whose member offset is negative, with
592
+ // NO filename, because the `seek` that fails there has none. Re-wrapping it would
593
+ // append `: '<path>'`.
594
+ // ONLY a filesystem rejection is an `OSError`: numeric `errno` AND the `syscall`
595
+ // it came from. A string `code` alone is not one — `Z_DATA_ERROR`,
596
+ // `ERR_INVALID_ARG_VALUE` and `ERR_STRING_TOO_LONG` all carry one, and until job43
597
+ // G2 each was printed as a fabricated `[Errno 0] …` sentence where the reference
598
+ // raises (the corrupt stream) or says `no such file` (the NUL byte, which
599
+ // `docread.statPath` now answers before this arm is reached).
600
+ const refusal = e instanceof docread.DocumentReadError || e instanceof PyOSError
601
+ ? e
602
+ : isFsError(e)
603
+ ? asPyOSError(e, path, undefined, 'crt')
604
+ : null;
605
+ if (refusal === null)
606
+ throw e;
607
+ log.record('bantamkit_read', 'refused-unreadable');
608
+ return { value: { t: 'str', v: noted(documentError(refusal)) }, wrapped: true };
609
+ }
610
+ // A refusal is NOT cached: the entry holds a parse, and a path that has no parse
611
+ // must reach the reader again next call so the reply stays the reader's own.
612
+ documents.put(key, doc);
437
613
  }
438
614
  const detail = { kind: doc.kind, parts: doc.parts.length };
439
615
  if (part === null) {
@@ -555,6 +731,67 @@ function runTool(name, memory, args, version, log) {
555
731
  return { value: { t: 'str', v: noted(result.asJson()) }, wrapped: true };
556
732
  });
557
733
  }
734
+ case 'repo_map': {
735
+ // The ranked definition map on the MCP surface (job45 row 10): `repomap` measures,
736
+ // this serves it. Byte for byte the reference's `repo_map` handler.
737
+ //
738
+ // THE THREE REFUSALS LIVE HERE AND NOT IN `repomap.ts`, and that is deliberate.
739
+ // `repoMap()` over a root that does not exist answers an EMPTY map on both runtimes
740
+ // — `os.walk` yields nothing for a missing directory and `walkSources`' `readdirSync`
741
+ // catch does the same — which is the right answer for a library and the wrong one for
742
+ // a tool: a caller who typed the path wrong would be told the tree holds no source.
743
+ // So the argument checks are the SURFACE's, and the engine J45-9/J45-10 proved
744
+ // byte-identical is not touched.
745
+ //
746
+ // THE `statSync` ARM IS `Path.exists()` / `Path.is_dir()`, NOT A SHORTCUT FOR THEM.
747
+ // Both pathlib predicates SWALLOW the not-here errno family and re-raise anything
748
+ // else, so `EXISTS_IGNORED` below is that family spelled out: a dangling symlink and
749
+ // `a-file.py/sub` are "no such directory" on both runtimes, while a permission
750
+ // failure flies to `recordRaise` rather than being dressed up as a missing tree.
751
+ //
752
+ // THE RECORD IS A DECISION AND HOLDS NO PATH. `root` is what the operator typed and
753
+ // `focus` is the name of the file they are editing; neither is a decision this
754
+ // handler made, so neither is written down. The counts are.
755
+ const root = asText(args.get('root'));
756
+ const focusArg = args.get('focus');
757
+ const focus = focusArg !== undefined && focusArg.t === 'list'
758
+ ? focusArg.v.map((item) => (item.t === 'str' ? item.v : ''))
759
+ : [];
760
+ const budgetArg = args.get('budget');
761
+ const budget = budgetArg !== undefined && budgetArg.t === 'int' ? Number(budgetArg.v) : repomap.DEFAULT_BUDGET;
762
+ return recordRaise(log, 'repo_map', () => {
763
+ const refuse = (detail) => {
764
+ log.record('repo_map', 'refused');
765
+ return { value: { t: 'str', v: noted(toolFailed('repo_map', detail)) }, wrapped: true };
766
+ };
767
+ if (root === '')
768
+ return refuse('root must not be empty; name the directory to map');
769
+ if (budget < 0)
770
+ return refuse(`budget must not be negative; got ${budget}`);
771
+ let stat = null;
772
+ try {
773
+ stat = statSync(root);
774
+ }
775
+ catch (e) {
776
+ const code = e?.code;
777
+ if (!EXISTS_IGNORED.has(String(code)))
778
+ throw asPyOSError(e, root);
779
+ }
780
+ if (stat === null)
781
+ return refuse(`no such directory: ${root}`);
782
+ if (!stat.isDirectory())
783
+ return refuse(`${root} is a file, not a directory to map`);
784
+ const result = repomap.repoMap(root, { focus, budget });
785
+ log.record('repo_map', 'mapped', {
786
+ definitions: result.definitions,
787
+ edges: result.edges,
788
+ files_rendered: result.filesRendered,
789
+ listing_bytes: result.listingBytes,
790
+ nodes: result.nodes,
791
+ });
792
+ return { value: { t: 'str', v: noted(repoMapReply(result)) }, wrapped: true };
793
+ });
794
+ }
558
795
  default:
559
796
  // `tool_manager.call_tool` raises `ToolError(f"Unknown tool: {name}")`, which the
560
797
  // handler turns into an isError result rather than a JSON-RPC error.
@@ -589,6 +826,10 @@ export function buildServer(memory, wire, version, log = EventLog.fromEnv(memory
589
826
  },
590
827
  instructions: loadSkill('memory'),
591
828
  });
829
+ // Per SERVER, not per process: two servers in one interpreter (the tests build several)
830
+ // must not answer each other's files, and the entry dies with the server rather than
831
+ // outliving it in a module global. The reference does the same, inside `build_server`.
832
+ const documents = new DocumentCache();
592
833
  // Startup, not call time: a tool without a manifest, or with the wrong surface, must stop
593
834
  // the process before a client ever sees a `tools/list`. The reference gets this from
594
835
  // `MCPServer(..., tools=[...])`; here it is one eager pass.
@@ -697,7 +938,7 @@ export function buildServer(memory, wire, version, log = EventLog.fromEnv(memory
697
938
  if (model === undefined)
698
939
  throw new BantamError(`Unknown tool: ${name}`);
699
940
  const bound = validateArguments(model, args);
700
- const { value, wrapped } = runTool(name, memory, bound, version, log);
941
+ const { value, wrapped } = runTool(name, memory, bound, version, log, documents);
701
942
  // `_convert_to_content` runs BEFORE the `{"result": ...}` wrap, so a str tool's text
702
943
  // block is the RAW STRING and a dict tool's is `to_json(..., indent=2)`.
703
944
  text = value.t === 'str' ? value.v : sdkJson(value, 2);
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * `docs/memory.md` states, as a design decision, that `lint`, `archived` and `restore`
6
6
  * are **not** agent tools — "lifecycle is an operator decision, not a model decision" —
7
- * and the eleven-tool surface holds to it; `compact` is the one exception since job42
7
+ * and the thirteen-tool surface holds to it; `compact` is the one exception since job42
8
8
  * (`memory_compact`, the on-refusal path the model reaches itself). `runtime-py` made that
9
9
  * position coherent in August 2026 by giving the operator `python -m bantamkit.memory`.
10
10
  * `runtime-ts` did not. MEASURED 2026-08-24 before this file existed: `node dist/cli.js -h`
@@ -67,7 +67,7 @@
67
67
  import { BantamError } from '../errors.js';
68
68
  import { ArgumentTypeError, ArgvError, formatHelp, formatUsageBlock, HelpRequested, helpWidth, parseArgs, pyIntStrict, } from '../pyargparse.js';
69
69
  import { discoverProjectStore } from './layers.js';
70
- import { pyJoin } from './pyfs.js';
70
+ import { pyJoin, PyOSError } from './pyfs.js';
71
71
  import { DEFAULT_INDEX_BUDGET, MemoryBudgetExceeded, MemoryStore, MemoryValidationError, pyText, } from './store.js';
72
72
  /** The Node operator's spelling of the reference's `python -m bantamkit.memory`. */
73
73
  const PROG = 'bantamkit-memory';
@@ -314,6 +314,40 @@ function cmdArchived(store) {
314
314
  out(` ${name}`);
315
315
  return 0;
316
316
  }
317
+ /**
318
+ * job44 (y): an uncaught OS exception used to reach the operator here as a raw stack trace.
319
+ * Measured 2026-09-05 on macOS, both entrances: `archive/` at 0o555 (the guards pass,
320
+ * `pyMkdirParents` is a no-op on a directory that is already there, and the MOVE is refused)
321
+ * and `index.md` as a directory (the move succeeds, `rebuildIndex` raises, the rollback
322
+ * restores the store correctly, and the exception then escapes `main` the same way). Both
323
+ * already exited 1 — Node's default for an uncaught exception — so only the TEXT differed:
324
+ * a `node:fs` / `pyfs.js` stack ending in `PyOSError` here, a CPython traceback ending in
325
+ * `PermissionError: [Errno 13]` on the reference. `PyOSError` does not extend `BantamError`,
326
+ * so it reached neither this catch nor the top-level `BantamError` arm below — it fell all
327
+ * the way to `throw error` and out of the process.
328
+ *
329
+ * THE SENTENCE DELIBERATELY DOES NOT INTERPOLATE THE UNDERLYING ERROR, the same reason
330
+ * `cmdLint`'s budget sentence is not the store's own message: `PyOSError`'s text is a
331
+ * translation of Node's `errno`/`code`, and `OSError`'s is CPython's `strerror` — the two
332
+ * are never going to read the same, so printing either would just move the divergence from
333
+ * a stack trace into a sentence. RECONCILED WITH U7's published wording (job44 handoff).
334
+ *
335
+ * "NOTHING UNDER `store.root` CHANGED" IS A CLAIM, NOT A FLOURISH, and it was FALSE on the
336
+ * reference until U7's own reconciliation: `restore`'s final `_rebuild_index()` used to sit
337
+ * OUTSIDE its only `try`, so the same `index.md`-is-a-directory fault this comment describes
338
+ * left a restored fact stuck in `facts/`, gone from `archive/`, with no rollback at all.
339
+ * MEASURED the same way on this side before printing this sentence, not reasoned from the
340
+ * code shape: driving that exact fault against the pre-reconciliation `restore` here left
341
+ * `facts/alpha.md` present and `archive/` empty afterwards — the same hole, open on both
342
+ * runtimes. `restore`'s two-step tail is now ONE `try` (`checkIndexBudget` and the closing
343
+ * `rebuildIndex` together) for exactly that reason, so this sentence is true when it prints.
344
+ * `archive` never had the hole — its one write step was inside its only `try` from the start
345
+ * — and is untouched by that fix.
346
+ */
347
+ function osErrorSentence(command, name, store) {
348
+ return (`${command} failed: a filesystem error stopped the move of '${name}'; ` +
349
+ `nothing under ${store.root} changed`);
350
+ }
317
351
  function cmdArchive(store, args) {
318
352
  try {
319
353
  store.archive(args.name);
@@ -323,6 +357,10 @@ function cmdArchive(store, args) {
323
357
  err(`archive failed: ${error.message}`);
324
358
  return 1;
325
359
  }
360
+ if (error instanceof PyOSError) {
361
+ err(osErrorSentence('archive', args.name, store));
362
+ return 1;
363
+ }
326
364
  throw error;
327
365
  }
328
366
  out(`archived '${args.name}' — index now ${size(store)}/${store.indexBudget} bytes`);
@@ -342,6 +380,10 @@ function cmdRestore(store, args) {
342
380
  err(`restore failed: ${error.message}`);
343
381
  return 1;
344
382
  }
383
+ if (error instanceof PyOSError) {
384
+ err(osErrorSentence('restore', args.name, store));
385
+ return 1;
386
+ }
345
387
  throw error;
346
388
  }
347
389
  out(`restored '${args.name}' — index now ${size(store)}/${store.indexBudget} bytes`);
@@ -1,5 +1,6 @@
1
1
  import { type StoreBinding } from './layers.js';
2
2
  import { MemoryStore } from './store.js';
3
+ import type { DreamResult } from './dream.js';
3
4
  /**
4
5
  * The model's spelling adapted to the store's contract: lowercase, `_`/space -> `-`.
5
6
  *
@@ -97,6 +98,30 @@ export interface RecallOutcome {
97
98
  readonly source: string | null;
98
99
  readonly unreadable: number;
99
100
  }
101
+ /**
102
+ * What `dream` DID, beside the sentence it says about it.
103
+ *
104
+ * The same seam as `SaveOutcome`, `RecallOutcome` and `CompactOutcome`: `status` is read off
105
+ * a decision the pass already made — `DreamResult.applied`, `.overBudget`, `.changes` — and
106
+ * never off the reply. `result` carries the whole diff for a caller that wants the numbers
107
+ * rather than the prose.
108
+ *
109
+ * `status` is one of `consolidated`, `previewed`, `nothing-to-consolidate`,
110
+ * `refused-budget`, `no-profile-layer`.
111
+ */
112
+ export interface DreamOutcome {
113
+ readonly reply: string;
114
+ readonly status: string;
115
+ readonly dryRun: boolean;
116
+ readonly merged: number;
117
+ readonly consumed: number;
118
+ readonly absolutised: number;
119
+ readonly superseded: number;
120
+ readonly indexBefore: number;
121
+ readonly indexAfter: number;
122
+ readonly budget: number;
123
+ readonly result: DreamResult | null;
124
+ }
100
125
  export declare class Memory {
101
126
  readonly store: MemoryStore;
102
127
  readonly k: number;
@@ -140,7 +165,7 @@ export declare class Memory {
140
165
  * fact. The floor is the operator's configured default, not a constant, so a consumer who
141
166
  * really wants top-1 says so once at construction.
142
167
  */
143
- recall(query: string, k?: number | null): string;
168
+ recall(query: string, k?: number | null, minRatio?: number): string;
144
169
  /**
145
170
  * `recall`, carrying the walk it performed as numbers rather than as prose.
146
171
  *
@@ -157,8 +182,18 @@ export declare class Memory {
157
182
  * refuses to list contributes nothing and raises the `unreadable` count instead of being
158
183
  * scored as empty; that distinction is the whole subject of `nothingToReport` below and
159
184
  * must not be undone here.
185
+ *
186
+ * `minRatio` (roadmap #6) rides through to every layer's `MemoryStore.recall` unchanged,
187
+ * so the gate is measured against EACH LAYER's own best score and never across layers: a
188
+ * profile fact does not have to out-score the project store's top hit to be admitted,
189
+ * because the two stores are answering as two stores. Its default is
190
+ * `RECALL_MIN_SCORE_RATIO` = 0.0, which keeps every fact `recall` was going to return,
191
+ * and nothing on the tool path passes anything else today. A ratio outside `[0.0, 1.0]`
192
+ * raises out of the FIRST layer, which is the writable project store, so it surfaces as
193
+ * the error it is rather than as an `unreadable` count — the read-only-layer `catch`
194
+ * below would otherwise file a caller's bad argument as a corrupt grant.
160
195
  */
161
- recallOutcome(query: string, k?: number | null): RecallOutcome;
196
+ recallOutcome(query: string, k?: number | null, minRatio?: number): RecallOutcome;
162
197
  /**
163
198
  * Archive the stalest facts until a fact as large as the biggest one kept will fit.
164
199
  *
@@ -179,6 +214,42 @@ export declare class Memory {
179
214
  * is — `MemoryStore.compact` builds it with `pyJoin`.
180
215
  */
181
216
  compactOutcome(reserve?: number | null): CompactOutcome;
217
+ /** Consolidate what the project and profile layers hold under the same name. */
218
+ dream(dryRun?: boolean): string;
219
+ /**
220
+ * `dream`, with the decision it took carried beside the sentence it wrote.
221
+ *
222
+ * ONLY THE PROFILE LAYER IS CONSUMED. `layers` also carries read-only GRANTS, and a grant
223
+ * is another operator's store: consolidating a fact out of one is not this person's move
224
+ * to make, so `dream` never looks at them. The label is matched exactly (`profile`), never
225
+ * by prefix, because a grant is labelled `extra:<name>` and a prefix match on a directory
226
+ * called `profile-something` would reach one.
227
+ *
228
+ * A `Memory` constructed directly — not through `layered` — has no profile layer at all,
229
+ * and that is `no-profile-layer` rather than an error: there is nothing to consolidate
230
+ * ACROSS when only one layer is bound.
231
+ *
232
+ * `dryRun` DEFAULTS TO TRUE. This is the only op in this component that writes into the
233
+ * user's home directory, and it is the only one whose effect is machine-wide: a fact
234
+ * archived out of the profile store stops answering for every other project on this
235
+ * machine that has no store of its own. A destructive consolidation nobody can preview is
236
+ * not shippable, so the safe call is the short one.
237
+ */
238
+ dreamOutcome(dryRun?: boolean): DreamOutcome;
239
+ /**
240
+ * The whole diff as prose: what merged, what was dated, what moved, what it cost.
241
+ *
242
+ * THE HONESTY SENTENCE AT THE END IS NOT DECORATION. Row 5 was planned as a token saving
243
+ * and J45-1 measured that it is not one: the profile store has no `index.md` on disk, its
244
+ * index is derived at read time, and nothing loads it into a prompt — so deduplicating it
245
+ * frees approximately zero prompt bytes. The value is that one ruling now has one copy
246
+ * instead of two that can disagree, and the reply says which of those two things the
247
+ * operator just bought.
248
+ *
249
+ * `formatFixed3` and not `toFixed(3)` for the two similarity figures: see the header of
250
+ * `dream.ts`. CPython's `.3f` rounds ties to EVEN and they are reachable.
251
+ */
252
+ private static dreamReply;
182
253
  /**
183
254
  * `(index bytes, budget)` for the writable project store; bytes may be `null`.
184
255
  *