bantamkit-mcp 0.35.7 → 0.35.9

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 (53) hide show
  1. package/README.md +24 -9
  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/assets.js +28 -4
  9. package/dist/cli.js +83 -24
  10. package/dist/contract.d.ts +0 -9
  11. package/dist/contract.js +51 -12
  12. package/dist/eventlog.d.ts +22 -2
  13. package/dist/eventlog.js +30 -3
  14. package/dist/hookadapter.js +263 -43
  15. package/dist/hostinstall.d.ts +2 -2
  16. package/dist/hostinstall.js +318 -84
  17. package/dist/index.d.ts +2 -2
  18. package/dist/index.js +1 -1
  19. package/dist/mcp/identity.js +27 -1
  20. package/dist/mcp/pyargs.d.ts +22 -6
  21. package/dist/mcp/pyargs.js +113 -39
  22. package/dist/mcp/server.js +177 -41
  23. package/dist/mcp/status.js +11 -4
  24. package/dist/mcp/transport.d.ts +51 -6
  25. package/dist/mcp/transport.js +217 -26
  26. package/dist/mcpreport.d.ts +12 -0
  27. package/dist/mcpreport.js +67 -15
  28. package/dist/memory/cli.js +18 -3
  29. package/dist/memory/component.d.ts +17 -0
  30. package/dist/memory/component.js +51 -7
  31. package/dist/memory/factfile.d.ts +21 -3
  32. package/dist/memory/factfile.js +351 -12
  33. package/dist/memory/layers.d.ts +19 -0
  34. package/dist/memory/layers.js +102 -2
  35. package/dist/memory/pyyaml.d.ts +6 -4
  36. package/dist/memory/pyyaml.js +6 -4
  37. package/dist/memory/store.d.ts +82 -7
  38. package/dist/memory/store.js +277 -29
  39. package/dist/npminstall.d.ts +18 -0
  40. package/dist/npminstall.js +26 -5
  41. package/dist/pyargparse.js +48 -2
  42. package/dist/pyjsonschema.d.ts +85 -4
  43. package/dist/pyjsonschema.js +1812 -181
  44. package/dist/selfupdate.d.ts +112 -32
  45. package/dist/selfupdate.js +221 -79
  46. package/dist/shiftwork.d.ts +46 -7
  47. package/dist/shiftwork.js +246 -42
  48. package/dist/tokenledger.js +20 -4
  49. package/dist/updatecheck.d.ts +15 -3
  50. package/dist/updatecheck.js +23 -8
  51. package/dist/workplan.d.ts +8 -3
  52. package/dist/workplan.js +62 -3
  53. package/package.json +2 -2
@@ -10,13 +10,13 @@
10
10
  * The SDK exposes no hook for this — `ReadBuffer.readMessage` parses inside the buffer and
11
11
  * the buffer is private — so the buffering is done here instead. It is thirty lines
12
12
  * (`indexOf('\n')`, strip a trailing `\r`) and it keeps the LINE next to the parsed
13
- * message, keyed by JSON-RPC id. A handler then re-reads its own arguments with
13
+ * message, keyed PER MESSAGE (see 3). A handler then re-reads its own arguments with
14
14
  * `pyjson.parseJson`, which tags `5.0` as a float and keeps an integer past 2^53 exact.
15
15
  *
16
16
  * 2. THE OUTGOING NUMBER LITERALS AND THE ENVELOPE ORDER. `serializeMessage` is
17
17
  * `JSON.stringify(message) + '\n'`, which un-does the work above on the way out: the same
18
18
  * `5.0` in a `structuredContent` leaves as `5`. So a tool handler registers the EXACT text
19
- * of its result here, keyed by request id, and `send` splices it in. Everything else still
19
+ * of its result here, keyed per message, and `send` splices it in. Everything else still
20
20
  * goes through `JSON.stringify`, which is byte-for-byte what pydantic-core writes for
21
21
  * strings, bools, nulls and safe integers.
22
22
  *
@@ -25,6 +25,30 @@
25
25
  * order is not observable to a conforming client, but it costs nothing here and it removes
26
26
  * a difference from the list rather than adding a ruling to it.
27
27
  *
