bantamkit-mcp 0.35.7 → 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.
- package/README.md +11 -7
- package/assets/tools/memory_compact.json +1 -2
- package/assets/tools/memory_recall.json +1 -3
- package/assets/tools/shiftwork_clock_in.json +1 -1
- package/assets/tools/shiftwork_clock_out.json +1 -1
- package/assets/tools/validate_json.json +1 -1
- package/assets/tools/work_plan.json +14 -8
- package/dist/cli.js +28 -0
- package/dist/contract.d.ts +0 -9
- package/dist/contract.js +51 -12
- package/dist/eventlog.d.ts +14 -1
- package/dist/eventlog.js +23 -3
- package/dist/hookadapter.js +247 -41
- package/dist/hostinstall.js +250 -79
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/mcp/pyargs.d.ts +22 -6
- package/dist/mcp/pyargs.js +113 -39
- package/dist/mcp/server.js +18 -27
- package/dist/mcp/status.js +11 -4
- package/dist/mcpreport.d.ts +12 -0
- package/dist/mcpreport.js +67 -15
- package/dist/memory/cli.js +18 -3
- package/dist/memory/component.d.ts +17 -0
- package/dist/memory/component.js +51 -7
- package/dist/memory/factfile.d.ts +21 -3
- package/dist/memory/factfile.js +351 -12
- package/dist/memory/layers.d.ts +19 -0
- package/dist/memory/layers.js +102 -2
- package/dist/memory/pyyaml.d.ts +6 -4
- package/dist/memory/pyyaml.js +6 -4
- package/dist/memory/store.d.ts +82 -7
- package/dist/memory/store.js +277 -29
- package/dist/npminstall.d.ts +18 -0
- package/dist/npminstall.js +26 -5
- package/dist/pyjsonschema.d.ts +85 -4
- package/dist/pyjsonschema.js +1812 -181
- package/dist/selfupdate.d.ts +112 -32
- package/dist/selfupdate.js +221 -79
- package/dist/shiftwork.d.ts +46 -7
- package/dist/shiftwork.js +246 -42
- package/dist/workplan.d.ts +8 -3
- package/dist/workplan.js +62 -3
- package/package.json +1 -1
package/dist/mcp/pyargs.js
CHANGED
|
@@ -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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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.
|
|
111
|
-
//
|
|
112
|
-
//
|
|
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,
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
// `
|
|
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 =
|
|
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 =
|
|
386
|
-
|
|
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)}]
|
|
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
|
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -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`
|
|
181
|
-
*
|
|
182
|
-
*
|
|
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
|
|
185
|
-
* reference, whose `str()` is the key in single quotes, so the SDK's envelope reads
|
|
186
|
-
* `Error executing tool work_plan: 'id'
|
|
187
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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. */
|
package/dist/mcp/status.js
CHANGED
|
@@ -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,
|
|
257
|
-
'
|
|
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
|
package/dist/mcpreport.d.ts
CHANGED
|
@@ -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 =
|
|
512
|
-
const hi =
|
|
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
|
|
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
|
|
663
|
-
` p50=${p50(values)} max=${
|
|
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
|
-
|
|
730
|
+
minOf(calls.map(anchorMs)),
|
|
679
731
|
name,
|
|
680
732
|
]);
|
|
681
733
|
out.push('', '[sessions]');
|
|
682
|
-
out
|
|
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(
|
|
690
|
-
` last=${formatTimestamp(
|
|
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
|
|
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' +
|
package/dist/memory/cli.js
CHANGED
|
@@ -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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
}
|