bantamkit-mcp 0.35.6 → 0.35.8

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 (44) hide show
  1. package/README.md +11 -7
  2. package/assets/tools/memory_compact.json +1 -2
  3. package/assets/tools/memory_recall.json +1 -3
  4. package/assets/tools/shiftwork_clock_in.json +1 -1
  5. package/assets/tools/shiftwork_clock_out.json +1 -1
  6. package/assets/tools/validate_json.json +1 -1
  7. package/assets/tools/work_plan.json +14 -8
  8. package/dist/cli.js +28 -0
  9. package/dist/contract.d.ts +0 -9
  10. package/dist/contract.js +51 -12
  11. package/dist/eventlog.d.ts +14 -1
  12. package/dist/eventlog.js +23 -3
  13. package/dist/hookadapter.js +260 -45
  14. package/dist/hostinstall.js +250 -79
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.js +1 -1
  17. package/dist/mcp/pyargs.d.ts +22 -6
  18. package/dist/mcp/pyargs.js +113 -39
  19. package/dist/mcp/server.js +18 -27
  20. package/dist/mcp/status.js +11 -4
  21. package/dist/mcpreport.d.ts +12 -0
  22. package/dist/mcpreport.js +67 -15
  23. package/dist/memory/cli.js +18 -3
  24. package/dist/memory/component.d.ts +17 -0
  25. package/dist/memory/component.js +51 -7
  26. package/dist/memory/factfile.d.ts +21 -3
  27. package/dist/memory/factfile.js +351 -12
  28. package/dist/memory/layers.d.ts +19 -0
  29. package/dist/memory/layers.js +102 -2
  30. package/dist/memory/pyyaml.d.ts +6 -4
  31. package/dist/memory/pyyaml.js +6 -4
  32. package/dist/memory/store.d.ts +82 -7
  33. package/dist/memory/store.js +277 -29
  34. package/dist/npminstall.d.ts +18 -0
  35. package/dist/npminstall.js +26 -5
  36. package/dist/pyjsonschema.d.ts +85 -4
  37. package/dist/pyjsonschema.js +1812 -181
  38. package/dist/selfupdate.d.ts +112 -32
  39. package/dist/selfupdate.js +221 -79
  40. package/dist/shiftwork.d.ts +46 -7
  41. package/dist/shiftwork.js +246 -42
  42. package/dist/workplan.d.ts +8 -3
  43. package/dist/workplan.js +62 -3
  44. package/package.json +1 -1
@@ -7,12 +7,12 @@
7
7
  * ship a server whose recovery behaviour differs from the reference's on every mistyped
8
8
  * argument, and that is a product difference, not a cosmetic one.
9
9
  *