28
+ * 3. ONE ENTRY PER MESSAGE, NOT PER ID (#146, #148). The side table used to be keyed by the
29
+ * client's id, so two requests in flight under ONE id (JSON-RPC forbids it; a client can
30
+ * still do it) shared one slot: the second line overwrote the first and both answers were
31
+ * computed from the second request's arguments. And the SDK's schema refuses an integer id
32
+ * past 2^53 (`JSON.parse` has already rounded it), so such a request got no answer at all.
33
+ * Both are fixed the same way: every request is handed to the SDK under a TOKEN of this
34
+ * file's own (`bantamkit-<n>`, unique per message), and the client's id is kept as the
35
+ * exact TEXT it arrived as, read losslessly with `pyjson.parseJson`; `send` writes that
36
+ * text back. The reference answers each request from its own arguments and echoes a big
37
+ * id exactly, so this is the port catching up, not a choice. A `notifications/cancelled`
38
+ * naming a client id is pointed at the newest token carrying it.
39
+ *
40
+ * 4. A FRAME THE SDK WOULD DROP IS ANSWERED (#148). A line that is not JSON is answered
41
+ * `-32700 Parse error`, and JSON that is not a JSON-RPC message (a batch array, a bare
42
+ * number, a message with no `jsonrpc`) `-32600 Invalid Request`, both with `id: null`
43
+ * as JSON-RPC 2.0 section 5.1 requires, and both in the bytes `mcpstdio.py` writes. Before,
44
+ * both went to `onerror` and the client waited forever. A blank line is still skipped.
45
+ *
46
+ * 5. A HOST THAT CLOSED ITS READ END ENDS THE SESSION QUIETLY (#181). `process.stdout` had no
47
+ * `error` listener, so an EPIPE mid-answer was an unhandled `error` event: exit 1 and a
48
+ * stack. It is now what stdin EOF already was, the host going away: the transport closes,
49
+ * stdin is released, and the process exits 0 with nothing on stderr. Any OTHER write error
50
+ * is rethrown and still crashes, because it is not that.
51
+ *
28
52
  * NOTHING IN THIS FILE WRITES TO STDOUT EXCEPT `send`. That is the invariant the whole
29
53
  * package is under, and it is checked by `test/server.test.mjs`, which asserts that every
30
54
  * line a real session produced parses as a frame and that stdout never ends mid-line.
@@ -33,6 +57,12 @@ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
33
57
  import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
34
58
  type Readable = NodeJS.ReadableStream;
35
59
  type Writable = NodeJS.WritableStream;
60
+ /**
61
+ * The two frames JSON-RPC 2.0 section 5.1 prescribes for a message no id can be read from,
62
+ * byte for byte what `mcpstdio.py` writes (`JSONRPCError(id=None, ...)` dumped by the SDK).
63
+ */
64
+ export declare const PARSE_ERROR_FRAME = "{\"jsonrpc\":\"2.0\",\"id\":null,\"error\":{\"code\":-32700,\"message\":\"Parse error\"}}";
65
+ export declare const INVALID_REQUEST_FRAME = "{\"jsonrpc\":\"2.0\",\"id\":null,\"error\":{\"code\":-32600,\"message\":\"Invalid Request\"}}";
36
66
  export declare class RawStdioTransport implements Transport {
37
67
  private readonly input;
38
68
  private readonly output;
@@ -42,18 +72,31 @@ export declare class RawStdioTransport implements Transport {
42
72
  sessionId?: string;
43
73
  private buffer;
44
74
  private started;
45
- private readonly rawLines;
46
- private readonly exactResults;
75
+ private outputGone;
76
+ private sequence;
77
+ /** Token (the id the SDK sees) -> the request it stands for. One entry per MESSAGE. */
78
+ private readonly inFlight;
79
+ /** Client id text -> the newest token carrying it, for `notifications/cancelled`. */
80
+ private readonly newest;
47
81
  private readonly ondata;
48
82
  private readonly onstreamerror;
83
+ private readonly onoutputerror;
49
84
  constructor(input?: Readable, output?: Writable);
50
- /** The verbatim request line that carried this id, or `undefined` if it is gone. */
85
+ /** The verbatim request line this message token stands for, or `undefined` if it is gone. */
51
86
  rawLineFor(id: unknown): string | undefined;
52
- /** Register the exact JSON text to send as this request's `result`. */
87
+ /**
88
+ * #147: whether the reference would refuse this request's params on its `_meta` alone, a
89
+ * member the TS SDK checks in its ENVELOPE schema and the reference only per method.
90
+ */
91
+ paramsRefused(id: unknown): boolean;
92
+ /** Register the exact JSON text to send as this message's `result`. */
53
93
  setExactResult(id: unknown, json: string): void;
54
94
  start(): Promise<void>;
55
95
  close(): Promise<void>;
56
96
  send(message: JSONRPCMessage): Promise<void>;
97
+ private write;
98
+ /** Answer a line no id could be read from, in order with every other frame. */
99
+ private refuse;
57
100
  /**
58
101
  * One frame, in the reference's key order, with an exact `result` when one was registered.
59
102
  *
@@ -64,5 +107,7 @@ export declare class RawStdioTransport implements Transport {
64
107
  */
65
108
  private frame;
66
109
  private consume;
110
+ /** Point a `notifications/cancelled` at the token now standing for the id it names. */
111
+ private retarget;
67
112
  }
68
113
  export {};
@@ -1,7 +1,37 @@
1
1
  import { JSONRPCMessageSchema } from '@modelcontextprotocol/sdk/types.js';
2
2
  import { PY_LINESEP } from '../memory/pyfs.js';
3
- /** A JSON-RPC id is a string or a number; the map key is its JSON text, which is total. */
4
- const idKey = (id) => JSON.stringify(id ?? null);
3
+ import { parseJson } from '../pyjson.js';
4
+ /**
5
+ * The two frames JSON-RPC 2.0 section 5.1 prescribes for a message no id can be read from,
6
+ * byte for byte what `mcpstdio.py` writes (`JSONRPCError(id=None, ...)` dumped by the SDK).
7
+ */
8
+ export const PARSE_ERROR_FRAME = '{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error"}}';
9
+ export const INVALID_REQUEST_FRAME = '{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Invalid Request"}}';
10
+ /** The write errors that mean the reader of stdout is gone, on POSIX and on Windows (libuv EOF). */
11
+ const HOST_GONE = new Set(['EPIPE', 'ECONNRESET', 'EOF', 'ERR_STREAM_DESTROYED']);
12
+ /**
13
+ * The client's id as the exact JSON text to echo, or `undefined` when it is not one a
14
+ * JSON-RPC id may be. A string is re-serialised (`JSON.stringify` is pydantic-core's string
15
+ * spelling); a number is read again LOSSLESSLY, because `JSON.parse` has already rounded an
16
+ * integer past 2^53 and cannot tell `1.0` from `1`. The reference's `RequestId` is a STRICT
17
+ * int or a str, so a float literal is no id at all there either.
18
+ */
19
+ function clientIdText(line, id) {
20
+ if (typeof id === 'string')
21
+ return JSON.stringify(id);
22
+ if (typeof id !== 'number')
23
+ return undefined;
24
+ try {
25
+ const frame = parseJson(line);
26
+ const value = frame.t === 'dict' ? frame.v.get('id') : undefined;
27
+ return value?.t === 'int' ? value.v.toString() : undefined;
28
+ }
29
+ catch {
30
+ return Number.isSafeInteger(id) ? String(id) : undefined;
31
+ }
32
+ }
33
+ /** The JSON text of an id named INSIDE a message (a cancellation's `requestId`). */
34
+ const namedIdText = (id) => typeof id === 'string' ? JSON.stringify(id) : Number.isSafeInteger(id) ? String(id) : undefined;
5
35
  export class RawStdioTransport {
6
36
  input;
7
37
  output;
@@ -11,21 +41,45 @@ export class RawStdioTransport {
11
41
  sessionId;
12
42
  buffer = Buffer.alloc(0);
13
43
  started = false;
14
- rawLines = new Map();
15
- exactResults = new Map();
44
+ outputGone = false;
45
+ sequence = 0;
46
+ /** Token (the id the SDK sees) -> the request it stands for. One entry per MESSAGE. */
47
+ inFlight = new Map();
48
+ /** Client id text -> the newest token carrying it, for `notifications/cancelled`. */
49
+ newest = new Map();
16
50
  ondata = (chunk) => this.consume(chunk);
17
51
  onstreamerror = (error) => this.onerror?.(error);
52
+ onoutputerror = (error) => {
53
+ if (!HOST_GONE.has(error.code ?? ''))
54
+ throw error;
55
+ if (this.outputGone)
56
+ return;
57
+ this.outputGone = true;
58
+ // The host is gone, so nothing will ever read another answer: stop reading too, or a
59
+ // stdin the host left open would keep the process alive with nowhere to write.
60
+ this.input.destroy?.();
61
+ void this.close();
62
+ };
18
63
  constructor(input = process.stdin, output = process.stdout) {
19
64
  this.input = input;
20
65
  this.output = output;
21
66
  }
22
- /** The verbatim request line that carried this id, or `undefined` if it is gone. */
67
+ /** The verbatim request line this message token stands for, or `undefined` if it is gone. */
23
68
  rawLineFor(id) {
24
- return this.rawLines.get(idKey(id));
69
+ return typeof id === 'string' ? this.inFlight.get(id)?.line : undefined;
70
+ }
71
+ /**
72
+ * #147: whether the reference would refuse this request's params on its `_meta` alone, a
73
+ * member the TS SDK checks in its ENVELOPE schema and the reference only per method.
74
+ */
75
+ paramsRefused(id) {
76
+ return typeof id === 'string' && this.inFlight.get(id)?.paramsRefused === true;
25
77
  }
26
- /** Register the exact JSON text to send as this request's `result`. */
78
+ /** Register the exact JSON text to send as this message's `result`. */
27
79
  setExactResult(id, json) {
28
- this.exactResults.set(idKey(id), json);
80
+ const entry = typeof id === 'string' ? this.inFlight.get(id) : undefined;
81
+ if (entry !== undefined)
82
+ entry.exact = json;
29
83
  }
30
84
  async start() {
31
85
  if (this.started)
@@ -33,6 +87,7 @@ export class RawStdioTransport {
33
87
  this.started = true;
34
88
  this.input.on('data', this.ondata);
35
89
  this.input.on('error', this.onstreamerror);
90
+ this.output.on('error', this.onoutputerror);
36
91
  }
37
92
  async close() {
38
93
  this.input.off('data', this.ondata);
@@ -40,13 +95,19 @@ export class RawStdioTransport {
40
95
  if (this.input.listenerCount('data') === 0)
41
96
  this.input.pause();
42
97
  this.buffer = Buffer.alloc(0);
43
- this.rawLines.clear();
44
- this.exactResults.clear();
98
+ this.inFlight.clear();
99
+ this.newest.clear();
45
100
  this.onclose?.();
46
101
  }
47
102
  send(message) {
103
+ return this.write(this.frame(message));
104
+ }
105
+ write(text) {
48
106
  return new Promise((resolve) => {
49
- const text = this.frame(message);
107
+ if (this.outputGone) {
108
+ resolve();
109
+ return;
110
+ }
50
111
  // `os.linesep`, because the reference's stdout is a TEXT stream. The Python SDK writes
51
112
  // its frames through `TextIOWrapper(sys.stdout.buffer)` with the default
52
113
  // `newline=None`, so on Windows every frame CPython emits ends `\r\n`. MEASURED, run
@@ -59,6 +120,10 @@ export class RawStdioTransport {
59
120
  this.output.once('drain', () => resolve());
60
121
  });
61
122
  }
123
+ /** Answer a line no id could be read from, in order with every other frame. */
124
+ refuse(frame) {
125
+ void this.write(frame);
126
+ }
62
127
  /**
63
128
  * One frame, in the reference's key order, with an exact `result` when one was registered.
64
129
  *
@@ -69,17 +134,18 @@ export class RawStdioTransport {
69
134
  */
70
135
  frame(message) {
71
136
  const framed = message;
72
- if (framed.id === undefined)
137
+ if (framed.id === undefined || 'method' in framed)
73
138
  return JSON.stringify(message);
74
- const key = idKey(framed.id);
75
- this.rawLines.delete(key);
76
- const id = JSON.stringify(framed.id);
139
+ const entry = typeof framed.id === 'string' ? this.inFlight.get(framed.id) : undefined;
140
+ if (entry !== undefined) {
141
+ this.inFlight.delete(framed.id);
142
+ if (this.newest.get(entry.idText) === framed.id)
143
+ this.newest.delete(entry.idText);
144
+ }
145
+ const id = entry?.idText ?? JSON.stringify(framed.id);
77
146
  if ('result' in framed) {
78
- const exact = this.exactResults.get(key);
79
- this.exactResults.delete(key);
80
- return `{"jsonrpc":"2.0","id":${id},"result":${exact ?? JSON.stringify(framed.result)}}`;
147
+ return `{"jsonrpc":"2.0","id":${id},"result":${entry?.exact ?? JSON.stringify(framed.result)}}`;
81
148
  }
82
- this.exactResults.delete(key);
83
149
  if ('error' in framed)
84
150
  return `{"jsonrpc":"2.0","id":${id},"error":${JSON.stringify(framed.error)}}`;
85
151
  return JSON.stringify(message);
@@ -94,18 +160,143 @@ export class RawStdioTransport {
94
160
  this.buffer = this.buffer.subarray(at + 1);
95
161
  if (line.trim() === '')
96
162
  continue;
97
- let message;
163
+ let parsed;
98
164
  try {
99
- message = JSONRPCMessageSchema.parse(JSON.parse(line));
165
+ parsed = JSON.parse(line);
166
+ }
167
+ catch {
168
+ this.refuse(PARSE_ERROR_FRAME);
169
+ continue;
100
170
  }
101
- catch (error) {
102
- this.onerror?.(error);
171
+ // A LONE SURROGATE is text `JSON.parse` keeps and pydantic's parser refuses, so the
172
+ // reference's SDK cannot read such a frame at all; `mcpstdio.py` answers it `-32600`
173
+ // (the standard library CAN read it). The one exception is job65/#160's: a `tools/call`
174
+ // carrying one in its arguments is refused BY NAME, which the server does here too.
175
+ if (LONE_ESCAPE.test(line) && holdsLoneSurrogate(parsed) && !refusedByName(line, parsed)) {
176
+ this.refuse(INVALID_REQUEST_FRAME);
103
177
  continue;
104
178
  }
105
- const id = message.id;
106
- if (id !== undefined)
107
- this.rawLines.set(idKey(id), line);
179
+ // The reference's adapter is pydantic, which IGNORES a member it has no field for and
180
+ // takes `params: null` as no params; the TS SDK's schemas are strict and refuse both.
181
+ // Measured on both SDKs (J66-3 note): normalised here, so a frame the reference answers
182
+ // is not one the port calls an Invalid Request.
183
+ if (isObject(parsed)) {
184
+ for (const key of Object.keys(parsed))
185
+ if (!MESSAGE_KEYS.has(key))
186
+ delete parsed[key];
187
+ if (parsed['params'] === null)
188
+ delete parsed['params'];
189
+ }
190
+ // A REQUEST (an object with an id and a method) travels under a token of this file's
191
+ // own; its client id is kept as text. Anything else goes to the SDK's schema unchanged.
192
+ let token;
193
+ let idText;
194
+ if (isObject(parsed) && 'id' in parsed && 'method' in parsed) {
195
+ idText = clientIdText(line, parsed['id']);
196
+ if (idText === undefined) {
197
+ // Not a str and not an int (`null`, `1.0`, `true`): the reference's `RequestId`
198
+ // refuses it, and its union then reads the frame as a NOTIFICATION with the id
199
+ // ignored, so nothing is answered. Measured; the port does the same.
200
+ delete parsed['id'];
201
+ }
202
+ else {
203
+ token = `bantamkit-${++this.sequence}`;
204
+ parsed['id'] = token;
205
+ }
206
+ }
207
+ let message;
208
+ let paramsRefused = false;
209
+ try {
210
+ message = JSONRPCMessageSchema.parse(parsed);
211
+ }
212
+ catch {
213
+ // #147: the TS SDK's envelope checks a request's `params._meta`; the reference's
214
+ // envelope takes any object as params and checks `_meta` with the METHOD's model,
215
+ // so `_meta: 5` is -32602 with the client's id there (and `_meta: null` or a
216
+ // `progressToken: true` is served), where this used to be -32600 with id null. A
217
+ // request the envelope takes once `_meta` is set aside travels on without it, and
218
+ // `server.ts`'s guard refuses it -32602 if the reference's model would have.
219
+ const params = token !== undefined && isObject(parsed) ? parsed['params'] : undefined;
220
+ const withoutMeta = isObject(params) && '_meta' in params ? withoutKey(parsed, params) : undefined;
221
+ const retry = withoutMeta === undefined ? undefined : JSONRPCMessageSchema.safeParse(withoutMeta);
222
+ if (retry === undefined || !retry.success) {
223
+ this.refuse(INVALID_REQUEST_FRAME);
224
+ continue;
225
+ }
226
+ message = retry.data;
227
+ paramsRefused = !referenceTakesMeta(params['_meta']);
228
+ }
229
+ if (token !== undefined && idText !== undefined) {
230
+ this.inFlight.set(token, paramsRefused ? { idText, line, paramsRefused } : { idText, line });
231
+ this.newest.set(idText, token);
232
+ }
233
+ else {
234
+ this.retarget(message);
235
+ }
108
236
  this.onmessage?.(message);
109
237
  }
110
238
  }
239
+ /** Point a `notifications/cancelled` at the token now standing for the id it names. */
240
+ retarget(message) {
241
+ const note = message;
242
+ if (note.method !== 'notifications/cancelled' || note.params === undefined)
243
+ return;
244
+ const named = namedIdText(note.params.requestId);
245
+ const token = named === undefined ? undefined : this.newest.get(named);
246
+ if (token !== undefined)
247
+ note.params.requestId = token;
248
+ }
249
+ }
250
+ /** Only a line spelling a surrogate escape can hold a lone surrogate (`mcpstdio._ESCAPE`). */
251
+ const LONE_ESCAPE = /\\u[dD][89a-fA-F][0-9a-fA-F]{2}/;
252
+ const LONE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
253
+ /** Whether any string in the value, member names included, holds a lone surrogate. */
254
+ function holdsLoneSurrogate(value) {
255
+ if (typeof value === 'string')
256
+ return LONE.test(value);
257
+ if (Array.isArray(value))
258
+ return value.some(holdsLoneSurrogate);
259
+ if (isObject(value))
260
+ return Object.entries(value).some(([key, item]) => LONE.test(key) || holdsLoneSurrogate(item));
261
+ return false;
262
+ }
263
+ /**
264
+ * `mcpstdio.refusal_for`'s test, less the roster: a `tools/call` with a str or int id whose
265
+ * `arguments` object holds a lone surrogate goes on to the server, which refuses it by name
266
+ * (`pyargs.loneSurrogateArgument`). The reference also requires the tool to be one it
267
+ * serves; for an unserved name it answers `-32600` where the port answers `Unknown tool:`.
268
+ */
269
+ function refusedByName(line, parsed) {
270
+ if (!isObject(parsed) || parsed['method'] !== 'tools/call' || !('id' in parsed))
271
+ return false;
272
+ if (clientIdText(line, parsed['id']) === undefined)
273
+ return false;
274
+ const params = parsed['params'];
275
+ return isObject(params) && typeof params['name'] === 'string' && isObject(params['arguments']) && holdsLoneSurrogate(params['arguments']);
276
+ }
277
+ /** The frame again with `params._meta` left out. */
278
+ function withoutKey(frame, params) {
279
+ const { _meta: _dropped, ...rest } = params;
280
+ return { ...frame, params: rest };
281
+ }
282
+ /**
283
+ * Whether `mcp_types.RequestParams.meta` (`RequestParamsMeta | None`, lax) takes this
284
+ * `_meta`: absent or `null`, or an open object whose `progressToken`, if any, is a str or
285
+ * what lax `int` takes — an integral number or a bool (`NotRequired`, so not `null`).
286
+ * Measured on the reference: `5`, `[]`, `{"progressToken": 1.5}`, `{"progressToken": null}`
287
+ * and `{"progressToken": {}}` are -32602; `null`, `{"progressToken": true}` and
288
+ * `{"progressToken": 2.0}` are served.
289
+ */
290
+ function referenceTakesMeta(meta) {
291
+ if (meta === undefined || meta === null)
292
+ return true;
293
+ if (!isObject(meta))
294
+ return false;
295
+ const token = meta['progressToken'];
296
+ return token === undefined || typeof token === 'string' || typeof token === 'boolean' || Number.isInteger(token);
297
+ }
298
+ /** The members a JSON-RPC 2.0 message may carry; any other is dropped as pydantic drops it. */
299
+ const MESSAGE_KEYS = new Set(['jsonrpc', 'id', 'method', 'params', 'result', 'error']);
300
+ function isObject(value) {
301
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
111
302
  }
@@ -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
  }