10
- * WHAT THE URL IS. `https://errors.pydantic.dev/2.13/v/<type>` names an entry in pydantic's
11
- * public error catalogue. This runtime has no pydantic; the URL is part of the sentence the
12
- * reference emits, reproduced because the sentence is the contract. `2.13` is therefore a
13
- * PIN on the reference, not a claim about this process — and it is compared against the
14
- * live server by `tools/conformance/suites/wire.mjs`, so the day the Python side bumps to
15
- * 2.14 the suite goes red here instead of the two runtimes drifting apart quietly.
10
+ * WHY THERE IS NO URL (job65/#185). pydantic ends each error with `For further information
11
+ * visit https://errors.pydantic.dev/<major.minor>/v/<type>`, naming whichever pydantic pip
12
+ * resolved. This port used to reproduce it with `2.13` hard-coded, and the reference printed
13
+ * `2.14` the day PyPI resolved 2.14 — the refusal texts differed by install date. The
14
+ * reference now sets `PYDANTIC_ERRORS_INCLUDE_URL=0` at import (`bantamkit.mcpserver`), so
15
+ * neither runtime prints the line; `tools/conformance/suites/wire.mjs` compares the two.
16
16
  *
17
17
  * EVERY RULE BELOW WAS MEASURED, not read off pydantic's documentation. The probes are in
18
18
  * `scratchpad/job38-n7/probe{4,7,8,9}.py` and each drove the real stdio server:
@@ -54,6 +54,15 @@ const opt = (name, kind, bound = {}) => ({
54
54
  * register a tool.
55
55
  */
56
56
  export const OFFSET_MAXIMUM = 9007199254740991n;
57
+ /**
58
+ * `assets/tools/skill_audit.json` `budget.maximum` — the same `Number.MAX_SAFE_INTEGER` as
59
+ * `OFFSET_MAXIMUM`, spelled as its own constant because it is tied to a different asset
60
+ * and would stay where it is if the other ever moved. Bound on the reference as
61
+ * `Annotated[int, Field(le=SKILL_BUDGET_MAXIMUM)]` (#133, job65): until then the manifest
62
+ * advertised a ceiling neither runtime enforced, and `budget: 2**53` was audited as if it
63
+ * were a number a catalogue could be measured against.
64
+ */
65
+ export const SKILL_BUDGET_MAXIMUM = 9007199254740991n;
57
66
  /**
58
67
  * One model per handler, mirroring `build_server`'s closures signature for signature.
59
68
  *
@@ -107,9 +116,10 @@ export const ARG_MODELS = {
107
116
  bantamkit_status: { model: 'bantamkit_statusArguments', fields: [] },
108
117
  // `reserve: int | None = None` on the reference, so it is the same lax `int` as `k`:
109
118
  // `'2'`, `True` and `3.0` validate, `'2.5'` is `int_parsing`, `2.5` is `int_from_float`,
110
- // and an explicit `null` is the default. The manifest's `minimum: 0` is advisory to the
111
- // client; nothing here floors it — a negative value reaches `MemoryStore.compact`, whose
112
- // `max(0, min(reserve, budget // 2))` is the only clamp on either side (6b966e5).
119
+ // and an explicit `null` is the default. Nothing here floors it — a negative value
120
+ // reaches `MemoryStore.compact`, whose `max(0, min(reserve, budget // 2))` is the only
121
+ // clamp on either side (6b966e5) — and since #133 (job65) the manifest no longer
122
+ // advertises a `minimum` it does not enforce: the clamp is stated in the description.
113
123
  memory_compact: { model: 'memory_compactArguments', fields: [opt('reserve', 'int')] },
114
124
  // `bantamkit_read(path: str, part: str | None = None, offset: int | None = None,
115
125
  // limit: int | None = None)` on the reference (job43). `path` and `part` are the strict
@@ -127,16 +137,19 @@ export const ARG_MODELS = {
127
137
  model: 'bantamkit_readArguments',
128
138
  fields: [req('path', 'str'), opt('part', 'str'), opt('offset', 'int', { le: OFFSET_MAXIMUM }), opt('limit', 'int')],
129
139
  },
130
- // `skill_audit(root: str, enabled: list[str] | None = None, usage: dict[str, int] | None =
131
- // None, check: str | None = None, budget: int | None = None)` on the reference (job44).
132
- // `usage` is the only `dict[str, int]` on the surface and the reason `dictInt` exists:
133
- // pydantic validates a typed dict's VALUES, so `{'a': 'x'}` is `usage.a` / `int_parsing`
134
- // where `handoff_patch`'s `dict[str, Any]` takes anything. `budget` carries no `le`,
135
- // because the reference binds none — the manifest's `maximum` is advisory to the client,
136
- // and the handler's own refusal is the negative one. `versions` is the only
137
- // `dict[str, str]`, and the reason `dictStr` exists beside it: a `str` field is STRICT even
138
- // in lax mode, so `{'a': 1}` is `versions.a` / `string_type` where the same value under
139
- // `usage` would validate.
140
+ // `skill_audit(root: str, enabled: list[str] | None = None, usage: dict[str,
141
+ // Annotated[int, Field(ge=0)]] | None = None, check: str | None = None, budget:
142
+ // Annotated[int, Field(le=SKILL_BUDGET_MAXIMUM)] | None = None)` on the reference (job44;
143
+ // the two bounds since #133, job65). `usage` is the only `dict[str, int]` on the surface
144
+ // and the reason `dictInt` exists: pydantic validates a typed dict's VALUES, so `{'a':
145
+ // 'x'}` is `usage.a` / `int_parsing` and `{'a': -1}` is `usage.a` /
146
+ // `greater_than_equal`, where `handoff_patch`'s `dict[str, Any]` takes anything. `budget`'s
147
+ // ceiling is the manifest's `maximum`, bound the way `bantamkit_read`'s `offset` is;
148
+ // its floor is still the handler's own refusal (`budget must not be negative`), which
149
+ // the manifest's `minimum: 0` describes and this table does not pre-empt. `versions` is
150
+ // the only `dict[str, str]`, and the reason `dictStr` exists beside it: a `str` field is
151
+ // STRICT even in lax mode, so `{'a': 1}` is `versions.a` / `string_type` where the same
152
+ // value under `usage` would validate.
140
153
  // `dry_run: bool | None = None` on the reference (job45 row 5). LAX bool, and every arm
141
154
  // below was MEASURED against the real stdio server, not read off pydantic's docs
142
155
  // (`scratchpad` probe, 33 inputs): `0`/`1` and `0.0`/`1.0` coerce; any OTHER integral
@@ -176,9 +189,9 @@ export const ARG_MODELS = {
176
189
  fields: [
177
190
  req('root', 'str'),
178
191
  opt('enabled', 'listStr'),
179
- opt('usage', 'dictInt'),
192
+ opt('usage', 'dictInt', { ge: 0n }),
180
193
  opt('check', 'str'),
181
- opt('budget', 'int'),
194
+ opt('budget', 'int', { le: SKILL_BUDGET_MAXIMUM }),
182
195
  opt('versions', 'dictStr'),
183
196
  ],
184
197
  },
@@ -297,7 +310,7 @@ function checkField(spec, value) {
297
310
  const bad = [];
298
311
  const coerced = new Map();
299
312
  for (const [key, item] of value.v) {
300
- const checked = checkInt(`${spec.name}.${key}`, item);
313
+ const checked = checkBoundedInt(spec, `${spec.name}.${key}`, item);
301
314
  if ('value' in checked)
302
315
  coerced.set(key, checked.value);
303
316
  else
@@ -382,24 +395,37 @@ function checkField(spec, value) {
382
395
  return wrongType();
383
396
  }
384
397
  case 'int': {
385
- const checked = checkInt(spec.name, value);
386
- if (!('value' in checked))
387
- return [checked];
388
- // `less_than_equal` names the ORIGINAL input, as every constraint error does.
389
- if (spec.le !== undefined && checked.value.t === 'int' && checked.value.v > spec.le) {
390
- return [
391
- {
392
- loc: spec.name,
393
- type: 'less_than_equal',
394
- msg: `Input should be less than or equal to ${spec.le}`,
395
- input: value,
396
- },
397
- ];
398
- }
399
- return checked;
398
+ const checked = checkBoundedInt(spec, spec.name, value);
399
+ return 'value' in checked ? checked : [checked];
400
400
  }
401
401
  }
402
402
  }
403
+ /**
404
+ * The lax int parse, then the field's `ge`/`le` bounds on what it parsed.
405
+ *
406
+ * A constraint frame names the ORIGINAL input, as every pydantic constraint error does:
407
+ * `usage: {'a': '-1'}` is `greater_than_equal` with `input_value='-1', input_type=str`, the
408
+ * string the client sent and not the `-1` it parsed to. Measured against pydantic 2.13 on
409
+ * `dict[str, Annotated[int, Field(ge=0)]]` and `Annotated[int, Field(le=2**53-1)]`,
410
+ * 2026-10-15 (job65 J65-13 note, §3), and compared over stdio by `suites/wire.mjs`.
411
+ */
412
+ function checkBoundedInt(spec, loc, value) {
413
+ const checked = checkInt(loc, value);
414
+ if (!('value' in checked) || checked.value.t !== 'int')
415
+ return checked;
416
+ if (spec.ge !== undefined && checked.value.v < spec.ge) {
417
+ return {
418
+ loc,
419
+ type: 'greater_than_equal',
420
+ msg: `Input should be greater than or equal to ${spec.ge}`,
421
+ input: value,
422
+ };
423
+ }
424
+ if (spec.le !== undefined && checked.value.v > spec.le) {
425
+ return { loc, type: 'less_than_equal', msg: `Input should be less than or equal to ${spec.le}`, input: value };
426
+ }
427
+ return checked;
428
+ }
403
429
  function checkInt(loc, value) {
404
430
  const tooBig = () => ({
405
431
  loc,
@@ -550,6 +576,52 @@ export class PyValidationFailure extends Error {
550
576
  this.name = 'PyValidationFailure';
551
577
  }
552
578
  }
579
+ /**
580
+ * A LONE SURROGATE IN ANY STRING ARGUMENT IS REFUSED BY NAME (job65/#160), before any other
581
+ * check. JSON can spell `\ud800` on its own; no UTF-8 file or frame can carry it. This port
582
+ * used to accept it and write U+FFFD ("saved", data silently altered); the reference never
583
+ * answered at all, because the SDK's frame parser rejects the escape and drops the request.
584
+ * Both now answer `Error executing tool <name>: argument '<path>' holds a lone surrogate
585
+ * (U+XXXX), which is not text; nothing was done` — the reference from its stdio reader
586
+ * (`bantamkit.mcpstdio`), this port from here. `<path>` is the top-level argument, then
587
+ * `[i]` for a list item and `.key` for a dict member; the first in document order wins.
588
+ * Keys are not inspected: an unknown key is ignored by both sides anyway.
589
+ */
590
+ const LONE_SURROGATE = /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/;
591
+ export function loneSurrogateArgument(args) {
592
+ const walk = (value, where) => {
593
+ if (value.t === 'str') {
594
+ const found = LONE_SURROGATE.exec(value.v);
595
+ if (found === null)
596
+ return null;
597
+ const unit = found[0].charCodeAt(0).toString(16).toUpperCase().padStart(4, '0');
598
+ return `argument '${where}' holds a lone surrogate (U+${unit}), which is not text; nothing was done`;
599
+ }
600
+ if (value.t === 'list') {
601
+ for (let i = 0; i < value.v.length; i += 1) {
602
+ const hit = walk(value.v[i], `${where}[${i}]`);
603
+ if (hit !== null)
604
+ return hit;
605
+ }
606
+ }
607
+ if (value.t === 'dict') {
608
+ for (const [key, item] of value.v) {
609
+ const hit = walk(item, `${where}.${key}`);
610
+ if (hit !== null)
611
+ return hit;
612
+ }
613
+ }
614
+ return null;
615
+ };
616
+ if (args.t !== 'dict')
617
+ return null;
618
+ for (const [key, item] of args.v) {
619
+ const hit = walk(item, key);
620
+ if (hit !== null)
621
+ return hit;
622
+ }
623
+ return null;
624
+ }
553
625
  /**
554
626
  * `model.model_validate(arguments)` for one tool, or the exact `ValidationError` text.
555
627
  *
@@ -564,6 +636,9 @@ export class PyValidationFailure extends Error {
564
636
  * its `input_value` and not the string the wire held.
565
637
  */
566
638
  export function validateArguments(model, args) {
639
+ const lone = loneSurrogateArgument(args);
640
+ if (lone !== null)
641
+ throw new Error(lone);
567
642
  const unwrapped = preParseJson(model, args);
568
643
  const supplied = unwrapped.t === 'dict' ? unwrapped.v : new Map();
569
644
  const errors = [];
@@ -589,8 +664,7 @@ export function validateArguments(model, args) {
589
664
  const head = `${count} validation error${count === 1 ? '' : 's'} for ${model.model}`;
590
665
  const body = errors
591
666
  .map((e) => `${e.loc}\n ${e.msg} [type=${e.type}, input_value=${truncateRepr(reprValue(e.input))}, ` +
592
- `input_type=${pyTypeName(e.input)}]\n For further information visit ` +
593
- `https://errors.pydantic.dev/2.13/v/${e.type}`)
667
+ `input_type=${pyTypeName(e.input)}]`)
594
668
  .join('\n');
595
669
  throw new PyValidationFailure(`${head}\n${body}`);
596
670
  }
@@ -174,41 +174,32 @@ function rpcError(code, message, data) {
174
174
  const asDict = (value) => (value.t === 'dict' ? value.v : new Map());
175
175
  const asText = (value) => (value && value.t === 'str' ? value.v : '');
176
176
  /**
177
- * `work_plan`'s `nodes` as `workplan.plan` takes them.
177
+ * `work_plan`'s `nodes` as `workplan.plan` takes them: the decoded objects, UNCHANGED.
178
178
  *
179
179
  * The reference's model is `list[dict[str, Any]]`, so pydantic guarantees a list of dicts
180
- * and NOTHING about what is inside one; `workplan.plan` then reads `node["id"]`,
181
- * `node.get("depends_on") or []` and `node.get("priority", 0)` off raw Python objects. This
182
- * reproduces those three reads and their defaults.
180
+ * and NOTHING about what is inside one; what is inside is checked by `workplan.plan` itself
181
+ * on both sides against the item schema `assets/tools/work_plan.json` advertises (#127,
182
+ * job65). Until that check existed THIS function was where the port diverged: it coerced a
183
+ * string `depends_on` to `[]`, a non-numeric `priority` to 0 and a numeric id to its
184
+ * `str()`, so `[{id: 'a', depends_on: 'b'}, {id: 'b', ...}]` was planned with `a` beside
185
+ * `b` here and `b` before `a` there. Nothing is reinterpreted here any more: `toJs` hands
186
+ * the planner the same value `json.loads` hands the reference, and a node that contradicts
187
+ * the schema comes back as the planner's own `{result: 'error', reason: 'nodes[<i>].<field>
188
+ * must be ...'}` — one sentence, both runtimes, compared over stdio by
189
+ * `tools/conformance/suites/workplan.mjs`.
183
190
  *
184
- * A NODE WITH NO `id` RAISES, and raising is the port: `node["id"]` is a `KeyError` on the
185
- * reference, whose `str()` is the key in single quotes, so the SDK's envelope reads
186
- * `Error executing tool work_plan: 'id'`. Defaulting it here would answer a plan over a
187
- * node the reference refused to look at.
188
- *
189
- * WHERE THE DYNAMIC HALF STOPS, said rather than hidden. Python keeps an id's own type and
190
- * this side stringifies it with `str()`'s spelling (`reprValue` for every non-`str`), so a
191
- * document mixing `5` and `"5"` is two nodes there and one here, and a non-numeric
192
- * `priority` is a `TypeError` there and a 0 here. Both need a node whose types contradict
193
- * `assets/tools/work_plan.json`, which nothing in the shift-work corpus produces; they are
194
- * named here because an unwritten difference is the one that is found later.
191
+ * A NODE WITH NO `id` STILL RAISES, and raising is the port: `node["id"]` is a `KeyError` on
192
+ * the reference, whose `str()` is the key in single quotes, so the SDK's envelope reads
193
+ * `Error executing tool work_plan: 'id'` (`tool/no-id` in the same suite). It is reproduced
194
+ * here, before the planner runs, because the planner's own answer to a missing id is the
195
+ * non-string-id sentence and the reference never reaches that line.
195
196
  */
196
197
  function planNodes(value) {
197
- const pyText = (v) => (v.t === 'str' ? v.v : reprValue(v));
198
198
  const items = value !== undefined && value.t === 'list' ? value.v : [];
199
199
  return items.map((item) => {
200
- const node = item.t === 'dict' ? item.v : new Map();
201
- const id = node.get('id');
202
- if (id === undefined)
200
+ if (item.t === 'dict' && !item.v.has('id'))
203
201
  throw new BantamError("'id'");
204
- const declared = node.get('depends_on');
205
- const priority = node.get('priority');
206
- return {
207
- id: pyText(id),
208
- // `list(node.get("depends_on") or [])`: missing, `null` and `[]` are one case.
209
- depends_on: declared !== undefined && declared.t === 'list' ? declared.v.map(pyText) : [],
210
- priority: priority !== undefined && (priority.t === 'int' || priority.t === 'float') ? Number(priority.v) : 0,
211
- };
202
+ return toJs(item);
212
203
  });
213
204
  }
214
205
  /** `int | None` after validation: the tagged int, or `null` for the default. */
@@ -251,11 +251,14 @@ export function indexPressureCondition(memory) {
251
251
  // and this comparison is the whole reason they cannot.
252
252
  if (size === null || size * 100 < INDEX_PRESSURE_PERCENT * budget)
253
253
  return null;
254
+ // AT OR OVER THE BUDGET IS NOT "CLOSE" (job65/#186): the reply that carried the budget
255
+ // refusal used to close with "the next save is close to being refused". At `size >= budget`
256
+ // no save that adds a byte can land, so the sentence says so; the 90 % band keeps its words.
257
+ const consequence = size >= budget ? 'so it is full and every save is refused until it is compacted' : 'so the next save is close to being refused';
254
258
  return {
255
259
  key: 'index-budget-low',
256
- sentence: `the memory index is ${size} bytes of a ${budget}-byte budget, so the next save ` +
257
- 'is close to being refused — archive or shorten facts with ' +
258
- '`bantamkit-memory compact`.',
260
+ sentence: `the memory index is ${size} bytes of a ${budget}-byte budget, ${consequence} — ` +
261
+ 'archive or shorten facts with `bantamkit-memory compact`.',
259
262
  };
260
263
  }
261
264
  /**
@@ -422,7 +425,11 @@ export function statusReport(memory, log, conditions, identity, tools, prompts,
422
425
  : `${plural(facts, 'fact')} in the project store`) +
423
426
  ', index ' +
424
427
  (size === null ? 'unreadable' : String(size)) +
425
- ` of ${budget} bytes`,
428
+ ` of ${budget} bytes` +
429
+ // job65/#142: the store this line counts, by path. WAS: no path at all, so a store the
430
+ // walk picked up ABOVE the project — an ancestor's, or `~/.bantamkit/memory` itself —
431
+ // answered as the writable project layer with nothing on the surface saying where.
432
+ `; bound to ${memory.store.root}`,
426
433
  `event log: ${log.enabled ? 'on' : 'off'}`,
427
434
  // THE UPDATE LINE IS NOT A CONDITION, and that is the ruling of 2026-09-19 rather than an
428
435
  // omission. A newer version existing is not a fault: the server is serving correctly, and
@@ -74,6 +74,18 @@ export interface HostCall {
74
74
  }
75
75
  /** The one timestamp the join hangs on when only one end was logged. */
76
76
  export declare function anchorMs(call: HostCall): number;
77
+ /**
78
+ * The two input paths, spelled in ASCII.
79
+ *
80
+ * `docs/mcpreport.md` promises an ASCII report, and the two paths it echoes are the only
81
+ * bytes in it that come from outside a closed vocabulary. Measured 2026-10-13 (#187): a root
82
+ * named `แคช-😀` reached stdout verbatim. Every UTF-16 code unit outside printable ASCII
83
+ * (0x20–0x7E) becomes `\uXXXX`, lowercase hex — one escape per code unit, so a character
84
+ * outside the BMP is two — and nothing else is touched: a backslash stays a backslash, so a
85
+ * Windows path reads as typed. The reference walks code points and splits astral ones into
86
+ * surrogates by hand; the two spell the same bytes.
87
+ */
88
+ export declare function escapeAscii(text: string): string;
77
89
  export interface PairedCalls {
78
90
  readonly calls: HostCall[];
79
91
  readonly incomplete: number;
package/dist/mcpreport.js CHANGED
@@ -211,6 +211,57 @@ export function parseHostLine(line) {
211
211
  export function anchorMs(call) {
212
212
  return call.startMs !== null ? call.startMs : (call.endMs ?? 0);
213
213
  }
214
+ /**
215
+ * `min()` / `max()` over a non-empty list, as a LOOP, and `list.extend` as a loop too.
216
+ *
217
+ * `Math.min(...values)` and `out.push(...lines)` spread a list into CALL ARGUMENTS, and V8
218
+ * caps those at the stack. Measured 2026-10-14 (#130): one session of 130,000 calls — 32 MB
219
+ * of host log, a legal busy day — ended `--mcp-report` with `RangeError: Maximum call stack
220
+ * size exceeded` on this side where the reference answered the same log in 1.4 s. The host
221
+ * log is UNBOUNDED by contract (`docs/mcpreport.md`: the host never rotates it), so nothing
222
+ * derived from it — a session's anchors, a bucket's durations, one line per session — may be
223
+ * spread into a call. `[...items]` in an array literal is iteration, not a call, and stays.
224
+ */
225
+ function minOf(values) {
226
+ let best = values[0];
227
+ for (let i = 1; i < values.length; i += 1)
228
+ if (values[i] < best)
229
+ best = values[i];
230
+ return best;
231
+ }
232
+ function maxOf(values) {
233
+ let best = values[0];
234
+ for (let i = 1; i < values.length; i += 1)
235
+ if (values[i] > best)
236
+ best = values[i];
237
+ return best;
238
+ }
239
+ function extend(out, lines) {
240
+ for (const line of lines)
241
+ out.push(line);
242
+ }
243
+ /**
244
+ * The two input paths, spelled in ASCII.
245
+ *
246
+ * `docs/mcpreport.md` promises an ASCII report, and the two paths it echoes are the only
247
+ * bytes in it that come from outside a closed vocabulary. Measured 2026-10-13 (#187): a root
248
+ * named `แคช-😀` reached stdout verbatim. Every UTF-16 code unit outside printable ASCII
249
+ * (0x20–0x7E) becomes `\uXXXX`, lowercase hex — one escape per code unit, so a character
250
+ * outside the BMP is two — and nothing else is touched: a backslash stays a backslash, so a
251
+ * Windows path reads as typed. The reference walks code points and splits astral ones into
252
+ * surrogates by hand; the two spell the same bytes.
253
+ */
254
+ export function escapeAscii(text) {
255
+ let out = '';
256
+ for (let i = 0; i < text.length; i += 1) {
257
+ const unit = text.charCodeAt(i);
258
+ if (unit >= 0x20 && unit <= 0x7e)
259
+ out += text[i];
260
+ else
261
+ out += `\\u${unit.toString(16).padStart(4, '0')}`;
262
+ }
263
+ return out;
264
+ }
214
265
  /** `sorted(key=...)` over tuples of numbers and strings — Python's, so codepoint order. */
215
266
  function byKey(items, key) {
216
267
  return [...items].sort((a, b) => {
@@ -508,8 +559,8 @@ function candidates(event, calls, windowMs) {
508
559
  const ends = [call.startMs, call.endMs].filter((t) => t !== null);
509
560
  if (ends.length === 0)
510
561
  continue;
511
- const lo = Math.min(...ends) - windowMs;
512
- const hi = Math.max(...ends) + windowMs;
562
+ const lo = minOf(ends) - windowMs;
563
+ const hi = maxOf(ends) + windowMs;
513
564
  if (lo <= event.tsMs && event.tsMs <= hi)
514
565
  out.push(index);
515
566
  }
@@ -628,22 +679,23 @@ export function render(host, events, joined) {
628
679
  out.push(`host-log-root: unknown on this platform (${host.platform}); set ${HOST_LOG_ROOT_ENV}`);
629
680
  }
630
681
  else if (host.missing === 'not-found') {
631
- out.push(`host-log-root: not found at ${host.root}`);
682
+ out.push(`host-log-root: not found at ${escapeAscii(host.root ?? '')}`);
632
683
  }
633
684
  else {
634
- out.push(`host-log-root: ${host.root}`);
685
+ out.push(`host-log-root: ${escapeAscii(host.root ?? '')}`);
635
686
  }
636
687
  out.push(`host-log-dirs: ${host.dirs}`, `host-log-files: ${host.files}`, `host-records: ${host.records}`, `host-records-lifecycle: ${host.lifecycle}`, `host-records-unclassified: ${host.unclassified}`, `host-error-records: ${host.errors}`, `host-calls: ${host.calls.length}`, `host-calls-incomplete: ${host.incomplete}`, `host-calls-orphan-completion: ${host.orphanCompletions}`);
637
688
  if (events.missing === 'off')
638
689
  out.push('event-log: off (BANTAMKIT_EVENT_LOG unset or off)');
639
- else if (events.missing === 'not-found')
640
- out.push(`event-log: not found at ${events.path}`);
690
+ else if (events.missing === 'not-found') {
691
+ out.push(`event-log: not found at ${escapeAscii(events.path ?? '')}`);
692
+ }
641
693
  else
642
- out.push(`event-log: ${events.path}`);
694
+ out.push(`event-log: ${escapeAscii(events.path ?? '')}`);
643
695
  out.push(`event-records: ${events.records}`, `event-records-unreadable: ${events.unreadable}`, '', '[join]', 'method: (ts, tool) interval containment, mutually unique only', 'APPROXIMATE: the host stamps the dispatch, bantamkit stamps the decision,' +
644
696
  ' and under concurrency the two files can disagree about order. (ts, tool)' +
645
697
  ' is EVIDENCE, NOT A KEY.', `window-ms: ${joined.windowMs}`, `matched-pairs: ${joined.matched.length}`, `ambiguous-groups: ${joined.ambiguousGroups.length}`, `ambiguous-events: ${joined.ambiguousEvents}`, `ambiguous-calls: ${joined.ambiguousCalls}`, `unmatched-events: ${joined.unmatchedEvents.length}`, `unmatched-calls: ${joined.unmatchedCalls.length}`, `attributed-events: ${joined.matched.length} of ${events.events.length}`);
646
- out.push(...linesOrNone(joined.ambiguousGroups.map(([e, c]) => `ambiguous-group: events=${e} calls=${c}`)));
698
+ extend(out, linesOrNone(joined.ambiguousGroups.map(([e, c]) => `ambiguous-group: events=${e} calls=${c}`)));
647
699
  // --- durations: the number NEITHER file holds. `ms` is source A, `outcome` is B.
648
700
  const buckets = new Map();
649
701
  for (const [event, call] of joined.matched) {
@@ -659,8 +711,8 @@ export function render(host, events, joined) {
659
711
  }
660
712
  }
661
713
  out.push('', '[durations]', 'matched pairs only; ms from the host, outcome from bantamkit');
662
- out.push(...linesOrNone(byKey([...buckets.values()], (b) => [b.tool, b.outcome]).map(({ tool, outcome, values }) => `${tool} ${outcome} n=${values.length} min=${Math.min(...values)}` +
663
- ` p50=${p50(values)} max=${Math.max(...values)}`)));
714
+ extend(out, linesOrNone(byKey([...buckets.values()], (b) => [b.tool, b.outcome]).map(({ tool, outcome, values }) => `${tool} ${outcome} n=${values.length} min=${minOf(values)}` +
715
+ ` p50=${p50(values)} max=${maxOf(values)}`)));
664
716
  // --- sessions: the only place a sessionId exists, plus what B could attach to it.
665
717
  const perSession = new Map();
666
718
  for (const call of host.calls) {
@@ -675,19 +727,19 @@ export function render(host, events, joined) {
675
727
  attributed.set(call.session, (attributed.get(call.session) ?? 0) + 1);
676
728
  }
677
729
  const orderedSessions = byKey([...perSession.entries()], ([name, calls]) => [
678
- Math.min(...calls.map(anchorMs)),
730
+ minOf(calls.map(anchorMs)),
679
731
  name,
680
732
  ]);
681
733
  out.push('', '[sessions]');
682
- out.push(...linesOrNone(orderedSessions.map(([name, calls]) => {
734
+ extend(out, linesOrNone(orderedSessions.map(([name, calls]) => {
683
735
  const anchors = calls.map(anchorMs);
684
736
  const tools = [...new Set(calls.map((c) => c.tool))].sort(cmpCodepoint);
685
737
  return (`${name} calls=${calls.length}` +
686
738
  ` ok=${calls.filter((c) => c.ok === true).length}` +
687
739
  ` fail=${calls.filter((c) => c.ok === false).length}` +
688
740
  ` incomplete=${calls.filter((c) => c.ok === null).length}` +
689
- ` first=${formatTimestamp(Math.min(...anchors))}` +
690
- ` last=${formatTimestamp(Math.max(...anchors))}` +
741
+ ` first=${formatTimestamp(minOf(anchors))}` +
742
+ ` last=${formatTimestamp(maxOf(anchors))}` +
691
743
  ` attributed=${attributed.get(name) ?? 0}` +
692
744
  ` tools=${tools.join(',')}`);
693
745
  })));
@@ -703,7 +755,7 @@ export function render(host, events, joined) {
703
755
  ` session=${call.session} later-calls=${later.length}` +
704
756
  ` later-same-tool=${later.filter((c) => c.tool === event.tool).length}`);
705
757
  }
706
- out.push(...linesOrNone(follow));
758
+ extend(out, linesOrNone(follow));
707
759
  // --- the host's errors, counted and never quoted.
708
760
  out.push('', '[host-errors]', `records=${host.errors} with-tool=${host.errorsWithTool}` +
709
761
  ` without-tool=${host.errors - host.errorsWithTool}`, 'text withheld by design: the host log has been measured to persist argument' +
@@ -89,6 +89,18 @@ function positive(text) {
89
89
  throw new ArgumentTypeError('must be >= 1');
90
90
  return value;
91
91
  }
92
+ /**
93
+ * `_nonnegative`: `--reserve 0` is a real request — compact to the budget itself — and the
94
+ * tool's schema says `minimum: 0`, so the CLI accepts it too (#134). WAS `positive`, which
95
+ * refused 0 with `must be >= 1` on both CLIs while `memory_compact` took it. `typeName` is
96
+ * `_nonnegative` for the reason `positive` gives: argparse prints the callable's `__name__`.
97
+ */
98
+ function nonnegative(text) {
99
+ const value = pyIntStrict(text);
100
+ if (value < 0)
101
+ throw new ArgumentTypeError('must be >= 0');
102
+ return value;
103
+ }
92
104
  /**
93
105
  * `common(sub)`: the store selector and the budget, on every subcommand.
94
106
  *
@@ -176,9 +188,12 @@ const PARSER = {
176
188
  dest: 'reserve',
177
189
  metavar: 'BYTES',
178
190
  kind: 'store',
179
- help: 'headroom to leave below the budget (default: the largest index line kept)',
180
- convert: positive,
181
- typeName: '_positive',
191
+ // The default is `MemoryStore.compact`'s (job46): the sentence quotes it rather
192
+ // than the pre-job46 "largest index line kept" it used to state (#134).
193
+ help: 'headroom to leave below the budget (default: the budget minus its 90 percent ' +
194
+ 'warning line, plus the largest index line kept; capped at half the budget)',
195
+ convert: nonnegative,
196
+ typeName: '_nonnegative',
182
197
  defaultValue: null,
183
198
  },
184
199
  ])),
@@ -104,6 +104,21 @@ export interface RecallOutcome {
104
104
  * that says so, `null` for every other query — the walk that has always run, unchanged.
105
105
  */
106
106
  readonly lookup: string | null;
107
+ /**
108
+ * Every fact file a layer this walk READ could not read as a fact (job65/#119), as
109
+ * `{layer, file, reason}`; the reply names them in a closing paragraph (`skippedNote`).
110
+ */
111
+ readonly skipped: ReadonlyArray<{
112
+ layer: string;
113
+ file: string;
114
+ reason: string;
115
+ }>;
116
+ /**
117
+ * The header line of each fact in the reply, in reply order, BUILT FROM THE PARSED FIELDS
118
+ * (job65/#155). The hook reads these instead of filtering the reply's lines, so a body line
119
+ * shaped like a header can never pass for one.
120
+ */
121
+ readonly headers: readonly string[];
107
122
  }
108
123
  /**
109
124
  * What `dream` DID, beside the sentence it says about it.
@@ -384,5 +399,7 @@ export declare class Memory {
384
399
  * literal, so the message and the resolver cannot drift onto two different variables.
385
400
  */
386
401
  private bindingDiagnosis;
402
+ /** `[layer] [name] (type) description`, from the parsed fields (job65/#155). */
403
+ private header;
387
404
  private format;
388
405
  }